Skip to content
Merged
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
Binary file added .github/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Relay Dash
<p align="center">
<img src="./docs/assets/banner.png" alt="Relay Dash" width="100%">
</p>

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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
24 changes: 24 additions & 0 deletions docs/assets/ARTWORK.md
Original file line number Diff line number Diff line change
@@ -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.
160 changes: 160 additions & 0 deletions docs/assets/artwork.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<!doctype html>
<!--
Source for banner.png and .github/social-preview.png. Render with:
see docs/assets/ARTWORK.md
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Relay Dash artwork</title>
<style>
html,
body {
margin: 0;
background: #080b0f;
}
svg {
display: block;
}
text {
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
}
.title {
font-family: 'Segoe UI', 'Inter', Arial, sans-serif;
font-weight: 800;
letter-spacing: 4px;
fill: #eef2f6;
}
.tagline {
fill: #a3adb8;
font-size: 27px;
}
.label {
font-weight: 700;
font-size: 26px;
letter-spacing: 1px;
}
.row {
font-size: 24px;
fill: #e8edf2;
}
.muted {
fill: #84909c;
}
</style>
</head>
<body>
<svg id="art" xmlns="http://www.w3.org/2000/svg"></svg>
<script>
const social = new URLSearchParams(location.search).has('social');
const palette = {
background: '#080B0F',
surface: '#0E141B',
rule: '#2A3440',
blue: '#48A8FF',
orange: '#FF8A3D',
green: '#4FD18B',
red: '#FF4D5A',
yellow: '#F0C84B',
};

// The Relay mark: a blue and an orange chevron joined by a dark square.
const mark = (x, y, size) => `
<g transform="translate(${x} ${y}) scale(${size / 640})">
<polyline points="40,40 250,245 40,450" fill="none" stroke="${palette.blue}"
stroke-width="112" stroke-linecap="round" stroke-linejoin="round" />
<polyline points="330,330 520,520 330,705" fill="none" stroke="${palette.orange}"
stroke-width="112" stroke-linecap="round" stroke-linejoin="round" />
<rect x="222" y="262" width="150" height="150" rx="22" fill="#151B23" />
</g>`;

const card = (x, y, width, height, accent, label, body) => `
<g transform="translate(${x} ${y})">
<rect width="${width}" height="${height}" rx="10" fill="${palette.surface}"
stroke="${palette.rule}" stroke-width="2" />
<path d="M10 0 H14 V${height} H10 A10 10 0 0 1 0 ${height - 10} V10 A10 10 0 0 1 10 0 Z"
fill="${accent}" />
<text x="40" y="50" class="label" fill="${accent}">${label}</text>
${body}
</g>`;

const services = [
['●', palette.green, 'inference', 'ready', '3', 'qwen3-8b'],
['●', palette.green, 'embeddings', 'ready', '0', 'bge-small-en'],
['○', palette.yellow, 'ocr', 'model_loading', '–', ''],
];
const incidents = [
['CRIT', palette.red, 'SSH brute force from 203.0.113.9', '2m'],
['HIGH', palette.red, 'Secret committed: AWS access key', '14m'],
['MED', palette.yellow, 'Honeyfile opened: payroll.xlsx', '3h'],
];

const servicesBody = (width) =>
`<text x="${width - 36}" y="50" class="row muted" text-anchor="end">2/3 ready</text>` +
services
.map(
([dot, color, name, state, queue, model], index) => `
<g transform="translate(40 ${98 + index * 40})">
<text class="row" fill="${color}" style="fill:${color}">${dot}</text>
<text x="36" class="row">${name}</text>
<text x="250" class="row" style="fill:${color}">${state}</text>
<text x="470" class="row muted">queue</text>
<text x="552" class="row" style="fill:${queue === '3' ? palette.yellow : '#E8EDF2'}">${queue}</text>
<text x="610" class="row muted">${model}</text>
</g>`,
)
.join('');

const sentinelBody = (width) =>
`<text x="${width - 36}" y="50" class="row muted" text-anchor="end">1284 signals · 17 incidents</text>` +
incidents
.map(
([severity, color, title, age], index) => `
<g transform="translate(40 ${98 + index * 40})">
<text class="row" style="fill:${color};font-weight:700">${severity}</text>
<text x="90" class="row muted">den</text>
<text x="160" class="row">${title}</text>
<text x="${width - 76}" class="row muted" text-anchor="end">${age}</text>
</g>`,
)
.join('');

const terminal = (x, y, width, height) => {
const inner = width - 40;
return `
<g transform="translate(${x} ${y})">
<rect width="${width}" height="${height}" rx="14" fill="#0A0F14"
stroke="${palette.rule}" stroke-width="2" />
<text x="24" y="44" class="row muted" style="font-size:26px">$ <tspan fill="#E8EDF2" style="fill:#E8EDF2">relay-dash</tspan></text>
<text x="${width - 24}" y="44" class="row muted" text-anchor="end" style="font-size:24px">updated 2s ago</text>
${card(20, 70, inner, 220, palette.blue, 'SERVICES', servicesBody(inner))}
${card(20, 308, inner, 220, palette.orange, 'PACK-SENTINEL', sentinelBody(inner))}
</g>`;
};

const svg = document.getElementById('art');
if (social) {
svg.setAttribute('width', 1774);
svg.setAttribute('height', 887);
svg.setAttribute('viewBox', '0 0 2000 1000');
svg.innerHTML = `
<rect width="2000" height="1000" fill="${palette.background}" />
${mark(200, 200, 320)}
<text x="370" y="730" class="title" font-size="86" text-anchor="middle">RELAY DASH</text>
<line x1="740" y1="170" x2="740" y2="830" stroke="${palette.rule}" stroke-width="2" />
${terminal(790, 210, 1150, 560)}`;
} else {
svg.setAttribute('width', 2172);
svg.setAttribute('height', 724);
svg.setAttribute('viewBox', '0 0 2000 667');
svg.innerHTML = `
<rect width="2000" height="667" fill="${palette.background}" />
${mark(60, 190, 250)}
<text x="320" y="330" class="title" font-size="92">RELAY DASH</text>
<text x="324" y="388" class="tagline">Services and findings, at a glance.</text>
<line x1="975" y1="50" x2="975" y2="617" stroke="${palette.rule}" stroke-width="2" />
${terminal(1015, 55, 945, 557)}`;
}
</script>
</body>
</html>
Binary file added docs/assets/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 9 additions & 6 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
let options;
Expand Down Expand Up @@ -43,15 +43,18 @@ const main = async (): Promise<void> => {
}

if (options.snapshot || !process.stdout.isTTY || !process.stdin.isTTY) {
const columns = process.stdout.columns || 80;
const frame = renderToString(<Dashboard width={columns} height={24} interactive={false} />, {
columns,
});
const columns = process.stdout.columns || 100;
const rows = process.stdout.rows || 24;
const data = await pollOnce(config);
const frame = renderToString(
<DashboardView config={config} data={data} width={columns} height={rows} />,
{columns},
);
process.stdout.write(`${frame}\n`);
return;
}

await runDashboard(<Dashboard />, {alternateScreen: !options.inline});
await runDashboard(<Dashboard config={config} />, {alternateScreen: !options.inline});
};

await main();
66 changes: 66 additions & 0 deletions src/data/usePoll.ts
Original file line number Diff line number Diff line change
@@ -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<RequestOptions, 'signal'> = {},
initial?: DashboardData,
): PollState => {
const [data, setData] = useState<DashboardData | undefined>(initial);
const [polling, setPolling] = useState(false);
const inFlight = useRef(false);
const controller = useRef(new AbortController());
const timer = useRef<NodeJS.Timeout | undefined>(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()};
};
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions src/sources/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading