feat: live docker/database widgets over WebSocket (M2)

Adds a custom server.ts (http server + Next request handler + a
noServer:true WebSocket server on /ws) so the app can push live data
without polling. A CollectorScheduler runs one interval-based job per
docker/database widget instance, backed by dockerode against
/var/run/docker.sock, and reconciles jobs when config.yml changes.

Config hot-reload is now fully wired: chokidar watches config.yml/.env,
re-validates on change, and broadcasts config:update (or a
non-destructive config:error, keeping the last-good config) to every
connected browser. The client subscribes to per-widget topics over a
single shared WebSocket with exponential-backoff reconnect and
last-result caching for instant resubscribe.

Verified end-to-end against real throwaway containers (nginx, redis):
live CPU/mem/uptime/health streamed over the socket, interval changes
picked up without a server restart, and a broken config.yml correctly
falls back to the last-valid config instead of crashing the app.
This commit is contained in:
2026-08-17 14:01:53 +02:00
parent 80c2362ac2
commit 9890f964c5
17 changed files with 693 additions and 7 deletions
+17
View File
@@ -0,0 +1,17 @@
const DURATION_PATTERN = /^(\d+)(ms|s|m|h)$/;
const UNIT_MS: Record<string, number> = {
ms: 1,
s: 1_000,
m: 60_000,
h: 3_600_000,
};
export function parseDuration(value: string): number {
const match = DURATION_PATTERN.exec(value.trim());
if (!match) {
throw new Error(`Invalid duration "${value}" (expected e.g. "500ms", "5s", "1m", "1h")`);
}
const [, amount, unit] = match;
return Number(amount) * UNIT_MS[unit];
}
+23
View File
@@ -2,6 +2,10 @@ import { z } from "zod";
const searchEngineSchema = z.enum(["duckduckgo", "google", "bing"]);
const durationSchema = z
.string()
.regex(/^\d+(ms|s|m|h)$/, 'Expected a duration like "500ms", "5s", "1m", or "1h"');
export const bookmarkWidgetSchema = z.object({
type: z.literal("bookmark"),
name: z.string(),
@@ -15,9 +19,28 @@ export const searchWidgetSchema = z.object({
defaultEngine: searchEngineSchema.optional(),
});
export const dockerWidgetSchema = z.object({
type: z.literal("docker"),
name: z.string(),
containerName: z.string(),
href: z.string().url().optional(),
showStats: z.boolean().default(true),
interval: durationSchema.default("5s"),
});
export const databaseWidgetSchema = z.object({
type: z.literal("database"),
name: z.string(),
containerName: z.string(),
engine: z.enum(["postgres", "redis"]),
interval: durationSchema.default("10s"),
});
export const widgetSchema = z.discriminatedUnion("type", [
bookmarkWidgetSchema,
searchWidgetSchema,
dockerWidgetSchema,
databaseWidgetSchema,
]);
export const groupSchema = z.object({
+21
View File
@@ -0,0 +1,21 @@
import type { Config, Widget } from "./schema";
export interface WidgetInstance {
id: string;
groupName: string;
widget: Widget;
}
export function widgetId(groupIndex: number, widgetIndex: number): string {
return `${groupIndex}:${widgetIndex}`;
}
export function flattenWidgets(config: Config): WidgetInstance[] {
const instances: WidgetInstance[] = [];
config.groups.forEach((group, groupIndex) => {
group.widgets.forEach((widget, widgetIndex) => {
instances.push({ id: widgetId(groupIndex, widgetIndex), groupName: group.name, widget });
});
});
return instances;
}