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:
@@ -0,0 +1,53 @@
|
||||
import Docker from "dockerode";
|
||||
import type { ContainerStats } from "dockerode";
|
||||
import type { DockerContainerResult } from "@/lib/types/widget-result";
|
||||
|
||||
const docker = new Docker({
|
||||
socketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock",
|
||||
});
|
||||
|
||||
function calcCpuPercent(stats: ContainerStats): number | null {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
|
||||
if (systemDelta <= 0 || cpuDelta < 0) return null;
|
||||
return (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
|
||||
const EPOCH_STARTED_AT = "0001-01-01T00:00:00Z";
|
||||
|
||||
export async function collectDockerContainer(
|
||||
containerName: string,
|
||||
showStats: boolean
|
||||
): Promise<DockerContainerResult> {
|
||||
const container = docker.getContainer(containerName);
|
||||
const inspect = await container.inspect();
|
||||
|
||||
let cpuPercent: number | null = null;
|
||||
let memUsageBytes: number | null = null;
|
||||
let memLimitBytes: number | null = null;
|
||||
|
||||
if (showStats && inspect.State.Running) {
|
||||
const stats = await container.stats({ stream: false });
|
||||
cpuPercent = calcCpuPercent(stats);
|
||||
memUsageBytes = stats.memory_stats.usage ?? null;
|
||||
memLimitBytes = stats.memory_stats.limit ?? null;
|
||||
}
|
||||
|
||||
const startedAt =
|
||||
inspect.State.StartedAt && inspect.State.StartedAt !== EPOCH_STARTED_AT ? inspect.State.StartedAt : null;
|
||||
|
||||
return {
|
||||
status: (inspect.State.Status as DockerContainerResult["status"]) || "unknown",
|
||||
health: (inspect.State.Health?.Status as DockerContainerResult["health"]) || "none",
|
||||
startedAt,
|
||||
uptimeSeconds: startedAt
|
||||
? Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000))
|
||||
: null,
|
||||
restartCount: inspect.RestartCount ?? 0,
|
||||
cpuPercent,
|
||||
memUsageBytes,
|
||||
memLimitBytes,
|
||||
image: inspect.Config.Image,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import pLimit from "p-limit";
|
||||
import type { Config, Widget } from "@/lib/config/schema";
|
||||
import { flattenWidgets } from "@/lib/config/widgets";
|
||||
import { parseDuration } from "@/lib/config/duration";
|
||||
import { collectDockerContainer } from "./docker";
|
||||
import type { WidgetResult } from "@/lib/types/widget-result";
|
||||
|
||||
const dockerLimit = pLimit(4);
|
||||
|
||||
type ResultListener = (widgetId: string, result: WidgetResult) => void;
|
||||
|
||||
interface Job {
|
||||
widget: Widget;
|
||||
intervalMs: number;
|
||||
timer: ReturnType<typeof setInterval>;
|
||||
}
|
||||
|
||||
function isCollectorWidget(widget: Widget): boolean {
|
||||
return widget.type === "docker" || widget.type === "database";
|
||||
}
|
||||
|
||||
function getIntervalMs(widget: Widget): number {
|
||||
if (widget.type === "docker" || widget.type === "database") {
|
||||
return parseDuration(widget.interval);
|
||||
}
|
||||
return 30_000;
|
||||
}
|
||||
|
||||
function sameTarget(a: Widget, b: Widget): boolean {
|
||||
if (a.type === "docker" && b.type === "docker") {
|
||||
return a.containerName === b.containerName && a.showStats === b.showStats;
|
||||
}
|
||||
if (a.type === "database" && b.type === "database") {
|
||||
return a.containerName === b.containerName;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function collect(widget: Widget): Promise<WidgetResult> {
|
||||
if (widget.type === "docker") {
|
||||
const data = await dockerLimit(() => collectDockerContainer(widget.containerName, widget.showStats));
|
||||
return { type: "docker", data };
|
||||
}
|
||||
if (widget.type === "database") {
|
||||
const data = await dockerLimit(() => collectDockerContainer(widget.containerName, true));
|
||||
return { type: "database", data };
|
||||
}
|
||||
throw new Error(`No collector for widget type "${widget.type}"`);
|
||||
}
|
||||
|
||||
class CollectorScheduler {
|
||||
private readonly jobs = new Map<string, Job>();
|
||||
private readonly lastResults = new Map<string, WidgetResult>();
|
||||
private readonly listeners = new Set<ResultListener>();
|
||||
|
||||
start(config: Config): void {
|
||||
this.reconcile(config);
|
||||
}
|
||||
|
||||
onResult(listener: ResultListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
getLastResult(widgetId: string): WidgetResult | undefined {
|
||||
return this.lastResults.get(widgetId);
|
||||
}
|
||||
|
||||
reconcile(config: Config): void {
|
||||
const instances = flattenWidgets(config).filter((instance) => isCollectorWidget(instance.widget));
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const instance of instances) {
|
||||
seen.add(instance.id);
|
||||
const intervalMs = getIntervalMs(instance.widget);
|
||||
const existing = this.jobs.get(instance.id);
|
||||
|
||||
if (
|
||||
existing &&
|
||||
existing.intervalMs === intervalMs &&
|
||||
existing.widget.type === instance.widget.type &&
|
||||
sameTarget(existing.widget, instance.widget)
|
||||
) {
|
||||
existing.widget = instance.widget;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing) clearInterval(existing.timer);
|
||||
|
||||
const run = () => this.runJob(instance.id, instance.widget);
|
||||
const timer = setInterval(run, intervalMs);
|
||||
this.jobs.set(instance.id, { widget: instance.widget, intervalMs, timer });
|
||||
run();
|
||||
}
|
||||
|
||||
for (const [id, job] of this.jobs) {
|
||||
if (!seen.has(id)) {
|
||||
clearInterval(job.timer);
|
||||
this.jobs.delete(id);
|
||||
this.lastResults.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const job of this.jobs.values()) clearInterval(job.timer);
|
||||
this.jobs.clear();
|
||||
}
|
||||
|
||||
private async runJob(id: string, widget: Widget): Promise<void> {
|
||||
let result: WidgetResult;
|
||||
try {
|
||||
result = await collect(widget);
|
||||
} catch (err) {
|
||||
result = { type: "error", message: (err as Error).message };
|
||||
}
|
||||
this.lastResults.set(id, result);
|
||||
for (const listener of this.listeners) listener(id, result);
|
||||
}
|
||||
}
|
||||
|
||||
export const collectorScheduler = new CollectorScheduler();
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const days = Math.floor(seconds / 86_400);
|
||||
const hours = Math.floor((seconds % 86_400) / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ["KB", "MB", "GB", "TB"];
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${BYTE_UNITS[unitIndex]}`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface DockerContainerResult {
|
||||
status: "running" | "exited" | "restarting" | "paused" | "dead" | "created" | "unknown";
|
||||
health: "healthy" | "unhealthy" | "starting" | "none";
|
||||
startedAt: string | null;
|
||||
uptimeSeconds: number | null;
|
||||
restartCount: number;
|
||||
cpuPercent: number | null;
|
||||
memUsageBytes: number | null;
|
||||
memLimitBytes: number | null;
|
||||
image: string;
|
||||
}
|
||||
|
||||
export type WidgetResult =
|
||||
| { type: "docker"; data: DockerContainerResult }
|
||||
| { type: "database"; data: DockerContainerResult }
|
||||
| { type: "error"; message: string };
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Config } from "@/lib/config/schema";
|
||||
import type { WidgetResult } from "@/lib/types/widget-result";
|
||||
|
||||
interface Envelope {
|
||||
topic: string;
|
||||
type: string;
|
||||
ts: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
type TopicListener = (envelope: Envelope) => void;
|
||||
|
||||
let sharedSocket: WebSocket | null = null;
|
||||
let refCount = 0;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const topicListeners = new Map<string, Set<TopicListener>>();
|
||||
const pendingWidgetSubscriptions = new Set<string>();
|
||||
|
||||
function getSocketUrl(): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer || refCount === 0) return;
|
||||
const delay = Math.min(30_000, 1_000 * 2 ** reconnectAttempts);
|
||||
reconnectAttempts += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (refCount > 0) ensureSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function ensureSocket(): WebSocket {
|
||||
if (sharedSocket && sharedSocket.readyState <= WebSocket.OPEN) {
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(getSocketUrl());
|
||||
sharedSocket = socket;
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
reconnectAttempts = 0;
|
||||
for (const widgetId of pendingWidgetSubscriptions) {
|
||||
socket.send(JSON.stringify({ action: "subscribe", widgetId }));
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
let envelope: Envelope;
|
||||
try {
|
||||
envelope = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const listeners = topicListeners.get(envelope.topic);
|
||||
if (!listeners) return;
|
||||
for (const listener of listeners) listener(envelope);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
if (sharedSocket === socket) sharedSocket = null;
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
function addTopicListener(topic: string, listener: TopicListener): () => void {
|
||||
let listeners = topicListeners.get(topic);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
topicListeners.set(topic, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners?.delete(listener);
|
||||
if (listeners && listeners.size === 0) topicListeners.delete(topic);
|
||||
};
|
||||
}
|
||||
|
||||
export function useWidgetSubscription(widgetId: string): WidgetResult | null {
|
||||
const [result, setResult] = useState<WidgetResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refCount += 1;
|
||||
pendingWidgetSubscriptions.add(widgetId);
|
||||
const socket = ensureSocket();
|
||||
const topic = `widget:${widgetId}`;
|
||||
|
||||
const removeListener = addTopicListener(topic, (envelope) => {
|
||||
if (envelope.type === "result") setResult(envelope.data as WidgetResult);
|
||||
});
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ action: "subscribe", widgetId }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
refCount -= 1;
|
||||
removeListener();
|
||||
if (!topicListeners.has(topic)) {
|
||||
pendingWidgetSubscriptions.delete(widgetId);
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ action: "unsubscribe", widgetId }));
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [widgetId]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useConfigSubscription(initial: Config): Config {
|
||||
const [config, setConfig] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
refCount += 1;
|
||||
ensureSocket();
|
||||
|
||||
const removeListener = addTopicListener("config", (envelope) => {
|
||||
if (envelope.type === "config:update") setConfig(envelope.data as Config);
|
||||
});
|
||||
|
||||
return () => {
|
||||
refCount -= 1;
|
||||
removeListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { collectorScheduler } from "@/lib/collectors/scheduler";
|
||||
import { configStore, type ConfigError } from "@/lib/config/loader";
|
||||
import type { Config } from "@/lib/config/schema";
|
||||
import type { WidgetResult } from "@/lib/types/widget-result";
|
||||
|
||||
type Envelope =
|
||||
| { topic: string; type: "result"; ts: number; data: WidgetResult }
|
||||
| { topic: "config"; type: "config:update"; ts: number; data: Config }
|
||||
| { topic: "config"; type: "config:error"; ts: number; data: { message: string } };
|
||||
|
||||
function send(socket: WebSocket, envelope: Envelope): void {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(envelope));
|
||||
}
|
||||
}
|
||||
|
||||
export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const subscriptions = new Map<string, Set<WebSocket>>();
|
||||
const allSockets = new Set<WebSocket>();
|
||||
|
||||
function subscribe(socket: WebSocket, widgetId: string): void {
|
||||
let set = subscriptions.get(widgetId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
subscriptions.set(widgetId, set);
|
||||
}
|
||||
set.add(socket);
|
||||
const cached = collectorScheduler.getLastResult(widgetId);
|
||||
if (cached) {
|
||||
send(socket, { topic: `widget:${widgetId}`, type: "result", ts: Date.now(), data: cached });
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribe(socket: WebSocket, widgetId: string): void {
|
||||
subscriptions.get(widgetId)?.delete(socket);
|
||||
}
|
||||
|
||||
function cleanupSocket(socket: WebSocket): void {
|
||||
allSockets.delete(socket);
|
||||
for (const set of subscriptions.values()) set.delete(socket);
|
||||
}
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
allSockets.add(socket);
|
||||
|
||||
try {
|
||||
const config = configStore.get();
|
||||
send(socket, { topic: "config", type: "config:update", ts: Date.now(), data: config });
|
||||
} catch {
|
||||
// no valid config loaded yet; client keeps its SSR-seeded config until one arrives
|
||||
}
|
||||
|
||||
socket.on("message", (raw) => {
|
||||
let message: { action?: string; widgetId?: string };
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.action === "subscribe" && typeof message.widgetId === "string") {
|
||||
subscribe(socket, message.widgetId);
|
||||
} else if (message.action === "unsubscribe" && typeof message.widgetId === "string") {
|
||||
unsubscribe(socket, message.widgetId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => cleanupSocket(socket));
|
||||
socket.on("error", () => cleanupSocket(socket));
|
||||
});
|
||||
|
||||
collectorScheduler.onResult((widgetId, result) => {
|
||||
const set = subscriptions.get(widgetId);
|
||||
if (!set || set.size === 0) return;
|
||||
const envelope: Envelope = { topic: `widget:${widgetId}`, type: "result", ts: Date.now(), data: result };
|
||||
for (const socket of set) send(socket, envelope);
|
||||
});
|
||||
|
||||
configStore.onUpdate((config) => {
|
||||
const envelope: Envelope = { topic: "config", type: "config:update", ts: Date.now(), data: config };
|
||||
for (const socket of allSockets) send(socket, envelope);
|
||||
});
|
||||
|
||||
configStore.onError((error: ConfigError) => {
|
||||
const envelope: Envelope = {
|
||||
topic: "config",
|
||||
type: "config:error",
|
||||
ts: Date.now(),
|
||||
data: { message: error.message },
|
||||
};
|
||||
for (const socket of allSockets) send(socket, envelope);
|
||||
});
|
||||
|
||||
httpServer.on("close", () => wss.close());
|
||||
|
||||
return wss;
|
||||
}
|
||||
Reference in New Issue
Block a user