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
This commit is contained in:
2026-03-24 18:04:40 +01:00
parent c1f1b6c41f
commit 80660d1c12
3 changed files with 455 additions and 35 deletions

View File

@@ -17,17 +17,17 @@ export function Page({ title, subtitle, actions, children }: PageProps) {
return (
<section className="page">
<div className="page-header">
<div className="page-title-block">
<div className="page-title-icon" aria-hidden="true">
<Icon size={22} strokeWidth={2} />
<div className="page-heading">
<div className="page-title-block">
<div className="page-title-icon" aria-hidden="true">
<Icon size={22} strokeWidth={2} />
</div>
<div className="page-title-copy">
<p className="eyebrow">{meta.eyebrow}</p>
<h3>{title}</h3>
</div>
</div>
<div className="page-title-copy">
<p className="eyebrow">{meta.eyebrow}</p>
<h3>{title}</h3>
</div>
</div>
<div className="page-subtitle-block">
<p>{subtitle}</p>
<p className="page-subtitle">{subtitle}</p>
</div>
{actions}
</div>

View File

@@ -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 (
<Page title="Dashboard" subtitle="Operational visibility across users, devices, gateways, and policy activity.">
<div className="grid three">
<Page
title="Dashboard"
subtitle="Operational visibility for users, gateways, device activity, and policy posture across your NexaVPN estate."
actions={<span className="pill">Live admin overview</span>}
>
{isError ? <p className="notice">Unable to load one or more dashboard data sources.</p> : null}
<div className="dashboard-hero card">
<div className="dashboard-hero-copy">
<p className="eyebrow">VPN Operations</p>
<h4>Private access posture at a glance</h4>
<p>
Track active identities, connected endpoints, gateway health, policy exposure, and the busiest devices from one place.
</p>
</div>
<div className="dashboard-hero-metrics">
<div className="dashboard-mini-stat">
<span className="dashboard-mini-label">Users</span>
<strong>{summary.activeUserCount}</strong>
<span>{summary.adminUserCount} admins</span>
</div>
<div className="dashboard-mini-stat">
<span className="dashboard-mini-label">Devices</span>
<strong>{summary.activeDeviceCount}</strong>
<span>{summary.revokedDeviceCount} revoked</span>
</div>
<div className="dashboard-mini-stat">
<span className="dashboard-mini-label">Gateways</span>
<strong>{summary.activeGatewayCount}</strong>
<span>{summary.gatewayHealth}</span>
</div>
</div>
</div>
<div className="dashboard-stats-grid">
<Card>
<p className="metric-label">Active users</p>
<strong className="metric-value">248</strong>
<span>12 enrolled this week</span>
<div className="dashboard-stat-head">
<div className="dashboard-stat-icon"><Users size={18} strokeWidth={2} /></div>
<p className="metric-label">Identity coverage</p>
</div>
<strong className="metric-value">{summary.activeUserCount}</strong>
<p className="dashboard-stat-support">{summary.adminUserCount} administrators currently active in the control plane.</p>
</Card>
<Card>
<p className="metric-label">Connected devices</p>
<strong className="metric-value">181</strong>
<span>7 pending rotation</span>
<div className="dashboard-stat-head">
<div className="dashboard-stat-icon"><Activity size={18} strokeWidth={2} /></div>
<p className="metric-label">Endpoint activity</p>
</div>
<strong className="metric-value">{summary.activeDeviceCount}</strong>
<p className="dashboard-stat-support">{formatBytes(summary.totalRX + summary.totalTX)} total observed tunnel traffic.</p>
</Card>
<Card>
<p className="metric-label">Gateway health</p>
<strong className="metric-value">Healthy</strong>
<span>Last sync 36s ago</span>
<div className="dashboard-stat-head">
<div className="dashboard-stat-icon"><Network size={18} strokeWidth={2} /></div>
<p className="metric-label">Gateway posture</p>
</div>
<strong className="metric-value">{summary.gatewayHealth}</strong>
<p className="dashboard-stat-support">{summary.activeGatewayCount} active gateway nodes enforcing policy.</p>
</Card>
<Card>
<div className="dashboard-stat-head">
<div className="dashboard-stat-icon"><Shield size={18} strokeWidth={2} /></div>
<p className="metric-label">Policy model</p>
</div>
<strong className="metric-value">{summary.activePolicyCount}</strong>
<p className="dashboard-stat-support">
{summary.servicePolicyCount} service-based and {summary.fullTunnelPolicyCount} full-tunnel policies active.
</p>
</Card>
</div>
<div className="grid two">
<div className="dashboard-main-grid">
<Card>
<h4>Recent enrollments</h4>
<p>New devices are issued profiles automatically after successful sign-in.</p>
<div className="dashboard-section-head">
<div>
<p className="eyebrow">Traffic leaders</p>
<h4>Top devices by observed transfer</h4>
</div>
<span className="pill">Live device stats</span>
</div>
<div className="dashboard-list">
{isLoading ? <p className="dashboard-empty">Loading device telemetry...</p> : null}
{!isLoading && summary.topDevices.length === 0 ? <p className="dashboard-empty">No device activity has been recorded yet.</p> : null}
{summary.topDevices.map((device) => (
<div className="dashboard-list-row" key={device.id}>
<div className="dashboard-list-copy">
<strong>{device.name}</strong>
<span>{device.assigned_ip ?? "No VPN IP"} · {device.platform}</span>
</div>
<div className="dashboard-list-metric">
<span>{formatBytes((device.rx_bytes ?? 0) + (device.tx_bytes ?? 0))}</span>
<small>{device.status}</small>
</div>
</div>
))}
</div>
</Card>
<Card>
<h4>Policy posture</h4>
<p>Gateway enforcement is generated from effective allow-lists backed by nftables.</p>
<div className="dashboard-section-head">
<div>
<p className="eyebrow">Recent activity</p>
<h4>Audit stream</h4>
</div>
<span className="pill">{summary.recentAudit.length} events</span>
</div>
<div className="dashboard-list">
{isLoading ? <p className="dashboard-empty">Loading audit events...</p> : null}
{!isLoading && summary.recentAudit.length === 0 ? <p className="dashboard-empty">No audit events available.</p> : null}
{summary.recentAudit.map((event) => (
<div className="dashboard-list-row" key={event.id}>
<div className="dashboard-list-copy">
<strong>{event.event_type}</strong>
<span>{event.message}</span>
</div>
<div className="dashboard-list-metric">
<span>{formatRelativeDate(event.created_at)}</span>
<small>{event.status}</small>
</div>
</div>
))}
</div>
</Card>
</div>
<div className="dashboard-bottom-grid">
<Card>
<div className="dashboard-section-head">
<div>
<p className="eyebrow">Access model</p>
<h4>Service exposure</h4>
</div>
<div className="dashboard-inline-icon">
<ArrowUpRight size={16} strokeWidth={2} />
</div>
</div>
<div className="dashboard-posture-grid">
<div className="dashboard-posture-item">
<span className="dashboard-posture-label">Published services</span>
<strong>{summary.serviceCount}</strong>
</div>
<div className="dashboard-posture-item">
<span className="dashboard-posture-label">Service-based policies</span>
<strong>{summary.servicePolicyCount}</strong>
</div>
<div className="dashboard-posture-item">
<span className="dashboard-posture-label">Full tunnel policies</span>
<strong>{summary.fullTunnelPolicyCount}</strong>
</div>
</div>
</Card>
<Card>
<div className="dashboard-section-head">
<div>
<p className="eyebrow">Operations note</p>
<h4>Gateway enforcement</h4>
</div>
<div className="dashboard-inline-icon">
<FileClock size={16} strokeWidth={2} />
</div>
</div>
<p className="dashboard-note">
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.
</p>
</Card>
</div>
</Page>

View File

@@ -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);
}