Compare commits
2 Commits
8b38d6a9c9
...
101aaa6e32
| Author | SHA1 | Date | |
|---|---|---|---|
| 101aaa6e32 | |||
| 55b9935f5b |
@@ -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(() => ({})),
|
||||||
]);
|
]);
|
||||||
|
const key = JSON.stringify({ items, stats });
|
||||||
|
if (key !== lastDataKey) {
|
||||||
|
lastDataKey = key;
|
||||||
renderCountryColumns(items, stats);
|
renderCountryColumns(items, stats);
|
||||||
updateSummary(items);
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(() => {
|
requestAnimationFrame(() => {
|
||||||
const hexW = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-w'));
|
const hexW = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-w'));
|
||||||
const hexGap = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-gap'));
|
const hexGap = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-gap'));
|
||||||
const colW = body.clientWidth - 40; // subtract padding
|
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('&')}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
18
server.js
18
server.js
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +52,6 @@ async function fetchKFCData() {
|
|||||||
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,23 +158,21 @@ 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] = { groupid: group.groupid, name: group.name, country: hostCountry, hosts: [], triggers: [] };
|
map[key] = { deviceType, name: deviceType, country: hostCountry, hosts: [], triggers: [] };
|
||||||
}
|
}
|
||||||
map[key].hosts.push(host);
|
map[key].hosts.push(host);
|
||||||
map[key].triggers.push(...(triggersByHost[host.hostid] || []));
|
map[key].triggers.push(...(triggersByHost[host.hostid] || []));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const result = Object.values(map)
|
const result = Object.values(map)
|
||||||
.map(g => {
|
.map(g => {
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user