Initial commit — Zabbix Dashboard app
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
417
public/app.js
Normal file
417
public/app.js
Normal file
@@ -0,0 +1,417 @@
|
||||
/* ── 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';
|
||||
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;
|
||||
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);
|
||||
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());
|
||||
} 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 = {}) {
|
||||
// Group by country
|
||||
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');
|
||||
wrap.innerHTML = '';
|
||||
|
||||
if (countries.length === 0) {
|
||||
wrap.innerHTML = '<div class="page-center"><p>No items found.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
countries.forEach(country => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'country-col';
|
||||
|
||||
const colItems = byCountry[country];
|
||||
const problems = colItems.filter(i => i.severity >= 2).length;
|
||||
|
||||
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);
|
||||
wrap.appendChild(col);
|
||||
|
||||
// Compute perRow after layout is in DOM
|
||||
requestAnimationFrame(() => {
|
||||
const hexW = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-w'));
|
||||
const hexGap = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--hex-gap'));
|
||||
const colW = body.clientWidth - 40; // subtract padding
|
||||
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 =
|
||||
'<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(`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 =
|
||||
'<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, '&').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;
|
||||
}
|
||||
76
public/index.html
Normal file
76
public/index.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KFC IT Monitor by Stranto</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-brand">
|
||||
<div class="brand-dot"></div>
|
||||
<span class="brand-name">KFC IT Monitor by Stranto</span>
|
||||
</div>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button class="toggle-btn active" data-view="restaurants">By Restaurant</button>
|
||||
<button class="toggle-btn" data-view="devices">By Device Type</button>
|
||||
</div>
|
||||
|
||||
<div class="header-meta">
|
||||
<span id="last-updated" class="last-updated">—</span>
|
||||
<button id="refresh-btn" class="refresh-btn" title="Refresh">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="subbar">
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="swatch ok"></span>OK</span>
|
||||
<span class="legend-item"><span class="swatch warning"></span>Warning</span>
|
||||
<span class="legend-item"><span class="swatch average"></span>Average</span>
|
||||
<span class="legend-item"><span class="swatch high"></span>High</span>
|
||||
<span class="legend-item"><span class="swatch disaster"></span>Disaster</span>
|
||||
</div>
|
||||
<div class="summary" id="summary"></div>
|
||||
</div>
|
||||
|
||||
<div id="countries-wrap" class="countries-wrap">
|
||||
<div class="page-center">
|
||||
<div class="spinner"></div>
|
||||
<p>Connecting to Zabbix…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-box" class="error-box" style="display:none;"></div>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<div id="modal" class="modal" style="display:none;" role="dialog" aria-modal="true">
|
||||
<div class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-wrap">
|
||||
<span id="modal-sev-dot" class="modal-sev-dot"></span>
|
||||
<h2 id="modal-title"></h2>
|
||||
</div>
|
||||
<button class="modal-close" id="modal-close-btn" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="modal-body" class="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
624
public/style.css
Normal file
624
public/style.css
Normal file
@@ -0,0 +1,624 @@
|
||||
/* ── Variables ─────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #f6f8fa;
|
||||
--surface-2: #eef1f4;
|
||||
--surface-3: #e1e4e8;
|
||||
--border: #d0d7de;
|
||||
--text: #1f2328;
|
||||
--text-muted: #636c76;
|
||||
--accent: #0969da;
|
||||
--accent-dim: rgba(9,105,218,0.08);
|
||||
|
||||
--c-ok: #2da44e;
|
||||
--c-warning: #c69026;
|
||||
--c-average: #e16f24;
|
||||
--c-high: #d1242f;
|
||||
--c-disaster: #8250df;
|
||||
--c-unknown: #8c959f;
|
||||
|
||||
--hex-w: 132px; /* = hex-h × sin(60°) = 152 × 0.866 — required for regular hexagon */
|
||||
--hex-h: 152px;
|
||||
--hex-gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────────────────────── */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 54px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-ok);
|
||||
box-shadow: 0 0 0 3px rgba(45,164,78,0.2);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* View toggle */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 5px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toggle-btn:not(.active):hover { color: var(--text); }
|
||||
|
||||
/* Header meta */
|
||||
.header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover { color: var(--text); background: var(--surface-2); }
|
||||
.refresh-btn.spinning svg { animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Subbar ────────────────────────────────────────────────────────────────── */
|
||||
.subbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 42px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.swatch.ok { background: var(--c-ok); }
|
||||
.swatch.warning { background: var(--c-warning); }
|
||||
.swatch.average { background: var(--c-average); }
|
||||
.swatch.high { background: var(--c-high); }
|
||||
.swatch.disaster { background: var(--c-disaster); }
|
||||
|
||||
.summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary strong { color: var(--text); }
|
||||
|
||||
/* ── Countries split layout ────────────────────────────────────────────────── */
|
||||
.countries-wrap {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 96px);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.country-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.country-col:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.country-col-header {
|
||||
padding: 14px 20px 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.country-col-count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.country-col-stats {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.country-col-body {
|
||||
padding: 20px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Loading / Error ───────────────────────────────────────────────────────── */
|
||||
.page-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 80px 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--surface-3);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: #fff0ee;
|
||||
border: 1px solid #ffa198;
|
||||
color: #b91c1c;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
font-size: 0.85rem;
|
||||
max-width: 600px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
/* ── Hex Grid ──────────────────────────────────────────────────────────────── */
|
||||
.hex-col-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hex-row {
|
||||
display: flex;
|
||||
gap: var(--hex-gap);
|
||||
/* Vertical overlap for equal gaps on all 6 sides:
|
||||
center-to-center-y = (hex-w + gap) × sin(60°)
|
||||
margin-top = -(hex-h - center-to-center-y) = -(hex-h/4 - gap × 0.866) */
|
||||
margin-top: calc(var(--hex-h) * -0.25 + var(--hex-gap) * 0.866);
|
||||
}
|
||||
|
||||
.hex-row:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hex-row.offset {
|
||||
margin-left: calc(var(--hex-w) / 2 + var(--hex-gap) / 2);
|
||||
}
|
||||
|
||||
/* ── Hex Item ──────────────────────────────────────────────────────────────── */
|
||||
.hex-item {
|
||||
width: var(--hex-w);
|
||||
height: var(--hex-h);
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, transform 0.15s;
|
||||
position: relative;
|
||||
padding: 18px 16px;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hex-item:hover {
|
||||
filter: brightness(1.1) saturate(1.15);
|
||||
transform: scale(1.07);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hex-item:active { transform: scale(0.97); }
|
||||
|
||||
.hex-label {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
max-width: 108px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.hex-hosts {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.82);
|
||||
margin-top: 2px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.hex-count {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
color: rgba(255,255,255,0.95);
|
||||
margin-top: 3px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ── Severity colours ──────────────────────────────────────────────────────── */
|
||||
.sev--1 { background: var(--c-ok); }
|
||||
.sev-2 { background: var(--c-warning); }
|
||||
.sev-3 { background: var(--c-average); }
|
||||
.sev-4 { background: var(--c-high); }
|
||||
.sev-5 {
|
||||
background: var(--c-disaster);
|
||||
animation: disaster-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes disaster-pulse {
|
||||
0%,100% { filter: brightness(1); }
|
||||
50% { filter: brightness(1.3) saturate(1.2); }
|
||||
}
|
||||
|
||||
/* ── Problem list ──────────────────────────────────────────────────────────── */
|
||||
.problems-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.problems-section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.problem-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.problem-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.problem-group-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.problem-group-count {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-muted);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.problem-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1.2fr) minmax(0, 2.5fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 3px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.problem-list-row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.pl-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pl-host {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pl-desc {
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pl-time {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Modal ─────────────────────────────────────────────────────────────────── */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.18);
|
||||
animation: modal-in 0.16s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(6px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.modal-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-sev-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.modal-close:hover { background: var(--surface-3); color: var(--text); }
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.host-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.host-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.host-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sev-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.problem-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.problem-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.problem-pip {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.problem-text { font-size: 0.78rem; line-height: 1.4; }
|
||||
.problem-time { font-size: 0.68rem; color: var(--text-muted); margin-top: 1px; }
|
||||
|
||||
.no-problems {
|
||||
font-size: 0.78rem;
|
||||
color: var(--c-ok);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 700px) {
|
||||
:root {
|
||||
--hex-w: 95px; /* 110 × 0.866 */
|
||||
--hex-h: 110px;
|
||||
--hex-gap: 4px;
|
||||
}
|
||||
|
||||
.countries-wrap { flex-direction: column; }
|
||||
.country-col { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.hex-label { font-size: 0.82rem; max-width: 68px; }
|
||||
}
|
||||
Reference in New Issue
Block a user