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 @@ -3,3 +3,4 @@
## Unreleased

- 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.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@ Press `q` to quit. To print one frame without opening the interactive screen:
npx tsx src/cli.tsx --snapshot
```

## Sources

By default Relay Dash polls pack-sentinel at `http://127.0.0.1:7331` and Impossible Inference at `http://127.0.0.1:8080`. To watch more services, copy [`relay-dash.example.json`](./relay-dash.example.json) to `relay-dash.json` in the directory you run from, or pass sources on the command line:

```sh
relay-dash --sentinel http://den:7331 --service inference=http://127.0.0.1:8080 --service ocr=http://127.0.0.1:8082
```

Set `"sentinel": false` in the file, or pass `--no-sentinel`, to watch services only.

When pack-sentinel has `apiToken` set, put the token in `RELAY_DASH_SENTINEL_TOKEN` (or the variable named by `sentinel.tokenEnv`). Relay Dash refuses a token written in the config file.

| Source | What Relay Dash reads |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| pack-sentinel | `/api/v1/health`, `/api/v1/state`, open and acknowledged `/api/v1/incidents`, recent `/api/v1/signals` |
| impossibleG services | `/health/live` and `/health/ready` (or `/healthz` and `/readyz`), `/v1/models`, and `impossible_queue_depth` and `*_active_requests` from `/metrics` |

## Check once

`--json` polls every source once, prints the results, and exits with status 1 if pack-sentinel is unreachable or any service is not ready. It works in scripts and cron jobs:

```sh
relay-dash --json --no-sentinel --service inference=http://127.0.0.1:8080
```

## Development

```sh
Expand Down
12 changes: 12 additions & 0 deletions relay-dash.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"sentinel": {"url": "http://127.0.0.1:7331"},
"services": [
{"name": "inference", "url": "http://127.0.0.1:8080"},
{"name": "embeddings", "url": "http://127.0.0.1:8081"},
{"name": "ocr", "url": "http://127.0.0.1:8082"},
{"name": "voice", "url": "http://127.0.0.1:8083"},
{"name": "mcp", "url": "http://127.0.0.1:3333"},
{"name": "agent", "url": "http://127.0.0.1:4141"}
],
"intervalSeconds": 5
}
22 changes: 22 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import {renderToString} from 'ink';
import {helpText, parseCliOptions} from './cli/options.js';
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';
Expand All @@ -20,6 +22,26 @@ const main = async (): Promise<void> => {
if (options.help) return void process.stdout.write(helpText);
if (options.version) return void process.stdout.write(`${relayDashVersion}\n`);

let config;
try {
config = await loadConfig(options);
} catch (error) {
if (!(error instanceof ConfigError)) throw error;
process.stderr.write(`relay-dash: ${error.message}\n`);
process.exitCode = 2;
return;
}

if (options.json) {
const data = await pollOnce(config);
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
const healthy =
(data.sentinel === undefined || data.sentinel.ok) &&
data.services.every((service) => service.ready);
if (!healthy) process.exitCode = 1;
return;
}

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} />, {
Expand Down
66 changes: 54 additions & 12 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,73 @@
export interface CliOptions {
import type {ConfigOverrides} from '../config.js';

export interface CliOptions extends ConfigOverrides {
help: boolean;
version: boolean;
inline: boolean;
snapshot: boolean;
json: boolean;
}

export const helpText = `Relay Dash

Usage:
relay-dash
relay-dash --snapshot

Options:
--inline Render in the current screen instead of the alternate screen
--snapshot Print one frame of the dashboard and exit
-h, --help Show this help
-v, --version Show the version
relay-dash [options]
relay-dash --json

Sources:
--config <path> Read sources from a JSON file (default: ./relay-dash.json if present)
--sentinel <url> pack-sentinel address (default: http://127.0.0.1:7331)
--no-sentinel Do not poll pack-sentinel
--service <name=url> Watch an impossibleG service; repeat for several
--interval <seconds> Seconds between polls (default: 5)

Output:
--json Poll every source once, print the results as JSON, and exit
--inline Render in the current screen instead of the alternate screen
--snapshot Print one frame of the dashboard and exit
-h, --help Show this help
-v, --version Show the version

The pack-sentinel API token is read from RELAY_DASH_SENTINEL_TOKEN.
`;

const valueOptions = new Set(['--config', '--sentinel', '--service', '--interval']);

export const parseCliOptions = (arguments_: readonly string[]): CliOptions => {
const options: CliOptions = {help: false, version: false, inline: false, snapshot: false};
for (const argument of arguments_) {
const options: CliOptions = {
help: false,
version: false,
inline: false,
snapshot: false,
json: false,
};
for (let index = 0; index < arguments_.length; index++) {
let argument = arguments_[index] ?? '';
let value: string | undefined;
const equals = argument.indexOf('=');
if (argument.startsWith('--') && equals > 0) {
value = argument.slice(equals + 1);
argument = argument.slice(0, equals);
}
if (valueOptions.has(argument) && value === undefined) {
value = arguments_[++index];
if (value === undefined) throw new Error(`${argument} needs a value`);
}
if (argument === '-h' || argument === '--help') options.help = true;
else if (argument === '-v' || argument === '--version') options.version = true;
else if (argument === '--inline') options.inline = true;
else if (argument === '--snapshot') options.snapshot = true;
else throw new Error(`Unknown option: ${argument}`);
else if (argument === '--json') options.json = true;
else if (argument === '--no-sentinel') options.noSentinel = true;
else if (argument === '--config' && value !== undefined) options.configPath = value;
else if (argument === '--sentinel' && value !== undefined) options.sentinelUrl = value;
else if (argument === '--service' && value !== undefined)
options.services = [...(options.services ?? []), value];
else if (argument === '--interval' && value !== undefined) {
const seconds = Number(value);
if (!Number.isFinite(seconds)) throw new Error(`--interval expects seconds, got: ${value}`);
options.intervalSeconds = seconds;
} else throw new Error(`Unknown option: ${arguments_[index] ?? ''}`);
}
return options;
};
173 changes: 173 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {readFile} from 'node:fs/promises';
import {isRecord} from './sources/http.js';
import type {SentinelSource} from './sources/sentinel.js';
import type {ServiceSource} from './sources/services.js';

export interface DashConfig {
/** Omitted when pack-sentinel is turned off with `"sentinel": false`. */
sentinel?: SentinelSource;
services: ServiceSource[];
/** Seconds between polls. */
intervalSeconds: number;
}

/** The token is never read from the config file or the command line. */
export const defaultTokenEnv = 'RELAY_DASH_SENTINEL_TOKEN';

export const defaultConfig: DashConfig = {
sentinel: {url: 'http://127.0.0.1:7331'},
services: [{name: 'inference', url: 'http://127.0.0.1:8080'}],
intervalSeconds: 5,
};

export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigError';
}
}

const checkUrl = (value: unknown, where: string): string => {
if (typeof value !== 'string') throw new ConfigError(`${where} must be a URL string`);
let url: URL;
try {
url = new URL(value);
} catch {
throw new ConfigError(`${where} is not a valid URL: ${value}`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new ConfigError(`${where} must use http or https: ${value}`);
}
return value;
};

const checkInterval = (value: unknown, where: string): number => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 3600) {
throw new ConfigError(`${where} must be a number of seconds between 1 and 3600`);
}
return value;
};

const checkUniqueNames = (services: readonly ServiceSource[]): void => {
const names = new Set<string>();
for (const service of services) {
if (names.has(service.name)) throw new ConfigError(`duplicate service name: ${service.name}`);
names.add(service.name);
}
};

/** Parse `name=url` from `--service`. */
export const parseServiceFlag = (value: string): ServiceSource => {
const index = value.indexOf('=');
const name = value.slice(0, Math.max(0, index)).trim();
if (index <= 0 || name === '') throw new ConfigError(`--service expects name=url, got: ${value}`);
return {name, url: checkUrl(value.slice(index + 1).trim(), `--service ${name}`)};
};

/**
* Validate a parsed config file. Unknown keys are rejected so a typo does not
* silently fall back to a default.
*/
export const parseConfig = (
value: unknown,
env: Record<string, string | undefined> = {},
): DashConfig => {
if (!isRecord(value)) throw new ConfigError('the config file must contain a JSON object');
for (const key of Object.keys(value)) {
if (!['$schema', 'sentinel', 'services', 'intervalSeconds'].includes(key)) {
throw new ConfigError(`unknown config key: ${key}`);
}
}
const config: DashConfig = {...defaultConfig};
const defaultToken = env[defaultTokenEnv];
if (defaultConfig.sentinel && defaultToken) {
config.sentinel = {...defaultConfig.sentinel, token: defaultToken};
}
if (value.sentinel === false) {
delete config.sentinel;
} else if (value.sentinel !== undefined) {
if (!isRecord(value.sentinel)) throw new ConfigError('sentinel must be an object or false');
const tokenEnv = value.sentinel.tokenEnv ?? defaultTokenEnv;
if (typeof tokenEnv !== 'string') throw new ConfigError('sentinel.tokenEnv must be a string');
if ('token' in value.sentinel) {
throw new ConfigError(
`sentinel.token is not read from the config file; set ${tokenEnv} instead`,
);
}
const token = env[tokenEnv];
config.sentinel = {
url: checkUrl(value.sentinel.url, 'sentinel.url'),
...(token ? {token} : {}),
};
}
if (value.services !== undefined) {
if (!Array.isArray(value.services)) throw new ConfigError('services must be an array');
config.services = value.services.map((entry: unknown, index) => {
if (!isRecord(entry) || typeof entry.name !== 'string' || entry.name.trim() === '') {
throw new ConfigError(`services[${index}] needs a name`);
}
return {name: entry.name, url: checkUrl(entry.url, `services[${index}].url`)};
});
}
if (value.intervalSeconds !== undefined) {
config.intervalSeconds = checkInterval(value.intervalSeconds, 'intervalSeconds');
}
checkUniqueNames(config.services);
return config;
};

export interface ConfigOverrides {
configPath?: string;
sentinelUrl?: string;
noSentinel?: boolean;
services?: string[];
intervalSeconds?: number;
}

const readConfigFile = async (path: string, required: boolean): Promise<unknown> => {
let text: string;
try {
text = await readFile(path, 'utf8');
} catch (error) {
if (!required && (error as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw new ConfigError(`cannot read ${path}: ${(error as Error).message}`);
}
try {
return JSON.parse(text) as unknown;
} catch (error) {
throw new ConfigError(`${path} is not valid JSON: ${(error as Error).message}`);
}
};

/**
* Load `relay-dash.json` from the working directory (or `--config`), then
* apply command-line overrides. A missing default file is fine; a missing
* `--config` file is an error.
*/
export const loadConfig = async (
overrides: ConfigOverrides = {},
env: Record<string, string | undefined> = process.env,
): Promise<DashConfig> => {
const file = await readConfigFile(
overrides.configPath ?? 'relay-dash.json',
overrides.configPath !== undefined,
);
const config = parseConfig(file, env);
if (overrides.noSentinel) {
delete config.sentinel;
} else if (overrides.sentinelUrl !== undefined) {
const token = config.sentinel?.token ?? env[defaultTokenEnv];
config.sentinel = {
url: checkUrl(overrides.sentinelUrl, '--sentinel'),
...(token ? {token} : {}),
};
}
if (overrides.services !== undefined && overrides.services.length > 0) {
config.services = overrides.services.map(parseServiceFlag);
}
if (overrides.intervalSeconds !== undefined) {
config.intervalSeconds = checkInterval(overrides.intervalSeconds, '--interval');
}
checkUniqueNames(config.services);
return config;
};
40 changes: 40 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
export {
ConfigError,
defaultConfig,
defaultTokenEnv,
loadConfig,
parseConfig,
parseServiceFlag,
type ConfigOverrides,
type DashConfig,
} from './config.js';
export {
describeError,
HttpError,
joinUrl,
type Fetch,
type RequestOptions,
} from './sources/http.js';
export {parseMetrics, sumMetric, type Sample} from './sources/metrics.js';
export {pollOnce, type DashboardData} from './sources/poll.js';
export {
checkSentinel,
compareIncidents,
fetchSentinel,
severities,
severityRank,
type IncidentStatus,
type SentinelIncident,
type SentinelResult,
type SentinelSignal,
type SentinelSnapshot,
type SentinelSource,
type Severity,
} from './sources/sentinel.js';
export {
checkService,
parseModels,
type ServiceModel,
type ServiceSource,
type ServiceStatus,
} from './sources/services.js';
export {Dashboard, type DashboardProps} from './views/Dashboard.js';
export {runDashboard, type RunDashboardOptions} from './run.js';
export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js';
Expand Down
Loading
Loading