Compare commits

..

7 Commits

Author SHA1 Message Date
Developer d998e6ab0c Merge pull request 'feat(services): cleanup, services admin UI, docs' (#10) from feat/service-registry-cleanup-services-ui into main 2026-06-23 11:07:20 +00:00
Developer 9a6cbfae68 style(services): apply formatter to App and ServicesPage 2026-06-23 11:07:19 +00:00
Developer c9c72be0b6 feat(services): cleanup, services admin UI, docs
PR 4a of the runtime service registry change.

- Remove addon pages (/addons/:addonId, AddonPage, addons/*) superseded by
  service pages.
- Remove grafana_url/prometheus_url from backend config, compose, .env.example,
  and README (URLs now live on service records; VITE_ frontend deep-link vars
  retained).
- Add Services page (/services) with create/list/delete + sidebar nav, so
  services are configurable in the tool itself and service pages are reachable.
- Update docs/REQUIREMENTS.md service-registry section; add CHANGELOG.md with
  the breaking-upgrade note (MANAGE_ENCRYPTION_KEY required; grafana/prometheus
  env vars removed; default widget seeding removed).

Verification: backend ruff clean, pytest 222 passed; frontend lint 0 errors,
build success, 70 tests passed.
2026-06-23 10:57:30 +00:00
Developer 5ec35b4849 Merge pull request 'feat(services): frontend services runtime and widget rebind' (#9) from feat/service-registry-frontend-runtime into main 2026-06-22 19:13:59 +00:00
Developer 739ad38e29 style(services): apply formatter to frontend services runtime 2026-06-22 19:13:59 +00:00
Developer 1da67f38c7 feat(services): frontend services runtime and widget rebind
PR 3 of 4 for the runtime service registry change.

- Add service + new-shape widget TypeScript types; widgets carry service_id
  + widget_kind (service-bound) or null (built-in).
- Add services API client + TanStack Query hooks; reconcile the widget API
  client/hooks to the new endpoints (remove sources/types; add builtin kinds).
- Add closed frontend service registry (integrations/registry.ts) mirroring the
  backend, with resolveWidget(widget, services) mapping a widget to its
  component + refresh interval.
- Add ServicePage at /services/:serviceType/:serviceId with config view,
  empty-on-edit secret inputs + 'set' badges, enable toggle, delete, and the
  service's widget-kind list.
- Register /services/:serviceType/:serviceId in App.tsx.
- Reconcile the six widget components to refreshIntervalMs + description props;
  rewrite WidgetConfigDialog around a service -> widget-kind picker.
- Update Dashboard test; add integrations/registry.test.ts.

Verification: frontend lint 0 errors, build success, 70 tests passed; backend
ruff clean, 222 tests passed.
2026-06-22 18:59:41 +00:00
Developer 41dddbccc0 Merge pull request 'feat(widgets): rebind widgets to the service registry' (#8) from feat/service-registry-widget-rebind into main 2026-06-22 18:22:18 +00:00
36 changed files with 1548 additions and 666 deletions
-2
View File
@@ -27,8 +27,6 @@ PROMETHEUS_ENABLED=true
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL=http://alertmanager:9093 ALERTMANAGER_URL=http://alertmanager:9093
ALERTMANAGER_WEBHOOK_URL= 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. # 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())" # Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key 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_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
# Grafana / Prometheus URLs used by widget adapters and frontend deep-links # Grafana / Prometheus public URLs for frontend deep-links (service adapters read URLs from service records)
GRAFANA_URL=http://grafana:3000
PROMETHEUS_URL=http://prometheus:9090
VITE_GRAFANA_URL=https://grafana.manage.example.com VITE_GRAFANA_URL=https://grafana.manage.example.com
VITE_PROMETHEUS_URL=https://prometheus.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`. - 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. - 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. - 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" prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
alertmanager_url: str = "http://alertmanager:9093" alertmanager_url: str = "http://alertmanager:9093"
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications 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 paths
remote_media_root: str = "" remote_media_root: str = ""
-2
View File
@@ -17,8 +17,6 @@ services:
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} 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} MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
ports: ports:
- "8000:8000" - "8000:8000"
-2
View File
@@ -28,8 +28,6 @@ services:
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd} PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-} 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())"} MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
volumes: volumes:
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache - ${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. - Job templates should remain centralized in `jobs.py` for future extension.
- Remote job template values must be shell-quoted before execution. - Remote job template values must be shell-quoted before execution.
## Configurable Dashboard Widgets ## Service Registry and Dashboard Widgets
### Overview ### Overview
The dashboard is composed of persisted widget instances stored in the backend SQLite External services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are
settings database. Each widget has a type, title, configuration, enabled flag, and configured **in the app** and persisted in the backend SQLite database. Each
sort order. The frontend renders enabled widgets in sort order and fetches data service instance holds non-secret config plus encrypted secret fields. Dashboard
independently through the backend source adapters. 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. ### Services
- **Backups** — backup job summary and active alerts.
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding). - **Grafana** — base URL + optional API key; provides a dashboard-link widget.
- **Prometheus metric** — result of a PromQL instant query. - **Prometheus** — base URL + optional bearer token; provides a PromQL metric widget.
- **SSH task output** — output of a saved task run on a machine. - **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. - **Static text** — plain text or markdown note.
These do not reference a service.
### Security ### Security
- Widget `config` may not contain credential keys such as `password`, `token`, - Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like Fernet using a single env-provided `MANAGE_ENCRYPTION_KEY`, which is always
secrets (e.g., base64 blobs, `sk-` prefixes). required to start the backend.
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for - Widget `config` and service `config` may not contain credential keys or
Grafana/Prometheus URLs; no secrets are stored in widget configuration. secret-looking values; secrets go in the dedicated secret fields only.
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands - Plaintext secrets are never returned by the API; only `secrets_set` flags are
are not accepted. surfaced.
- SSH task widgets only run tasks from the saved-task registry; arbitrary
### Addon pages commands are not accepted.
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.
### API ### API
- `GET /api/widgets/sources` — list source types. - `GET /api/services/types` — service definition metadata (config schema,
- `GET /api/widgets/types` — list widget type metadata. 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. - `GET /api/widgets/instances` — list widget instances.
- `POST /api/widgets/instances` — create instance. - `POST/PUT/DELETE /api/widgets/instances/{id}` — widget CRUD.
- `PUT /api/widgets/instances/{id}` — update instance.
- `DELETE /api/widgets/instances/{id}` — delete instance.
- `GET /api/widgets/instances/{id}/data` — fetch widget data. - `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 ## 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. - 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.
+14 -3
View File
@@ -22,7 +22,8 @@ import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions"; 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 { ServicePage } from "./pages/ServicePage";
import { ServicesPage } from "./pages/ServicesPage";
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";
@@ -56,6 +57,7 @@ import {
LogOut, LogOut,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Boxes,
} from "lucide-react"; } from "lucide-react";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -89,6 +91,7 @@ const navItems = [
{ path: "/backups", label: "Backups", icon: DatabaseBackup }, { path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users }, { path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap }, { path: "/actions", label: "Actions", icon: Zap },
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon }, { path: "/settings", label: "Settings", icon: SettingsIcon },
]; ];
@@ -450,7 +453,11 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} /> <Route path="/backups" element={<BackupsPage />} />
<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="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -482,7 +489,11 @@ function AppInner() {
<Route path="/backups" element={<BackupsPage />} /> <Route path="/backups" element={<BackupsPage />} />
<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="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
-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";
+59
View File
@@ -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();
}
+6 -10
View File
@@ -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();
} }
+181 -167
View File
@@ -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>
+19 -9
View File
@@ -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}
/>
);
} }
+47
View File
@@ -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"] });
},
});
}
+5 -12
View File
@@ -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,
}); });
} }
+100
View File
@@ -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();
});
});
+213
View File
@@ -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;
}
-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 />;
}
+2 -2
View File
@@ -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
+255
View File
@@ -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>
);
}
+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>
);
}
@@ -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
View File
@@ -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;
}
+9 -8
View File
@@ -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" />
+9 -8
View File
@@ -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 ? (
+9 -8
View File
@@ -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 ? (
+9 -8
View File
@@ -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" />
+9 -5
View File
@@ -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>
) : ( ) : (
-6
View File
@@ -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";
-42
View File
@@ -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();
}
});
});
-122
View File
@@ -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,66 @@
# 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:** PRs #7#10 (Slices 14a)
**Date:** 2026-06-19 **Date:** 2026-06-19
## Slice 1 — Backend service foundation (MERGED) ## Slices 13 (MERGED)
Completed in PR #7. See git history. Summary: Fernet secrets helper, closed - Slice 1 (#7): backend service foundation — encryption, integrations registry,
`integrations/` registry with Pydantic config + widget-config definitions for services + service_task_runs tables, `/api/services*` CRUD.
grafana/prometheus/jellyfin/nextcloud/ssh_tasks, `services` + `service_task_runs` - Slice 2 (#8): backend widget rebind — service_id + widget_kind, ServiceRecord
tables with cascade delete, `/api/services*` CRUD, `MANAGE_ENCRYPTION_KEY` adapters, built-ins, SSH run logging, retired old widget registry.
required at startup, 25 tests. - Slice 3 (#9): frontend services runtime — types/API/hooks, frontend registry,
ServicePage, route swap, reconciled widget components + config dialog.
## Slice 2Backend widget rebind to services (this PR) ## Slice 4aCleanup + services admin UI + docs (this PR)
### Completed tasks ### Completed tasks
- [x] 2.1 Add `service_id` / `widget_kind` columns to `dashboard_widgets` - [x] Removed addon pages (`/addons/:addonId`, `AddonPage.tsx`, `addons/*`) —
(additive ALTER; legacy `addon_id`/`widget_type` kept but unused). superseded by service pages.
- [x] 2.2 Refactor source adapters to `fetch(service, widget_kind, config)` - [x] Removed `grafana_url` / `prometheus_url` from `config.py`, both compose
with `ServiceRecord | None`. `SERVICE_ADAPTERS` keyed by service_type; files, `.env.example`, and README. (Frontend `VITE_GRAFANA_URL` /
`BUILTIN_ADAPTERS` for backups/static. SSH adapter resolves the task + `VITE_PROMETHEUS_URL` deep-link vars retained.)
instance, runs, and appends a `service_task_runs` row (success/failure/ - [x] Added a **Services page** (`/services`) with create/list/delete and a nav
timeout/error). entry, so service pages are reachable and services are configurable in the
- [x] 2.3 Retire old `widgets/registry.py` (deleted; metadata now comes from tool itself.
`integrations/registry` + `widgets/builtin`). - [x] Registered `/services` route in both route trees + sidebar nav.
- [x] 2.4 Update widgets router + models for service-bound + built-in widgets. - [x] Updated `docs/REQUIREMENTS.md` (service registry section) and added
Removed `/api/widgets/types` and `/api/widgets/sources`; added `CHANGELOG.md` with the breaking-upgrade note.
`/api/widgets/builtin`.
- [x] 2.5 Rewrite widget tests around the new model.
- [x] 2.6 Stop default widget seeding (fresh install = empty dashboard).
### Decision resolved mid-slice ### Decision resolved mid-slice
Backups and static widgets stay as **service-less built-ins** (`service_id` "Full machine migration" was scoped into **4a (cleanup) + 4b (Jellyfin/Jellyseerr
nullable), per product decision. The data endpoint resolves built-ins via migration)** because removing machine-level Jellyfin/Jellyseerr fields is deeply
`BUILTIN_ADAPTERS` and service-bound widgets via `SERVICE_ADAPTERS` + a coupled to the Media/Users/Files pages (load-bearing) and there is no
decrypted `ServiceRecord`. `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 2) ### Files changed (Slice 4a)
- New: `widgets/builtin.py` (built-in kinds + light config validation). - Backend: `config.py` (removed grafana_url/prometheus_url).
- Rewritten: `widgets/sources.py` (`ServiceRecord`, new protocol, service + - Compose/env/docs: `docker-compose.yml`, `docker-compose.dev.yml`,
built-in adapters, SSH run logging, `_build_ssh_client`). `.env.example`, `README.md`, `docs/REQUIREMENTS.md`, `CHANGELOG.md` (new).
- Deleted: `widgets/registry.py`. - Frontend: new `pages/ServicesPage.tsx`; `App.tsx` (routes + nav); removed
- Modified: `models/widgets.py` (service_id + widget_kind; `BuiltinWidgetKindInfo`). `pages/AddonPage.tsx`, `addons/*`.
- Modified: `routers/widgets.py` (new validation, `/builtin`, data resolution).
- Modified: `services/settings_store.py` (widget columns; no-op seeding).
- Modified: `integrations/base.py` (`WidgetKind.config_model` for Pydantic
widget-config validation).
- Rewritten: `tests/test_widgets.py` (26 tests).
### Verification (Slice 2) ### Verification (Slice 4a)
```bash ```bash
cd backend cd backend
.venv/bin/ruff check . # All checks passed .venv/bin/ruff check . # clean
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
cd ../frontend 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
``` ```
### Known transient state (resolved by Slice 3)
Slice 2 is a backend-only breaking change to the widget API. Until Slice 3
lands, the frontend still calls the removed `/api/widgets/types` and
`/api/widgets/sources` endpoints and uses the old `widget_type` shape, so the
dashboard widget config UI is non-functional at runtime. Build/lint stay green.
This is the accepted transient state for a stacked backend→frontend rebind.
## Remaining work ## Remaining work
- Slice 3: Frontend services runtime (types, API, hooks, frontend service - Slice 4b: add `jellyseerr` service definition; rewire `dependencies.py`
registry, service pages, route swap, remove addon pages, reconcile widget UI). Jellyfin/Jellyseerr resolution to the service registry; migrate the
- Slice 4: Dashboard picker on services, settings rework, remove Media/Users/Files/Dashboard selector from machine_id to service instance;
`grafana_url`/`prometheus_url` env vars, docs + changelog. remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`,
`routers/settings.py`, and the Settings UI.