diff --git a/daemon/handlers/system.py b/daemon/handlers/system.py new file mode 100644 index 0000000..2715a0b --- /dev/null +++ b/daemon/handlers/system.py @@ -0,0 +1,35 @@ +"""System metrics handler. + +Returns CPU load, memory usage, and per-interface network traffic stats. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from daemon.iface import GET_SYSTEM_METRICS +from daemon.server import registry +from lib.state import state as state_store + +logger = logging.getLogger(__name__) + + +@registry.register(GET_SYSTEM_METRICS) +def system_metrics(_request: Any, _body: Any) -> dict[str, Any]: + """Return system-wide metrics. + + Reads from pre-collected state (CPU load, memory, network traffic). + + Returns: + Dict with load, memory, swap, and traffic data. + """ + sys_state = state_store.get("system") + if sys_state is None: + return { + "load": {"load1": 0.0, "load5": 0.0, "load15": 0.0}, + "memory": {"total": 0, "available": 0, "used": 0, "used_pct": 0}, + "swap": {"total": 0, "used": 0, "used_pct": 0}, + "traffic": {}, + } + return sys_state diff --git a/daemon/iface.py b/daemon/iface.py index 7ac7884..e5df741 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -155,12 +155,12 @@ GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app") # ---- Server infra (not going through client) ---- GET_HEALTH: Endpoint = _ep("GET", "/health") -GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all") POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh") GET_WS: Endpoint = _ep("GET", "/ws") POST_BATCH: Endpoint = _ep("POST", "/batch") GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending") POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all") +GET_SYSTEM_METRICS: Endpoint = _ep("GET", "/system/metrics") # Collect all endpoint module-level constants for __all__ verification _all_endpoints = [ diff --git a/daemon/server.py b/daemon/server.py index 2d788a5..647043e 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -350,7 +350,6 @@ def create_app() -> web.Application: """ app = web.Application() app.router.add_route("GET", "/health", _health) - app.router.add_route("GET", "/status/all", get_status_all) app.router.add_route("POST", "/status/refresh", refresh_status) app.router.add_route("POST", "/batch", _handle_batch) app.router.add_route("GET", "/ws", _handle_ws) @@ -462,15 +461,6 @@ async def _health(_request: web.Request) -> web.Response: return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)}) -async def get_status_all(_request: web.Request) -> web.Response: - """Return the entire state snapshot in one call. - - Returns: - JSON response containing state data for all subsystems. - """ - return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS}) - - async def refresh_status(_request: web.Request) -> web.Response: """Re-collect all state from system. @@ -517,6 +507,7 @@ def _register_routes() -> None: network, # noqa: F401 nginx, # noqa: F401 status, # noqa: F401 + system, # noqa: F401 wireguard, # noqa: F401 ) diff --git a/lib/state.py b/lib/state.py index c51c406..103d9c1 100644 --- a/lib/state.py +++ b/lib/state.py @@ -36,6 +36,7 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = { "wireguard": 10, "dnsmasq": 10, "networkd": 10, + "system": 30, } @@ -63,6 +64,7 @@ class State: "acme", "wireguard", "networkd", + "system", ] def __init__(self) -> None: @@ -1071,6 +1073,118 @@ register_volatile( ), ) +# --------------------------------------------------------------------------- +# System metrics collector +# --------------------------------------------------------------------------- + + +def _parse_meminfo() -> dict[str, Any]: + """Read /proc/meminfo and return dict with key memory stats in bytes.""" + info: dict[str, int] = {} + try: + for line in Path("/proc/meminfo").read_text().splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + parts = value.strip().split() + val = int(parts[0]) + # Convert kB to bytes + if parts and parts[-1] == "kB": + val *= 1024 + info[key] = val + except (OSError, ValueError): + return {} + return info + + +def _collect_system() -> dict[str, Any]: + """Collect system-wide metrics: CPU load, memory, network traffic. + + Reads from /proc and /sys — no subprocess needed. + + Returns: + Dict with load (1/5/15 min), memory usage, and per-interface traffic. + """ + # CPU load + loads = [] + try: + parts = Path("/proc/loadavg").read_text().split() + loads = [float(x) for x in parts[:3]] + except (OSError, ValueError): + loads = [0.0, 0.0, 0.0] + + # Memory + meminfo_raw = _parse_meminfo() + mem_total = meminfo_raw.get("MemTotal", 0) + mem_free = meminfo_raw.get("MemFree", 0) + mem_available = meminfo_raw.get("MemAvailable", mem_free) + mem_buffers = meminfo_raw.get("Buffers", 0) + mem_cached = meminfo_raw.get("Cached", 0) + mem_used = mem_total - mem_free - mem_buffers - mem_cached + if mem_used < 0: + mem_used = mem_total - mem_available + + # Swap + swap_total = meminfo_raw.get("SwapTotal", 0) + swap_free = meminfo_raw.get("SwapFree", 0) + swap_used = swap_total - swap_free + + # Network traffic from /sys/class/net//statistics/ + traffic: dict[str, dict[str, int]] = {} + try: + net_root = Path("/sys/class/net") + if net_root.is_dir(): + for iface_dir in net_root.iterdir(): + stats_dir = iface_dir / "statistics" + if not stats_dir.is_dir(): + continue + iface_name = iface_dir.name + rx_bytes = 0 + tx_bytes = 0 + rx_packets = 0 + tx_packets = 0 + try: + rx_bytes = int((stats_dir / "rx_bytes").read_text().strip()) + tx_bytes = int((stats_dir / "tx_bytes").read_text().strip()) + rx_packets = int((stats_dir / "rx_packets").read_text().strip()) + tx_packets = int((stats_dir / "tx_packets").read_text().strip()) + except (OSError, ValueError): + continue + traffic[iface_name] = { + "rx_bytes": rx_bytes, + "tx_bytes": tx_bytes, + "rx_packets": rx_packets, + "tx_packets": tx_packets, + } + except OSError: + pass + + return { + "load": { + "load1": loads[0], + "load5": loads[1], + "load15": loads[2], + }, + "memory": { + "total": mem_total, + "available": mem_available, + "used": mem_used, + "used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0, + }, + "swap": { + "total": swap_total, + "used": swap_used, + "used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0, + }, + "traffic": traffic, + "timestamp": _now_iso(), + } + + +register_collector("system", _collect_system) + + __all__ = [ "_DEFAULT_POLL_INTERVALS", "State", diff --git a/lib/system_import.py b/lib/system_import.py index 90043f6..d6af2da 100644 --- a/lib/system_import.py +++ b/lib/system_import.py @@ -379,20 +379,26 @@ def _parse_wireguard_conf(text: str) -> dict[str, Any]: def import_networkd() -> bool: - """Parse /etc/systemd/network/99-*.network -> config/network/config.json.""" + """Parse /etc/systemd/network/*.network -> config/network/config.json.""" if not NETWORKD_DIR.exists(): logger.debug("Skipping network: %s not found", NETWORKD_DIR) return False - network_files = sorted(NETWORKD_DIR.glob("99-*.network")) + network_files = sorted(NETWORKD_DIR.glob("*.network")) if not network_files: - logger.debug("Skipping network: no 99-*.network files") + logger.debug("Skipping network: no *.network files") return False parsed_interfaces: dict[str, dict[str, Any]] = {} for nf in network_files: - # Extract interface name from filename: 99-eth0.network -> eth0 - iface_name = nf.stem[3:] # strip "99-" + # Extract interface name from filename, stripping optional priority prefix: + # 99-eth0.network -> eth0 + # eth0.network -> eth0 + base = nf.stem + if "-" in base and base.split("-", 1)[0].isdigit(): + iface_name = base.split("-", 1)[1] + else: + iface_name = base try: parsed_interface = _parse_network_file(nf) if parsed_interface: diff --git a/tests/test_server.py b/tests/test_server.py index ebe8f58..702d37a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -43,25 +43,6 @@ class TestWsUrlGeneration: assert b"ws://[::1]:9090/ws" in resp.data -class TestApiStatusAll: - @patch("webui.server.get") - def test_success(self, mock_get, client): - mock_get.return_value = {"firewall": {"zones": {}}, "dnsmasq": {}} - resp = client.get("/api/status/all") - assert resp.status_code == 200 - data = resp.get_json() - assert data["ok"] is True - assert "firewall" in data["data"] - - @patch("webui.server.get") - def test_error(self, mock_get, client): - mock_get.side_effect = RuntimeError("connection refused") - resp = client.get("/api/status/all") - assert resp.status_code == 500 - data = resp.get_json() - assert data["ok"] is False - - class TestBlueprintsRegistered: def test_all_blueprints_registered(self, client): from webui.server import BLUEPRINTS diff --git a/webui/api/status.py b/webui/api/status.py index 5918731..3c38b9f 100644 --- a/webui/api/status.py +++ b/webui/api/status.py @@ -10,7 +10,7 @@ import logging from flask import Blueprint from daemon.client import get, post -from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL +from daemon.iface import GET_STATUS_PENDING, GET_SYSTEM_METRICS, POST_STATUS_APPLY_ALL from webui.api.common import _error, _ok logger = logging.getLogger(__name__) @@ -49,3 +49,20 @@ def apply_all(): except RuntimeError as exc: logger.error("Failed to apply all pending changes: %s", exc) return _error(str(exc), 500) + + +@bp.route("/system-metrics", methods=["GET"]) +def system_metrics(): + """Retrieve system-wide metrics. + + Endpoint: + GET /api/status/system-metrics + + Returns: + JSON response with CPU load, memory usage, and network traffic stats. + """ + try: + return _ok(get(GET_SYSTEM_METRICS)) + except RuntimeError as exc: + logger.error("Failed to get system metrics: %s", exc) + return _error(str(exc), 500) diff --git a/webui/server.py b/webui/server.py index eb45abd..201fa1a 100644 --- a/webui/server.py +++ b/webui/server.py @@ -17,8 +17,6 @@ from pathlib import Path from flask import Flask, abort, request from werkzeug.middleware.proxy_fix import ProxyFix -from daemon.client import get -from daemon.iface import GET_STATUS_ALL from lib.logging import setup_logging from webui.api.certs import bp as certs_bp from webui.api.dhcp import bp as dhcp_bp @@ -158,27 +156,6 @@ def _log_request_finish(response): return response -# --------------------------------------------------------------------------- -# API proxy routes -# --------------------------------------------------------------------------- - - -@app.route("/api/status/all") -def api_status_all(): - """Return aggregated status from all subsystems. - - Proxies the daemon's ``/status/all`` endpoint for SPA consumption. - - Returns: - JSON response with state data for all subsystems. - """ - try: - return {"ok": True, "data": get(GET_STATUS_ALL)} - except Exception as exc: - logger.warning("Status all failed: %s", exc) - return {"ok": False, "error": str(exc)}, 500 - - # --------------------------------------------------------------------------- # SPA entry point — serve index.html for /, 404 for everything else # --------------------------------------------------------------------------- diff --git a/webui/static/app.js b/webui/static/app.js index 74c0d52..19cae0f 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -1,6 +1,6 @@ -import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8'; +import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=10'; -import DashboardPage from '/static/pages/dashboard.js?v=9'; +import DashboardPage from '/static/pages/dashboard.js?v=11'; import InterfacesPage from '/static/pages/interfaces.js?v=9'; import ZonesPage from '/static/pages/zones.js?v=9'; import RulesPage from '/static/pages/rules.js?v=9'; @@ -28,16 +28,6 @@ const Nav = [ { path: '/logs', label: 'Logs' }, ]; -/* ── Model registration ────────────────────────────────────── */ -modelRegister('status', { - subsystem: 'status', - fetch: async () => { - const r = await apiFetch('/api/status/all'); - if (!r.ok) throw new Error(r.error); - return r.data; - }, -}); - modelRegister('firewall', { subsystem: 'firewall', fetch: async () => { @@ -166,8 +156,26 @@ modelRegister('logs', { }, }); +modelRegister('status', { + subsystem: '*', + fetch: async () => { + const [pendingR, metricsR] = await Promise.allSettled([ + apiFetch('/api/status/pending'), + apiFetch('/api/status/system-metrics'), + ]); + const result = {}; + if (pendingR.status === 'fulfilled' && pendingR.value.ok) { + result.pending = pendingR.value.data || {}; + } + if (metricsR.status === 'fulfilled' && metricsR.value.ok) { + result.metrics = metricsR.value.data || {}; + } + return result; + }, +}); + /* ── Initial fetch ─────────────────────────────────────────── */ -for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme']) { +for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) { modelFetch(name); } modelFetch('logs', 'journal'); diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index e041817..1179686 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -1,52 +1,222 @@ -import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=9'; +import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton } from '/static/hoover/index.js?v=10'; + +function fmtBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + sizes[i]; +} export default definePage({ init() { return { + firewall: getModel('firewall'), + network: getModel('network'), + dnsmasq: getModel('dnsmasq'), + wireguard: getModel('wireguard'), + acme: getModel('acme'), + nginx: getModel('nginx'), status: getModel('status'), }; }, render(state) { - const guard = renderGuard(state.status, 'Dashboard', 'System overview', state.status.data); + const guard = renderGuardMulti('Dashboard', 'System overview', + state.firewall, state.network, state.dnsmasq, state.wireguard, + state.acme, state.nginx, state.status); if (guard) return guard; - const d = state.status.data; - const fwZones = (d.firewall?.zones) || {}; - const net = d.net || {}; - const nCount = Object.keys(net).length; - const upI = Object.values(net).filter(i => i.state === 'up'); - const upC = upI.length; - const certs = d.certs || []; - const certW = certs.filter(c => c.expired || c.days_remaining <= 30); - const dmsk = d.dnsmasq?.status || {}; - const wP = (d.wg || {}).peers || []; + // Extract data + const fwIfaces = Array.isArray(state.firewall.data?.interfaces) ? state.firewall.data.interfaces : []; + const fwZones = state.firewall.data?.zones || {}; + const netIfaces = state.network.data?.interfaces || {}; + const dnsmasqStatus = state.dnsmasq.data?.status || {}; + const dnsmasqLeases = state.dnsmasq.data?.leases || []; + const activeLeaseCount = dnsmasqStatus.active_leases || dnsmasqLeases.length; + const proxyDomains = state.nginx.data?.domains || []; + const onlineDomains = proxyDomains ? proxyDomains.filter(d => d.online).length : 0; + const offlineDomains = (proxyDomains?.length || 0) - onlineDomains; + const wgp = state.wireguard.data?.peers || []; + const wgStatus = state.wireguard.data?.status || {}; + const allCerts = state.acme.data?.certs || []; + const expiringCerts = allCerts.filter(c => c.expired || (c.days_remaining !== undefined && c.days_remaining <= 30)); + // System metrics from status model + const sysMetrics = state.status.data?.metrics || {}; + const sysLoad = sysMetrics.load || {}; + const sysMem = sysMetrics.memory || {}; + const sysSwap = sysMetrics.swap || {}; + const sysTraffic = sysMetrics.traffic || {}; + + // Pending changes + const pend = state.status.data?.pending || {}; + const totalChanges = pend.total_changes || 0; + const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k => + k === 'firewall' ? (pend[k]?.needs_apply) : (pend[k]?.pending_changes) + ); + const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' }; + + // Build merged interface list + const allNames = [...new Set([...fwIfaces.map(f => f.name), ...Object.keys(netIfaces)])]; + const ifaces = allNames.map(name => { + const fw = fwIfaces.find(f => f.name === name); + const netEntry = netIfaces[name] || {}; + const traffic = sysTraffic[name] || {}; + const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : []; + const addrs = netEntry?.addresses || []; + const isUp = ['routable', 'degraded'].some(s => (netEntry.state || '').startsWith(s)); + return { + name, + mac: fw?.mac || null, + ips: ips.length ? ips : addrs, + isUp, + zone: fw?.zone || '—', + rx: traffic.rx_bytes || 0, + tx: traffic.tx_bytes || 0, + }; + }); + + // ── Stat cards ── + const zoneNames = Object.keys(fwZones); const stats = html`
- <${StatCard} label="Active Zones" value=${Object.keys(fwZones).length} - meta=${Object.keys(fwZones).join(', ') || 'None'} /> - <${StatCard} label="Interfaces Up" value=${upC + '/' + nCount} - meta=${upI.map(i => i.name).join(', ') || 'None up'} /> - <${StatCard} label="WireGuard" value=${String((d.wg?.status?.up) ? 'up' : 'down')} - meta=${wP.length + ' peers'} /> - <${StatCard} label="Certificates" value=${certs.length} - meta=${certW.length + ' expiring/expired'} /> + <${StatCard} label="Active Zones" value=${zoneNames.length} + meta=${zoneNames.join(', ') || 'None'} /> + <${StatCard} label="DHCP Leases" value=${activeLeaseCount} + meta=${dnsmasqLeases.slice(0, 3).map(l => l.hostname || l.mac).join(', ') || 'None'} /> + <${StatCard} label="Proxy Domains" value=${onlineDomains + '/' + (proxyDomains?.length || 0)} + meta=${offlineDomains + ' offline'} /> + <${StatCard} label="Certificates" value=${allCerts.length} + meta=${expiringCerts.length ? expiringCerts.length + ' expiring' : 'All valid'} />
`; - const services = html`
-
-
Services
+ // ── Pending changes ── + const pendingCard = pendKeys.length > 0 + ? html`
+
Pending Changes <${Badge} text=${String(totalChanges)} variant="warning" />
-
    -
  • <${ServiceStatus} state=${dmsk.service_active ? 'up' : 'down'} label="Dnsmasq" />
  • -
  • <${ServiceStatus} state=${(d.wg?.status?.up) ? 'up' : 'down'} label="WireGuard" />
  • -
+

Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}

+ <${ActionButton} url="/api/status/apply-all" label="Apply All Changes" + successMsg="All changes applied" refresh="status" + cls="btn btn-sm btn-primary" />
+
` + : html`
+
Pending Changes
+
<${Badge} text="All configured" variant="success" />
+
`; + + // ── System resources ── + const memPct = sysMem.used_pct || 0; + const memVariant = memPct > 80 ? 'danger' : memPct > 60 ? 'warning' : 'success'; + const swapPct = sysSwap.used_pct || 0; + const swapVariant = swapPct > 50 ? 'warning' : 'success'; + const systemCard = html`
+
System Resources
+
+ + + + + + + + + + + + + + + + +
CPU Load${(sysLoad.load1 || 0).toFixed(2)} / ${(sysLoad.load5 || 0).toFixed(2)} / ${(sysLoad.load15 || 0).toFixed(2)}1m / 5m / 15m
Memory<${Badge} text=${Math.round(memPct) + '%'} variant=${memVariant} /> + ${fmtBytes(sysMem.used || 0)} / ${fmtBytes(sysMem.total || 0)}used / total
Swap${sysSwap.total > 0 + ? html`<${Badge} text=${Math.round(swapPct) + '%'} variant=${swapVariant} /> + ${fmtBytes(sysSwap.used || 0)} / ${fmtBytes(sysSwap.total)}` + : html`Disabled`}used / total
`; + // ── Interface table ── + const ifaceRows = ifaces.map(i => html` + ${i.name} + ${i.mac || '—'} + ${i.ips.join(', ') || '—'} + ${i.zone !== '—' ? html`<${Badge} text=${i.zone} variant="info" />` : html``} + <${ServiceStatus} state=${i.isUp ? 'up' : 'down'} label="" /> + ${fmtBytes(i.rx)} + ${fmtBytes(i.tx)} + `); + const interfacesCard = html`
+
Network Interfaces
+
+ <${Table} + columns=${['Name', 'MAC', 'IP Addresses', 'Zone', 'State', 'RX', 'TX']} + rows=${ifaceRows} emptyText="No interfaces found" /> +
+
`; + + // ── DHCP leases ── + const leaseRows = dnsmasqLeases.slice(0, 10).map(l => html` + ${l.ip} + ${l.mac} + ${l.hostname || '—'} + ${l.interface || '—'} + `); + const dhcpCard = html`
+
Active DHCP Leases (${activeLeaseCount})
+
+ <${Table} + columns=${['IP', 'MAC', 'Hostname', 'Interface']} + rows=${leaseRows} emptyText="No active leases" /> + ${dnsmasqLeases.length > 10 ? html`

Showing 10 of ${dnsmasqLeases.length}.

` : ''} +
+
`; + + // ── Proxy domains ── + const proxyCard = html`
+
Proxy Domains
+
+
+ <${Badge} text=${onlineDomains} variant="success" /> Online + <${Badge} text=${offlineDomains} variant=${offlineDomains > 0 ? 'danger' : 'info'} /> Offline +
+ ${proxyDomains.slice(0, 3).map(d => html`
+ ${d.domain} → ${d.backend_name || '—'} + ${d.path || '/'} +
`)} + ${proxyDomains.length === 0 ? html`No proxy domains configured.` : ''} +
+
`; + + // ── Services ── + const upCount = ifaces.filter(i => i.isUp).length; + const services = html`
+
Services
+
+
    +
  • <${ServiceStatus} state=${dnsmasqStatus.service_active ? 'up' : 'down'} label="Dnsmasq" />
  • +
  • <${ServiceStatus} state=${wgStatus.up ? 'up' : 'down'} label="WireGuard" /> + ${wgp.length} peers
  • +
  • <${ServiceStatus} state=${upCount > 0 ? 'up' : 'down'} label="Network" /> + ${upCount}/${ifaces.length} interfaces online
  • +
+
+
`; + + // ── Side-by-side layout ── + const dualCards = html`
+ ${dhcpCard} + ${proxyCard} +
`; + return [ PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), stats, + pendingCard, + systemCard, + interfacesCard, + dualCards, services, ]; }, diff --git a/webui/static/style.css b/webui/static/style.css index 9927c3e..d774642 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -465,6 +465,10 @@ body { grid-template-columns: repeat(3, 1fr); } +.grid-4 { + grid-template-columns: repeat(4, 1fr); +} + /* Text Colors */ .text-success { color: var(--success); @@ -521,26 +525,39 @@ body { /* Stat cards */ .stat-card { - background: var(--bg-card); + background: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.25rem; + border-top: 3px solid var(--accent); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.stat-card:hover { + border-color: var(--accent); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); } .stat-card .label { - font-size: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; color: var(--text-muted); - margin-bottom: 4px; + margin-bottom: 6px; } .stat-card .value { - font-size: 28px; + font-size: 32px; font-weight: 700; + color: var(--text-primary); + line-height: 1.2; } .stat-card .meta { font-size: 12px; color: var(--text-muted); - margin-top: 4px; + margin-top: 6px; } /* Status dot */ @@ -603,16 +620,30 @@ body { } .service-list li { - padding: 6px 0; + padding: 8px 0; display: flex; align-items: center; - gap: 8px; + gap: 10px; + border-bottom: 1px solid var(--border); +} + +.service-list li:last-child { + border-bottom: none; } .service-list .svc-name { flex: 1; } +/* Service status inline badge */ +.service-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 500; + font-size: 14px; +} + /* Tabs */ .tabs { display: flex; @@ -827,7 +858,8 @@ body { } .grid-2, - .grid-3 { + .grid-3, + .grid-4 { grid-template-columns: 1fr; } }