diff --git a/README.md b/README.md index 949e7b3..c2a128f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ pnpm build | `packages/ui` | `@wavegrid/ui` | Artist UI — Paint, Gradient, Drops, Motion, Scenes, Animations, Flags, Brightness, Audio | | `packages/layout` | `@wavegrid/layout` | Layout model — presets, fixture generators (grid/ring/filledRing), config resolution | | `packages/settings` | `@wavegrid/settings` | Centralized appstash store — projects, secrets, users, state, logs | +| `packages/doctor` | `@wavegrid/doctor` | Diagnostics as data — the checks behind `wavegrid doctor` and the desktop Status screen | | `packages/cli` | `@wavegrid/cli` | `wavegrid` CLI — projects, settings, start, doctor | | `packages/receiver` | `@wavegrid/receiver` | Receiver brain — LP filter, sine fallback, pluggable adapter pattern | | `packages/osc` | `@wavegrid/osc` | OSC output adapters for BEYOND and FB4 laser hardware | diff --git a/packages/cli/package.json b/packages/cli/package.json index 491249e..8e14e44 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,6 +41,7 @@ ], "dependencies": { "@wavegrid/discovery": "workspace:*", + "@wavegrid/doctor": "workspace:*", "@wavegrid/layout": "workspace:*", "@wavegrid/receiver": "workspace:*", "@wavegrid/server": "workspace:*", diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 18cfd43..6206ff1 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,198 +1,15 @@ import { loadWavegridConfig } from '@wavegrid/layout'; -import { formatRanges, type SystemStatus } from '@wavegrid/server'; -import { projectSecretsFile } from '@wavegrid/settings'; -import { accessSync, constants, existsSync, statSync } from 'fs'; -import net from 'net'; -import { dirname } from 'path'; -import { URL } from 'url'; -import { WebSocket } from 'ws'; -import c from 'yanse'; - -import { type Flags, getStore } from '../project'; import { type Check, checkEnvHijack, - checkOsc, - checkShard, - isSecureMode, + collectDiagnostics, + type Diagnostics, overallStatus -} from './doctor-checks'; - -const NODE_MIN_MAJOR = 18; - -/** TCP probe: resolve 'open' if something is listening, else 'closed'. */ -function tcpProbe(host: string, port: number, timeoutMs = 1500): Promise<'open' | 'closed'> { - const target = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; - return new Promise((resolve) => { - const socket = new net.Socket(); - const done = (result: 'open' | 'closed') => { - socket.destroy(); - resolve(result); - }; - socket.setTimeout(timeoutMs); - socket.once('connect', () => done('open')); - socket.once('timeout', () => done('closed')); - socket.once('error', () => done('closed')); - socket.connect(port, target); - }); -} - -interface StatusProbe { - status?: SystemStatus; - /** 'unauthorized' when the server rejected our receiver key. */ - error?: 'unauthorized' | 'timeout' | 'refused' | 'not-wavegrid'; -} - -/** Connect to a running server and request a system_status snapshot. */ -function querySystemStatus(url: string, key: string, timeoutMs = 3000): Promise { - return new Promise((resolve) => { - const u = new URL(url); - if (key) u.searchParams.set('key', key); - let settled = false; - const finish = (probe: StatusProbe) => { - if (settled) return; - settled = true; - try { ws.close(); } catch { /* ignore */ } - resolve(probe); - }; - const timer = setTimeout(() => finish({ error: 'timeout' }), timeoutMs); - const ws = new WebSocket(u.toString()); - - ws.on('open', () => ws.send(JSON.stringify({ type: 'system_status' }))); - ws.on('unexpected-response', (_req, res) => { - clearTimeout(timer); - finish({ error: res.statusCode === 401 ? 'unauthorized' : 'not-wavegrid' }); - }); - ws.on('message', (raw) => { - try { - const msg = JSON.parse(raw.toString()); - if (msg.type === 'system_status') { - clearTimeout(timer); - finish({ status: msg as SystemStatus }); - } - } catch { /* ignore non-JSON frames */ } - }); - ws.on('error', () => { - clearTimeout(timer); - finish({ error: 'refused' }); - }); - }); -} - -/** - * Whether `dir` is (or can be) writable. Store subdirs are created lazily at - * `start`, so a missing dir is fine as long as its nearest existing ancestor - * is writable — otherwise a fresh project would spuriously fail the check. - */ -function dirWritable(dir: string): boolean { - let target = dir; - while (!existsSync(target)) { - const parent = dirname(target); - if (parent === target) return false; - target = parent; - } - try { - accessSync(target, constants.W_OK); - return true; - } catch { - return false; - } -} - -/** Build the local (this-laptop) checks. */ -function localChecks(flags: Flags, cwd: string): { checks: Check[]; project?: string; serverUrl?: string; key?: string } { - const checks: Check[] = []; - - // Node version - const major = parseInt(process.versions.node.split('.')[0], 10); - checks.push( - major >= NODE_MIN_MAJOR - ? { name: 'Node', status: 'pass', detail: `v${process.versions.node}` } - : { name: 'Node', status: 'warn', detail: `v${process.versions.node} < ${NODE_MIN_MAJOR}`, remedy: `install Node ${NODE_MIN_MAJOR}+` } - ); - - // Store reachable + writable - const store = getStore(); - checks.push( - dirWritable(store.paths.root) - ? { name: 'Store', status: 'pass', detail: store.paths.root } - : { name: 'Store', status: 'fail', detail: `not writable: ${store.paths.root}`, remedy: 'check permissions or APPSTASH_BASE_DIR' } - ); - - // Machine-local device identity (self-registration / discovery). - const device = store.getDevice(); - checks.push({ name: 'Device', status: 'pass', detail: `${device.name} (${device.id.slice(0, 8)}…)` }); - - // Active project resolution - const explicit = (typeof flags.project === 'string' ? flags.project : undefined) ?? process.env.WAVEGRID_PROJECT; - const project = explicit ?? store.getActiveProject() ?? undefined; - if (!project || !store.hasProject(project)) { - checks.push({ - name: 'Project', - status: 'fail', - detail: project ? `unknown project "${project}"` : 'no active project', - remedy: 'wavegrid init (or `wavegrid use `)' - }); - // Everything below needs a project; return early. - checks.push(checkEnvHijack(process.env)); - return { checks }; - } - checks.push({ name: 'Project', status: 'pass', detail: project }); - if (store.getActiveProject() !== project) store.setActiveProject(project); - - // Resolve config (layout, mode, ports) - const resolved = loadWavegridConfig({ cwd }); - const { config, layout, runMode } = resolved; - checks.push({ name: 'Layout', status: 'pass', detail: `${layout.name} (${layout.topology}, ${layout.count} cannons) · ${runMode}` }); - - // Shard bounds - checks.push(checkShard(layout.count, config.receiver.shard)); - - // Secrets present + secure perms - for (const s of store.requiredSecrets(project)) { - if (!s.set) { - checks.push({ name: `Secret ${s.name}`, status: 'fail', detail: 'NOT SET', remedy: 'wavegrid secrets init' }); - continue; - } - let modeOk = true; - try { - const mode = statSync(projectSecretsFile(store.paths, project)).mode; - modeOk = isSecureMode(mode); - } catch { /* file existence already implied by s.set */ } - checks.push( - modeOk - ? { name: `Secret ${s.name}`, status: 'pass', detail: 'set (0600)' } - : { name: `Secret ${s.name}`, status: 'warn', detail: 'set but file is group/other-readable', remedy: `chmod 600 ${projectSecretsFile(store.paths, project)}` } - ); - } - - // Users - const users = store.listUsers(project); - checks.push( - users.length > 0 - ? { name: 'Users', status: 'pass', detail: `${users.length} UI login(s)` } - : { name: 'Users', status: 'warn', detail: 'no UI users — login returns 503', remedy: 'wavegrid users add ' } - ); - - // State + logs writable - for (const [label, dir] of [['State dir', store.stateDir(project)], ['Logs dir', store.logsDir(project)]] as const) { - checks.push( - dirWritable(dir) - ? { name: label, status: 'pass', detail: dir } - : { name: label, status: 'warn', detail: `not writable: ${dir}`, remedy: 'check store permissions' } - ); - } - - // OSC - checks.push(checkOsc(config)); - - // Ambient env footgun - checks.push(checkEnvHijack(process.env)); +} from '@wavegrid/doctor'; +import { formatRanges, type SystemStatus } from '@wavegrid/server'; +import c from 'yanse'; - const key = store.hasSecret(project, 'receiverKey') ? store.requireSecret(project, 'receiverKey') : undefined; - const serverUrl = process.env.SIMULATOR_URL || `ws://localhost:${config.server.port}`; - return { checks, project, serverUrl, key }; -} +import { type Flags, getStore } from '../project'; function renderCheck(check: Check): void { const icon = check.status === 'pass' ? c.green('✓') : check.status === 'warn' ? c.yellow('!') : c.red('✗'); @@ -229,13 +46,11 @@ function renderSystem(status: SystemStatus): void { } /** Render the project device registry (devices that have joined, incl. offline). */ -function renderDevices(project: string): void { - const store = getStore(); - const devices = store.listDevices(project); - if (devices.length === 0) return; +function renderDevices(diag: Diagnostics): void { + if (diag.devices.length === 0) return; console.log(''); - console.log(c.bold(` Devices (registered · ${project})`)); - for (const d of devices) { + console.log(c.bold(` Devices (registered · ${diag.project})`)); + for (const d of diag.devices) { const at = d.address ? c.gray(d.address) : c.gray('—'); const seen = d.lastSeen ? c.gray(seenAgo(d.lastSeen)) : c.gray('never'); console.log(` • ${c.cyan(d.name)} ${at} ${seen}`); @@ -244,40 +59,34 @@ function renderDevices(project: string): void { /** * Render config-sync state: the current project revision and any devices that - * lag it (divergence is surfaced, never hidden). Prefers the server's - * authoritative view; falls back to this laptop's local sync state offline. - * Silent for a simple project with no synced edits — no pay-as-you-go noise. + * lag it (divergence is surfaced, never hidden). Silent for a simple project + * with no synced edits — no pay-as-you-go noise. */ -function renderSync(project: string, serverSync?: SystemStatus['sync']): void { - const store = getStore(); - const local = store.getSyncState(project); - const revision = serverSync?.revision ?? local.revision; - const hasEdits = revision > 0 || Object.keys(local.entries).length > 0; +function renderSync(diag: Diagnostics): void { + const { sync, project } = diag; // Explicitly-off sync is worth a one-liner (edits won't propagate); an - // untouched simple project with sync on stays silent — no pay-as-you-go noise. - if (store.getProjectConfig(project)?.sync?.enabled === false) { + // untouched simple project with sync on stays silent. + if (!sync.enabled) { console.log(''); console.log(c.bold(` Config sync (${project})`)); console.log(` ${c.yellow('○')} disabled ${c.gray('— edits stay local to each device (`wavegrid config set sync true` to replicate)')}`); return; } - if (!hasEdits) return; + if (sync.revision === 0 && sync.entryCount === 0) return; - const known = store.listDevices(project); - const nameFor = (id: string): string => known.find((d) => d.id === id)?.name ?? `${id.slice(0, 8)}…`; - const divergent = serverSync?.divergent ?? store.divergentDevices(project, known.map((d) => d.id)); - const source = serverSync ? 'server-reported' : 'local'; + const nameFor = (id: string): string => diag.devices.find((d) => d.id === id)?.name ?? `${id.slice(0, 8)}…`; + const source = sync.fromServer ? 'server-reported' : 'local'; console.log(''); console.log(c.bold(` Config sync (${project})`)); - console.log(` → Revision: ${c.cyan(String(revision))} ${c.gray(`(${source})`)}`); - if (divergent.length === 0) { - console.log(` ${c.green('✓')} all devices at revision ${revision}`); + console.log(` → Revision: ${c.cyan(String(sync.revision))} ${c.gray(`(${source})`)}`); + if (sync.divergent.length === 0) { + console.log(` ${c.green('✓')} all devices at revision ${sync.revision}`); } else { - console.log(c.yellow(` ! ${divergent.length} device(s) behind:`)); - for (const d of divergent) { + console.log(c.yellow(` ! ${sync.divergent.length} device(s) behind:`)); + for (const d of sync.divergent) { console.log(` • ${c.cyan(nameFor(d.deviceId))} ${c.gray(`acked rev ${d.ackedRevision}, behind by ${d.behindBy}`)}`); } console.log(` ${c.gray('↳ reconnect the device(s) to the brain to resync (`wavegrid receiver` / reopen the UI)')}`); @@ -294,37 +103,49 @@ function seenAgo(lastSeen: number): string { return `seen ${Math.round(hrs / 24)}d ago`; } +/** Report the one case the shared collector cannot: no project to diagnose. */ +function reportNoProject(project: string | undefined, json: boolean): void { + const checks: Check[] = [ + { + name: 'Project', + status: 'fail', + detail: project ? `unknown project "${project}"` : 'no active project', + remedy: 'wavegrid init (or `wavegrid use `)' + }, + checkEnvHijack(process.env) + ]; + if (json) { + console.log(JSON.stringify({ checks, overall: overallStatus(checks), server: null }, null, 2)); + } else { + console.log(''); + console.log(c.bold(' Wavegrid · doctor')); + console.log(''); + for (const check of checks) renderCheck(check); + console.log(''); + console.log(` Result: ${c.red('problems found')}`); + console.log(''); + } + process.exitCode = 1; +} + /** `wavegrid doctor [--project name] [--server ws://host:port] [--json]` */ export async function runDoctor(flags: Flags = {}, cwd = process.cwd()): Promise { - const { checks, project, serverUrl, key } = localChecks(flags, cwd); - - // System view: connect to the configured (or --server) server if reachable. - const url = (typeof flags.server === 'string' ? flags.server : undefined) ?? serverUrl; - let system: StatusProbe | undefined; - let portState: 'open' | 'closed' | undefined; - if (url) { - const u = new URL(url); - const port = parseInt(u.port || '3000', 10); - portState = await tcpProbe(u.hostname, port); - if (portState === 'open') { - system = await querySystemStatus(url, key ?? ''); - } + const store = getStore(); + const explicit = (typeof flags.project === 'string' ? flags.project : undefined) ?? process.env.WAVEGRID_PROJECT; + const project = explicit ?? store.getActiveProject() ?? undefined; + if (!project || !store.hasProject(project)) { + reportNoProject(project, Boolean(flags.json)); + return; } + if (store.getActiveProject() !== project) store.setActiveProject(project); + + const resolved = loadWavegridConfig({ cwd }); + const serverUrl = (typeof flags.server === 'string' ? flags.server : undefined) ?? process.env.SIMULATOR_URL; + const diag = await collectDiagnostics({ store, project, resolved, serverUrl }); if (flags.json) { - console.log(JSON.stringify({ - checks, - overall: overallStatus(checks), - devices: project ? getStore().listDevices(project) : [], - sync: project ? getStore().getSyncState(project) : null, - divergent: project - ? (system?.status?.sync?.divergent ?? - getStore().divergentDevices(project, getStore().listDevices(project).map((d) => d.id))) - : [], - server: system?.status ?? null, - serverError: system?.error ?? (portState === 'closed' ? 'not-running' : undefined) - }, null, 2)); - process.exitCode = overallStatus(checks) === 'fail' ? 1 : 0; + console.log(JSON.stringify(diag, null, 2)); + process.exitCode = diag.overall === 'fail' ? 1 : 0; return; } @@ -332,31 +153,28 @@ export async function runDoctor(flags: Flags = {}, cwd = process.cwd()): Promise console.log(c.bold(' Wavegrid · doctor')); console.log(''); console.log(c.bold(' Local')); - for (const check of checks) renderCheck(check); + for (const check of diag.checks) renderCheck(check); - if (project) renderDevices(project); - if (project) renderSync(project, system?.status?.sync); + renderDevices(diag); + renderSync(diag); - if (url) { - if (portState === 'closed') { - console.log(''); - console.log(c.gray(` Server not running at ${url} (start it with \`wavegrid start\`).`)); - } else if (system?.status) { - renderSystem(system.status); - } else if (system?.error === 'unauthorized') { - console.log(''); - console.log(c.red(` ✗ Server at ${url} rejected our receiverKey (401).`)); - console.log(` ${c.gray('↳ this laptop\'s receiverKey must match the server\'s — re-sync via `wavegrid env export`')}`); - } else if (system?.error) { - console.log(''); - console.log(c.yellow(` ! Could not read system status from ${url} (${system.error}).`)); - } + if (diag.server) { + renderSystem(diag.server); + } else if (diag.serverError === 'not-running') { + console.log(''); + console.log(c.gray(` Server not running at ${diag.serverUrl} (start it with \`wavegrid start\`).`)); + } else if (diag.serverError === 'unauthorized') { + console.log(''); + console.log(c.red(` ✗ Server at ${diag.serverUrl} rejected our receiverKey (401).`)); + console.log(` ${c.gray('↳ this laptop\'s receiverKey must match the server\'s — re-sync via `wavegrid env export`')}`); + } else if (diag.serverError) { + console.log(''); + console.log(c.yellow(` ! Could not read system status from ${diag.serverUrl} (${diag.serverError}).`)); } - const overall = overallStatus(checks); console.log(''); - const label = overall === 'pass' ? c.green('healthy') : overall === 'warn' ? c.yellow('warnings') : c.red('problems found'); + const label = diag.overall === 'pass' ? c.green('healthy') : diag.overall === 'warn' ? c.yellow('warnings') : c.red('problems found'); console.log(` Result: ${label}`); console.log(''); - process.exitCode = overall === 'fail' ? 1 : 0; + process.exitCode = diag.overall === 'fail' ? 1 : 0; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c5ed278..f38689c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,15 +1,6 @@ export { parseArgs, type ParsedArgs,run } from './cli'; export { runConfigSet } from './commands/config-set'; export { runDoctor } from './commands/doctor'; -export { - type Check, - checkEnvHijack, - checkOsc, - checkShard, - type CheckStatus, - isSecureMode, - overallStatus -} from './commands/doctor-checks'; export { buildEnvLines, runEnvExport } from './commands/env'; export { runInit } from './commands/init'; export { runPrintConfig } from './commands/print-config'; diff --git a/packages/desktop/README.md b/packages/desktop/README.md index ef23875..e04053b 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -47,6 +47,19 @@ leaves exactly one target behind, and a unified routing spec (authored with its `ws://` URL instead of hunting for an IP. Multicast is often blocked, so scanning is explicit and an empty result is reported as such. +The **Status** route is `wavegrid doctor` as a live screen: it calls the same +`@wavegrid/doctor` collector the CLI does, so the two can never disagree about +health. Left column is the show — brain version/layout/mode/uptime, its connected +receivers with their shards and any layout mismatch, shard coverage (gaps and +overlaps), the registered-device list and config-sync divergence. Right column is +this laptop's checklist with the exact remedy for anything warning or failing. +Both columns scroll independently inside a fixed-height page, so nothing falls +below the fold on a short window. It also carries the **receiver controls**: the +output stage can be stopped and restarted on its own, without dropping the +server, the laser UI, or connected clients — which is how an OSC-target, shard, +or light-map change is applied mid-session (the receiver reads all three at +startup). + The **Settings** route shows where that store lives and offers *clear all* — the same wipe as `wavegrid settings clear`, gated on typing `clear all`. It stops the brain first, then removes every project, secret, user, access key, diff --git a/packages/desktop/__tests__/doctor-format.test.ts b/packages/desktop/__tests__/doctor-format.test.ts new file mode 100644 index 0000000..3f67f2d --- /dev/null +++ b/packages/desktop/__tests__/doctor-format.test.ts @@ -0,0 +1,88 @@ +import { + bySeverity, + formatUptime, + relativeSeen, + serverErrorMessage, + tally +} from '@/renderer/lib/doctor-format'; +import type { DoctorCheck, DoctorReport } from '@/types/ipc'; + +function report(over: Partial = {}): DoctorReport { + return { + project: 'show', + checks: [], + overall: 'pass', + devices: [], + sync: { enabled: true, revision: 0, fromServer: false, relevant: false, behind: [] }, + server: null, + serverUrl: 'ws://localhost:3000', + serverError: null, + receiverRunning: false, + generatedAt: 0, + ...over + }; +} + +const check = (status: DoctorCheck['status'], name: string): DoctorCheck => ({ + name, + status, + detail: name +}); + +describe('formatUptime', () => { + it('scales from seconds to hours', () => { + expect(formatUptime(4_000)).toBe('4s'); + expect(formatUptime(95_000)).toBe('1m 35s'); + expect(formatUptime(3_930_000)).toBe('1h 5m'); + }); +}); + +describe('relativeSeen', () => { + it('reports never for a device that has not checked in', () => { + expect(relativeSeen(null)).toBe('never seen'); + }); + + it('scales the relative age', () => { + const now = 1_000_000_000; + expect(relativeSeen(now - 5_000, now)).toBe('seen 5s ago'); + expect(relativeSeen(now - 120_000, now)).toBe('seen 2m ago'); + expect(relativeSeen(now - 7_200_000, now)).toBe('seen 2h ago'); + expect(relativeSeen(now - 172_800_000, now)).toBe('seen 2d ago'); + }); +}); + +describe('tally + bySeverity', () => { + const checks = [check('pass', 'a'), check('fail', 'b'), check('warn', 'c'), check('pass', 'd')]; + + it('counts each status', () => { + expect(tally(checks)).toEqual({ pass: 2, warn: 1, fail: 1 }); + }); + + it('sorts failures first, then warnings', () => { + expect(bySeverity(checks).map((c) => c.name)).toEqual(['b', 'c', 'a', 'd']); + }); + + it('does not mutate the input order', () => { + bySeverity(checks); + expect(checks.map((c) => c.name)).toEqual(['a', 'b', 'c', 'd']); + }); +}); + +describe('serverErrorMessage', () => { + it('explains a brain that is simply not running', () => { + expect(serverErrorMessage(report({ serverError: 'not-running' }))).toContain( + 'Nothing is listening at ws://localhost:3000' + ); + }); + + it('names the secret mismatch behind a 401 instead of just saying unauthorized', () => { + const msg = serverErrorMessage(report({ serverError: 'unauthorized' })); + expect(msg).toContain('receiver key'); + expect(msg).toContain('different secrets'); + }); + + it('distinguishes a foreign listener from a wedged brain', () => { + expect(serverErrorMessage(report({ serverError: 'not-wavegrid' }))).toContain('not a Wavegrid brain'); + expect(serverErrorMessage(report({ serverError: 'timeout' }))).toContain('never reported status'); + }); +}); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5018ff7..b350835 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -28,6 +28,7 @@ "dependencies": { "@base-ui/react": "^1.4.1", "@wavegrid/discovery": "workspace:*", + "@wavegrid/doctor": "workspace:*", "@wavegrid/layout": "workspace:*", "@wavegrid/receiver": "workspace:*", "@wavegrid/server": "workspace:*", diff --git a/packages/desktop/src/main/brain.ts b/packages/desktop/src/main/brain.ts index c171583..00ca51f 100644 --- a/packages/desktop/src/main/brain.ts +++ b/packages/desktop/src/main/brain.ts @@ -81,9 +81,10 @@ export function status(): BrainStatus { url: current.url, project: current.project, runMode: current.runMode, + receiverRunning: current.receiver != null, lanUrls: lanAddresses().map((ip) => `http://${ip}:${new URL(current!.url).port}`) } - : { running: false, url: null, project: null, runMode: null, lanUrls: [] }; + : { running: false, url: null, project: null, runMode: null, receiverRunning: false, lanUrls: [] }; runtime.lastStatus = s; return s; } @@ -131,6 +132,37 @@ export async function startBrain(project: string): Promise { return broadcast(); } +/** Whether this machine's in-process receiver is driving `project` right now. */ +export function receiverRunning(project: string): boolean { + return current?.project === project && current.receiver != null; +} + +/** + * Start this machine's receiver against the already-running brain. Separate + * from `startBrain` so an operator can restart just the output stage — the + * receiver reads its OSC target, shard, and light map at startup, so a config + * change only takes effect on a restart, and the show's server/UI keep running. + */ +export async function startLocalReceiver(): Promise { + if (!current) throw new Error('Start the brain first — the receiver dials into it.'); + if (current.receiver) return status(); + + const store = openStore(); + applyReceiverEnv(store, current.project); + const { startReceiver } = await import('@wavegrid/receiver'); + current.receiver = startReceiver(loadWavegridConfig()); + return broadcast(); +} + +/** Stop this machine's receiver, leaving the brain (server + laser UI) up. */ +export function stopLocalReceiver(): BrainStatus { + if (current?.receiver) { + current.receiver.stop(); + current.receiver = null; + } + return broadcast(); +} + /** * Inject a command into the running brain for `project`, in-process. Returns * false (a no-op) unless that exact project's brain is currently driving the diff --git a/packages/desktop/src/main/doctor.ts b/packages/desktop/src/main/doctor.ts new file mode 100644 index 0000000..9ca8bc7 --- /dev/null +++ b/packages/desktop/src/main/doctor.ts @@ -0,0 +1,86 @@ +/** + * Adapter from `@wavegrid/doctor`'s diagnostics to the renderer's read model. + * Same collector the CLI's `wavegrid doctor` uses, so the dashboard and the + * terminal can never disagree about whether the rig is healthy. Range/relative + * time formatting happens here so the renderer stays free of node deps. + */ +import { collectDiagnostics } from '@wavegrid/doctor'; +import { loadWavegridConfig } from '@wavegrid/layout'; +import { formatRanges } from '@wavegrid/server'; +import { openStore } from '@wavegrid/settings'; + +import { receiverRunning } from '@/main/brain'; +import type { DoctorReceiver, DoctorReport } from '@/types/ipc'; + +export async function buildDoctorReport(project: string): Promise { + const store = openStore(); + if (!project || !store.hasProject(project)) return null; + if (store.getActiveProject() !== project) store.setActiveProject(project); + + const resolved = loadWavegridConfig(); + const diag = await collectDiagnostics({ store, project, resolved }); + const nameFor = (id: string): string => + diag.devices.find((d) => d.id === id)?.name ?? `${id.slice(0, 8)}…`; + + const status = diag.server; + const server: DoctorReport['server'] = status + ? { + version: status.server.version, + layoutName: status.server.layout.name, + count: status.server.layout.count, + mode: status.server.mode, + port: status.server.port, + uptimeMs: status.server.uptimeMs, + uiClients: status.uiClients, + receivers: status.receivers.map((r) => { + const hello = r.hello; + const mismatch = + hello && hello.layout.count !== status.server.layout.count + ? `layout ${hello.layout.id} (${hello.layout.count}) ≠ server (${status.server.layout.count})` + : null; + return { + label: hello?.deviceName ?? hello?.host ?? r.remote, + remote: r.remote, + shard: hello?.shard ? `${hello.shard.start}–${hello.shard.end}` : 'all cannons', + version: hello?.version ?? null, + layoutMismatch: mismatch + }; + }), + coverage: { + claimed: formatRanges(status.coverage.claimed), + gaps: formatRanges(status.coverage.gaps), + overlaps: formatRanges(status.coverage.overlaps), + healthy: status.coverage.gaps.length === 0 && status.coverage.overlaps.length === 0 + } + } + : null; + + return { + project, + checks: diag.checks, + overall: diag.overall, + devices: diag.devices.map((d) => ({ + name: d.name, + address: d.address ?? null, + lastSeen: d.lastSeen ?? null, + shard: d.shard ? `${d.shard.start}–${d.shard.end}` : null + })), + sync: { + enabled: diag.sync.enabled, + revision: diag.sync.revision, + fromServer: diag.sync.fromServer, + // A project nobody has edited has nothing to say about replication. + relevant: diag.sync.revision > 0 || diag.sync.entryCount > 0, + behind: diag.sync.divergent.map((d) => ({ + name: nameFor(d.deviceId), + ackedRevision: d.ackedRevision, + behindBy: d.behindBy + })) + }, + server, + serverUrl: diag.serverUrl, + serverError: diag.serverError ?? null, + receiverRunning: receiverRunning(project), + generatedAt: Date.now() + }; +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index fa5e6c9..5daba01 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -3,7 +3,15 @@ import { autoMap, resolveLayout } from '@wavegrid/layout'; import { openStore } from '@wavegrid/settings'; import { ipcMain } from 'electron'; -import { sendToBrain, startBrain, status, stopBrain } from '@/main/brain'; +import { + sendToBrain, + startBrain, + startLocalReceiver, + status, + stopBrain, + stopLocalReceiver +} from '@/main/brain'; +import { buildDoctorReport } from '@/main/doctor'; import { type LaserSyncState, syncLaser } from '@/main/laser-view'; import { buildLightMapView } from '@/main/light-map'; import { applyOscTarget, toOscTarget } from '@/main/osc-target'; @@ -93,6 +101,13 @@ export function registerAllIpc(): void { ipcMain.handle('brain:status', () => status()); ipcMain.handle('brain:start', (_e, project: string) => startBrain(project)); ipcMain.handle('brain:stop', () => stopBrain()); + // Receiver-only controls: the output stage reads its OSC target, shard, and + // light map at startup, so restarting just the receiver applies a config + // change without dropping the server, the laser UI, or connected clients. + ipcMain.handle('brain:startReceiver', () => startLocalReceiver()); + ipcMain.handle('brain:stopReceiver', () => stopLocalReceiver()); + + ipcMain.handle('doctor:report', (_e, project: string) => buildDoctorReport(project)); ipcMain.handle('projects:list', () => projectSummaries()); ipcMain.handle('projects:active', () => openStore().getActiveProject()); diff --git a/packages/desktop/src/main/runtime.ts b/packages/desktop/src/main/runtime.ts index d665272..ab1992e 100644 --- a/packages/desktop/src/main/runtime.ts +++ b/packages/desktop/src/main/runtime.ts @@ -10,7 +10,14 @@ interface Runtime { export const runtime: Runtime = { mainWindow: null, - lastStatus: { running: false, url: null, project: null, runMode: null, lanUrls: [] } + lastStatus: { + running: false, + url: null, + project: null, + runMode: null, + receiverRunning: false, + lanUrls: [] + } }; /** Push an event + payload to the renderer, if a live window exists. */ diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index dd3cf9a..e0a0616 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { AccessKeyInfo, BrainStatus, DeviceInfo, DiscoveredBrainInfo, EditableConfig, ExportResult, ImportRequest, ImportSummary, LaserSyncState, LightMapView, NewProjectInput, OscTarget, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, StoreClearResult, StoreInfo, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc'; +import type { AccessKeyInfo, BrainStatus, DeviceInfo, DiscoveredBrainInfo, DoctorReport, EditableConfig, ExportResult, ImportRequest, ImportSummary, LaserSyncState, LightMapView, NewProjectInput, OscTarget, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, StoreClearResult, StoreInfo, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc'; // The single, narrow bridge exposed to the renderer. The renderer never imports // @wavegrid/settings or `fs`; everything goes through these typed calls. @@ -9,12 +9,17 @@ const api: WavegridApi = { status: () => ipcRenderer.invoke('brain:status'), start: (project) => ipcRenderer.invoke('brain:start', project), stop: () => ipcRenderer.invoke('brain:stop'), + startReceiver: () => ipcRenderer.invoke('brain:startReceiver'), + stopReceiver: () => ipcRenderer.invoke('brain:stopReceiver'), onStatus: (cb: (status: BrainStatus) => void) => { const listener = (_e: unknown, payload: BrainStatus) => cb(payload); ipcRenderer.on('brain:status', listener); return () => ipcRenderer.removeListener('brain:status', listener); } }, + doctor: { + report: (project) => ipcRenderer.invoke('doctor:report', project) as Promise + }, projects: { list: () => ipcRenderer.invoke('projects:list') as Promise, active: () => ipcRenderer.invoke('projects:active') as Promise, diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 1980612..5dac044 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { Cog, Cpu, FolderKanban, Lightbulb, MonitorPlay, Radio, ShieldCheck, SlidersHorizontal } from 'lucide-react'; +import { Activity, Cog, Cpu, FolderKanban, Lightbulb, MonitorPlay, Radio, ShieldCheck, SlidersHorizontal } from 'lucide-react'; import * as React from 'react'; import { type AppLinkRenderer } from '@/components/ui/app-bar'; @@ -10,6 +10,7 @@ import { useBrainStatus, useDevices, useDiscovery, + useDoctor, useLightMap, useOscTarget, usePresets, @@ -29,9 +30,11 @@ import { OutputRoute } from '@/renderer/routes/output-route'; import { ProjectsRoute } from '@/renderer/routes/projects-route'; import { SettingsRoute } from '@/renderer/routes/settings-route'; import { ShowRoute } from '@/renderer/routes/show-route'; +import { StatusRoute } from '@/renderer/routes/status-route'; type Route = | 'show' + | 'status' | 'projects' | 'config' | 'access' @@ -42,6 +45,7 @@ type Route = const ROUTE_LABEL: Record = { show: 'Show', + status: 'Status', projects: 'Projects', config: 'Config', access: 'Users & Secrets', @@ -53,6 +57,7 @@ const ROUTE_LABEL: Record = { const ROUTES: Route[] = [ 'show', + 'status', 'projects', 'config', 'access', @@ -127,6 +132,12 @@ export function App() { identifyClear: identifyClearLights } = useLightMap(editingProject); const { info: storeInfo, refresh: refreshStore, clear: clearStore } = useStore(); + const { + report: doctorReport, + loading: doctorLoading, + error: doctorError, + refresh: refreshDoctor + } = useDoctor(activeProject); const { target: oscTarget, refresh: refreshOsc, save: saveOsc } = useOscTarget(editingProject); const discovery = useDiscovery(); const { exportProject, importProject } = useTransfer(refresh); @@ -239,6 +250,13 @@ export function App() { icon: MonitorPlay, isActive: route === 'show' }, + { + id: 'status', + label: 'Status', + href: '#status', + icon: Activity, + isActive: route === 'status' + }, { id: 'projects', label: 'Projects', @@ -307,7 +325,8 @@ export function App() { if (route === 'lights') void refreshLightMap(); if (route === 'output') void refreshOsc(); if (route === 'settings') void refreshStore(); - }, [route, refresh, refreshDevices, refreshConfig, refreshUsers, refreshSessions, refreshKeys, refreshSecrets, refreshLightMap, refreshOsc, refreshStore]); + if (route === 'status') void refreshDoctor(); + }, [route, refresh, refreshDevices, refreshConfig, refreshUsers, refreshSessions, refreshKeys, refreshSecrets, refreshLightMap, refreshOsc, refreshStore, refreshDoctor]); return ( )} + {route === 'status' && ( + void refreshDoctor()} + brainLive={status.running && status.project === activeProject} + receiverRunning={status.receiverRunning && status.project === activeProject} + onStartReceiver={() => void withBusy(async () => { + await window.wavegrid.brain.startReceiver(); + await refreshDoctor(); + })} + onStopReceiver={() => void withBusy(async () => { + await window.wavegrid.brain.stopReceiver(); + await refreshDoctor(); + })} + busy={busy} + /> + )} {route === 'projects' && ( ( + (acc, c) => ({ ...acc, [c.status]: acc[c.status] + 1 }), + { pass: 0, warn: 0, fail: 0 } + ); +} + +/** Failures first, then warnings, then passes — the order you want to read. */ +export function bySeverity(checks: DoctorCheck[]): DoctorCheck[] { + const rank = { fail: 0, warn: 1, pass: 2 }; + return [...checks].sort((a, b) => rank[a.status] - rank[b.status]); +} + +/** Why the brain's own view is missing, phrased as something to act on. */ +export function serverErrorMessage(report: DoctorReport): string { + switch (report.serverError) { + case 'not-running': + return `Nothing is listening at ${report.serverUrl} — start the show to see receivers and coverage.`; + case 'unauthorized': + return `The brain at ${report.serverUrl} rejected this laptop's receiver key (401) — the two stores hold different secrets. Re-import the project (or re-sync secrets) so they match.`; + case 'timeout': + return `The brain at ${report.serverUrl} accepted the connection but never reported status — it may be starting up or wedged.`; + case 'not-wavegrid': + return `Something is listening at ${report.serverUrl}, but it is not a Wavegrid brain.`; + case 'refused': + return `The connection to ${report.serverUrl} was refused.`; + default: + return `Could not read status from ${report.serverUrl}.`; + } +} + +export const OVERALL_LABEL: Record = { + pass: 'healthy', + warn: 'warnings', + fail: 'problems found' +}; diff --git a/packages/desktop/src/renderer/lib/use-wavegrid.ts b/packages/desktop/src/renderer/lib/use-wavegrid.ts index 9551cd6..cf1ebc3 100644 --- a/packages/desktop/src/renderer/lib/use-wavegrid.ts +++ b/packages/desktop/src/renderer/lib/use-wavegrid.ts @@ -5,6 +5,7 @@ import type { BrainStatus, DeviceInfo, DiscoveredBrainInfo, + DoctorReport, EditableConfig, ExportResult, ImportRequest, @@ -27,6 +28,7 @@ const EMPTY_STATUS: BrainStatus = { url: null, project: null, runMode: null, + receiverRunning: false, lanUrls: [] }; @@ -514,6 +516,40 @@ export function useDevices(project: string | null): { return { devices, refresh, rename, assignShard }; } +/** + * The live health snapshot for a project — the same data `wavegrid doctor` + * prints. Each collection probes the brain, so refresh is explicit (or on an + * interval the Status screen owns) rather than on every render. + */ +export function useDoctor(project: string | null): { + report: DoctorReport | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + } { + const [report, setReport] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const refresh = React.useCallback(async () => { + if (!project) { + setReport(null); + return; + } + setLoading(true); + try { + setReport(await window.wavegrid.doctor.report(project)); + setError(null); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [project]); + + return { report, loading, error, refresh }; +} + /** The global store: where it lives, what it holds, and clear-all. */ export function useStore(): { info: StoreInfo | null; diff --git a/packages/desktop/src/renderer/routes/status-route.tsx b/packages/desktop/src/renderer/routes/status-route.tsx new file mode 100644 index 0000000..f9e099c --- /dev/null +++ b/packages/desktop/src/renderer/routes/status-route.tsx @@ -0,0 +1,351 @@ +import { + Activity, + AlertTriangle, + CheckCircle2, + Cpu, + Play, + RefreshCw, + RotateCcw, + Square, + XCircle +} from 'lucide-react'; +import * as React from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle +} from '@/components/ui/empty'; +import { Separator } from '@/components/ui/separator'; +import { + bySeverity, + formatUptime, + OVERALL_LABEL, + relativeSeen, + serverErrorMessage, + tally +} from '@/renderer/lib/doctor-format'; +import type { DoctorCheck, DoctorReport } from '@/types/ipc'; + +interface StatusRouteProps { + project: string | null; + report: DoctorReport | null; + loading: boolean; + error: string | null; + onRefresh: () => void; + /** Receiver controls — only meaningful while this project's brain is running. */ + brainLive: boolean; + receiverRunning: boolean; + onStartReceiver: () => void; + onStopReceiver: () => void; + busy: boolean; +} + +const REFRESH_MS = 5000; + +const STATUS_ICON: Record = { + pass: , + warn: , + fail: +}; + +function Card({ title, children, action }: { + title: string; + children: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+
+ {title} + {action} +
+ + {children} +
+ ); +} + +/** + * Status — the desktop face of `wavegrid doctor`, refreshed live. Left column is + * the show (brain, its receivers, coverage, the device registry); right column is + * the checklist of this laptop's own problems and their exact fixes. Each column + * scrolls on its own inside a fixed-height page, so nothing falls below the fold + * on a short window. + */ +export function StatusRoute({ + project, + report, + loading, + error, + onRefresh, + brainLive, + receiverRunning, + onStartReceiver, + onStopReceiver, + busy +}: StatusRouteProps) { + const [live, setLive] = React.useState(true); + + React.useEffect(() => { + if (!live || !project) return; + const id = window.setInterval(onRefresh, REFRESH_MS); + return () => window.clearInterval(id); + }, [live, project, onRefresh]); + + if (!project) { + return ( +
+ + + + + + No active project + Select a project to see its health. + + +
+ ); + } + + const counts = report ? tally(report.checks) : null; + const server = report?.server ?? null; + + return ( +
+ {/* Toolbar: verdict + refresh + receiver controls, always on screen. */} +
+ + {project} + {report && ( + + {OVERALL_LABEL[report.overall]} + + )} + + {server ? `brain up · ${formatUptime(server.uptimeMs)}` : 'brain down'} + + {server && ( + + {server.receivers.length} receiver{server.receivers.length === 1 ? '' : 's'} · {server.uiClients} UI + + )} + +
+ + +
+
+ + {error &&

{error}

} + +
+ {/* ── The show ─────────────────────────────────────────────────── */} +
+ {report?.serverUrl} + } + > + {server ? ( +
+
Version
+
v{server.version}
+
Layout
+
+ {server.layoutName} · {server.count} cannons +
+
Mode
+
{server.mode}
+
Port
+
:{server.port}
+
Coverage
+
+ claimed {server.coverage.claimed} + {!server.coverage.healthy && ( + + {' '}· gaps {server.coverage.gaps} · overlaps {server.coverage.overlaps} + + )} +
+
+ ) : ( +

+ {report ? serverErrorMessage(report) : 'Collecting…'} +

+ )} +
+ + + {receiverRunning ? 'running' : 'stopped'} + + } + > +

+ The output stage reads its OSC target, shard and light map when it starts — restart it + after changing any of those to apply them without dropping the show. +

+
+ {receiverRunning ? ( + <> + + + + ) : ( + + )} + {!brainLive && ( + + Start the show first — the receiver dials into the brain. + + )} +
+
+ + + {!server ? ( +

Needs a running brain.

+ ) : server.receivers.length === 0 ? ( +

+ None connected — nothing is driving the lasers. +

+ ) : ( +
    + {server.receivers.map((r) => ( +
  • + + {r.label} + {r.remote} + {r.shard} + {r.version && v{r.version}} + {r.layoutMismatch && ( + ⚠ {r.layoutMismatch} + )} +
  • + ))} +
+ )} +
+ + {report && report.devices.length > 0 && ( + +
    + {report.devices.map((d) => ( +
  • + {d.name} + {d.address && {d.address}} + {d.shard && {d.shard}} + {relativeSeen(d.lastSeen)} +
  • + ))} +
+
+ )} + + {report && (!report.sync.enabled || report.sync.relevant) && ( + + {report.sync.enabled ? `revision ${report.sync.revision}` : 'disabled'} + + } + > + {!report.sync.enabled ? ( +

+ Edits stay local to each device — turn sync on to replicate them. +

+ ) : report.sync.behind.length === 0 ? ( +

+ Every device is at revision {report.sync.revision}. +

+ ) : ( +
    + {report.sync.behind.map((d) => ( +
  • + {d.name}{' '} + + acked rev {d.ackedRevision}, behind by {d.behindBy} — reconnect it to resync + +
  • + ))} +
+ )} +
+ )} +
+ + {/* ── This laptop's checks ─────────────────────────────────────── */} +
+
+ Checks + {counts && ( + + {counts.fail} failing · {counts.warn} warning · {counts.pass} passing + + )} +
+ +
+ {!report ? ( +

Collecting…

+ ) : ( +
    + {bySeverity(report.checks).map((check) => ( +
  • +
    + {STATUS_ICON[check.status]} + {check.name} + + {check.detail} + +
    + {check.remedy && check.status !== 'pass' && ( + ↳ {check.remedy} + )} +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index 8f7fba1..d8b3b56 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -9,6 +9,9 @@ export interface BrainStatus { url: string | null; project: string | null; runMode: RunMode | null; + /** Whether this machine's in-process receiver (the output stage) is running. + * It can be stopped and restarted without taking the brain down. */ + receiverRunning: boolean; /** LAN URLs receivers / iPads can point at while the brain is running. */ lanUrls: string[]; } @@ -253,6 +256,63 @@ export interface StoreClearResult { info: StoreInfo; } +/** One diagnostic and, when it isn't passing, the exact fix. */ +export interface DoctorCheck { + name: string; + status: 'pass' | 'warn' | 'fail'; + detail: string; + remedy?: string; +} + +/** A receiver the brain currently has connected, as the brain sees it. */ +export interface DoctorReceiver { + /** Device name, else host, else remote address. */ + label: string; + remote: string; + /** "start–end" or "all cannons". */ + shard: string; + version: string | null; + /** Set when this receiver's layout disagrees with the server's — it would + * drive the wrong fixtures. */ + layoutMismatch: string | null; +} + +/** The live health snapshot behind the Status screen — the same data + * `wavegrid doctor` prints, formatted for display. */ +export interface DoctorReport { + project: string; + checks: DoctorCheck[]; + overall: 'pass' | 'warn' | 'fail'; + devices: { name: string; address: string | null; lastSeen: number | null; shard: string | null }[]; + sync: { + enabled: boolean; + revision: number; + /** True when the revision came from the running brain, not local state. */ + fromServer: boolean; + /** False for an untouched project, where replication has nothing to report. */ + relevant: boolean; + behind: { name: string; ackedRevision: number; behindBy: number }[]; + }; + /** The brain's own report, or null when it could not be read. */ + server: { + version: string; + layoutName: string; + count: number; + mode: string; + port: number; + uptimeMs: number; + uiClients: number; + receivers: DoctorReceiver[]; + coverage: { claimed: string; gaps: string; overlaps: string; healthy: boolean }; + } | null; + serverUrl: string; + /** 'not-running' | 'unauthorized' | 'timeout' | 'refused' | 'not-wavegrid'. */ + serverError: string | null; + /** Whether this machine's receiver is driving the show right now. */ + receiverRunning: boolean; + generatedAt: number; +} + export interface LaserBounds { x: number; y: number; @@ -272,8 +332,17 @@ export interface WavegridApi { status(): Promise; start(project: string): Promise; stop(): Promise; + /** Start this machine's receiver against the running brain (no-op if up). */ + startReceiver(): Promise; + /** Stop the receiver only — the server and laser UI keep running. */ + stopReceiver(): Promise; onStatus(cb: (status: BrainStatus) => void): () => void; }; + doctor: { + /** Collect the full health snapshot for a project (probes the brain, so it + * can take up to a few seconds when nothing is listening). */ + report(project: string): Promise; + }; projects: { list(): Promise; active(): Promise; diff --git a/packages/doctor/README.md b/packages/doctor/README.md new file mode 100644 index 0000000..646b7ce --- /dev/null +++ b/packages/doctor/README.md @@ -0,0 +1,20 @@ +# @wavegrid/doctor + +Wavegrid diagnostics, collected as data. + +`collectDiagnostics()` runs every this-laptop check (store writability, device +identity, layout/shard sanity, secrets and their file mode, UI users, OSC +target, ambient env footguns) and — when a brain is reachable — reads that +brain's own `system_status` for connected receivers, shard coverage, and +config-sync divergence. + +`wavegrid doctor` renders the result to a terminal; the desktop app renders the +same snapshot as a live status screen. One implementation, so the two can never +disagree about whether the rig is healthy. + +```ts +import { collectDiagnostics } from '@wavegrid/doctor'; + +const diag = await collectDiagnostics({ store, project, resolved }); +diag.overall; // 'pass' | 'warn' | 'fail' +``` diff --git a/packages/cli/__tests__/doctor-checks.test.ts b/packages/doctor/__tests__/checks.test.ts similarity index 76% rename from packages/cli/__tests__/doctor-checks.test.ts rename to packages/doctor/__tests__/checks.test.ts index 4907afa..9ac5e18 100644 --- a/packages/cli/__tests__/doctor-checks.test.ts +++ b/packages/doctor/__tests__/checks.test.ts @@ -1,3 +1,7 @@ +import { chmodSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + import type { WavegridConfig } from '@wavegrid/layout'; import { @@ -6,7 +10,8 @@ import { checkShard, isSecureMode, overallStatus -} from '../src/commands/doctor-checks'; +} from '../src/checks'; +import { dirWritable } from '../src/collect'; function config(osc: WavegridConfig['osc']): WavegridConfig { return { @@ -88,3 +93,24 @@ describe('overallStatus', () => { ])).toBe('fail'); }); }); + +describe('dirWritable', () => { + it('accepts an existing writable dir', () => { + expect(dirWritable(tmpdir())).toBe(true); + }); + + it('accepts a not-yet-created dir whose ancestor is writable (subdirs are lazy)', () => { + expect(dirWritable(join(tmpdir(), 'wg-doctor-does-not-exist', 'state'))).toBe(true); + }); + + it('rejects a dir under a read-only ancestor', () => { + const root = mkdtempSync(join(tmpdir(), 'wg-ro-')); + chmodSync(root, 0o500); + try { + expect(dirWritable(join(root, 'state'))).toBe(false); + } finally { + chmodSync(root, 0o700); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/doctor/jest.config.js b/packages/doctor/jest.config.js new file mode 100644 index 0000000..e301e43 --- /dev/null +++ b/packages/doctor/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json' + } + ] + }, + transformIgnorePatterns: ['/node_modules/*'], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/packages/doctor/package.json b/packages/doctor/package.json new file mode 100644 index 0000000..d5dc602 --- /dev/null +++ b/packages/doctor/package.json @@ -0,0 +1,54 @@ +{ + "name": "@wavegrid/doctor", + "version": "1.0.0", + "author": "Dan Lynch ", + "description": "Wavegrid diagnostics — the checks behind `wavegrid doctor`, collected as data so the CLI and the desktop app report the same health", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./esm/index.js", + "require": "./index.js" + } + }, + "homepage": "https://github.com/constructive-io/wavegrid", + "license": "SEE LICENSE IN LICENSE", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/wavegrid" + }, + "bugs": { + "url": "https://github.com/constructive-io/wavegrid/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "ESLINT_USE_FLAT_CONFIG=false eslint src --fix", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch" + }, + "keywords": [ + "wavegrid", + "diagnostics", + "doctor", + "laser" + ], + "dependencies": { + "@wavegrid/layout": "workspace:*", + "@wavegrid/server": "workspace:*", + "@wavegrid/settings": "workspace:*", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.13", + "makage": "^0.3.0" + } +} diff --git a/packages/cli/src/commands/doctor-checks.ts b/packages/doctor/src/checks.ts similarity index 100% rename from packages/cli/src/commands/doctor-checks.ts rename to packages/doctor/src/checks.ts diff --git a/packages/doctor/src/collect.ts b/packages/doctor/src/collect.ts new file mode 100644 index 0000000..7ac9baf --- /dev/null +++ b/packages/doctor/src/collect.ts @@ -0,0 +1,193 @@ +/** + * Collect every diagnostic as data. `wavegrid doctor` renders this to a + * terminal and the desktop app renders the same snapshot as a live status + * screen — one implementation, so the two can never disagree about health. + */ +import type { ResolvedConfig } from '@wavegrid/layout'; +import type { SystemStatus } from '@wavegrid/server'; +import { + type DeviceRecord, + type DivergentDevice, + projectSecretsFile, + type SettingsStore +} from '@wavegrid/settings'; +import { accessSync, constants, existsSync, statSync } from 'fs'; +import { dirname } from 'path'; +import { URL } from 'url'; + +import { + type Check, + checkEnvHijack, + checkOsc, + checkShard, + type CheckStatus, + isSecureMode, + overallStatus +} from './checks'; +import { type ProbeError, querySystemStatus, tcpProbe } from './probe'; + +const NODE_MIN_MAJOR = 18; + +/** Why the server view is missing, when it is. */ +export type ServerError = ProbeError | 'not-running'; + +export interface Diagnostics { + project: string; + checks: Check[]; + overall: CheckStatus; + /** Devices registered with the project, online or not. */ + devices: DeviceRecord[]; + sync: { + /** False when the operator turned replication off for this project. */ + enabled: boolean; + revision: number; + /** Number of replicated config entries this laptop holds. */ + entryCount: number; + divergent: DivergentDevice[]; + /** True when the revision/divergence came from the running brain rather + * than this laptop's local state. */ + fromServer: boolean; + }; + /** The URL probed for a live server view. */ + serverUrl: string; + /** The brain's own report, or null when it could not be read. */ + server: SystemStatus | null; + serverError?: ServerError; +} + +/** + * Whether `dir` is (or can be) writable. Store subdirs are created lazily at + * `start`, so a missing dir is fine as long as its nearest existing ancestor + * is writable — otherwise a fresh project would spuriously fail the check. + */ +export function dirWritable(dir: string): boolean { + let target = dir; + while (!existsSync(target)) { + const parent = dirname(target); + if (parent === target) return false; + target = parent; + } + try { + accessSync(target, constants.W_OK); + return true; + } catch { + return false; + } +} + +export interface LocalChecksInput { + store: SettingsStore; + project: string; + resolved: ResolvedConfig; + env?: NodeJS.ProcessEnv; +} + +/** + * The this-laptop checks for an already-resolved project. Callers own project + * resolution, because "which project" is answered differently by the CLI + * (flags/env/active) and the desktop app (the selected one). + */ +export function localChecks({ store, project, resolved, env = process.env }: LocalChecksInput): Check[] { + const checks: Check[] = []; + const { config, layout, runMode } = resolved; + + const major = parseInt(process.versions.node.split('.')[0], 10); + checks.push( + major >= NODE_MIN_MAJOR + ? { name: 'Node', status: 'pass', detail: `v${process.versions.node}` } + : { name: 'Node', status: 'warn', detail: `v${process.versions.node} < ${NODE_MIN_MAJOR}`, remedy: `install Node ${NODE_MIN_MAJOR}+` } + ); + + checks.push( + dirWritable(store.paths.root) + ? { name: 'Store', status: 'pass', detail: store.paths.root } + : { name: 'Store', status: 'fail', detail: `not writable: ${store.paths.root}`, remedy: 'check permissions or APPSTASH_BASE_DIR' } + ); + + const device = store.getDevice(); + checks.push({ name: 'Device', status: 'pass', detail: `${device.name} (${device.id.slice(0, 8)}…)` }); + checks.push({ name: 'Project', status: 'pass', detail: project }); + checks.push({ name: 'Layout', status: 'pass', detail: `${layout.name} (${layout.topology}, ${layout.count} cannons) · ${runMode}` }); + checks.push(checkShard(layout.count, config.receiver.shard)); + + for (const secret of store.requiredSecrets(project)) { + if (!secret.set) { + checks.push({ name: `Secret ${secret.name}`, status: 'fail', detail: 'NOT SET', remedy: 'wavegrid secrets init' }); + continue; + } + let modeOk = true; + try { + modeOk = isSecureMode(statSync(projectSecretsFile(store.paths, project)).mode); + } catch { /* file existence already implied by secret.set */ } + checks.push( + modeOk + ? { name: `Secret ${secret.name}`, status: 'pass', detail: 'set (0600)' } + : { + name: `Secret ${secret.name}`, + status: 'warn', + detail: 'set but file is group/other-readable', + remedy: `chmod 600 ${projectSecretsFile(store.paths, project)}` + } + ); + } + + const users = store.listUsers(project); + checks.push( + users.length > 0 + ? { name: 'Users', status: 'pass', detail: `${users.length} UI login(s)` } + : { name: 'Users', status: 'warn', detail: 'no UI users — login returns 503', remedy: 'wavegrid users add ' } + ); + + for (const [label, dir] of [['State dir', store.stateDir(project)], ['Logs dir', store.logsDir(project)]] as const) { + checks.push( + dirWritable(dir) + ? { name: label, status: 'pass', detail: dir } + : { name: label, status: 'warn', detail: `not writable: ${dir}`, remedy: 'check store permissions' } + ); + } + + checks.push(checkOsc(config)); + checks.push(checkEnvHijack(env)); + return checks; +} + +export interface CollectInput extends LocalChecksInput { + /** Override the probed brain URL (defaults to the project's server port). */ + serverUrl?: string; + timeoutMs?: number; +} + +/** Run the local checks, then read the brain's own view if it is reachable. */ +export async function collectDiagnostics(input: CollectInput): Promise { + const { store, project, resolved, serverUrl, timeoutMs } = input; + const checks = localChecks(input); + + const url = serverUrl ?? `ws://localhost:${resolved.config.server.port}`; + const parsed = new URL(url); + const port = parseInt(parsed.port || '3000', 10); + const portState = await tcpProbe(parsed.hostname, port, timeoutMs); + + const key = store.hasSecret(project, 'receiverKey') ? store.requireSecret(project, 'receiverKey') : ''; + const probe = portState === 'open' ? await querySystemStatus(url, key, timeoutMs) : undefined; + + const local = store.getSyncState(project); + const known = store.listDevices(project); + const serverSync = probe?.status?.sync; + + return { + project, + checks, + overall: overallStatus(checks), + devices: known, + sync: { + enabled: store.getProjectConfig(project)?.sync?.enabled !== false, + revision: serverSync?.revision ?? local.revision, + entryCount: Object.keys(local.entries).length, + divergent: serverSync?.divergent ?? store.divergentDevices(project, known.map((d) => d.id)), + fromServer: serverSync != null + }, + serverUrl: url, + server: probe?.status ?? null, + serverError: portState === 'closed' ? 'not-running' : probe?.error + }; +} diff --git a/packages/doctor/src/index.ts b/packages/doctor/src/index.ts new file mode 100644 index 0000000..28bccff --- /dev/null +++ b/packages/doctor/src/index.ts @@ -0,0 +1,26 @@ +export { + type Check, + checkEnvHijack, + checkOsc, + checkShard, + type CheckStatus, + IGNORED_ENV_VARS, + isSecureMode, + overallStatus +} from './checks'; +export { + collectDiagnostics, + type CollectInput, + type Diagnostics, + dirWritable, + localChecks, + type LocalChecksInput, + type ServerError +} from './collect'; +export { + type PortState, + type ProbeError, + querySystemStatus, + type StatusProbe, + tcpProbe +} from './probe'; diff --git a/packages/doctor/src/probe.ts b/packages/doctor/src/probe.ts new file mode 100644 index 0000000..ab7558b --- /dev/null +++ b/packages/doctor/src/probe.ts @@ -0,0 +1,71 @@ +/** + * Liveness probes against a running brain. Both resolve rather than reject — + * a diagnostic must report "not running" as a fact, never crash the caller. + */ +import type { SystemStatus } from '@wavegrid/server'; +import net from 'net'; +import { URL } from 'url'; +import { WebSocket } from 'ws'; + +export type PortState = 'open' | 'closed'; + +/** Why a system_status read failed, when it did. */ +export type ProbeError = 'unauthorized' | 'timeout' | 'refused' | 'not-wavegrid'; + +export interface StatusProbe { + status?: SystemStatus; + error?: ProbeError; +} + +/** TCP probe: 'open' if something is listening, else 'closed'. */ +export function tcpProbe(host: string, port: number, timeoutMs = 1500): Promise { + const target = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; + return new Promise((resolve) => { + const socket = new net.Socket(); + const done = (result: PortState) => { + socket.destroy(); + resolve(result); + }; + socket.setTimeout(timeoutMs); + socket.once('connect', () => done('open')); + socket.once('timeout', () => done('closed')); + socket.once('error', () => done('closed')); + socket.connect(port, target); + }); +} + +/** Connect to a running server and request a system_status snapshot. */ +export function querySystemStatus(url: string, key: string, timeoutMs = 3000): Promise { + return new Promise((resolve) => { + const u = new URL(url); + if (key) u.searchParams.set('key', key); + let settled = false; + const finish = (probe: StatusProbe) => { + if (settled) return; + settled = true; + try { ws.close(); } catch { /* already closing */ } + resolve(probe); + }; + const timer = setTimeout(() => finish({ error: 'timeout' }), timeoutMs); + const ws = new WebSocket(u.toString()); + + ws.on('open', () => ws.send(JSON.stringify({ type: 'system_status' }))); + ws.on('unexpected-response', (_req, res) => { + clearTimeout(timer); + finish({ error: res.statusCode === 401 ? 'unauthorized' : 'not-wavegrid' }); + }); + ws.on('message', (raw) => { + try { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'system_status') { + clearTimeout(timer); + finish({ status: msg as SystemStatus }); + } + } catch { /* ignore non-JSON frames */ } + }); + ws.on('error', () => { + clearTimeout(timer); + finish({ error: 'refused' }); + }); + }); +} diff --git a/packages/doctor/tsconfig.esm.json b/packages/doctor/tsconfig.esm.json new file mode 100644 index 0000000..aff046f --- /dev/null +++ b/packages/doctor/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "moduleResolution": "bundler", + "declaration": false + } +} diff --git a/packages/doctor/tsconfig.json b/packages/doctor/tsconfig.json new file mode 100644 index 0000000..9c8a7d7 --- /dev/null +++ b/packages/doctor/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc672d3..9f1329d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: '@wavegrid/discovery': specifier: workspace:* version: link:../discovery/dist + '@wavegrid/doctor': + specifier: workspace:* + version: link:../doctor/dist '@wavegrid/layout': specifier: workspace:* version: link:../layout/dist @@ -131,6 +134,9 @@ importers: '@wavegrid/discovery': specifier: workspace:* version: link:../discovery/dist + '@wavegrid/doctor': + specifier: workspace:* + version: link:../doctor/dist '@wavegrid/layout': specifier: workspace:* version: link:../layout/dist @@ -243,6 +249,29 @@ importers: version: 0.3.1 publishDirectory: dist + packages/doctor: + dependencies: + '@wavegrid/layout': + specifier: workspace:* + version: link:../layout/dist + '@wavegrid/server': + specifier: workspace:* + version: link:../server/dist + '@wavegrid/settings': + specifier: workspace:* + version: link:../settings/dist + ws: + specifier: ^8.18.0 + version: 8.21.0 + devDependencies: + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 + makage: + specifier: ^0.3.0 + version: 0.3.1 + publishDirectory: dist + packages/layout: dependencies: confstash: