418 lines
15 KiB
JavaScript
418 lines
15 KiB
JavaScript
/* ── Severity config ─────────────────────────────────────────────────────── */
|
|
const SEV = {
|
|
'-1': { label: 'OK', color: '#2da44e' },
|
|
'2': { label: 'Warning', color: '#c69026' },
|
|
'3': { label: 'Average', color: '#e16f24' },
|
|
'4': { label: 'High', color: '#d1242f' },
|
|
'5': { label: 'Disaster', color: '#8250df' },
|
|
};
|
|
|
|
const COUNTRY_NAMES = {
|
|
AT: 'Austria', AUT: 'Austria', AUSTRIA: 'Austria',
|
|
SK: 'Slovakia', SVK: 'Slovakia', SLOVAKIA: 'Slovakia',
|
|
};
|
|
|
|
const COUNTRY_FLAGS = {
|
|
AT: 'at', AUT: 'at', AUSTRIA: 'at',
|
|
SK: 'sk', SVK: 'sk', SLOVAKIA: 'sk',
|
|
};
|
|
|
|
function countryLabel(code) {
|
|
return COUNTRY_NAMES[code?.toUpperCase()] || code || 'Unknown';
|
|
}
|
|
|
|
function countryFlagUrl(code) {
|
|
const fc = COUNTRY_FLAGS[code?.toUpperCase()];
|
|
return fc ? `https://flagcdn.com/32x24/${fc}.png` : null;
|
|
}
|
|
|
|
function sevInfo(sev) {
|
|
return SEV[String(sev)] || SEV['-1'];
|
|
}
|
|
|
|
/* ── State ───────────────────────────────────────────────────────────────── */
|
|
let currentView = 'restaurants';
|
|
let refreshTimer = null;
|
|
let zabbixUrl = '';
|
|
let customerTagValue = 'QWE';
|
|
const REFRESH_MS = 30_000;
|
|
|
|
/* ── Boot ────────────────────────────────────────────────────────────────── */
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
try {
|
|
const cfg = await apiFetch('/api/config');
|
|
zabbixUrl = cfg.zabbixUrl.replace(/\/$/, '');
|
|
customerTagValue = cfg.customerTagValue || 'QWE';
|
|
} catch (_) {}
|
|
initControls();
|
|
loadData();
|
|
scheduleRefresh();
|
|
});
|
|
|
|
/* ── Controls ────────────────────────────────────────────────────────────── */
|
|
function initControls() {
|
|
document.querySelectorAll('.toggle-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
currentView = btn.dataset.view;
|
|
loadData();
|
|
});
|
|
});
|
|
|
|
document.getElementById('refresh-btn').addEventListener('click', () => {
|
|
loadData();
|
|
scheduleRefresh();
|
|
});
|
|
|
|
document.getElementById('modal-close-btn').addEventListener('click', closeModal);
|
|
document.getElementById('modal').addEventListener('click', e => {
|
|
if (e.target === e.currentTarget) closeModal();
|
|
});
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeModal();
|
|
});
|
|
}
|
|
|
|
/* ── Data loading ────────────────────────────────────────────────────────── */
|
|
async function loadData() {
|
|
setRefreshSpinner(true);
|
|
showPageLoading();
|
|
|
|
try {
|
|
const endpoint = currentView === 'restaurants' ? '/api/restaurants' : '/api/devices';
|
|
const [items, stats] = await Promise.all([
|
|
apiFetch(endpoint),
|
|
apiFetch('/api/stats').catch(() => ({})),
|
|
]);
|
|
renderCountryColumns(items, stats);
|
|
updateSummary(items);
|
|
setLastUpdated(new Date());
|
|
} catch (e) {
|
|
showPageError(e.message);
|
|
} finally {
|
|
setRefreshSpinner(false);
|
|
}
|
|
}
|
|
|
|
function scheduleRefresh() {
|
|
clearTimeout(refreshTimer);
|
|
refreshTimer = setTimeout(() => { loadData(); scheduleRefresh(); }, REFRESH_MS);
|
|
}
|
|
|
|
/* ── Country column layout ───────────────────────────────────────────────── */
|
|
function renderCountryColumns(items, stats = {}) {
|
|
// Group by country
|
|
const byCountry = {};
|
|
for (const item of items) {
|
|
const key = item.country || '';
|
|
if (!byCountry[key]) byCountry[key] = [];
|
|
byCountry[key].push(item);
|
|
}
|
|
|
|
const countries = Object.keys(byCountry).sort();
|
|
const wrap = document.getElementById('countries-wrap');
|
|
wrap.innerHTML = '';
|
|
|
|
if (countries.length === 0) {
|
|
wrap.innerHTML = '<div class="page-center"><p>No items found.</p></div>';
|
|
return;
|
|
}
|
|
|
|
countries.forEach(country => {
|
|
const col = document.createElement('div');
|
|
col.className = 'country-col';
|
|
|
|
const colItems = byCountry[country];
|
|
const problems = colItems.filter(i => i.severity >= 2).length;
|
|
|
|
const flagUrl = countryFlagUrl(country);
|
|
const flagImg = flagUrl
|
|
? `<img src="${flagUrl}" alt="" style="height:22px;width:auto;border-radius:2px;flex-shrink:0;">`
|
|
: '';
|
|
|
|
const s = stats[country];
|
|
const statsHtml = s
|
|
? `${s.hostCount.toLocaleString()} hosts · ${s.itemCount.toLocaleString()} items · ${s.triggerCount.toLocaleString()} triggers`
|
|
: '';
|
|
|
|
const hdr = document.createElement('div');
|
|
hdr.className = 'country-col-header';
|
|
hdr.innerHTML = `
|
|
<span style="display:flex;align-items:center;gap:10px;">${flagImg}${esc(countryLabel(country))}</span>
|
|
<span class="country-col-stats">${statsHtml}</span>
|
|
`;
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'country-col-body';
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'hex-grid';
|
|
body.appendChild(grid);
|
|
col.appendChild(hdr);
|
|
col.appendChild(body);
|
|
wrap.appendChild(col);
|
|
|
|
// Compute perRow after layout is in DOM
|
|
requestAnimationFrame(() => {
|
|
const hexW = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-w'));
|
|
const hexGap = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-gap'));
|
|
const colW = body.clientWidth - 40; // subtract padding
|
|
const perRow = Math.max(2, Math.floor(colW / (hexW + hexGap)));
|
|
renderHexGrid(grid, colItems, perRow);
|
|
renderProblemList(body, colItems);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderHexGrid(container, items, perRow) {
|
|
const rows = chunk(items, perRow);
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'hex-col-wrap';
|
|
|
|
rows.forEach((row, ri) => {
|
|
const rowEl = document.createElement('div');
|
|
rowEl.className = 'hex-row' + (ri % 2 === 1 ? ' offset' : '');
|
|
row.forEach(item => rowEl.appendChild(buildHex(item)));
|
|
wrap.appendChild(rowEl);
|
|
});
|
|
|
|
container.innerHTML = '';
|
|
container.appendChild(wrap);
|
|
}
|
|
|
|
/* ── Hex builder ─────────────────────────────────────────────────────────── */
|
|
function buildHex(item) {
|
|
const sev = item.severity;
|
|
const info = sevInfo(sev);
|
|
const label = item.location || item.name || '—';
|
|
const id = item.location || item.groupid || '';
|
|
const type = item.location ? 'restaurant' : 'device';
|
|
const issues = item.problemCount || 0;
|
|
const hosts = item.hostCount || 0;
|
|
|
|
const el = document.createElement('div');
|
|
el.className = `hex-item sev-${sev}`;
|
|
el.style.background = info.color;
|
|
el.title = `${label}\n${hosts} host(s) · ${issues} issue(s) · ${info.label}\n\nClick: details Double-click: open in Zabbix`;
|
|
|
|
const labelEl = document.createElement('span');
|
|
labelEl.className = 'hex-label';
|
|
labelEl.textContent = label;
|
|
el.appendChild(labelEl);
|
|
|
|
const hostsEl = document.createElement('span');
|
|
hostsEl.className = 'hex-hosts';
|
|
hostsEl.textContent = `${hosts} host${hosts !== 1 ? 's' : ''}`;
|
|
el.appendChild(hostsEl);
|
|
|
|
if (issues > 0) {
|
|
const countEl = document.createElement('span');
|
|
countEl.className = 'hex-count';
|
|
countEl.textContent = `${issues} issue${issues !== 1 ? 's' : ''}`;
|
|
el.appendChild(countEl);
|
|
}
|
|
|
|
// Use timer to distinguish single click (modal) from double click (Zabbix)
|
|
let clickTimer = null;
|
|
el.addEventListener('click', () => {
|
|
clearTimeout(clickTimer);
|
|
clickTimer = setTimeout(() => openModal(type, id, label, String(sev)), 240);
|
|
});
|
|
el.addEventListener('dblclick', () => {
|
|
clearTimeout(clickTimer);
|
|
window.open(buildZabbixProblemsUrl(type, id), '_blank');
|
|
});
|
|
return el;
|
|
}
|
|
|
|
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
|
async function openModal(type, id, label, sev) {
|
|
const modal = document.getElementById('modal');
|
|
const info = sevInfo(sev);
|
|
|
|
document.getElementById('modal-title').textContent = label;
|
|
document.getElementById('modal-sev-dot').style.background = info.color;
|
|
document.getElementById('modal-body').innerHTML =
|
|
'<div class="modal-loading"><div class="spinner"></div></div>';
|
|
modal.style.display = 'flex';
|
|
|
|
try {
|
|
const items = await apiFetch(
|
|
`/api/detail?type=${encodeURIComponent(type)}&id=${encodeURIComponent(id)}`
|
|
);
|
|
renderModalBody(document.getElementById('modal-body'), items);
|
|
} catch (e) {
|
|
document.getElementById('modal-body').innerHTML =
|
|
`<div class="error-box">Failed to load details: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderModalBody(body, items) {
|
|
if (!items || items.length === 0) {
|
|
body.innerHTML = '<p style="color:var(--text-muted);font-size:0.85rem;">No hosts found.</p>';
|
|
return;
|
|
}
|
|
|
|
body.innerHTML = items.map(host => {
|
|
const info = sevInfo(String(host.severity));
|
|
|
|
const problemsHtml = host.problems.length === 0
|
|
? `<p class="no-problems">✓ No active problems</p>`
|
|
: `<div class="problem-list">${host.problems.map(p => {
|
|
const pi = sevInfo(String(p.priority));
|
|
return `
|
|
<div class="problem-row">
|
|
<span class="problem-pip" style="background:${pi.color}"></span>
|
|
<div>
|
|
<div class="problem-text">${esc(p.description)}</div>
|
|
<div class="problem-time">${esc(p.lastchange)}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('')}</div>`;
|
|
|
|
return `
|
|
<div class="host-card">
|
|
<div class="host-card-header">
|
|
<span class="host-name" title="${esc(host.name)}">${esc(host.name)}</span>
|
|
<span class="sev-badge" style="background:${info.color}">${esc(info.label)}</span>
|
|
</div>
|
|
${problemsHtml}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
/* ── Problem list (below hex grid) ──────────────────────────────────────── */
|
|
function renderProblemList(container, items) {
|
|
const withProblems = items.filter(i => i.problems && i.problems.length > 0);
|
|
if (withProblems.length === 0) return;
|
|
|
|
const section = document.createElement('div');
|
|
section.className = 'problems-section';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'problems-section-title';
|
|
title.textContent = 'Active Problems';
|
|
section.appendChild(title);
|
|
|
|
for (const item of withProblems) {
|
|
const label = item.location || item.name || '—';
|
|
|
|
const group = document.createElement('div');
|
|
group.className = 'problem-group';
|
|
|
|
const groupHdr = document.createElement('div');
|
|
groupHdr.className = 'problem-group-header';
|
|
groupHdr.innerHTML =
|
|
`<span class="problem-group-label">${esc(label)}</span>` +
|
|
`<span class="problem-group-count">${item.problems.length}</span>`;
|
|
group.appendChild(groupHdr);
|
|
|
|
for (const p of item.problems) {
|
|
const info = sevInfo(String(p.priority));
|
|
const row = document.createElement('div');
|
|
row.className = 'problem-list-row';
|
|
row.innerHTML =
|
|
`<span class="pl-dot" style="background:${info.color}"></span>` +
|
|
`<span class="pl-host">${esc(p.hostName)}</span>` +
|
|
`<span class="pl-desc">${esc(p.description)}</span>` +
|
|
`<span class="pl-time">${timeAgo(p.lastchange)}</span>`;
|
|
group.appendChild(row);
|
|
}
|
|
|
|
section.appendChild(group);
|
|
}
|
|
|
|
container.appendChild(section);
|
|
}
|
|
|
|
function timeAgo(unixSeconds) {
|
|
const diff = Math.floor(Date.now() / 1000) - unixSeconds;
|
|
if (diff < 60) return `${diff}s ago`;
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
return `${Math.floor(diff / 86400)}d ago`;
|
|
}
|
|
|
|
function buildZabbixProblemsUrl(type, id) {
|
|
// Zabbix 7.x URL parameter format (no filter_ prefix)
|
|
const p = [
|
|
'action=problem.view',
|
|
'show=1', // Recent problems
|
|
'evaltype=0', // AND tag matching
|
|
// Exclude Not classified (0) and Information (1) — show Warning and above
|
|
'severities[]=2',
|
|
'severities[]=3',
|
|
'severities[]=4',
|
|
'severities[]=5',
|
|
];
|
|
|
|
if (type === 'restaurant') {
|
|
p.push(`tags[0][tag]=customer`);
|
|
p.push(`tags[0][value]=${encodeURIComponent(customerTagValue)}`);
|
|
p.push(`tags[0][operator]=1`);
|
|
p.push(`tags[1][tag]=location`);
|
|
p.push(`tags[1][value]=${encodeURIComponent(id)}`);
|
|
p.push(`tags[1][operator]=1`);
|
|
} else {
|
|
p.push(`groupids[]=${encodeURIComponent(id)}`);
|
|
p.push(`tags[0][tag]=customer`);
|
|
p.push(`tags[0][value]=${encodeURIComponent(customerTagValue)}`);
|
|
p.push(`tags[0][operator]=1`);
|
|
}
|
|
|
|
return `${zabbixUrl}/zabbix.php?${p.join('&')}`;
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modal').style.display = 'none';
|
|
}
|
|
|
|
/* ── UI helpers ──────────────────────────────────────────────────────────── */
|
|
function showPageLoading() {
|
|
document.getElementById('countries-wrap').innerHTML =
|
|
'<div class="page-center"><div class="spinner"></div><p>Loading…</p></div>';
|
|
}
|
|
|
|
function showPageError(msg) {
|
|
document.getElementById('countries-wrap').innerHTML =
|
|
`<div class="error-box">Error: ${esc(msg)}</div>`;
|
|
}
|
|
|
|
function setRefreshSpinner(on) {
|
|
document.getElementById('refresh-btn').classList.toggle('spinning', on);
|
|
}
|
|
|
|
function updateSummary(items) {
|
|
const issues = items.filter(i => i.severity >= 2).length;
|
|
document.getElementById('summary').innerHTML = issues > 0
|
|
? `<strong>${items.length}</strong> items · <strong style="color:#d1242f">${issues} with issues</strong>`
|
|
: `<strong>${items.length}</strong> items · <strong style="color:#2da44e">All OK</strong>`;
|
|
}
|
|
|
|
function setLastUpdated(date) {
|
|
document.getElementById('last-updated').textContent =
|
|
`Updated ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`;
|
|
}
|
|
|
|
/* ── Fetch wrapper ───────────────────────────────────────────────────────── */
|
|
async function apiFetch(url) {
|
|
const res = await fetch(url);
|
|
const data = await res.json();
|
|
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
|
return data;
|
|
}
|
|
|
|
/* ── Utils ───────────────────────────────────────────────────────────────── */
|
|
function esc(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function chunk(arr, size) {
|
|
const out = [];
|
|
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
return out;
|
|
}
|