diff --git a/app/page.tsx b/app/page.tsx index 5e54a7b..dfc2d9b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ import { ConfigError } from "@/lib/config/loader"; import { effectiveConfigStore } from "@/lib/config/effective"; +import { toPublicConfig } from "@/lib/config/public"; import { Dashboard } from "@/components/layout/Dashboard"; export const dynamic = "force-dynamic"; @@ -20,5 +21,5 @@ export default function Home() { ); } - return ; + return ; } diff --git a/components/widgets/registry.ts b/components/widgets/registry.ts index 8fbbc9c..19af177 100644 --- a/components/widgets/registry.ts +++ b/components/widgets/registry.ts @@ -7,6 +7,7 @@ import { DatabaseWidget } from "./database/Widget"; import { SystemWidget } from "./system/Widget"; import { HttpWidget } from "./http/Widget"; import { TraefikWidget } from "./traefik/Widget"; +import { ServiceWidget } from "./service/Widget"; type WidgetComponent = ComponentType<{ widget: Extract; @@ -21,4 +22,5 @@ export const widgetRegistry: { [K in Widget["type"]]: WidgetComponent } = { system: SystemWidget, http: HttpWidget, traefik: TraefikWidget, + service: ServiceWidget, }; diff --git a/components/widgets/service/Widget.tsx b/components/widgets/service/Widget.tsx new file mode 100644 index 0000000..795c082 --- /dev/null +++ b/components/widgets/service/Widget.tsx @@ -0,0 +1,90 @@ +"use client"; + +import type { Widget } from "@/lib/config/schema"; +import { useWidgetSubscription } from "@/lib/ws/client"; +import { StatusDot } from "@/components/widgets/StatusDot"; +import { StatusTag } from "@/components/widgets/StatusTag"; +import { SkeletonLines } from "@/components/widgets/Skeleton"; +import { WidgetCard } from "@/components/widgets/WidgetCard"; +import { MetricBar } from "@/components/widgets/MetricBar"; +import { BrandIcon } from "@/components/widgets/BrandIcon"; +import { formatBytes, formatUptime } from "@/lib/format"; + +type ServiceWidget = Extract; + +export function ServiceWidget({ widget, widgetId }: { widget: ServiceWidget; widgetId: string }) { + const result = useWidgetSubscription(widgetId); + const data = result?.type === "service" ? result.data : null; + const errorMessage = result?.type === "error" ? result.message : null; + const memPercent = + data?.docker.memUsageBytes != null && data.docker.memLimitBytes + ? (data.docker.memUsageBytes / data.docker.memLimitBytes) * 100 + : null; + + return ( + +
+
+ + {widget.href ? ( + + {widget.name} + + ) : ( + {widget.name} + )} +
+ +
+ {errorMessage && {errorMessage}} + {!result && !errorMessage && } + {data && ( + <> + {data.stats.length > 0 && ( +
+ {data.stats.map((stat, index) => ( +
0 ? "border-l border-border pl-4" : ""}`} + > + + {stat.value} + + {stat.label} +
+ ))} +
+ )} + {data.detail && data.detail.length > 0 && ( +
+ {data.detail.map((entry) => ( + + {entry} + + ))} +
+ )} + {data.statError && {data.statError}} + {widget.showStats && data.docker.cpuPercent !== null && memPercent !== null && ( +
+ + +
+ )} +
+ {data.docker.uptimeSeconds !== null && up {formatUptime(data.docker.uptimeSeconds)}} + {widget.showStats && data.docker.memUsageBytes !== null && memPercent === null && ( + {formatBytes(data.docker.memUsageBytes)} + )} + {data.docker.restartCount > 0 && {data.docker.restartCount} restarts} +
+ + )} +
+ ); +} diff --git a/lib/collectors/docker.ts b/lib/collectors/docker.ts index 5fc4076..89cd7e3 100644 --- a/lib/collectors/docker.ts +++ b/lib/collectors/docker.ts @@ -1,11 +1,14 @@ import Docker from "dockerode"; import type { ContainerStats } from "dockerode"; +import pLimit from "p-limit"; import type { DockerContainerResult } from "@/lib/types/widget-result"; const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET_PATH ?? "/var/run/docker.sock", }); +export const dockerLimit = pLimit(4); + 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; diff --git a/lib/collectors/scheduler.ts b/lib/collectors/scheduler.ts index 2806b73..0aed6ad 100644 --- a/lib/collectors/scheduler.ts +++ b/lib/collectors/scheduler.ts @@ -2,13 +2,13 @@ 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 { collectDockerContainer, dockerLimit } from "./docker"; import { collectSystem } from "./system"; import { collectHttp } from "./http"; import { collectTraefik } from "./traefik"; +import { collectService } from "./service"; import type { WidgetResult } from "@/lib/types/widget-result"; -const dockerLimit = pLimit(4); const httpLimit = pLimit(8); type ResultListener = (widgetId: string, result: WidgetResult) => void; @@ -20,7 +20,14 @@ interface Job { consecutiveFailures: number; } -const COLLECTOR_TYPES = new Set(["docker", "database", "system", "http", "traefik"]); +const COLLECTOR_TYPES = new Set([ + "docker", + "database", + "system", + "http", + "traefik", + "service", +]); function isCollectorWidget(widget: Widget): boolean { return COLLECTOR_TYPES.has(widget.type); @@ -32,7 +39,8 @@ function getIntervalMs(widget: Widget): number { widget.type === "database" || widget.type === "system" || widget.type === "http" || - widget.type === "traefik" + widget.type === "traefik" || + widget.type === "service" ) { return parseDuration(widget.interval); } @@ -55,6 +63,9 @@ function sameTarget(a: Widget, b: Widget): boolean { if (a.type === "traefik" && b.type === "traefik") { return a.apiUrl === b.apiUrl; } + if (a.type === "service" && b.type === "service") { + return a.containerName === b.containerName && a.service === b.service && a.showStats === b.showStats; + } return false; } @@ -81,6 +92,10 @@ async function collect(widget: Widget): Promise { const data = await collectTraefik(widget.apiUrl); return { type: "traefik", data }; } + if (widget.type === "service") { + const data = await collectService(widget); + return { type: "service", data }; + } throw new Error(`No collector for widget type "${widget.type}"`); } diff --git a/lib/collectors/service.ts b/lib/collectors/service.ts new file mode 100644 index 0000000..2ba6bc6 --- /dev/null +++ b/lib/collectors/service.ts @@ -0,0 +1,41 @@ +import type { ServiceWidget } from "@/lib/config/schema"; +import type { ServiceStat, ServiceWidgetResult } from "@/lib/types/service-result"; +import { collectDockerContainer, dockerLimit } from "./docker"; +import { collectGitea } from "./services/gitea"; +import { collectCoolify } from "./services/coolify"; +import { collectImmich } from "./services/immich"; +import { collectN8n } from "./services/n8n"; +import { collectUmami } from "./services/umami"; +import { collectHeadscale } from "./services/headscale"; + +function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat[]; detail?: string[] }> { + switch (widget.service) { + case "gitea": + return collectGitea(widget); + case "coolify": + return collectCoolify(widget); + case "immich": + return collectImmich(widget); + case "n8n": + return collectN8n(widget); + case "umami": + return collectUmami(widget); + case "headscale": + return collectHeadscale(widget); + } +} + +export async function collectService(widget: ServiceWidget): Promise { + const dockerPromise = dockerLimit(() => collectDockerContainer(widget.containerName, widget.showStats)); + + // A failed service-API call (bad token, service down) must not take out the + // docker health readout too - the two are independent failure modes and + // merging them into one card should not let one mask the other. + const statPromise = collectServiceStat(widget).catch((err) => ({ + stats: [] as ServiceStat[], + statError: (err as Error).message, + })); + + const [docker, statResult] = await Promise.all([dockerPromise, statPromise]); + return { docker, ...statResult }; +} diff --git a/lib/collectors/services/base-url.ts b/lib/collectors/services/base-url.ts new file mode 100644 index 0000000..6299986 --- /dev/null +++ b/lib/collectors/services/base-url.ts @@ -0,0 +1,14 @@ +import type { ServiceWidget } from "@/lib/config/schema"; + +const SERVICE_PORTS: Record = { + gitea: 3000, + coolify: 8080, + immich: 2283, + n8n: 5678, + umami: 3000, + headscale: 8080, +}; + +export function serviceBaseUrl(widget: ServiceWidget): string { + return `http://${widget.containerName}:${SERVICE_PORTS[widget.service]}`; +} diff --git a/lib/collectors/services/coolify.ts b/lib/collectors/services/coolify.ts new file mode 100644 index 0000000..ec11e87 --- /dev/null +++ b/lib/collectors/services/coolify.ts @@ -0,0 +1,30 @@ +import type { CoolifyServiceWidget } from "@/lib/config/schema"; +import type { ServiceStat } from "@/lib/types/service-result"; +import { serviceBaseUrl } from "./base-url"; + +const DETAIL_LIMIT = 5; + +interface CoolifyProject { + name: string; +} + +export async function collectCoolify( + widget: CoolifyServiceWidget +): Promise<{ stats: ServiceStat[]; detail?: string[] }> { + const base = serviceBaseUrl(widget); + const response = await fetch(`${base}/api/v1/projects`, { + headers: { Authorization: `Bearer ${widget.apiToken}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + throw new Error(`Coolify API returned ${response.status}`); + } + const projects = (await response.json()) as CoolifyProject[]; + const names = projects.slice(0, DETAIL_LIMIT).map((p) => p.name); + if (projects.length > DETAIL_LIMIT) names.push(`+${projects.length - DETAIL_LIMIT} more`); + + return { + stats: [{ label: "Projects", value: String(projects.length) }], + detail: names.length > 0 ? names : undefined, + }; +} diff --git a/lib/collectors/services/gitea.ts b/lib/collectors/services/gitea.ts new file mode 100644 index 0000000..4517255 --- /dev/null +++ b/lib/collectors/services/gitea.ts @@ -0,0 +1,16 @@ +import type { GiteaServiceWidget } from "@/lib/config/schema"; +import type { ServiceStat } from "@/lib/types/service-result"; +import { serviceBaseUrl } from "./base-url"; + +export async function collectGitea(widget: GiteaServiceWidget): Promise<{ stats: ServiceStat[] }> { + const base = serviceBaseUrl(widget); + const response = await fetch(`${base}/api/v1/users/${widget.username}/repos?limit=1`, { + headers: { Authorization: `token ${widget.apiToken}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + throw new Error(`Gitea API returned ${response.status}`); + } + const count = response.headers.get("X-Total-Count") ?? "0"; + return { stats: [{ label: "Repos", value: count }] }; +} diff --git a/lib/collectors/services/headscale.ts b/lib/collectors/services/headscale.ts new file mode 100644 index 0000000..46e3699 --- /dev/null +++ b/lib/collectors/services/headscale.ts @@ -0,0 +1,30 @@ +import type { HeadscaleServiceWidget } from "@/lib/config/schema"; +import type { ServiceStat } from "@/lib/types/service-result"; +import { serviceBaseUrl } from "./base-url"; + +async function fetchList(url: string, apiToken: string, label: string, key: "users" | "nodes"): Promise { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${apiToken}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + throw new Error(`Headscale ${label} API returned ${response.status}`); + } + const body = (await response.json()) as Record; + return body[key] ?? []; +} + +export async function collectHeadscale(widget: HeadscaleServiceWidget): Promise<{ stats: ServiceStat[] }> { + const base = serviceBaseUrl(widget); + const [users, nodes] = await Promise.all([ + fetchList(`${base}/api/v1/user`, widget.apiToken, "user", "users"), + fetchList(`${base}/api/v1/node`, widget.apiToken, "node", "nodes"), + ]); + + return { + stats: [ + { label: "Users", value: String(users.length) }, + { label: "Nodes", value: String(nodes.length) }, + ], + }; +} diff --git a/lib/collectors/services/immich.ts b/lib/collectors/services/immich.ts new file mode 100644 index 0000000..33cdb01 --- /dev/null +++ b/lib/collectors/services/immich.ts @@ -0,0 +1,26 @@ +import type { ImmichServiceWidget } from "@/lib/config/schema"; +import type { ServiceStat } from "@/lib/types/service-result"; +import { serviceBaseUrl } from "./base-url"; + +interface ImmichStatistics { + photos: number; + videos: number; +} + +export async function collectImmich(widget: ImmichServiceWidget): Promise<{ stats: ServiceStat[] }> { + const base = serviceBaseUrl(widget); + const response = await fetch(`${base}/api/server/statistics`, { + headers: { "x-api-key": widget.apiKey }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + throw new Error(`Immich API returned ${response.status}`); + } + const stats = (await response.json()) as ImmichStatistics; + return { + stats: [ + { label: "Photos", value: String(stats.photos) }, + { label: "Videos", value: String(stats.videos) }, + ], + }; +} diff --git a/lib/collectors/services/n8n.ts b/lib/collectors/services/n8n.ts new file mode 100644 index 0000000..6175d1a --- /dev/null +++ b/lib/collectors/services/n8n.ts @@ -0,0 +1,20 @@ +import type { N8nServiceWidget } from "@/lib/config/schema"; +import type { ServiceStat } from "@/lib/types/service-result"; +import { serviceBaseUrl } from "./base-url"; + +interface N8nWorkflowsPage { + data: unknown[]; +} + +export async function collectN8n(widget: N8nServiceWidget): Promise<{ stats: ServiceStat[] }> { + const base = serviceBaseUrl(widget); + const response = await fetch(`${base}/api/v1/workflows?limit=250`, { + headers: { "X-N8N-API-KEY": widget.apiKey }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + throw new Error(`n8n API returned ${response.status}`); + } + const page = (await response.json()) as N8nWorkflowsPage; + return { stats: [{ label: "Workflows", value: String(page.data.length) }] }; +} diff --git a/lib/collectors/services/umami.ts b/lib/collectors/services/umami.ts new file mode 100644 index 0000000..838d2f0 --- /dev/null +++ b/lib/collectors/services/umami.ts @@ -0,0 +1,48 @@ +import type { UmamiServiceWidget } from "@/lib/config/schema"; +import type { ServiceStat } from "@/lib/types/service-result"; +import { serviceBaseUrl } from "./base-url"; + +// Self-hosted Umami has no static API-key header (that's Cloud-only) - it needs +// a username/password login exchanged for a Bearer token. Cache the token per +// container and only re-login when a request comes back 401, since the docs +// don't document a fixed token lifetime to pre-emptively refresh against. +const tokenCache = new Map(); + +async function login(widget: UmamiServiceWidget, base: string): Promise { + const response = await fetch(`${base}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: widget.username, password: widget.password }), + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) { + throw new Error(`Umami login returned ${response.status}`); + } + const body = (await response.json()) as { token: string }; + tokenCache.set(widget.containerName, body.token); + return body.token; +} + +export async function collectUmami(widget: UmamiServiceWidget): Promise<{ stats: ServiceStat[] }> { + const base = serviceBaseUrl(widget); + let token = tokenCache.get(widget.containerName) ?? (await login(widget, base)); + + let response = await fetch(`${base}/api/websites/${widget.websiteId}/active`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + + if (response.status === 401) { + token = await login(widget, base); + response = await fetch(`${base}/api/websites/${widget.websiteId}/active`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + } + + if (!response.ok) { + throw new Error(`Umami API returned ${response.status}`); + } + const body = (await response.json()) as { visitors: number }; + return { stats: [{ label: "Active", value: String(body.visitors) }] }; +} diff --git a/lib/config/public.ts b/lib/config/public.ts new file mode 100644 index 0000000..452f003 --- /dev/null +++ b/lib/config/public.ts @@ -0,0 +1,19 @@ +import type { Config, Widget } from "./schema"; + +const SECRET_WIDGET_FIELDS = ["apiToken", "apiKey", "password"] as const; + +export function toPublicConfig(config: Config): Config { + return { + ...config, + groups: config.groups.map((group) => ({ + ...group, + widgets: group.widgets.map(redactWidget), + })), + }; +} + +function redactWidget(widget: Widget): Widget { + const clone: Record = { ...widget }; + for (const key of SECRET_WIDGET_FIELDS) delete clone[key]; + return clone as Widget; +} diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 5d506a4..abb5f47 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -65,6 +65,64 @@ export const traefikWidgetSchema = z.object({ 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, @@ -73,6 +131,7 @@ export const widgetSchema = z.discriminatedUnion("type", [ systemWidgetSchema, httpWidgetSchema, traefikWidgetSchema, + serviceWidgetSchema, ]); export const groupSchema = z.object({ @@ -126,3 +185,10 @@ export type Config = z.infer; export type Widget = z.infer; export type Group = z.infer; export type SearchEngine = z.infer; +export type ServiceWidget = z.infer; +export type GiteaServiceWidget = z.infer; +export type CoolifyServiceWidget = z.infer; +export type ImmichServiceWidget = z.infer; +export type N8nServiceWidget = z.infer; +export type UmamiServiceWidget = z.infer; +export type HeadscaleServiceWidget = z.infer; diff --git a/lib/types/service-result.ts b/lib/types/service-result.ts new file mode 100644 index 0000000..1a48aca --- /dev/null +++ b/lib/types/service-result.ts @@ -0,0 +1,13 @@ +import type { DockerContainerResult } from "./widget-result"; + +export interface ServiceStat { + label: string; + value: string; +} + +export interface ServiceWidgetResult { + docker: DockerContainerResult; + stats: ServiceStat[]; + detail?: string[]; + statError?: string; +} diff --git a/lib/types/widget-result.ts b/lib/types/widget-result.ts index dbcd09c..aa85bbd 100644 --- a/lib/types/widget-result.ts +++ b/lib/types/widget-result.ts @@ -1,6 +1,7 @@ import type { SystemResult } from "./system-result"; import type { HttpCheckResult } from "./http-result"; import type { TraefikResult } from "./traefik-result"; +import type { ServiceWidgetResult } from "./service-result"; export interface DockerContainerResult { status: "running" | "exited" | "restarting" | "paused" | "dead" | "created" | "unknown"; @@ -20,4 +21,5 @@ export type WidgetResult = | { type: "system"; data: SystemResult } | { type: "http"; data: HttpCheckResult } | { type: "traefik"; data: TraefikResult } + | { type: "service"; data: ServiceWidgetResult } | { type: "error"; message: string }; diff --git a/lib/ws/server.ts b/lib/ws/server.ts index 2f5ae8f..e9098b6 100644 --- a/lib/ws/server.ts +++ b/lib/ws/server.ts @@ -3,6 +3,7 @@ import { WebSocket, WebSocketServer } from "ws"; import { collectorScheduler } from "@/lib/collectors/scheduler"; import { configStore, type ConfigError } from "@/lib/config/loader"; import { effectiveConfigStore } from "@/lib/config/effective"; +import { toPublicConfig } from "@/lib/config/public"; import type { Config } from "@/lib/config/schema"; import type { WidgetResult } from "@/lib/types/widget-result"; @@ -49,7 +50,7 @@ export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer { try { const config = effectiveConfigStore.get(); - send(socket, { topic: "config", type: "config:update", ts: Date.now(), data: config }); + send(socket, { topic: "config", type: "config:update", ts: Date.now(), data: toPublicConfig(config) }); } catch { // no valid config loaded yet; client keeps its SSR-seeded config until one arrives } @@ -80,7 +81,12 @@ export function attachWebSocketServer(httpServer: HttpServer): WebSocketServer { }); effectiveConfigStore.onUpdate((config) => { - const envelope: Envelope = { topic: "config", type: "config:update", ts: Date.now(), data: config }; + const envelope: Envelope = { + topic: "config", + type: "config:update", + ts: Date.now(), + data: toPublicConfig(config), + }; for (const socket of allSockets) send(socket, envelope); });