Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

All notable changes to the self-hosted Lumyx dashboard. Versions follow
[SemVer](https://semver.org/); the `@lumyx/ui` version each release needs is noted.

## 0.2.0

Requires `@lumyx/ui` 0.4.0.

### Changed

- Rebuilt on the Dashboard UI v3 design: ink brand tile, sidebar with per-screen counts and an
alert dot, a "Signaling connected" panel in the sidebar foot, and a translucent top bar with
breadcrumb, time range, uptime and refresh. The bottom status strip is gone.
- Overview, Rooms and Room detail restyled; numbers and identifiers in Geist Mono.
- Links go through `LumyxLinkProvider`.

### Added

- Peers, Alerts, Metrics, Session replay, Server, Signaling and Settings screens.
- Deep links between screens: `/rooms/room?id=&peer=` and `/replay?session=&t=`.
- `verify:ds` follows the design system's rules (type scale, palette, durations).
146 changes: 135 additions & 11 deletions app/alerts/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,142 @@
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, Dot, EmptyState, InsetStat, PageBody, PageHeader, SegmentedControl, TimeSeriesChart, cn, toneVar,
type Tone,
} from '@lumyx/ui';
import { DashboardTopBar } from '@/components/dashboard-top-bar';
import { ALERTS, type AlertRow } 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<Tab>('open');
const [selected, setSelected] = React.useState('a1');
const [acked, setAcked] = React.useState<Record<string, boolean>>({});

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 = toneVar[tone];
const data = curve(ad);

export default function Page() {
return (
<>
<AppHeader title="Alerts" />
<DashboardTopBar crumbs={[{ label: 'Alerts' }]} />
<PageBody>
<Card className="overflow-hidden">
<EmptyState
icon={Bell}
title="No design exists for this screen yet"
body="Overview, Rooms and Room detail are designed. Ask for this screen and it gets built from the same components."
/>
</Card>
<PageHeader
title="Alerts"
description="A metric that stays past its threshold for 30s opens an alert."
actions={<SegmentedControl aria-label="Alert state" options={TABS} value={tab} onValueChange={setTab} />}
/>

<div className="grid items-start gap-4 min-[1100px]:grid-cols-[minmax(0,1fr)_440px]">
<Card className="flex min-w-0 flex-col gap-1 p-2">
{list.map((a) => {
const isSel = a.id === ad.id;
const state = !a.open ? 'Resolved' : acked[a.id] ? 'Acknowledged' : 'Open';
return (
<button
key={a.id}
type="button"
onClick={() => setSelected(a.id)}
aria-pressed={isSel}
className={cn(
'grid w-full grid-cols-[8px_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 rounded-md border p-3 text-left transition-colors duration-300 ease-[var(--ease-out)]',
isSel ? 'border-accent-border bg-accent-subtle' : 'border-transparent hover:bg-sunken',
)}
>
<Dot tone={a.open ? (a.sev === 'critical' ? 'danger' : 'warn') : 'idle'} />
<span className="text-14 font-semibold text-strong">{a.title}</span>
<Badge tone={state === 'Open' ? 'danger' : state === 'Acknowledged' ? 'warn' : 'ok'}>{state}</Badge>
<span />
<span className="sl-num truncate text-12 text-faint">{a.peer} · {a.room} · {a.value} vs {a.th}</span>
<span className="sl-num text-12 text-faint">{a.dur}</span>
</button>
);
})}
{list.length === 0 ? (
<EmptyState title="Nothing here." body="Every metric is within its threshold." className="py-12" />
) : null}
</Card>

<Card className="flex flex-col gap-4 p-4">
<div className="flex items-start gap-3">
<span
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-md',
tone === 'ok' ? 'bg-ok-tint text-ok' : tone === 'danger' ? 'bg-danger-tint text-danger' : 'bg-warn-tint text-warn',
)}
>
<AlertTriangle className="size-5 stroke-[1.75]" />
</span>
<span className="flex flex-1 flex-col gap-0.5">
<span className="text-20 font-semibold text-strong">{ad.title}</span>
<span className="sl-num text-12 text-faint">{ad.metric} · opened {ad.opened}</span>
</span>
<Badge tone={tone === 'ok' ? 'ok' : tone === 'danger' ? 'danger' : 'warn'}>
{!ad.open ? 'Resolved' : ad.sev === 'critical' ? 'Critical' : 'Warning'}
</Badge>
</div>

<div className="grid grid-cols-3 gap-2">
<InsetStat label="Current" value={ad.value} tone={tone} size="lg" />
<InsetStat label="Threshold" value={ad.th} size="lg" />
<InsetStat label="Duration" value={ad.dur} size="lg" />
</div>

<div>
<span className="text-12 text-faint">{ad.metric} · last 10 min</span>
<TimeSeriesChart
className="mt-2"
height={140}
max={ad.max}
legend={false}
series={[{ name: ad.metric, color, data, area: true }]}
thresholds={[{ value: ad.t, color: 'var(--text-faint)' }]}
/>
</div>

<div className="flex flex-col gap-1">
<span className="text-12 text-faint">Suggested action</span>
<p className="text-14 text-body">{ad.action}</p>
</div>

<div className="flex flex-wrap gap-2">
<Button variant="primary" onClick={() => router.push(`/rooms/room?id=${ad.room}`)}>Open room</Button>
{ad.open ? (
<Button onClick={() => setAcked((x) => ({ ...x, [ad.id]: !x[ad.id] }))}>
{acked[ad.id] ? 'Unacknowledge' : 'Acknowledge'}
</Button>
) : null}
<Button onClick={() => router.push(`/replay?session=${ad.room === 'test-room' ? 's2' : 's1'}&t=85`)}>
Replay session
</Button>
</div>
</Card>
</div>
</PageBody>
</>
);
Expand Down
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,7 +11,7 @@ export const metadata: Metadata = {

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={GeistSans.variable}>
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body>
<DashboardChrome>{children}</DashboardChrome>
</body>
Expand Down
130 changes: 121 additions & 9 deletions app/metrics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,129 @@
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, 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 { METRICS, ROOMS, TIME_LABELS, series, type MetricDef } 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));

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 (
<>
<AppHeader title="Metrics" />
<DashboardTopBar crumbs={[{ label: 'Metrics' }]} />
<PageBody>
<PageHeader title="Metrics" description="Six numbers per peer, sampled every 500 ms, rolled up per room." />

<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 min-[1100px]:grid-cols-6">
{METRICS.map((x) => {
const on = x.field === m.field;
const pv = x.base * 1.6 + x.spike * 0.6;
return (
<button
key={x.field}
type="button"
aria-pressed={on}
onClick={() => setField(x.field)}
className={cn(
'flex flex-col gap-1 rounded-lg border bg-card px-4 py-3 text-left transition-all duration-400 ease-[var(--ease-out)]',
on ? 'border-accent shadow-[0_0_0_3px_var(--accent-tint)]' : 'border-hairline hover:border-stroke-hover',
)}
>
<span className="text-12 text-muted">{x.name}</span>
<span className={cn('sl-num text-20 font-semibold', isWorse(x, pv) ? 'text-danger' : 'text-strong')}>{fmt(x, pv)}</span>
<span className="sl-num text-12 text-faint">
p95 · limit {x.field === 'bitrate_kbps' ? '< ' : '> '}{x.th}{x.unit === '%' ? '%' : x.unit}
</span>
</button>
);
})}
</div>

<Card>
<CardHeader>
<CardTitle>{m.name}</CardTitle>
<span className="sl-num text-12 text-faint">{m.field}</span>
<span className="flex-1" />
<ChartLegend
items={[
{ name: 'p50', color: 'var(--series-1)' },
{ name: 'p95', color: 'var(--series-2)' },
{ name: 'threshold', color: 'var(--text-faint)', swatch: 'dashed' },
]}
/>
</CardHeader>
<CardContent>
<TimeSeriesChart
height={240}
max={m.max}
yUnit={m.unit === '%' ? '%' : ''}
xLabels={TIME_LABELS}
legend={false}
band={{ lo: p50, hi: p95, color: 'var(--series-1)' }}
thresholds={[{ value: m.th, color: 'var(--text-faint)' }]}
series={[
{ name: 'p50', color: 'var(--series-1)', data: p50 },
{ name: 'p95', color: 'var(--series-2)', data: p95 },
]}
/>
</CardContent>
</Card>

<Card className="overflow-hidden">
<EmptyState
icon={Gauge}
title="No design exists for this screen yet"
body="Overview, Rooms and Room detail are designed. Ask for this screen and it gets built from the same components."
/>
<CardHeader><CardTitle>By room</CardTitle></CardHeader>
<Table className="min-w-[560px]">
<TableHeader>
<TableRow>
<TableHead>Room</TableHead>
<TableHead className="text-right">p50</TableHead>
<TableHead className="text-right">p95</TableHead>
<TableHead className="text-right">Max</TableHead>
<TableHead className="text-right">Peers over</TableHead>
<TableHead>Trend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rooms.map((r) => {
const tone = toneOf(m, r.v95);
return (
<TableRow key={r.id} className="cursor-pointer" onClick={() => router.push(`/rooms/room?id=${r.id}`)}>
<TableCell className="sl-num font-medium text-strong">{r.id}</TableCell>
<TableCell className="sl-num text-right text-strong">{fmt(m, r.v50)}</TableCell>
<TableCell className={cn('sl-num text-right', toneText[tone])}>{fmt(m, r.v95)}</TableCell>
<TableCell className="sl-num text-right text-muted">{fmt(m, r.vmax)}</TableCell>
<TableCell className={cn('sl-num text-right', r.over ? 'text-danger' : 'text-faint')}>{r.over}</TableCell>
<TableCell>
<Sparkline data={r.spark} width={96} height={24} fill={false} color={toneVar[tone]} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
</PageBody>
</>
Expand Down
Loading
Loading