Add visible "Updated Xs ago" timestamps and freshness indicators to make cached stats look more alive and help users know data currency. Backend changes: - luci.metrics: Add _freshness metadata (age, fresh, timestamp_epoch) to overview, waf_stats, and connections responses - luci.crowdsec-dashboard: Add _freshness metadata to get_overview response using sed injection into cached JSON Frontend changes: - metrics/dashboard.js: Display freshness indicator (green/yellow/red) in header, animate value changes with flash effect - crowdsec-dashboard/overview.js: Display freshness indicator next to running badge, update on poll Shared utilities (kiss-theme.js): - formatAge(seconds): Format "Xs ago", "Xm ago", "Xh ago" - getFreshnessClass(age): Return fresh/recent/stale based on age - getFreshnessColor(class): Return #00c853/#ff9800/#f44336 - freshnessIndicator(age, id): Create indicator DOM element - updateFreshness(age, id): Update existing indicator Freshness thresholds: - Fresh (green): < 15s for metrics, < 30s for CrowdSec - Recent (yellow): < 45s for metrics, < 90s for CrowdSec - Stale (red): > threshold Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
482 lines
18 KiB
JavaScript
482 lines
18 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require poll';
|
|
'require rpc';
|
|
'require secubox/kiss-theme';
|
|
|
|
var callOverview = rpc.declare({
|
|
object: 'luci.metrics',
|
|
method: 'overview',
|
|
expect: {}
|
|
});
|
|
|
|
var callWafStats = rpc.declare({
|
|
object: 'luci.metrics',
|
|
method: 'waf_stats',
|
|
expect: {}
|
|
});
|
|
|
|
var callConnections = rpc.declare({
|
|
object: 'luci.metrics',
|
|
method: 'connections',
|
|
expect: {}
|
|
});
|
|
|
|
function formatUptime(seconds) {
|
|
var d = Math.floor(seconds / 86400);
|
|
var h = Math.floor((seconds % 86400) / 3600);
|
|
var m = Math.floor((seconds % 3600) / 60);
|
|
if (d > 0) return d + 'd ' + h + 'h';
|
|
if (h > 0) return h + 'h ' + m + 'm';
|
|
return m + 'm';
|
|
}
|
|
|
|
function formatMem(kb) {
|
|
return (kb / 1048576).toFixed(1) + ' GB';
|
|
}
|
|
|
|
function formatAge(seconds) {
|
|
if (seconds < 60) return seconds + 's ago';
|
|
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
|
|
return Math.floor(seconds / 3600) + 'h ago';
|
|
}
|
|
|
|
function getFreshnessClass(age) {
|
|
if (age < 15) return 'fresh'; // < 15s = green/fresh
|
|
if (age < 45) return 'recent'; // < 45s = yellow/recent
|
|
return 'stale'; // > 45s = red/stale
|
|
}
|
|
|
|
// Track previous values for change detection
|
|
var prevValues = {};
|
|
|
|
return view.extend({
|
|
load: function() {
|
|
// Inject custom CSS
|
|
var style = document.createElement('style');
|
|
style.textContent = `
|
|
.mx-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid var(--kiss-border, #2a2a40);
|
|
}
|
|
.mx-title {
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
}
|
|
.mx-live {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--kiss-muted, #888);
|
|
}
|
|
.mx-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
background: #00c853;
|
|
border-radius: 50%;
|
|
animation: blink 1.5s infinite;
|
|
}
|
|
@keyframes blink {
|
|
50% { opacity: 0.4; }
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.05); }
|
|
}
|
|
@keyframes flash {
|
|
0% { background: rgba(0, 200, 83, 0.3); }
|
|
100% { background: transparent; }
|
|
}
|
|
.mx-changed {
|
|
animation: flash 0.5s ease-out;
|
|
}
|
|
|
|
/* Freshness indicators */
|
|
.mx-freshness {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
background: var(--kiss-bg2, #1a1a2e);
|
|
border: 1px solid var(--kiss-border, #2a2a40);
|
|
}
|
|
.mx-freshness-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
transition: background 0.3s, box-shadow 0.3s;
|
|
}
|
|
.mx-freshness-dot.fresh {
|
|
background: #00c853;
|
|
box-shadow: 0 0 6px #00c853;
|
|
}
|
|
.mx-freshness-dot.recent {
|
|
background: #ff9800;
|
|
box-shadow: 0 0 4px #ff9800;
|
|
}
|
|
.mx-freshness-dot.stale {
|
|
background: #f44336;
|
|
box-shadow: 0 0 4px #f44336;
|
|
}
|
|
.mx-age {
|
|
color: var(--kiss-muted, #888);
|
|
transition: color 0.3s;
|
|
}
|
|
.mx-age.fresh { color: #00c853; }
|
|
.mx-age.recent { color: #ff9800; }
|
|
.mx-age.stale { color: #f44336; }
|
|
|
|
/* Stats Grid */
|
|
.mx-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.mx-card {
|
|
background: var(--kiss-bg2, #1a1a2e);
|
|
border: 1px solid var(--kiss-border, #2a2a40);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
text-align: center;
|
|
}
|
|
.mx-card:hover {
|
|
border-color: var(--kiss-green, #00c853);
|
|
}
|
|
.mx-icon {
|
|
font-size: 24px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.mx-val {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
line-height: 1;
|
|
}
|
|
.mx-val.green { color: #00c853; }
|
|
.mx-val.cyan { color: #00bcd4; }
|
|
.mx-val.orange { color: #ff9800; }
|
|
.mx-val.red { color: #f44336; }
|
|
.mx-val.purple { color: #ab47bc; }
|
|
.mx-lbl {
|
|
font-size: 11px;
|
|
color: var(--kiss-muted, #888);
|
|
text-transform: uppercase;
|
|
margin-top: 4px;
|
|
}
|
|
.mx-sub {
|
|
font-size: 10px;
|
|
color: #555;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* Services bar */
|
|
.mx-svc {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
background: var(--kiss-bg2, #1a1a2e);
|
|
border: 1px solid var(--kiss-border, #2a2a40);
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.mx-svc-title {
|
|
font-size: 11px;
|
|
color: var(--kiss-muted, #888);
|
|
text-transform: uppercase;
|
|
}
|
|
.mx-svc-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
}
|
|
.mx-svc-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
}
|
|
.mx-svc-dot.on {
|
|
background: #00c853;
|
|
box-shadow: 0 0 6px #00c853;
|
|
}
|
|
.mx-svc-dot.off {
|
|
background: #f44336;
|
|
}
|
|
|
|
/* Panels */
|
|
.mx-panels {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.mx-panels { grid-template-columns: 1fr; }
|
|
}
|
|
.mx-panel {
|
|
background: var(--kiss-bg2, #1a1a2e);
|
|
border: 1px solid var(--kiss-border, #2a2a40);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
}
|
|
.mx-panel-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
margin-bottom: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.mx-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--kiss-border, #2a2a40);
|
|
}
|
|
.mx-row:last-child { border-bottom: none; }
|
|
.mx-row-label {
|
|
font-size: 12px;
|
|
color: var(--kiss-muted, #888);
|
|
}
|
|
.mx-row-val {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #00bcd4;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
return Promise.all([
|
|
callOverview().catch(function() { return {}; }),
|
|
callWafStats().catch(function() { return {}; }),
|
|
callConnections().catch(function() { return {}; })
|
|
]);
|
|
},
|
|
|
|
render: function(data) {
|
|
var o = data[0] || {};
|
|
var w = data[1] || {};
|
|
var c = data[2] || {};
|
|
|
|
var memPct = o.mem_pct || 0;
|
|
var memCls = memPct > 85 ? 'red' : (memPct > 70 ? 'orange' : 'green');
|
|
|
|
var content = [
|
|
// Header with freshness indicator
|
|
E('div', { 'class': 'mx-header' }, [
|
|
E('div', { 'class': 'mx-title' }, 'Metrics Dashboard'),
|
|
E('div', { 'style': 'display:flex; align-items:center; gap:16px;' }, [
|
|
E('div', { 'class': 'mx-freshness', 'id': 'mx-freshness' }, [
|
|
E('span', { 'class': 'mx-freshness-dot fresh', 'id': 'mx-fresh-dot' }),
|
|
E('span', { 'class': 'mx-age fresh', 'id': 'mx-age' }, 'just now')
|
|
]),
|
|
E('div', { 'class': 'mx-live' }, [
|
|
E('span', { 'class': 'mx-dot' }),
|
|
E('span', { 'id': 'mx-time' }, new Date().toLocaleTimeString())
|
|
])
|
|
])
|
|
]),
|
|
|
|
// Main stats grid
|
|
E('div', { 'class': 'mx-grid' }, [
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '⏱'),
|
|
E('div', { 'class': 'mx-val green', 'id': 's-up' }, formatUptime(o.uptime || 0)),
|
|
E('div', { 'class': 'mx-lbl' }, 'Uptime'),
|
|
E('div', { 'class': 'mx-sub', 'id': 's-load' }, 'Load: ' + (o.load || '0'))
|
|
]),
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '🧠'),
|
|
E('div', { 'class': 'mx-val ' + memCls, 'id': 's-mem' }, memPct + '%'),
|
|
E('div', { 'class': 'mx-lbl' }, 'Memory'),
|
|
E('div', { 'class': 'mx-sub' }, formatMem(o.mem_used_kb || 0) + ' / ' + formatMem(o.mem_total_kb || 0))
|
|
]),
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '🌐'),
|
|
E('div', { 'class': 'mx-val cyan', 'id': 's-vh' }, String(o.vhosts || 0)),
|
|
E('div', { 'class': 'mx-lbl' }, 'vHosts')
|
|
]),
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '🔒'),
|
|
E('div', { 'class': 'mx-val purple', 'id': 's-cert' }, String(o.certificates || 0)),
|
|
E('div', { 'class': 'mx-lbl' }, 'SSL Certs')
|
|
]),
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '📄'),
|
|
E('div', { 'class': 'mx-val cyan' }, String(o.metablogs || 0)),
|
|
E('div', { 'class': 'mx-lbl' }, 'MetaBlogs')
|
|
]),
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '🐍'),
|
|
E('div', { 'class': 'mx-val green' }, String(o.streamlits || 0)),
|
|
E('div', { 'class': 'mx-lbl' }, 'Streamlits')
|
|
]),
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '📦'),
|
|
E('div', { 'class': 'mx-val purple' }, String(o.lxc_containers || 0)),
|
|
E('div', { 'class': 'mx-lbl' }, 'LXC')
|
|
]),
|
|
E('div', { 'class': 'mx-card' }, [
|
|
E('div', { 'class': 'mx-icon' }, '🔗'),
|
|
E('div', { 'class': 'mx-val cyan', 'id': 's-tcp' }, String(c.total_tcp || 0)),
|
|
E('div', { 'class': 'mx-lbl' }, 'TCP Conns')
|
|
])
|
|
]),
|
|
|
|
// Services bar
|
|
E('div', { 'class': 'mx-svc' }, [
|
|
E('span', { 'class': 'mx-svc-title' }, 'Services'),
|
|
E('div', { 'class': 'mx-svc-item' }, [
|
|
E('span', { 'class': 'mx-svc-dot ' + (o.haproxy ? 'on' : 'off'), 'id': 'sv-ha' }),
|
|
'HAProxy'
|
|
]),
|
|
E('div', { 'class': 'mx-svc-item' }, [
|
|
E('span', { 'class': 'mx-svc-dot ' + (o.mitmproxy ? 'on' : 'off'), 'id': 'sv-waf' }),
|
|
'WAF'
|
|
]),
|
|
E('div', { 'class': 'mx-svc-item' }, [
|
|
E('span', { 'class': 'mx-svc-dot ' + (o.crowdsec ? 'on' : 'off'), 'id': 'sv-cs' }),
|
|
'CrowdSec'
|
|
])
|
|
]),
|
|
|
|
// Panels
|
|
E('div', { 'class': 'mx-panels' }, [
|
|
// WAF Panel
|
|
E('div', { 'class': 'mx-panel' }, [
|
|
E('div', { 'class': 'mx-panel-title' }, [
|
|
E('span', {}, '🛡'),
|
|
'WAF & Security'
|
|
]),
|
|
E('div', { 'class': 'mx-row' }, [
|
|
E('span', { 'class': 'mx-row-label' }, 'Active Bans'),
|
|
E('span', { 'class': 'mx-row-val', 'id': 'w-bans', 'style': (w.active_bans || 0) > 0 ? 'color:#ff9800' : '' }, String(w.active_bans || 0))
|
|
]),
|
|
E('div', { 'class': 'mx-row' }, [
|
|
E('span', { 'class': 'mx-row-label' }, 'Alerts (24h)'),
|
|
E('span', { 'class': 'mx-row-val', 'id': 'w-alerts' }, String(w.alerts_today || 0))
|
|
]),
|
|
E('div', { 'class': 'mx-row' }, [
|
|
E('span', { 'class': 'mx-row-label' }, 'WAF Blocked'),
|
|
E('span', { 'class': 'mx-row-val', 'id': 'w-blocked', 'style': (w.waf_blocked || 0) > 0 ? 'color:#ff9800' : '' }, String(w.waf_blocked || 0))
|
|
])
|
|
]),
|
|
|
|
// Connections Panel
|
|
E('div', { 'class': 'mx-panel' }, [
|
|
E('div', { 'class': 'mx-panel-title' }, [
|
|
E('span', {}, '🔗'),
|
|
'Connections'
|
|
]),
|
|
E('div', { 'class': 'mx-row' }, [
|
|
E('span', { 'class': 'mx-row-label' }, 'HTTPS (443)'),
|
|
E('span', { 'class': 'mx-row-val', 'id': 'c-https' }, String(c.https || 0))
|
|
]),
|
|
E('div', { 'class': 'mx-row' }, [
|
|
E('span', { 'class': 'mx-row-label' }, 'HTTP (80)'),
|
|
E('span', { 'class': 'mx-row-val', 'id': 'c-http' }, String(c.http || 0))
|
|
]),
|
|
E('div', { 'class': 'mx-row' }, [
|
|
E('span', { 'class': 'mx-row-label' }, 'SSH (22)'),
|
|
E('span', { 'class': 'mx-row-val', 'id': 'c-ssh' }, String(c.ssh || 0))
|
|
]),
|
|
E('div', { 'class': 'mx-row' }, [
|
|
E('span', { 'class': 'mx-row-label' }, 'Total TCP'),
|
|
E('span', { 'class': 'mx-row-val', 'id': 'c-total', 'style': 'color:#ab47bc' }, String(c.total_tcp || 0))
|
|
])
|
|
])
|
|
])
|
|
];
|
|
|
|
// Setup polling
|
|
poll.add(L.bind(this.pollMetrics, this), 5);
|
|
|
|
// Clock
|
|
setInterval(function() {
|
|
var el = document.getElementById('mx-time');
|
|
if (el) el.textContent = new Date().toLocaleTimeString();
|
|
}, 1000);
|
|
|
|
return KissTheme.wrap(content, 'admin/status/metrics');
|
|
},
|
|
|
|
pollMetrics: function() {
|
|
return Promise.all([
|
|
callOverview(),
|
|
callWafStats(),
|
|
callConnections()
|
|
]).then(function(data) {
|
|
var o = data[0] || {};
|
|
var w = data[1] || {};
|
|
var c = data[2] || {};
|
|
|
|
// Extract freshness info (from overview response)
|
|
var freshness = o._freshness || { age: 0, fresh: true };
|
|
var age = freshness.age || 0;
|
|
var freshClass = getFreshnessClass(age);
|
|
|
|
// Update freshness indicator
|
|
var freshDot = document.getElementById('mx-fresh-dot');
|
|
var ageEl = document.getElementById('mx-age');
|
|
if (freshDot) {
|
|
freshDot.className = 'mx-freshness-dot ' + freshClass;
|
|
}
|
|
if (ageEl) {
|
|
ageEl.textContent = age < 5 ? 'just now' : formatAge(age);
|
|
ageEl.className = 'mx-age ' + freshClass;
|
|
}
|
|
|
|
var upd = {
|
|
's-up': formatUptime(o.uptime || 0),
|
|
's-load': 'Load: ' + (o.load || '0'),
|
|
's-mem': (o.mem_pct || 0) + '%',
|
|
's-vh': String(o.vhosts || 0),
|
|
's-cert': String(o.certificates || 0),
|
|
's-tcp': String(c.total_tcp || 0),
|
|
'w-bans': String(w.active_bans || 0),
|
|
'w-alerts': String(w.alerts_today || 0),
|
|
'w-blocked': String(w.waf_blocked || 0),
|
|
'c-https': String(c.https || 0),
|
|
'c-http': String(c.http || 0),
|
|
'c-ssh': String(c.ssh || 0),
|
|
'c-total': String(c.total_tcp || 0)
|
|
};
|
|
|
|
for (var id in upd) {
|
|
var el = document.getElementById(id);
|
|
if (el) {
|
|
var newVal = upd[id];
|
|
// Animate if value changed
|
|
if (prevValues[id] !== undefined && prevValues[id] !== newVal) {
|
|
el.classList.remove('mx-changed');
|
|
void el.offsetWidth; // Force reflow
|
|
el.classList.add('mx-changed');
|
|
}
|
|
el.textContent = newVal;
|
|
prevValues[id] = newVal;
|
|
}
|
|
}
|
|
|
|
// Service dots
|
|
var ha = document.getElementById('sv-ha');
|
|
var waf = document.getElementById('sv-waf');
|
|
var cs = document.getElementById('sv-cs');
|
|
if (ha) ha.className = 'mx-svc-dot ' + (o.haproxy ? 'on' : 'off');
|
|
if (waf) waf.className = 'mx-svc-dot ' + (o.mitmproxy ? 'on' : 'off');
|
|
if (cs) cs.className = 'mx-svc-dot ' + (o.crowdsec ? 'on' : 'off');
|
|
});
|
|
}
|
|
});
|