Compare commits

...

2 Commits

Author SHA1 Message Date
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
55b9935f5b Fix 30s auto-refresh flicker in frontend
Skip the full-page loading state on background refreshes (only show it
on first load or view switch). Replace per-column requestAnimationFrame
appends with a single DocumentFragment swap so the browser never paints
a blank container between clear and insert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:06:13 +02:00
3 changed files with 53 additions and 33 deletions

View File

@@ -35,6 +35,8 @@ let currentView = 'restaurants';
let refreshTimer = null; let refreshTimer = null;
let zabbixUrl = ''; let zabbixUrl = '';
let customerTagValue = 'QWE'; let customerTagValue = 'QWE';
let hasRendered = false;
let lastDataKey = null;
const REFRESH_MS = 30_000; const REFRESH_MS = 30_000;
/* ── Boot ────────────────────────────────────────────────────────────────── */ /* ── Boot ────────────────────────────────────────────────────────────────── */
@@ -56,6 +58,8 @@ function initControls() {
document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
currentView = btn.dataset.view; currentView = btn.dataset.view;
hasRendered = false;
lastDataKey = null;
loadData(); loadData();
}); });
}); });
@@ -77,7 +81,7 @@ function initControls() {
/* ── Data loading ────────────────────────────────────────────────────────── */ /* ── Data loading ────────────────────────────────────────────────────────── */
async function loadData() { async function loadData() {
setRefreshSpinner(true); setRefreshSpinner(true);
showPageLoading(); if (!hasRendered) showPageLoading();
try { try {
const endpoint = currentView === 'restaurants' ? '/api/restaurants' : '/api/devices'; const endpoint = currentView === 'restaurants' ? '/api/restaurants' : '/api/devices';
@@ -85,9 +89,14 @@ async function loadData() {
apiFetch(endpoint), apiFetch(endpoint),
apiFetch('/api/stats').catch(() => ({})), apiFetch('/api/stats').catch(() => ({})),
]); ]);
renderCountryColumns(items, stats); const key = JSON.stringify({ items, stats });
updateSummary(items); if (key !== lastDataKey) {
lastDataKey = key;
renderCountryColumns(items, stats);
updateSummary(items);
}
setLastUpdated(new Date()); setLastUpdated(new Date());
hasRendered = true;
} catch (e) { } catch (e) {
showPageError(e.message); showPageError(e.message);
} finally { } finally {
@@ -102,7 +111,6 @@ function scheduleRefresh() {
/* ── Country column layout ───────────────────────────────────────────────── */ /* ── Country column layout ───────────────────────────────────────────────── */
function renderCountryColumns(items, stats = {}) { function renderCountryColumns(items, stats = {}) {
// Group by country
const byCountry = {}; const byCountry = {};
for (const item of items) { for (const item of items) {
const key = item.country || ''; const key = item.country || '';
@@ -112,20 +120,20 @@ function renderCountryColumns(items, stats = {}) {
const countries = Object.keys(byCountry).sort(); const countries = Object.keys(byCountry).sort();
const wrap = document.getElementById('countries-wrap'); const wrap = document.getElementById('countries-wrap');
wrap.innerHTML = '';
if (countries.length === 0) { if (countries.length === 0) {
wrap.innerHTML = '<div class="page-center"><p>No items found.</p></div>'; wrap.innerHTML = '<div class="page-center"><p>No items found.</p></div>';
return; return;
} }
const frag = document.createDocumentFragment();
const refs = [];
countries.forEach(country => { countries.forEach(country => {
const col = document.createElement('div'); const col = document.createElement('div');
col.className = 'country-col'; col.className = 'country-col';
const colItems = byCountry[country]; const colItems = byCountry[country];
const problems = colItems.filter(i => i.severity >= 2).length;
const flagUrl = countryFlagUrl(country); const flagUrl = countryFlagUrl(country);
const flagImg = flagUrl const flagImg = flagUrl
? `<img src="${flagUrl}" alt="" style="height:22px;width:auto;border-radius:2px;flex-shrink:0;">` ? `<img src="${flagUrl}" alt="" style="height:22px;width:auto;border-radius:2px;flex-shrink:0;">`
@@ -151,13 +159,20 @@ function renderCountryColumns(items, stats = {}) {
body.appendChild(grid); body.appendChild(grid);
col.appendChild(hdr); col.appendChild(hdr);
col.appendChild(body); col.appendChild(body);
wrap.appendChild(col); frag.appendChild(col);
// Compute perRow after layout is in DOM refs.push({ grid, body, colItems });
requestAnimationFrame(() => { });
const hexW = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-w'));
const hexGap = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-gap')); // Single atomic swap — old content replaced in one operation
const colW = body.clientWidth - 40; // subtract padding 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))); const perRow = Math.max(2, Math.floor(colW / (hexW + hexGap)));
renderHexGrid(grid, colItems, perRow); renderHexGrid(grid, colItems, perRow);
renderProblemList(body, colItems); renderProblemList(body, colItems);
@@ -186,13 +201,13 @@ function buildHex(item) {
const sev = item.severity; const sev = item.severity;
const info = sevInfo(sev); const info = sevInfo(sev);
const label = item.location || item.name || '—'; const label = item.location || item.name || '—';
const id = item.location || item.groupid || ''; const id = item.location || item.deviceType || '';
const type = item.location ? 'restaurant' : 'device'; const type = item.location ? 'restaurant' : 'device';
const issues = item.problemCount || 0; const issues = item.problemCount || 0;
const hosts = item.hostCount || 0; const hosts = item.hostCount || 0;
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `hex-item sev-${sev}`; el.className = `hex-item sev-${sev}${type === 'device' ? ' hex-item--device' : ''}`;
el.style.background = info.color; 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`; el.title = `${label}\n${hosts} host(s) · ${issues} issue(s) · ${info.label}\n\nClick: details Double-click: open in Zabbix`;
@@ -355,10 +370,12 @@ function buildZabbixProblemsUrl(type, id) {
p.push(`tags[1][value]=${encodeURIComponent(id)}`); p.push(`tags[1][value]=${encodeURIComponent(id)}`);
p.push(`tags[1][operator]=1`); p.push(`tags[1][operator]=1`);
} else { } else {
p.push(`groupids[]=${encodeURIComponent(id)}`);
p.push(`tags[0][tag]=customer`); p.push(`tags[0][tag]=customer`);
p.push(`tags[0][value]=${encodeURIComponent(customerTagValue)}`); p.push(`tags[0][value]=${encodeURIComponent(customerTagValue)}`);
p.push(`tags[0][operator]=1`); 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('&')}`; return `${zabbixUrl}/zabbix.php?${p.join('&')}`;

View File

@@ -317,6 +317,11 @@ header {
.hex-item:active { transform: scale(0.97); } .hex-item:active { transform: scale(0.97); }
.hex-item--device .hex-label {
font-size: 1.2rem;
font-weight: 700;
}
.hex-label { .hex-label {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 800; font-weight: 800;

View File

@@ -29,7 +29,8 @@ async function zabbix(method, params) {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function getTag(tags, name) { function getTag(tags, name) {
const t = tags.find(t => t.tag === name); const lower = name.toLowerCase();
const t = tags.find(t => t.tag.toLowerCase() === lower);
return t ? t.value : null; return t ? t.value : null;
} }
@@ -48,10 +49,9 @@ function problemCount(triggers) {
async function fetchKFCData() { async function fetchKFCData() {
const hosts = await zabbix('host.get', { const hosts = await zabbix('host.get', {
output: ['hostid', 'host', 'name'], output: ['hostid', 'host', 'name'],
tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }], tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }],
selectTags: 'extend', selectTags: 'extend',
selectHostGroups: ['groupid', 'name'],
}); });
if (hosts.length === 0) return { hosts: [], triggersByHost: {} }; if (hosts.length === 0) return { hosts: [], triggersByHost: {} };
@@ -158,22 +158,20 @@ app.get('/api/restaurants', async (req, res) => {
app.get('/api/devices', async (req, res) => { app.get('/api/devices', async (req, res) => {
try { try {
const country = req.query.country || '';
const { hosts, triggersByHost } = await fetchKFCData(); const { hosts, triggersByHost } = await fetchKFCData();
const map = {}; const map = {};
for (const host of hosts) { for (const host of hosts) {
const hostCountry = getTag(host.tags, COUNTRY_TAG); const hostCountry = getTag(host.tags, COUNTRY_TAG);
if (country && hostCountry && hostCountry.toLowerCase() !== country.toLowerCase()) continue; const deviceType = getTag(host.tags, 'deviceType');
if (!deviceType) continue;
for (const group of host.hostgroups) { const key = `${deviceType}__${hostCountry || ''}`;
const key = `${group.groupid}__${hostCountry || ''}`; if (!map[key]) {
if (!map[key]) { map[key] = { deviceType, name: deviceType, country: hostCountry, hosts: [], triggers: [] };
map[key] = { groupid: group.groupid, name: group.name, country: hostCountry, hosts: [], triggers: [] };
}
map[key].hosts.push(host);
map[key].triggers.push(...(triggersByHost[host.hostid] || []));
} }
map[key].hosts.push(host);
map[key].triggers.push(...(triggersByHost[host.hostid] || []));
} }
const result = Object.values(map) const result = Object.values(map)
@@ -193,7 +191,7 @@ app.get('/api/devices', async (req, res) => {
} }
problems.sort((a, b) => b.priority - a.priority || b.lastchange - a.lastchange); problems.sort((a, b) => b.priority - a.priority || b.lastchange - a.lastchange);
return { return {
groupid: g.groupid, deviceType: g.deviceType,
name: g.name, name: g.name,
country: g.country, country: g.country,
hostCount: g.hosts.length, hostCount: g.hosts.length,
@@ -220,7 +218,7 @@ app.get('/api/detail', async (req, res) => {
const filtered = type === 'restaurant' const filtered = type === 'restaurant'
? hosts.filter(h => getTag(h.tags, 'location') === id) ? hosts.filter(h => getTag(h.tags, 'location') === id)
: hosts.filter(h => h.hostgroups.some(g => g.groupid === id)); : hosts.filter(h => getTag(h.tags, 'deviceType') === id);
const items = filtered.map(host => ({ const items = filtered.map(host => ({
hostid: host.hostid, hostid: host.hostid,