From 998ce3003494a07b84ede8e178163d75b1315f63 Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:58:17 -0300 Subject: [PATCH] feat: add the live dashboard for services and pack-sentinel --- CHANGELOG.md | 1 + README.md | 2 +- SECURITY.md | 8 ++ docs/architecture.md | 4 +- src/cli.tsx | 15 +-- src/data/usePoll.ts | 66 ++++++++++++ src/index.ts | 11 +- src/sources/http.ts | 1 + src/text.ts | 40 +++++++ src/views/Dashboard.tsx | 117 +++++++++++++++----- src/views/SentinelPanel.tsx | 122 +++++++++++++++++++++ src/views/ServicesPanel.tsx | 92 ++++++++++++++++ tests/cli.test.ts | 22 +++- tests/dashboard.test.tsx | 42 ++++++++ tests/text.test.ts | 45 ++++++++ tests/views.test.tsx | 206 ++++++++++++++++++++++++++++++++++++ 16 files changed, 760 insertions(+), 34 deletions(-) create mode 100644 src/data/usePoll.ts create mode 100644 src/text.ts create mode 100644 src/views/SentinelPanel.tsx create mode 100644 src/views/ServicesPanel.tsx create mode 100644 tests/dashboard.test.tsx create mode 100644 tests/text.test.ts create mode 100644 tests/views.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb51ad..51de9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,3 +4,4 @@ - Added the `relay-dash` command with a full-screen frame, `--snapshot`, and Relay themes. - Added pack-sentinel and impossibleG service clients, `relay-dash.json` configuration, and `--json` to poll every source once. +- Added the live dashboard: a services table with state, queue depth, active requests, and models, and pack-sentinel's active incidents and recent signals, refreshed on an interval or with `r`. diff --git a/README.md b/README.md index 2563aaf..75e77a0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ npm install npm run dev ``` -Press `q` to quit. To print one frame without opening the interactive screen: +Press `r` to refresh now and `q` to quit. To print one frame without opening the interactive screen: ```sh npx tsx src/cli.tsx --snapshot diff --git a/SECURITY.md b/SECURITY.md index 2a04096..cffda61 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,3 +3,11 @@ ## Reporting Please report a vulnerability privately through GitHub's security advisory form for this repository. Include a minimal reproduction and the affected version. + +## Untrusted text + +Incident titles, signal summaries, and service error messages come from the systems being watched, and some of them are derived from log lines. Relay Dash strips terminal control sequences from that text before rendering it, so a crafted log line cannot move the cursor or change terminal settings. + +## Tokens + +The pack-sentinel API token is read from an environment variable, never from a command-line argument, so it does not appear in process listings or shell history. Relay Dash sends it only to the configured pack-sentinel address. diff --git a/docs/architecture.md b/docs/architecture.md index 8169f48..a72b7fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,6 +5,8 @@ Relay Dash is a read-only view over two kinds of source. 1. **pack-sentinel** exposes an HTTP API under `/api/v1` with health, collector state, incidents, and signals. 2. **impossibleG services** (inference, embeddings, OCR, voice, MCP, and the ReAct agent) each expose health checks, an OpenAI-style `/v1/models` list, and Prometheus metrics that include the request queue depth. -Each source has a small client that turns responses into plain data. A polling loop calls the clients on an interval, and the views render whatever the last poll returned, including the error when a source is unreachable. +Each source has a small client under `src/sources/` that turns responses into plain data and never throws. `usePoll` calls the clients on an interval without overlapping a slow poll, and `DashboardView` renders whatever the last poll returned, including the error when a source is unreachable. `DashboardView` is pure, so `--snapshot` and the tests render it directly from recorded data. + +Every string that comes from a watched system goes through `clean()` in `src/text.ts` before it is drawn. The frame, keybindings, and status bar will come from [Relay Shell](https://github.com/relay-cli/relay-shell) once it is published. Until then `src/views/Frame.tsx` is a minimal stand-in with the same props as Relay Shell's `ShellFrame`. diff --git a/src/cli.tsx b/src/cli.tsx index b7a1989..7cd270b 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -6,7 +6,7 @@ import {ConfigError, loadConfig} from './config.js'; import {pollOnce} from './sources/poll.js'; import {runDashboard} from './run.js'; import {relayDashVersion} from './version.js'; -import {Dashboard} from './views/Dashboard.js'; +import {Dashboard, DashboardView} from './views/Dashboard.js'; const main = async (): Promise => { let options; @@ -43,15 +43,18 @@ const main = async (): Promise => { } if (options.snapshot || !process.stdout.isTTY || !process.stdin.isTTY) { - const columns = process.stdout.columns || 80; - const frame = renderToString(, { - columns, - }); + const columns = process.stdout.columns || 100; + const rows = process.stdout.rows || 24; + const data = await pollOnce(config); + const frame = renderToString( + , + {columns}, + ); process.stdout.write(`${frame}\n`); return; } - await runDashboard(, {alternateScreen: !options.inline}); + await runDashboard(, {alternateScreen: !options.inline}); }; await main(); diff --git a/src/data/usePoll.ts b/src/data/usePoll.ts new file mode 100644 index 0000000..4d1d3e4 --- /dev/null +++ b/src/data/usePoll.ts @@ -0,0 +1,66 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import type {DashConfig} from '../config.js'; +import type {RequestOptions} from '../sources/http.js'; +import {pollOnce} from '../sources/poll.js'; +import type {DashboardData} from '../sources/poll.js'; + +export interface PollState { + /** The most recent results. Undefined until the first poll finishes. */ + data?: DashboardData; + /** True while a poll is in flight. */ + polling: boolean; + /** Poll now instead of waiting for the next interval. */ + refresh: () => void; +} + +/** + * Poll every source on the configured interval. A slow poll is never + * overlapped by the next one, and an unmounted dashboard aborts its requests. + */ +export const usePoll = ( + config: DashConfig, + options: Omit = {}, + initial?: DashboardData, +): PollState => { + const [data, setData] = useState(initial); + const [polling, setPolling] = useState(false); + const inFlight = useRef(false); + const controller = useRef(new AbortController()); + const timer = useRef(undefined); + const optionsRef = useRef(options); + optionsRef.current = options; + + // A function, so TypeScript does not narrow `aborted` across the await. + const stopped = (): boolean => controller.current.signal.aborted; + + const poll = useCallback(async () => { + if (inFlight.current || stopped()) return; + inFlight.current = true; + setPolling(true); + clearTimeout(timer.current); + try { + const next = await pollOnce(config, { + ...optionsRef.current, + signal: controller.current.signal, + }); + if (!stopped()) setData(next); + } finally { + inFlight.current = false; + if (!stopped()) { + setPolling(false); + timer.current = setTimeout(() => void poll(), config.intervalSeconds * 1000); + } + } + }, [config]); + + useEffect(() => { + controller.current = new AbortController(); + void poll(); + return () => { + controller.current.abort(); + clearTimeout(timer.current); + }; + }, [poll]); + + return {...(data === undefined ? {} : {data}), polling, refresh: () => void poll()}; +}; diff --git a/src/index.ts b/src/index.ts index 9186431..bacb9e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,16 @@ export { type ServiceSource, type ServiceStatus, } from './sources/services.js'; -export {Dashboard, type DashboardProps} from './views/Dashboard.js'; +export { + Dashboard, + DashboardView, + type DashboardProps, + type DashboardViewProps, +} from './views/Dashboard.js'; +export {SentinelPanel, type SentinelPanelProps} from './views/SentinelPanel.js'; +export {ServicesPanel, type ServicesPanelProps} from './views/ServicesPanel.js'; +export {usePoll, type PollState} from './data/usePoll.js'; +export {clean} from './text.js'; export {runDashboard, type RunDashboardOptions} from './run.js'; export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js'; export {relayDashVersion} from './version.js'; diff --git a/src/sources/http.ts b/src/sources/http.ts index b2238d6..5b107b3 100644 --- a/src/sources/http.ts +++ b/src/sources/http.ts @@ -65,6 +65,7 @@ export const describeError = (error: unknown): string => { if (cause.code === 'ENOTFOUND') return 'host not found'; return cause.code; } + if (cause instanceof Error && cause.message !== '') return cause.message; return error.message; } return String(error); diff --git a/src/text.ts b/src/text.ts new file mode 100644 index 0000000..72f8873 --- /dev/null +++ b/src/text.ts @@ -0,0 +1,40 @@ +/* eslint-disable no-control-regex */ +// CSI and OSC sequences, then any remaining C0/C1 control character. +const escapes = + /\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)?|\u001B[@-_]?/g; +const controls = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g; + +/** + * Make text from a watched system safe to draw on one terminal line: strip + * escape sequences and control characters, and fold tabs and newlines into + * spaces. + */ +export const clean = (text: string): string => + text + .replace(escapes, '') + .replace(controls, '') + .replace(/[\t\n]+/g, ' ') + .trim(); + +/** Cut text to `width` columns, ending with an ellipsis when it was longer. */ +export const fit = (text: string, width: number): string => { + if (width <= 0) return ''; + if (text.length <= width) return text; + return width === 1 ? '…' : `${text.slice(0, width - 1)}…`; +}; + +/** Pad or cut text to exactly `width` columns, always leaving at least one space after it. */ +export const cell = (text: string, width: number): string => fit(text, width - 1).padEnd(width); + +/** A short "how long ago" label such as `12s`, `4m`, `3h`, or `2d`. */ +export const ago = (iso: string, now: Date): string => { + const then = Date.parse(iso); + if (Number.isNaN(then)) return ''; + const seconds = Math.max(0, Math.round((now.getTime() - then) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +}; diff --git a/src/views/Dashboard.tsx b/src/views/Dashboard.tsx index dc957b5..5007e93 100644 --- a/src/views/Dashboard.tsx +++ b/src/views/Dashboard.tsx @@ -1,39 +1,108 @@ -import {Text, useApp, useInput} from 'ink'; +import {useEffect, useState} from 'react'; +import {Box, Text, useApp, useInput, useWindowSize} from 'ink'; +import type {DashConfig} from '../config.js'; +import {usePoll} from '../data/usePoll.js'; +import type {RequestOptions} from '../sources/http.js'; +import type {DashboardData} from '../sources/poll.js'; import {relayTheme} from '../theme.js'; import type {ShellTheme} from '../theme.js'; import {Frame} from './Frame.js'; +import {SentinelPanel} from './SentinelPanel.js'; +import {ServicesPanel} from './ServicesPanel.js'; -export interface DashboardProps { +export interface DashboardViewProps { + config: DashConfig; + /** The latest poll. Undefined until the first one finishes. */ + data?: DashboardData; + polling?: boolean; theme?: ShellTheme; /** Fixed size, mainly for tests and snapshots. Defaults to the terminal size. */ width?: number; height?: number; - /** Read the keyboard. Disable it to print a single frame. */ - interactive?: boolean; + /** The clock used for "how long ago" labels. */ + now?: Date; } -const QuitKey = () => { - const {exit} = useApp(); - useInput((input, key) => { - if (input === 'q' || (key.ctrl && input === 'c')) exit(); - }); - return null; +const lastChecked = (data: DashboardData | undefined): Date | undefined => { + const times = [ + ...(data?.sentinel ? [data.sentinel.checkedAt] : []), + ...(data?.services.map((service) => service.checkedAt) ?? []), + ]; + return times.length === 0 + ? undefined + : new Date(Math.max(...times.map((time) => time.getTime()))); }; -export const Dashboard = ({ +/** The dashboard for one set of results. It does not poll; see `Dashboard`. */ +export const DashboardView = ({ + config, + data, + polling = false, theme = relayTheme, width, height, - interactive = true, -}: DashboardProps) => ( - q quit} - > - {interactive ? : null} - No sources are configured yet. - -); + now = new Date(), +}: DashboardViewProps) => { + const size = useWindowSize(); + const columns = Math.max(20, width ?? size.columns); + const rows = Math.max(8, height ?? size.rows); + const names = config.services.map((service) => service.name); + const checked = lastChecked(data); + const age = + checked === undefined + ? 'waiting for the first poll' + : `updated ${Math.max(0, Math.round((now.getTime() - checked.getTime()) / 1000))}s ago`; + const servicesHeight = names.length === 0 ? 1 : names.length + 2; + // Title row, footer row, and a spacer between the panels. + const sentinelHeight = Math.max(2, rows - 3 - servicesHeight); + return ( + + q quit · r refresh · every {config.intervalSeconds}s + + } + > + + + + + ); +}; + +export interface DashboardProps extends Omit { + /** Passed to every request, for example a custom `fetch` or a timeout. */ + requestOptions?: Omit; + /** Results to show before the first poll returns. */ + initialData?: DashboardData; +} + +/** The live dashboard: polls every source, redraws, and reads `q` and `r`. */ +export const Dashboard = ({initialData, requestOptions, ...props}: DashboardProps) => { + const {exit} = useApp(); + const {data, polling, refresh} = usePoll(props.config, requestOptions, initialData); + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const tick = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(tick); + }, []); + useInput((input, key) => { + if (input === 'q' || (key.ctrl && input === 'c')) exit(); + else if (input === 'r') refresh(); + }); + return ( + + ); +}; diff --git a/src/views/SentinelPanel.tsx b/src/views/SentinelPanel.tsx new file mode 100644 index 0000000..422e8a1 --- /dev/null +++ b/src/views/SentinelPanel.tsx @@ -0,0 +1,122 @@ +import {Box, Text} from 'ink'; +import type {SentinelResult, Severity} from '../sources/sentinel.js'; +import {ago, cell, clean, fit} from '../text.js'; +import type {ShellTheme} from '../theme.js'; + +export interface SentinelPanelProps { + result?: SentinelResult; + /** Undefined means pack-sentinel is not configured. */ + url?: string; + width: number; + /** Rows available for the whole panel, including its heading. */ + height: number; + theme: ShellTheme; + now: Date; +} + +const severityLabel: Record = { + critical: 'CRIT', + high: 'HIGH', + medium: 'MED', + low: 'LOW', + info: 'INFO', +}; + +export const severityColor = (severity: Severity, theme: ShellTheme): string => { + if (severity === 'critical' || severity === 'high') return theme.danger; + if (severity === 'medium') return theme.warning; + return theme.muted; +}; + +const Heading = ({theme, children}: {theme: ShellTheme; children: string}) => ( + + {children} + +); + +export const SentinelPanel = ({result, url, width, height, theme, now}: SentinelPanelProps) => { + if (url === undefined) { + return ( + + PACK-SENTINEL + Not configured. + + ); + } + if (result === undefined) { + return ( + + PACK-SENTINEL + {fit(`Connecting to ${url}…`, width)} + + ); + } + if (!result.ok) { + return ( + + PACK-SENTINEL + {fit(`× ${url}: ${clean(result.error)}`, width)} + + ); + } + + const {snapshot} = result; + const collecting = + snapshot.running.length > 0 ? ` · collecting ${snapshot.running.join(', ')}` : ''; + const summary = `${snapshot.signalTotal} signals · ${snapshot.incidentTotal} incidents${collecting}`; + // Heading, summary, two section titles, and a spacer leave the rest for rows. + const rows = Math.max(0, height - 5); + const incidentRows = Math.min( + snapshot.incidents.length, + Math.max(Math.ceil(rows * 0.6), rows - snapshot.signals.length), + ); + const signalRows = Math.max(0, Math.min(snapshot.signals.length, rows - incidentRows)); + const hiddenIncidents = snapshot.incidents.length - incidentRows; + const titleWidth = Math.max(8, width - 26); + + return ( + + PACK-SENTINEL + + ● + {summary} + + + + Incidents{' '} + + {snapshot.incidents.length === 0 + ? 'none active' + : `${snapshot.incidents.length} active${hiddenIncidents > 0 ? `, ${hiddenIncidents} not shown` : ''}`} + + + {snapshot.incidents.slice(0, incidentRows).map((incident) => ( + + + {cell(severityLabel[incident.severity], 5)} + + + {cell(incident.status === 'open' ? 'open' : 'ack', 5)} + + {cell(clean(incident.host), 10)} + {cell(clean(incident.title), titleWidth)} + {ago(incident.updatedAt, now).padStart(4)} + + ))} + + Recent signals{' '} + {snapshot.signals.length === 0 ? none : null} + + {snapshot.signals.slice(0, signalRows).map((signal) => ( + + + {cell(severityLabel[signal.severity], 5)} + + {cell(clean(signal.source), 15)} + {cell(clean(signal.title), Math.max(8, width - 26))} + {ago(signal.observedAt, now).padStart(4)} + + ))} + + ); +}; diff --git a/src/views/ServicesPanel.tsx b/src/views/ServicesPanel.tsx new file mode 100644 index 0000000..f6b1c6f --- /dev/null +++ b/src/views/ServicesPanel.tsx @@ -0,0 +1,92 @@ +import {Box, Text} from 'ink'; +import type {ServiceStatus} from '../sources/services.js'; +import {cell, clean} from '../text.js'; +import type {ShellTheme} from '../theme.js'; + +export interface ServicesPanelProps { + services: readonly ServiceStatus[]; + /** Configured service names, shown as "checking" until the first poll returns. */ + names: readonly string[]; + width: number; + theme: ShellTheme; +} + +const stateOf = (service: ServiceStatus): string => { + if (!service.reachable) return clean(service.reason ?? 'unreachable'); + if (service.ready) return 'ready'; + if (!service.live) return clean(service.reason ?? 'not live'); + return clean(service.reason ?? 'not ready'); +}; + +const modelsOf = (service: ServiceStatus): string => { + if (service.models === undefined) return ''; + if (service.models.length === 0) return 'no models'; + return service.models + .map((model) => + model.state === undefined || ['ready', 'loaded'].includes(model.state) + ? clean(model.id) + : `${clean(model.id)} (${clean(model.state)})`, + ) + .join(', '); +}; + +const count = (value: number | undefined): string => (value === undefined ? '–' : String(value)); + +export const ServicesPanel = ({services, names, width, theme}: ServicesPanelProps) => { + const byName = new Map(services.map((service) => [service.name, service])); + const ready = services.filter((service) => service.ready).length; + const nameWidth = Math.min(14, Math.max(6, ...names.map((name) => name.length + 2))); + const stateWidth = 20; + const modelWidth = Math.max(0, width - 2 - nameWidth - stateWidth - 14); + return ( + + + SERVICES{' '} + + {names.length === 0 ? 'none configured' : `${ready}/${names.length} ready`} + + + {names.length === 0 ? null : ( + + {' '} + {cell('name', nameWidth)} + {cell('state', stateWidth)} + {cell('queue', 7)} + {cell('active', 7)} + {modelWidth > 0 ? 'models' : ''} + + )} + {names.map((name) => { + const service = byName.get(name); + if (service === undefined) { + return ( + + {'· '} + {cell(clean(name), nameWidth)}checking… + + ); + } + const color = service.ready + ? theme.success + : service.reachable && service.live + ? theme.warning + : theme.danger; + const mark = service.ready ? '●' : service.reachable ? '○' : '×'; + const queueColor = + service.queueDepth !== undefined && service.queueDepth > 0 ? theme.warning : theme.text; + return ( + + {mark} + {cell(clean(name), nameWidth)} + {cell(stateOf(service), stateWidth)} + {cell(count(service.queueDepth), 7)} + {cell(count(service.active), 7)} + {modelWidth > 0 ? ( + {cell(modelsOf(service), modelWidth)} + ) : null} + + ); + })} + + ); +}; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index cb1a4b6..3c47ffa 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,6 @@ import {execFile} from 'node:child_process'; +import {createServer} from 'node:net'; +import type {AddressInfo} from 'node:net'; import {fileURLToPath} from 'node:url'; import {promisify} from 'node:util'; import {describe, expect, it} from 'vitest'; @@ -7,11 +9,29 @@ const run = promisify(execFile); const cli = fileURLToPath(new URL('../src/cli.tsx', import.meta.url)); const tsx = fileURLToPath(new URL('../node_modules/tsx/dist/cli.mjs', import.meta.url)); +/** A local port that nothing is listening on. */ +const closedPort = async (): Promise => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const {port} = server.address() as AddressInfo; + await new Promise((resolve) => server.close(resolve)); + return port; +}; + describe('relay-dash command', () => { it('prints a snapshot of the dashboard without a terminal', async () => { - const {stdout} = await run(process.execPath, [tsx, cli, '--snapshot']); + const port = await closedPort(); + const {stdout} = await run(process.execPath, [ + tsx, + cli, + '--snapshot', + '--no-sentinel', + '--service', + `inference=http://127.0.0.1:${port}`, + ]); expect(stdout).toContain('Relay Dash'); expect(stdout).toContain('q quit'); + expect(stdout).toMatch(/× inference\s+connection refused/); }, 30_000); it('prints the version', async () => { diff --git a/tests/dashboard.test.tsx b/tests/dashboard.test.tsx new file mode 100644 index 0000000..925d813 --- /dev/null +++ b/tests/dashboard.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import {render} from 'ink-testing-library'; +import {describe, expect, it, vi} from 'vitest'; +import type {DashConfig} from '../src/config.js'; +import {Dashboard} from '../src/views/Dashboard.js'; +import {fakeFetch} from './helpers/fake-fetch.js'; + +const url = 'http://127.0.0.1:8080'; +const config: DashConfig = {services: [{name: 'inference', url}], intervalSeconds: 60}; + +const ready = () => + fakeFetch({ + [`${url}/health/live`]: {body: {status: 'live'}}, + [`${url}/health/ready`]: {body: {status: 'ready'}}, + [`${url}/v1/models`]: {body: {data: [{id: 'qwen3-8b'}]}}, + [`${url}/metrics`]: {body: 'impossible_queue_depth 0\n'}, + }); + +describe('Dashboard', () => { + it('polls on mount and redraws with the results', async () => { + const fake = ready(); + const view = render( + , + ); + expect(view.lastFrame()).toContain('checking…'); + await vi.waitFor(() => expect(view.lastFrame()).toMatch(/● inference\s+ready/)); + expect(view.lastFrame()).toContain('qwen3-8b'); + view.unmount(); + }); + + it('polls again on r', async () => { + const fake = ready(); + const view = render( + , + ); + await vi.waitFor(() => expect(view.lastFrame()).toMatch(/ready/)); + const before = fake.calls.length; + view.stdin.write('r'); + await vi.waitFor(() => expect(fake.calls.length).toBeGreaterThan(before)); + view.unmount(); + }); +}); diff --git a/tests/text.test.ts b/tests/text.test.ts new file mode 100644 index 0000000..2ea8b77 --- /dev/null +++ b/tests/text.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from 'vitest'; +import {ago, cell, clean, fit} from '../src/text.js'; + +describe('clean', () => { + it('removes escape sequences and control characters', () => { + expect(clean('\u001B[31mred\u001B[0m \u001B]8;;http://x\u001B\\link\u001B]8;;\u001B\\')).toBe( + 'red link', + ); + expect(clean('a\u0000b\u0007c\u009Bd')).toBe('abcd'); + }); + + it('folds tabs and newlines into one space', () => { + expect(clean(' one\n\ttwo\r\nthree ')).toBe('one two three'); + }); +}); + +describe('fit and cell', () => { + it('cuts long text with an ellipsis', () => { + expect(fit('abcdef', 4)).toBe('abc…'); + expect(fit('abc', 4)).toBe('abc'); + expect(fit('abc', 1)).toBe('…'); + expect(fit('abc', 0)).toBe(''); + }); + + it('pads to a fixed width with a gap after the text', () => { + expect(cell('ab', 4)).toBe('ab '); + expect(cell('abc', 4)).toBe('abc '); + expect(cell('abcd', 4)).toBe('ab… '); + expect(cell('abcdef', 4)).toBe('ab… '); + }); +}); + +describe('ago', () => { + const now = new Date('2026-10-21T12:00:00.000Z'); + it.each([ + ['2026-10-21T11:59:48.000Z', '12s'], + ['2026-10-21T11:56:00.000Z', '4m'], + ['2026-10-21T09:00:00.000Z', '3h'], + ['2026-10-18T12:00:00.000Z', '3d'], + ['2026-10-21T12:00:05.000Z', '0s'], + ['not a date', ''], + ])('%s is %s', (iso, label) => { + expect(ago(iso, now)).toBe(label); + }); +}); diff --git a/tests/views.test.tsx b/tests/views.test.tsx new file mode 100644 index 0000000..e49ef3c --- /dev/null +++ b/tests/views.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import {render} from 'ink-testing-library'; +import {describe, expect, it} from 'vitest'; +import type {DashConfig} from '../src/config.js'; +import type {DashboardData} from '../src/sources/poll.js'; +import type {SentinelIncident} from '../src/sources/sentinel.js'; +import {DashboardView} from '../src/views/Dashboard.js'; + +const now = new Date('2026-10-21T13:10:00.000Z'); +const checkedAt = new Date('2026-10-21T13:09:57.000Z'); + +const config: DashConfig = { + sentinel: {url: 'http://127.0.0.1:7331'}, + services: [ + {name: 'inference', url: 'http://127.0.0.1:8080'}, + {name: 'ocr', url: 'http://127.0.0.1:8082'}, + {name: 'voice', url: 'http://127.0.0.1:8083'}, + ], + intervalSeconds: 5, +}; + +const incident = (id: string, overrides: Partial = {}): SentinelIncident => ({ + id, + title: `Incident ${id}`, + summary: '', + severity: 'medium', + status: 'open', + host: 'den', + ruleId: 'rule', + signalCount: 1, + createdAt: '2026-10-21T13:00:00.000Z', + updatedAt: '2026-10-21T13:05:00.000Z', + ...overrides, +}); + +const data: DashboardData = { + sentinel: { + ok: true, + checkedAt, + snapshot: { + ok: true, + signalTotal: 42, + incidentTotal: 7, + running: ['ssh-guard'], + scheduled: 3, + incidents: [ + incident('1', {severity: 'critical', title: 'SSH brute force from 203.0.113.9'}), + incident('2', {status: 'acknowledged', title: 'Open port 2375'}), + ], + signals: [ + { + id: 's1', + source: 'ssh-guard', + kind: 'auth.failure', + title: 'Failed password for root', + severity: 'high', + host: 'den', + observedAt: '2026-10-21T13:09:00.000Z', + }, + ], + latencyMs: 4, + }, + }, + services: [ + { + name: 'inference', + url: 'http://127.0.0.1:8080', + reachable: true, + live: true, + ready: true, + models: [{id: 'qwen3-8b', state: 'loaded'}], + queueDepth: 3, + active: 2, + checkedAt, + }, + { + name: 'ocr', + url: 'http://127.0.0.1:8082', + reachable: true, + live: true, + ready: false, + reason: 'model_loading', + models: [], + checkedAt, + }, + { + name: 'voice', + url: 'http://127.0.0.1:8083', + reachable: false, + live: false, + ready: false, + reason: 'connection refused', + checkedAt, + }, + ], +}; + +const frame = (element: React.ReactElement): string => render(element).lastFrame() ?? ''; + +describe('DashboardView', () => { + it('shows each service with its state, queue, and models', () => { + const output = frame( + , + ); + expect(output).toContain('Relay Dash · updated 3s ago'); + expect(output).toContain('SERVICES 1/3 ready'); + expect(output).toMatch(/● inference\s+ready\s+3\s+2\s+qwen3-8b/); + expect(output).toMatch(/○ ocr\s+model_loading\s+–\s+–\s+no models/); + expect(output).toMatch(/× voice\s+connection refused/); + }); + + it('shows pack-sentinel incidents and signals', () => { + const output = frame( + , + ); + expect(output).toContain('42 signals · 7 incidents · collecting ssh-guard'); + expect(output).toContain('Incidents 2 active'); + expect(output).toMatch(/CRIT\s+open\s+den\s+SSH brute force from 203\.0\.113\.9\s+5m/); + expect(output).toMatch(/MED\s+ack\s+den\s+Open port 2375/); + expect(output).toMatch(/HIGH\s+ssh-guard\s+Failed password for root\s+1m/); + expect(output).toContain('q quit · r refresh · every 5s'); + }); + + it('fills exactly the requested size', () => { + const lines = frame( + , + ).split('\n'); + expect(lines).toHaveLength(12); + expect(lines.every((line) => line.length <= 60)).toBe(true); + }); + + it('says how many incidents did not fit', () => { + const many = Array.from({length: 30}, (_, index) => incident(String(index))); + const crowded: DashboardData = { + ...data, + sentinel: { + ok: true, + checkedAt, + snapshot: { + ...(data.sentinel?.ok ? data.sentinel.snapshot : ({} as never)), + incidents: many, + }, + }, + }; + const output = frame( + , + ); + expect(output).toMatch(/Incidents 30 active, \d+ not shown/); + }); + + it('shows an unreachable pack-sentinel', () => { + const output = frame( + , + ); + expect(output).toContain('× http://127.0.0.1:7331: HTTP 401'); + }); + + it('waits for the first poll', () => { + const output = frame(); + expect(output).toContain('waiting for the first poll'); + expect(output).toContain('· inference'); + expect(output).toContain('checking…'); + expect(output).toContain('Connecting to http://127.0.0.1:7331'); + }); + + it('strips terminal control sequences from watched text', () => { + const hostile: DashboardData = { + services: [], + sentinel: { + ok: true, + checkedAt, + snapshot: { + ...(data.sentinel?.ok ? data.sentinel.snapshot : ({} as never)), + incidents: [incident('x', {title: 'evil\u001B[2J\u001B]0;pwned\u0007 title\nnext'})], + }, + }, + }; + const output = frame( + , + ); + expect(output).toContain('evil title next'); + expect(output).not.toContain('pwned'); + expect(output).not.toContain('[2J'); + }); + + it('notes when pack-sentinel is turned off', () => { + const rest: DashConfig = {services: config.services, intervalSeconds: 5}; + const output = frame( + , + ); + expect(output).toContain('PACK-SENTINEL'); + expect(output).toContain('Not configured.'); + }); +});