/* ── 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;
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;
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(() => ({})),
]);
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 = '
';
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.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 =
'';
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 `
${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(`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 =
'';
}
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;
}