Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
],
"dependencies": {
"@wavegrid/discovery": "workspace:*",
"@wavegrid/doctor": "workspace:*",
"@wavegrid/layout": "workspace:*",
"@wavegrid/receiver": "workspace:*",
"@wavegrid/server": "workspace:*",
Expand Down
340 changes: 79 additions & 261 deletions packages/cli/src/commands/doctor.ts

Large diffs are not rendered by default.

9 changes: 0 additions & 9 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
13 changes: 13 additions & 0 deletions packages/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions packages/desktop/__tests__/doctor-format.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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');
});
});
1 change: 1 addition & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
34 changes: 33 additions & 1 deletion packages/desktop/src/main/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -131,6 +132,37 @@ export async function startBrain(project: string): Promise<BrainStatus> {
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<BrainStatus> {
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
Expand Down
86 changes: 86 additions & 0 deletions packages/desktop/src/main/doctor.ts
Original file line number Diff line number Diff line change
@@ -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<DoctorReport | null> {
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<DoctorReceiver>((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()
};
}
17 changes: 16 additions & 1 deletion packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down
9 changes: 8 additions & 1 deletion packages/desktop/src/main/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 6 additions & 1 deletion packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<DoctorReport | null>
},
projects: {
list: () => ipcRenderer.invoke('projects:list') as Promise<ProjectSummary[]>,
active: () => ipcRenderer.invoke('projects:active') as Promise<string | null>,
Expand Down
Loading
Loading