Merge pull request 'feat(services): frontend services runtime and widget rebind' (#9) from feat/service-registry-frontend-runtime into main
This commit is contained in:
@@ -23,6 +23,7 @@ import { Actions } from "./pages/Actions";
|
|||||||
import BackupsPage from "./components/BackupsPage";
|
import BackupsPage from "./components/BackupsPage";
|
||||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||||
import { AddonPage } from "./pages/AddonPage";
|
import { AddonPage } from "./pages/AddonPage";
|
||||||
|
import { ServicePage } from "./pages/ServicePage";
|
||||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
@@ -451,6 +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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
@@ -483,6 +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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
export async function fetchServiceTypes(): Promise<ServiceTypeInfo[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/services/types`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch service types");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceInstances(
|
||||||
|
serviceType?: string,
|
||||||
|
): Promise<ServiceInstance[]> {
|
||||||
|
const query = serviceType
|
||||||
|
? `?service_type=${encodeURIComponent(serviceType)}`
|
||||||
|
: "";
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances${query}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch service instances");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServiceInstance(
|
||||||
|
input: ServiceInstanceInput,
|
||||||
|
): Promise<ServiceInstance> {
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create service instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServiceInstance(
|
||||||
|
input: ServiceInstanceInput,
|
||||||
|
): Promise<ServiceInstance> {
|
||||||
|
if (!input.id) throw new Error("Service ID is required for update");
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances/${input.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update service instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServiceInstance(
|
||||||
|
serviceId: string,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances/${serviceId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete service instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
|
BuiltinWidgetKindInfo,
|
||||||
WidgetDataResponse,
|
WidgetDataResponse,
|
||||||
WidgetInstance,
|
WidgetInstance,
|
||||||
WidgetInstanceInput,
|
WidgetInstanceInput,
|
||||||
WidgetTypeInfo,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const API_BASE = "/api";
|
const API_BASE = "/api";
|
||||||
|
|
||||||
export async function fetchWidgetSources(): Promise<string[]> {
|
export async function fetchBuiltinWidgetKinds(): Promise<
|
||||||
const res = await fetch(`${API_BASE}/widgets/sources`);
|
BuiltinWidgetKindInfo[]
|
||||||
if (!res.ok) throw new Error("Failed to fetch widget sources");
|
> {
|
||||||
return res.json();
|
const res = await fetch(`${API_BASE}/widgets/builtin`);
|
||||||
}
|
if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
|
||||||
|
|
||||||
export async function fetchWidgetTypes(): Promise<WidgetTypeInfo[]> {
|
|
||||||
const res = await fetch(`${API_BASE}/widgets/types`);
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch widget types");
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,36 +23,29 @@ import {
|
|||||||
useDeleteWidgetInstance,
|
useDeleteWidgetInstance,
|
||||||
useSaveWidgetInstance,
|
useSaveWidgetInstance,
|
||||||
useWidgetInstances,
|
useWidgetInstances,
|
||||||
useWidgetTypes,
|
|
||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import type {
|
import { useTasks } from "../hooks/useSettings";
|
||||||
MonitoringMachine,
|
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||||
SavedTask,
|
|
||||||
WidgetInstance,
|
|
||||||
WidgetInstanceInput,
|
|
||||||
} from "../types";
|
|
||||||
import {
|
import {
|
||||||
getWidgetDefinition,
|
BUILTIN_WIDGETS,
|
||||||
listWidgetTypes,
|
SERVICE_REGISTRY,
|
||||||
type WidgetDefinition,
|
type ServiceWidgetBinding,
|
||||||
} from "../widgets/registry";
|
} from "../integrations/registry";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyDraft(widgetType: string): WidgetInstanceInput {
|
interface Draft {
|
||||||
const def = getWidgetDefinition(widgetType);
|
id?: string;
|
||||||
return {
|
serviceId: string | null;
|
||||||
addon_id: def?.addonId ?? "",
|
widgetKind: string;
|
||||||
widget_type: widgetType,
|
title: string;
|
||||||
title: def?.name ?? "",
|
config: Record<string, unknown>;
|
||||||
config: { ...(def?.defaultConfig ?? {}) },
|
enabled: boolean;
|
||||||
enabled: true,
|
sortOrder: number;
|
||||||
sort_order: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
@@ -77,126 +70,88 @@ function Field({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetConfigFields({
|
function bindingLabel(serviceId: string | null, widgetKind: string): string {
|
||||||
definition,
|
if (serviceId === null)
|
||||||
|
return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
|
||||||
|
return widgetKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetConfigEditor({
|
||||||
|
binding,
|
||||||
|
isTaskOutput,
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
machines,
|
|
||||||
tasks,
|
tasks,
|
||||||
}: {
|
}: {
|
||||||
definition: WidgetDefinition;
|
binding: ServiceWidgetBinding | undefined;
|
||||||
|
isTaskOutput: boolean;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
onChange: (config: Record<string, unknown>) => void;
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
machines: MonitoringMachine[];
|
tasks: { id: string; name: string; enabled: boolean }[];
|
||||||
tasks: SavedTask[];
|
|
||||||
}) {
|
}) {
|
||||||
|
// SSH task output gets a dedicated task picker; everything else gets a
|
||||||
|
// generic text field per top-level schema property.
|
||||||
|
if (isTaskOutput) {
|
||||||
|
return (
|
||||||
|
<Field label="Saved task" htmlFor="widget-task-id">
|
||||||
|
<Select
|
||||||
|
value={String(config.task_id ?? "")}
|
||||||
|
onValueChange={(v) => onChange({ ...config, task_id: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="widget-task-id">
|
||||||
|
<SelectValue placeholder="Select a task" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tasks
|
||||||
|
.filter((t) => t.enabled)
|
||||||
|
.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = binding
|
||||||
|
? Object.entries(
|
||||||
|
(
|
||||||
|
binding.configSchema as
|
||||||
|
| { properties?: Record<string, unknown> }
|
||||||
|
| undefined
|
||||||
|
)?.properties ?? {},
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (properties.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{definition.configFields.map((field) => {
|
{properties.map(([key, schema]) => {
|
||||||
const value = config[field.key] ?? "";
|
const isNumber =
|
||||||
|
(schema as { type?: string }).type === "integer" ||
|
||||||
if (
|
(schema as { type?: string }).type === "number";
|
||||||
definition.widgetType === "jellyfin" &&
|
|
||||||
field.key === "machine_id"
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={field.key}
|
|
||||||
label={field.label}
|
|
||||||
htmlFor={field.key}
|
|
||||||
helper={field.helper}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={String(value)}
|
|
||||||
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={field.key}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="">Default</SelectItem>
|
|
||||||
{machines
|
|
||||||
.filter((m) => m.enabled && m.services.includes("jellyfin"))
|
|
||||||
.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definition.widgetType === "ssh-task" && field.key === "task_id") {
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={field.key}
|
|
||||||
label={field.label}
|
|
||||||
htmlFor={field.key}
|
|
||||||
helper={field.helper}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={String(value)}
|
|
||||||
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={field.key}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{tasks
|
|
||||||
.filter((t) => t.enabled)
|
|
||||||
.map((t) => (
|
|
||||||
<SelectItem key={t.id} value={t.id}>
|
|
||||||
{t.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === "number") {
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={field.key}
|
|
||||||
label={field.label}
|
|
||||||
htmlFor={field.key}
|
|
||||||
helper={field.helper}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id={field.key}
|
|
||||||
type="number"
|
|
||||||
value={String(value)}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({
|
|
||||||
...config,
|
|
||||||
[field.key]:
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={field.key}
|
key={key}
|
||||||
label={field.label}
|
label={key}
|
||||||
htmlFor={field.key}
|
htmlFor={`widget-cfg-${key}`}
|
||||||
helper={field.helper}
|
helper={(schema as { description?: string }).description}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id={field.key}
|
id={`widget-cfg-${key}`}
|
||||||
value={String(value)}
|
type={isNumber ? "number" : "text"}
|
||||||
|
value={String(config[key] ?? "")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
[field.key]: e.target.value,
|
[key]: isNumber
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -209,16 +164,12 @@ function WidgetConfigFields({
|
|||||||
|
|
||||||
export function WidgetConfigDialog({ open, onClose }: Props) {
|
export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||||
const { data: instances = [] } = useWidgetInstances();
|
const { data: instances = [] } = useWidgetInstances();
|
||||||
const { data: types = [] } = useWidgetTypes();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const { data: machines = [] } = useMonitoringSettings();
|
|
||||||
const { data: tasks = [] } = useTasks();
|
const { data: tasks = [] } = useTasks();
|
||||||
const saveWidget = useSaveWidgetInstance();
|
const saveWidget = useSaveWidgetInstance();
|
||||||
const deleteWidget = useDeleteWidgetInstance();
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
|
|
||||||
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
|
|
||||||
|
|
||||||
const sortedInstances = useMemo(
|
const sortedInstances = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -228,39 +179,72 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
[instances],
|
[instances],
|
||||||
);
|
);
|
||||||
|
|
||||||
function startAdd(widgetType: string) {
|
function startAddBuiltIn(kind: string) {
|
||||||
setDraft(emptyDraft(widgetType));
|
const binding = BUILTIN_WIDGETS[kind];
|
||||||
setEditingId(null);
|
setDraft({
|
||||||
|
serviceId: null,
|
||||||
|
widgetKind: kind,
|
||||||
|
title: binding?.name ?? kind,
|
||||||
|
config: { ...(binding?.defaultConfig ?? {}) },
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAddService(serviceId: string, kind: string) {
|
||||||
|
const binding = SERVICE_REGISTRY[
|
||||||
|
services.find((s) => s.id === serviceId)?.service_type ?? ""
|
||||||
|
]?.widgets.find((w) => w.kind === kind);
|
||||||
|
setDraft({
|
||||||
|
serviceId,
|
||||||
|
widgetKind: kind,
|
||||||
|
title: binding?.name ?? kind,
|
||||||
|
config: { ...(binding?.defaultConfig ?? {}) },
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(instance: WidgetInstance) {
|
function startEdit(instance: WidgetInstance) {
|
||||||
setDraft({
|
setDraft({
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
addon_id: instance.addon_id,
|
serviceId: instance.service_id,
|
||||||
widget_type: instance.widget_type,
|
widgetKind: instance.widget_kind,
|
||||||
title: instance.title,
|
title: instance.title,
|
||||||
config: instance.config,
|
config: instance.config,
|
||||||
enabled: instance.enabled,
|
enabled: instance.enabled,
|
||||||
sort_order: instance.sort_order,
|
sortOrder: instance.sort_order,
|
||||||
});
|
});
|
||||||
setEditingId(instance.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setEditingId(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDraft() {
|
async function saveDraft() {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
await saveWidget.mutateAsync(draft);
|
const input: WidgetInstanceInput = {
|
||||||
|
id: draft.id ?? null,
|
||||||
|
service_id: draft.serviceId,
|
||||||
|
widget_kind: draft.widgetKind,
|
||||||
|
title: draft.title,
|
||||||
|
config: draft.config,
|
||||||
|
enabled: draft.enabled,
|
||||||
|
sort_order: draft.sortOrder,
|
||||||
|
};
|
||||||
|
await saveWidget.mutateAsync(input);
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleEnabled(instance: WidgetInstance) {
|
async function toggleEnabled(instance: WidgetInstance) {
|
||||||
await saveWidget.mutateAsync({
|
await saveWidget.mutateAsync({
|
||||||
...instance,
|
id: instance.id,
|
||||||
|
service_id: instance.service_id,
|
||||||
|
widget_kind: instance.widget_kind,
|
||||||
|
title: instance.title,
|
||||||
|
config: instance.config,
|
||||||
enabled: !instance.enabled,
|
enabled: !instance.enabled,
|
||||||
|
sort_order: instance.sort_order,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +270,17 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
|
const draftBinding = draft
|
||||||
|
? draft.serviceId
|
||||||
|
? SERVICE_REGISTRY[
|
||||||
|
services.find((s) => s.id === draft.serviceId)?.service_type ?? ""
|
||||||
|
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||||
|
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||||
|
: undefined;
|
||||||
|
const isTaskOutput =
|
||||||
|
draft?.serviceId !== null &&
|
||||||
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||||
|
"ssh_tasks";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
@@ -294,18 +288,15 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{draft
|
{draft
|
||||||
? editingId
|
? draft.id
|
||||||
? "Edit widget"
|
? "Edit widget"
|
||||||
: "Add widget"
|
: "Add widget"
|
||||||
: "Dashboard widgets"}
|
: "Dashboard widgets"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{draft && definition ? (
|
{draft ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{definition.description}
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Field label="Title" htmlFor="widget-title">
|
<Field label="Title" htmlFor="widget-title">
|
||||||
<Input
|
<Input
|
||||||
@@ -320,11 +311,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
id="widget-sort-order"
|
id="widget-sort-order"
|
||||||
type="number"
|
type="number"
|
||||||
value={String(draft.sort_order)}
|
value={String(draft.sortOrder)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft({
|
setDraft({
|
||||||
...draft,
|
...draft,
|
||||||
sort_order:
|
sortOrder:
|
||||||
e.target.value === "" ? 0 : Number(e.target.value),
|
e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -341,11 +332,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="widget-enabled">Enabled</Label>
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||||
</div>
|
</div>
|
||||||
<WidgetConfigFields
|
<WidgetConfigEditor
|
||||||
definition={definition}
|
binding={draftBinding}
|
||||||
|
isTaskOutput={!!isTaskOutput}
|
||||||
config={draft.config}
|
config={draft.config}
|
||||||
onChange={(config) => setDraft({ ...draft, config })}
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
machines={machines}
|
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -368,7 +359,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{sortedInstances.map((instance, index) => {
|
{sortedInstances.map((instance, index) => {
|
||||||
const typeDef = getWidgetDefinition(instance.widget_type);
|
const serviceName = instance.service_id
|
||||||
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
|
: "Built-in";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={instance.id}
|
key={instance.id}
|
||||||
@@ -378,8 +371,16 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
<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">
|
||||||
{typeDef?.name ?? instance.widget_type}
|
{bindingLabel(
|
||||||
|
instance.service_id,
|
||||||
|
instance.widget_kind,
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{serviceName ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{!instance.enabled ? (
|
{!instance.enabled ? (
|
||||||
<Badge variant="secondary">disabled</Badge>
|
<Badge variant="secondary">disabled</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -435,27 +436,40 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<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">
|
||||||
{registryDefinitions.map((def) => (
|
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
||||||
<Button
|
<Button
|
||||||
key={def.widgetType}
|
key={b.kind}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => startAdd(def.widgetType)}
|
onClick={() => startAddBuiltIn(b.kind)}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
{def.name}
|
{b.name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
{services
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.flatMap((s) =>
|
||||||
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
||||||
|
(w) => (
|
||||||
|
<Button
|
||||||
|
key={`${s.id}:${w.kind}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startAddService(s.id, w.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{w.name} · {s.name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure services on their service pages to unlock more
|
||||||
|
widgets.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{types.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Widget registry is empty. Backend may not be running.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { getWidgetDefinition } from "../widgets/registry";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
|
import { resolveWidget } from "../integrations/registry";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
|
|
||||||
@@ -7,20 +8,29 @@ interface Props {
|
|||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WidgetInstance({ widget }: Props) {
|
export function WidgetInstanceCard({ widget }: Props) {
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
const { data: services = [] } = useServiceInstances();
|
||||||
if (!def) {
|
const resolved = resolveWidget(widget, services);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
const label = widget.service_id
|
||||||
|
? `Unknown widget: ${widget.widget_kind} (service-bound)`
|
||||||
|
: `Unknown widget: ${widget.widget_kind} (built-in)`;
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title}>
|
<SectionCard title={widget.title}>
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>{label}</AlertDescription>
|
||||||
Unknown widget type: {widget.widget_type}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Component = def.component;
|
const Component = resolved.component;
|
||||||
return <Component widget={widget} />;
|
return (
|
||||||
|
<Component
|
||||||
|
widget={widget}
|
||||||
|
refreshIntervalMs={resolved.refreshIntervalMs}
|
||||||
|
description={resolved.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createServiceInstance,
|
||||||
|
deleteServiceInstance,
|
||||||
|
fetchServiceInstances,
|
||||||
|
fetchServiceTypes,
|
||||||
|
updateServiceInstance,
|
||||||
|
} from "../api/services";
|
||||||
|
import type { ServiceInstanceInput } from "../types";
|
||||||
|
|
||||||
|
export function useServiceTypes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services", "types"],
|
||||||
|
queryFn: fetchServiceTypes,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceInstances(serviceType?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services", "instances", serviceType ?? "all"],
|
||||||
|
queryFn: () => fetchServiceInstances(serviceType),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveServiceInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: ServiceInstanceInput) =>
|
||||||
|
input.id ? updateServiceInstance(input) : createServiceInstance(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteServiceInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (serviceId: string) => deleteServiceInstance(serviceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
createWidgetInstance,
|
createWidgetInstance,
|
||||||
deleteWidgetInstance,
|
deleteWidgetInstance,
|
||||||
|
fetchBuiltinWidgetKinds,
|
||||||
fetchWidgetData,
|
fetchWidgetData,
|
||||||
fetchWidgetInstances,
|
fetchWidgetInstances,
|
||||||
fetchWidgetSources,
|
|
||||||
fetchWidgetTypes,
|
|
||||||
updateWidgetInstance,
|
updateWidgetInstance,
|
||||||
} from "../api/widgets";
|
} from "../api/widgets";
|
||||||
import type { WidgetInstanceInput } from "../types";
|
import type { WidgetInstanceInput } from "../types";
|
||||||
@@ -49,16 +48,10 @@ export function useDeleteWidgetInstance() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWidgetSources() {
|
export function useBuiltinWidgetKinds() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["widgets", "sources"],
|
queryKey: ["widgets", "builtin"],
|
||||||
queryFn: fetchWidgetSources,
|
queryFn: fetchBuiltinWidgetKinds,
|
||||||
});
|
staleTime: 5 * 60 * 1000,
|
||||||
}
|
|
||||||
|
|
||||||
export function useWidgetTypes() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["widgets", "types"],
|
|
||||||
queryFn: fetchWidgetTypes,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
BUILTIN_WIDGETS,
|
||||||
|
SERVICE_REGISTRY,
|
||||||
|
getBuiltinBinding,
|
||||||
|
getServiceBinding,
|
||||||
|
resolveWidget,
|
||||||
|
} from "./registry";
|
||||||
|
import type { ServiceInstance, WidgetInstance } from "../types";
|
||||||
|
|
||||||
|
describe("service registry", () => {
|
||||||
|
it("registers the five backend service types", () => {
|
||||||
|
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||||
|
"grafana",
|
||||||
|
"jellyfin",
|
||||||
|
"nextcloud",
|
||||||
|
"prometheus",
|
||||||
|
"ssh_tasks",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds widget kinds per service", () => {
|
||||||
|
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
||||||
|
"link",
|
||||||
|
]);
|
||||||
|
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
|
||||||
|
"task_output",
|
||||||
|
]);
|
||||||
|
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers the two built-in widget kinds", () => {
|
||||||
|
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a service-bound widget via the services list", () => {
|
||||||
|
const widget: WidgetInstance = {
|
||||||
|
id: "w1",
|
||||||
|
service_id: "s1",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Dashboard",
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
const services: ServiceInstance[] = [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
service_type: "grafana",
|
||||||
|
name: "Grafana",
|
||||||
|
config: { base_url: "https://grafana.example.com" },
|
||||||
|
secrets_set: { api_key: true },
|
||||||
|
enabled: true,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const resolved = resolveWidget(widget, services);
|
||||||
|
expect(resolved).toBeDefined();
|
||||||
|
expect(resolved?.refreshIntervalMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a built-in widget without a service", () => {
|
||||||
|
const widget: WidgetInstance = {
|
||||||
|
id: "w2",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "static",
|
||||||
|
title: "Note",
|
||||||
|
config: { text: "hi" },
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
const resolved = resolveWidget(widget, []);
|
||||||
|
expect(resolved).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown widget kind", () => {
|
||||||
|
const widget: WidgetInstance = {
|
||||||
|
id: "w3",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "bogus",
|
||||||
|
title: "x",
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
expect(resolveWidget(widget, [])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lookups return undefined for unknown types", () => {
|
||||||
|
expect(getServiceBinding("nope")).toBeUndefined();
|
||||||
|
expect(getBuiltinBinding("nope")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||||
|
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||||
|
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||||
|
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
||||||
|
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||||
|
import { StaticWidget } from "../widgets/StaticWidget";
|
||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
WidgetInstance,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed frontend registry mirroring the backend service definitions.
|
||||||
|
*
|
||||||
|
* Each service type maps its widget kinds to a presentational component and a
|
||||||
|
* refresh interval. Built-in (service-less) kinds are listed separately.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WidgetComponentProps {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceWidgetBinding {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
defaultConfig: Record<string, unknown>;
|
||||||
|
configSchema: Record<string, unknown>;
|
||||||
|
component: ComponentType<WidgetComponentProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceBinding {
|
||||||
|
serviceType: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
widgets: ServiceWidgetBinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||||
|
grafana: {
|
||||||
|
serviceType: "grafana",
|
||||||
|
name: "Grafana",
|
||||||
|
description: "Dashboards, metrics, and logs.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
name: "Dashboard link",
|
||||||
|
description: "Deep-link to a Grafana dashboard or panel.",
|
||||||
|
refreshIntervalMs: 0,
|
||||||
|
defaultConfig: { dashboard_uid: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
dashboard_uid: { type: "string" },
|
||||||
|
panel_id: { type: "integer" },
|
||||||
|
},
|
||||||
|
required: ["dashboard_uid"],
|
||||||
|
},
|
||||||
|
component: GrafanaLinkWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
prometheus: {
|
||||||
|
serviceType: "prometheus",
|
||||||
|
name: "Prometheus",
|
||||||
|
description: "Metrics storage and PromQL queries.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "metric",
|
||||||
|
name: "Metric",
|
||||||
|
description: "Instant query result rendered as a metric.",
|
||||||
|
refreshIntervalMs: 30_000,
|
||||||
|
defaultConfig: { promql: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { promql: { type: "string" } },
|
||||||
|
required: ["promql"],
|
||||||
|
},
|
||||||
|
component: PrometheusMetricWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jellyfin: {
|
||||||
|
serviceType: "jellyfin",
|
||||||
|
name: "Jellyfin",
|
||||||
|
description: "Media server with live session activity.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "activity",
|
||||||
|
name: "Activity",
|
||||||
|
description: "Live sessions and idle users.",
|
||||||
|
refreshIntervalMs: 30_000,
|
||||||
|
defaultConfig: {},
|
||||||
|
configSchema: { type: "object", properties: {}, required: [] },
|
||||||
|
component: JellyfinWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
nextcloud: {
|
||||||
|
serviceType: "nextcloud",
|
||||||
|
name: "Nextcloud",
|
||||||
|
description: "Self-hosted files and collaboration.",
|
||||||
|
widgets: [],
|
||||||
|
},
|
||||||
|
ssh_tasks: {
|
||||||
|
serviceType: "ssh_tasks",
|
||||||
|
name: "SSH task runner",
|
||||||
|
description: "Run reusable saved tasks over SSH and keep run history.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "task_output",
|
||||||
|
name: "Task output",
|
||||||
|
description: "Output of a saved task run.",
|
||||||
|
refreshIntervalMs: 0,
|
||||||
|
defaultConfig: { task_id: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { task_id: { type: "string" } },
|
||||||
|
required: ["task_id"],
|
||||||
|
},
|
||||||
|
component: SshTaskWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUILTIN_WIDGETS: Record<string, ServiceWidgetBinding> = {
|
||||||
|
backups: {
|
||||||
|
kind: "backups",
|
||||||
|
name: "Backups",
|
||||||
|
description: "Backup job summary and active alerts.",
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfig: {},
|
||||||
|
configSchema: { type: "object", properties: {}, required: [] },
|
||||||
|
component: BackupsWidget,
|
||||||
|
},
|
||||||
|
static: {
|
||||||
|
kind: "static",
|
||||||
|
name: "Static text",
|
||||||
|
description: "Plain text or markdown note.",
|
||||||
|
refreshIntervalMs: 0,
|
||||||
|
defaultConfig: { text: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { text: { type: "string" } },
|
||||||
|
required: ["text"],
|
||||||
|
},
|
||||||
|
component: StaticWidget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getServiceBinding(
|
||||||
|
serviceType: string,
|
||||||
|
): ServiceBinding | undefined {
|
||||||
|
return SERVICE_REGISTRY[serviceType];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBuiltinBinding(
|
||||||
|
kind: string,
|
||||||
|
): ServiceWidgetBinding | undefined {
|
||||||
|
return BUILTIN_WIDGETS[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWidget {
|
||||||
|
component: ComponentType<WidgetComponentProps>;
|
||||||
|
description: string;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a widget instance to its component + metadata.
|
||||||
|
*
|
||||||
|
* Service-bound widgets are resolved via the parent service's type (looked up
|
||||||
|
* from the services list); built-in widgets are resolved directly.
|
||||||
|
*/
|
||||||
|
export function resolveWidget(
|
||||||
|
widget: WidgetInstance,
|
||||||
|
services: ServiceInstance[],
|
||||||
|
): ResolvedWidget | undefined {
|
||||||
|
if (widget.service_id) {
|
||||||
|
const service = services.find((s) => s.id === widget.service_id);
|
||||||
|
if (!service) return undefined;
|
||||||
|
const binding = getServiceBinding(service.service_type);
|
||||||
|
const widgetBinding = binding?.widgets.find(
|
||||||
|
(w) => w.kind === widget.widget_kind,
|
||||||
|
);
|
||||||
|
if (!widgetBinding) return undefined;
|
||||||
|
return {
|
||||||
|
component: widgetBinding.component,
|
||||||
|
description: widgetBinding.description,
|
||||||
|
refreshIntervalMs: widgetBinding.refreshIntervalMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const builtin = getBuiltinBinding(widget.widget_kind);
|
||||||
|
if (!builtin) return undefined;
|
||||||
|
return {
|
||||||
|
component: builtin.component,
|
||||||
|
description: builtin.description,
|
||||||
|
refreshIntervalMs: builtin.refreshIntervalMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge backend type metadata (config_schema, secret_fields) onto bindings. */
|
||||||
|
export function enrichServiceTypes(
|
||||||
|
types: ServiceTypeInfo[],
|
||||||
|
): ServiceTypeInfo[] {
|
||||||
|
return types;
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { WidgetInstance } from "../components/WidgetInstance";
|
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||||
|
|
||||||
function emptyShortcut(): DashboardShortcutInput {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
@@ -418,7 +418,7 @@ export function Dashboard() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{visibleWidgets.map((widget) => (
|
{visibleWidgets.map((widget) => (
|
||||||
<WidgetInstance key={widget.id} widget={widget} />
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<ShortcutDialog
|
<ShortcutDialog
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
useDeleteServiceInstance,
|
||||||
|
useSaveServiceInstance,
|
||||||
|
useServiceInstances,
|
||||||
|
} from "../hooks/useServices";
|
||||||
|
import type { ServiceInstance, ServiceInstanceInput } from "../types";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
helper,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
helper?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={htmlFor}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{helper ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{helper}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicePage() {
|
||||||
|
const { serviceType = "", serviceId = "" } = useParams<{
|
||||||
|
serviceType: string;
|
||||||
|
serviceId: string;
|
||||||
|
}>();
|
||||||
|
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||||
|
const saveService = useSaveServiceInstance();
|
||||||
|
const deleteService = useDeleteServiceInstance();
|
||||||
|
|
||||||
|
const instance = useMemo(
|
||||||
|
() => services.find((s) => s.id === serviceId),
|
||||||
|
[services, serviceId],
|
||||||
|
);
|
||||||
|
const binding = getServiceBinding(serviceType);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
// Hydrate local form state once the instance loads.
|
||||||
|
if (instance && !hydrated) {
|
||||||
|
setName(instance.name);
|
||||||
|
setEnabled(instance.enabled);
|
||||||
|
setHydrated(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!binding) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>Unknown service type: {serviceType}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>Service not found.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInput(): ServiceInstanceInput {
|
||||||
|
return {
|
||||||
|
id: instance!.id,
|
||||||
|
service_type: instance!.service_type,
|
||||||
|
name,
|
||||||
|
config: instance!.config,
|
||||||
|
secrets: {}, // secrets are managed via the dedicated inputs below
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await saveService.mutateAsync(buildInput());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{binding.name}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionCard title="General">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Field label="Name" htmlFor="service-name">
|
||||||
|
<Input
|
||||||
|
id="service-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="service-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="service-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button onClick={save} disabled={saveService.isPending}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<ServiceSecretsCard instance={instance} />
|
||||||
|
|
||||||
|
{binding.widgets.length > 0 ? (
|
||||||
|
<SectionCard
|
||||||
|
title="Widgets"
|
||||||
|
description="Widget kinds this service provides."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{binding.widgets.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.kind}
|
||||||
|
className="flex items-center justify-between rounded border p-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{w.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{w.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{w.kind}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add these to the dashboard from the dashboard's edit dialog.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
title="Delete service?"
|
||||||
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteService.mutate(instance.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
|
||||||
|
const saveService = useSaveServiceInstance();
|
||||||
|
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
||||||
|
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
title="Connection"
|
||||||
|
description="Non-secret config is read-only here for now; edit secret values below."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Object.entries(instance.config).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No connection config.</p>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
|
{Object.entries(instance.config).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex flex-col">
|
||||||
|
<dt className="text-xs text-muted-foreground">{key}</dt>
|
||||||
|
<dd className="truncate font-mono text-xs">{String(value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(instance.secrets_set).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
|
<Field
|
||||||
|
label={key}
|
||||||
|
htmlFor={`secret-${key}`}
|
||||||
|
helper="Leave blank to keep the current value."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`secret-${key}`}
|
||||||
|
type="password"
|
||||||
|
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||||
|
value={draftSecrets[key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftSecrets({
|
||||||
|
...draftSecrets,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const onlyChanged = Object.fromEntries(
|
||||||
|
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||||
|
);
|
||||||
|
saveService.mutate({
|
||||||
|
id: instance.id,
|
||||||
|
service_type: instance.service_type,
|
||||||
|
name: instance.name,
|
||||||
|
config: instance.config,
|
||||||
|
secrets: onlyChanged,
|
||||||
|
enabled: instance.enabled,
|
||||||
|
});
|
||||||
|
setDraftSecrets({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update secrets
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,12 +5,12 @@ import { Dashboard } from "../Dashboard";
|
|||||||
import type { DashboardShortcut } from "../../types";
|
import type { DashboardShortcut } from "../../types";
|
||||||
|
|
||||||
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||||
// (shortcut CRUD) without rendering the session panel or the backup query.
|
// (shortcut CRUD) without rendering widgets or their data queries.
|
||||||
vi.mock("../../components/NowPlaying", () => ({
|
vi.mock("../../components/WidgetInstance", () => ({
|
||||||
NowPlaying: () => <div data-testid="now-playing-stub" />,
|
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../components/BackupDashboardWidget", () => ({
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||||
default: () => <div data-testid="backup-widget-stub" />,
|
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const navigate = vi.fn();
|
const navigate = vi.fn();
|
||||||
@@ -21,6 +21,9 @@ vi.mock("react-router-dom", () => ({
|
|||||||
vi.mock("../../hooks/useSettings", () => ({
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
useMonitoringSettings: () => ({ data: [] }),
|
useMonitoringSettings: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
const deleteShortcutMutate = vi.fn();
|
const deleteShortcutMutate = vi.fn();
|
||||||
|
|||||||
+58
-14
@@ -446,8 +446,8 @@ export interface PrometheusTarget {
|
|||||||
|
|
||||||
export interface WidgetInstance {
|
export interface WidgetInstance {
|
||||||
id: string;
|
id: string;
|
||||||
addon_id: string;
|
service_id: string | null;
|
||||||
widget_type: string;
|
widget_kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -458,27 +458,71 @@ export interface WidgetInstance {
|
|||||||
|
|
||||||
export interface WidgetInstanceInput {
|
export interface WidgetInstanceInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
addon_id: string;
|
service_id: string | null;
|
||||||
widget_type: string;
|
widget_kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetTypeInfo {
|
|
||||||
addon_id: string;
|
|
||||||
widget_type: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
source_type: string;
|
|
||||||
config_schema: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WidgetDataResponse {
|
export interface WidgetDataResponse {
|
||||||
widget_id: string;
|
widget_id: string;
|
||||||
widget_type: string;
|
|
||||||
data: Record<string, unknown> | null;
|
data: Record<string, unknown> | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetched_at: number;
|
fetched_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecretFieldInfo {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
helper?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceWidgetKindInfo {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
default_config: Record<string, unknown>;
|
||||||
|
refresh_interval_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceTypeInfo {
|
||||||
|
service_type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
secret_fields: SecretFieldInfo[];
|
||||||
|
widget_kinds: ServiceWidgetKindInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInstance {
|
||||||
|
id: string;
|
||||||
|
service_type: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
secrets_set: Record<string, boolean>;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInstanceInput {
|
||||||
|
id?: string | null;
|
||||||
|
service_type: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
secrets: Record<string, string>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuiltinWidgetKindInfo {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
default_config: Record<string, unknown>;
|
||||||
|
refresh_interval_ms: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,22 +5,23 @@ import { SectionCard } from "../components/SectionCard";
|
|||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { BackupDashboardSummary } from "../types/backups";
|
import type { BackupDashboardSummary } from "../types/backups";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackupsWidget({ widget }: Props) {
|
export function BackupsWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const summary = data?.data as BackupDashboardSummary | undefined;
|
const summary = data?.data as BackupDashboardSummary | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<div className="flex flex-row flex-wrap gap-6">
|
<div className="flex flex-row flex-wrap gap-6">
|
||||||
<Skeleton className="h-10 w-20" />
|
<Skeleton className="h-10 w-20" />
|
||||||
|
|||||||
@@ -5,22 +5,23 @@ import { ExternalLink } from "lucide-react";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GrafanaLinkWidget({ widget }: Props) {
|
export function GrafanaLinkWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const url = data?.data?.url as string | undefined;
|
const url = data?.data?.url as string | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<Skeleton className="h-10 w-48" />
|
<Skeleton className="h-10 w-48" />
|
||||||
) : data?.error ? (
|
) : data?.error ? (
|
||||||
|
|||||||
@@ -4,22 +4,23 @@ import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { NowPlayingSession, WidgetInstance } from "../types";
|
import type { NowPlayingSession, WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JellyfinWidget({ widget }: Props) {
|
export function JellyfinWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromQLResult = {
|
type PromQLResult = {
|
||||||
@@ -35,16 +36,16 @@ function formatPrometheusValue(result: PromQLResult | undefined): string {
|
|||||||
return JSON.stringify(result, null, 2);
|
return JSON.stringify(result, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrometheusMetricWidget({ widget }: Props) {
|
export function PrometheusMetricWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const result = data?.data?.result as PromQLResult | undefined;
|
const result = data?.data?.result as PromQLResult | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<Skeleton className="h-10 w-32" />
|
<Skeleton className="h-10 w-32" />
|
||||||
) : data?.error ? (
|
) : data?.error ? (
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SshTaskResult = {
|
type SshTaskResult = {
|
||||||
@@ -15,16 +16,16 @@ type SshTaskResult = {
|
|||||||
stderr: string;
|
stderr: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SshTaskWidget({ widget }: Props) {
|
export function SshTaskWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const result = data?.data as SshTaskResult | undefined;
|
const result = data?.data as SshTaskResult | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StaticWidget({ widget }: Props) {
|
export function StaticWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
|
refreshIntervalMs,
|
||||||
|
description,
|
||||||
|
}: Props) {
|
||||||
|
const { data } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const text = data?.data?.text as string | undefined;
|
const text = data?.data?.text as string | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{text ? (
|
{text ? (
|
||||||
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,9 +4,3 @@ export { JellyfinWidget } from "./JellyfinWidget";
|
|||||||
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
export { SshTaskWidget } from "./SshTaskWidget";
|
export { SshTaskWidget } from "./SshTaskWidget";
|
||||||
export { StaticWidget } from "./StaticWidget";
|
export { StaticWidget } from "./StaticWidget";
|
||||||
export {
|
|
||||||
getWidgetDefinition,
|
|
||||||
listWidgetTypes,
|
|
||||||
WIDGET_REGISTRY,
|
|
||||||
} from "./registry";
|
|
||||||
export type { WidgetConfigField, WidgetDefinition } from "./registry";
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
getWidgetDefinition,
|
|
||||||
listWidgetTypes,
|
|
||||||
WIDGET_REGISTRY,
|
|
||||||
} from "./registry";
|
|
||||||
|
|
||||||
describe("widget registry", () => {
|
|
||||||
it("contains exactly six Phase 1 types", () => {
|
|
||||||
const types = listWidgetTypes();
|
|
||||||
expect(types).toHaveLength(6);
|
|
||||||
expect(types.map((t) => t.widgetType).sort()).toEqual([
|
|
||||||
"backups",
|
|
||||||
"grafana-link",
|
|
||||||
"jellyfin",
|
|
||||||
"prometheus-metric",
|
|
||||||
"ssh-task",
|
|
||||||
"static",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has refresh intervals matching the spec", () => {
|
|
||||||
expect(getWidgetDefinition("jellyfin")?.refreshInterval).toBe(30_000);
|
|
||||||
expect(getWidgetDefinition("backups")?.refreshInterval).toBe(60_000);
|
|
||||||
expect(getWidgetDefinition("grafana-link")?.refreshInterval).toBe(0);
|
|
||||||
expect(getWidgetDefinition("prometheus-metric")?.refreshInterval).toBe(
|
|
||||||
30_000,
|
|
||||||
);
|
|
||||||
expect(getWidgetDefinition("ssh-task")?.refreshInterval).toBe(0);
|
|
||||||
expect(getWidgetDefinition("static")?.refreshInterval).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defines required metadata for every widget", () => {
|
|
||||||
for (const def of Object.values(WIDGET_REGISTRY)) {
|
|
||||||
expect(def.widgetType).toBeTruthy();
|
|
||||||
expect(def.addonId).toBeTruthy();
|
|
||||||
expect(def.name).toBeTruthy();
|
|
||||||
expect(def.sourceType).toBeTruthy();
|
|
||||||
expect(def.component).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import type { ComponentType } from "react";
|
|
||||||
import type { WidgetInstance } from "../types";
|
|
||||||
import { BackupsWidget } from "./BackupsWidget";
|
|
||||||
import { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
|
||||||
import { JellyfinWidget } from "./JellyfinWidget";
|
|
||||||
import { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
|
||||||
import { SshTaskWidget } from "./SshTaskWidget";
|
|
||||||
import { StaticWidget } from "./StaticWidget";
|
|
||||||
|
|
||||||
export interface WidgetConfigField {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
type: "string" | "select" | "boolean" | "number";
|
|
||||||
options?: { label: string; value: string }[];
|
|
||||||
helper?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WidgetDefinition {
|
|
||||||
widgetType: string;
|
|
||||||
addonId: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
sourceType: string;
|
|
||||||
refreshInterval: number;
|
|
||||||
defaultConfig: Record<string, unknown>;
|
|
||||||
configFields: WidgetConfigField[];
|
|
||||||
component: ComponentType<{ widget: WidgetInstance }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
|
|
||||||
jellyfin: {
|
|
||||||
widgetType: "jellyfin",
|
|
||||||
addonId: "core",
|
|
||||||
name: "Jellyfin activity",
|
|
||||||
description: "Live sessions and idle users from a Jellyfin server.",
|
|
||||||
sourceType: "jellyfin",
|
|
||||||
refreshInterval: 30_000,
|
|
||||||
defaultConfig: { machine_id: "" },
|
|
||||||
configFields: [
|
|
||||||
{
|
|
||||||
key: "machine_id",
|
|
||||||
label: "Machine ID",
|
|
||||||
type: "string",
|
|
||||||
helper: "Jellyfin machine id (empty = default)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
component: JellyfinWidget,
|
|
||||||
},
|
|
||||||
backups: {
|
|
||||||
widgetType: "backups",
|
|
||||||
addonId: "backups",
|
|
||||||
name: "Backups",
|
|
||||||
description: "Backup job summary and active alerts.",
|
|
||||||
sourceType: "backups",
|
|
||||||
refreshInterval: 60_000,
|
|
||||||
defaultConfig: {},
|
|
||||||
configFields: [],
|
|
||||||
component: BackupsWidget,
|
|
||||||
},
|
|
||||||
"grafana-link": {
|
|
||||||
widgetType: "grafana-link",
|
|
||||||
addonId: "grafana",
|
|
||||||
name: "Grafana link",
|
|
||||||
description: "Deep-link to a Grafana dashboard or panel.",
|
|
||||||
sourceType: "grafana",
|
|
||||||
refreshInterval: 0,
|
|
||||||
defaultConfig: { dashboard_uid: "" },
|
|
||||||
configFields: [
|
|
||||||
{ key: "dashboard_uid", label: "Dashboard UID", type: "string" },
|
|
||||||
{
|
|
||||||
key: "panel_id",
|
|
||||||
label: "Panel ID",
|
|
||||||
type: "number",
|
|
||||||
helper: "Optional",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
component: GrafanaLinkWidget,
|
|
||||||
},
|
|
||||||
"prometheus-metric": {
|
|
||||||
widgetType: "prometheus-metric",
|
|
||||||
addonId: "prometheus",
|
|
||||||
name: "Prometheus metric",
|
|
||||||
description: "Instant query result rendered as a metric.",
|
|
||||||
sourceType: "prometheus",
|
|
||||||
refreshInterval: 30_000,
|
|
||||||
defaultConfig: { promql: "" },
|
|
||||||
configFields: [{ key: "promql", label: "PromQL query", type: "string" }],
|
|
||||||
component: PrometheusMetricWidget,
|
|
||||||
},
|
|
||||||
"ssh-task": {
|
|
||||||
widgetType: "ssh-task",
|
|
||||||
addonId: "ssh-tasks",
|
|
||||||
name: "SSH task output",
|
|
||||||
description: "Output of a saved task run on a machine.",
|
|
||||||
sourceType: "ssh_task",
|
|
||||||
refreshInterval: 0,
|
|
||||||
defaultConfig: { task_id: "" },
|
|
||||||
configFields: [{ key: "task_id", label: "Saved task ID", type: "string" }],
|
|
||||||
component: SshTaskWidget,
|
|
||||||
},
|
|
||||||
static: {
|
|
||||||
widgetType: "static",
|
|
||||||
addonId: "core",
|
|
||||||
name: "Static text",
|
|
||||||
description: "Plain text or markdown note.",
|
|
||||||
sourceType: "static",
|
|
||||||
refreshInterval: 0,
|
|
||||||
defaultConfig: { text: "" },
|
|
||||||
configFields: [{ key: "text", label: "Text", type: "string" }],
|
|
||||||
component: StaticWidget,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getWidgetDefinition(
|
|
||||||
widgetType: string,
|
|
||||||
): WidgetDefinition | undefined {
|
|
||||||
return WIDGET_REGISTRY[widgetType];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listWidgetTypes(): WidgetDefinition[] {
|
|
||||||
return Object.values(WIDGET_REGISTRY);
|
|
||||||
}
|
|
||||||
@@ -1,78 +1,87 @@
|
|||||||
# Apply Progress: Runtime Service Registry
|
# Apply Progress: Runtime Service Registry
|
||||||
|
|
||||||
**Change:** `service-registry`
|
**Change:** `service-registry`
|
||||||
**Apply run:** PR 1 + PR 2 / Slice 1 + Slice 2
|
**Apply run:** PR 1 + PR 2 + PR 3 (Slices 1–3)
|
||||||
**Date:** 2026-06-19
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
## Slice 1 — Backend service foundation (MERGED)
|
## Slice 1 — Backend service foundation (MERGED, PR #7)
|
||||||
|
|
||||||
Completed in PR #7. See git history. Summary: Fernet secrets helper, closed
|
Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config
|
||||||
`integrations/` registry with Pydantic config + widget-config definitions for
|
for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` +
|
||||||
grafana/prometheus/jellyfin/nextcloud/ssh_tasks, `services` + `service_task_runs`
|
`service_task_runs` tables with cascade delete, `/api/services*` CRUD,
|
||||||
tables with cascade delete, `/api/services*` CRUD, `MANAGE_ENCRYPTION_KEY`
|
`MANAGE_ENCRYPTION_KEY` required at startup.
|
||||||
required at startup, 25 tests.
|
|
||||||
|
|
||||||
## Slice 2 — Backend widget rebind to services (this PR)
|
## Slice 2 — Backend widget rebind (MERGED, PR #8)
|
||||||
|
|
||||||
|
Widgets carry `service_id` + `widget_kind`; adapters take
|
||||||
|
`fetch(service: ServiceRecord | None, widget_kind, config)`; backups + static
|
||||||
|
stay as service-less built-ins; SSH adapter logs to `service_task_runs`; old
|
||||||
|
`widgets/registry.py` retired; default seeding removed.
|
||||||
|
|
||||||
|
## Slice 3 — Frontend services runtime (this PR)
|
||||||
|
|
||||||
### Completed tasks
|
### Completed tasks
|
||||||
|
|
||||||
- [x] 2.1 Add `service_id` / `widget_kind` columns to `dashboard_widgets`
|
- [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`,
|
||||||
(additive ALTER; legacy `addon_id`/`widget_type` kept but unused).
|
`ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`,
|
||||||
- [x] 2.2 Refactor source adapters to `fetch(service, widget_kind, config)`
|
`SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` +
|
||||||
with `ServiceRecord | None`. `SERVICE_ADAPTERS` keyed by service_type;
|
`widget_kind`).
|
||||||
`BUILTIN_ADAPTERS` for backups/static. SSH adapter resolves the task +
|
- [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`).
|
||||||
instance, runs, and appends a `service_task_runs` row (success/failure/
|
Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape
|
||||||
timeout/error).
|
(removed sources/types; added builtin kinds).
|
||||||
- [x] 2.3 Retire old `widgets/registry.py` (deleted; metadata now comes from
|
- [x] 3.3 Closed frontend service registry (`integrations/registry.ts`)
|
||||||
`integrations/registry` + `widgets/builtin`).
|
mirroring the backend; `resolveWidget(widget, services)` maps a widget to
|
||||||
- [x] 2.4 Update widgets router + models for service-bound + built-in widgets.
|
its component + refresh interval.
|
||||||
Removed `/api/widgets/types` and `/api/widgets/sources`; added
|
- [x] 3.4 Service page at `/services/:serviceType/:serviceId` with config view,
|
||||||
`/api/widgets/builtin`.
|
empty-on-edit secret inputs + "set" badges, enable toggle, delete, and the
|
||||||
- [x] 2.5 Rewrite widget tests around the new model.
|
service's widget-kind list.
|
||||||
- [x] 2.6 Stop default widget seeding (fresh install = empty dashboard).
|
- [x] 3.5 Route swap: added `/services/:serviceType/:serviceId`; addon route
|
||||||
|
retained for now (removed in Slice 4 cleanup).
|
||||||
|
- [x] 3.6 Reconciled widget components to take `refreshIntervalMs` +
|
||||||
|
`description` props; rewrote `WidgetConfigDialog` around the
|
||||||
|
service → widget-kind picker (pulled 4.1 forward to keep the build whole).
|
||||||
|
- [x] 3.7 Registry + Dashboard tests updated; new
|
||||||
|
`integrations/registry.test.ts`.
|
||||||
|
|
||||||
### Decision resolved mid-slice
|
### Decision resolved mid-slice
|
||||||
|
|
||||||
Backups and static widgets stay as **service-less built-ins** (`service_id`
|
Secret edit UX = **empty-on-edit + "set" badge** (blank = keep existing; typing
|
||||||
nullable), per product decision. The data endpoint resolves built-ins via
|
= replace). Applied on the ServicePage secrets card.
|
||||||
`BUILTIN_ADAPTERS` and service-bound widgets via `SERVICE_ADAPTERS` + a
|
|
||||||
decrypted `ServiceRecord`.
|
|
||||||
|
|
||||||
### Files changed (Slice 2)
|
### Files changed (Slice 3)
|
||||||
|
|
||||||
- New: `widgets/builtin.py` (built-in kinds + light config validation).
|
- New: `api/services.ts`, `hooks/useServices.ts`, `integrations/registry.ts`,
|
||||||
- Rewritten: `widgets/sources.py` (`ServiceRecord`, new protocol, service +
|
`integrations/registry.test.ts`, `pages/ServicePage.tsx`.
|
||||||
built-in adapters, SSH run logging, `_build_ssh_client`).
|
- Modified: `types/index.ts`, `api/widgets.ts`, `hooks/useWidgets.ts`,
|
||||||
- Deleted: `widgets/registry.py`.
|
`components/WidgetInstance.tsx`, `components/WidgetConfigDialog.tsx`,
|
||||||
- Modified: `models/widgets.py` (service_id + widget_kind; `BuiltinWidgetKindInfo`).
|
`pages/Dashboard.tsx`, `pages/__tests__/Dashboard.test.tsx`, `App.tsx`,
|
||||||
- Modified: `routers/widgets.py` (new validation, `/builtin`, data resolution).
|
all six `widgets/*.tsx` components, `widgets/index.ts`.
|
||||||
- Modified: `services/settings_store.py` (widget columns; no-op seeding).
|
- Deleted: `widgets/registry.ts`, `widgets/registry.test.ts`.
|
||||||
- Modified: `integrations/base.py` (`WidgetKind.config_model` for Pydantic
|
|
||||||
widget-config validation).
|
|
||||||
- Rewritten: `tests/test_widgets.py` (26 tests).
|
|
||||||
|
|
||||||
### Verification (Slice 2)
|
### Verification (Slice 3)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd frontend
|
||||||
.venv/bin/ruff check . # All checks passed
|
|
||||||
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
|
|
||||||
cd ../frontend
|
|
||||||
npm run lint # 0 errors
|
npm run lint # 0 errors
|
||||||
npm run build # success
|
npm run build # success
|
||||||
|
npm run test # 70 passed
|
||||||
|
cd ../backend
|
||||||
|
.venv/bin/ruff check . # clean
|
||||||
|
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Known transient state (resolved by Slice 3)
|
### Deviations / notes
|
||||||
|
|
||||||
Slice 2 is a backend-only breaking change to the widget API. Until Slice 3
|
- `WidgetConfigDialog` was rewritten in this slice (pulled forward from task
|
||||||
lands, the frontend still calls the removed `/api/widgets/types` and
|
4.1) because the old dialog imported the deleted widget registry and would
|
||||||
`/api/widgets/sources` endpoints and uses the old `widget_type` shape, so the
|
not compile. The SSH task-output widget keeps a dedicated task picker; other
|
||||||
dashboard widget config UI is non-functional at runtime. Build/lint stay green.
|
widget configs use a generic schema-driven field editor.
|
||||||
This is the accepted transient state for a stacked backend→frontend rebind.
|
- Addon pages (`/addons/:addonId`) are kept compiling but superseded by service
|
||||||
|
pages; Slice 4 removes them and the now-unused machine Jellyfin/Jellyseerr
|
||||||
|
fields + `grafana_url`/`prometheus_url` env vars, and writes the changelog.
|
||||||
|
|
||||||
## Remaining work
|
## Remaining work
|
||||||
|
|
||||||
- Slice 3: Frontend services runtime (types, API, hooks, frontend service
|
- Slice 4: remove addon pages + machine app fields, remove
|
||||||
registry, service pages, route swap, remove addon pages, reconcile widget UI).
|
`grafana_url`/`prometheus_url` from config + compose, docs + changelog
|
||||||
- Slice 4: Dashboard picker on services, settings rework, remove
|
(breaking upgrade note).
|
||||||
`grafana_url`/`prometheus_url` env vars, docs + changelog.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user