style(services): apply formatter to frontend services runtime

This commit is contained in:
Developer
2026-06-22 19:13:59 +00:00
parent 1da67f38c7
commit 739ad38e29
13 changed files with 177 additions and 61 deletions
+8 -2
View File
@@ -452,7 +452,10 @@ function AppInner() {
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} /> <Route path="/addons/:addonId" element={<AddonPage />} />
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} /> <Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -485,7 +488,10 @@ function AppInner() {
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} /> <Route path="/addons/:addonId" element={<AddonPage />} />
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} /> <Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+3 -1
View File
@@ -15,7 +15,9 @@ export async function fetchServiceTypes(): Promise<ServiceTypeInfo[]> {
export async function fetchServiceInstances( export async function fetchServiceInstances(
serviceType?: string, serviceType?: string,
): Promise<ServiceInstance[]> { ): Promise<ServiceInstance[]> {
const query = serviceType ? `?service_type=${encodeURIComponent(serviceType)}` : ""; const query = serviceType
? `?service_type=${encodeURIComponent(serviceType)}`
: "";
const res = await fetch(`${API_BASE}/services/instances${query}`); const res = await fetch(`${API_BASE}/services/instances${query}`);
if (!res.ok) throw new Error("Failed to fetch service instances"); if (!res.ok) throw new Error("Failed to fetch service instances");
return res.json(); return res.json();
+3 -1
View File
@@ -7,7 +7,9 @@ import type {
const API_BASE = "/api"; const API_BASE = "/api";
export async function fetchBuiltinWidgetKinds(): Promise<BuiltinWidgetKindInfo[]> { export async function fetchBuiltinWidgetKinds(): Promise<
BuiltinWidgetKindInfo[]
> {
const res = await fetch(`${API_BASE}/widgets/builtin`); const res = await fetch(`${API_BASE}/widgets/builtin`);
if (!res.ok) throw new Error("Failed to fetch built-in widget kinds"); if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
return res.json(); return res.json();
+99 -35
View File
@@ -71,7 +71,8 @@ function Field({
} }
function bindingLabel(serviceId: string | null, widgetKind: string): string { function bindingLabel(serviceId: string | null, widgetKind: string): string {
if (serviceId === null) return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind; if (serviceId === null)
return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
return widgetKind; return widgetKind;
} }
@@ -116,8 +117,11 @@ function WidgetConfigEditor({
const properties = binding const properties = binding
? Object.entries( ? Object.entries(
(binding.configSchema as { properties?: Record<string, unknown> } | undefined) (
?.properties ?? {}, binding.configSchema as
| { properties?: Record<string, unknown> }
| undefined
)?.properties ?? {},
) )
: []; : [];
@@ -188,8 +192,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
} }
function startAddService(serviceId: string, kind: string) { function startAddService(serviceId: string, kind: string) {
const binding = SERVICE_REGISTRY[services.find((s) => s.id === serviceId)?.service_type ?? ""] const binding = SERVICE_REGISTRY[
?.widgets.find((w) => w.kind === kind); services.find((s) => s.id === serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === kind);
setDraft({ setDraft({
serviceId, serviceId,
widgetKind: kind, widgetKind: kind,
@@ -267,19 +272,27 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
const draftBinding = draft const draftBinding = draft
? draft.serviceId ? draft.serviceId
? SERVICE_REGISTRY[services.find((s) => s.id === draft.serviceId)?.service_type ?? ""] ? SERVICE_REGISTRY[
?.widgets.find((w) => w.kind === draft.widgetKind) services.find((s) => s.id === draft.serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === draft.widgetKind)
: BUILTIN_WIDGETS[draft.widgetKind] : BUILTIN_WIDGETS[draft.widgetKind]
: undefined; : undefined;
const isTaskOutput = const isTaskOutput =
draft?.serviceId !== null && draft?.serviceId !== null &&
services.find((s) => s.id === draft?.serviceId)?.service_type === "ssh_tasks"; services.find((s) => s.id === draft?.serviceId)?.service_type ===
"ssh_tasks";
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{draft ? (draft.id ? "Edit widget" : "Add widget") : "Dashboard widgets"}</DialogTitle> <DialogTitle>
{draft
? draft.id
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
</DialogHeader> </DialogHeader>
{draft ? ( {draft ? (
@@ -289,7 +302,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<Input <Input
id="widget-title" id="widget-title"
value={draft.title} value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })} onChange={(e) =>
setDraft({ ...draft, title: e.target.value })
}
/> />
</Field> </Field>
<Field label="Sort order" htmlFor="widget-sort-order"> <Field label="Sort order" htmlFor="widget-sort-order">
@@ -300,7 +315,8 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
onChange={(e) => onChange={(e) =>
setDraft({ setDraft({
...draft, ...draft,
sortOrder: e.target.value === "" ? 0 : Number(e.target.value), sortOrder:
e.target.value === "" ? 0 : Number(e.target.value),
}) })
} }
/> />
@@ -310,7 +326,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<Switch <Switch
id="widget-enabled" id="widget-enabled"
checked={draft.enabled} checked={draft.enabled}
onCheckedChange={(checked) => setDraft({ ...draft, enabled: checked })} onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/> />
<Label htmlFor="widget-enabled">Enabled</Label> <Label htmlFor="widget-enabled">Enabled</Label>
</div> </div>
@@ -334,7 +352,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? ( {sortedInstances.length === 0 ? (
<Alert> <Alert>
<AlertDescription>No widgets yet. Add one below.</AlertDescription> <AlertDescription>
No widgets yet. Add one below.
</AlertDescription>
</Alert> </Alert>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -343,31 +363,67 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
? services.find((s) => s.id === instance.service_id)?.name ? services.find((s) => s.id === instance.service_id)?.name
: "Built-in"; : "Built-in";
return ( return (
<div key={instance.id} className="flex items-center gap-2 rounded border p-2"> <div
key={instance.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1"> <div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span> <span className="font-medium">{instance.title}</span>
<Badge variant="outline"> <Badge variant="outline">
{bindingLabel(instance.service_id, instance.widget_kind)} {bindingLabel(
instance.service_id,
instance.widget_kind,
)}
</Badge> </Badge>
{serviceName ? ( {serviceName ? (
<span className="text-xs text-muted-foreground">{serviceName}</span> <span className="text-xs text-muted-foreground">
{serviceName}
</span>
) : null}
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null} ) : null}
{!instance.enabled ? <Badge variant="secondary">disabled</Badge> : null}
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === 0} onClick={() => moveInstance(index, -1)}> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === 0}
onClick={() => moveInstance(index, -1)}
>
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === sortedInstances.length - 1} onClick={() => moveInstance(index, 1)}> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === sortedInstances.length - 1}
onClick={() => moveInstance(index, 1)}
>
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
<Switch checked={instance.enabled} onCheckedChange={() => toggleEnabled(instance)} aria-label={`Toggle ${instance.title}`} /> <Switch
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => startEdit(instance)}> checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => removeInstance(instance)}> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => removeInstance(instance)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -381,7 +437,12 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
<p className="text-sm font-medium">Add widget</p> <p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{Object.values(BUILTIN_WIDGETS).map((b) => ( {Object.values(BUILTIN_WIDGETS).map((b) => (
<Button key={b.kind} variant="outline" size="sm" onClick={() => startAddBuiltIn(b.kind)}> <Button
key={b.kind}
variant="outline"
size="sm"
onClick={() => startAddBuiltIn(b.kind)}
>
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
{b.name} {b.name}
</Button> </Button>
@@ -389,21 +450,24 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
{services {services
.filter((s) => s.enabled) .filter((s) => s.enabled)
.flatMap((s) => .flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => ( (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
<Button (w) => (
key={`${s.id}:${w.kind}`} <Button
variant="outline" key={`${s.id}:${w.kind}`}
size="sm" variant="outline"
onClick={() => startAddService(s.id, w.kind)} size="sm"
> onClick={() => startAddService(s.id, w.kind)}
<Plus className="mr-1 h-3 w-3" /> >
{w.name} · {s.name} <Plus className="mr-1 h-3 w-3" />
</Button> {w.name} · {s.name}
)), </Button>
),
),
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more widgets. Configure services on their service pages to unlock more
widgets.
</p> </p>
</div> </div>
</div> </div>
+3 -1
View File
@@ -20,7 +20,9 @@ describe("service registry", () => {
}); });
it("binds widget kinds per service", () => { it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual(["link"]); expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link",
]);
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
"task_output", "task_output",
]); ]);
+13 -4
View File
@@ -55,7 +55,10 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
defaultConfig: { dashboard_uid: "" }, defaultConfig: { dashboard_uid: "" },
configSchema: { configSchema: {
type: "object", type: "object",
properties: { dashboard_uid: { type: "string" }, panel_id: { type: "integer" } }, properties: {
dashboard_uid: { type: "string" },
panel_id: { type: "integer" },
},
required: ["dashboard_uid"], required: ["dashboard_uid"],
}, },
component: GrafanaLinkWidget, component: GrafanaLinkWidget,
@@ -151,11 +154,15 @@ export const BUILTIN_WIDGETS: Record<string, ServiceWidgetBinding> = {
}, },
}; };
export function getServiceBinding(serviceType: string): ServiceBinding | undefined { export function getServiceBinding(
serviceType: string,
): ServiceBinding | undefined {
return SERVICE_REGISTRY[serviceType]; return SERVICE_REGISTRY[serviceType];
} }
export function getBuiltinBinding(kind: string): ServiceWidgetBinding | undefined { export function getBuiltinBinding(
kind: string,
): ServiceWidgetBinding | undefined {
return BUILTIN_WIDGETS[kind]; return BUILTIN_WIDGETS[kind];
} }
@@ -199,6 +206,8 @@ export function resolveWidget(
} }
/** Merge backend type metadata (config_schema, secret_fields) onto bindings. */ /** Merge backend type metadata (config_schema, secret_fields) onto bindings. */
export function enrichServiceTypes(types: ServiceTypeInfo[]): ServiceTypeInfo[] { export function enrichServiceTypes(
types: ServiceTypeInfo[],
): ServiceTypeInfo[] {
return types; return types;
} }
+18 -11
View File
@@ -31,7 +31,9 @@ function Field({
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label> <Label htmlFor={htmlFor}>{label}</Label>
{children} {children}
{helper ? <p className="text-xs text-muted-foreground">{helper}</p> : null} {helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div> </div>
); );
} }
@@ -66,9 +68,7 @@ export function ServicePage() {
if (!binding) { if (!binding) {
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>Unknown service type: {serviceType}</AlertDescription>
Unknown service type: {serviceType}
</AlertDescription>
</Alert> </Alert>
); );
} }
@@ -127,10 +127,7 @@ export function ServicePage() {
<Button onClick={save} disabled={saveService.isPending}> <Button onClick={save} disabled={saveService.isPending}>
Save Save
</Button> </Button>
<Button <Button variant="destructive" onClick={() => setDeleteOpen(true)}>
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -140,7 +137,10 @@ export function ServicePage() {
<ServiceSecretsCard instance={instance} /> <ServiceSecretsCard instance={instance} />
{binding.widgets.length > 0 ? ( {binding.widgets.length > 0 ? (
<SectionCard title="Widgets" description="Widget kinds this service provides."> <SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{binding.widgets.map((w) => ( {binding.widgets.map((w) => (
<div <div
@@ -208,14 +208,21 @@ function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{Object.entries(instance.secrets_set).map(([key, isSet]) => ( {Object.entries(instance.secrets_set).map(([key, isSet]) => (
<div key={key} className="flex flex-col gap-1.5"> <div key={key} className="flex flex-col gap-1.5">
<Field label={key} htmlFor={`secret-${key}`} helper="Leave blank to keep the current value."> <Field
label={key}
htmlFor={`secret-${key}`}
helper="Leave blank to keep the current value."
>
<Input <Input
id={`secret-${key}`} id={`secret-${key}`}
type="password" type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"} placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""} value={draftSecrets[key] ?? ""}
onChange={(e) => onChange={(e) =>
setDraftSecrets({ ...draftSecrets, [key]: e.target.value }) setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
} }
/> />
</Field> </Field>
+5 -1
View File
@@ -12,7 +12,11 @@ interface Props {
description?: string; description?: string;
} }
export function BackupsWidget({ widget, refreshIntervalMs, description }: Props) { export function BackupsWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const summary = data?.data as BackupDashboardSummary | undefined; const summary = data?.data as BackupDashboardSummary | undefined;
+5 -1
View File
@@ -12,7 +12,11 @@ interface Props {
description?: string; description?: string;
} }
export function GrafanaLinkWidget({ widget, refreshIntervalMs, description }: Props) { export function GrafanaLinkWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const url = data?.data?.url as string | undefined; const url = data?.data?.url as string | undefined;
+5 -1
View File
@@ -11,7 +11,11 @@ interface Props {
description?: string; description?: string;
} }
export function JellyfinWidget({ widget, refreshIntervalMs, description }: Props) { export function JellyfinWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined; const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
@@ -36,7 +36,11 @@ function formatPrometheusValue(result: PromQLResult | undefined): string {
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
} }
export function PrometheusMetricWidget({ widget, refreshIntervalMs, description }: Props) { export function PrometheusMetricWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data?.result as PromQLResult | undefined; const result = data?.data?.result as PromQLResult | undefined;
+5 -1
View File
@@ -16,7 +16,11 @@ type SshTaskResult = {
stderr: string; stderr: string;
}; };
export function SshTaskWidget({ widget, refreshIntervalMs, description }: Props) { export function SshTaskWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const result = data?.data as SshTaskResult | undefined; const result = data?.data as SshTaskResult | undefined;
+5 -1
View File
@@ -8,7 +8,11 @@ interface Props {
description?: string; description?: string;
} }
export function StaticWidget({ widget, refreshIntervalMs, description }: Props) { export function StaticWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data } = useWidgetData(widget.id, refreshIntervalMs); const { data } = useWidgetData(widget.id, refreshIntervalMs);
const text = data?.data?.text as string | undefined; const text = data?.data?.text as string | undefined;