From 74252341d566a2e16c0a921e51b3f9762139789d Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:51:15 -0300 Subject: [PATCH] feat: add pack-sentinel and impossibleG service clients --- CHANGELOG.md | 1 + README.md | 25 ++++++ relay-dash.example.json | 12 +++ src/cli.tsx | 22 +++++ src/cli/options.ts | 66 +++++++++++--- src/config.ts | 173 ++++++++++++++++++++++++++++++++++++ src/index.ts | 40 +++++++++ src/sources/http.ts | 74 +++++++++++++++ src/sources/metrics.ts | 48 ++++++++++ src/sources/poll.ts | 24 +++++ src/sources/sentinel.ts | 158 ++++++++++++++++++++++++++++++++ src/sources/services.ts | 168 ++++++++++++++++++++++++++++++++++ tests/cli-json.test.ts | 68 ++++++++++++++ tests/cli-options.test.ts | 34 ++++++- tests/config.test.ts | 130 +++++++++++++++++++++++++++ tests/helpers/fake-fetch.ts | 28 ++++++ tests/metrics.test.ts | 46 ++++++++++ tests/sentinel.test.ts | 128 ++++++++++++++++++++++++++ tests/services.test.ts | 130 +++++++++++++++++++++++++++ 19 files changed, 1362 insertions(+), 13 deletions(-) create mode 100644 relay-dash.example.json create mode 100644 src/config.ts create mode 100644 src/sources/http.ts create mode 100644 src/sources/metrics.ts create mode 100644 src/sources/poll.ts create mode 100644 src/sources/sentinel.ts create mode 100644 src/sources/services.ts create mode 100644 tests/cli-json.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/helpers/fake-fetch.ts create mode 100644 tests/metrics.test.ts create mode 100644 tests/sentinel.test.ts create mode 100644 tests/services.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c7672..fdb51ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 9564162..2563aaf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/relay-dash.example.json b/relay-dash.example.json new file mode 100644 index 0000000..c921bfc --- /dev/null +++ b/relay-dash.example.json @@ -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 +} diff --git a/src/cli.tsx b/src/cli.tsx index 1c2c008..b7a1989 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -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'; @@ -20,6 +22,26 @@ const main = async (): Promise => { 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(, { diff --git a/src/cli/options.ts b/src/cli/options.ts index ec02f33..40d5ce7 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -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 Read sources from a JSON file (default: ./relay-dash.json if present) + --sentinel pack-sentinel address (default: http://127.0.0.1:7331) + --no-sentinel Do not poll pack-sentinel + --service Watch an impossibleG service; repeat for several + --interval 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; }; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..833ba27 --- /dev/null +++ b/src/config.ts @@ -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(); + 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 = {}, +): 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 => { + 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 = process.env, +): Promise => { + 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; +}; diff --git a/src/index.ts b/src/index.ts index ba1c012..9186431 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/sources/http.ts b/src/sources/http.ts new file mode 100644 index 0000000..b2238d6 --- /dev/null +++ b/src/sources/http.ts @@ -0,0 +1,74 @@ +export type Fetch = (input: string, init?: RequestInit) => Promise; + +export interface RequestOptions { + fetch?: Fetch; + /** Abort a request that takes longer than this. Defaults to 3 seconds. */ + timeoutMs?: number; + headers?: Record; + signal?: AbortSignal; +} + +export interface HttpResult { + status: number; + body: string; + latencyMs: number; +} + +/** Join a base address and a path without doubling or dropping the slash. */ +export const joinUrl = (base: string, path: string): string => + `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; + +/** + * GET a URL and return the status and body. Network failures and timeouts + * throw; HTTP error statuses do not, because health endpoints use 503 to say + * "not ready". + */ +export const get = async (url: string, options: RequestOptions = {}): Promise => { + const fetch_ = options.fetch ?? fetch; + const timeout = AbortSignal.timeout(options.timeoutMs ?? 3000); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + const started = performance.now(); + const response = await fetch_(url, { + headers: {accept: 'application/json, text/plain;q=0.9', ...options.headers}, + signal, + }); + const body = await response.text(); + return {status: response.status, body, latencyMs: Math.round(performance.now() - started)}; +}; + +export class HttpError extends Error { + constructor( + readonly url: string, + readonly status: number, + ) { + super(`${url} answered ${status}`); + this.name = 'HttpError'; + } +} + +/** GET a URL that must answer 2xx with JSON. */ +export const getJson = async (url: string, options: RequestOptions = {}): Promise => { + const result = await get(url, options); + if (result.status < 200 || result.status >= 300) throw new HttpError(url, result.status); + return JSON.parse(result.body) as unknown; +}; + +/** Describe a failed request in a few words suitable for a dashboard cell. */ +export const describeError = (error: unknown): string => { + if (error instanceof HttpError) return `HTTP ${error.status}`; + if (error instanceof DOMException && error.name === 'TimeoutError') return 'timed out'; + if (error instanceof SyntaxError) return 'invalid JSON'; + if (error instanceof Error) { + const cause = (error as Error & {cause?: unknown}).cause; + if (cause && typeof cause === 'object' && 'code' in cause && typeof cause.code === 'string') { + if (cause.code === 'ECONNREFUSED') return 'connection refused'; + if (cause.code === 'ENOTFOUND') return 'host not found'; + return cause.code; + } + return error.message; + } + return String(error); +}; + +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/sources/metrics.ts b/src/sources/metrics.ts new file mode 100644 index 0000000..62170ce --- /dev/null +++ b/src/sources/metrics.ts @@ -0,0 +1,48 @@ +export interface Sample { + name: string; + labels: Record; + value: number; +} + +const samplePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{(.*)\})?\s+(\S+)/; +const labelPattern = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g; + +const parseValue = (raw: string): number => { + if (raw === '+Inf') return Infinity; + if (raw === '-Inf') return -Infinity; + return Number(raw); +}; + +/** Parse the Prometheus text exposition format into samples. Comments and malformed lines are skipped. */ +export const parseMetrics = (text: string): Sample[] => { + const samples: Sample[] = []; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) continue; + const match = samplePattern.exec(trimmed); + if (!match) continue; + const [, name = '', labelText = '', rawValue = ''] = match; + const value = parseValue(rawValue); + if (Number.isNaN(value)) continue; + const labels: Record = {}; + for (const label of labelText.matchAll(labelPattern)) { + labels[label[1] ?? ''] = (label[2] ?? '').replace(/\\(.)/g, (_, char: string) => + char === 'n' ? '\n' : char, + ); + } + samples.push({name, labels, value}); + } + return samples; +}; + +/** Sum every sample whose name matches. Returns `undefined` when none does. */ +export const sumMetric = ( + samples: readonly Sample[], + matches: (name: string) => boolean, +): number | undefined => { + let total: number | undefined; + for (const sample of samples) { + if (matches(sample.name)) total = (total ?? 0) + sample.value; + } + return total; +}; diff --git a/src/sources/poll.ts b/src/sources/poll.ts new file mode 100644 index 0000000..da9f377 --- /dev/null +++ b/src/sources/poll.ts @@ -0,0 +1,24 @@ +import type {DashConfig} from '../config.js'; +import type {RequestOptions} from './http.js'; +import {checkSentinel} from './sentinel.js'; +import type {SentinelResult} from './sentinel.js'; +import {checkService} from './services.js'; +import type {ServiceStatus} from './services.js'; + +export interface DashboardData { + /** Undefined when pack-sentinel is not configured. */ + sentinel?: SentinelResult; + services: ServiceStatus[]; +} + +/** Check every configured source once, in parallel. Never throws. */ +export const pollOnce = async ( + config: DashConfig, + options: RequestOptions = {}, +): Promise => { + const [sentinel, services] = await Promise.all([ + config.sentinel ? checkSentinel(config.sentinel, options) : Promise.resolve(undefined), + Promise.all(config.services.map((service) => checkService(service, options))), + ]); + return sentinel === undefined ? {services} : {sentinel, services}; +}; diff --git a/src/sources/sentinel.ts b/src/sources/sentinel.ts new file mode 100644 index 0000000..2b91051 --- /dev/null +++ b/src/sources/sentinel.ts @@ -0,0 +1,158 @@ +import {describeError, getJson, isRecord, joinUrl} from './http.js'; +import type {RequestOptions} from './http.js'; + +export const severities = ['info', 'low', 'medium', 'high', 'critical'] as const; +export type Severity = (typeof severities)[number]; +export type IncidentStatus = 'open' | 'acknowledged' | 'resolved' | 'suppressed'; + +export interface SentinelIncident { + id: string; + title: string; + summary: string; + severity: Severity; + status: IncidentStatus; + host: string; + ruleId: string; + signalCount: number; + createdAt: string; + updatedAt: string; +} + +export interface SentinelSignal { + id: string; + source: string; + kind: string; + title: string; + severity: Severity; + host: string; + observedAt: string; +} + +export interface SentinelSnapshot { + ok: boolean; + /** Record counts from `/api/v1/health`. */ + signalTotal: number; + incidentTotal: number; + /** Collectors running right now and the number of scheduled timers. */ + running: string[]; + scheduled: number; + /** Open and acknowledged incidents, most severe first, then most recent. */ + incidents: SentinelIncident[]; + /** The most recent signals, newest first. */ + signals: SentinelSignal[]; + latencyMs: number; +} + +export type SentinelResult = + | {ok: true; checkedAt: Date; snapshot: SentinelSnapshot} + | {ok: false; checkedAt: Date; error: string}; + +export interface SentinelSource { + url: string; + /** Sent as `Authorization: Bearer ` when pack-sentinel has `apiToken` set. */ + token?: string; +} + +const asString = (value: unknown, fallback = ''): string => + typeof value === 'string' ? value : fallback; + +const asNumber = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0; + +const asSeverity = (value: unknown): Severity => + severities.includes(value as Severity) ? (value as Severity) : 'info'; + +export const severityRank = (value: Severity): number => severities.indexOf(value); + +const items = (value: unknown): unknown[] => + isRecord(value) && Array.isArray(value.items) ? value.items : []; + +const toIncident = (value: unknown): SentinelIncident | undefined => { + if (!isRecord(value) || typeof value.id !== 'string') return undefined; + const status = asString(value.status, 'open'); + return { + id: value.id, + title: asString(value.title, '(untitled)'), + summary: asString(value.summary), + severity: asSeverity(value.severity), + status: (['open', 'acknowledged', 'resolved', 'suppressed'].includes(status) + ? status + : 'open') as IncidentStatus, + host: asString(value.host, 'localhost'), + ruleId: asString(value.ruleId), + signalCount: Array.isArray(value.signalIds) ? value.signalIds.length : 0, + createdAt: asString(value.createdAt), + updatedAt: asString(value.updatedAt), + }; +}; + +const toSignal = (value: unknown): SentinelSignal | undefined => { + if (!isRecord(value) || typeof value.id !== 'string') return undefined; + return { + id: value.id, + source: asString(value.source), + kind: asString(value.kind), + title: asString(value.title, '(untitled)'), + severity: asSeverity(value.severity), + host: asString(value.host, 'localhost'), + observedAt: asString(value.observedAt), + }; +}; + +export const compareIncidents = (left: SentinelIncident, right: SentinelIncident): number => + severityRank(right.severity) - severityRank(left.severity) || + right.updatedAt.localeCompare(left.updatedAt); + +const defined = (value: T | undefined): value is T => value !== undefined; + +/** Read health, collector state, active incidents, and recent signals from pack-sentinel. */ +export const fetchSentinel = async ( + source: SentinelSource, + options: RequestOptions & {signalLimit?: number} = {}, +): Promise => { + const request: RequestOptions = { + ...options, + headers: { + ...options.headers, + ...(source.token ? {authorization: `Bearer ${source.token}`} : {}), + }, + }; + const api = (path: string) => joinUrl(source.url, `/api/v1/${path}`); + const started = performance.now(); + const [health, state, open, acknowledged, signals] = await Promise.all([ + getJson(api('health'), request), + getJson(api('state'), request), + getJson(api('incidents?status=open&limit=500'), request), + getJson(api('incidents?status=acknowledged&limit=500'), request), + getJson(api(`signals?limit=${options.signalLimit ?? 20}`), request), + ]); + const healthRecord = isRecord(health) ? health : {}; + const stateRecord = isRecord(state) ? state : {}; + return { + ok: healthRecord.ok === true, + signalTotal: asNumber(healthRecord.signals), + incidentTotal: asNumber(healthRecord.incidents), + running: Array.isArray(stateRecord.running) + ? stateRecord.running.filter((name): name is string => typeof name === 'string') + : [], + scheduled: asNumber(stateRecord.scheduled), + incidents: [...items(open), ...items(acknowledged)] + .map(toIncident) + .filter(defined) + .sort(compareIncidents), + signals: items(signals).map(toSignal).filter(defined), + latencyMs: Math.round(performance.now() - started), + }; +}; + +/** Like `fetchSentinel`, but returns failures as data so a view can show them. */ +export const checkSentinel = async ( + source: SentinelSource, + options: RequestOptions & {signalLimit?: number} = {}, +): Promise => { + try { + return {ok: true, checkedAt: new Date(), snapshot: await fetchSentinel(source, options)}; + } catch (error) { + return {ok: false, checkedAt: new Date(), error: describeError(error)}; + } +}; diff --git a/src/sources/services.ts b/src/sources/services.ts new file mode 100644 index 0000000..0c0253e --- /dev/null +++ b/src/sources/services.ts @@ -0,0 +1,168 @@ +import {describeError, get, isRecord, joinUrl} from './http.js'; +import type {HttpResult, RequestOptions} from './http.js'; +import {parseMetrics, sumMetric} from './metrics.js'; + +export interface ServiceSource { + /** A short label such as `inference` or `ocr`. */ + name: string; + /** The service's HTTP address, for example `http://127.0.0.1:8080`. */ + url: string; +} + +export interface ServiceModel { + id: string; + /** The service's own word for the model's state, such as `ready`, `loaded`, or `missing`. */ + state?: string; +} + +export interface ServiceStatus { + name: string; + url: string; + /** False when the service could not be reached at all. */ + reachable: boolean; + live: boolean; + ready: boolean; + /** Why the service is not ready, in the service's own words, or the network error. */ + reason?: string; + /** Undefined when the service has no model list. */ + models?: ServiceModel[]; + /** Requests waiting for a worker. Undefined when the service exports no queue metric. */ + queueDepth?: number; + /** Requests being served now. */ + active?: number; + latencyMs?: number; + checkedAt: Date; +} + +/** impossibleG services use `/health/*`; the MCP server and ReAct agent use `/healthz` and `/readyz`. */ +const livePaths = ['/health/live', '/healthz']; +const readyPaths = ['/health/ready', '/readyz']; + +const firstFound = async ( + base: string, + paths: readonly string[], + options: RequestOptions, +): Promise => { + for (const path of paths) { + const result = await get(joinUrl(base, path), options); + if (result.status !== 404) return result; + } + return undefined; +}; + +const parseJson = (body: string): unknown => { + try { + return JSON.parse(body) as unknown; + } catch { + return undefined; + } +}; + +const readinessReason = (result: HttpResult): string | undefined => { + const body = parseJson(result.body); + if (isRecord(body)) { + if (typeof body.reason === 'string') return body.reason; + if (typeof body.status === 'string' && result.status >= 300) return body.status; + } + return result.status >= 300 ? `HTTP ${result.status}` : undefined; +}; + +/** + * Read a model list in any of the shapes impossibleG services use: + * OpenAI-style `{data: [{id}]}` (inference, voice), `{data: [{model, state}]}` + * (embeddings), or `{state, models: ["id"]}` (OCR). + */ +export const parseModels = (value: unknown): ServiceModel[] | undefined => { + if (!isRecord(value)) return undefined; + if (Array.isArray(value.data)) { + const fallbackState = typeof value.status === 'string' ? value.status : undefined; + return value.data.flatMap((entry): ServiceModel[] => { + if (!isRecord(entry)) return []; + const id = + typeof entry.id === 'string' + ? entry.id + : typeof entry.model === 'string' + ? entry.model + : undefined; + if (id === undefined) return []; + const state = + typeof entry.state === 'string' + ? entry.state + : typeof entry.status === 'string' + ? entry.status + : fallbackState; + return [state === undefined ? {id} : {id, state}]; + }); + } + if (Array.isArray(value.models)) { + const state = typeof value.state === 'string' ? value.state : undefined; + return value.models.flatMap((entry): ServiceModel[] => { + if (typeof entry === 'string') + return [state === undefined ? {id: entry} : {id: entry, state}]; + if (isRecord(entry) && typeof entry.id === 'string') return [{id: entry.id}]; + return []; + }); + } + return undefined; +}; + +const optional = async (work: Promise): Promise => { + try { + return await work; + } catch { + return undefined; + } +}; + +/** Probe one impossibleG service: health, models, and queue metrics. */ +export const checkService = async ( + source: ServiceSource, + options: RequestOptions = {}, +): Promise => { + const base = {name: source.name, url: source.url}; + let live: HttpResult | undefined; + try { + live = await firstFound(source.url, livePaths, options); + } catch (error) { + return { + ...base, + reachable: false, + live: false, + ready: false, + reason: describeError(error), + checkedAt: new Date(), + }; + } + const [ready, models, metrics] = await Promise.all([ + optional(firstFound(source.url, readyPaths, options)), + optional(get(joinUrl(source.url, '/v1/models'), options)), + optional(get(joinUrl(source.url, '/metrics'), options)), + ]); + const isLive = live !== undefined && live.status >= 200 && live.status < 300; + const isReady = ready !== undefined && ready.status >= 200 && ready.status < 300; + const reason = + ready === undefined ? (isLive ? undefined : 'no health check') : readinessReason(ready); + const modelList = + models !== undefined && models.status === 200 ? parseModels(parseJson(models.body)) : undefined; + const samples = + metrics !== undefined && metrics.status === 200 ? parseMetrics(metrics.body) : undefined; + const queueDepth = samples && sumMetric(samples, (name) => name === 'impossible_queue_depth'); + const active = + samples && + sumMetric( + samples, + (name) => name.startsWith('impossible_') && name.endsWith('_active_requests'), + ); + return { + ...base, + reachable: true, + live: isLive, + ready: isReady, + ...(reason === undefined ? {} : {reason}), + ...(modelList === undefined ? {} : {models: modelList}), + ...(queueDepth === undefined ? {} : {queueDepth}), + ...(active === undefined ? {} : {active}), + ...(live === undefined ? {} : {latencyMs: live.latencyMs}), + checkedAt: new Date(), + }; +}; diff --git a/tests/cli-json.test.ts b/tests/cli-json.test.ts new file mode 100644 index 0000000..eb3fcb7 --- /dev/null +++ b/tests/cli-json.test.ts @@ -0,0 +1,68 @@ +import {execFile} from 'node:child_process'; +import {createServer} from 'node:http'; +import type {AddressInfo} from 'node:net'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; +import {afterAll, beforeAll, describe, expect, it} from 'vitest'; + +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)); + +const responses: Record = { + '/health/live': [200, '{"status":"live"}'], + '/health/ready': [200, '{"status":"ready"}'], + '/v1/models': [200, '{"object":"list","data":[{"id":"qwen3-8b"}],"status":"loaded"}'], + '/metrics': [200, 'impossible_queue_depth 1\nimpossible_active_requests 0\n'], +}; + +const server = createServer((request, response) => { + const [status, body] = responses[request.url ?? ''] ?? [404, '{}']; + response.writeHead(status).end(body); +}); +let url = ''; + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +describe('relay-dash --json', () => { + it('polls each source once and prints the results', async () => { + const {stdout} = await run(process.execPath, [ + tsx, + cli, + '--json', + '--no-sentinel', + '--service', + `inference=${url}`, + ]); + const data = JSON.parse(stdout) as {sentinel?: unknown; services: unknown[]}; + expect(data.sentinel).toBeUndefined(); + expect(data.services).toEqual([ + expect.objectContaining({ + name: 'inference', + ready: true, + models: [{id: 'qwen3-8b', state: 'loaded'}], + queueDepth: 1, + active: 0, + }), + ]); + }, 30_000); + + it('exits 1 when a source is down', async () => { + await expect( + run(process.execPath, [tsx, cli, '--json', '--sentinel', `${url}/nothing-here`]), + ).rejects.toMatchObject({code: 1}); + }, 30_000); + + it('exits 2 on an invalid source', async () => { + await expect( + run(process.execPath, [tsx, cli, '--json', '--service', 'broken']), + ).rejects.toMatchObject({code: 2}); + }, 30_000); +}); diff --git a/tests/cli-options.test.ts b/tests/cli-options.test.ts index 2c1c3d7..5825630 100644 --- a/tests/cli-options.test.ts +++ b/tests/cli-options.test.ts @@ -8,18 +8,50 @@ describe('parseCliOptions', () => { version: false, inline: false, snapshot: false, + json: false, }); }); it('reads short and long flags', () => { - expect(parseCliOptions(['-h', '-v', '--inline', '--snapshot'])).toEqual({ + expect(parseCliOptions(['-h', '-v', '--inline', '--snapshot', '--json'])).toMatchObject({ help: true, version: true, inline: true, snapshot: true, + json: true, }); }); + it('reads source options with separate or attached values', () => { + expect( + parseCliOptions([ + '--config', + 'dash.json', + '--sentinel=http://den:7331', + '--service', + 'inference=http://127.0.0.1:8080', + '--service=ocr=http://127.0.0.1:8081', + '--interval', + '10', + '--no-sentinel', + ]), + ).toMatchObject({ + configPath: 'dash.json', + sentinelUrl: 'http://den:7331', + services: ['inference=http://127.0.0.1:8080', 'ocr=http://127.0.0.1:8081'], + intervalSeconds: 10, + noSentinel: true, + }); + }); + + it('rejects a missing value', () => { + expect(() => parseCliOptions(['--service'])).toThrow('--service needs a value'); + }); + + it('rejects a non-numeric interval', () => { + expect(() => parseCliOptions(['--interval', 'soon'])).toThrow('--interval expects seconds'); + }); + it('rejects unknown options', () => { expect(() => parseCliOptions(['--nope'])).toThrow('Unknown option: --nope'); }); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..2c0d5fd --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,130 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import { + ConfigError, + defaultConfig, + loadConfig, + parseConfig, + parseServiceFlag, +} from '../src/config.js'; + +describe('parseConfig', () => { + it('uses the defaults for an empty object', () => { + expect(parseConfig({})).toEqual(defaultConfig); + }); + + it('reads sources and takes the token from the environment', () => { + const config = parseConfig( + { + sentinel: {url: 'http://den:7331', tokenEnv: 'DEN_TOKEN'}, + services: [ + {name: 'inference', url: 'http://127.0.0.1:8080'}, + {name: 'ocr', url: 'http://127.0.0.1:8081'}, + ], + intervalSeconds: 10, + }, + {DEN_TOKEN: 'secret'}, + ); + expect(config).toEqual({ + sentinel: {url: 'http://den:7331', token: 'secret'}, + services: [ + {name: 'inference', url: 'http://127.0.0.1:8080'}, + {name: 'ocr', url: 'http://127.0.0.1:8081'}, + ], + intervalSeconds: 10, + }); + }); + + it('applies the default token variable to the default address', () => { + expect(parseConfig({}, {RELAY_DASH_SENTINEL_TOKEN: 't'}).sentinel).toEqual({ + url: 'http://127.0.0.1:7331', + token: 't', + }); + }); + + it('turns pack-sentinel off with false', () => { + expect(parseConfig({sentinel: false}).sentinel).toBeUndefined(); + }); + + it.each([ + [{sentinel: {url: 'http://x', token: 'leak'}}, 'sentinel.token is not read'], + [{sentinel: {url: 'ftp://x'}}, 'must use http or https'], + [{services: [{name: 'a', url: 'nope'}]}, 'not a valid URL'], + [{services: [{url: 'http://x'}]}, 'needs a name'], + [ + { + services: [ + {name: 'a', url: 'http://x'}, + {name: 'a', url: 'http://y'}, + ], + }, + 'duplicate service name', + ], + [{intervalSeconds: 0}, 'between 1 and 3600'], + [{servics: []}, 'unknown config key: servics'], + [[], 'JSON object'], + ])('rejects %j', (value, message) => { + expect(() => parseConfig(value)).toThrow(message); + }); +}); + +describe('parseServiceFlag', () => { + it('splits name and url at the first equals sign', () => { + expect(parseServiceFlag('ocr=http://127.0.0.1:8081/?a=b')).toEqual({ + name: 'ocr', + url: 'http://127.0.0.1:8081/?a=b', + }); + }); + + it('requires a name', () => { + expect(() => parseServiceFlag('=http://x')).toThrow(ConfigError); + expect(() => parseServiceFlag('http://x')).toThrow(ConfigError); + }); +}); + +describe('loadConfig', () => { + let directory = ''; + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'relay-dash-')); + }); + afterEach(async () => { + await rm(directory, {recursive: true, force: true}); + }); + + it('reads a config file and applies command-line overrides', async () => { + const path = join(directory, 'dash.json'); + await writeFile( + path, + JSON.stringify({services: [{name: 'voice', url: 'http://127.0.0.1:8082'}]}), + ); + const config = await loadConfig( + { + configPath: path, + sentinelUrl: 'http://den:7331', + services: ['ocr=http://127.0.0.1:8081'], + intervalSeconds: 30, + }, + {RELAY_DASH_SENTINEL_TOKEN: 'tok'}, + ); + expect(config).toEqual({ + sentinel: {url: 'http://den:7331', token: 'tok'}, + services: [{name: 'ocr', url: 'http://127.0.0.1:8081'}], + intervalSeconds: 30, + }); + }); + + it('fails when an explicit config file is missing', async () => { + await expect(loadConfig({configPath: join(directory, 'missing.json')}, {})).rejects.toThrow( + ConfigError, + ); + }); + + it('turns pack-sentinel off from the command line', async () => { + const path = join(directory, 'dash.json'); + await writeFile(path, JSON.stringify({sentinel: {url: 'http://den:7331'}})); + const config = await loadConfig({configPath: path, noSentinel: true}, {}); + expect(config.sentinel).toBeUndefined(); + }); +}); diff --git a/tests/helpers/fake-fetch.ts b/tests/helpers/fake-fetch.ts new file mode 100644 index 0000000..a6e2abe --- /dev/null +++ b/tests/helpers/fake-fetch.ts @@ -0,0 +1,28 @@ +import type {Fetch} from '../../src/sources/http.js'; + +export type Route = {status?: number; body?: unknown} | Error; + +export interface FakeFetch { + fetch: Fetch; + /** Every request made, with its headers. */ + calls: {url: string; headers: Record}[]; +} + +/** A fetch that answers from a table of URLs. Unknown URLs answer 404. */ +export const fakeFetch = (routes: Record): FakeFetch => { + const calls: FakeFetch['calls'] = []; + const fetch: Fetch = (input, init) => { + calls.push({url: input, headers: {...(init?.headers as Record | undefined)}}); + const route = routes[input]; + if (route instanceof Error) return Promise.reject(route); + const status = route?.status ?? (route ? 200 : 404); + const body = route?.body ?? {error: 'not found'}; + return Promise.resolve( + new Response(typeof body === 'string' ? body : JSON.stringify(body), {status}), + ); + }; + return {fetch, calls}; +}; + +export const refused = (): Error => + Object.assign(new TypeError('fetch failed'), {cause: {code: 'ECONNREFUSED'}}); diff --git a/tests/metrics.test.ts b/tests/metrics.test.ts new file mode 100644 index 0000000..78a1f2f --- /dev/null +++ b/tests/metrics.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from 'vitest'; +import {parseMetrics, sumMetric} from '../src/sources/metrics.js'; + +describe('parseMetrics', () => { + it('reads samples with labels and skips comments', () => { + const samples = parseMetrics( + [ + '# HELP impossible_requests_total Completed requests.', + '# TYPE impossible_requests_total counter', + 'impossible_requests_total{operation="embed",outcome="ok"} 5', + 'impossible_requests_total{operation="embed",outcome="error"} 1', + 'impossible_queue_depth 2', + 'garbage line', + 'impossible_latency_seconds_bucket{le="+Inf"} +Inf', + '', + ].join('\r\n'), + ); + expect(samples).toEqual([ + { + name: 'impossible_requests_total', + labels: {operation: 'embed', outcome: 'ok'}, + value: 5, + }, + { + name: 'impossible_requests_total', + labels: {operation: 'embed', outcome: 'error'}, + value: 1, + }, + {name: 'impossible_queue_depth', labels: {}, value: 2}, + {name: 'impossible_latency_seconds_bucket', labels: {le: '+Inf'}, value: Infinity}, + ]); + }); + + it('unescapes label values', () => { + const [sample] = parseMetrics('m{path="a\\"b\\\\c"} 1'); + expect(sample?.labels.path).toBe('a"b\\c'); + }); +}); + +describe('sumMetric', () => { + it('adds matching samples and returns undefined when none match', () => { + const samples = parseMetrics('a 1\na 2\nb 4\n'); + expect(sumMetric(samples, (name) => name === 'a')).toBe(3); + expect(sumMetric(samples, (name) => name === 'c')).toBeUndefined(); + }); +}); diff --git a/tests/sentinel.test.ts b/tests/sentinel.test.ts new file mode 100644 index 0000000..644ae86 --- /dev/null +++ b/tests/sentinel.test.ts @@ -0,0 +1,128 @@ +import {describe, expect, it} from 'vitest'; +import {checkSentinel} from '../src/sources/sentinel.js'; +import {fakeFetch, refused} from './helpers/fake-fetch.js'; + +const base = 'http://127.0.0.1:7331/api/v1'; + +const incident = (id: string, severity: string, status: string, updatedAt: string) => ({ + id, + key: `key-${id}`, + title: `Incident ${id}`, + summary: 'summary', + severity, + status, + host: 'den', + ruleId: 'ssh-bruteforce', + signalIds: ['a', 'b'], + createdAt: '2026-10-21T10:00:00.000Z', + updatedAt, +}); + +const routes = { + [`${base}/health`]: {body: {ok: true, signals: 42, incidents: 7}}, + [`${base}/state`]: {body: {running: ['ssh-guard'], scheduled: 3}}, + [`${base}/incidents?status=open&limit=500`]: { + body: { + items: [ + incident('1', 'medium', 'open', '2026-10-21T12:00:00.000Z'), + incident('2', 'critical', 'open', '2026-10-21T11:00:00.000Z'), + ], + }, + }, + [`${base}/incidents?status=acknowledged&limit=500`]: { + body: {items: [incident('3', 'medium', 'acknowledged', '2026-10-21T13:00:00.000Z')]}, + }, + [`${base}/signals?limit=20`]: { + body: { + items: [ + { + id: 's1', + source: 'ssh-guard', + kind: 'auth.failure', + title: 'Failed password', + summary: 'x', + severity: 'high', + host: 'den', + observedAt: '2026-10-21T13:01:00.000Z', + receivedAt: '2026-10-21T13:01:01.000Z', + evidence: {}, + tags: [], + fingerprint: 'f', + }, + ], + }, + }, +}; + +describe('checkSentinel', () => { + it('reads health, state, active incidents, and recent signals', async () => { + const fake = fakeFetch(routes); + const result = await checkSentinel({url: 'http://127.0.0.1:7331'}, {fetch: fake.fetch}); + expect(result.ok).toBe(true); + if (!result.ok) return; + const snapshot = result.snapshot; + expect(snapshot).toMatchObject({ + ok: true, + signalTotal: 42, + incidentTotal: 7, + running: ['ssh-guard'], + scheduled: 3, + }); + expect(snapshot.incidents.map((item) => item.id)).toEqual(['2', '3', '1']); + expect(snapshot.incidents[0]).toMatchObject({severity: 'critical', signalCount: 2}); + expect(snapshot.signals).toEqual([ + { + id: 's1', + source: 'ssh-guard', + kind: 'auth.failure', + title: 'Failed password', + severity: 'high', + host: 'den', + observedAt: '2026-10-21T13:01:00.000Z', + }, + ]); + }); + + it('sends the API token as a bearer token', async () => { + const fake = fakeFetch(routes); + await checkSentinel({url: 'http://127.0.0.1:7331/', token: 'secret'}, {fetch: fake.fetch}); + expect(fake.calls).toHaveLength(5); + for (const call of fake.calls) { + expect(call.url.startsWith(base)).toBe(true); + expect(call.headers.authorization).toBe('Bearer secret'); + } + }); + + it('sends no authorization header without a token', async () => { + const fake = fakeFetch(routes); + await checkSentinel({url: 'http://127.0.0.1:7331'}, {fetch: fake.fetch}); + expect(fake.calls.every((call) => call.headers.authorization === undefined)).toBe(true); + }); + + it('reports a rejected token', async () => { + const fake = fakeFetch({...routes, [`${base}/health`]: {status: 401, body: {}}}); + const result = await checkSentinel({url: 'http://127.0.0.1:7331'}, {fetch: fake.fetch}); + expect(result).toMatchObject({ok: false, error: 'HTTP 401'}); + }); + + it('reports an unreachable server', async () => { + const fake = fakeFetch({[`${base}/health`]: refused()}); + const result = await checkSentinel({url: 'http://127.0.0.1:7331'}, {fetch: fake.fetch}); + expect(result).toMatchObject({ok: false, error: 'connection refused'}); + }); + + it('tolerates unknown severities and missing fields', async () => { + const fake = fakeFetch({ + ...routes, + [`${base}/incidents?status=open&limit=500`]: { + body: {items: [{id: 'x', severity: 'apocalyptic'}, {title: 'no id'}]}, + }, + [`${base}/incidents?status=acknowledged&limit=500`]: {body: {}}, + }); + const result = await checkSentinel({url: 'http://127.0.0.1:7331'}, {fetch: fake.fetch}); + if (!result.ok) throw new Error(result.error); + expect(result.snapshot.incidents).toEqual([ + expect.objectContaining({id: 'x', severity: 'info', status: 'open', title: '(untitled)'}), + ]); + }); +}); diff --git a/tests/services.test.ts b/tests/services.test.ts new file mode 100644 index 0000000..ea165c2 --- /dev/null +++ b/tests/services.test.ts @@ -0,0 +1,130 @@ +import {describe, expect, it} from 'vitest'; +import {checkService, parseModels} from '../src/sources/services.js'; +import {fakeFetch, refused} from './helpers/fake-fetch.js'; + +const url = 'http://127.0.0.1:8080'; + +const inferenceMetrics = `# HELP impossible_requests_total Completed requests. +# TYPE impossible_requests_total counter +impossible_requests_total{operation="chat",outcome="ok"} 12 +# TYPE impossible_queue_depth gauge +impossible_queue_depth 3 +# TYPE impossible_active_requests gauge +impossible_active_requests 2 +`; + +describe('checkService', () => { + it('reads health, models, and queue metrics from an impossibleG service', async () => { + const fake = fakeFetch({ + [`${url}/health/live`]: {body: {status: 'live'}}, + [`${url}/health/ready`]: {body: {status: 'ready'}}, + [`${url}/v1/models`]: { + body: { + object: 'list', + data: [{id: 'qwen3-8b', object: 'model', owned_by: 'local'}], + status: 'loaded', + }, + }, + [`${url}/metrics`]: {body: inferenceMetrics}, + }); + const status = await checkService({name: 'inference', url}, {fetch: fake.fetch}); + expect(status).toMatchObject({ + name: 'inference', + reachable: true, + live: true, + ready: true, + models: [{id: 'qwen3-8b', state: 'loaded'}], + queueDepth: 3, + active: 2, + }); + expect(status.reason).toBeUndefined(); + }); + + it('reports why a live service is not ready', async () => { + const fake = fakeFetch({ + [`${url}/health/live`]: {body: {status: 'live'}}, + [`${url}/health/ready`]: {status: 503, body: {status: 'not_ready', reason: 'model_loading'}}, + [`${url}/v1/models`]: {body: {object: 'list', data: [], status: 'not_ready'}}, + }); + const status = await checkService({name: 'inference', url}, {fetch: fake.fetch}); + expect(status).toMatchObject({live: true, ready: false, reason: 'model_loading', models: []}); + expect(status.queueDepth).toBeUndefined(); + }); + + it('falls back to /healthz and /readyz', async () => { + const mcp = 'http://127.0.0.1:3333'; + const fake = fakeFetch({ + [`${mcp}/healthz`]: {body: 'ok'}, + [`${mcp}/readyz`]: {body: 'ok'}, + }); + const status = await checkService({name: 'mcp', url: mcp}, {fetch: fake.fetch}); + expect(status).toMatchObject({reachable: true, live: true, ready: true}); + expect(status.models).toBeUndefined(); + }); + + it('marks an unreachable service', async () => { + const fake = fakeFetch({[`${url}/health/live`]: refused()}); + const status = await checkService({name: 'voice', url}, {fetch: fake.fetch}); + expect(status).toMatchObject({ + reachable: false, + live: false, + ready: false, + reason: 'connection refused', + }); + }); + + it('sums service-specific active request gauges', async () => { + const fake = fakeFetch({ + [`${url}/health/live`]: {body: {status: 'live'}}, + [`${url}/health/ready`]: {body: {status: 'ready'}}, + [`${url}/metrics`]: { + body: 'impossible_queue_depth 0\nimpossible_ocr_active_requests 4\nimpossible_ocr_completed_requests 9\n', + }, + }); + const status = await checkService({name: 'ocr', url}, {fetch: fake.fetch}); + expect(status).toMatchObject({queueDepth: 0, active: 4}); + }); +}); + +describe('parseModels', () => { + it('reads OpenAI-style lists with per-model status', () => { + expect( + parseModels({ + object: 'list', + ready: true, + data: [ + {id: 'impossible-voice-stt', status: 'ready'}, + {id: 'impossible-voice-tts', status: 'ready'}, + ], + }), + ).toEqual([ + {id: 'impossible-voice-stt', state: 'ready'}, + {id: 'impossible-voice-tts', state: 'ready'}, + ]); + }); + + it('reads the embeddings shape', () => { + expect( + parseModels({ + data: [ + {model: 'bge-small-en', state: 'ready'}, + {model: 'e5', state: 'missing'}, + ], + }), + ).toEqual([ + {id: 'bge-small-en', state: 'ready'}, + {id: 'e5', state: 'missing'}, + ]); + }); + + it('reads the OCR shape', () => { + expect(parseModels({state: 'ready', models: ['paddle-v4']})).toEqual([ + {id: 'paddle-v4', state: 'ready'}, + ]); + }); + + it('returns undefined for anything else', () => { + expect(parseModels({hello: 'world'})).toBeUndefined(); + expect(parseModels('nope')).toBeUndefined(); + }); +});