/* ── 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'; let hasRendered = false; let lastDataKey = null; 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; hasRendered = false; lastDataKey = null; 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); if (!hasRendered) showPageLoading(); try { const endpoint = currentView === 'restaurants' ? '/api/restaurants' : '/api/devices'; const [items, stats] = await Promise.all([ apiFetch(endpoint), apiFetch('/api/stats').catch(() => ({})), ]); const key = JSON.stringify({ items, stats }); if (key !== lastDataKey) { lastDataKey = key; renderCountryColumns(items, stats); updateSummary(items); } setLastUpdated(new Date()); hasRendered = true; } 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 = {}) { 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'); if (countries.length === 0) { wrap.innerHTML = '

No items found.

'; return; } const frag = document.createDocumentFragment(); const refs = []; countries.forEach(country => { const col = document.createElement('div'); col.className = 'country-col'; const colItems = byCountry[country]; const flagUrl = countryFlagUrl(country); const flagImg = flagUrl ? `` : ''; 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 = ` ${flagImg}${esc(countryLabel(country))} ${statsHtml} `; 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); frag.appendChild(col); refs.push({ grid, body, colItems }); }); // Single atomic swap — old content replaced in one operation wrap.replaceChildren(frag); // Measure layout and render hex items after browser has laid out the new columns requestAnimationFrame(() => { const hexW = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-w')); const hexGap = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-gap')); refs.forEach(({ grid, body, colItems }) => { const colW = body.clientWidth - 40; 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.deviceType || ''; 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}${type === 'device' ? ' hex-item--device' : ''}`; 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 = ''; 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 = `
Failed to load details: ${esc(e.message)}
`; } } function renderModalBody(body, items) { if (!items || items.length === 0) { body.innerHTML = '

No hosts found.

'; return; } body.innerHTML = items.map(host => { const info = sevInfo(String(host.severity)); const problemsHtml = host.problems.length === 0 ? `

✓ No active problems

` : `
${host.problems.map(p => { const pi = sevInfo(String(p.priority)); return `
${esc(p.description)}
${esc(p.lastchange)}
`; }).join('')}
`; return `
${esc(host.name)} ${esc(info.label)}
${problemsHtml}
`; }).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 = `${esc(label)}` + `${item.problems.length}`; 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 = `` + `${esc(p.hostName)}` + `${esc(p.description)}` + `${timeAgo(p.lastchange)}`; 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(`tags[0][tag]=customer`); p.push(`tags[0][value]=${encodeURIComponent(customerTagValue)}`); p.push(`tags[0][operator]=1`); p.push(`tags[1][tag]=deviceType`); p.push(`tags[1][value]=${encodeURIComponent(id)}`); p.push(`tags[1][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 = '

Loading…

'; } function showPageError(msg) { document.getElementById('countries-wrap').innerHTML = `
Error: ${esc(msg)}
`; } 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 ? `${items.length} items · ${issues} with issues` : `${items.length} items · All OK`; } 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, '"'); } function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; }