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
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`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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`.
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
40 changes: 40 additions & 0 deletions src/text.ts
Original file line number Diff line number Diff line change
@@ -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`;
};
117 changes: 93 additions & 24 deletions src/views/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Frame
title="Relay Dash"
theme={theme}
{...(width === undefined ? {} : {width})}
{...(height === undefined ? {} : {height})}
footer={<Text color={theme.muted}>q quit</Text>}
>
{interactive ? <QuitKey /> : null}
<Text color={theme.muted}>No sources are configured yet.</Text>
</Frame>
);
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 (
<Frame
title="Relay Dash"
subtitle={polling ? 'polling…' : age}
theme={theme}
width={columns}
height={rows}
footer={
<Text color={theme.muted} wrap="truncate-end">
q quit · r refresh · every {config.intervalSeconds}s
</Text>
}
>
<ServicesPanel services={data?.services ?? []} names={names} width={columns} theme={theme} />
<Box height={1} />
<SentinelPanel
{...(data?.sentinel === undefined ? {} : {result: data.sentinel})}
{...(config.sentinel === undefined ? {} : {url: config.sentinel.url})}
width={columns}
height={sentinelHeight}
theme={theme}
now={now}
/>
</Frame>
);
};

export interface DashboardProps extends Omit<DashboardViewProps, 'data' | 'polling' | 'now'> {
/** Passed to every request, for example a custom `fetch` or a timeout. */
requestOptions?: Omit<RequestOptions, 'signal'>;
/** 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 (
<DashboardView {...props} {...(data === undefined ? {} : {data})} polling={polling} now={now} />
);
};
Loading
Loading