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.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
+7
-11
@@ -22,8 +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 { 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";
|
||||||
@@ -57,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({
|
||||||
@@ -90,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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -451,11 +453,8 @@ 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
|
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} />
|
||||||
path="/services/:serviceType/:serviceId"
|
|
||||||
element={<ServicePage />}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
@@ -487,11 +486,8 @@ 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
|
<Route path="/services/:serviceType/:serviceId" element={<ServicePage />} />
|
||||||
path="/services/:serviceType/:serviceId"
|
|
||||||
element={<ServicePage />}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { GrafanaAddonPage } from "./GrafanaAddonPage";
|
|
||||||
export { PrometheusAddonPage } from "./PrometheusAddonPage";
|
|
||||||
export { SshTasksAddonPage } from "./SshTasksAddonPage";
|
|
||||||
@@ -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 />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
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
|
# Apply Progress: Runtime Service Registry
|
||||||
|
|
||||||
**Change:** `service-registry`
|
**Change:** `service-registry`
|
||||||
**Apply run:** PR 1 + PR 2 + PR 3 (Slices 1–3)
|
**Apply run:** PRs #7–#10 (Slices 1–4a)
|
||||||
**Date:** 2026-06-19
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
## Slice 1 — Backend service foundation (MERGED, PR #7)
|
## Slices 1–3 (MERGED)
|
||||||
|
|
||||||
Fernet secrets, closed `integrations/` registry (Pydantic config + widget-config
|
- Slice 1 (#7): backend service foundation — encryption, integrations registry,
|
||||||
for grafana/prometheus/jellyfin/nextcloud/ssh_tasks), `services` +
|
services + service_task_runs tables, `/api/services*` CRUD.
|
||||||
`service_task_runs` tables with cascade delete, `/api/services*` CRUD,
|
- Slice 2 (#8): backend widget rebind — service_id + widget_kind, ServiceRecord
|
||||||
`MANAGE_ENCRYPTION_KEY` required at startup.
|
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 2 — Backend widget rebind (MERGED, PR #8)
|
## Slice 4a — Cleanup + services admin UI + docs (this PR)
|
||||||
|
|
||||||
Widgets carry `service_id` + `widget_kind`; adapters take
|
|
||||||
`fetch(service: ServiceRecord | None, widget_kind, config)`; backups + static
|
|
||||||
stay as service-less built-ins; SSH adapter logs to `service_task_runs`; old
|
|
||||||
`widgets/registry.py` retired; default seeding removed.
|
|
||||||
|
|
||||||
## Slice 3 — Frontend services runtime (this PR)
|
|
||||||
|
|
||||||
### Completed tasks
|
### Completed tasks
|
||||||
|
|
||||||
- [x] 3.1 Service + new widget TypeScript types (`ServiceInstance`,
|
- [x] Removed addon pages (`/addons/:addonId`, `AddonPage.tsx`, `addons/*`) —
|
||||||
`ServiceInstanceInput`, `ServiceTypeInfo`, `ServiceWidgetKindInfo`,
|
superseded by service pages.
|
||||||
`SecretFieldInfo`, `BuiltinWidgetKindInfo`; widget gains `service_id` +
|
- [x] Removed `grafana_url` / `prometheus_url` from `config.py`, both compose
|
||||||
`widget_kind`).
|
files, `.env.example`, and README. (Frontend `VITE_GRAFANA_URL` /
|
||||||
- [x] 3.2 Services API + hooks (`api/services.ts`, `hooks/useServices.ts`).
|
`VITE_PROMETHEUS_URL` deep-link vars retained.)
|
||||||
Reconciled `api/widgets.ts` + `hooks/useWidgets.ts` to the new shape
|
- [x] Added a **Services page** (`/services`) with create/list/delete and a nav
|
||||||
(removed sources/types; added builtin kinds).
|
entry, so service pages are reachable and services are configurable in the
|
||||||
- [x] 3.3 Closed frontend service registry (`integrations/registry.ts`)
|
tool itself.
|
||||||
mirroring the backend; `resolveWidget(widget, services)` maps a widget to
|
- [x] Registered `/services` route in both route trees + sidebar nav.
|
||||||
its component + refresh interval.
|
- [x] Updated `docs/REQUIREMENTS.md` (service registry section) and added
|
||||||
- [x] 3.4 Service page at `/services/:serviceType/:serviceId` with config view,
|
`CHANGELOG.md` with the breaking-upgrade note.
|
||||||
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`.
|
|
||||||
|
|
||||||
### Decision resolved mid-slice
|
### Decision resolved mid-slice
|
||||||
|
|
||||||
Secret edit UX = **empty-on-edit + "set" badge** (blank = keep existing; typing
|
"Full machine migration" was scoped into **4a (cleanup) + 4b (Jellyfin/Jellyseerr
|
||||||
= replace). Applied on the ServicePage secrets card.
|
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`,
|
- Backend: `config.py` (removed grafana_url/prometheus_url).
|
||||||
`integrations/registry.test.ts`, `pages/ServicePage.tsx`.
|
- Compose/env/docs: `docker-compose.yml`, `docker-compose.dev.yml`,
|
||||||
- Modified: `types/index.ts`, `api/widgets.ts`, `hooks/useWidgets.ts`,
|
`.env.example`, `README.md`, `docs/REQUIREMENTS.md`, `CHANGELOG.md` (new).
|
||||||
`components/WidgetInstance.tsx`, `components/WidgetConfigDialog.tsx`,
|
- Frontend: new `pages/ServicesPage.tsx`; `App.tsx` (routes + nav); removed
|
||||||
`pages/Dashboard.tsx`, `pages/__tests__/Dashboard.test.tsx`, `App.tsx`,
|
`pages/AddonPage.tsx`, `addons/*`.
|
||||||
all six `widgets/*.tsx` components, `widgets/index.ts`.
|
|
||||||
- Deleted: `widgets/registry.ts`, `widgets/registry.test.ts`.
|
|
||||||
|
|
||||||
### Verification (Slice 3)
|
### Verification (Slice 4a)
|
||||||
|
|
||||||
```bash
|
```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 lint # 0 errors
|
||||||
npm run build # success
|
npm run build # success
|
||||||
npm run test # 70 passed
|
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
|
## Remaining work
|
||||||
|
|
||||||
- Slice 4: remove addon pages + machine app fields, remove
|
- Slice 4b: add `jellyseerr` service definition; rewire `dependencies.py`
|
||||||
`grafana_url`/`prometheus_url` from config + compose, docs + changelog
|
Jellyfin/Jellyseerr resolution to the service registry; migrate the
|
||||||
(breaking upgrade note).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user