diff --git a/.github/social-preview.png b/.github/social-preview.png new file mode 100644 index 0000000..4025b70 Binary files /dev/null and b/.github/social-preview.png differ 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..5c04538 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# Relay Dash +

+ Relay Dash +

-Relay Dash is a terminal dashboard for the services we run. It shows open findings from [pack-sentinel](https://github.com/dispersal-wolves/pack-sentinel) next to the status of the impossibleG services: which models are loaded, how deep each request queue is, and whether every service is live and ready. +Relay Dash is a terminal dashboard for the services we run. It shows open findings from [pack-sentinel](https://github.com/dispersal-wolves/pack-sentinel) next to the status of the impossibleG services: which models are loaded, how deep each request queue is, and whether every service is live and ready. It only reads; it never acknowledges incidents, runs collectors, or loads models. ## Try it @@ -9,7 +11,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 @@ -40,13 +42,21 @@ When pack-sentinel has `apiToken` set, put the token in `RELAY_DASH_SENTINEL_TOK relay-dash --json --no-sentinel --service inference=http://127.0.0.1:8080 ``` +## Documentation + +- [Architecture](./docs/architecture.md) +- [Artwork](./docs/assets/ARTWORK.md) +- [Contributing](./CONTRIBUTING.md) +- [Security](./SECURITY.md) + ## Development ```sh +npm install npm run check ``` -Relay Dash requires Node.js 22 or newer. See [the architecture notes](./docs/architecture.md) and [CONTRIBUTING.md](./CONTRIBUTING.md). +`npm run check` verifies formatting, linting, strict types, tests, and the distributable package. Relay Dash requires Node.js 22 or newer. ## License 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/docs/assets/ARTWORK.md b/docs/assets/ARTWORK.md new file mode 100644 index 0000000..f8011ca --- /dev/null +++ b/docs/assets/ARTWORK.md @@ -0,0 +1,24 @@ +# Relay Dash artwork + +The repository banner and social preview are drawn from [`artwork.html`](./artwork.html), an SVG composition that uses the official Relay mark and the shared Relay palette. + +## Banner + +`banner.png` uses a 3:1 composition. The left side contains the Relay mark, product name, and a direct description. The right side depicts the real application structure: the services table with ready state, queue depth, and models, and the pack-sentinel panel with active incidents by severity. + +## Social preview + +`.github/social-preview.png` recomposes the same interface for a 2:1 thumbnail. The description is removed and the panel labels stay large enough to read in repository cards. + +Both use monospace interface typography, restrained terminal borders, and only the data the dashboard really shows. There is no decorative-code, robot, or chart imagery. + +## Rendering + +Open `artwork.html` in a Chromium browser, or render both images headlessly from the repository root: + +```sh +chrome --headless=new --hide-scrollbars --force-device-scale-factor=1 --window-size=2172,724 --screenshot=docs/assets/banner.png docs/assets/artwork.html +chrome --headless=new --hide-scrollbars --force-device-scale-factor=1 --window-size=1774,887 --screenshot=.github/social-preview.png "docs/assets/artwork.html?social" +``` + +The banner is 2172×724 and the social preview is 1774×887, matching the other Relay repositories. Upload the social preview in the repository settings under Social preview. diff --git a/docs/assets/artwork.html b/docs/assets/artwork.html new file mode 100644 index 0000000..06b93bc --- /dev/null +++ b/docs/assets/artwork.html @@ -0,0 +1,160 @@ + + + + + + Relay Dash artwork + + + + + + + diff --git a/docs/assets/banner.png b/docs/assets/banner.png new file mode 100644 index 0000000..7a64a80 Binary files /dev/null and b/docs/assets/banner.png differ 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.'); + }); +});