Files
Christoph Gasser 101aaa6e32 Rework device type view to group by deviceType tag
- /api/devices now groups hosts by deviceType tag instead of host group;
  hosts without the tag are skipped
- /api/detail device lookup filters by deviceType tag instead of groupid
- getTag() is now case-insensitive on the tag name
- Removed selectHostGroups from fetchKFCData (no longer needed)
- Frontend: hex id and Zabbix deep-link use deviceType instead of groupid
- Smaller hex label (1.2rem) for device type view via hex-item--device class
- Skip DOM re-render on auto-refresh when data is unchanged (lastDataKey diff)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 14:59:17 +02:00

435 lines
16 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';
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 = '<div class="page-center"><p>No items found.</p></div>';
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
? `<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);
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 =
'<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(`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 =
'<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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}