From 80660d1c12b9c31d9ba9b4be54f1cfbcd2a0661a Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 24 Mar 2026 18:04:40 +0100 Subject: [PATCH] feat: add comprehensive dashboard with live metrics, traffic leaders, and audit stream visualization Replace placeholder dashboard with full operational overview including user/device/gateway summary hero, four-metric stats grid, top devices by traffic, recent audit events stream, service exposure posture, and gateway enforcement note. Add formatBytes and formatRelativeDate utility functions for human-readable data display. Fetch users, devices, gateways, policies, services, and audit data with --- admin-web/src/components/Page.tsx | 20 +- .../src/features/dashboard/DashboardPage.tsx | 289 +++++++++++++++++- admin-web/src/styles/global.css | 181 ++++++++++- 3 files changed, 455 insertions(+), 35 deletions(-) diff --git a/admin-web/src/components/Page.tsx b/admin-web/src/components/Page.tsx index 3a595ef..6cf0178 100644 --- a/admin-web/src/components/Page.tsx +++ b/admin-web/src/components/Page.tsx @@ -17,17 +17,17 @@ export function Page({ title, subtitle, actions, children }: PageProps) { return (
-
- diff --git a/admin-web/src/features/dashboard/DashboardPage.tsx b/admin-web/src/features/dashboard/DashboardPage.tsx index 4c6121a..9b19189 100644 --- a/admin-web/src/features/dashboard/DashboardPage.tsx +++ b/admin-web/src/features/dashboard/DashboardPage.tsx @@ -1,34 +1,291 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { + Activity, + ArrowUpRight, + FileClock, + Network, + Shield, + Users +} from "lucide-react"; + +import { api } from "../../api/client"; import { Card } from "../../components/Card"; import { Page } from "../../components/Page"; +function formatBytes(bytes: number) { + if (!bytes) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + return `${value >= 100 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; +} + +function formatRelativeDate(value: string) { + const target = new Date(value).getTime(); + if (Number.isNaN(target)) { + return value; + } + + const diffMs = Date.now() - target; + const diffMinutes = Math.max(0, Math.floor(diffMs / 60000)); + + if (diffMinutes < 1) { + return "just now"; + } + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours}h ago`; + } + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + export function DashboardPage() { + const results = useQueries({ + queries: [ + { queryKey: ["users"], queryFn: api.users }, + { queryKey: ["devices"], queryFn: api.devices }, + { queryKey: ["gateways"], queryFn: api.gateways }, + { queryKey: ["policies"], queryFn: api.policies }, + { queryKey: ["services"], queryFn: api.services }, + { queryKey: ["audit"], queryFn: api.audit } + ] + }); + + const [usersQuery, devicesQuery, gatewaysQuery, policiesQuery, servicesQuery, auditQuery] = results; + const isLoading = results.some((query) => query.isLoading); + const isError = results.some((query) => query.isError); + + const summary = useMemo(() => { + const users = usersQuery.data ?? []; + const devices = devicesQuery.data ?? []; + const gateways = gatewaysQuery.data ?? []; + const policies = policiesQuery.data ?? []; + const services = servicesQuery.data ?? []; + const auditEvents = auditQuery.data ?? []; + + const activeUsers = users.filter((user) => user.is_active); + const adminUsers = users.filter((user) => user.role === "admin" && user.is_active); + const activeDevices = devices.filter((device) => device.status === "active"); + const revokedDevices = devices.filter((device) => device.status === "revoked"); + const activeGateways = gateways.filter((gateway) => gateway.is_active); + const activePolicies = policies.filter((policy) => policy.is_active); + const fullTunnelPolicies = activePolicies.filter((policy) => policy.full_tunnel); + const servicePolicies = activePolicies.filter((policy) => (policy.services?.length ?? 0) > 0); + const totalRX = devices.reduce((sum, device) => sum + (device.rx_bytes ?? 0), 0); + const totalTX = devices.reduce((sum, device) => sum + (device.tx_bytes ?? 0), 0); + + const topDevices = [...devices] + .sort((left, right) => (right.rx_bytes + right.tx_bytes) - (left.rx_bytes + left.tx_bytes)) + .slice(0, 5); + + const recentAudit = [...auditEvents] + .sort((left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime()) + .slice(0, 6); + + return { + activeUserCount: activeUsers.length, + adminUserCount: adminUsers.length, + activeDeviceCount: activeDevices.length, + revokedDeviceCount: revokedDevices.length, + activeGatewayCount: activeGateways.length, + gatewayHealth: activeGateways.length > 0 ? "Healthy" : "No active gateway", + activePolicyCount: activePolicies.length, + fullTunnelPolicyCount: fullTunnelPolicies.length, + servicePolicyCount: servicePolicies.length, + serviceCount: services.filter((service) => service.is_active).length, + totalRX, + totalTX, + topDevices, + recentAudit + }; + }, [auditQuery.data, devicesQuery.data, gatewaysQuery.data, policiesQuery.data, servicesQuery.data, usersQuery.data]); + return ( - -
+ Live admin overview} + > + {isError ?

Unable to load one or more dashboard data sources.

: null} + +
+
+

VPN Operations

+

Private access posture at a glance

+

+ Track active identities, connected endpoints, gateway health, policy exposure, and the busiest devices from one place. +

+
+
+
+ Users + {summary.activeUserCount} + {summary.adminUserCount} admins +
+
+ Devices + {summary.activeDeviceCount} + {summary.revokedDeviceCount} revoked +
+
+ Gateways + {summary.activeGatewayCount} + {summary.gatewayHealth} +
+
+
+ +
-

Active users

- 248 - 12 enrolled this week +
+
+

Identity coverage

+
+ {summary.activeUserCount} +

{summary.adminUserCount} administrators currently active in the control plane.

+ -

Connected devices

- 181 - 7 pending rotation +
+
+

Endpoint activity

+
+ {summary.activeDeviceCount} +

{formatBytes(summary.totalRX + summary.totalTX)} total observed tunnel traffic.

+ -

Gateway health

- Healthy - Last sync 36s ago +
+
+

Gateway posture

+
+ {summary.gatewayHealth} +

{summary.activeGatewayCount} active gateway nodes enforcing policy.

+
+ + +
+
+

Policy model

+
+ {summary.activePolicyCount} +

+ {summary.servicePolicyCount} service-based and {summary.fullTunnelPolicyCount} full-tunnel policies active. +

-
+ +
-

Recent enrollments

-

New devices are issued profiles automatically after successful sign-in.

+
+
+

Traffic leaders

+

Top devices by observed transfer

+
+ Live device stats +
+
+ {isLoading ?

Loading device telemetry...

: null} + {!isLoading && summary.topDevices.length === 0 ?

No device activity has been recorded yet.

: null} + {summary.topDevices.map((device) => ( +
+
+ {device.name} + {device.assigned_ip ?? "No VPN IP"} ยท {device.platform} +
+
+ {formatBytes((device.rx_bytes ?? 0) + (device.tx_bytes ?? 0))} + {device.status} +
+
+ ))} +
+ -

Policy posture

-

Gateway enforcement is generated from effective allow-lists backed by nftables.

+
+
+

Recent activity

+

Audit stream

+
+ {summary.recentAudit.length} events +
+
+ {isLoading ?

Loading audit events...

: null} + {!isLoading && summary.recentAudit.length === 0 ?

No audit events available.

: null} + {summary.recentAudit.map((event) => ( +
+
+ {event.event_type} + {event.message} +
+
+ {formatRelativeDate(event.created_at)} + {event.status} +
+
+ ))} +
+
+
+ +
+ +
+
+

Access model

+

Service exposure

+
+
+ +
+
+
+
+ Published services + {summary.serviceCount} +
+
+ Service-based policies + {summary.servicePolicyCount} +
+
+ Full tunnel policies + {summary.fullTunnelPolicyCount} +
+
+
+ + +
+
+

Operations note

+

Gateway enforcement

+
+
+ +
+
+

+ Effective WireGuard peer state and nftables enforcement are generated from active policies, selected device profiles, + and service-level access rules. Use this dashboard to spot drift before it becomes an outage. +

diff --git a/admin-web/src/styles/global.css b/admin-web/src/styles/global.css index 6e7e2d2..47a3998 100644 --- a/admin-web/src/styles/global.css +++ b/admin-web/src/styles/global.css @@ -273,7 +273,13 @@ button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; - gap: 16px; + gap: 22px; + padding-bottom: 6px; +} + +.page-heading { + display: grid; + gap: 12px; } .page-title-block { @@ -287,14 +293,11 @@ button { gap: 6px; } -.page-subtitle-block { - grid-column: 1 / 2; - padding-left: 68px; -} - -.page-subtitle-block p { +.page-subtitle { margin: 0; + padding-left: 68px; max-width: 760px; + color: var(--muted); } .page-title-icon { @@ -433,6 +436,153 @@ button { font-size: 2rem; } +.dashboard-hero { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr); + gap: 22px; + padding: 26px; + background: + radial-gradient(circle at top left, rgba(68, 230, 231, 0.1), transparent 32%), + linear-gradient(180deg, rgba(18, 31, 67, 0.92), rgba(10, 18, 40, 0.92)); +} + +.dashboard-hero-copy { + display: grid; + gap: 12px; +} + +.dashboard-hero-copy h4, +.dashboard-section-head h4 { + margin: 0; + font-size: 1.35rem; + line-height: 1.12; +} + +.dashboard-hero-copy p:last-child, +.dashboard-note { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.dashboard-hero-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.dashboard-mini-stat { + display: grid; + gap: 6px; + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: rgba(8, 14, 30, 0.64); +} + +.dashboard-mini-stat strong { + font-size: 1.65rem; + line-height: 1; +} + +.dashboard-mini-stat span:last-child, +.dashboard-mini-label { + color: var(--muted); +} + +.dashboard-stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; +} + +.dashboard-stat-head, +.dashboard-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.dashboard-stat-icon, +.dashboard-inline-icon { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 14px; + color: var(--accent); + border: 1px solid rgba(68, 230, 231, 0.14); + background: rgba(8, 14, 30, 0.68); +} + +.dashboard-stat-support { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.dashboard-main-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); + gap: 18px; +} + +.dashboard-bottom-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.dashboard-list { + display: grid; + gap: 12px; +} + +.dashboard-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--line); + background: rgba(8, 14, 30, 0.58); +} + +.dashboard-list-copy, +.dashboard-list-metric, +.dashboard-posture-item { + display: grid; + gap: 4px; +} + +.dashboard-list-copy strong, +.dashboard-list-metric span, +.dashboard-posture-item strong { + font-weight: 700; +} + +.dashboard-list-copy span, +.dashboard-list-metric small, +.dashboard-posture-label, +.dashboard-empty { + color: var(--muted); +} + +.dashboard-posture-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.dashboard-posture-item { + padding: 16px; + border-radius: 16px; + border: 1px solid var(--line); + background: rgba(8, 14, 30, 0.58); +} + .notice { margin: 0; color: #ffcf9b; @@ -556,12 +706,25 @@ button { .auth-brand, .brand-block, .topbar-brand, - .topbar-actions, - .page-header { + .topbar-actions { align-items: flex-start; flex-direction: column; } + .page-header, + .dashboard-hero, + .dashboard-main-grid, + .dashboard-bottom-grid, + .dashboard-stats-grid, + .dashboard-posture-grid, + .dashboard-hero-metrics { + grid-template-columns: 1fr; + } + + .page-subtitle { + padding-left: 0; + } + .brand-logo-full { width: min(100%, 200px); }