Files
pulsenode/lib/config/schema.ts
T
valknar ffb70ecddf
CI / Static checks (push) Successful in 35s
CI / Build and push image (push) Successful in 1m12s
feat: add combined docker+API service widgets for gitea/coolify/immich/n8n/umami/headscale
One new `service` widget type (nested discriminated union on `service`)
rather than six, so a single config entry shows both docker container
health and a service-specific stat (repo count, project list, photo
count, workflow count, active users, users/nodes) - avoiding the
overhead of configuring a docker widget and a separate service widget
per container. All six collectors hit the service's container name +
internal port directly on falcon_network, the same container-to-
container pattern just proven out for Traefik's own API, avoiding
vpn-only/hairpin-NAT entirely.

Also adds the public/private config split that was scoped in the
original project plan but never built: lib/config/public.ts strips
apiToken/apiKey/password fields before the config reaches the browser
via SSR or the WS config topic - required before any widget could
carry a real secret. Verified via a throwaway secret field that it's
absent from both the SSR HTML and the WS config:update frame.

Endpoint shapes verified live against the running gitea/coolify/immich/
n8n/umami/headscale containers before committing (unauthenticated
requests correctly 401/200 on every target route; gitea's
X-Total-Count header confirmed present).
2026-08-17 20:45:29 +02:00

195 lines
5.4 KiB
TypeScript

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(),
href: z.string().url(),
description: z.string().optional(),
icon: z.string().optional(),
});
export const searchWidgetSchema = z.object({
type: z.literal("search"),
engines: z.array(searchEngineSchema).min(1).default(["duckduckgo"]),
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"),
icon: z.string().optional(),
});
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 systemWidgetSchema = z.object({
type: z.literal("system"),
name: z.string().default("System"),
interval: durationSchema.default("5s"),
});
export const httpWidgetSchema = z.object({
type: z.literal("http"),
name: z.string(),
url: z.string().url(),
method: z.enum(["GET", "HEAD", "POST"]).default("GET"),
timeout: durationSchema.default("5s"),
interval: durationSchema.default("30s"),
expect: z
.object({
status: z.number().int().min(100).max(599).default(200),
})
.default({ status: 200 }),
});
export const traefikWidgetSchema = z.object({
type: z.literal("traefik"),
name: z.string().default("Traefik"),
apiUrl: z.string().url(),
interval: durationSchema.default("15s"),
});
const serviceCommonFields = {
type: z.literal("service") as z.ZodLiteral<"service">,
name: z.string(),
containerName: z.string(),
href: z.string().url().optional(),
showStats: z.boolean().default(true),
interval: durationSchema.default("15s"),
icon: z.string().optional(),
};
export const giteaServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("gitea"),
username: z.string().default("Valknar"),
apiToken: z.string(),
});
export const coolifyServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("coolify"),
apiToken: z.string(),
});
export const immichServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("immich"),
apiKey: z.string(),
});
export const n8nServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("n8n"),
apiKey: z.string(),
});
export const umamiServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("umami"),
username: z.string(),
password: z.string(),
websiteId: z.string(),
});
export const headscaleServiceWidgetSchema = z.object({
...serviceCommonFields,
service: z.literal("headscale"),
apiToken: z.string(),
});
export const serviceWidgetSchema = z.discriminatedUnion("service", [
giteaServiceWidgetSchema,
coolifyServiceWidgetSchema,
immichServiceWidgetSchema,
n8nServiceWidgetSchema,
umamiServiceWidgetSchema,
headscaleServiceWidgetSchema,
]);
export const widgetSchema = z.discriminatedUnion("type", [
bookmarkWidgetSchema,
searchWidgetSchema,
dockerWidgetSchema,
databaseWidgetSchema,
systemWidgetSchema,
httpWidgetSchema,
traefikWidgetSchema,
serviceWidgetSchema,
]);
export const groupSchema = z.object({
name: z.string(),
widgets: z.array(widgetSchema),
});
export const themeSchema = z
.object({
mode: z.enum(["dark", "light", "auto"]).default("dark"),
variables: z.record(z.string(), z.string()).default({}),
customCssPath: z.string().optional(),
})
.default({ mode: "dark", variables: {} });
export const settingsSchema = z
.object({
title: z.string().default("PulseNode"),
})
.default({ title: "PulseNode" });
const dockerDiscoveryDefaults = {
enabled: false,
socketPath: "/var/run/docker.sock",
labelPrefix: "traefik",
excludeLabels: [] as string[],
};
const dockerDiscoverySchema = z.object({
enabled: z.boolean().default(false),
socketPath: z.string().default("/var/run/docker.sock"),
labelPrefix: z.string().default("traefik"),
network: z.string().optional(),
excludeLabels: z.array(z.string()).default([]),
});
export const discoverySchema = z
.object({
docker: dockerDiscoverySchema.default(dockerDiscoveryDefaults),
})
.default({ docker: dockerDiscoveryDefaults });
export const configSchema = z.object({
theme: themeSchema,
settings: settingsSchema,
discovery: discoverySchema,
groups: z.array(groupSchema).default([]),
});
export type Config = z.infer<typeof configSchema>;
export type Widget = z.infer<typeof widgetSchema>;
export type Group = z.infer<typeof groupSchema>;
export type SearchEngine = z.infer<typeof searchEngineSchema>;
export type ServiceWidget = z.infer<typeof serviceWidgetSchema>;
export type GiteaServiceWidget = z.infer<typeof giteaServiceWidgetSchema>;
export type CoolifyServiceWidget = z.infer<typeof coolifyServiceWidgetSchema>;
export type ImmichServiceWidget = z.infer<typeof immichServiceWidgetSchema>;
export type N8nServiceWidget = z.infer<typeof n8nServiceWidgetSchema>;
export type UmamiServiceWidget = z.infer<typeof umamiServiceWidgetSchema>;
export type HeadscaleServiceWidget = z.infer<typeof headscaleServiceWidgetSchema>;