Merge pull request 'feat(services): cleanup, services admin UI, docs' (#10) from feat/service-registry-cleanup-services-ui into main

This commit is contained in:
Developer
2026-06-23 11:07:20 +00:00
15 changed files with 534 additions and 243 deletions
-2
View File
@@ -27,8 +27,6 @@ PROMETHEUS_ENABLED=true
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL=http://alertmanager:9093
ALERTMANAGER_WEBHOOK_URL=
GRAFANA_URL=http://grafana:3000
PROMETHEUS_URL=http://prometheus:9090
# Required: master key for encrypting service secrets (API keys/tokens) at rest.
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
+56
View File
@@ -0,0 +1,56 @@
# Changelog
All notable changes to Manage. Breaking changes are marked with **BREAKING**.
## [Unreleased]
### Added — Service registry
- Runtime **service registry** persisted in the backend SQLite database. External
services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are now
configured in the app instead of via environment variables.
- Services page (`/services`) to create, list, and delete service instances.
- Service detail pages (`/services/:serviceType/:serviceId`) to edit name/enabled
state, rotate secrets, and view the widgets a service provides.
- Service definitions live as Pydantic modules in `backend/.../integrations/`,
each declaring its config schema, secret fields, and widget kinds.
- Multi-instance support: multiple Grafana/Jellyfin/etc. instances per type.
- SSH task runner service records run history in a new `service_task_runs`
table, shown on the runner's service page.
### Changed
- Dashboard widgets are now **service-bound** (reference a service instance +
widget kind) or **built-in** (backups, static text). The "Add widget" flow is
pick-service → pick-widget-kind → configure.
- Deleting a service cascade-deletes widgets that reference it.
### Security
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
Fernet.
### **BREAKING**
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
with:
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
- The `GRAFANA_URL` and `PROMETHEUS_URL` backend environment variables were
removed; Grafana/Prometheus URLs now live on service records configured in the
UI. Re-create them on the Services page after upgrading.
- The legacy widget/addon-pages model (`/addons/:addonId`,
`/api/widgets/types`, `/api/widgets/sources`) was removed in favor of the
service registry.
- Default dashboard widget seeding was removed; a fresh install starts with an
empty dashboard. Add widgets from the dashboard's edit dialog after
configuring services.
### Notes / follow-ups
- Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
pages. Migrating those onto the service registry is a separate follow-up change
(see `openspec/changes/service-registry/design.md` §12.5).
+2 -4
View File
@@ -144,9 +144,7 @@ VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
# Grafana / Prometheus URLs used by widget adapters and frontend deep-links
GRAFANA_URL=http://grafana:3000
PROMETHEUS_URL=http://prometheus:9090
# Grafana / Prometheus public URLs for frontend deep-links (service adapters read URLs from service records)
VITE_GRAFANA_URL=https://grafana.manage.example.com
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
@@ -186,4 +184,4 @@ cd frontend && npx tsc --noEmit && npm run build
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
- Grafana and Prometheus widget adapters use `GRAFANA_URL` and `PROMETHEUS_URL` (backend) and `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` (frontend) for deep-links; no credentials are stored in widget config.
- Grafana and Prometheus widget adapters resolve URLs from service records configured in the app; `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` are only used for frontend deep-links. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`.
@@ -57,8 +57,6 @@ class Settings(BaseSettings):
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
alertmanager_url: str = "http://alertmanager:9093"
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
grafana_url: str = "http://grafana:3000"
prometheus_url: str = "http://prometheus:9090"
# Remote paths
remote_media_root: str = ""
-2
View File
@@ -17,8 +17,6 @@ services:
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
ports:
- "8000:8000"
-2
View File
@@ -28,8 +28,6 @@ services:
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
volumes:
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
+58 -33
View File
@@ -256,54 +256,79 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
- Job templates should remain centralized in `jobs.py` for future extension.
- Remote job template values must be shell-quoted before execution.
## Configurable Dashboard Widgets
## Service Registry and Dashboard Widgets
### Overview
The dashboard is composed of persisted widget instances stored in the backend SQLite
settings database. Each widget has a type, title, configuration, enabled flag, and
sort order. The frontend renders enabled widgets in sort order and fetches data
independently through the backend source adapters.
External services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are
configured **in the app** and persisted in the backend SQLite database. Each
service instance holds non-secret config plus encrypted secret fields. Dashboard
widgets are either **service-bound** (reference a service instance + a widget
kind declared by that service) or **built-in / service-less** (backups, static
text).
### Widget types
Service definitions live as Pydantic modules in the backend
(`integrations/`); they declare the service config schema, secret fields, and
the widget kinds the service provides. There is no runtime plugin loading.
- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine.
- **Backups** — backup job summary and active alerts.
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding).
- **Prometheus metric** — result of a PromQL instant query.
- **SSH task output** — output of a saved task run on a machine.
### Services
- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
- **Prometheus** — base URL + optional bearer token; provides a PromQL metric widget.
- **Jellyfin** — base URL + API key; provides a live-activity widget.
- **Nextcloud** — base URL + app password (no widgets yet).
- **SSH task runner** — host/port/username + saved SSH key reference + optional
passphrase; provides a task-output widget. Tasks stay in the global saved-task
registry; every run is recorded in `service_task_runs` as history.
Multiple instances per service type are supported. Services are managed from the
**Services** page (`/services`) and each instance has a detail page at
`/services/:serviceType/:serviceId`.
### Built-in widgets
- **Backups** — internal backup job summary and active alerts.
- **Static text** — plain text or markdown note.
These do not reference a service.
### Security
- Widget `config` may not contain credential keys such as `password`, `token`,
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like
secrets (e.g., base64 blobs, `sk-` prefixes).
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for
Grafana/Prometheus URLs; no secrets are stored in widget configuration.
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands
are not accepted.
### Addon pages
Each non-core addon gets a dedicated page at `/addons/:addonId`:
- `/addons/grafana`
- `/addons/prometheus`
- `/addons/ssh-tasks`
Unknown addons render a "not installed" alert.
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
Fernet using a single env-provided `MANAGE_ENCRYPTION_KEY`, which is always
required to start the backend.
- Widget `config` and service `config` may not contain credential keys or
secret-looking values; secrets go in the dedicated secret fields only.
- Plaintext secrets are never returned by the API; only `secrets_set` flags are
surfaced.
- SSH task widgets only run tasks from the saved-task registry; arbitrary
commands are not accepted.
### API
- `GET /api/widgets/sources` — list source types.
- `GET /api/widgets/types` — list widget type metadata.
- `GET /api/services/types` — service definition metadata (config schema,
secret fields, widget kinds).
- `GET /api/services/instances` — list service instances (no plaintext secrets).
- `POST /api/services/instances` — create instance.
- `PUT /api/services/instances/{id}` — update instance.
- `DELETE /api/services/instances/{id}` — delete instance (cascade-deletes
widgets referencing it).
- `GET /api/widgets/builtin` — built-in (service-less) widget kinds.
- `GET /api/widgets/instances` — list widget instances.
- `POST /api/widgets/instances` — create instance.
- `PUT /api/widgets/instances/{id}` — update instance.
- `DELETE /api/widgets/instances/{id}` — delete instance.
- `POST/PUT/DELETE /api/widgets/instances/{id}` — widget CRUD.
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
### Breaking change
Grafana/Prometheus URLs and credentials moved from environment variables into
service records. The legacy `GRAFANA_URL` / `PROMETHEUS_URL` backend settings and
the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now required.
> **Follow-up (not in this change):** machine-level Jellyfin/Jellyseerr app
> config still powers the Media/Users/Files pages. Migrating those onto the
> service registry (and removing the machine app fields) is a separate change;
> see `openspec/changes/service-registry/design.md` §12.5.
## Decision Log
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
+5 -3
View File
@@ -22,8 +22,8 @@ import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { AddonPage } from "./pages/AddonPage";
import { ServicePage } from "./pages/ServicePage";
import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
@@ -57,6 +57,7 @@ import {
LogOut,
ChevronLeft,
ChevronRight,
Boxes,
} from "lucide-react";
const queryClient = new QueryClient({
@@ -90,6 +91,7 @@ const navItems = [
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
@@ -451,7 +453,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
@@ -487,7 +489,7 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
-37
View File
@@ -1,37 +0,0 @@
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function GrafanaAddonPage() {
const grafanaUrl =
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
"http://localhost:3000";
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Grafana</h2>
<Card>
<CardHeader>
<CardTitle>Metrics & logs</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open the full Grafana instance for dashboards, metrics, and log
exploration.
</p>
<Button asChild>
<a
href={grafanaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Grafana
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -1,36 +0,0 @@
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function PrometheusAddonPage() {
const prometheusUrl =
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
"http://localhost:9090";
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Prometheus</h2>
<Card>
<CardHeader>
<CardTitle>Metrics explorer</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
</p>
<Button asChild>
<a
href={prometheusUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Prometheus
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useNavigate } from "react-router-dom";
export function SshTasksAddonPage() {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">SSH tasks</h2>
<Card>
<CardHeader>
<CardTitle>Saved actions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create, edit, and run saved shell or Python tasks against local or
remote machines.
</p>
<Button onClick={() => navigate("/actions")}>
<Terminal className="mr-2 h-4 w-4" />
Open Actions
</Button>
</CardContent>
</Card>
</div>
);
}
-3
View File
@@ -1,3 +0,0 @@
export { GrafanaAddonPage } from "./GrafanaAddonPage";
export { PrometheusAddonPage } from "./PrometheusAddonPage";
export { SshTasksAddonPage } from "./SshTasksAddonPage";
-28
View File
@@ -1,28 +0,0 @@
import { useParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
GrafanaAddonPage,
PrometheusAddonPage,
SshTasksAddonPage,
} from "../addons";
const ADDON_PAGES: Record<string, React.ComponentType> = {
grafana: GrafanaAddonPage,
prometheus: PrometheusAddonPage,
"ssh-tasks": SshTasksAddonPage,
};
export function AddonPage() {
const { addonId } = useParams<{ addonId: string }>();
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
if (!Page) {
return (
<Alert>
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
</Alert>
);
}
return <Page />;
}
+372
View File
@@ -0,0 +1,372 @@
import { useMemo, useState } from "react";
import { useNavigate } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ExternalLink, Plus, Trash2 } from "lucide-react";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
} from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices";
import type {
SecretFieldInfo,
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { getServiceBinding } from "../integrations/registry";
interface CreateDraft {
serviceType: string;
name: string;
config: Record<string, unknown>;
secrets: Record<string, string>;
enabled: boolean;
}
function emptyDraft(serviceType: string): CreateDraft {
return { serviceType, name: "", config: {}, secrets: {}, enabled: true };
}
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>
);
}
function ServiceConfigFields({
type,
config,
onChange,
}: {
type: ServiceTypeInfo;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
}) {
const properties =
(
type.config_schema as {
properties?: Record<string, { type?: string; description?: string }>;
}
).properties ?? {};
return (
<div className="flex flex-col gap-3">
{Object.entries(properties).map(([key, schema]) => {
const isNumber = schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) =>
onChange({
...config,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</Field>
);
})}
</div>
);
}
function ServiceSecretFields({
fields,
secrets,
onChange,
}: {
fields: SecretFieldInfo[];
secrets: Record<string, string>;
onChange: (secrets: Record<string, string>) => void;
}) {
if (fields.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{fields.map((field) => (
<Field
key={field.key}
label={field.label}
htmlFor={`secret-${field.key}`}
helper={field.helper ?? (field.required ? "Required" : undefined)}
>
<Input
id={`secret-${field.key}`}
type="password"
value={secrets[field.key] ?? ""}
onChange={(e) =>
onChange({ ...secrets, [field.key]: e.target.value })
}
/>
</Field>
))}
</div>
);
}
function CreateServiceDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const { data: types = [] } = useServiceTypes();
const saveService = useSaveServiceInstance();
const [draft, setDraft] = useState<CreateDraft | null>(null);
function reset() {
setDraft(null);
}
async function save() {
if (!draft) return;
if (!draft.name.trim()) return;
const input: ServiceInstanceInput = {
service_type: draft.serviceType,
name: draft.name.trim(),
config: draft.config,
secrets: draft.secrets,
enabled: draft.enabled,
};
await saveService.mutateAsync(input);
reset();
onClose();
}
const selectedType = types.find((t) => t.service_type === draft?.serviceType);
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
reset();
onClose();
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New service</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
{!draft ? (
<div className="flex flex-col gap-2">
{types.map((t) => (
<Button
key={t.service_type}
variant="outline"
onClick={() => setDraft(emptyDraft(t.service_type))}
>
<Plus className="mr-1 h-3 w-3" />
{t.name}
</Button>
))}
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
{selectedType?.description}
</p>
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</Field>
{selectedType ? (
<ServiceConfigFields
type={selectedType}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
/>
) : null}
{selectedType ? (
<ServiceSecretFields
fields={selectedType.secret_fields}
secrets={draft.secrets}
onChange={(secrets) => setDraft({ ...draft, secrets })}
/>
) : null}
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
</>
)}
</div>
{draft ? (
<DialogFooter
onCancel={reset}
onConfirm={save}
confirmLabel="Create service"
confirmDisabled={!draft.name.trim() || saveService.isPending}
/>
) : null}
</DialogContent>
</Dialog>
);
}
export function ServicesPage() {
const navigate = useNavigate();
const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes();
const deleteService = useDeleteServiceInstance();
const [createOpen, setCreateOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const grouped = useMemo(() => {
const map = new Map<string, ServiceInstance[]>();
for (const s of services) {
const list = map.get(s.service_type) ?? [];
list.push(s);
map.set(s.service_type, list);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [services]);
const typeName = (t: string) =>
types.find((x) => x.service_type === t)?.name ??
getServiceBinding(t)?.name ??
t;
return (
<div className="flex flex-col gap-4">
<SectionCard
title="Services"
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
action={
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" />
Add service
</Button>
}
>
{services.length === 0 ? (
<Alert>
<AlertDescription>
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud,
or SSH task runner.
</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-4">
{grouped.map(([serviceType, instances]) => (
<div key={serviceType} className="flex flex-col gap-2">
<div className="text-sm font-medium">
{typeName(serviceType)}
</div>
<div className="flex flex-col gap-2">
{instances.map((s) => (
<div
key={s.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{s.name}</span>
<Badge variant="outline">{s.service_type}</Badge>
{!s.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
{Object.entries(s.secrets_set).some(([, v]) => v) ? (
<Badge variant="outline">secrets set</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() =>
navigate(`/services/${s.service_type}/${s.id}`)
}
>
Open <ExternalLink className="ml-1 h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(s.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</SectionCard>
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
<ConfirmDialog
open={Boolean(deleteId)}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (deleteId) deleteService.mutate(deleteId);
setDeleteId(null);
}}
/>
</div>
);
}
@@ -1,87 +1,66 @@
# Apply Progress: Runtime Service Registry
**Change:** `service-registry`
**Apply run:** PR 1 + PR 2 + PR 3 (Slices 13)
**Apply run:** PRs #7#10 (Slices 14a)
**Date:** 2026-06-19
## Slice 1 — Backend service foundation (MERGED, PR #7)
## Slices 13 (MERGED)
Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config
for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` +
`service_task_runs` tables with cascade delete, `/api/services*` CRUD,
`MANAGE_ENCRYPTION_KEY` required at startup.
- Slice 1 (#7): backend service foundation — encryption, integrations registry,
services + service_task_runs tables, `/api/services*` CRUD.
- Slice 2 (#8): backend widget rebind — service_id + widget_kind, ServiceRecord
adapters, built-ins, SSH run logging, retired old widget registry.
- Slice 3 (#9): frontend services runtime — types/API/hooks, frontend registry,
ServicePage, route swap, reconciled widget components + config dialog.
## Slice 2Backend 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)
## Slice 4aCleanup + services admin UI + docs (this PR)
### Completed tasks
- [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`,
`ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`,
`SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` +
`widget_kind`).
- [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`).
Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape
(removed sources/types; added builtin kinds).
- [x] 3.3 Closed frontend service registry (`integrations/registry.ts`)
mirroring the backend; `resolveWidget(widget, services)` maps a widget to
its component + refresh interval.
- [x] 3.4 Service page at `/services/:serviceType/:serviceId` with config view,
empty-on-edit secret inputs + "set" badges, enable toggle, delete, and the
service's widget-kind list.
- [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`.
- [x] Removed addon pages (`/addons/:addonId`, `AddonPage.tsx`, `addons/*`) —
superseded by service pages.
- [x] Removed `grafana_url` / `prometheus_url` from `config.py`, both compose
files, `.env.example`, and README. (Frontend `VITE_GRAFANA_URL` /
`VITE_PROMETHEUS_URL` deep-link vars retained.)
- [x] Added a **Services page** (`/services`) with create/list/delete and a nav
entry, so service pages are reachable and services are configurable in the
tool itself.
- [x] Registered `/services` route in both route trees + sidebar nav.
- [x] Updated `docs/REQUIREMENTS.md` (service registry section) and added
`CHANGELOG.md` with the breaking-upgrade note.
### Decision resolved mid-slice
Secret edit UX = **empty-on-edit + "set" badge** (blank = keep existing; typing
= replace). Applied on the ServicePage secrets card.
"Full machine migration" was scoped into **4a (cleanup) + 4b (Jellyfin/Jellyseerr
migration)** because removing machine-level Jellyfin/Jellyseerr fields is deeply
coupled to the Media/Users/Files pages (load-bearing) and there is no
`jellyseerr` service definition yet. 4a ships the safe cleanup + the services
admin UI; 4b does the machine-app-field migration as its own reviewable change.
### Files changed (Slice 3)
### Files changed (Slice 4a)
- New: `api/services.ts`, `hooks/useServices.ts`, `integrations/registry.ts`,
`integrations/registry.test.ts`, `pages/ServicePage.tsx`.
- Modified: `types/index.ts`, `api/widgets.ts`, `hooks/useWidgets.ts`,
`components/WidgetInstance.tsx`, `components/WidgetConfigDialog.tsx`,
`pages/Dashboard.tsx`, `pages/__tests__/Dashboard.test.tsx`, `App.tsx`,
all six `widgets/*.tsx` components, `widgets/index.ts`.
- Deleted: `widgets/registry.ts`, `widgets/registry.test.ts`.
- Backend: `config.py` (removed grafana_url/prometheus_url).
- Compose/env/docs: `docker-compose.yml`, `docker-compose.dev.yml`,
`.env.example`, `README.md`, `docs/REQUIREMENTS.md`, `CHANGELOG.md` (new).
- Frontend: new `pages/ServicesPage.tsx`; `App.tsx` (routes + nav); removed
`pages/AddonPage.tsx`, `addons/*`.
### Verification (Slice 3)
### Verification (Slice 4a)
```bash
cd frontend
cd backend
.venv/bin/ruff check . # clean
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
cd ../frontend
npm run lint # 0 errors
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
```
### Deviations / notes
- `WidgetConfigDialog` was rewritten in this slice (pulled forward from task
4.1) because the old dialog imported the deleted widget registry and would
not compile. The SSH task-output widget keeps a dedicated task picker; other
widget configs use a generic schema-driven field editor.
- 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
- Slice 4: remove addon pages + machine app fields, remove
`grafana_url`/`prometheus_url` from config + compose, docs + changelog
(breaking upgrade note).
- Slice 4b: add `jellyseerr` service definition; rewire `dependencies.py`
Jellyfin/Jellyseerr resolution to the service registry; migrate the
Media/Users/Files/Dashboard selector from machine_id to service instance;
remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`,
`routers/settings.py`, and the Settings UI.