- /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>
300 lines
10 KiB
JavaScript
300 lines
10 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const axios = require('axios');
|
|
const path = require('path');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
const ZABBIX_URL = (process.env.ZABBIX_URL || 'https://monitor.stranto.com').replace(/\/$/, '');
|
|
const ZABBIX_TOKEN = process.env.ZABBIX_TOKEN || '';
|
|
const COUNTRY_TAG = process.env.COUNTRY_TAG || 'country';
|
|
const CUSTOMER_TAG_VALUE = process.env.CUSTOMER_TAG_VALUE || 'QWE';
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// ── Zabbix JSON-RPC helper ────────────────────────────────────────────────────
|
|
|
|
async function zabbix(method, params) {
|
|
const { data } = await axios.post(
|
|
`${ZABBIX_URL}/api_jsonrpc.php`,
|
|
{ jsonrpc: '2.0', method, params, id: 1 },
|
|
{ timeout: 20000, headers: { Authorization: `Bearer ${ZABBIX_TOKEN}` } }
|
|
);
|
|
if (data.error) {
|
|
throw new Error(data.error.data || data.error.message || 'Zabbix API error');
|
|
}
|
|
return data.result;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function getTag(tags, name) {
|
|
const lower = name.toLowerCase();
|
|
const t = tags.find(t => t.tag.toLowerCase() === lower);
|
|
return t ? t.value : null;
|
|
}
|
|
|
|
function worstSeverity(triggers) {
|
|
// Severity 0 (Not classified) and 1 (Information) are treated as OK
|
|
const relevant = (triggers || []).filter(t => parseInt(t.priority, 10) >= 2);
|
|
if (relevant.length === 0) return -1;
|
|
return Math.max(...relevant.map(t => parseInt(t.priority, 10)));
|
|
}
|
|
|
|
function problemCount(triggers) {
|
|
return (triggers || []).filter(t => parseInt(t.priority, 10) >= 2).length;
|
|
}
|
|
|
|
// ── Core data fetch ───────────────────────────────────────────────────────────
|
|
|
|
async function fetchKFCData() {
|
|
const hosts = await zabbix('host.get', {
|
|
output: ['hostid', 'host', 'name'],
|
|
tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }],
|
|
selectTags: 'extend',
|
|
});
|
|
|
|
if (hosts.length === 0) return { hosts: [], triggersByHost: {} };
|
|
|
|
const hostIds = hosts.map(h => h.hostid);
|
|
|
|
const triggers = await zabbix('trigger.get', {
|
|
output: ['triggerid', 'description', 'priority', 'lastchange'],
|
|
hostids: hostIds,
|
|
monitored: true,
|
|
filter: { value: 1 },
|
|
withLastEventUnacknowledged: true,
|
|
expandDescription: true,
|
|
selectHosts: ['hostid', 'name'],
|
|
sortfield: 'priority',
|
|
sortorder: 'DESC',
|
|
});
|
|
|
|
const triggersByHost = {};
|
|
for (const trigger of triggers) {
|
|
for (const host of trigger.hosts) {
|
|
if (!triggersByHost[host.hostid]) triggersByHost[host.hostid] = [];
|
|
triggersByHost[host.hostid].push(trigger);
|
|
}
|
|
}
|
|
|
|
return { hosts, triggersByHost };
|
|
}
|
|
|
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
app.get('/api/countries', async (req, res) => {
|
|
try {
|
|
const hosts = await zabbix('host.get', {
|
|
output: ['hostid'],
|
|
tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }],
|
|
selectTags: 'extend',
|
|
});
|
|
const countries = [...new Set(
|
|
hosts.map(h => getTag(h.tags, COUNTRY_TAG)).filter(Boolean)
|
|
)].sort();
|
|
res.json(countries);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/restaurants', async (req, res) => {
|
|
try {
|
|
const country = req.query.country || '';
|
|
const { hosts, triggersByHost } = await fetchKFCData();
|
|
|
|
const map = {};
|
|
for (const host of hosts) {
|
|
const location = getTag(host.tags, 'location');
|
|
const hostCountry = getTag(host.tags, COUNTRY_TAG);
|
|
if (!location) continue;
|
|
if (country && hostCountry && hostCountry.toLowerCase() !== country.toLowerCase()) continue;
|
|
|
|
if (!map[location]) {
|
|
map[location] = { location, country: hostCountry, hosts: [], triggers: [] };
|
|
}
|
|
map[location].hosts.push(host);
|
|
map[location].triggers.push(...(triggersByHost[host.hostid] || []));
|
|
}
|
|
|
|
const result = Object.values(map)
|
|
.map(loc => {
|
|
const problems = [];
|
|
for (const host of loc.hosts) {
|
|
for (const t of (triggersByHost[host.hostid] || [])) {
|
|
if (parseInt(t.priority, 10) >= 2) {
|
|
problems.push({
|
|
description: t.description,
|
|
priority: parseInt(t.priority, 10),
|
|
lastchange: parseInt(t.lastchange, 10),
|
|
hostName: host.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
problems.sort((a, b) => b.priority - a.priority || b.lastchange - a.lastchange);
|
|
return {
|
|
location: loc.location,
|
|
country: loc.country,
|
|
hostCount: loc.hosts.length,
|
|
problemCount: problems.length,
|
|
severity: worstSeverity(loc.triggers),
|
|
problems,
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
if ((a.country || '') < (b.country || '')) return -1;
|
|
if ((a.country || '') > (b.country || '')) return 1;
|
|
return a.location.localeCompare(b.location);
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/devices', async (req, res) => {
|
|
try {
|
|
const { hosts, triggersByHost } = await fetchKFCData();
|
|
|
|
const map = {};
|
|
for (const host of hosts) {
|
|
const hostCountry = getTag(host.tags, COUNTRY_TAG);
|
|
const deviceType = getTag(host.tags, 'deviceType');
|
|
if (!deviceType) continue;
|
|
|
|
const key = `${deviceType}__${hostCountry || ''}`;
|
|
if (!map[key]) {
|
|
map[key] = { deviceType, name: deviceType, country: hostCountry, hosts: [], triggers: [] };
|
|
}
|
|
map[key].hosts.push(host);
|
|
map[key].triggers.push(...(triggersByHost[host.hostid] || []));
|
|
}
|
|
|
|
const result = Object.values(map)
|
|
.map(g => {
|
|
const problems = [];
|
|
for (const host of g.hosts) {
|
|
for (const t of (triggersByHost[host.hostid] || [])) {
|
|
if (parseInt(t.priority, 10) >= 2) {
|
|
problems.push({
|
|
description: t.description,
|
|
priority: parseInt(t.priority, 10),
|
|
lastchange: parseInt(t.lastchange, 10),
|
|
hostName: host.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
problems.sort((a, b) => b.priority - a.priority || b.lastchange - a.lastchange);
|
|
return {
|
|
deviceType: g.deviceType,
|
|
name: g.name,
|
|
country: g.country,
|
|
hostCount: g.hosts.length,
|
|
problemCount: problems.length,
|
|
severity: worstSeverity(g.triggers),
|
|
problems,
|
|
};
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
res.json(result);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/detail', async (req, res) => {
|
|
try {
|
|
const { type, id } = req.query;
|
|
if (!type || !id) return res.status(400).json({ error: 'type and id required' });
|
|
|
|
const { hosts, triggersByHost } = await fetchKFCData();
|
|
|
|
const filtered = type === 'restaurant'
|
|
? hosts.filter(h => getTag(h.tags, 'location') === id)
|
|
: hosts.filter(h => getTag(h.tags, 'deviceType') === id);
|
|
|
|
const items = filtered.map(host => ({
|
|
hostid: host.hostid,
|
|
name: host.name,
|
|
host: host.host,
|
|
tags: host.tags,
|
|
severity: worstSeverity(triggersByHost[host.hostid] || []),
|
|
problems: (triggersByHost[host.hostid] || [])
|
|
.filter(t => parseInt(t.priority, 10) >= 2)
|
|
.map(t => ({
|
|
description: t.description,
|
|
priority: parseInt(t.priority, 10),
|
|
lastchange: new Date(parseInt(t.lastchange, 10) * 1000).toLocaleString(),
|
|
})),
|
|
}));
|
|
|
|
res.json(items);
|
|
} catch (e) {
|
|
console.error(e);
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/config', (req, res) => {
|
|
res.json({ zabbixUrl: ZABBIX_URL, customerTagValue: CUSTOMER_TAG_VALUE });
|
|
});
|
|
|
|
app.get('/api/stats', async (req, res) => {
|
|
try {
|
|
const hosts = await zabbix('host.get', {
|
|
output: ['hostid'],
|
|
tags: [{ tag: 'customer', value: CUSTOMER_TAG_VALUE, operator: '1' }],
|
|
selectTags: 'extend',
|
|
});
|
|
|
|
// Group host IDs by country
|
|
const byCountry = {};
|
|
for (const host of hosts) {
|
|
const country = getTag(host.tags, COUNTRY_TAG) || '';
|
|
if (!byCountry[country]) byCountry[country] = [];
|
|
byCountry[country].push(host.hostid);
|
|
}
|
|
|
|
// Fetch item + trigger counts per country in parallel
|
|
const stats = {};
|
|
await Promise.all(Object.entries(byCountry).map(async ([country, hostIds]) => {
|
|
const [itemCount, triggerCount] = await Promise.all([
|
|
zabbix('item.get', { countOutput: true, hostids: hostIds, monitored: true }),
|
|
zabbix('trigger.get', { countOutput: true, hostids: hostIds, monitored: true }),
|
|
]);
|
|
stats[country] = {
|
|
hostCount: hostIds.length,
|
|
itemCount: parseInt(itemCount, 10),
|
|
triggerCount: parseInt(triggerCount, 10),
|
|
};
|
|
}));
|
|
|
|
res.json(stats);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/health', async (req, res) => {
|
|
try {
|
|
const version = await zabbix('apiinfo.version', {});
|
|
res.json({ ok: true, zabbixVersion: version });
|
|
} catch (e) {
|
|
res.status(503).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Zabbix KFC Dashboard → http://localhost:${PORT}`);
|
|
if (!ZABBIX_TOKEN) console.warn('Warning: ZABBIX_TOKEN is not set');
|
|
});
|