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).
This commit is contained in:
@@ -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<Widget, { type: "service" }>;
|
||||
|
||||
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 (
|
||||
<WidgetCard span={4}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BrandIcon slug={widget.icon} />
|
||||
{widget.href ? (
|
||||
<a
|
||||
href={widget.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate text-[15px] font-medium text-fg hover:text-accent"
|
||||
>
|
||||
{widget.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate text-[15px] font-medium text-fg">{widget.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusDot status={data?.docker.status} health={data?.docker.health} />
|
||||
</div>
|
||||
{errorMessage && <span className="text-xs text-status-down">{errorMessage}</span>}
|
||||
{!result && !errorMessage && <SkeletonLines count={3} />}
|
||||
{data && (
|
||||
<>
|
||||
{data.stats.length > 0 && (
|
||||
<div className="flex items-start gap-4">
|
||||
{data.stats.map((stat, index) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={`flex flex-col gap-0.5 ${index > 0 ? "border-l border-border pl-4" : ""}`}
|
||||
>
|
||||
<span className="font-mono text-2xl leading-none font-semibold tabular-nums text-accent">
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="text-[10px] tracking-wide text-fg-muted uppercase">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.detail && data.detail.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.detail.map((entry) => (
|
||||
<StatusTag key={entry} tone="neutral">
|
||||
{entry}
|
||||
</StatusTag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.statError && <StatusTag tone="degraded">{data.statError}</StatusTag>}
|
||||
{widget.showStats && data.docker.cpuPercent !== null && memPercent !== null && (
|
||||
<div className="mt-0.5 flex gap-4">
|
||||
<MetricBar label="CPU" percent={data.docker.cpuPercent} detail={`${data.docker.cpuPercent.toFixed(1)}%`} />
|
||||
<MetricBar label="MEM" percent={memPercent} detail={`${memPercent.toFixed(0)}%`} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[11px] text-fg-muted">
|
||||
{data.docker.uptimeSeconds !== null && <span>up {formatUptime(data.docker.uptimeSeconds)}</span>}
|
||||
{widget.showStats && data.docker.memUsageBytes !== null && memPercent === null && (
|
||||
<span className="font-mono tabular-nums">{formatBytes(data.docker.memUsageBytes)}</span>
|
||||
)}
|
||||
{data.docker.restartCount > 0 && <span>{data.docker.restartCount} restarts</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user