Service IA refinement: nav naming, instance tabs, config to Settings, configurable Overview

Four coupled changes to the services-as-hub IA:

1. Nav entries use service TYPE names (Jellyfin, SSH Tasks, Alertmanager,
   Grafana, Prometheus, Backups, Authentik) instead of conceptual names
   (Media, Files, Actions, Alerts, Users). ssh_tasks collapses to one
   entry ('SSH Tasks') instead of two. The content tabs inside each
   service page surface the concepts (Files, Actions).

2. Service page gains a two-level tab structure when multiple enabled
   instances of the same type exist: instance tabs on top ([Main Jellyfin]
   [Backup Jellyfin]), content tabs below ([Overview] [Media] [Requests]
   [Widgets]). Clicking an instance tab navigates to the sibling's route.
   Single instance: no instance tabs. Replaces the dropdown switcher.

3. Config tab (connection fields, secrets, enable/disable, delete) moves
   from the service page to Settings > Services tab. The service page
   becomes a PURE operational view (Overview + content tabs + Widgets) --
   no save/delete/config state. Settings gains a 4th tab 'Services' with
   ServiceConfigEditor per instance (schema-driven config fields, secrets
   with leave-blank-to-keep semantics, ConfirmDialog on delete).

4. Overview tab is now a configurable widget grid per service instance.
   Each instance manages its own set of widgets on its Overview. Backend
   widget list endpoints gain ?service_id= and ?scope= (dashboard|service)
   filter params; the main Dashboard uses scope=dashboard to exclude
   service-scoped widgets. The OverviewTab reuses WidgetInstanceCard +
   WidgetConfigDialog. Empty state CTA for instances with no widgets.

All service-tab stubs are replaced; stubs.tsx deleted.

272 backend tests pass (+1 widget filter); 121 frontend tests pass (+3
instance-tabs + OverviewTab); lint/build green both sides.
This commit is contained in:
Developer
2026-06-26 22:25:46 +00:00
parent fef0ded76f
commit 8d2e4c9bfd
17 changed files with 812 additions and 422 deletions
@@ -103,10 +103,18 @@ def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
@router.get("/instances")
def list_instances(
service_id: str | None = None,
scope: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""Return all persisted widget instances."""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
"""Return widget instances, optionally filtered.
- ``?service_id=X``: only widgets for service X.
- ``?scope=dashboard``: only widgets with NULL service_id.
- ``?scope=service``: only widgets with a non-null service_id.
- No params: all widgets (backward-compatible).
"""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
@router.post("/instances", status_code=status.HTTP_201_CREATED)
@@ -1403,10 +1403,36 @@ class SettingsStore:
"sort_order": sort_order,
}
def list_widgets(self) -> list[dict[str, Any]]:
def list_widgets(
self,
service_id: str | None = None,
*,
scope: str | None = None,
all_widgets: bool = True,
) -> list[dict[str, Any]]:
"""List widget instances, optionally filtered.
- ``service_id=X``: only widgets for service X.
- ``scope="dashboard"``: only widgets with NULL service_id.
- ``scope="service"``: only widgets with a non-null service_id.
- ``all_widgets=True, service_id=None, scope=None``: all widgets.
"""
self.init_schema()
clauses: list[str] = []
params: list[Any] = []
if service_id is not None:
clauses.append("service_id = ?")
params.append(service_id)
if scope == "dashboard":
clauses.append("service_id IS NULL")
elif scope == "service":
clauses.append("service_id IS NOT NULL")
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as conn:
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
rows = conn.execute(
f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC",
params,
).fetchall()
return [self._row_to_widget(row) for row in rows]
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
+38
View File
@@ -90,6 +90,44 @@ def test_create_backups_widget(client):
assert response.status_code == 201
def test_widget_filtering_by_service_id_and_scope(client):
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
service = _make_grafana_service(client)
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
)
client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "Dash",
"config": {"dashboard_uid": "o"},
},
)
# No filter: both widgets.
all_widgets = client.get("/api/widgets/instances").json()
assert len(all_widgets) == 2
# Filter by service_id: only the service-scoped one.
by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
assert len(by_service) == 1
assert by_service[0]["service_id"] == service["id"]
# scope=dashboard: only the built-in (NULL service_id).
dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
assert len(dashboard_scope) == 1
assert dashboard_scope[0]["service_id"] is None
# scope=service: only the non-null service_id widget.
service_scope = client.get("/api/widgets/instances?scope=service").json()
assert len(service_scope) == 1
assert service_scope[0]["service_id"] == service["id"]
def test_unknown_builtin_kind_rejected(client):
response = client.post(
"/api/widgets/instances",