Design-only artifacts for the runtime service registry change. No implementation yet. - proposal: motivation, goals, non-goals, grilling decisions, risks - design: data model, Pydantic service definitions, encryption, API, frontend structure, migration/breaking changes, 4-PR slice plan - tasks: backend foundation, backend widget rebind, frontend services runtime, dashboard + settings rework + docs
15 KiB
Design: Runtime Service Registry
Change: service-registry
Phase: design
Date: 2026-06-19
1. Architecture overview
┌────────────────────────────────────────────────────────────────────┐
│ Browser │
│ /services/:type/:id ─► ServicePage ─► frontend SERVICE_REGISTRY
│ Dashboard ─► WidgetInstance ─► widget component │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ FastAPI /api/services + /api/widgets │
│ CRUD service instances · registry metadata · widget data │
└────────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────┼───────────────────────────┐
▼ ▼ ▼
ServiceStore (SQLite) integrations/ definitions source adapters
services table (Pydantic, closed registry) (resolve service
dashboard_widgets table grafana/prometheus/jellyfin/ → decrypt → call)
nextcloud/ssh_tasks
Two closed, compile-time registries cooperate:
integrations.registry.SERVICE_DEFINITIONSmapsservice_type → ServiceDefinition. Each definition declares config schema, secret fields, and widget kinds.- The widget types available to the dashboard are derived from
SERVICE_DEFINITIONSat startup, not hand-maintained.
2. Backend data model
2.1 New services table
Extend SettingsStore.init_schema():
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
service_type TEXT NOT NULL,
name TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
secrets_json TEXT NOT NULL DEFAULT '{}', -- encrypted blob (Fernet)
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type);
config_json— non-secret config validated against the service definition'sconfig_schema.secrets_json— a JSON object of{field_name: ciphertext}produced by the encryption helper. Never returned to the client in plaintext; only the boolean "is set" flags are surfaced.
2.2 dashboard_widgets schema change
The existing table gains two columns and loses the global meaning of widget_type:
ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT;
ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT;
widget_kindis the kind declared by the service definition (e.g."link","metric","activity").service_idreferencesservices.id.widget_typeis retained temporarily as"{service_type}.{widget_kind}"for backwards-compatible reads during the transition, then dropped in the final slice.- The old
addon_idcolumn is dropped; addon identity is nowservice_type.
3. Service definitions (Pydantic, in repo)
New package: backend/src/media_library_viewer_api/integrations/
(chosen to avoid collision with the existing services/ infra package).
3.1 Base classes — integrations/base.py
from typing import Any, ClassVar
from pydantic import BaseModel, Field
class SecretField(BaseModel):
key: str
label: str
required: bool = False
helper: str | None = None
class WidgetKind(BaseModel):
kind: str # e.g. "link", "metric", "activity"
name: str
description: str
config_schema: dict[str, Any] # JSON schema for widget config
default_config: dict[str, Any] = {}
refresh_interval_ms: int = 0
class ServiceConfigBase(BaseModel):
"""Subclass per service to define non-secret config fields."""
class ServiceDefinition(BaseModel):
service_type: ClassVar[str]
name: ClassVar[str]
description: ClassVar[str]
config_schema: ClassVar[dict[str, Any]]
secret_fields: ClassVar[list[SecretField]]
widget_kinds: ClassVar[list[WidgetKind]]
# Adapters are referenced by dotted path or registered separately;
# see §4. The definition itself stays a pure data/schema object.
3.2 Example — integrations/grafana.py
class GrafanaConfig(ServiceConfigBase):
base_url: str = Field(..., description="Grafana base URL, e.g. https://grafana.example.com")
GRAFANA_DEFINITION = ServiceDefinition(
service_type="grafana",
name="Grafana",
description="Dashboards, metrics, and logs.",
config_schema=GrafanaConfig.model_json_schema(),
secret_fields=[SecretField(key="api_key", label="API key", helper="Service account token")],
widget_kinds=[
WidgetKind(
kind="link",
name="Dashboard link",
description="Deep-link to a Grafana dashboard or panel.",
config_schema={
"type": "object",
"properties": {
"dashboard_uid": {"type": "string"},
"panel_id": {"type": "integer"},
},
"required": ["dashboard_uid"],
},
default_config={"dashboard_uid": ""},
refresh_interval_ms=0,
),
],
)
Other definition modules follow the same shape: prometheus.py, jellyfin.py,
nextcloud.py, ssh_tasks.py.
3.3 Registry — integrations/registry.py
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
"grafana": GRAFANA_DEFINITION,
"prometheus": PROMETHEUS_DEFINITION,
"jellyfin": JELLYFIN_DEFINITION,
"nextcloud": NEXTCLOUD_DEFINITION,
"ssh_tasks": SSH_TASKS_DEFINITION,
}
def list_service_types() -> list[str]: ...
def get_service_definition(service_type: str) -> ServiceDefinition | None: ...
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None: ...
The closed widgets/registry.py from Phase 1 is retired; its metadata is now derived
from SERVICE_DEFINITIONS.
4. Source adapters
widgets/sources.py is refactored so each adapter resolves a service instance rather
than reading get_settings():
class WidgetSource(Protocol):
async def fetch(
self,
service: ServiceRecord, # config + decrypted secrets
widget_kind: str,
config: dict[str, Any],
) -> dict[str, Any]: ...
ServiceRecordis a runtime object built byServiceStorecarrying the decrypted secret dict in memory for the duration of the fetch.SOURCE_ADAPTERSis keyed byservice_type.- The data endpoint loads the widget's
service_id, builds theServiceRecord, then callsadapter.fetch(service, widget_kind, widget_config).
5. Encryption — services/secrets.py
from cryptography.fernet import Fernet, InvalidToken
def get_encryption_key() -> bytes:
raw = os.environ.get("MANAGE_ENCRYPTION_KEY")
if not raw:
raise RuntimeError("MANAGE_ENCRYPTION_KEY is required")
return raw.encode()
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]: ...
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]: ...
cryptography.fernet.Fernet(already a transitive dependency to verify).- Startup validation:
validate_auth_settingsis extended to requireMANAGE_ENCRYPTION_KEYand to reject an obviously invalid key. - Secrets are encrypted field-by-field so the "which secrets are set" metadata is cheap to compute without decrypting.
6. REST API
Services
| Method | Path | Handler |
|---|---|---|
| GET | /api/services/types |
List service definitions (metadata + config schema + widget kinds). |
| GET | /api/services |
List service instances (no plaintext secrets; only "set" flags). |
| POST | /api/services |
Create instance (validates type, config, secret schema). |
| PUT | /api/services/{id} |
Update instance. |
| DELETE | /api/services/{id} |
Delete instance (and refuse while widgets reference it, or cascade). |
Widgets (unchanged paths, new semantics)
| Method | Path | Handler |
|---|---|---|
| GET | /api/widgets/instances |
List widgets; each carries service_id, widget_kind. |
| POST/PUT/DELETE | /api/widgets/instances/{id} |
CRUD; validation uses service definition's widget schema. |
| GET | /api/widgets/instances/{id}/data |
Resolve service → adapter → fetch. |
GET /api/widgets/types and /api/widgets/sources are removed; widget metadata is
served via /api/services/types (widget kinds under each service).
7. Frontend
7.1 New frontend/src/integrations/registry.ts
Closed frontend registry mirroring the backend: serviceType → ServiceDefinition
(config fields, secret fields with secret: true, widget kinds, default refresh
intervals, and a component for the service page).
7.2 Service pages
- Route:
/services/:serviceType/:serviceId(replaces/addons/:addonId). ServicePagelooks up the definition and renders the service-specific component, a config editor, and the list of widget kinds that can be added to the dashboard.App.tsxremoves the/addons/:addonIdroute; old addon URLs redirect to the default service of that type (or a not-found alert).
7.3 Dashboard config dialog
- "Add widget" flow becomes: pick service → pick widget kind → configure.
- The widget card shows the parent service name.
7.4 Types / API / hooks
frontend/src/api/services.ts+hooks/useServices.tsfor the services API.frontend/src/types/index.tsgainsServiceInstance,ServiceInstanceInput,ServiceTypeInfo,ServiceWidgetKind.
8. Migration and breaking changes
- DB migration on startup: add
servicestable; addservice_id/widget_kindcolumns todashboard_widgets; dropaddon_id. - Machine app fields removed:
jellyfin_url,jellyfin_user_id,jellyfin_api_key,jellyseerr_url,jellyseerr_api_keyare dropped from machine records and theMonitoringMachinemodel. Machines keep SSH + node_exporter transport fields only. - Env vars removed from
config.py:grafana_url,prometheus_url. (Grafana/Prometheus URLs now live on service records.)MANAGE_ENCRYPTION_KEYis added as required. - Default widget seeding is removed; a fresh install starts with no widgets. The user adds Jellyfin/Backups widgets after configuring the corresponding services.
docs/REQUIREMENTS.mdandREADME.mdupdated to describe services, theMANAGE_ENCRYPTION_KEYrequirement, and the breaking upgrade note.
9. File-level plan
Create (backend)
| File | Purpose |
|---|---|
integrations/__init__.py |
Package marker. |
integrations/base.py |
ServiceDefinition, WidgetKind, SecretField, ServiceConfigBase. |
integrations/registry.py |
Closed SERVICE_DEFINITIONS + helpers. |
integrations/grafana.py, prometheus.py, jellyfin.py, nextcloud.py, ssh_tasks.py |
One module per service. |
services/secrets.py |
Fernet encrypt/decrypt + key validation. |
services/service_store.py |
CRUD for services table; decrypt-on-read for adapters. |
routers/services.py |
/api/services* endpoints. |
models/services.py |
Pydantic request/response models. |
Modify (backend)
| File | Change |
|---|---|
services/settings_store.py |
services table; widget columns; drop machine app fields. |
widgets/sources.py |
Adapters take a ServiceRecord. |
widgets/registry.py |
Retired (metadata served by integrations/registry.py). |
routers/widgets.py |
Validate against service widget schema; resolve service on data fetch. |
config.py |
Remove grafana_url/prometheus_url; document MANAGE_ENCRYPTION_KEY (read in secrets.py). |
main.py |
Register services_router; validate encryption key on startup. |
dependencies.py |
Jellyfin/SSH resolution now goes via services, not machine app fields. |
Create (frontend)
| File | Purpose |
|---|---|
integrations/registry.ts |
Closed frontend service registry. |
api/services.ts, hooks/useServices.ts |
Services API + hooks. |
pages/ServicePage.tsx |
Generic /services/:type/:id page. |
integrations/components/* |
Per-service page components. |
Modify (frontend)
| File | Change |
|---|---|
App.tsx |
Replace /addons/:addonId with /services/:serviceType/:serviceId. |
components/WidgetConfigDialog.tsx |
Service → widget-kind picker. |
widgets/registry.ts |
Retired; widgets derived from service registry. |
types/index.ts |
Service types; widget gains service_id + widget_kind. |
pages/Settings.tsx |
Remove machine Jellyfin/Jellyseerr fields. |
10. Slice boundaries (chained PRs)
Each slice keeps pytest, ruff, npm run lint, and npm run build green.
- Backend foundation — encryption helper,
integrations/base + 5 definitions + registry,servicestable + store,/api/services*endpoints, tests. No widget changes yet. - Backend widget rebind — add
service_id/widget_kindto widgets, refactor adapters to take aServiceRecord, retire oldwidgets/registry.py, update data endpoint. - Frontend services runtime — types, API, hooks,
integrations/registry.ts, service pages, route swap, remove addon pages. - Frontend dashboard + settings rework — service-based widget picker, drop machine
app fields from Settings, remove
grafana_url/prometheus_urlfrom config, re-seed behavior, docs (README.md,REQUIREMENTS.md), changelog breaking-change note.
Estimated total: ~2,000–2,400 changed lines across four PRs.
11. Open questions to resolve before apply
- Should deleting a service that still has widgets block (return 409) or cascade-delete the widgets? Recommend block with 409 and require the user to remove widgets first.
- Should
MANAGE_ENCRYPTION_KEYhave a development default (e.g. derived from a fixed dev key whenAUTH_ENABLED=false)? Recommend no — require it always to avoid accidental plaintext in dev. - Does the SSH task runner service hold the SSH key reference, or does it reference a
machine? Recommend the service record holds
machine_id(transport) + optional task-scoped overrides; the saved-task registry stays unchanged.