From a76d4bfb1ab19587cf7e5ab5ed3c16c962ba5fec Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Tue, 10 Nov 2026 10:06:02 +0100 Subject: [PATCH 01/10] chore: align verify-ds with @lumyx/ui 0.4 and load Geist Mono --- app/layout.tsx | 3 ++- scripts/verify-ds.mjs | 51 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 22f97f2..c28e88a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { GeistSans } from 'geist/font/sans'; +import { GeistMono } from 'geist/font/mono'; import './globals.css'; import { DashboardChrome } from '@/components/dashboard-chrome'; @@ -10,7 +11,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/scripts/verify-ds.mjs b/scripts/verify-ds.mjs index c85e7fd..bac4777 100644 --- a/scripts/verify-ds.mjs +++ b/scripts/verify-ds.mjs @@ -45,11 +45,19 @@ for (const f of files) { }); } -// 2. Zéro monospace — un vrai font-family/classe, pas une mention en prose ("no monospace tier"). +// 2. Monospace par token uniquement (aligne sur @lumyx/ui 0.4). `sl-num`, `font-mono` et +// `var(--font-mono)` resolvent sur Geist Mono declare par le design system ; une pile litterale +// le contourne et reste interdite. +const LITERAL_MONO = /ui-monospace|'SF Mono'|"SF Mono"|\bMenlo\b|\bConsolas\b|\bmonospace\b/i; +const MONO_TOKEN = /\bfont-mono\b|var\(--font-mono\)/g; for (const f of files) { - if (/\bfont-mono\b|ui-monospace|'SF Mono'|Menlo|Consolas/i.test(readFileSync(f, 'utf8'))) { - fail('no-monospace', `${rel(f)} contient une font monospace`); - } + readFileSync(f, 'utf8') + .split('\n') + .forEach((line, i) => { + if (LITERAL_MONO.test(line.replace(MONO_TOKEN, ''))) { + fail('no-literal-monospace', `${rel(f)}:${i + 1} — ${line.trim()}`); + } + }); } // 3. Pas de dangerouslySetInnerHTML @@ -67,6 +75,41 @@ for (const f of files) { } } +// 5-7. Les namespaces par defaut de Tailwind restent actifs : rien n'empeche a la compilation un +// `text-sm`, un `bg-white` ou un `duration-700`. Memes regles que @lumyx/ui (scripts/verify-ds.mjs). +const CODE_FILES = files.filter((f) => ['.ts', '.tsx', '.js'].includes(extname(f))); +function scanClasses(rule, pattern, accept = () => false) { + for (const f of CODE_FILES) { + readFileSync(f, 'utf8') + .split('\n') + .forEach((line, i) => { + for (const m of line.matchAll(pattern)) { + if (!accept(m)) fail(rule, `${rel(f)}:${i + 1} — ${m[0]}`); + } + }); + } +} + +// 5. Taille de texte sur l'echelle numerique (text-11 … text-44), jamais l'echelle Tailwind. +scanClasses('type-scale', /(? SCALE.has(m[1])); + +// 6. Couleur par token semantique : ni black/white, ni la palette par defaut de Tailwind. +const PALETTE = + 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'; +const COLOR_UTILS = + 'bg|text|border(?:-[trblxyse])?|ring|ring-offset|outline|fill|stroke|from|via|to|divide|placeholder|caret|accent|decoration|shadow'; +scanClasses( + 'no-raw-palette', + new RegExp(`(? DURATIONS.has(m[1])); + if (failures.length) { console.error(`\n✗ ${failures.length} violation(s) des contraintes du design system\n`); failures.forEach((f) => console.error(' ' + f)); From ed28694462bb73c5105e5bfe34d30b76912759c0 Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Tue, 10 Nov 2026 10:09:48 +0100 Subject: [PATCH 02/10] feat: rebuild the chrome on the v3 sidebar and top bar Ink brand tile, nav counts and alert dot, a signaling panel in the sidebar foot instead of the status strip, and a shared top bar with the time range, uptime pill and refresh. Sample data follows Dashboard UI v3. --- components/dashboard-chrome.tsx | 123 +++++++++----- components/dashboard-parts.tsx | 152 +++++++++++++++++ components/dashboard-top-bar.tsx | 46 ++++++ lib/dashboard-data.ts | 276 ++++++++++++++++++++++++++----- 4 files changed, 516 insertions(+), 81 deletions(-) create mode 100644 components/dashboard-parts.tsx create mode 100644 components/dashboard-top-bar.tsx diff --git a/components/dashboard-chrome.tsx b/components/dashboard-chrome.tsx index 0593942..f5d753e 100644 --- a/components/dashboard-chrome.tsx +++ b/components/dashboard-chrome.tsx @@ -1,90 +1,131 @@ 'use client'; +import * as React from 'react'; import { - LayoutDashboard, + LayoutGrid, RadioTower, Users, Bell, - Gauge, + ChartNoAxesColumn, CirclePlay, - Terminal, Server, - SlidersHorizontal, + Activity, + Settings, } from 'lucide-react'; import { Sidebar, + SidebarBrand, SidebarContent, SidebarFooter, SidebarHeader, SidebarInset, SidebarNav, + SidebarPanel, LumyxLinkProvider, SidebarProvider, - StatusStrip, - Wordmark, + StatusDot, type NavSection, } from '@lumyx/ui'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { INSTANCE } from '@/lib/dashboard-data'; const SECTIONS: NavSection[] = [ { label: 'Live', items: [ - { href: '/', label: 'Overview', icon: LayoutDashboard }, - { href: '/rooms', label: 'Rooms', icon: RadioTower }, - { href: '/peers', label: 'Peers', icon: Users }, - { href: '/alerts', label: 'Alerts', icon: Bell }, + { href: '/', label: 'Overview', icon: LayoutGrid }, + { href: '/rooms', label: 'Rooms', icon: RadioTower, count: 4 }, + { href: '/peers', label: 'Peers', icon: Users, count: 65 }, + { href: '/alerts', label: 'Alerts', icon: Bell, dot: 'danger' }, ], }, { label: 'History', items: [ - { href: '/metrics', label: 'Metrics', icon: Gauge }, + { href: '/metrics', label: 'Metrics', icon: ChartNoAxesColumn }, { href: '/replay', label: 'Session replay', icon: CirclePlay }, - { href: '/signaling', label: 'Signaling', icon: Terminal }, ], }, { - label: 'Instance', + label: 'System', items: [ { href: '/server', label: 'Server', icon: Server }, - { href: '/settings', label: 'Settings', icon: SlidersHorizontal }, + { href: '/signaling', label: 'Signaling', icon: Activity }, + { href: '/settings', label: 'Settings', icon: Settings }, ], }, ]; +/** Rooms owns /rooms/room; every other item matches its own path. */ +const isActive = (href: string, pathname: string) => + href === pathname || (href !== '/' && pathname.startsWith(`${href}/`)); + +export type TimeRange = '15m' | '1h' | '6h' | '24h'; + +type DashboardState = { + range: TimeRange; + setRange: (range: TimeRange) => void; + /** Bumped every 2s and by the refresh button; screens derive their live values from it. */ + tick: number; + refresh: () => void; +}; + +const DashboardContext = React.createContext({ + range: '1h', + setRange: () => {}, + tick: 0, + refresh: () => {}, +}); + +export const useDashboard = () => React.useContext(DashboardContext); + export function DashboardChrome({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const [range, setRange] = React.useState('1h'); + const [tick, setTick] = React.useState(0); + + React.useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 2000); + return () => clearInterval(id); + }, []); + + const value = React.useMemo( + () => ({ range, setRange, tick, refresh: () => setTick((t) => t + 1) }), + [range, tick], + ); + return ( - {}}> - - - - - - - - - - - v0.4.1 · AGPL-3.0 · sfu-eu-3 - - - - {children} - - - + + {}} + style={{ '--sidebar-width': '240px' } as React.CSSProperties} + > + + + + + + + + + + + + + + Signaling connected + + {INSTANCE.signaling} + {INSTANCE.version} · AGPL-3.0 licensed + + + + {children} + + ); } diff --git a/components/dashboard-parts.tsx b/components/dashboard-parts.tsx new file mode 100644 index 0000000..d1fe2d3 --- /dev/null +++ b/components/dashboard-parts.tsx @@ -0,0 +1,152 @@ +'use client'; + +import * as React from 'react'; +import { Search } from 'lucide-react'; +import { Input, cn } from '@lumyx/ui'; +import { scoreTone, type FeedEvent, type Tone } from '@/lib/dashboard-data'; + +/* + * Small pieces the v3 screens repeat that @lumyx/ui does not ship as such. They are composed + * from tokens only — no literal colours. + */ + +export const TONE_TEXT: Record = { + ok: 'text-ok', + warn: 'text-warn', + danger: 'text-danger', + accent: 'text-accent-text', + idle: 'text-faint', + neutral: 'text-strong', +}; + +export const TONE_BG: Record = { + ok: 'bg-ok-solid', + warn: 'bg-warn-solid', + danger: 'bg-danger-solid', + accent: 'bg-accent', + idle: 'bg-idle', + neutral: 'bg-strong', +}; + +/** For SVG strokes and fills. */ +export const TONE_VAR: Record = { + ok: 'var(--ok-solid)', + warn: 'var(--warn-solid)', + danger: 'var(--danger-solid)', + accent: 'var(--accent)', + idle: 'var(--idle)', + neutral: 'var(--text-strong)', +}; + +/** A static 8px dot in a tone (StatusDot breathes on live; most v3 dots do not). */ +export function Dot({ tone, className }: { tone: Tone; className?: string }) { + return ; +} + +/** Quality score: a 48×4 meter and the score in mono, both in the score's tone. */ +export function ScoreBar({ score }: { score: number }) { + const tone = scoreTone(score); + return ( + + + + + {score} + + ); +} + +/** Two-line event feed: message, mono detail underneath, time on the right. */ +export function EventFeed({ events, className }: { events: FeedEvent[]; className?: string }) { + return ( +
    + {events.map((e, i) => ( +
  • + + + {e.message} + {e.detail} + + {e.time} +
  • + ))} +
+ ); +} + +/** A 240px search input with a leading glyph. */ +export function SearchField({ + value, onChange, placeholder, label, className, +}: { value: string; onChange: (v: string) => void; placeholder: string; label: string; className?: string }) { + return ( + + ); +} + +/** An inset tile inside a card: faint label, mono value. */ +export function InsetStat({ + label, value, tone = 'neutral', size = 'md', className, +}: { label: string; value: React.ReactNode; tone?: Tone; size?: 'md' | 'lg'; className?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** A legend swatch for charts whose legend sits in the card header. */ +export function LegendItem({ color, label, dashed = false }: { color: string; label: string; dashed?: boolean }) { + return ( + + + {label} + + ); +} + +/** A labelled 8px meter row: label left, mono value right. */ +export function MeterRow({ label, value, children }: { label: string; value: React.ReactNode; children: React.ReactNode }) { + return ( +
+ + {label} + {value} + + {children} +
+ ); +} + +/** SVG path through `d`, scaled to a W×H box with `max` at the top. */ +export const linePath = (d: number[], max: number, W: number, H: number) => + d + .map((v, i) => `${i ? 'L' : 'M'}${((i * W) / (d.length - 1)).toFixed(1)} ${(H - (Math.min(max, Math.max(0, v)) / max) * H).toFixed(1)}`) + .join(' '); + +/** The same path closed down to the baseline, for a 6–8% area fill. */ +export const areaPath = (d: number[], max: number, W: number, H: number) => `${linePath(d, max, W, H)} L${W} ${H} L0 ${H} Z`; + +/** Mono x-axis labels spread under a chart. */ +export function TimeAxis({ labels }: { labels: string[] }) { + return ( +
+ {labels.map((t) => ( + {t} + ))} +
+ ); +} diff --git a/components/dashboard-top-bar.tsx b/components/dashboard-top-bar.tsx new file mode 100644 index 0000000..66871d9 --- /dev/null +++ b/components/dashboard-top-bar.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { RotateCw } from 'lucide-react'; +import { Badge, Button, SegmentedControl, TopBar, type Crumb } from '@lumyx/ui'; +import { useDashboard, type TimeRange } from '@/components/dashboard-chrome'; +import { INSTANCE } from '@/lib/dashboard-data'; + +const RANGES: { value: TimeRange; label: string }[] = [ + { value: '15m', label: '15m' }, + { value: '1h', label: '1h' }, + { value: '6h', label: '6h' }, + { value: '24h', label: '24h' }, +]; + +export const RANGE_LABEL: Record = { + '15m': '15 minutes', + '1h': 'hour', + '6h': '6 hours', + '24h': '24 hours', +}; + +/** + * Row 1 of every screen: instance › screen breadcrumb, the shared time range, the uptime pill + * and a refresh button. `crumbs` are the crumbs after the instance name. + */ +export function DashboardTopBar({ crumbs }: { crumbs: Crumb[] }) { + const { range, setRange, refresh } = useDashboard(); + return ( + + + + + Up {INSTANCE.uptime} + + + + ); +} diff --git a/lib/dashboard-data.ts b/lib/dashboard-data.ts index 7d24045..f59cf8a 100644 --- a/lib/dashboard-data.ts +++ b/lib/dashboard-data.ts @@ -1,67 +1,116 @@ +/** Sample data for the self-hosted dashboard (Dashboard UI v3). Swap for the SFU's API later. */ + export const series = (n: number, seed: number, base: number, amp: number) => Array.from({ length: n }, (_, i) => - Math.max(0, +(base + amp * Math.sin(i / 2.6 + seed) + amp * 0.45 * Math.sin(i / 7.3 + seed * 2) + - ((i * seed * 17) % (amp * 0.3)) - amp * 0.15).toFixed(2)) + Math.max(0, base + amp * Math.sin(i / 2.6 + seed) + amp * 0.45 * Math.sin(i / 7.3 + seed * 2)), ); -export type Health = "ok" | "degraded" | "idle"; +/** Rotates a series by `k` samples — a cheap way to make a sparkline look live. */ +export const shift = (arr: T[], k: number) => { + const n = ((k % arr.length) + arr.length) % arr.length; + return arr.slice(n).concat(arr.slice(0, n)); +}; + +export const INSTANCE = { + name: 'sfu-eu-3', + region: 'eu-west-3', + signaling: 'wss://127.0.0.1:3000/ws', + version: 'v0.4.1', + uptime: '6d 04h', +}; + +export const TIME_LABELS = ['14:02', '14:17', '14:32', '14:47', '15:02']; + +// ── Tones ──────────────────────────────────────────────────────────────────── + +export type Tone = 'ok' | 'warn' | 'danger' | 'accent' | 'idle' | 'neutral'; + +/** Past `warn` is a warning, past `danger` a breach; under both stays neutral. */ +export const sev = (v: number, warn: number, danger: number): Tone => + v > danger ? 'danger' : v > warn ? 'warn' : 'neutral'; + +export const scoreTone = (score: number): Tone => (score < 50 ? 'danger' : score < 80 ? 'warn' : 'ok'); + +// ── Rooms ──────────────────────────────────────────────────────────────────── + +export type Health = 'ok' | 'degraded' | 'idle'; export type RoomRow = { id: string; peers: number; uptime: string; bitrate: string; region: string; - health: Health; state: "active" | "ended"; started: string; data: string; series: number[]; + health: Health; state: 'active' | 'ended'; data: string; series: number[]; }; export const ROOMS: RoomRow[] = [ - { id: "test-room", peers: 6, uptime: "2h 14m", bitrate: "2.4 Mbps", region: "eu-west-3", health: "ok", state: "active", started: "12:46:02", data: "1.29 GB", series: series(30, 1, 2400, 340) }, - { id: "standup-eu", peers: 4, uptime: "18m", bitrate: "812 kbps", region: "eu-west-3", health: "degraded", state: "active", started: "14:42:11", data: "104 MB", series: series(30, 4, 800, 220) }, - { id: "webinar-us", peers: 47, uptime: "52m", bitrate: "8.1 Mbps", region: "us-east-1", health: "ok", state: "active", started: "14:08:37", data: "3.42 GB", series: series(30, 7, 8100, 900) }, - { id: "pairing-fr", peers: 2, uptime: "4h 02m", bitrate: "640 kbps", region: "eu-west-3", health: "ok", state: "active", started: "10:58:19", data: "812 MB", series: series(30, 2, 640, 120) }, - { id: "load-test-9", peers: 0, uptime: "—", bitrate: "—", region: "eu-west-3", health: "idle", state: "ended", started: "09:14:00", data: "2.10 GB", series: series(30, 9, 40, 30) }, - { id: "demo-call-3", peers: 0, uptime: "31m", bitrate: "—", region: "eu-west-3", health: "idle", state: "ended", started: "08:22:41", data: "286 MB", series: series(30, 5, 60, 40) }, + { id: 'test-room', peers: 6, uptime: '2h 14m', bitrate: '2.4 Mbps', region: 'eu-west-3', health: 'ok', state: 'active', data: '1.29 GB', series: series(30, 1, 2400, 340) }, + { id: 'standup-eu', peers: 4, uptime: '18m', bitrate: '812 kbps', region: 'eu-west-3', health: 'degraded', state: 'active', data: '104 MB', series: series(30, 4, 800, 220) }, + { id: 'webinar-us', peers: 47, uptime: '52m', bitrate: '8.1 Mbps', region: 'us-east-1', health: 'degraded', state: 'active', data: '3.42 GB', series: series(30, 7, 8100, 900) }, + { id: 'pairing-fr', peers: 2, uptime: '4h 02m', bitrate: '640 kbps', region: 'eu-west-3', health: 'ok', state: 'active', data: '812 MB', series: series(30, 2, 640, 120) }, + { id: 'load-test-9', peers: 0, uptime: 'ended', bitrate: 'none', region: 'eu-west-3', health: 'idle', state: 'ended', data: '2.10 GB', series: series(30, 9, 40, 30) }, + { id: 'demo-call-3', peers: 0, uptime: '31m', bitrate: 'none', region: 'eu-west-3', health: 'idle', state: 'ended', data: '286 MB', series: series(30, 5, 60, 40) }, ]; +// ── Peers ──────────────────────────────────────────────────────────────────── + +export type IcePath = 'host' | 'srflx' | 'relay'; + export type PeerRow = { peer_id: string; room: string; score: number; rtt: number; jitter: number; loss: number; - nack: number; freeze: number; codec: string; tracks: string[]; - status: "live" | "degraded"; region: string; series: number[]; + nack: number; codec: string; tracks: string[]; status: 'live' | 'degraded'; region: string; + path: IcePath; series: number[]; }; +/** The six peers of test-room — the room detail's sample. */ export const PEERS: PeerRow[] = [ - { peer_id: "a3f91c02", room: "test-room", score: 96, rtt: 38, jitter: 11, loss: 0.2, nack: 0.9, freeze: 0, codec: "vp8", tracks: ["audio", "video"], status: "live", region: "eu-west-3", series: series(30, 1, 38, 9) }, - { peer_id: "0b8e2f61", room: "test-room", score: 88, rtt: 44, jitter: 14, loss: 0.04, nack: 1.1, freeze: 0, codec: "h264", tracks: ["audio", "video"], status: "live", region: "eu-west-3", series: series(30, 3, 44, 11) }, - { peer_id: "5e7b21f4", room: "test-room", score: 74, rtt: 96, jitter: 22, loss: 0.81, nack: 2.4, freeze: 0.2, codec: "vp8", tracks: ["audio"], status: "live", region: "eu-west-3", series: series(30, 6, 96, 18) }, - { peer_id: "d41f9ab7", room: "test-room", score: 41, rtt: 212, jitter: 38, loss: 3.41, nack: 6.2, freeze: 1.4, codec: "vp8", tracks: ["audio", "video"], status: "degraded", region: "us-east-1", series: series(30, 5, 190, 45) }, - { peer_id: "9c0d34aa", room: "test-room", score: 92, rtt: 41, jitter: 9, loss: 0.11, nack: 0.7, freeze: 0, codec: "vp9", tracks: ["audio", "video"], status: "live", region: "eu-west-3", series: series(30, 8, 41, 8) }, - { peer_id: "2f81be07", room: "test-room", score: 62, rtt: 128, jitter: 31, loss: 1.62, nack: 4.1, freeze: 0.6, codec: "vp8", tracks: ["audio", "video"], status: "degraded", region: "eu-west-3", series: series(30, 10, 130, 30) }, + { peer_id: 'a3f91c02', room: 'test-room', score: 96, rtt: 38, jitter: 11, loss: 0.2, nack: 0.9, codec: 'vp8', tracks: ['audio', 'video'], status: 'live', region: 'eu-west-3', path: 'host', series: series(30, 1, 38, 9) }, + { peer_id: '0b8e2f61', room: 'test-room', score: 88, rtt: 44, jitter: 14, loss: 0.04, nack: 1.1, codec: 'h264', tracks: ['audio', 'video'], status: 'live', region: 'eu-west-3', path: 'host', series: series(30, 3, 44, 11) }, + { peer_id: '5e7b21f4', room: 'test-room', score: 74, rtt: 96, jitter: 22, loss: 0.81, nack: 2.4, codec: 'vp8', tracks: ['audio'], status: 'live', region: 'eu-west-3', path: 'srflx', series: series(30, 6, 96, 18) }, + { peer_id: 'd41f9ab7', room: 'test-room', score: 41, rtt: 212, jitter: 38, loss: 3.41, nack: 6.2, codec: 'vp8', tracks: ['audio', 'video'], status: 'degraded', region: 'us-east-1', path: 'relay', series: series(30, 5, 190, 45) }, + { peer_id: '9c0d34aa', room: 'test-room', score: 92, rtt: 41, jitter: 9, loss: 0.11, nack: 0.7, codec: 'vp9', tracks: ['audio', 'video'], status: 'live', region: 'eu-west-3', path: 'host', series: series(30, 8, 41, 8) }, + { peer_id: '2f81be07', room: 'test-room', score: 62, rtt: 128, jitter: 31, loss: 1.62, nack: 4.1, codec: 'vp8', tracks: ['audio', 'video'], status: 'degraded', region: 'eu-west-3', path: 'srflx', series: series(30, 10, 130, 30) }, ]; +/** The peers the overview ranks worst first. */ export const FLEET_PEERS: PeerRow[] = [ ...PEERS, - { peer_id: "ff104b2c", room: "webinar-us", score: 18, rtt: 284, jitter: 61, loss: 7.9, nack: 11.4, freeze: 4.1, codec: "vp8", tracks: ["audio", "video"], status: "degraded", region: "ap-south-1", series: series(30, 11, 260, 60) }, - { peer_id: "c27ad930", room: "webinar-us", score: 92, rtt: 41, jitter: 9, loss: 0.11, nack: 0.7, freeze: 0, codec: "vp9", tracks: ["audio", "video"], status: "live", region: "eu-west-3", series: series(30, 8, 41, 8) }, + { peer_id: 'ff104b2c', room: 'webinar-us', score: 18, rtt: 284, jitter: 61, loss: 7.9, nack: 11.4, codec: 'vp8', tracks: ['audio', 'video'], status: 'degraded', region: 'ap-south-1', path: 'relay', series: series(30, 11, 260, 60) }, ]; -export const EVENTS = [ - { time: "14:06:41", message: "Packet loss above threshold", detail: "ff104b2c · 7.90% vs 2%", status: "failed" as const }, - { time: "14:06:38", message: "ICE candidate sent", detail: "ff104b2c" }, - { time: "14:06:31", message: "Peer joined", detail: "c27ad930", status: "live" as const }, - { time: "14:06:22", message: "Answer received", detail: "sfu_answer" }, - { time: "14:06:22", message: "Offer sent", detail: "sfu_offer" }, - { time: "14:06:19", message: "Room created", detail: "standup-eu", status: "live" as const }, - { time: "14:06:11", message: "Signaling connected", detail: "wss://127.0.0.1:3000", status: "live" as const }, - { time: "14:05:47", message: "ICE failed", detail: "ff104b2c", status: "failed" as const }, - { time: "14:05:12", message: "Peer left", detail: "71c0e5da · 1006" }, +/** Every listed peer on the instance — the Peers screen. */ +export const ALL_PEERS: PeerRow[] = [ + ...FLEET_PEERS, + { peer_id: 'c27ad930', room: 'webinar-us', score: 92, rtt: 41, jitter: 9, loss: 0.11, nack: 0.7, codec: 'vp9', tracks: ['audio', 'video'], status: 'live', region: 'eu-west-3', path: 'host', series: series(30, 8, 41, 8) }, + { peer_id: '7e01d4aa', room: 'webinar-us', score: 84, rtt: 52, jitter: 12, loss: 0.3, nack: 1.2, codec: 'vp8', tracks: ['audio', 'video'], status: 'live', region: 'us-east-1', path: 'srflx', series: series(30, 12, 52, 10) }, + { peer_id: '5b9c3e10', room: 'standup-eu', score: 58, rtt: 168, jitter: 24, loss: 1.4, nack: 3.2, codec: 'vp8', tracks: ['audio', 'video'], status: 'degraded', region: 'eu-west-3', path: 'srflx', series: series(30, 13, 160, 25) }, + { peer_id: '91da0f67', room: 'standup-eu', score: 95, rtt: 44, jitter: 5, loss: 0.1, nack: 0.4, codec: 'vp9', tracks: ['audio', 'video'], status: 'live', region: 'eu-west-3', path: 'host', series: series(30, 14, 44, 6) }, + { peer_id: '3ac5e219', room: 'pairing-fr', score: 97, rtt: 22, jitter: 4, loss: 0.05, nack: 0.2, codec: 'h264', tracks: ['audio', 'video', 'screen'], status: 'live', region: 'eu-west-3', path: 'host', series: series(30, 15, 22, 4) }, + { peer_id: 'e8f0124b', room: 'pairing-fr', score: 93, rtt: 29, jitter: 6, loss: 0.08, nack: 0.3, codec: 'h264', tracks: ['audio', 'video'], status: 'live', region: 'eu-west-3', path: 'host', series: series(30, 16, 29, 5) }, ]; -export const ROOM_EVENTS = [ - { time: "14:06:44", message: "Jitter above threshold", detail: "d41f9ab7 · 38ms vs 30ms", status: "failed" as const }, - { time: "14:05:58", message: "Keyframe requested", detail: "2f81be07" }, - { time: "14:04:20", message: "Track published", detail: "5e7b21f4 · video", status: "live" as const }, - { time: "14:02:07", message: "Peer joined", detail: "9c0d34aa", status: "live" as const }, - { time: "13:59:41", message: "Answer received", detail: "sfu_answer" }, - { time: "12:46:02", message: "Room created", detail: "test-room", status: "live" as const }, +// ── Events ─────────────────────────────────────────────────────────────────── + +export type FeedEvent = { time: string; message: string; detail: string; tone: Tone }; + +export const EVENTS: FeedEvent[] = [ + { time: '14:06:41', message: 'Packet loss above threshold', detail: 'ff104b2c · 7.90% vs 2%', tone: 'danger' }, + { time: '14:06:38', message: 'ICE candidate sent', detail: 'ff104b2c', tone: 'accent' }, + { time: '14:06:31', message: 'Peer joined', detail: 'c27ad930', tone: 'ok' }, + { time: '14:06:22', message: 'Answer received', detail: 'sfu_answer', tone: 'idle' }, + { time: '14:06:19', message: 'Room created', detail: 'standup-eu', tone: 'ok' }, + { time: '14:05:47', message: 'ICE failed', detail: 'ff104b2c', tone: 'danger' }, + { time: '14:05:12', message: 'Peer left', detail: '71c0e5da · 1006', tone: 'idle' }, +]; + +export const ROOM_EVENTS: FeedEvent[] = [ + { time: '14:06:44', message: 'Jitter above threshold', detail: 'd41f9ab7 · 38ms vs 30ms', tone: 'danger' }, + { time: '14:05:58', message: 'Keyframe requested', detail: '2f81be07', tone: 'accent' }, + { time: '14:04:20', message: 'Track published', detail: '5e7b21f4 · video', tone: 'ok' }, + { time: '14:02:07', message: 'Peer joined', detail: '9c0d34aa', tone: 'ok' }, + { time: '13:59:41', message: 'Answer received', detail: 'sfu_answer', tone: 'idle' }, + { time: '12:46:02', message: 'Room created', detail: 'test-room', tone: 'ok' }, ]; +// ── Topology ───────────────────────────────────────────────────────────────── + /** Sender × receiver loss health. null is the diagonal. */ export const MATRIX: (number | null)[][] = [ [null, 0.9, 0.8, 0.4, 0.9, 0.6], @@ -72,7 +121,154 @@ export const MATRIX: (number | null)[][] = [ [0.7, 0.6, 0.7, 0.2, 0.7, null], ]; -export const TIME_LABELS = ["14:02", "14:17", "14:32", "14:47", "15:02"]; +/** Node positions in the topology graph, in % of its box. */ +export const GRAPH_POS: [number, number][] = [[16, 22], [14, 78], [84, 20], [88, 62], [62, 86], [34, 88]]; + +// ── Alerts ─────────────────────────────────────────────────────────────────── + +export type AlertRow = { + id: string; title: string; metric: string; peer: string; room: string; value: string; th: string; + v: number; t: number; max: number; dur: string; opened: string; sev: 'critical' | 'warning'; + open: boolean; action: string; +}; -export const qualityOf = (score: number) => - score >= 85 ? "excellent" : score >= 70 ? "good" : score >= 40 ? "degraded" : "poor"; +export const ALERTS: AlertRow[] = [ + { id: 'a1', title: 'Packet loss above 2%', metric: 'packet_loss_ratio', peer: 'ff104b2c', room: 'webinar-us', value: '7.90%', th: '2%', v: 7.9, t: 2, max: 10, dur: '3m 12s', opened: '14:03:29', sev: 'critical', open: true, action: 'The peer fell back to a TURN relay after ICE failed. Force audio only for ff104b2c, or drop it to the lowest simulcast layer.' }, + { id: 'a2', title: 'Freeze ratio above 1%', metric: 'freeze_ratio', peer: 'ff104b2c', room: 'webinar-us', value: '4.10%', th: '1%', v: 4.1, t: 1, max: 6, dur: '2m 40s', opened: '14:04:01', sev: 'critical', open: true, action: 'Freezes follow the loss on the same peer. Fix the loss first; a keyframe request alone will not hold.' }, + { id: 'a3', title: 'Jitter above 30 ms', metric: 'jitter_ms', peer: 'd41f9ab7', room: 'test-room', value: '38 ms', th: '30 ms', v: 38, t: 30, max: 60, dur: '1m 05s', opened: '14:05:39', sev: 'warning', open: true, action: 'Usually a path problem, not bandwidth. Compare with loss before touching bitrate.' }, + { id: 'a4', title: 'NACK ratio above 5%', metric: 'nack_ratio', peer: 'd41f9ab7', room: 'test-room', value: '6.2%', th: '5%', v: 6.2, t: 5, max: 10, dur: '48s', opened: '14:05:56', sev: 'warning', open: true, action: 'The uplink is saturating. Lower the publisher target bitrate or turn off its top simulcast layer.' }, + { id: 'a5', title: 'Round trip above 200 ms', metric: 'rtt_ms', peer: '5b9c3e10', room: 'standup-eu', value: '168 ms', th: '200 ms', v: 168, t: 200, max: 320, dur: '4m 10s', opened: '13:57:12', sev: 'warning', open: false, action: 'Resolved on its own when the peer moved back to a direct path.' }, + { id: 'a6', title: 'Bitrate under 100 kbps', metric: 'bitrate_kbps', peer: '71c0e5da', room: 'standup-eu', value: '412 kbps', th: '100 kbps', v: 412, t: 100, max: 800, dur: '2m 02s', opened: '13:31:40', sev: 'warning', open: false, action: 'Resolved after the publisher restarted its encoder.' }, +]; + +// ── Metrics ────────────────────────────────────────────────────────────────── + +export type MetricDef = { + field: string; name: string; unit: string; th: number; base: number; amp: number; + spike: number; max: number; dp: number; +}; + +export const METRICS: MetricDef[] = [ + { field: 'packet_loss_ratio', name: 'Packet loss', unit: '%', th: 2, base: 0.6, amp: 0.5, spike: 5, max: 9, dp: 2 }, + { field: 'rtt_ms', name: 'Round trip', unit: ' ms', th: 200, base: 70, amp: 20, spike: 140, max: 320, dp: 0 }, + { field: 'jitter_ms', name: 'Jitter', unit: ' ms', th: 30, base: 12, amp: 4, spike: 26, max: 60, dp: 0 }, + { field: 'nack_ratio', name: 'NACK ratio', unit: '%', th: 5, base: 1.2, amp: 0.5, spike: 5, max: 12, dp: 1 }, + { field: 'freeze_ratio', name: 'Freeze ratio', unit: '%', th: 1, base: 0.1, amp: 0.08, spike: 2.4, max: 5, dp: 2 }, + { field: 'bitrate_kbps', name: 'Bitrate floor', unit: ' kbps', th: 100, base: 1800, amp: 300, spike: -1500, max: 2800, dp: 0 }, +]; + +// ── Session replay ─────────────────────────────────────────────────────────── + +export type Session = { id: string; room: string; when: string; len: number; note: string; tone: Tone }; + +export const SESSIONS: Session[] = [ + { id: 's1', room: 'webinar-us', when: 'Today 14:03', len: 180, note: 'freeze on ff104b2c', tone: 'danger' }, + { id: 's2', room: 'test-room', when: 'Today 13:58', len: 150, note: 'jitter on d41f9ab7', tone: 'warn' }, + { id: 's3', room: 'standup-eu', when: 'Today 09:30', len: 120, note: 'clean', tone: 'ok' }, + { id: 's4', room: 'cohort-42', when: 'Yesterday 17:04', len: 160, note: 'one reconnect', tone: 'warn' }, +]; + +/** Replay events, `at` in seconds from the start of the session. */ +export const REPLAY_EVENTS: { at: number; label: string; detail: string; tone: Tone }[] = [ + { at: 12, label: 'Peer joined', detail: 'ff104b2c', tone: 'ok' }, + { at: 47, label: 'ICE failed on direct path', detail: 'ff104b2c · host', tone: 'danger' }, + { at: 62, label: 'Fell back to TURN relay', detail: 'ff104b2c · relay', tone: 'warn' }, + { at: 89, label: 'Packet loss above 2%', detail: 'ff104b2c · 7.9%', tone: 'danger' }, + { at: 101, label: 'Freeze detected', detail: 'ff104b2c · 5 receivers', tone: 'danger' }, + { at: 103, label: 'Keyframe requested', detail: 'PLI to ff104b2c', tone: 'accent' }, + { at: 140, label: 'Audio only forced', detail: 'ff104b2c', tone: 'accent' }, + { at: 151, label: 'Loss back under 2%', detail: 'ff104b2c · 0.8%', tone: 'ok' }, +]; + +export const REPLAY_PEERS = ['ff104b2c', 'c27ad930', '7e01d4aa', 'a3f91c02', '9c0d34aa', '0b8e2f61']; + +/** Packet loss (%) of ff104b2c at second `x` of the replayed session. */ +export const replayLossAt = (x: number) => + x < 62 ? 0.4 + 0.2 * Math.sin(x) + : x < 89 ? 0.6 + (x - 62) * 0.08 + : x < 140 ? 7 + Math.sin(x / 3) + : Math.max(0.6, 7 - (x - 140) * 0.6); + +const pad2 = (n: number) => String(n).padStart(2, '0'); +export const clock = (x: number) => `${pad2(Math.floor(x / 60))}:${pad2(Math.floor(x % 60))}`; + +// ── Signaling ──────────────────────────────────────────────────────────────── + +export type SignalType = 'offer' | 'answer' | 'ice' | 'join' | 'leave' | 'error'; + +export const SIGNAL_TYPES: [SignalType, '↑' | '↓', string][] = [ + ['offer', '↑', 'sdp v=0 · 2 m-lines · simulcast 3 layers'], + ['answer', '↓', 'sdp v=0 · a=group:BUNDLE 0 1'], + ['ice', '↑', 'candidate:1 udp 2122260223 10.0.4.19 54400 typ host'], + ['ice', '↓', 'candidate:3 udp 41885439 52.47.12.8 3478 typ relay'], + ['join', '↑', '{"room":"webinar-us","token":"eyJhbGciOi…"}'], + ['leave', '↑', '{"code":1000,"reason":"client closed"}'], + ['error', '↓', '{"code":4003,"reason":"token expired"}'], +]; + +export const SIGNAL_PEERS = ['ff104b2c', 'c27ad930', '7e01d4aa', 'a3f91c02', '5b9c3e10', '3ac5e219', 'd41f9ab7']; + +/** A deterministic slice of the signaling stream at tick `k`, newest first. */ +export const signalLog = (k: number, n = 40) => + Array.from({ length: n }, (_, j) => { + const i = k * 3 + n - j; + const hx = Math.abs(Math.sin(i * 12.9898) * 43758.5453) % 1; + const ty = SIGNAL_TYPES[hx < 0.22 ? 0 : hx < 0.42 ? 1 : hx < 0.62 ? 2 : hx < 0.8 ? 3 : hx < 0.88 ? 4 : hx < 0.96 ? 5 : 6]; + const sec = 41 + Math.floor(i * 0.7); + return { + key: i, + type: ty[0], dir: ty[1], body: ty[2], + peer: SIGNAL_PEERS[(i * 5) % SIGNAL_PEERS.length], + time: `14:${pad2(6 + (Math.floor(sec / 60) % 50))}:${pad2(sec % 60)}`, + }; + }); + +// ── Server & settings ──────────────────────────────────────────────────────── + +export const PROCESS_INFO: [string, string][] = [ + ['Version', 'sfu-core 0.4.1 (alpha)'], + ['Build', 'a91f3c2 · 2026-10-28'], + ['Toolchain', 'rustc 1.79.0'], + ['Started', '2026-10-31 10:52:07 UTC'], + ['Uptime', '6d 04h 11m'], + ['Workers', '8 · one per core'], + ['Signaling', 'ws://0.0.0.0:3000/ws'], + ['Config', '/etc/lumyx/lumyx.toml'], +]; + +export type Config = { + name: string; region: string; + th: Record<'packet_loss_ratio' | 'rtt_ms' | 'jitter_ms' | 'nack_ratio' | 'freeze_ratio' | 'debounce', string>; + slack: boolean; pager: boolean; audio: boolean; retention: '24h' | '7d' | '30d'; +}; + +export const DEFAULT_CONFIG: Config = { + name: 'sfu-eu-3', region: 'eu-west-3', + th: { packet_loss_ratio: '2', rtt_ms: '200', jitter_ms: '30', nack_ratio: '5', freeze_ratio: '1', debounce: '30' }, + slack: true, pager: false, audio: true, retention: '7d', +}; + +/** Threshold fields: key, label, unit, min, max. */ +export const THRESHOLD_FIELDS: [keyof Config['th'], string, string, number, number][] = [ + ['packet_loss_ratio', 'Packet loss', '%', 0, 100], + ['rtt_ms', 'Round trip', 'ms', 1, 5000], + ['jitter_ms', 'Jitter', 'ms', 1, 1000], + ['nack_ratio', 'NACK ratio', '%', 0, 100], + ['freeze_ratio', 'Freeze ratio', '%', 0, 100], + ['debounce', 'Debounce', 's', 5, 3600], +]; + +/** lumyx.toml as the server read it. */ +export const tomlLines = (cfg: Config) => [ + '[server]', + `name = "${cfg.name}"`, + `region = "${cfg.region}"`, + 'listen = "0.0.0.0:3000"', + '', + '[thresholds]', + ...THRESHOLD_FIELDS.slice(0, 5).map(([k]) => `${k} = ${cfg.th[k]}`), + `debounce = "${cfg.th.debounce}s"`, + '', + '[retention]', + `metrics = "${cfg.retention}"`, +]; From 79a2bb5b6b895c19328f1c8c283da4690bd0c97a Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Tue, 10 Nov 2026 10:09:48 +0100 Subject: [PATCH 03/10] feat: restyle overview, rooms and room detail on v3 --- app/page.tsx | 296 ++++++++++++++------------ app/rooms/page.tsx | 152 ++++++------- app/rooms/room/page.tsx | 461 ++++++++++++++++++++-------------------- 3 files changed, 471 insertions(+), 438 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 67a68ea..35e24c0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,152 +1,184 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useRouter } from "next/navigation"; +import * as React from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { - AppHeader, PageBody, - Card, CardHeader, CardTitle, CardContent, CardDescription, Badge, Progress, Table, TableBody, - TableCell, TableHead, TableHeader, TableRow, MetricCard, MetricGrid, TimeSeriesChart, - EventList, AlertBanner, StatusDot, QualityIndicator, LatencyChip, Sparkline, RoomCard, + AlertBanner, Button, Card, CardHeader, CardTitle, CardDescription, CardContent, MetricCard, MetricGrid, + PageBody, PageHeader, Progress, Sparkline, StatusDot, Table, TableBody, TableCell, TableHead, + TableHeader, TableRow, } from '@lumyx/ui'; -import { ROOMS, FLEET_PEERS, EVENTS, TIME_LABELS, series, qualityOf } from "@/lib/dashboard-data"; +import { useDashboard } from '@/components/dashboard-chrome'; +import { DashboardTopBar, RANGE_LABEL } from '@/components/dashboard-top-bar'; +import { + Dot, EventFeed, LegendItem, MeterRow, ScoreBar, TONE_TEXT, TONE_VAR, TimeAxis, areaPath, linePath, +} from '@/components/dashboard-parts'; +import { EVENTS, FLEET_PEERS, ROOMS, TIME_LABELS, scoreTone, series, sev, shift } from '@/lib/dashboard-data'; + +const BW_MAX = 16; export default function OverviewPage() { const router = useRouter(); - const [alert, setAlert] = useState(true); - const worst = [...FLEET_PEERS].sort((a, b) => a.score - b.score); + const { range, tick } = useDashboard(); + const [alert, setAlert] = React.useState(true); + + const bwOut = series(48, 2 + tick * 0.15, 11, 2.2); + const bwIn = series(48, 5 + tick * 0.15, 3.1, 0.8); + const worst = [...FLEET_PEERS].sort((a, b) => a.score - b.score).slice(0, 6); + const active = ROOMS.filter((r) => r.state === 'active'); return ( <> - + -
- {alert ? ( - setAlert(false)} - /> - ) : null} + - - - Server - last 60 min - - - - - - - - - + {alert ? ( + router.push('/rooms/room?id=webinar-us')} + onDismiss={() => setAlert(false)} + dismissLabel="Dismiss alert" + /> + ) : null} -
-
- - - Bandwidth - last 60 min · outbound vs inbound - - - - - - - - - Peers - {worst.length} of 65 · worst first - - - - - PeerRoomQuality - RttJitter - LossCodec60s - - - - {worst.map((p) => ( - router.push(`/rooms/room?id=${p.room}&peer=${p.peer_id}`)}> - - - {p.peer_id} - - - {p.room} - - - {p.jitter}ms - = 2 ? "text-danger" : "text-muted"}`}>{p.loss.toFixed(2)}% - {p.codec} - - - - - ))} - -
-
-
+ + + + + + + -
- - - Active rooms - 4 - -
- {ROOMS.filter((r) => r.state === "active").map((r) => ( - - ))} +
+
+ + + Bandwidth + + + + + +
+ + + + + + + 16 Mbps
-
+ + + - - Capacitythis instance - - {[ - { label: "Rooms", value: 12.5, right: "4 / 32" }, - { label: "Uplink", value: 41, right: "41%" }, - { label: "Metrics retention", value: 82, right: "82%", tone: "bg-accent2" }, - ].map((b) => ( -
-
- {b.label} - {b.right} -
- -
+ + + Peers + 6 of 65 · worst first + + + + + + + Peer + Room + Quality + RTT + Jitter + Loss + 60s + + + + {worst.map((p) => ( + router.push(`/rooms/room?id=${p.room}&peer=${p.peer_id}`)} + > + + + + {p.peer_id} + + + {p.room} + + {p.rtt} + {p.jitter} + {p.loss.toFixed(2)}% + + + + ))} - - + +
+
+
+ +
+ +
+ Active rooms + {active.length} +
+ {active.map((r) => { + const tone = r.health === 'ok' ? 'ok' : 'warn'; + return ( + + + + {r.id} + + + {r.peers} peers · {r.uptime} · {r.region} + {r.bitrate} + + ); + })} +
+ + + Capacity + + + + + + - - Recent eventslive - - -
+ + + Recent events + + + + + +
diff --git a/app/rooms/page.tsx b/app/rooms/page.tsx index cf57db6..f5aa41e 100644 --- a/app/rooms/page.tsx +++ b/app/rooms/page.tsx @@ -1,92 +1,96 @@ -"use client"; -import * as React from "react"; -import { useRouter } from "next/navigation"; -import { AppHeader, PageBody, Card, CardHeader, CardTitle, CardDescription, Badge, Input, Tabs, TabsList, TabsTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, StatusDot, EmptyState } from '@lumyx/ui'; -import { RadioTower } from "lucide-react"; -import { ROOMS } from "@/lib/dashboard-data"; +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { + Badge, Card, EmptyState, PageBody, PageHeader, SegmentedControl, Sparkline, Table, TableBody, TableCell, + TableHead, TableHeader, TableRow, +} from '@lumyx/ui'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { Dot, SearchField, TONE_VAR } from '@/components/dashboard-parts'; +import { ROOMS } from '@/lib/dashboard-data'; + +type Filter = 'active' | 'ended' | 'all'; + +const FILTERS: { value: Filter; label: string }[] = [ + { value: 'active', label: 'Active' }, + { value: 'ended', label: 'Ended' }, + { value: 'all', label: 'All' }, +]; export default function RoomsPage() { const router = useRouter(); - const [filter, setFilter] = React.useState<"active" | "ended" | "all">("active"); - const [q, setQ] = React.useState(""); + const [filter, setFilter] = React.useState('active'); + const [q, setQ] = React.useState(''); - const rows = ROOMS - .filter((r) => (filter === "all" ? true : r.state === filter)) - .filter((r) => r.id.includes(q.trim())); + const query = q.trim(); + const rows = ROOMS.filter((r) => (filter === 'all' || r.state === filter) && (!query || r.id.includes(query))); return ( <> - + -
-
- - setFilter(v as typeof filter)}> - - Active - Ended - All - - - - setQ(e.target.value)} /> -
+ + + + + } + /> - - - {filter === "ended" ? "Ended rooms" : filter === "all" ? "All rooms" : "Active rooms"} - {rows.length} of {ROOMS.length} - - {rows.length ? ( - - - - room_id - Participants - Duration - Bandwidth - Data - Started - Region - Status - - - - {rows.map((r) => ( + + {rows.length ? ( +
+ + + room_id + Participants + Duration + Bandwidth + Data + Region + Trend + Status + + + + {rows.map((r) => { + const tone = r.health === 'idle' ? 'idle' : r.health === 'ok' ? 'ok' : 'warn'; + return ( router.push(`/rooms/room?id=${r.id}`)}> - + - + {r.id} - {r.peers} - {r.uptime} - {r.bitrate} - {r.data} - {r.started} - {r.region} - {r.state === "active" ? "Active" : "Ended"} + {r.peers} + {r.uptime} + {r.bitrate} + {r.data} + {r.region} + + + + + {r.state === 'active' ? 'Active' : 'Ended'} + - ))} - -
- ) : ( - - )} -
-
+ ); + })} + + + ) : ( + + )} +
); diff --git a/app/rooms/room/page.tsx b/app/rooms/room/page.tsx index 72c2dc9..2bfb50a 100644 --- a/app/rooms/room/page.tsx +++ b/app/rooms/room/page.tsx @@ -1,279 +1,276 @@ -"use client"; +'use client'; -import * as React from "react"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import * as React from 'react'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { - AppHeader, PageBody, - Card, CardHeader, CardTitle, CardContent, CardDescription, Badge, Button, Progress, Tabs, - TabsList, TabsTrigger, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, - MetricCard, MetricGrid, TimeSeriesChart, EventList, StatusDot, QualityIndicator, LatencyChip, - PeerCard, + Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, MetricCard, PageBody, + SegmentedControl, StatusDot, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn, } from '@lumyx/ui'; -import { PEERS, ROOM_EVENTS, MATRIX, TIME_LABELS, series, qualityOf } from "@/lib/dashboard-data"; +import { useDashboard } from '@/components/dashboard-chrome'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { + EventFeed, InsetStat, ScoreBar, TONE_TEXT, TONE_VAR, areaPath, linePath, +} from '@/components/dashboard-parts'; +import { + GRAPH_POS, MATRIX, PEERS, ROOMS, ROOM_EVENTS, scoreTone, sev, shift, type Tone, +} from '@/lib/dashboard-data'; + +type Topo = 'graph' | 'matrix'; + +const TOPO: { value: Topo; label: string }[] = [ + { value: 'graph', label: 'Graph' }, + { value: 'matrix', label: 'Matrix' }, +]; function RoomDetail() { - const roomId = useSearchParams().get("id") ?? "test-room"; - const [sel, setSel] = React.useState(3); - const [topo, setTopo] = React.useState<"graph" | "matrix">("graph"); - const peer = PEERS[sel]; + const params = useSearchParams(); + const room = ROOMS.find((r) => r.id === params.get('id')) ?? ROOMS[0]; + const fromParam = PEERS.findIndex((p) => p.peer_id === params.get('peer')); + const [sel, setSel] = React.useState(fromParam >= 0 ? fromParam : 3); + const [topo, setTopo] = React.useState('graph'); return ( <> - - - {roomId} - eu-west-3 - - } - meta={Up 2h 14m · 6 peers · 2.4 Mbps} - actions={ - <> - - - - } - /> + -
- - - Peers - Media - Signaling - - - -
-
- - Bandwidthlast 30 min - - - - - - - - Topology - {topo === "graph" ? "who forwards to whom" : "sender × receiver"} - - -
- setTopo(v as typeof topo)}> - - Node-link - Matrix - - - - Stroke width = bitrate -
+
+ +

{room.id}

+ {room.region} + Up {room.uptime} · {room.peers} peers · {room.bitrate} + + + +
- {topo === "graph" ? ( - <> -
-

- Dashed edge = relay path, ICE degraded. Ring = selected peer. Hover an edge for its bitrate, loss and NACK ratio. -

- - ) : ( - <> - -

- Rows are senders, columns receivers. A pale row isolates the faulty uplink at a glance, at constant - density whatever the peer count. -

- - )} -
-
+
+
+ + + Topology + {PEERS.length} peers · 1 relay path + + + + + {topo === 'graph' ? ( + <> + +

Stroke width is bitrate. Dashed edge is a relay path. Click a peer to inspect it.

+ + ) : ( + <> + +

Rows are senders, columns receivers. A pale row isolates the faulty uplink at a glance.

+ + )} +
+
- - PeersClick a row to inspect - - - - PeerQuality - RttJitter - LossNack - CodecTracks + + + Peers + Click a row to inspect + +
+ + + Peer + Quality + RTT + Jitter + Loss + NACK + Codec + + + + {PEERS.map((p, i) => ( + setSel(i)}> + + + + {p.peer_id} + + + + {p.rtt} + {p.jitter} + {p.loss.toFixed(2)}% + {p.nack.toFixed(1)}% + {p.codec} - - - {PEERS.map((p, i) => ( - setSel(i)}> - - - {p.peer_id} - - - - - {p.jitter}ms - = 2 ? "text-danger" : "text-muted"}`}>{p.loss.toFixed(2)}% - = 5 ? "text-danger" : "text-muted"}`}>{p.nack.toFixed(2)}% - {p.codec} - {p.tracks.join(", ")} - - ))} - -
-
-
- -
- - Room - - - - - - - - - - - Selected peer - {peer.peer_id} - - - -
-
- Quality score - {peer.score} -
- -
-
-
+ ))} + + + +
- - Room timelinelive - - +
+
+ + + +
+ + + + Room timeline + + + + + +
- - ← All rooms
); } -const NODES = [ - { id: "a3f91c02", x: 120, y: 62, w: 3, tone: "" }, - { id: "0b8e2f61", x: 500, y: 62, w: 2, tone: "" }, - { id: "5e7b21f4", x: 80, y: 206, w: 4, tone: "" }, - { id: "d41f9ab7", x: 540, y: 206, w: 2, tone: "danger" }, - { id: "9c0d34aa", x: 230, y: 264, w: 2, tone: "" }, - { id: "2f81be07", x: 400, y: 264, w: 3, tone: "warn" }, -]; - -function NodeLink({ selected }: { selected: string }) { +function TopologyGraph({ selected, onSelect }: { selected: number; onSelect: (i: number) => void }) { return ( - - - {NODES.map((n) => ( - - ))} - - - SFU - forwarding · 6 - - {NODES.map((n) => { - const isSel = n.id === selected; +
+ + {PEERS.map((p, i) => { + const [x, y] = GRAPH_POS[i]; + const stroke = p.status === 'degraded' ? (p.path === 'relay' ? 'var(--danger-solid)' : 'var(--warn-solid)') : 'var(--border-hover)'; return ( - - - {n.id} - + ); })} - - + + + sfu-core + + {PEERS.map((p, i) => { + const [x, y] = GRAPH_POS[i]; + const ring = TONE_VAR[scoreTone(p.score)]; + return ( + + ); + })} +
); } -const cellStyle = (v: number | null) => - v === null - ? { bg: "repeating-linear-gradient(45deg,var(--n-150),var(--n-150) 4px,transparent 4px,transparent 8px)", border: "var(--border-strong)", title: "self" } - : v >= 0.75 ? { bg: "var(--ok-tint)", border: "var(--ok-solid)", title: "healthy · loss under 2%" } - : v >= 0.5 ? { bg: "var(--warn-tint)", border: "var(--warn-solid)", title: "approaching threshold" } - : { bg: "var(--danger-tint)", border: "var(--danger-solid)", title: "breached · loss above 2%" }; +const CELL: Record<'self' | 'ok' | 'warn' | 'danger', { cls: string; title: string }> = { + self: { cls: 'bg-hover border-hairline', title: 'self' }, + ok: { cls: 'bg-ok-tint border-ok-border', title: 'healthy' }, + warn: { cls: 'bg-warn-tint border-warn-border', title: 'approaching threshold' }, + danger: { cls: 'bg-danger-tint border-danger-border', title: 'breached' }, +}; -function Matrix() { +function TopologyMatrix() { return ( -
-
+
+
{PEERS.map((p) => ( - {p.peer_id} + {p.peer_id.slice(0, 4)} ))} - {PEERS.map((p, i) => ( + {PEERS.map((p, r) => ( - {p.peer_id} - {MATRIX[i].map((v, j) => { - const s = cellStyle(v); + {p.peer_id} + {MATRIX[r].map((v, c) => { + const cell = CELL[v === null ? 'self' : v >= 0.75 ? 'ok' : v >= 0.5 ? 'warn' : 'danger']; return ( ); })} ))}
-
- {[ - ["healthy", "var(--ok-tint)", "var(--ok-solid)"], - ["approaching", "var(--warn-tint)", "var(--warn-solid)"], - ["breached", "var(--danger-tint)", "var(--danger-solid)"], - ].map(([label, bg, bd]) => ( - - - {label} +
+ ); +} + +const RING = 2 * Math.PI * 24; + +function SelectedPeer({ index }: { index: number }) { + const { tick } = useDashboard(); + const p = PEERS[index] ?? PEERS[0]; + const tone = scoreTone(p.score); + const color = TONE_VAR[tone]; + const rtt = shift(p.series, tick % 6); + const max = Math.max(...rtt) * 1.2; + const stats: { k: string; v: string; tone: Tone }[] = [ + { k: 'RTT', v: `${p.rtt} ms`, tone: sev(p.rtt, 150, 200) }, + { k: 'Jitter', v: `${p.jitter} ms`, tone: sev(p.jitter, 20, 30) }, + { k: 'Loss', v: `${p.loss.toFixed(2)}%`, tone: sev(p.loss, 1, 2) }, + { k: 'NACK', v: `${p.nack.toFixed(1)}%`, tone: sev(p.nack, 3, 5) }, + ]; + + return ( + +
+ + + + + + + {p.score} - ))} - - - self + + {p.peer_id} + {p.region} · {p.codec} · {p.tracks.join(' + ')} + + {p.status === 'live' ? 'Live' : 'Degraded'}
-
+
+ {stats.map((s) => )} +
+
+ RTT · last 60s + + + + +
+ ); } @@ -282,7 +279,7 @@ export default function RoomDetailPage() { - + {null} } From 4c7e56c9a443fe655066c1633f4c51c195b41e3d Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Tue, 10 Nov 2026 10:10:46 +0100 Subject: [PATCH 04/10] feat: build the peers and alerts screens --- app/alerts/page.tsx | 144 ++++++++++++++++++++++++++++++++++--- app/peers/page.tsx | 168 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 291 insertions(+), 21 deletions(-) diff --git a/app/alerts/page.tsx b/app/alerts/page.tsx index fc5a2fc..a1e07c9 100644 --- a/app/alerts/page.tsx +++ b/app/alerts/page.tsx @@ -1,18 +1,140 @@ -import { AppHeader, Card, EmptyState, PageBody } from '@lumyx/ui'; -import { Bell } from "lucide-react"; +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { AlertTriangle } from 'lucide-react'; +import { Badge, Button, Card, EmptyState, PageBody, PageHeader, SegmentedControl, cn } from '@lumyx/ui'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { Dot, InsetStat, TONE_VAR, areaPath, linePath } from '@/components/dashboard-parts'; +import { ALERTS, type AlertRow, type Tone } from '@/lib/dashboard-data'; + +type Tab = 'open' | 'resolved'; + +const TABS: { value: Tab; label: string }[] = [ + { value: 'open', label: `Open · ${ALERTS.filter((a) => a.open).length}` }, + { value: 'resolved', label: 'Resolved' }, +]; + +const alertTone = (a: AlertRow): Tone => (!a.open ? 'ok' : a.sev === 'critical' ? 'danger' : 'warn'); + +/** The metric over the last 10 minutes: under threshold, then crossing it (or back under once resolved). */ +function curve(a: AlertRow) { + return Array.from({ length: 40 }, (_, i) => { + const r = i / 39; + const v = a.open + ? a.t * 0.5 + (a.v - a.t * 0.5) * Math.min(1, Math.max(0, (r - 0.45) * 3)) + : a.v * (r < 0.6 ? 1 : 0.4); + return Math.max(0, v * (1 + 0.06 * Math.sin(i * 1.7))); + }); +} + +export default function AlertsPage() { + const router = useRouter(); + const [tab, setTab] = React.useState('open'); + const [selected, setSelected] = React.useState('a1'); + const [acked, setAcked] = React.useState>({}); + + const list = ALERTS.filter((a) => (tab === 'open' ? a.open : !a.open)); + const ad = list.find((a) => a.id === selected) ?? list[0] ?? ALERTS[0]; + const tone = alertTone(ad); + const color = TONE_VAR[tone]; + const data = curve(ad); + // The chart keeps 4% headroom over the metric's max, as the design does. + const max = ad.max / 0.96; + const thY = (100 - (Math.min(1, ad.t / ad.max) * 96)).toFixed(1); -export default function Page() { return ( <> - + - - - + } + /> + +
+ + {list.map((a) => { + const isSel = a.id === ad.id; + const state = !a.open ? 'Resolved' : acked[a.id] ? 'Acknowledged' : 'Open'; + return ( + + ); + })} + {list.length === 0 ? ( + + ) : null} + + + +
+ + + + + {ad.title} + {ad.metric} · opened {ad.opened} + + + {!ad.open ? 'Resolved' : ad.sev === 'critical' ? 'Critical' : 'Warning'} + +
+ +
+ + + +
+ +
+ {ad.metric} · last 10 min + + + + + +
+ +
+ Suggested action +

{ad.action}

+
+ +
+ + {ad.open ? ( + + ) : null} + +
+
+
); diff --git a/app/peers/page.tsx b/app/peers/page.tsx index bf683e0..77ae1dc 100644 --- a/app/peers/page.tsx +++ b/app/peers/page.tsx @@ -1,18 +1,166 @@ -import { AppHeader, Card, EmptyState, PageBody } from '@lumyx/ui'; -import { Users } from "lucide-react"; +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { + Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, MetricCard, MetricGrid, PageBody, PageHeader, + SegmentedControl, Sparkline, StatusDot, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn, +} from '@lumyx/ui'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { MeterRow, ScoreBar, SearchField, TONE_BG, TONE_TEXT, TONE_VAR } from '@/components/dashboard-parts'; +import { ALL_PEERS, scoreTone, sev, type IcePath } from '@/lib/dashboard-data'; + +type Filter = 'all' | 'degraded' | 'live'; + +const FILTERS: { value: Filter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'live', label: 'Healthy' }, +]; + +const BUCKETS: { lo: number; hi: number; label: string; bar: string }[] = [ + { lo: 0, hi: 50, label: '0–49', bar: TONE_BG.danger }, + { lo: 50, hi: 70, label: '50–69', bar: TONE_BG.warn }, + { lo: 70, hi: 85, label: '70–84', bar: 'bg-[var(--indigo-300)]' }, + { lo: 85, hi: 101, label: '85+', bar: TONE_BG.ok }, +]; + +const PATHS: { key: IcePath; label: string; bar: string }[] = [ + { key: 'host', label: 'Direct (host)', bar: TONE_BG.ok }, + { key: 'srflx', label: 'Through NAT (srflx)', bar: 'bg-[var(--indigo-300)]' }, + { key: 'relay', label: 'TURN relay', bar: TONE_BG.danger }, +]; + +const inBucket = (score: number, b: (typeof BUCKETS)[number]) => score >= b.lo && score < b.hi; +const pathCount = (k: IcePath) => ALL_PEERS.filter((p) => p.path === k).length; + +export default function PeersPage() { + const router = useRouter(); + const [filter, setFilter] = React.useState('all'); + const [q, setQ] = React.useState(''); + + const query = q.trim().toLowerCase(); + const fleet = ALL_PEERS + .filter((p) => filter === 'all' || p.status === filter) + .filter((p) => !query || `${p.peer_id} ${p.room}`.toLowerCase().includes(query)) + .sort((a, b) => a.score - b.score); + const histMax = Math.max(...BUCKETS.map((b) => ALL_PEERS.filter((p) => inBucket(p.score, b)).length)); -export default function Page() { return ( <> - + - - + + + + } + /> + + + + p.status === 'degraded').length} state="danger" + sub="score under 70" subState="neutral" /> - + + + + +
+ + {fleet.length ? ( + + + + Peer + Room + Quality + RTT + Jitter + Loss + Path + 60s + + + + {fleet.map((p) => ( + router.push(`/rooms/room?id=${p.room}&peer=${p.peer_id}`)} + > + + + + {p.peer_id} + + + {p.room} + + {p.rtt} + {p.jitter} + {p.loss.toFixed(2)}% + + {p.path} + + + + + + ))} + +
+ ) : ( + + )} +
+ +
+ + Quality distribution + +
+ {BUCKETS.map((b) => { + const n = ALL_PEERS.filter((p) => inBucket(p.score, b)).length; + return ( + + {n} + + + ); + })} +
+
+ {BUCKETS.map((b) => {b.label})} +
+
+
+ + + Connection paths + + {PATHS.map((path) => { + const n = pathCount(path.key); + return ( + + + + + + ); + })} +

+ Relay peers pay an extra hop on every packet. They are usually the first to cross a threshold. +

+
+
+
+
); From f1abe2ca8bdf2565a48371b5264446dc56265e18 Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Tue, 10 Nov 2026 10:11:56 +0100 Subject: [PATCH 05/10] feat: build the metrics and session replay screens --- app/metrics/page.tsx | 131 ++++++++++++++++++++-- app/replay/page.tsx | 251 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 362 insertions(+), 20 deletions(-) diff --git a/app/metrics/page.tsx b/app/metrics/page.tsx index 0871f51..dcd6b4a 100644 --- a/app/metrics/page.tsx +++ b/app/metrics/page.tsx @@ -1,17 +1,130 @@ -import { AppHeader, Card, EmptyState, PageBody } from '@lumyx/ui'; -import { Gauge } from "lucide-react"; +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { + Card, CardContent, CardHeader, CardTitle, PageBody, PageHeader, Sparkline, Table, TableBody, TableCell, + TableHead, TableHeader, TableRow, cn, +} from '@lumyx/ui'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { LegendItem, TONE_TEXT, TONE_VAR, TimeAxis, linePath } from '@/components/dashboard-parts'; +import { METRICS, ROOMS, TIME_LABELS, series, type MetricDef, type Tone } from '@/lib/dashboard-data'; + +const N = 48; + +const isWorse = (m: MetricDef, v: number) => (m.field === 'bitrate_kbps' ? v < m.th : v > m.th); +const fmt = (m: MetricDef, v: number) => (m.unit === '%' ? `${v.toFixed(m.dp)}%` : `${Math.round(v)}${m.unit}`); +const toneOf = (m: MetricDef, v: number): Tone => (isWorse(m, v) ? 'danger' : isWorse(m, v * 1.25) ? 'warn' : 'neutral'); + +export default function MetricsPage() { + const router = useRouter(); + const [field, setField] = React.useState(METRICS[0].field); + const m = METRICS.find((x) => x.field === field) ?? METRICS[0]; + + const spike = (i: number, k: number) => (i > 30 && i < 40 ? m.spike * k * Math.sin(((i - 30) / 10) * Math.PI) : 0); + const p50 = Array.from({ length: N }, (_, i) => m.base + m.amp * Math.sin(i / 3.1) + spike(i, 0.25)); + const p95 = p50.map((v, i) => v * 1.6 + spike(i, 1)); + // Plot box: 220 high, 10 of headroom, so the max sits at y=10. + const max = m.max * (220 / 210); + const y = (v: number) => (220 - (Math.min(1, Math.max(0, v) / m.max) * 210)).toFixed(1); + const p95Line = linePath(p95, max, 600, 220); + const band = `${p95Line} ${p50 + .slice() + .reverse() + .map((v, i) => `L${(((N - 1 - i) * 600) / (N - 1)).toFixed(1)} ${y(v)}`) + .join(' ')} Z`; + + const rooms = ROOMS.filter((r) => r.state === 'active').map((r, k) => { + const f = [1, 0.8, 2.2, 0.5][k]; + const v50 = m.base * f; + const v95 = m.base * 1.6 * f + (k === 2 ? m.spike * 0.6 : 0); + const over = isWorse(m, v95) ? (k === 2 ? 2 : 1) : 0; + return { id: r.id, v50, v95, vmax: v95 * 1.4, over, spark: series(30, k + 3, v50, m.amp * f + 0.01) }; + }); -export default function Page() { return ( <> - + + + +
+ {METRICS.map((x) => { + const on = x.field === m.field; + const pv = x.base * 1.6 + x.spike * 0.6; + return ( + + ); + })} +
+ + + + {m.name} + {m.field} + + + + + + + + + + + + + + + + + - + By room + + + + Room + p50 + p95 + Max + Peers over + Trend + + + + {rooms.map((r) => { + const tone = toneOf(m, r.v95); + return ( + router.push(`/rooms/room?id=${r.id}`)}> + {r.id} + {fmt(m, r.v50)} + {fmt(m, r.v95)} + {fmt(m, r.vmax)} + {r.over} + + + + + ); + })} + +
diff --git a/app/replay/page.tsx b/app/replay/page.tsx index 9a10164..c2fe5f7 100644 --- a/app/replay/page.tsx +++ b/app/replay/page.tsx @@ -1,19 +1,248 @@ -import { AppHeader, Card, EmptyState, PageBody } from '@lumyx/ui'; -import { CirclePlay } from "lucide-react"; +'use client'; + +import * as React from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Pause, Play } from 'lucide-react'; +import { Card, CardTitle, PageBody, PageHeader, cn } from '@lumyx/ui'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { Dot, InsetStat, TONE_BG, linePath } from '@/components/dashboard-parts'; +import { + REPLAY_EVENTS, REPLAY_PEERS, SESSIONS, clock, replayLossAt, type Tone, +} from '@/lib/dashboard-data'; + +function Replay() { + const params = useSearchParams(); + const initial = + SESSIONS.find((s) => s.id === params.get('session')) ?? SESSIONS.find((s) => s.room === params.get('room')) ?? SESSIONS[0]; + const [sessionId, setSessionId] = React.useState(initial.id); + const [rt, setRt] = React.useState(() => Number(params.get('t') ?? 89) || 0); + const [playing, setPlaying] = React.useState(false); + + const se = SESSIONS.find((s) => s.id === sessionId) ?? SESSIONS[0]; + const t = Math.min(rt, se.len); + const atEnd = t >= se.len; + const isPlaying = playing && !atEnd; + + React.useEffect(() => { + if (!isPlaying) return; + const id = setInterval(() => setRt((r) => Math.min(se.len, r + 1)), 120); + return () => clearInterval(id); + }, [isPlaying, se.len]); + + const togglePlay = () => { + if (isPlaying) return setPlaying(false); + if (atEnd) setRt(0); + setPlaying(true); + }; + + const pick = (id: string) => { + setPlaying(false); + setSessionId(id); + setRt(0); + }; + + const seekTo = (fraction: number) => setRt(Math.round(Math.max(0, Math.min(1, fraction)) * se.len)); + + const lossNow = replayLossAt(t); + const frozen = t >= 101 && t < 140; + const audioOnly = t >= 140; + const direct = t < 62; + const events = REPLAY_EVENTS.filter((e) => e.at <= se.len); + const lossCurve = Array.from({ length: 61 }, (_, i) => replayLossAt((i * se.len) / 60)); + const pct = (t / se.len) * 100; + + const stats: { k: string; v: string; tone: Tone }[] = [ + { k: 'Packet loss', v: `${lossNow.toFixed(1)}%`, tone: lossNow > 2 ? 'danger' : 'neutral' }, + { k: 'Round trip', v: `${direct ? 48 : 284} ms`, tone: direct ? 'neutral' : 'danger' }, + { k: 'Path', v: direct ? 'host' : 'relay', tone: direct ? 'neutral' : 'warn' }, + { k: 'Video', v: t < 12 ? 'joining' : frozen ? 'frozen' : audioOnly ? 'off' : 'flowing', tone: frozen ? 'danger' : 'neutral' }, + ]; -export default function Page() { return ( <> - + - - - + + +
+ + Recordings + {SESSIONS.map((s) => { + const on = s.id === se.id; + return ( + + ); + })} + + +
+ +
+ {REPLAY_PEERS.map((id, k) => { + const fz = k === 0 && frozen; + const ao = k === 0 && audioOnly; + const joined = !(k === 0 && t < 12); + const tag = !joined ? 'Joining' : fz ? 'Frozen' : ao ? 'Audio only' : 'Live'; + return ( +
+ + {id.replace(/[^a-z]/g, '')[0].toUpperCase()} + + {id} + + {tag} + +
+ ); + })} +
+ +
+ + {clock(t)} +
{ + const r = e.currentTarget.getBoundingClientRect(); + seekTo((e.clientX - r.left) / r.width); + }} + onKeyDown={(e) => { + if (e.key === 'ArrowRight') setRt(Math.min(se.len, t + 5)); + if (e.key === 'ArrowLeft') setRt(Math.max(0, t - 5)); + }} + className="relative h-10 flex-1 cursor-pointer rounded-xs" + > + + + {events.map((e) => ( + + ))} + +
+ {clock(se.len)} +
+
+ +
+ + Events + {events.map((e) => ( + + ))} + + + + + ff104b2c at {clock(t)} + +
+ {stats.map((s) => )} +
+ + + {/* 9% loss fills 74 of the 80px box, leaving 2px under and 4px over. */} + + + + Packet loss over the session. The violet line is the playhead. +
+
+
+
); } + +export default function ReplayPage() { + return ( + + + {null} + + } + > + + + ); +} From ef0bcae712044bcf6e0b318c255643e799f4bd22 Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Tue, 10 Nov 2026 10:13:21 +0100 Subject: [PATCH 06/10] feat: build the server, signaling and settings screens --- app/server/page.tsx | 107 +++++++++++++++++++++--- app/settings/page.tsx | 181 ++++++++++++++++++++++++++++++++++++++--- app/signaling/page.tsx | 105 ++++++++++++++++++++++-- 3 files changed, 362 insertions(+), 31 deletions(-) diff --git a/app/server/page.tsx b/app/server/page.tsx index 7b6bb39..42eed71 100644 --- a/app/server/page.tsx +++ b/app/server/page.tsx @@ -1,17 +1,104 @@ -import { AppHeader, Card, EmptyState, PageBody } from '@lumyx/ui'; -import { Server } from "lucide-react"; +'use client'; + +import Link from 'next/link'; +import { + Button, Card, CardContent, CardDescription, CardHeader, CardTitle, CodeBlock, MetricGrid, PageBody, PageHeader, + Progress, cn, +} from '@lumyx/ui'; +import { useDashboard } from '@/components/dashboard-chrome'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { DEFAULT_CONFIG, INSTANCE, PROCESS_INFO, tomlLines } from '@/lib/dashboard-data'; + +export default function ServerPage() { + const { tick } = useDashboard(); + const cpu = Math.round(23 + 6 * Math.sin(tick / 2)); + const host = [ + { label: 'CPU', value: String(cpu), unit: '%', pct: cpu, warn: false, sub: '8 cores · load 1.84' }, + { label: 'Memory', value: '412', unit: 'MB', pct: 20, warn: false, sub: 'of 2 GB · RSS stable' }, + { label: 'Uplink', value: '12.4', unit: 'Mbps', pct: 12, warn: false, sub: 'of 100 Mbps provisioned' }, + { label: 'UDP ports', value: '64', unit: 'in use', pct: 63, warn: true, sub: 'range 50000 to 50100' }, + ]; + const cores = Array.from({ length: 8 }, (_, i) => + Math.max(6, Math.min(96, 18 + 30 * Math.abs(Math.sin(i * 1.3 + tick / 3)) + (i === 2 ? 28 : 0))), + ); -export default function Page() { return ( <> - + - - + {INSTANCE.name} · {INSTANCE.region} · pid 1} + actions={ + + } + /> + + + {host.map((k) => ( + + {k.label} + + {k.value} + {k.unit} + + + {k.sub} + + ))} + + +
+ + Load per core + +
+ {cores.map((v, i) => ( + + 70 ? 'bg-warn-solid' : 'bg-[var(--indigo-300)]', + )} + style={{ height: `${v.toFixed(0)}%` }} + /> + + ))} +
+
+ {cores.map((_, i) => c{i})} +
+

One forwarding worker per core. Rooms are pinned to a worker when they are created.

+
+
+ + + Process + {PROCESS_INFO.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ + + + lumyx.toml + Read at startup · read only here + + + + {tomlLines(DEFAULT_CONFIG).map((line, i) => ( +
+ {line || ' '} +
+ ))} +
+
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 4458d77..a1c5433 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,18 +1,175 @@ -import { AppHeader, Card, EmptyState, PageBody } from '@lumyx/ui'; -import { SlidersHorizontal } from "lucide-react"; +'use client'; + +import * as React from 'react'; +import { Button, Card, Input, PageBody, PageHeader, SegmentedControl, Switch, cn } from '@lumyx/ui'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { DEFAULT_CONFIG, THRESHOLD_FIELDS, type Config } from '@/lib/dashboard-data'; + +const TOGGLES: { key: 'slack' | 'pager' | 'audio'; label: string; hint: string }[] = [ + { key: 'slack', label: 'Send alerts to Slack', hint: 'Warnings and critical, to #video-oncall' }, + { key: 'pager', label: 'Page on critical', hint: 'PagerDuty, critical alerts only' }, + { key: 'audio', label: 'Allow automatic audio only', hint: 'Lumyx may switch a degrading peer to audio and log it' }, +]; + +const RETENTION: { value: Config['retention']; label: string }[] = [ + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, + { value: '30d', label: '30d' }, +]; + +function Section({ title, description, danger = false, children }: { + title: string; description: string; danger?: boolean; children: React.ReactNode; +}) { + return ( + +
+

{title}

+

{description}

+
+ {children} +
+ ); +} + +export default function SettingsPage() { + const [cfg, setCfg] = React.useState(DEFAULT_CONFIG); + const [saved, setSaved] = React.useState(DEFAULT_CONFIG); + const [justSaved, setJustSaved] = React.useState(false); + + const edit = (patch: Partial) => { + setCfg((c) => ({ ...c, ...patch })); + setJustSaved(false); + }; + + const fields = THRESHOLD_FIELDS.map(([key, label, unit, lo, hi]) => { + const raw = cfg.th[key]; + const v = Number(raw); + return { key, label, unit, lo, hi, raw, ok: raw.trim() !== '' && Number.isFinite(v) && v >= lo && v <= hi }; + }); + const allOk = fields.every((f) => f.ok) && cfg.name.trim() !== ''; + const dirty = JSON.stringify(cfg) !== JSON.stringify(saved); -export default function Page() { return ( <> - - - - - + + + + +
+
+ + +
+
+ +
+
+ {fields.map((f) => ( + + ))} +
+
+ +
+ {TOGGLES.map((t) => ( +
+ + {t.label} + {t.hint} + + edit({ [t.key]: on })} /> +
+ ))} +
+ + Metrics retention + Older samples are dropped first. + + edit({ retention })} + /> +
+
+ +
+
+ + +
+
+ + {dirty || justSaved ? ( +
+ + {!dirty && justSaved + ? 'Saved to lumyx.toml. Thresholds are live.' + : allOk + ? 'You have unsaved changes.' + : 'Fix the highlighted fields to save.'} + + {dirty ? ( + <> + + + + ) : null} +
+ ) : null}
); diff --git a/app/signaling/page.tsx b/app/signaling/page.tsx index c9bf8cc..bb6b115 100644 --- a/app/signaling/page.tsx +++ b/app/signaling/page.tsx @@ -1,17 +1,104 @@ -import { AppHeader, Card, EmptyState, PageBody } from '@lumyx/ui'; -import { Terminal } from "lucide-react"; +'use client'; + +import * as React from 'react'; +import { + Badge, Button, Card, CardContent, CardHeader, CardTitle, MetricCard, MetricGrid, PageBody, PageHeader, + SegmentedControl, StatusDot, cn, +} from '@lumyx/ui'; +import { useDashboard } from '@/components/dashboard-chrome'; +import { DashboardTopBar } from '@/components/dashboard-top-bar'; +import { ALL_PEERS, INSTANCE, signalLog, type SignalType } from '@/lib/dashboard-data'; + +type Filter = 'all' | 'offer' | 'ice' | 'session' | 'error'; + +const FILTERS: { value: Filter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'offer', label: 'Offer' }, + { value: 'ice', label: 'ICE' }, + { value: 'session', label: 'Join / leave' }, + { value: 'error', label: 'Errors' }, +]; + +const TYPE_TONE: Record = { + offer: 'accent', + answer: 'accent', + ice: 'neutral', + join: 'ok', + leave: 'neutral', + error: 'danger', +}; + +const matches = (f: Filter, type: SignalType) => + f === 'all' || f === type || (f === 'session' && (type === 'join' || type === 'leave')); + +export default function SignalingPage() { + const { tick } = useDashboard(); + const [frozenAt, setFrozenAt] = React.useState(null); + const [filter, setFilter] = React.useState('all'); + + const k = frozenAt ?? tick; + const log = signalLog(k).filter((l) => matches(filter, l.type)).slice(0, 12); + const bars = Array.from({ length: 60 }, (_, i) => 30 + 14 * Math.abs(Math.sin((i + k) / 4)) + ((i + k) % 23 === 0 ? 50 : 0)); -export default function Page() { return ( <> - + + {INSTANCE.signaling}} + actions={ + + } + /> + + + + + + + + + + Messages per second + +
+ {bars.map((v, i) => ( + 80 ? 'bg-accent2' : 'bg-accent-soft')} + style={{ height: `${((v / 112) * 100).toFixed(0)}%` }} + /> + ))} +
+
+
+ - + + Live log + + + + +
+
+ {log.map((l) => ( +
+ {l.time} + {l.dir} + {l.type} + {l.peer} + {l.body} +
+ ))} +
+
From a35174765d8c154e557b7d35bc914f4cedba68fe Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Tue, 10 Nov 2026 10:25:33 +0100 Subject: [PATCH 07/10] refactor: use the @lumyx/ui 0.4 data-viz and tone components --- app/alerts/page.tsx | 14 +-- app/metrics/page.tsx | 51 ++++++----- app/page.tsx | 50 +++++------ app/peers/page.tsx | 32 +++---- app/replay/page.tsx | 11 +-- app/rooms/page.tsx | 9 +- app/rooms/room/page.tsx | 36 ++++---- app/server/page.tsx | 25 +++--- app/settings/page.tsx | 28 ++---- app/signaling/page.tsx | 2 +- components/dashboard-parts.tsx | 152 --------------------------------- lib/dashboard-data.ts | 19 +++-- 12 files changed, 133 insertions(+), 296 deletions(-) delete mode 100644 components/dashboard-parts.tsx diff --git a/app/alerts/page.tsx b/app/alerts/page.tsx index a1e07c9..dfa7129 100644 --- a/app/alerts/page.tsx +++ b/app/alerts/page.tsx @@ -3,10 +3,12 @@ import * as React from 'react'; import { useRouter } from 'next/navigation'; import { AlertTriangle } from 'lucide-react'; -import { Badge, Button, Card, EmptyState, PageBody, PageHeader, SegmentedControl, cn } from '@lumyx/ui'; +import { + Badge, Button, Card, Dot, EmptyState, InsetStat, PageBody, PageHeader, SegmentedControl, areaPath, cn, linePath, toneVar, + type Tone, +} from '@lumyx/ui'; import { DashboardTopBar } from '@/components/dashboard-top-bar'; -import { Dot, InsetStat, TONE_VAR, areaPath, linePath } from '@/components/dashboard-parts'; -import { ALERTS, type AlertRow, type Tone } from '@/lib/dashboard-data'; +import { ALERTS, type AlertRow } from '@/lib/dashboard-data'; type Tab = 'open' | 'resolved'; @@ -37,11 +39,11 @@ export default function AlertsPage() { const list = ALERTS.filter((a) => (tab === 'open' ? a.open : !a.open)); const ad = list.find((a) => a.id === selected) ?? list[0] ?? ALERTS[0]; const tone = alertTone(ad); - const color = TONE_VAR[tone]; + const color = toneVar[tone]; const data = curve(ad); - // The chart keeps 4% headroom over the metric's max, as the design does. + // Hand-drawn: in a 440px card TimeSeriesChart's axis text gets squashed. 4% headroom over max. const max = ad.max / 0.96; - const thY = (100 - (Math.min(1, ad.t / ad.max) * 96)).toFixed(1); + const thY = (100 - Math.min(1, ad.t / ad.max) * 96).toFixed(1); return ( <> diff --git a/app/metrics/page.tsx b/app/metrics/page.tsx index dcd6b4a..6095110 100644 --- a/app/metrics/page.tsx +++ b/app/metrics/page.tsx @@ -3,12 +3,11 @@ import * as React from 'react'; import { useRouter } from 'next/navigation'; import { - Card, CardContent, CardHeader, CardTitle, PageBody, PageHeader, Sparkline, Table, TableBody, TableCell, - TableHead, TableHeader, TableRow, cn, + Card, CardContent, CardHeader, CardTitle, ChartLegend, PageBody, PageHeader, Sparkline, Table, TableBody, TableCell, + TableHead, TableHeader, TableRow, TimeSeriesChart, cn, toneText, toneVar, type Tone, } from '@lumyx/ui'; import { DashboardTopBar } from '@/components/dashboard-top-bar'; -import { LegendItem, TONE_TEXT, TONE_VAR, TimeAxis, linePath } from '@/components/dashboard-parts'; -import { METRICS, ROOMS, TIME_LABELS, series, type MetricDef, type Tone } from '@/lib/dashboard-data'; +import { METRICS, ROOMS, TIME_LABELS, series, type MetricDef } from '@/lib/dashboard-data'; const N = 48; @@ -24,15 +23,6 @@ export default function MetricsPage() { const spike = (i: number, k: number) => (i > 30 && i < 40 ? m.spike * k * Math.sin(((i - 30) / 10) * Math.PI) : 0); const p50 = Array.from({ length: N }, (_, i) => m.base + m.amp * Math.sin(i / 3.1) + spike(i, 0.25)); const p95 = p50.map((v, i) => v * 1.6 + spike(i, 1)); - // Plot box: 220 high, 10 of headroom, so the max sits at y=10. - const max = m.max * (220 / 210); - const y = (v: number) => (220 - (Math.min(1, Math.max(0, v) / m.max) * 210)).toFixed(1); - const p95Line = linePath(p95, max, 600, 220); - const band = `${p95Line} ${p50 - .slice() - .reverse() - .map((v, i) => `L${(((N - 1 - i) * 600) / (N - 1)).toFixed(1)} ${y(v)}`) - .join(' ')} Z`; const rooms = ROOMS.filter((r) => r.state === 'active').map((r, k) => { const f = [1, 0.8, 2.2, 0.5][k]; @@ -78,19 +68,28 @@ export default function MetricsPage() { {m.name} {m.field} - - - + - - - - - - - - + @@ -114,11 +113,11 @@ export default function MetricsPage() { router.push(`/rooms/room?id=${r.id}`)}> {r.id} {fmt(m, r.v50)} - {fmt(m, r.v95)} + {fmt(m, r.v95)} {fmt(m, r.vmax)} {r.over} - + ); diff --git a/app/page.tsx b/app/page.tsx index 35e24c0..b0caa7d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,16 +4,13 @@ import * as React from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { - AlertBanner, Button, Card, CardHeader, CardTitle, CardDescription, CardContent, MetricCard, MetricGrid, - PageBody, PageHeader, Progress, Sparkline, StatusDot, Table, TableBody, TableCell, TableHead, - TableHeader, TableRow, + AlertBanner, Button, Card, CardHeader, CardTitle, CardDescription, CardContent, ChartLegend, Dot, EventFeed, + MeterRow, MetricCard, MetricGrid, PageBody, PageHeader, Progress, ScoreBar, Sparkline, StatusDot, Table, + TableBody, TableCell, TableHead, TableHeader, TableRow, TimeSeriesChart, scoreTone, toneText, toneVar, } from '@lumyx/ui'; import { useDashboard } from '@/components/dashboard-chrome'; import { DashboardTopBar, RANGE_LABEL } from '@/components/dashboard-top-bar'; -import { - Dot, EventFeed, LegendItem, MeterRow, ScoreBar, TONE_TEXT, TONE_VAR, TimeAxis, areaPath, linePath, -} from '@/components/dashboard-parts'; -import { EVENTS, FLEET_PEERS, ROOMS, TIME_LABELS, scoreTone, series, sev, shift } from '@/lib/dashboard-data'; +import { EVENTS, FLEET_PEERS, ROOMS, TIME_LABELS, metricTone, series, shift } from '@/lib/dashboard-data'; const BW_MAX = 16; @@ -69,20 +66,25 @@ export default function OverviewPage() { Bandwidth - - + -
- - - - - - - 16 Mbps -
- +
@@ -122,11 +124,11 @@ export default function OverviewPage() { {p.room} - {p.rtt} - {p.jitter} - {p.loss.toFixed(2)}% + {p.rtt} + {p.jitter} + {p.loss.toFixed(2)}% - + ))} @@ -153,7 +155,7 @@ export default function OverviewPage() { {r.id}
- + {r.peers} peers · {r.uptime} · {r.region} {r.bitrate} diff --git a/app/peers/page.tsx b/app/peers/page.tsx index 77ae1dc..8c892df 100644 --- a/app/peers/page.tsx +++ b/app/peers/page.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import { useRouter } from 'next/navigation'; import { - Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, MetricCard, MetricGrid, PageBody, PageHeader, - SegmentedControl, Sparkline, StatusDot, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn, + Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, MeterRow, MetricCard, MetricGrid, PageBody, PageHeader, + ScoreBar, SearchField, SegmentedControl, Sparkline, StatusDot, Table, TableBody, TableCell, TableHead, TableHeader, + TableRow, cn, scoreTone, toneBg, toneText, toneVar, } from '@lumyx/ui'; import { DashboardTopBar } from '@/components/dashboard-top-bar'; -import { MeterRow, ScoreBar, SearchField, TONE_BG, TONE_TEXT, TONE_VAR } from '@/components/dashboard-parts'; -import { ALL_PEERS, scoreTone, sev, type IcePath } from '@/lib/dashboard-data'; +import { ALL_PEERS, metricTone, type IcePath } from '@/lib/dashboard-data'; type Filter = 'all' | 'degraded' | 'live'; @@ -19,16 +19,16 @@ const FILTERS: { value: Filter; label: string }[] = [ ]; const BUCKETS: { lo: number; hi: number; label: string; bar: string }[] = [ - { lo: 0, hi: 50, label: '0–49', bar: TONE_BG.danger }, - { lo: 50, hi: 70, label: '50–69', bar: TONE_BG.warn }, - { lo: 70, hi: 85, label: '70–84', bar: 'bg-[var(--indigo-300)]' }, - { lo: 85, hi: 101, label: '85+', bar: TONE_BG.ok }, + { lo: 0, hi: 50, label: '0–49', bar: toneBg.danger }, + { lo: 50, hi: 70, label: '50–69', bar: toneBg.warn }, + { lo: 70, hi: 85, label: '70–84', bar: 'bg-accent-muted' }, + { lo: 85, hi: 101, label: '85+', bar: toneBg.ok }, ]; const PATHS: { key: IcePath; label: string; bar: string }[] = [ - { key: 'host', label: 'Direct (host)', bar: TONE_BG.ok }, - { key: 'srflx', label: 'Through NAT (srflx)', bar: 'bg-[var(--indigo-300)]' }, - { key: 'relay', label: 'TURN relay', bar: TONE_BG.danger }, + { key: 'host', label: 'Direct (host)', bar: toneBg.ok }, + { key: 'srflx', label: 'Through NAT (srflx)', bar: 'bg-accent-muted' }, + { key: 'relay', label: 'TURN relay', bar: toneBg.danger }, ]; const inBucket = (score: number, b: (typeof BUCKETS)[number]) => score >= b.lo && score < b.hi; @@ -56,7 +56,7 @@ export default function PeersPage() { actions={ <> - + setQ(e.target.value)} placeholder="Search peer or room" aria-label="Search peers" /> } /> @@ -102,14 +102,14 @@ export default function PeersPage() { {p.room} - {p.rtt} - {p.jitter} - {p.loss.toFixed(2)}% + {p.rtt} + {p.jitter} + {p.loss.toFixed(2)}% {p.path} - + ))} diff --git a/app/replay/page.tsx b/app/replay/page.tsx index c2fe5f7..3200b1b 100644 --- a/app/replay/page.tsx +++ b/app/replay/page.tsx @@ -3,11 +3,12 @@ import * as React from 'react'; import { useSearchParams } from 'next/navigation'; import { Pause, Play } from 'lucide-react'; -import { Card, CardTitle, PageBody, PageHeader, cn } from '@lumyx/ui'; +import { + Card, CardTitle, Dot, InsetStat, PageBody, PageHeader, cn, linePath, toneBg, type Tone, +} from '@lumyx/ui'; import { DashboardTopBar } from '@/components/dashboard-top-bar'; -import { Dot, InsetStat, TONE_BG, linePath } from '@/components/dashboard-parts'; import { - REPLAY_EVENTS, REPLAY_PEERS, SESSIONS, clock, replayLossAt, type Tone, + REPLAY_EVENTS, REPLAY_PEERS, SESSIONS, clock, replayLossAt, } from '@/lib/dashboard-data'; function Replay() { @@ -166,7 +167,7 @@ function Replay() { ))} @@ -209,9 +210,9 @@ function Replay() {
{stats.map((s) => )}
+ {/* Hand-drawn: the playhead is violet, and the card is too narrow for axis text. */} - {/* 9% loss fills 74 of the 80px box, leaving 2px under and 4px over. */} - + setQ(e.target.value)} placeholder="Search by room_id" aria-label="Search rooms" /> } /> @@ -73,7 +72,7 @@ export default function RoomsPage() { {r.data} {r.region} - + {r.state === 'active' ? 'Active' : 'Ended'} diff --git a/app/rooms/room/page.tsx b/app/rooms/room/page.tsx index 2bfb50a..8929ccd 100644 --- a/app/rooms/room/page.tsx +++ b/app/rooms/room/page.tsx @@ -4,17 +4,13 @@ import * as React from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { - Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, MetricCard, PageBody, - SegmentedControl, StatusDot, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn, + Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, EventFeed, InsetStat, MetricCard, PageBody, + ScoreBar, SegmentedControl, StatusDot, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, areaPath, cn, + linePath, scoreTone, toneText, toneVar, type Tone, } from '@lumyx/ui'; import { useDashboard } from '@/components/dashboard-chrome'; import { DashboardTopBar } from '@/components/dashboard-top-bar'; -import { - EventFeed, InsetStat, ScoreBar, TONE_TEXT, TONE_VAR, areaPath, linePath, -} from '@/components/dashboard-parts'; -import { - GRAPH_POS, MATRIX, PEERS, ROOMS, ROOM_EVENTS, scoreTone, sev, shift, type Tone, -} from '@/lib/dashboard-data'; +import { GRAPH_POS, MATRIX, PEERS, ROOMS, ROOM_EVENTS, metricTone, shift } from '@/lib/dashboard-data'; type Topo = 'graph' | 'matrix'; @@ -97,10 +93,10 @@ function RoomDetail() {
- {p.rtt} - {p.jitter} - {p.loss.toFixed(2)}% - {p.nack.toFixed(1)}% + {p.rtt} + {p.jitter} + {p.loss.toFixed(2)}% + {p.nack.toFixed(1)}% {p.codec} ))} @@ -159,7 +155,7 @@ function TopologyGraph({ selected, onSelect }: { selected: number; onSelect: (i: {PEERS.map((p, i) => { const [x, y] = GRAPH_POS[i]; - const ring = TONE_VAR[scoreTone(p.score)]; + const ring = toneVar[scoreTone(p.score)]; return ( ); })} @@ -228,14 +224,14 @@ function SelectedPeer({ index }: { index: number }) { const { tick } = useDashboard(); const p = PEERS[index] ?? PEERS[0]; const tone = scoreTone(p.score); - const color = TONE_VAR[tone]; + const color = toneVar[tone]; const rtt = shift(p.series, tick % 6); const max = Math.max(...rtt) * 1.2; const stats: { k: string; v: string; tone: Tone }[] = [ - { k: 'RTT', v: `${p.rtt} ms`, tone: sev(p.rtt, 150, 200) }, - { k: 'Jitter', v: `${p.jitter} ms`, tone: sev(p.jitter, 20, 30) }, - { k: 'Loss', v: `${p.loss.toFixed(2)}%`, tone: sev(p.loss, 1, 2) }, - { k: 'NACK', v: `${p.nack.toFixed(1)}%`, tone: sev(p.nack, 3, 5) }, + { k: 'RTT', v: `${p.rtt} ms`, tone: metricTone(p.rtt, 150, 200) }, + { k: 'Jitter', v: `${p.jitter} ms`, tone: metricTone(p.jitter, 20, 30) }, + { k: 'Loss', v: `${p.loss.toFixed(2)}%`, tone: metricTone(p.loss, 1, 2) }, + { k: 'NACK', v: `${p.nack.toFixed(1)}%`, tone: metricTone(p.nack, 3, 5) }, ]; return ( @@ -250,7 +246,7 @@ function SelectedPeer({ index }: { index: number }) { transform="rotate(-90 28 28)" /> - + {p.score} diff --git a/app/server/page.tsx b/app/server/page.tsx index 42eed71..a118b8b 100644 --- a/app/server/page.tsx +++ b/app/server/page.tsx @@ -2,8 +2,8 @@ import Link from 'next/link'; import { - Button, Card, CardContent, CardDescription, CardHeader, CardTitle, CodeBlock, MetricGrid, PageBody, PageHeader, - Progress, cn, + Button, Card, CardContent, CardDescription, CardHeader, CardTitle, CodeBlock, MetricCard, MetricGrid, PageBody, + PageHeader, cn, } from '@lumyx/ui'; import { useDashboard } from '@/components/dashboard-chrome'; import { DashboardTopBar } from '@/components/dashboard-top-bar'; @@ -38,15 +38,14 @@ export default function ServerPage() { {host.map((k) => ( - - {k.label} - - {k.value} - {k.unit} - - - {k.sub} - + ))} @@ -60,7 +59,7 @@ export default function ServerPage() { 70 ? 'bg-warn-solid' : 'bg-[var(--indigo-300)]', + v > 70 ? 'bg-warn-solid' : 'bg-accent-muted', )} style={{ height: `${v.toFixed(0)}%` }} /> @@ -93,7 +92,7 @@ export default function ServerPage() { {tomlLines(DEFAULT_CONFIG).map((line, i) => ( -
+
{line || ' '}
))} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index a1c5433..e6b88a1 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Button, Card, Input, PageBody, PageHeader, SegmentedControl, Switch, cn } from '@lumyx/ui'; +import { Button, Card, Input, PageBody, PageHeader, SegmentedControl, Switch, UnitInput, cn } from '@lumyx/ui'; import { DashboardTopBar } from '@/components/dashboard-top-bar'; import { DEFAULT_CONFIG, THRESHOLD_FIELDS, type Config } from '@/lib/dashboard-data'; @@ -76,23 +76,13 @@ export default function SettingsPage() { {f.label} {f.key} - - edit({ th: { ...cfg.th, [f.key]: e.target.value } })} - /> - - {f.unit} - - + edit({ th: { ...cfg.th, [f.key]: e.target.value } })} + /> {!f.ok ? `Enter a number between ${f.lo} and ${f.hi}.` @@ -149,7 +139,7 @@ export default function SettingsPage() { <>