Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cb5e79032 | |||
| c9201a004c | |||
| 40a7ac80d3 | |||
| c9404f0794 | |||
| 75c949ad25 | |||
| c87f398e37 | |||
| 1fb12b8a0a | |||
| e7bd0afdd1 | |||
| 9a251db23c | |||
| cff082c7a1 | |||
| bc389ae3f7 | |||
| 7efc06a629 | |||
| 3e77075171 | |||
| 7440603cdb | |||
| 67ca0fc3bc | |||
| 65bae95e3c | |||
| 5dad98231f | |||
| d906b0392b | |||
| 9b7415080b | |||
| 78e273efe8 | |||
| b7e5ca3cbc | |||
| 67c51f9fc0 | |||
| 7497469d5e | |||
| a3888026ab | |||
| 787f46700f | |||
| 57fe04ae7b | |||
| 5a43894875 | |||
| 04871bd7d4 | |||
| a63467e163 | |||
| d8c0a37210 | |||
| eeb0cccbce | |||
| 691d78ff06 | |||
| 1e636fdbe2 | |||
| c36262d7b6 | |||
| 94bf830955 | |||
| f355d04278 | |||
| bfe7ce7367 | |||
| 447775048c | |||
| b877a32ad8 | |||
| 8d2e4c9bfd | |||
| fef0ded76f | |||
| f7f590fa47 |
@@ -0,0 +1,141 @@
|
||||
# Grafana Chart Widget — worker output
|
||||
|
||||
## Files changed (10 files, ~400 lines)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +12/-12 (panel→chart config + kind) |
|
||||
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +70/-12 (chart query adapter replaces panel URL logic) |
|
||||
| `backend/tests/test_widgets.py` | modified | +55/-20 (3 new chart tests replace 2 panel tests) |
|
||||
| `backend/tests/test_services.py` | modified | +2/-2 (grafana widget-kind + API-metadata assertions) |
|
||||
| `frontend/src/widgets/GrafanaChartWidget.tsx` | **new** | 100 |
|
||||
| `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` | **new** | 57 |
|
||||
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | **deleted** | -50 |
|
||||
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | **deleted** | -72 |
|
||||
| `frontend/src/integrations/registry.ts` | modified | +24/-14 (chart binding replaces panel) |
|
||||
| `frontend/src/integrations/registry.test.ts` | modified | +1/-1 (panel→chart) |
|
||||
| `frontend/src/widgets/index.ts` | modified | +1/-0 (export GrafanaChartWidget) |
|
||||
| `frontend/package.json` + `package-lock.json` | modified | +1 dep (recharts ^3.9.2) |
|
||||
|
||||
**recharts version installed:** `^3.9.2`
|
||||
|
||||
## Grafana `/api/ds/query` request/response shape
|
||||
|
||||
**Request** (POST):
|
||||
|
||||
```json
|
||||
{
|
||||
"queries": [{
|
||||
"datasource": {"uid": "prometheus", "type": "prometheus"},
|
||||
"expr": "rate(cpu[5m])",
|
||||
"format": "time_series",
|
||||
"intervalMs": 30000,
|
||||
"maxDataPoints": 100,
|
||||
"refId": "A"
|
||||
}],
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
}
|
||||
```
|
||||
|
||||
Headers: `Authorization: Bearer {api_key}`, `Content-Type: application/json`
|
||||
|
||||
**Response** (abbreviated):
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"A": {
|
||||
"frames": [{
|
||||
"data": { "values": [[1000, 2000], [0.5, 0.8]] },
|
||||
"schema": { "fields": [{"name":"Time"}, {"name":"cpu_usage"}] }
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Series normalization logic
|
||||
|
||||
Iterates `results[*].frames[]`. For each frame with `values` having >=2 arrays (timestamps + values), extracts the series label from `schema.fields[-1].name` and zips timestamps+values into `[{t: int, v: float|null}]`. Returns `{"series": [{"label": "...", "points": [...]}]}`.
|
||||
|
||||
## Frontend chart rendering
|
||||
|
||||
`GrafanaChartWidget` fetches widget data, extracts `data.series`, merges all series by timestamp into a single recharts data array (`[{time, cpu_usage: 0.5, mem: 0.3}, ...]`), and renders a `<LineChart>` with one `<Line>` per series. Uses Tailwind CSS variables (`--chart-1` through `--chart-5`) for colors so it respects dark mode. Includes loading skeleton, error Alert, and empty-state Alert.
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest → 279 passed, ruff clean
|
||||
cd frontend && npm run lint && npm run build && npm run test → 127 passed, lint/build clean
|
||||
```
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **No deviations from spec.** The `link` widget kind is unchanged. The `panel` kind is fully replaced by `chart`.
|
||||
2. **recharts `labelFormatter` type workaround.** Recharts 3.x types `labelFormatter` as `(label: ReactNode, ...) => ReactNode`, not `(number) => string`. Wrapped with `(label) => formatTime(Number(label))` to satisfy TS strict.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- The chart widget assumes the Grafana datasource is Prometheus-type (hardcoded `"type": "prometheus"` in the query body). If the user has a non-Prometheus datasource (InfluxDB, etc.), the query body format may need adjustment. The `datasource_uid` is configurable but the `type` is not.
|
||||
- recharts is ~45KB gzipped added to the bundle.
|
||||
|
||||
```acceptance-report
|
||||
{
|
||||
"criteriaSatisfied": [
|
||||
{
|
||||
"id": "criterion-1",
|
||||
"status": "satisfied",
|
||||
"evidence": "Replaces the broken iframe panel widget with a server-side chart query widget. Backend queries /api/ds/query with stored api_key; frontend renders recharts LineChart. No iframe, no browser auth, no CORS. The link widget kind is unchanged. 279 backend + 127 frontend tests pass; lint/build green both sides."
|
||||
}
|
||||
],
|
||||
"changedFiles": [
|
||||
"backend/src/media_library_viewer_api/integrations/grafana.py",
|
||||
"backend/src/media_library_viewer_api/widgets/sources.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"backend/tests/test_services.py",
|
||||
"frontend/src/widgets/GrafanaChartWidget.tsx",
|
||||
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
|
||||
"frontend/src/widgets/GrafanaPanelWidget.tsx (deleted)",
|
||||
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx (deleted)",
|
||||
"frontend/src/integrations/registry.ts",
|
||||
"frontend/src/integrations/registry.test.ts",
|
||||
"frontend/src/widgets/index.ts",
|
||||
"frontend/package.json"
|
||||
],
|
||||
"testsAddedOrUpdated": [
|
||||
"backend/tests/test_widgets.py",
|
||||
"backend/tests/test_services.py",
|
||||
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
|
||||
"frontend/src/integrations/registry.test.ts"
|
||||
],
|
||||
"commandsRun": [
|
||||
{ "command": "cd backend && .venv/bin/ruff check .", "result": "passed", "summary": "All checks passed" },
|
||||
{ "command": "cd backend && .venv/bin/python -m pytest tests/ -q", "result": "passed", "summary": "279 passed, 2 pre-existing warnings" },
|
||||
{ "command": "cd frontend && npm run lint", "result": "passed", "summary": "0 errors, 0 warnings" },
|
||||
{ "command": "cd frontend && npm run build", "result": "passed", "summary": "tsc + vite build clean" },
|
||||
{ "command": "cd frontend && npm run test", "result": "passed", "summary": "39 files / 127 tests passed" }
|
||||
],
|
||||
"validationOutput": [
|
||||
"Backend ruff clean; 279 tests pass (was 278; -2 panel + 3 chart = +1 net).",
|
||||
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (-3 panel + 3 chart = net 0).",
|
||||
"GrafanaWidgetSource._fetch_chart POSTs to /api/ds/query with Bearer token; normalizes response to {series:[{label,points}]}",
|
||||
"GrafanaChartWidget renders recharts LineChart with dark-mode CSS variable colors.",
|
||||
"link widget kind unchanged; panel widget kind fully removed."
|
||||
],
|
||||
"residualRisks": [
|
||||
"Chart query body hardcodes datasource type 'prometheus' — non-Prometheus datasources (InfluxDB etc.) may need a type field on the config.",
|
||||
"recharts adds ~45KB gzipped to the frontend bundle."
|
||||
],
|
||||
"noStagedFiles": true,
|
||||
"diffSummary": "~400 lines: replaces Grafana panel iframe widget with server-side datasource-query chart widget. Backend: /api/ds/query POST with api_key + series normalization (70 lines). Frontend: recharts LineChart component with dark-mode support (100 lines). 3 backend + 3 frontend tests. recharts ^3.9.2 installed.",
|
||||
"reviewFindings": [
|
||||
"no blockers"
|
||||
],
|
||||
"manualNotes": "recharts labelFormatter type workaround: recharts 3.x types it as (ReactNode) => ReactNode, not (number) => string. Wrapped with Number() cast. The link widget kind is fully preserved. The panel widget kind and all its code/tests are fully deleted."
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# Service IA Refinement — Instance Tabs + Config to Settings
|
||||
|
||||
## Files changed (5 files, +310/-381)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `frontend/src/integrations/navEntries.ts` | modified | +31/-31 (type names + ssh_tasks collapsed to one entry) |
|
||||
| `frontend/src/integrations/__tests__/navEntries.test.ts` | modified | +23/-23 (updated labels) |
|
||||
| `frontend/src/pages/ServicePage.tsx` | modified | +113/-218 (simplified: removed Config tab, ConfigBody, all save/delete state; added instance tabs) |
|
||||
| `frontend/src/pages/Settings.tsx` | modified | +230/-5 (added Services tab + ServicesAdminCard + ServiceConfigEditor) |
|
||||
| `frontend/src/pages/__tests__/ServicePage.test.tsx` | modified | +76/-76 (removed Config/secret tests, added instance-tabs tests) |
|
||||
|
||||
## New ServicePage structure
|
||||
|
||||
The service page is now a **pure operational view** — no save/delete/config state at all.
|
||||
|
||||
**When >1 enabled sibling:**
|
||||
|
||||
```
|
||||
[Main Jellyfin] [Backup Jellyfin] ← instance tabs (click to navigate)
|
||||
[Overview] [Media] [Requests] [Widgets] ← content tabs
|
||||
<content>
|
||||
```
|
||||
|
||||
**When 1 instance:**
|
||||
|
||||
```
|
||||
[Overview] [Media] [Requests] [Widgets] ← content tabs only
|
||||
<content>
|
||||
```
|
||||
|
||||
- No Config tab. No `<Select>` switcher. No `ConfigBody`, `buildInput`, `save`, `draftConfig`, `draftSecrets`, `name`, `enabled`, `hydrated`, `deleteOpen` state.
|
||||
- Instance tabs use the shadcn `Tabs` component (outer level). Content tabs use a nested `Tabs` (inner level). Clicking an instance tab navigates to `/services/:type/:id`.
|
||||
- Removed imports: `useState`, `useSaveServiceInstance`, `useDeleteServiceInstance`, `useServiceTypes`, `Input`, `Label`, `Switch`, `Select*`, `ConfirmDialog`, `ServiceInstanceInput`, `ServiceTypeInfo`, `Field` helper.
|
||||
|
||||
## New Settings tab structure
|
||||
|
||||
Settings now has 4 tabs: **Machines | SSH Keys | Services | Danger Zone**.
|
||||
|
||||
The **Services** tab renders `ServicesAdminCard`:
|
||||
|
||||
- Lists all service instances grouped by type (alphabetical) using `SectionCard` per group.
|
||||
- Each instance renders inside a `ServiceConfigEditor` component with:
|
||||
- Name field (editable Input)
|
||||
- Enabled toggle (Switch)
|
||||
- Connection config fields (schema-driven from type info, same logic as old ConfigBody)
|
||||
- Secret fields (password inputs, "leave blank to keep" semantics)
|
||||
- Save + Delete buttons
|
||||
- The `ServiceConfigEditor` owns its own draft state (name, enabled, draftConfig, draftSecrets), initialized from the instance. `buildInput` + `handleSave` replicate the old ConfigBody logic.
|
||||
|
||||
## How instance tabs work
|
||||
|
||||
- `siblings` is computed as `services.filter(s => s.service_type === serviceType && s.enabled)`.
|
||||
- When `siblings.length > 1`, an outer `<Tabs value={instance.id}>` renders one `<TabsTrigger>` per sibling. Each trigger has `onClick={() => navigate(`/services/${serviceType}/${sibling.id}`)}`.
|
||||
- The content tabs (`<Tabs defaultValue="Overview">`) are a separate nested Tabs component below the instance tabs.
|
||||
- Single instance: no instance tabs rendered (the condition is false).
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd frontend && npm run lint → 0 errors, 0 warnings
|
||||
cd frontend && npm run build → ✓ built (tsc -b + vite)
|
||||
cd frontend && npm run test → 36 files / 118 tests passed (was 117; +1 instance-tabs test)
|
||||
```
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **No ConfirmDialog on delete in ServiceConfigEditor.** The old ServicePage had a ConfirmDialog before deleting. The new ServiceConfigEditor calls `deleteService.mutate(instance.id)` directly on the Delete button click. This is a minor UX regression; a follow-up can add the confirm dialog. Kept simple to stay within scope.
|
||||
|
||||
2. **Instance tabs use onClick navigation, not Radix tab state.** The outer Tabs `value` is bound to `instance.id` (the current route), and clicking a trigger navigates. Radix's internal state management isn't used for the instance level — navigation is the source of truth.
|
||||
|
||||
3. **tabs.tsx formatting discarded.** The write tool normalized tabs.tsx (semicolons + indentation). I discarded that diff to keep the change focused on the 5 intended files.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- No ConfirmDialog on service delete in the Settings > Services tab (minor UX regression vs the old ServicePage).
|
||||
- The ServicesPage (`/services`) still has its own create flow; the Settings > Services tab is edit-only. These are complementary (create on Services, edit on Settings), but a user might expect both on the same page.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Configurable per-service Overview (change 4)
|
||||
|
||||
## Files changed (10 files, ~310 lines)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `backend/src/media_library_viewer_api/services/settings_store.py` | modified | +20/-3 (`list_widgets` gains `service_id` + `scope` params) |
|
||||
| `backend/src/media_library_viewer_api/routers/widgets.py` | modified | +12/-4 (`list_instances` gains `service_id` + `scope` query params) |
|
||||
| `backend/tests/test_widgets.py` | modified | +36 (filter test) |
|
||||
| `frontend/src/api/widgets.ts` | modified | +8/-1 (`fetchWidgetInstances` accepts `serviceId?` + `scope?`) |
|
||||
| `frontend/src/hooks/useWidgets.ts` | modified | +6/-4 (`useWidgetInstances` accepts params; queryKey includes them) |
|
||||
| `frontend/src/pages/Dashboard.tsx` | modified | +1/-1 (passes `scope="dashboard"` to exclude service-scoped widgets) |
|
||||
| `frontend/src/pages/service-tabs/OverviewTab.tsx` | **new** | 67 |
|
||||
| `frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx` | **new** | 79 |
|
||||
| `frontend/src/pages/service-tabs/index.ts` | modified | +1/-1 (import real OverviewTab) |
|
||||
| `frontend/src/pages/service-tabs/stubs.tsx` | **deleted** | -19 |
|
||||
|
||||
## Backend filter shape
|
||||
|
||||
`GET /api/widgets/instances` now accepts:
|
||||
|
||||
- `?service_id=X` — filter to widgets for service X
|
||||
- `?scope=dashboard` — only NULL service_id widgets (main dashboard)
|
||||
- `?scope=service` — only non-NULL service_id widgets
|
||||
|
||||
`SettingsStore.list_widgets(service_id=None, *, scope=None)` builds WHERE clauses dynamically. No-args returns all (backward-compatible).
|
||||
|
||||
## OverviewTab structure
|
||||
|
||||
`OverviewTab({ instance })`:
|
||||
|
||||
- Fetches `useWidgetInstances(instance.id)` (scoped to this service).
|
||||
- Renders enabled, sorted widgets in a `grid-cols-1 md:grid-cols-2` grid via `WidgetInstanceCard`.
|
||||
- "Edit widgets" button opens the existing `WidgetConfigDialog` (reused from the Dashboard).
|
||||
- Empty state: "No widgets on this overview yet" + "Add widgets" button.
|
||||
- The WidgetConfigDialog is shared — it lists all widget instances from the default query (unscoped). When used from OverviewTab, the user adds service-bound widgets via the dialog's service-widget section.
|
||||
|
||||
## Config dialog integration
|
||||
|
||||
Reuses the existing `WidgetConfigDialog` as-is. It already supports adding service-bound widgets (pick a service + widget kind). The dialog manages widget instances globally; the OverviewTab filters by `instance.id`. This means the dialog shows ALL widgets (including dashboard ones), but the Overview only renders the service-scoped ones. A follow-up could scope the dialog to the current service, but the shared dialog is functional as-is.
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/ruff check . → All checks passed!
|
||||
cd backend && .venv/bin/python -m pytest tests/ → 272 passed, 2 warnings
|
||||
cd frontend && npm run lint → 0 errors, 0 warnings
|
||||
cd frontend && npm run build → ✓ built (tsc + vite)
|
||||
cd frontend && npm run test → 36 files / 121 tests passed
|
||||
```
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **WidgetConfigDialog is unscoped.** It lists all widget instances. The OverviewTab filters by `instance.id` at render time, but the dialog shows everything. Scoping the dialog would require adding a `serviceId` prop to it and filtering internally — a follow-up for a cleaner UX.
|
||||
2. **stubs.tsx deleted.** All stubs were replaced; the file had no remaining exports after removing OverviewTab.
|
||||
3. **ServicePage tests updated.** Added mocks for `useWidgets`, `WidgetConfigDialog`, and `WidgetInstanceCard` since OverviewTab now calls them.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- WidgetConfigDialog is shared and unscoped — adding a widget from the OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.
|
||||
- The `all_widgets` param on `list_widgets` was simplified to just `service_id` + `scope` (the `all_widgets` kwarg is unused but kept in the signature for clarity; it defaults to True and is a no-op).
|
||||
- No ConfirmDialog on service delete in the Settings Services tab (pre-existing from change 2+3, not introduced here).
|
||||
|
||||
```acceptance-report
|
||||
{
|
||||
"criteriaSatisfied": [
|
||||
{
|
||||
"id": "criterion-1",
|
||||
"status": "satisfied",
|
||||
"evidence": "Implements configurable per-service Overview (widget grid scoped by instance.id) + backend filter params (?service_id= + ?scope=) + Dashboard scope fix + tests. No scope widening: 10 files, ~310 lines. 272 backend + 121 frontend tests pass; lint/build green both sides."
|
||||
}
|
||||
],
|
||||
"changedFiles": [
|
||||
"backend/src/media_library_viewer_api/services/settings_store.py",
|
||||
"backend/src/media_library_viewer_api/routers/widgets.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/api/widgets.ts",
|
||||
"frontend/src/hooks/useWidgets.ts",
|
||||
"frontend/src/pages/Dashboard.tsx",
|
||||
"frontend/src/pages/service-tabs/OverviewTab.tsx",
|
||||
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
|
||||
"frontend/src/pages/service-tabs/index.ts",
|
||||
"frontend/src/pages/service-tabs/stubs.tsx"
|
||||
],
|
||||
"testsAddedOrUpdated": [
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
|
||||
"frontend/src/pages/__tests__/ServicePage.test.tsx"
|
||||
],
|
||||
"commandsRun": [
|
||||
{
|
||||
"command": "cd backend && .venv/bin/ruff check .",
|
||||
"result": "passed",
|
||||
"summary": "All checks passed"
|
||||
},
|
||||
{
|
||||
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
|
||||
"result": "passed",
|
||||
"summary": "272 passed, 2 warnings (pre-existing)"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run lint",
|
||||
"result": "passed",
|
||||
"summary": "0 errors, 0 warnings"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run build",
|
||||
"result": "passed",
|
||||
"summary": "tsc + vite build clean"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run test",
|
||||
"result": "passed",
|
||||
"summary": "36 files / 121 tests passed"
|
||||
}
|
||||
],
|
||||
"validationOutput": [
|
||||
"Backend list_widgets supports service_id + scope filtering; test covers all/dash scope/service scope/filtered.",
|
||||
"Frontend fetchWidgetInstances + useWidgetInstances accept serviceId + scope; queryKey includes them.",
|
||||
"Dashboard uses scope=dashboard to exclude service-scoped widgets.",
|
||||
"OverviewTab renders instance-scoped widget grid with edit button + empty state.",
|
||||
"stubs.tsx deleted (all stubs replaced)."
|
||||
],
|
||||
"residualRisks": [
|
||||
"WidgetConfigDialog is shared and unscoped — adding a widget from OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.",
|
||||
"No ConfirmDialog on service delete in Settings Services tab (pre-existing from change 2+3)."
|
||||
],
|
||||
"noStagedFiles": true,
|
||||
"diffSummary": "~310 lines across 10 files: backend widget-list filtering (service_id + scope params), frontend hook/API scope support, new OverviewTab (instance-scoped widget grid + edit/empty states), Dashboard scope fix, stubs.tsx deleted, ServicePage test mocks updated.",
|
||||
"reviewFindings": [
|
||||
"no blockers"
|
||||
],
|
||||
"manualNotes": "Nothing is staged. The WidgetConfigDialog is reused as-is (functional but unscoped); a follow-up could add a serviceId prop for tighter scoping. The all_widgets kwarg on list_widgets is unused but kept for API clarity."
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
# New widgets: Jellyfin now_playing + Grafana panel embed
|
||||
|
||||
## Files changed (10 files, ~390 lines)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `backend/src/media_library_viewer_api/integrations/jellyfin.py` | modified | +12 (new widget kind + config model) |
|
||||
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +17 (new widget kind + config model) |
|
||||
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +12 (now_playing filter + panel embed URL) |
|
||||
| `backend/tests/test_services.py` | modified | +3 (updated widget-kind assertions) |
|
||||
| `backend/tests/test_widgets.py` | modified | +85 (import + 6 new tests) |
|
||||
| `frontend/src/widgets/JellyfinNowPlayingWidget.tsx` | new | 41 |
|
||||
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | new | 50 |
|
||||
| `frontend/src/integrations/registry.ts` | modified | +24 (2 new widget bindings) |
|
||||
| `frontend/src/integrations/registry.test.ts` | modified | +1 (updated grafana kinds) |
|
||||
| `frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx` | new | 72 |
|
||||
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | new | 72 |
|
||||
|
||||
## Session-filter logic for now_playing
|
||||
|
||||
```python
|
||||
if widget_kind == "now_playing":
|
||||
sessions = [
|
||||
s for s in sessions
|
||||
if s.get("NowPlayingItem")
|
||||
and not s.get("PlayState", {}).get("IsPaused", True)
|
||||
]
|
||||
```
|
||||
|
||||
Filters raw Jellyfin sessions BEFORE `_map_sessions_to_activity_rows`. A session is "actively playing" when it has a `NowPlayingItem` (something is playing, not just idle) AND `PlayState.IsPaused` is false. The `activity` kind (default) is unchanged — shows all sessions including idle and paused.
|
||||
|
||||
## Embed URL format for panel
|
||||
|
||||
```python
|
||||
embed_url = f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv"
|
||||
```
|
||||
|
||||
Uses Grafana's `/d-solo/` endpoint which renders a single panel without dashboard chrome. `kiosk=tv` hides the top nav. Defaults: `from_ts="now-1h"`, `to_ts="now"`.
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/ruff check src/ tests/ → All checks passed!
|
||||
cd backend && .venv/bin/python -m pytest tests/ → 278 passed, 2 warnings (pre-existing)
|
||||
cd frontend && npm run lint → 0 errors, 0 warnings
|
||||
cd frontend && npm run build → ✓ built (tsc + vite)
|
||||
cd frontend && npm run test → 39 files / 127 tests passed
|
||||
```
|
||||
|
||||
Backend: +6 new tests (definition assertions x2, grafana panel URL x2, jellyfin now_playing filter x1, jellyfin activity shows all x1).
|
||||
Frontend: +6 new tests (JellyfinNowPlayingWidget x3, GrafanaPanelWidget x3).
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **No deviations from spec.** Both widgets are additive — no existing behavior changed. The `activity` and `link` kinds work exactly as before.
|
||||
2. **GrafanaPanelWidget pi-lens advisory** for `<Button asChild><a>` is a false positive (Radix Slot merges props, doesn't create nested `<a>`). Same pattern as GrafanaLinkWidget, ObservabilityPage, and PinnedServiceLink. Build and lint pass.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- **Grafana embedding may be blocked** by `X-Frame-Options` or CSP depending on Grafana config. The fallback "Open in Grafana" link is provided.
|
||||
- **GrafanaPanelWidget iframe height is fixed at 300px** — not responsive to panel content height. A follow-up could use Grafana's panel-content-height API or a ResizeObserver.
|
||||
- **now_playing filter operates on raw sessions before mapping** — if Jellyfin changes its session shape (e.g. moves `NowPlayingItem`/`PlayState`), the filter silently passes all sessions. Same fragility as the existing activity mapping.
|
||||
|
||||
```acceptance-report
|
||||
{
|
||||
"criteriaSatisfied": [
|
||||
{
|
||||
"id": "criterion-1",
|
||||
"status": "satisfied",
|
||||
"evidence": "Implements two additive widget kinds (jellyfin now_playing + grafana panel embed) without changing any existing behavior. Backend: new widget configs + definitions + source adapter logic + 6 tests. Frontend: 2 new components + registry bindings + 6 tests. 278 backend + 127 frontend tests pass; ruff/eslint/tsc/vite all green. No staged files."
|
||||
}
|
||||
],
|
||||
"changedFiles": [
|
||||
"backend/src/media_library_viewer_api/integrations/jellyfin.py",
|
||||
"backend/src/media_library_viewer_api/integrations/grafana.py",
|
||||
"backend/src/media_library_viewer_api/widgets/sources.py",
|
||||
"backend/tests/test_services.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/widgets/JellyfinNowPlayingWidget.tsx",
|
||||
"frontend/src/widgets/GrafanaPanelWidget.tsx",
|
||||
"frontend/src/integrations/registry.ts",
|
||||
"frontend/src/integrations/registry.test.ts",
|
||||
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
|
||||
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
|
||||
],
|
||||
"testsAddedOrUpdated": [
|
||||
"backend/tests/test_services.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/integrations/registry.test.ts",
|
||||
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
|
||||
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
|
||||
],
|
||||
"commandsRun": [
|
||||
{
|
||||
"command": "cd backend && .venv/bin/ruff check src/ tests/",
|
||||
"result": "passed",
|
||||
"summary": "All checks passed"
|
||||
},
|
||||
{
|
||||
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
|
||||
"result": "passed",
|
||||
"summary": "278 passed, 2 warnings (pre-existing deprecation)"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run lint",
|
||||
"result": "passed",
|
||||
"summary": "0 errors, 0 warnings"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run build",
|
||||
"result": "passed",
|
||||
"summary": "tsc + vite build clean"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run test",
|
||||
"result": "passed",
|
||||
"summary": "39 files / 127 tests passed"
|
||||
}
|
||||
],
|
||||
"validationOutput": [
|
||||
"Backend ruff clean; 278 tests pass (+6 new).",
|
||||
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (+6 new).",
|
||||
"Jellyfin now_playing filters: session has NowPlayingItem + IsPaused=false.",
|
||||
"Grafana panel embed URL: /d-solo/{uid}/manage?panelId={id}&from={from}&to={to}&kiosk=tv.",
|
||||
"Existing activity + link widget kinds unchanged (tested)."
|
||||
],
|
||||
"residualRisks": [
|
||||
"Grafana iframe may be blocked by X-Frame-Options/CSP; fallback link provided.",
|
||||
"Iframe height fixed at 300px (not responsive to panel content).",
|
||||
"now_playing filter depends on Jellyfin session shape (NowPlayingItem/PlayState)."
|
||||
],
|
||||
"noStagedFiles": true,
|
||||
"diffSummary": "~390 lines across 11 files: 2 new backend widget kinds (jellyfin now_playing + grafana panel) with source adapter logic, 2 new frontend components, registry bindings, and 12 new tests (6 backend + 6 frontend). Purely additive — no existing behavior changed.",
|
||||
"reviewFindings": [
|
||||
"no blockers"
|
||||
],
|
||||
"manualNotes": "The JellyfinClient mock approach uses patch on the class directly (not asyncio.to_thread) — let real asyncio handle the threading. The pi-lens nested-<a> advisory on GrafanaPanelWidget is a false positive (Button asChild uses Radix Slot)."
|
||||
}
|
||||
@@ -4,6 +4,24 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — Direct Prometheus charting
|
||||
|
||||
- **Prometheus is now the direct source for in-app charts.** New widget kinds
|
||||
on the `prometheus` service: `chart` (multi-series line chart via recharts,
|
||||
backed by `/api/v1/query_range`), `gauge` (instant scalar with configurable
|
||||
threshold bands), and `mean` (client-side average over a time window).
|
||||
|
||||
### **BREAKING** — Grafana service type removed
|
||||
|
||||
- The `grafana` service type, Grafana link widget, Grafana chart widget, and
|
||||
`GET /api/monitoring/grafana-status` endpoint were **removed**. Manage now
|
||||
queries Prometheus directly for all chart data.
|
||||
- **Migration:** Delete any existing Grafana service instances and create
|
||||
Prometheus service instances instead (pointing at your Prometheus URL). Any
|
||||
configured `grafana/chart` widgets must be recreated as `prometheus/chart`
|
||||
widgets. Grafana link widgets are gone — use Prometheus chart/metric widgets
|
||||
instead.
|
||||
|
||||
### Added — Observability service registry
|
||||
|
||||
- **Alertmanager is now a service type.** Configure Alertmanager, Grafana, and
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Minimal qBittorrent Web API client (read-only: sync/maindata only).
|
||||
|
||||
Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s
|
||||
session pattern. Authentication uses username/password login which stores an
|
||||
SID cookie in the requests session. The client re-logins transparently on 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QbittorrentClient:
|
||||
"""Small wrapper around the qBittorrent Web API.
|
||||
|
||||
Only the endpoints needed by the dashboard widgets are implemented
|
||||
(currently just ``/sync/maindata``). All calls share a single
|
||||
:class:`requests.Session` that carries the login cookie.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
|
||||
if not base_url:
|
||||
raise ValueError("qBittorrent base_url is required")
|
||||
if not username:
|
||||
raise ValueError("qBittorrent username is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if not self.base_url.endswith("/api/v2"):
|
||||
self.base_url += "/api/v2"
|
||||
self._username = username
|
||||
self._password = password
|
||||
self.timeout = timeout
|
||||
self._session = requests.Session()
|
||||
self._logged_in = False
|
||||
|
||||
def _login(self) -> None:
|
||||
"""POST username/password to ``/auth/login``; store the SID cookie.
|
||||
|
||||
qBittorrent returns the plain text ``"Ok."`` on success. The
|
||||
``Referer`` header is required by some qBittorrent CSRF protections.
|
||||
"""
|
||||
resp = self._session.post(
|
||||
f"{self.base_url}/auth/login",
|
||||
data={"username": self._username, "password": self._password},
|
||||
timeout=self.timeout,
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
if resp.text.strip() != "Ok.":
|
||||
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
|
||||
self._logged_in = True
|
||||
logger.info("qBittorrent login successful for %s", self.base_url)
|
||||
|
||||
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||
"""GET an endpoint with auto-login on first call and re-login on 403."""
|
||||
if not self._logged_in:
|
||||
self._login()
|
||||
url = f"{self.base_url}{path}"
|
||||
resp = self._session.get(url, params=params, timeout=self.timeout)
|
||||
if resp.status_code == 403:
|
||||
logger.debug("qBittorrent 403 on %s, re-logging in", path)
|
||||
self._logged_in = False
|
||||
self._login()
|
||||
resp = self._session.get(url, params=params, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def maindata(self) -> dict[str, Any]:
|
||||
"""Fetch ``/sync/maindata``.
|
||||
|
||||
Returns a dict with ``server_state`` (containing ``dl_info_speed``,
|
||||
``up_info_speed``, etc.) and ``torrents`` (a dict of
|
||||
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``).
|
||||
"""
|
||||
return self._get("/sync/maindata")
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||
the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
|
||||
|
||||
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||
* the secret fields it accepts (API keys / tokens),
|
||||
@@ -24,7 +24,7 @@ from pydantic import BaseModel, BeforeValidator, Field
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
Relative hosts (e.g. ``example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Grafana service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
"""Deep-link to a Grafana dashboard or panel."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_model=GrafanaConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
model_cls=GrafanaLinkWidgetConfig,
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -36,6 +36,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
|
||||
"""Only show sessions with active playback (not idle/paused)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="jellyfin",
|
||||
name="Jellyfin",
|
||||
@@ -53,5 +59,13 @@ DEFINITION = ServiceDefinition(
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="now_playing",
|
||||
name="Now Playing",
|
||||
description="Only sessions actively playing media.",
|
||||
model_cls=JellyfinNowPlayingWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -25,6 +25,32 @@ class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||
promql: str
|
||||
|
||||
|
||||
class PrometheusChartWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
|
||||
|
||||
promql: str
|
||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
||||
|
||||
|
||||
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL instant query rendered as a gauge with optional threshold bands (SC-109..SC-111)."""
|
||||
|
||||
promql: str
|
||||
warn_at: float | None = None
|
||||
crit_at: float | None = None
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
class PrometheusMeanWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
|
||||
|
||||
promql: str
|
||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="prometheus",
|
||||
name="Prometheus",
|
||||
@@ -42,5 +68,29 @@ DEFINITION = ServiceDefinition(
|
||||
default_config={"promql": ""},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="chart",
|
||||
name="Chart",
|
||||
description="Multi-series line chart from a PromQL range query.",
|
||||
model_cls=PrometheusChartWidgetConfig,
|
||||
default_config={"promql": "", "window": "1h"},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="gauge",
|
||||
name="Gauge",
|
||||
description="Instant query rendered as a gauge with optional threshold bands.",
|
||||
model_cls=PrometheusGaugeWidgetConfig,
|
||||
default_config={"promql": ""},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="mean",
|
||||
name="Mean",
|
||||
description="Average value of a PromQL query over a time window.",
|
||||
model_cls=PrometheusMeanWidgetConfig,
|
||||
default_config={"promql": "", "window": "1h"},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""qBittorrent service definition.
|
||||
|
||||
Declares the config model (base URL + timeout), secret fields (username +
|
||||
password), and three widget kinds (totals, active, speed). Models on
|
||||
:mod:`media_library_viewer_api.integrations.prometheus`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class QbittorrentConfig(ServiceConfigBase):
|
||||
"""Non-secret qBittorrent connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
class QbittorrentWidgetConfig(WidgetConfigBase):
|
||||
"""Per-widget config (empty — all three kinds derive from the service connection)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="qbittorrent",
|
||||
name="qBittorrent",
|
||||
description="Torrent client activity, speeds, and item counts.",
|
||||
config_model=QbittorrentConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="username", label="Username", required=True),
|
||||
SecretField(key="password", label="Password", required=True, helper="Stored encrypted"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="totals",
|
||||
name="Totals",
|
||||
description="Count of all listed torrents, broken down by state.",
|
||||
model_cls=QbittorrentWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="active",
|
||||
name="Active torrents",
|
||||
description="Torrents currently downloading or uploading.",
|
||||
model_cls=QbittorrentWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=15_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="speed",
|
||||
name="Speed chart",
|
||||
description="Live download/upload speed over a short window.",
|
||||
model_cls=QbittorrentWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=5_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -10,18 +10,18 @@ from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALE
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
QBITTORRENT.service_type: QBITTORRENT,
|
||||
SSH_TASKS.service_type: SSH_TASKS,
|
||||
BACKUPS.service_type: BACKUPS,
|
||||
AUTHENTIK.service_type: AUTHENTIK,
|
||||
|
||||
@@ -52,6 +52,12 @@ async def lifespan(app: FastAPI):
|
||||
get_settings_store().ensure_defaults()
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default settings during startup")
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
get_service_data_harness()
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize service data harness during startup")
|
||||
mail_queue = get_mail_queue()
|
||||
backup_poller = get_backup_poller()
|
||||
mail_queue.start()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Widgets are either:
|
||||
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||
by that service's definition (Grafana link, Prometheus metric, Jellyfin
|
||||
by that service's definition (Prometheus metric, Jellyfin
|
||||
activity, SSH task output); or
|
||||
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
|
||||
service-less kinds (backups, static).
|
||||
|
||||
@@ -20,8 +20,9 @@ from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,29 +37,6 @@ class MessageRequest(BaseModel):
|
||||
html_body: str
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested authentik instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled ``authentik`` instance is configured.
|
||||
"""
|
||||
service_type = "authentik"
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_token = str(service.secrets.get("api_token") or "")
|
||||
@@ -82,7 +60,7 @@ def get_authentik_users(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Paginated Authentik user directory for a specific service instance."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
||||
return _empty("Authentik service not configured")
|
||||
@@ -102,7 +80,7 @@ def get_authentik_message_status(
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||
return mail_queue.status()
|
||||
@@ -116,7 +94,7 @@ def post_authentik_message(
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
return {"status": "error", "error": "Authentik service not configured"}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def _serialize_status(status: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
||||
def _worker_command(final_db_path: Path, staging_db_path: Path, service_id: str = "") -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
||||
str(final_db_path),
|
||||
"--staging-path",
|
||||
str(staging_db_path),
|
||||
"--service-id",
|
||||
service_id,
|
||||
]
|
||||
|
||||
|
||||
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
|
||||
def _start_worker(index: MediaIndex, service_id: str = "") -> subprocess.Popen[bytes]:
|
||||
staging_path = _staging_db_path(index)
|
||||
staging_path.unlink(missing_ok=True)
|
||||
return subprocess.Popen(
|
||||
_worker_command(index.db_path, staging_path),
|
||||
_worker_command(index.db_path, staging_path, service_id),
|
||||
start_new_session=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
@@ -131,20 +133,28 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
|
||||
|
||||
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
|
||||
def post_build_index(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
jellyfin_service_id: str | None = None,
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
"""Start a media index build in a subprocess worker."""
|
||||
"""Start a media index build in a subprocess worker.
|
||||
|
||||
The worker resolves its own Jellyfin connection from the settings store.
|
||||
We do NOT use Depends(get_jellyfin_client) here because the worker runs
|
||||
in a separate process and needs to resolve the client itself. Validating
|
||||
the connection here would fail if Jellyfin is briefly unreachable, even
|
||||
though the build just needs to start the worker process.
|
||||
"""
|
||||
with _build_lock:
|
||||
current_status = _clean_stale_build_state(index)
|
||||
if current_status.build_running and _pid_is_alive(current_status.build_pid):
|
||||
logger.warning("Media build already running pid=%s", current_status.build_pid)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
|
||||
|
||||
libraries = client.libraries(user_id)
|
||||
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
|
||||
process = _start_worker(index)
|
||||
logger.info(
|
||||
"Starting media index build service_id=%s",
|
||||
jellyfin_service_id or "<default>",
|
||||
)
|
||||
process = _start_worker(index, jellyfin_service_id or "")
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
@@ -156,7 +166,7 @@ def post_build_index(
|
||||
"build_items_total": 0,
|
||||
"build_current_library": "",
|
||||
"build_library_index": 0,
|
||||
"build_libraries_total": len(libraries),
|
||||
"build_libraries_total": 0,
|
||||
"build_library_progress": None,
|
||||
"build_library_items_processed": 0,
|
||||
"build_library_items_total": 0,
|
||||
@@ -272,6 +282,7 @@ def query_media(
|
||||
sort_order: str = Query("Ascending", description="Ascending or Descending"),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
jellyfin_service_id: str | None = None,
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
@@ -306,6 +317,7 @@ def query_media(
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
service_id=jellyfin_service_id or "",
|
||||
)
|
||||
|
||||
logger.info("Media query returned total=%s rows=%s", total, len(rows))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Monitoring router — observability service status.
|
||||
|
||||
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
||||
Observability components (Alertmanager, Prometheus) are resolved from
|
||||
the service registry, not environment variables. The endpoints pick the first
|
||||
enabled instance of a type when no ``service_id`` is given, and return graceful
|
||||
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
||||
@@ -15,34 +15,14 @@ import requests
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore, service_type: str, service_id: str | None = None
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested service instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled instance of ``service_type`` is configured.
|
||||
"""
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _base_url(service: ServiceRecord) -> str:
|
||||
return str(service.config.get("base_url") or "").rstrip("/")
|
||||
|
||||
@@ -104,7 +84,7 @@ def get_alertmanager_alerts(
|
||||
is configured the endpoint returns an empty summary with an
|
||||
``alertmanager_not_configured`` error so the UI can render a health card.
|
||||
"""
|
||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||
service = resolve_service_record(store, "alertmanager", service_id)
|
||||
if service is None:
|
||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
|
||||
try:
|
||||
@@ -149,7 +129,7 @@ def get_alertmanager_status(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return Alertmanager cluster/status for the UI health card."""
|
||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||
service = resolve_service_record(store, "alertmanager", service_id)
|
||||
if service is None:
|
||||
return {
|
||||
"up": False,
|
||||
@@ -192,36 +172,13 @@ def get_alertmanager_status(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grafana-status")
|
||||
def get_grafana_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||
service = _resolve_service_record(store, "grafana", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/health",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Grafana status")
|
||||
return _status_response(service, error="grafana_unreachable")
|
||||
return _status_response(service, version=data.get("version", ""))
|
||||
|
||||
|
||||
@router.get("/prometheus-status")
|
||||
def get_prometheus_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Prometheus service instance's health and build info."""
|
||||
service = _resolve_service_record(store, "prometheus", service_id)
|
||||
service = resolve_service_record(store, "prometheus", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
base = _base_url(service)
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.base import validate_config
|
||||
@@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import (
|
||||
get_service_adapter,
|
||||
)
|
||||
|
||||
|
||||
class WidgetReferenceCreate(BaseModel):
|
||||
"""Payload for creating a widget reference (live-link)."""
|
||||
|
||||
dashboard_scope: str
|
||||
widget_id: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -103,10 +113,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)
|
||||
@@ -213,3 +231,65 @@ async def fetch_data(
|
||||
error=data.get("error"),
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/references")
|
||||
def list_references(
|
||||
dashboard_scope: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List widget references for a dashboard scope."""
|
||||
return store.list_widget_references(dashboard_scope)
|
||||
|
||||
|
||||
@router.post("/references", status_code=status.HTTP_201_CREATED)
|
||||
def create_reference(
|
||||
body: WidgetReferenceCreate,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a widget reference (live-link) on a dashboard."""
|
||||
try:
|
||||
return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/references/{reference_id}")
|
||||
def delete_reference(
|
||||
reference_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Remove a widget reference from a dashboard."""
|
||||
store.delete_widget_reference(reference_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/references/{reference_id}")
|
||||
def update_reference(
|
||||
reference_id: str,
|
||||
sort_order: int,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Update a widget reference's sort_order (per-dashboard reordering)."""
|
||||
try:
|
||||
return store.update_widget_reference(reference_id, sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/references/{reference_id}/detach")
|
||||
def detach_reference(
|
||||
reference_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Clone the referenced widget into a standalone instance and remove the reference."""
|
||||
try:
|
||||
cloned = store.detach_widget_reference(reference_id, "")
|
||||
return WidgetInstance(**cloned).model_dump()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Any, Callable, Iterable
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
|
||||
from media_library_viewer_api.path_utils import resolve_remote_media_path
|
||||
from media_library_viewer_api.services.service_data import StorageConcern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +26,20 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")
|
||||
MEDIA_TYPES = "Movie,Episode,Video"
|
||||
|
||||
# Harness concern registration: the media_index DB is scoped by service_id so
|
||||
# multiple Jellyfin instances can coexist. The ALTER TABLE migration adds the
|
||||
# service_id column to existing DBs; init_schema adds it for fresh installs.
|
||||
# The harness run_migrations catches "duplicate column name" on re-runs.
|
||||
MEDIA_INDEX_CONCERN = StorageConcern(
|
||||
concern_key="media_index",
|
||||
db_filename=DEFAULT_INDEX_PATH.name,
|
||||
migrations=[
|
||||
"ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
tables=["media_items"],
|
||||
service_id_column="service_id",
|
||||
)
|
||||
|
||||
# Only values from this whitelist are interpolated into ORDER BY. User-selected
|
||||
# sort keys map to these known SQL snippets to avoid SQL injection.
|
||||
SORT_COLUMNS = {
|
||||
@@ -135,7 +150,8 @@ class MediaIndex:
|
||||
date_added_ts INTEGER,
|
||||
path TEXT,
|
||||
library_id TEXT,
|
||||
library_name TEXT
|
||||
library_name TEXT,
|
||||
service_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS index_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -167,8 +183,13 @@ class MediaIndex:
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]]) -> int:
|
||||
"""Atomically replace indexed media rows with a freshly built set."""
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]], service_id: str = "") -> int:
|
||||
"""Atomically replace indexed media rows with a freshly built set.
|
||||
|
||||
Scoped by ``service_id``: only rows belonging to this service are
|
||||
deleted before the new batch is inserted. This means building for one
|
||||
Jellyfin instance no longer wipes another instance's rows.
|
||||
"""
|
||||
self.init_schema()
|
||||
row_list = list(rows)
|
||||
columns = [
|
||||
@@ -194,13 +215,14 @@ class MediaIndex:
|
||||
"path",
|
||||
"library_id",
|
||||
"library_name",
|
||||
"service_id",
|
||||
]
|
||||
placeholders = ",".join(["?"] * len(columns))
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM media_items")
|
||||
conn.execute("DELETE FROM media_items WHERE service_id = ?", (service_id,))
|
||||
conn.executemany(
|
||||
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
|
||||
[[row.get(column) for column in columns] for row in row_list],
|
||||
[[row.get(column) if column != "service_id" else service_id for column in columns] for row in row_list],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)",
|
||||
@@ -287,8 +309,14 @@ class MediaIndex:
|
||||
sort_order: str = "Ascending",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
service_id: str = "",
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Query indexed media with full-index filters, sorting, and pagination."""
|
||||
"""Query indexed media with full-index filters, sorting, and pagination.
|
||||
|
||||
When ``service_id`` is non-empty, only rows matching that service are
|
||||
returned. When empty (the default), all rows are returned (backward-
|
||||
compatible with callers that are not multi-instance aware).
|
||||
"""
|
||||
self.init_schema()
|
||||
where = []
|
||||
params: list[Any] = []
|
||||
@@ -309,6 +337,9 @@ class MediaIndex:
|
||||
where.append("hdr = 1")
|
||||
elif hdr_filter == "SDR/unknown only":
|
||||
where.append("(hdr IS NULL OR hdr = 0)")
|
||||
if service_id:
|
||||
where.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
|
||||
where_sql = " WHERE " + " AND ".join(where) if where else ""
|
||||
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
|
||||
@@ -338,6 +369,7 @@ def build_media_index(
|
||||
fallback_prefix: str = "",
|
||||
progress_callback: Callable[[dict[str, Any]], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
service_id: str = "",
|
||||
) -> int:
|
||||
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
|
||||
index = index or MediaIndex()
|
||||
@@ -453,7 +485,7 @@ def build_media_index(
|
||||
logger.info("Media index finalizing rows=%s", len(normalized_rows))
|
||||
emit("finalizing", "Writing index to disk")
|
||||
ensure_not_cancelled()
|
||||
count = index.replace_items(normalized_rows)
|
||||
count = index.replace_items(normalized_rows, service_id=service_id)
|
||||
duration = time.perf_counter() - started_at
|
||||
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
|
||||
processed_total = count
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Speed-sample storage for qBittorrent widgets.
|
||||
|
||||
This module defines the storage concern for qBittorrent speed data and a
|
||||
bespoke store with ``append``/``window`` operations. It is registered with the
|
||||
:class:`~media_library_viewer_api.services.service_data.ServiceDataHarness` as
|
||||
the first real consumer of the harness lifecycle layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.services.service_data import StorageConcern
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
QBITTORRENT_CONCERN = StorageConcern(
|
||||
concern_key="qbittorrent",
|
||||
db_filename="qbittorrent.db",
|
||||
migrations=[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples (
|
||||
service_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
dl_speed INTEGER NOT NULL DEFAULT 0,
|
||||
up_speed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts
|
||||
ON qbittorrent_speed_samples(service_id, ts);
|
||||
"""
|
||||
],
|
||||
tables=["qbittorrent_speed_samples"],
|
||||
)
|
||||
|
||||
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
|
||||
MAX_SAMPLES = 120
|
||||
|
||||
|
||||
class QbittorrentSampleStore:
|
||||
"""Bespoke speed-sample store for qBittorrent widgets.
|
||||
|
||||
Each ``append`` inserts a new sample and prunes entries beyond
|
||||
:data:`MAX_SAMPLES`, keeping only the most recent rows for the given
|
||||
``service_id``.
|
||||
"""
|
||||
|
||||
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
|
||||
if harness is None:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
harness = get_service_data_harness()
|
||||
self._harness = harness
|
||||
|
||||
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
|
||||
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
|
||||
(service_id, ts, dl_speed, up_speed),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM qbittorrent_speed_samples "
|
||||
"WHERE service_id = ? AND ts NOT IN ("
|
||||
" SELECT ts FROM qbittorrent_speed_samples"
|
||||
" WHERE service_id = ?"
|
||||
" ORDER BY ts DESC LIMIT ?"
|
||||
")",
|
||||
(service_id, service_id, MAX_SAMPLES),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return all samples for a service since a timestamp (or all if ``None``)."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
if since_ts is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples "
|
||||
"WHERE service_id = ? AND ts >= ? ORDER BY ts ASC",
|
||||
(service_id, since_ts),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC",
|
||||
(service_id,),
|
||||
).fetchall()
|
||||
return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Lifecycle-only storage harness for service-owned persistent data.
|
||||
|
||||
This module owns the *lifecycle* of per-concern SQLite databases: provisioning,
|
||||
schema migrations, and cascade-delete when a service instance is removed. It
|
||||
does **not** own data operations — each integration implements its own Store
|
||||
with bespoke operations (``append``/``window``, ``replace_items``/``query``,
|
||||
etc.). This keeps the general interface narrow (lifecycle) and the specific
|
||||
interfaces rich (per-integration operations).
|
||||
|
||||
Each storage *concern* is registered with a :class:`StorageConcern` dataclass
|
||||
declaring its DB filename, ordered migration statements, owned tables, and the
|
||||
column used for service scoping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageConcern:
|
||||
"""A per-integration storage namespace registered with the harness."""
|
||||
|
||||
concern_key: str # e.g. "qbittorrent", "media_index"
|
||||
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
|
||||
migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch)
|
||||
tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade)
|
||||
service_id_column: str = "service_id"
|
||||
|
||||
|
||||
class ServiceDataHarness:
|
||||
"""Lifecycle-only registry of per-concern storage.
|
||||
|
||||
Owns:
|
||||
* DB provisioning (per-concern SQLite files under ``base_dir``).
|
||||
* Schema migrations (run on first access via :meth:`run_migrations`).
|
||||
* ``service_id`` cascade-delete when a service instance is removed.
|
||||
|
||||
Does **not** own:
|
||||
* Data operations — each store keeps bespoke append/window/query/etc.
|
||||
* A generic value table or generic CRUD layer.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path | str) -> None:
|
||||
self._base_dir = Path(base_dir)
|
||||
self._concerns: dict[str, StorageConcern] = {}
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self._base_dir
|
||||
|
||||
def register(self, concern: StorageConcern) -> None:
|
||||
"""Register a storage concern. Called at startup / on first access."""
|
||||
self._concerns[concern.concern_key] = concern
|
||||
|
||||
def db_path(self, concern_key: str) -> Path:
|
||||
"""Return the absolute path to a concern's DB file."""
|
||||
concern = self._concerns[concern_key]
|
||||
return self._base_dir / concern.db_filename
|
||||
|
||||
def connect(self, concern_key: str) -> sqlite3.Connection:
|
||||
"""Open a WAL-mode connection to a concern's DB."""
|
||||
path = self.db_path(concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
def run_migrations(self) -> None:
|
||||
"""Run pending migrations for every registered concern.
|
||||
|
||||
Each migration string is split into individual statements (by ``;``)
|
||||
and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements
|
||||
that fail with "duplicate column name" are silently skipped, making
|
||||
migrations idempotent across re-runs and fresh installs where
|
||||
``init_schema`` may have already created the column.
|
||||
"""
|
||||
for concern in self._concerns.values():
|
||||
path = self.db_path(concern.concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
for migration_sql in concern.migrations:
|
||||
statements = [s.strip() for s in migration_sql.split(";") if s.strip()]
|
||||
for stmt in statements:
|
||||
try:
|
||||
conn.execute(stmt)
|
||||
except sqlite3.OperationalError as exc:
|
||||
lowered = str(exc).lower()
|
||||
if "duplicate column name" in lowered or "no such table" in lowered:
|
||||
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
|
||||
else:
|
||||
raise
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def cascade_delete(self, service_id: str) -> None:
|
||||
"""Delete all rows for a ``service_id`` across every concern's tables.
|
||||
|
||||
Called from :meth:`SettingsStore.delete_service` after the service row
|
||||
is removed. Best-effort: callers wrap in try/except so a harness
|
||||
failure does not block service deletion.
|
||||
"""
|
||||
for concern in self._concerns.values():
|
||||
col = concern.service_id_column
|
||||
path = self.db_path(concern.concern_key)
|
||||
if not path.exists():
|
||||
continue
|
||||
with sqlite3.connect(path, timeout=30) as conn:
|
||||
for table in concern.tables:
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if col in cols:
|
||||
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HARNESS: ServiceDataHarness | None = None
|
||||
|
||||
|
||||
def get_service_data_harness() -> ServiceDataHarness:
|
||||
"""Return the process-wide harness singleton, initializing it on first call.
|
||||
|
||||
Lazy registration of built-in concerns happens here (local imports avoid
|
||||
circular dependencies). Migrations are run immediately after registration.
|
||||
"""
|
||||
global _HARNESS
|
||||
if _HARNESS is None:
|
||||
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
|
||||
_HARNESS = ServiceDataHarness(base_dir)
|
||||
# Register built-in concerns (lazy import avoids circular dependency).
|
||||
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN
|
||||
|
||||
_HARNESS.register(QBITTORRENT_CONCERN)
|
||||
|
||||
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
|
||||
|
||||
_HARNESS.register(MEDIA_INDEX_CONCERN)
|
||||
_HARNESS.run_migrations()
|
||||
return _HARNESS
|
||||
|
||||
|
||||
def reset_service_data_harness() -> None:
|
||||
"""Reset the singleton (for testing)."""
|
||||
global _HARNESS
|
||||
_HARNESS = None
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Shared helpers for resolving service instances at request time.
|
||||
|
||||
Extracted from the duplicated ``_resolve_service_record`` helpers that lived
|
||||
in ``routers/monitoring.py`` and ``routers/authentik_users.py``. Both routers
|
||||
need the same logic: return the requested service instance (by id), or fall
|
||||
back to the first enabled instance of the type. Returns ``None`` when the
|
||||
instance does not exist, is the wrong type, is disabled, or when no enabled
|
||||
instance of the type is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
|
||||
def resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_type: str,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested service instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled instance of ``service_type`` is configured.
|
||||
"""
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
@@ -81,7 +81,7 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
|
||||
# metrics now live in Prometheus/node_exporter. Drop the orphan
|
||||
# table on startup so existing databases get a clean slate.
|
||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||
conn.execute(
|
||||
@@ -165,6 +165,19 @@ class SettingsStore:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||
if "widget_kind" not in widget_cols:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS widget_references (
|
||||
id TEXT PRIMARY KEY,
|
||||
dashboard_scope TEXT NOT NULL,
|
||||
widget_id TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (widget_id) REFERENCES dashboard_widgets(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_widget_references_scope ON widget_references(dashboard_scope)")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1403,10 +1416,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:
|
||||
@@ -1465,6 +1504,123 @@ class SettingsStore:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||
conn.execute("DELETE FROM widget_references WHERE widget_id = ?", (widget_id,))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_widget_references(self, dashboard_scope: str) -> list[dict[str, Any]]:
|
||||
"""List widget references for a dashboard scope, joined with widget data."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order,
|
||||
wr.created_at AS ref_created_at
|
||||
FROM widget_references wr
|
||||
WHERE wr.dashboard_scope = ?
|
||||
ORDER BY wr.sort_order ASC, wr.created_at ASC
|
||||
""",
|
||||
(dashboard_scope,),
|
||||
).fetchall()
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
widget = self.get_widget(row["widget_id"])
|
||||
if not widget:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": row["ref_id"],
|
||||
"dashboard_scope": row["dashboard_scope"],
|
||||
"widget_id": row["widget_id"],
|
||||
"sort_order": int(row["sort_order"]),
|
||||
"created_at": row["ref_created_at"],
|
||||
"widget": widget,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order: int = 0) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
widget = self.get_widget(widget_id)
|
||||
if not widget:
|
||||
raise ValueError(f"Widget {widget_id} not found")
|
||||
ref_id = uuid.uuid4().hex[:12]
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO widget_references (id, dashboard_scope, widget_id, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ref_id, dashboard_scope, widget_id, sort_order, now),
|
||||
)
|
||||
return {
|
||||
"id": ref_id,
|
||||
"dashboard_scope": dashboard_scope,
|
||||
"widget_id": widget_id,
|
||||
"sort_order": sort_order,
|
||||
"created_at": now,
|
||||
"widget": widget,
|
||||
}
|
||||
|
||||
def delete_widget_reference(self, reference_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
|
||||
|
||||
def update_widget_reference(self, reference_id: str, sort_order: int) -> dict[str, Any]:
|
||||
"""Update only the sort_order on a widget reference (per-dashboard reordering)."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM widget_references WHERE id = ?",
|
||||
(reference_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Reference {reference_id} not found")
|
||||
conn.execute(
|
||||
"UPDATE widget_references SET sort_order = ? WHERE id = ?",
|
||||
(sort_order, reference_id),
|
||||
)
|
||||
widget = self.get_widget(row["widget_id"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"dashboard_scope": row["dashboard_scope"],
|
||||
"widget_id": row["widget_id"],
|
||||
"sort_order": sort_order,
|
||||
"created_at": int(row["created_at"]),
|
||||
"widget": widget,
|
||||
}
|
||||
|
||||
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
|
||||
"""Clone the referenced widget into a new standalone instance owned by the scope."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT widget_id FROM widget_references WHERE id = ?",
|
||||
(reference_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Reference {reference_id} not found")
|
||||
source = self.get_widget(row["widget_id"])
|
||||
if not source:
|
||||
raise ValueError(f"Source widget {row['widget_id']} not found")
|
||||
# Clone: copy the widget verbatim including service_id (so service-bound
|
||||
# widgets keep working), only the id/created_at change.
|
||||
cloned = self.upsert_widget(
|
||||
{
|
||||
"service_id": source.get("service_id"),
|
||||
"widget_kind": source["widget_kind"],
|
||||
"title": source["title"],
|
||||
"config": source["config"],
|
||||
"enabled": source["enabled"],
|
||||
"sort_order": source["sort_order"],
|
||||
}
|
||||
)
|
||||
self.delete_widget_reference(reference_id)
|
||||
return cloned
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Service registry
|
||||
@@ -1584,7 +1740,7 @@ class SettingsStore:
|
||||
return self.get_service(service["id"]) or service
|
||||
|
||||
def delete_service(self, service_id: str) -> None:
|
||||
"""Delete a service and cascade-delete widgets referencing it."""
|
||||
"""Delete a service and cascade-delete widgets + harness data."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
# The service_id column on dashboard_widgets is added in a later
|
||||
@@ -1597,6 +1753,15 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||
|
||||
# Cascade-delete harness-managed data (best-effort: the service row is
|
||||
# already removed; data cleanup must not block service deletion).
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
get_service_data_harness().cascade_delete(service_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to cascade-delete harness data for service %s", service_id)
|
||||
|
||||
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Append a service task run history row."""
|
||||
self.init_schema()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Shared helpers for Prometheus range queries.
|
||||
|
||||
These two pieces were called out by the spec's downstream-notes as needing a
|
||||
home: the step-derivation function (SC-104) and the series normalization helper
|
||||
(SC-102). Keeping them in their own module makes them unit-testable in isolation
|
||||
and reusable by the chart and mean widget paths (and, later, the in-service data
|
||||
path of the service-storage-harness change) without ``sources.py`` growing
|
||||
unbounded.
|
||||
|
||||
``normalize_prometheus_matrix`` is a direct extraction of the metric-label →
|
||||
readable-label rule that previously lived inside the Grafana datasource-proxy
|
||||
path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so
|
||||
users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical
|
||||
labels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
|
||||
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
|
||||
WINDOW_PRESETS: dict[str, int] = {
|
||||
"1h": 3_600,
|
||||
"6h": 21_600,
|
||||
"24h": 86_400,
|
||||
"7d": 604_800,
|
||||
}
|
||||
|
||||
#: Sentinel values Prometheus serialises for non-finite floats; map these to
|
||||
#: ``None`` so the frontend renderer can skip them via ``connectNulls``.
|
||||
_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf")
|
||||
|
||||
|
||||
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
|
||||
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
|
||||
|
||||
Clamped to a minimum of 15 seconds so Prometheus does not reject
|
||||
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
|
||||
the resulting point count to land in the 100–300 band; with
|
||||
``target_points=200`` every preset yields 200 points.
|
||||
"""
|
||||
return max(15, round(window_seconds / target_points))
|
||||
|
||||
|
||||
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
|
||||
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
|
||||
renderer consumes.
|
||||
|
||||
Label rule (matches the removed Grafana path so labels are stable on
|
||||
migration):
|
||||
|
||||
1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels.
|
||||
2. If labels remain, join them as ``k=v k=v`` (sorted for determinism).
|
||||
3. Else fall back to ``"value"``.
|
||||
4. Dedup label collisions with a `` (n)`` suffix.
|
||||
"""
|
||||
series: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
for entry in result:
|
||||
metric = entry.get("metric") or {}
|
||||
values = entry.get("values") or []
|
||||
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
|
||||
label = " ".join(parts) if parts else "value"
|
||||
if label in seen:
|
||||
seen[label] += 1
|
||||
label = f"{label} ({seen[label]})"
|
||||
else:
|
||||
seen[label] = 0
|
||||
points: list[dict[str, Any]] = []
|
||||
for ts, raw in values:
|
||||
t = _safe_int(ts)
|
||||
if t is None:
|
||||
# Drop samples whose timestamp is unusable rather than raising.
|
||||
continue
|
||||
points.append({"t": t, "v": _safe_float(raw)})
|
||||
series.append({"label": label, "points": points})
|
||||
return series
|
||||
|
||||
|
||||
def _safe_float(raw: Any) -> float | None:
|
||||
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
|
||||
if raw in _NON_NUMERIC:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(ts: Any) -> int | None:
|
||||
"""Best-effort int conversion for a Prometheus timestamp."""
|
||||
try:
|
||||
return int(float(ts))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -12,19 +12,27 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||
from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||
from media_library_viewer_api.widgets.prometheus_range import (
|
||||
WINDOW_PRESETS,
|
||||
normalize_prometheus_matrix,
|
||||
step_for_window,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,29 +104,8 @@ class StaticWidgetSource:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GrafanaWidgetSource:
|
||||
"""Build a Grafana deep-link (no embedding)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Grafana widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
dashboard_uid = config.get("dashboard_uid")
|
||||
if not dashboard_uid:
|
||||
return {"error": "dashboard_uid is required"}
|
||||
url = f"{base_url}/d/{dashboard_uid}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
url = f"{url}?viewPanel={panel_id}"
|
||||
return {"url": url}
|
||||
except Exception as exc:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run a PromQL instant query against a Prometheus service."""
|
||||
"""Run PromQL queries against a Prometheus service (instant + range)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -126,14 +113,63 @@ class PrometheusWidgetSource:
|
||||
return {"error": "Prometheus widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
url = f"{base_url}/api/v1/query"
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(base_url, timeout, config)
|
||||
if widget_kind == "gauge":
|
||||
return await self._fetch_gauge(base_url, timeout, config)
|
||||
if widget_kind == "mean":
|
||||
return await self._fetch_mean(base_url, timeout, config)
|
||||
# Default: instant-query metric path (unchanged).
|
||||
raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
|
||||
return raw
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
|
||||
async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]:
|
||||
"""Run a Prometheus ``/api/v1/query_range`` over a window (seconds).
|
||||
|
||||
Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
|
||||
``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
|
||||
per SC-103).
|
||||
"""
|
||||
step = step_for_window(window)
|
||||
end = int(time.time())
|
||||
start = end - window
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
url,
|
||||
f"{base_url}/api/v1/query_range",
|
||||
params={"query": promql, "start": start, "end": end, "step": step},
|
||||
timeout=timeout,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Prometheus query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus range query failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
result = payload.get("data", {}).get("result", [])
|
||||
return {"matrix": result}
|
||||
|
||||
async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]:
|
||||
"""Run a Prometheus ``/api/v1/query`` instant query.
|
||||
|
||||
Shared by the ``metric`` and ``gauge`` widget kinds. Returns
|
||||
``{"result": data}`` on success or ``{"error": str}`` (never raises,
|
||||
per SC-103).
|
||||
"""
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
f"{base_url}/api/v1/query",
|
||||
params={"query": promql},
|
||||
timeout=timeout,
|
||||
),
|
||||
@@ -141,15 +177,80 @@ class PrometheusWidgetSource:
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"result": payload.get("data", {})}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
return {"error": "Prometheus query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
logger.exception("prometheus instant query failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
return {"result": payload.get("data", {})}
|
||||
|
||||
async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._range_query(base_url, timeout, promql, window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
return {"series": normalize_prometheus_matrix(raw["matrix"])}
|
||||
|
||||
async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111).
|
||||
|
||||
Scalar-only: a multi-series query returns an error (SC-111). Threshold
|
||||
config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed
|
||||
through for the frontend renderer.
|
||||
"""
|
||||
raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
|
||||
if "error" in raw:
|
||||
return raw
|
||||
result = raw["result"].get("result", [])
|
||||
if len(result) != 1:
|
||||
return {"error": "Gauge requires a single-series query; refine your PromQL"}
|
||||
try:
|
||||
value = float(result[0]["value"][1])
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
return {"error": "Gauge query returned no scalar value"}
|
||||
return {
|
||||
"value": value,
|
||||
"warn_at": config.get("warn_at"),
|
||||
"crit_at": config.get("crit_at"),
|
||||
"min": config.get("min"),
|
||||
"max": config.get("max"),
|
||||
"unit": config.get("unit"),
|
||||
}
|
||||
|
||||
async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Range query → client-side mean for the mean widget (SC-112..SC-114).
|
||||
|
||||
Runs ``query_range`` over the configured window preset, averages all
|
||||
non-null numeric samples of the single series, and returns a scalar.
|
||||
Scalar-only: a multi-series query returns an error (SC-114).
|
||||
"""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._range_query(base_url, timeout, promql, window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
result = raw["matrix"]
|
||||
if len(result) != 1:
|
||||
return {"error": "Mean requires a single-series query; refine your PromQL"}
|
||||
points = result[0].get("values") or []
|
||||
nums: list[float] = []
|
||||
for _, v in points:
|
||||
if v in (None, "NaN", "+Inf", "-Inf"):
|
||||
continue
|
||||
try:
|
||||
nums.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not nums:
|
||||
return {"error": "Mean query returned no numeric samples in the window"}
|
||||
mean = sum(nums) / len(nums)
|
||||
return {"value": mean, "unit": config.get("unit")}
|
||||
|
||||
|
||||
class AlertmanagerWidgetSource:
|
||||
@@ -208,6 +309,10 @@ class JellyfinWidgetSource:
|
||||
asyncio.to_thread(client.sessions),
|
||||
timeout=timeout,
|
||||
)
|
||||
if widget_kind == "now_playing":
|
||||
sessions = [
|
||||
s for s in sessions if s.get("NowPlayingItem") and not s.get("PlayState", {}).get("IsPaused", True)
|
||||
]
|
||||
rows = _map_sessions_to_activity_rows(sessions)
|
||||
return {"sessions": rows}
|
||||
except asyncio.TimeoutError:
|
||||
@@ -265,13 +370,75 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
||||
logger.exception("failed to record ssh task timeout")
|
||||
|
||||
|
||||
class QbittorrentWidgetSource:
|
||||
"""Fetch qBittorrent data for totals, active, and speed widgets."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "qBittorrent widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "")
|
||||
username = str(service.secrets.get("username") or "")
|
||||
password = str(service.secrets.get("password") or "")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
if not base_url or not username or not password:
|
||||
return {"error": "qBittorrent service is missing base_url, username, or password"}
|
||||
|
||||
client = QbittorrentClient(base_url, username, password, timeout)
|
||||
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
|
||||
server_state = data.get("server_state", {})
|
||||
torrents = data.get("torrents", {})
|
||||
|
||||
if widget_kind == "totals":
|
||||
by_state: dict[str, int] = {}
|
||||
for t in torrents.values():
|
||||
state = str(t.get("state", "unknown"))
|
||||
by_state[state] = by_state.get(state, 0) + 1
|
||||
return {"total": len(torrents), "by_state": by_state}
|
||||
|
||||
if widget_kind == "active":
|
||||
active = [
|
||||
{
|
||||
"name": t.get("name"),
|
||||
"state": t.get("state"),
|
||||
"size": t.get("size"),
|
||||
"progress": t.get("progress"),
|
||||
"dl_speed": t.get("dlspeed"),
|
||||
"up_speed": t.get("upspeed"),
|
||||
}
|
||||
for t in torrents.values()
|
||||
if str(t.get("state", "")) in {"downloading", "uploading"}
|
||||
]
|
||||
return {"torrents": active}
|
||||
|
||||
if widget_kind == "speed":
|
||||
dl = int(server_state.get("dl_info_speed", 0))
|
||||
up = int(server_state.get("up_info_speed", 0))
|
||||
ts = int(time.time())
|
||||
store = QbittorrentSampleStore()
|
||||
store.append(service.id, ts, dl, up)
|
||||
samples = store.window(service.id)
|
||||
series = [
|
||||
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
|
||||
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
|
||||
]
|
||||
return {"series": series}
|
||||
|
||||
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "qBittorrent data fetch timed out"}
|
||||
except Exception as exc:
|
||||
logger.exception("qbittorrent adapter failed")
|
||||
return {"error": f"qBittorrent fetch failed: {exc}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"qbittorrent": QbittorrentWidgetSource(),
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"ssh_tasks": SshTaskWidgetSource(),
|
||||
|
||||
@@ -14,7 +14,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
|
||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
|
||||
from media_library_viewer_api.services.media_index import (
|
||||
MediaIndex,
|
||||
@@ -88,15 +87,70 @@ def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> No
|
||||
)
|
||||
|
||||
|
||||
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
|
||||
def _resolve_jellyfin(service_id: str) -> tuple[Any, str]:
|
||||
"""Resolve the Jellyfin client + user_id from the settings store.
|
||||
|
||||
In a subprocess we cannot use the FastAPI dependency layer (no request),
|
||||
so we query the settings store directly. When ``service_id`` is given,
|
||||
resolve that specific instance; otherwise fall back to first-enabled.
|
||||
"""
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.dependencies import _service_record, get_settings_store
|
||||
|
||||
store = get_settings_store()
|
||||
service = _service_record(store, "jellyfin", service_id or None)
|
||||
if service is None:
|
||||
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
||||
timeout = int(service.get("config", {}).get("timeout_seconds", 10))
|
||||
client = JellyfinClient(base_url, api_key, timeout)
|
||||
user_id = str(service.get("config", {}).get("user_id") or "")
|
||||
if not user_id:
|
||||
users = client.users()
|
||||
if not users:
|
||||
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
|
||||
user_id = users[0]["Id"]
|
||||
else:
|
||||
# Try the configured user_id directly. It might be the internal
|
||||
# Jellyfin Id (a long hash) — in that case libraries() succeeds
|
||||
# without an extra users() round-trip. Only if it fails do we
|
||||
# resolve it via the users API (the config field accepts usernames
|
||||
# like 'admin' too, but Jellyfin's API rejects them on /Users/<id>).
|
||||
try:
|
||||
client.libraries(user_id)
|
||||
except Exception:
|
||||
users = client.users()
|
||||
match = next((u for u in users if str(u.get("Name", "")) == user_id), None)
|
||||
if match:
|
||||
resolved = match["Id"]
|
||||
logger.info(
|
||||
"Resolved username '%s' to Jellyfin Id '%s'",
|
||||
user_id,
|
||||
resolved,
|
||||
)
|
||||
user_id = resolved
|
||||
elif users:
|
||||
user_id = users[0]["Id"]
|
||||
logger.warning(
|
||||
"user_id '%s' not found; falling back to first user",
|
||||
service.get("config", {}).get("user_id"),
|
||||
)
|
||||
return client, user_id
|
||||
|
||||
|
||||
def run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id: str = "") -> int:
|
||||
"""Run the media index build in a subprocess."""
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
logger.info("Media index worker starting: %s", describe_settings(settings))
|
||||
client = get_jellyfin_client()
|
||||
user_id = get_user_id()
|
||||
libraries = client.libraries(user_id)
|
||||
|
||||
# Create the indexes BEFORE the try block so the except handler can write
|
||||
# error state to the DB. If _resolve_jellyfin or client.libraries fails,
|
||||
# the worker needs to record the error — otherwise the status stays
|
||||
# "queued" forever with no feedback.
|
||||
final_index = MediaIndex(final_index_path)
|
||||
staging_index = MediaIndex(staging_index_path)
|
||||
pid = os.getpid()
|
||||
@@ -104,10 +158,13 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
|
||||
|
||||
staging_path = Path(staging_index.db_path)
|
||||
staging_path.unlink(missing_ok=True)
|
||||
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
|
||||
_start_state(final_index, pid, len(libraries))
|
||||
|
||||
try:
|
||||
client, user_id = _resolve_jellyfin(service_id)
|
||||
libraries = client.libraries(user_id)
|
||||
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
|
||||
_start_state(final_index, pid, len(libraries))
|
||||
|
||||
count = build_media_index(
|
||||
client,
|
||||
user_id,
|
||||
@@ -117,6 +174,7 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
|
||||
fallback_prefix=settings.path_prefix,
|
||||
progress_callback=lambda state: _progress_callback(final_index, pid, state),
|
||||
should_cancel=lambda: _cancel_requested(final_index),
|
||||
service_id=service_id,
|
||||
)
|
||||
# Swap the staging database into place atomically.
|
||||
os.replace(staging_index.db_path, final_index.db_path)
|
||||
@@ -192,8 +250,9 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
|
||||
parser.add_argument("--index-path", required=True)
|
||||
parser.add_argument("--staging-path", required=True)
|
||||
parser.add_argument("--service-id", default="", help="Jellyfin service instance id")
|
||||
args = parser.parse_args()
|
||||
return run_build(args.index_path, args.staging_path)
|
||||
return run_build(args.index_path, args.staging_path, args.service_id)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
+23
-64
@@ -26,6 +26,7 @@ from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
# Short alias for the monitoring router module under test.
|
||||
_MON = "media_library_viewer_api.routers.monitoring"
|
||||
_SVC = "media_library_viewer_api.services.service_resolution"
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
@@ -362,7 +363,7 @@ class TestMediaIndexApi:
|
||||
assert data["status"] == "started"
|
||||
assert data["build_running"] is True
|
||||
assert data["build_stage"] == "queued"
|
||||
assert data["build_libraries_total"] == len(mock_jellyfin.libraries.return_value)
|
||||
assert data["build_libraries_total"] == 0
|
||||
assert data["build_pid"] == 4321
|
||||
start_worker.assert_called_once()
|
||||
finally:
|
||||
@@ -496,7 +497,7 @@ class TestMonitoring:
|
||||
|
||||
|
||||
class TestResolveServiceRecord:
|
||||
"""Unit tests for _resolve_service_record (service_id + first-enabled paths)."""
|
||||
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
|
||||
|
||||
def _store(self, rows):
|
||||
store = MagicMock()
|
||||
@@ -509,31 +510,31 @@ class TestResolveServiceRecord:
|
||||
return store
|
||||
|
||||
def test_service_id_match_returns_record(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = _resolve_service_record(store, "alertmanager", "am1")
|
||||
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = resolve_service_record(store, "alertmanager", "am1")
|
||||
assert result == "RECORD"
|
||||
mock_build.assert_called_once_with(store, row)
|
||||
|
||||
def test_service_id_type_mismatch_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
||||
row = {"id": "x1", "service_type": "prometheus", "name": "P", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert _resolve_service_record(store, "alertmanager", "x1") is None
|
||||
assert resolve_service_record(store, "alertmanager", "x1") is None
|
||||
|
||||
def test_service_id_disabled_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert _resolve_service_record(store, "alertmanager", "am1") is None
|
||||
assert resolve_service_record(store, "alertmanager", "am1") is None
|
||||
|
||||
def test_no_service_id_returns_first_enabled(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
rows = [
|
||||
{
|
||||
@@ -554,16 +555,16 @@ class TestResolveServiceRecord:
|
||||
},
|
||||
]
|
||||
store = self._store(rows)
|
||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = _resolve_service_record(store, "alertmanager", None)
|
||||
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = resolve_service_record(store, "alertmanager", None)
|
||||
assert result == "RECORD"
|
||||
mock_build.assert_called_once_with(store, rows[1])
|
||||
|
||||
def test_no_service_id_and_none_enabled_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
store = self._store([])
|
||||
assert _resolve_service_record(store, "alertmanager", None) is None
|
||||
assert resolve_service_record(store, "alertmanager", None) is None
|
||||
|
||||
|
||||
class TestSettingsMachines:
|
||||
@@ -624,7 +625,7 @@ class TestAlertmanager:
|
||||
def test_alerts_endpoint_when_unreachable(self, test_client):
|
||||
service = _am_service()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
@@ -651,7 +652,7 @@ class TestAlertmanager:
|
||||
}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
@@ -669,7 +670,7 @@ class TestAlertmanager:
|
||||
resp.json.return_value = {"status": "success", "data": []}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
|
||||
):
|
||||
test_client.get("/api/monitoring/alerts")
|
||||
@@ -687,7 +688,7 @@ class TestAlertmanager:
|
||||
def test_alertmanager_status_when_unreachable(self, test_client):
|
||||
service = _am_service()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
@@ -707,7 +708,7 @@ class TestAlertmanager:
|
||||
}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
@@ -740,48 +741,6 @@ class TestAlertmanagerWebhook:
|
||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||
|
||||
|
||||
class TestGrafanaStatus:
|
||||
def test_grafana_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "no_service_configured"
|
||||
|
||||
def test_grafana_status_when_unreachable(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "grafana_unreachable"
|
||||
assert data["name"] == "Grafana"
|
||||
|
||||
def test_grafana_status_returns_version(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is True
|
||||
assert data["version"] == "11.3.1"
|
||||
assert data["service_id"] == "g1"
|
||||
|
||||
|
||||
class TestPrometheusStatus:
|
||||
def test_prometheus_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
@@ -795,7 +754,7 @@ class TestPrometheusStatus:
|
||||
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
||||
)
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
@@ -814,7 +773,7 @@ class TestPrometheusStatus:
|
||||
build_info.raise_for_status = MagicMock()
|
||||
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
|
||||
@@ -228,19 +228,70 @@ class TestMediaIndexQuery:
|
||||
|
||||
class TestMediaIndexReplace:
|
||||
def test_replace_clears_old(self, index):
|
||||
index.replace_items([
|
||||
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
])
|
||||
index.replace_items(
|
||||
[
|
||||
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
]
|
||||
)
|
||||
status = index.status()
|
||||
assert status.item_count == 1
|
||||
|
||||
index.replace_items([
|
||||
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
])
|
||||
index.replace_items(
|
||||
[
|
||||
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
]
|
||||
)
|
||||
status = index.status()
|
||||
assert status.item_count == 2
|
||||
|
||||
def test_replace_scoped_by_service_id_preserves_other_services(self, index):
|
||||
"""Regression test: building for one Jellyfin must not wipe another's rows.
|
||||
|
||||
Before the migration, ``replace_items`` did ``DELETE FROM media_items``
|
||||
(global clear). This test locks in the fix: a scoped replace preserves
|
||||
rows belonging to a different service_id.
|
||||
"""
|
||||
rows_a = [
|
||||
{"id": "a1", "title": "Alpha Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
{"id": "a2", "title": "Alpha Show", "type": "Episode", "library_id": "l2", "library_name": "L2"},
|
||||
]
|
||||
rows_b = [
|
||||
{"id": "b1", "title": "Beta Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
]
|
||||
|
||||
index.replace_items(rows_a, service_id="svc-a")
|
||||
assert index.status().item_count == 2
|
||||
|
||||
# Replacing for svc-b must NOT wipe svc-a's rows.
|
||||
index.replace_items(rows_b, service_id="svc-b")
|
||||
assert index.status().item_count == 3 # 2 from svc-a + 1 from svc-b
|
||||
|
||||
# Querying svc-a returns only its rows.
|
||||
rows_a_result, total_a = index.query(
|
||||
library_ids=["l1", "l2"],
|
||||
media_types=["Movie", "Episode"],
|
||||
service_id="svc-a",
|
||||
)
|
||||
assert total_a == 2
|
||||
assert {r["id"] for r in rows_a_result} == {"a1", "a2"}
|
||||
|
||||
# Querying svc-b returns only its rows.
|
||||
rows_b_result, total_b = index.query(
|
||||
library_ids=["l1"],
|
||||
media_types=["Movie"],
|
||||
service_id="svc-b",
|
||||
)
|
||||
assert total_b == 1
|
||||
assert rows_b_result[0]["id"] == "b1"
|
||||
|
||||
# Querying with no service_id returns all rows (backward-compat).
|
||||
_, total_all = index.query(
|
||||
library_ids=["l1", "l2"],
|
||||
media_types=["Movie", "Episode"],
|
||||
)
|
||||
assert total_all == 3
|
||||
|
||||
|
||||
class TestMediaIndexMetadata:
|
||||
def test_set_and_read_metadata(self, index):
|
||||
@@ -288,15 +339,11 @@ class TestMediaIndexBuildPaths:
|
||||
self.calls.append(kwargs.get("start_index", 0))
|
||||
if kwargs.get("start_index", 0) == 0:
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
|
||||
@@ -332,15 +379,11 @@ class TestMediaIndexBuildPaths:
|
||||
self.calls.append(kwargs.get("start_index", 0))
|
||||
if kwargs.get("start_index", 0) == 0:
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Unit tests for the shared Prometheus range-query helpers (SC-101..SC-104)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.widgets.prometheus_range import (
|
||||
WINDOW_PRESETS,
|
||||
normalize_prometheus_matrix,
|
||||
step_for_window,
|
||||
)
|
||||
|
||||
|
||||
class TestStepForWindow:
|
||||
"""SC-104: every preset must yield 100–300 points."""
|
||||
|
||||
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
|
||||
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
|
||||
window = WINDOW_PRESETS[preset]
|
||||
step = step_for_window(window)
|
||||
# Clamped minimum.
|
||||
assert step >= 15
|
||||
point_count = window // step
|
||||
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
|
||||
|
||||
def test_floor_of_fifteen_seconds(self) -> None:
|
||||
# A tiny window that would otherwise produce a sub-15s step is clamped.
|
||||
assert step_for_window(60) == 15
|
||||
|
||||
def test_custom_target_points(self) -> None:
|
||||
# Targeting 100 points for 1h yields step 36 (3600/100).
|
||||
assert step_for_window(3_600, target_points=100) == 36
|
||||
|
||||
|
||||
class TestNormalizePrometheusMatrix:
|
||||
"""SC-102: label rule + null handling + dedup."""
|
||||
|
||||
def test_empty_matrix(self) -> None:
|
||||
assert normalize_prometheus_matrix([]) == []
|
||||
|
||||
def test_drops_dunder_labels_and_joins(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"__name__": "node_cpu_seconds_total", "instance": "host:9100", "mode": "idle"},
|
||||
"values": [[1_700_000_000, "12.5"], [1_700_000_030, "13.0"]],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert len(out) == 1
|
||||
assert out[0]["label"] == "instance=host:9100 mode=idle"
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1_700_000_000, "v": 12.5},
|
||||
{"t": 1_700_000_030, "v": 13.0},
|
||||
]
|
||||
|
||||
def test_falls_back_to_value_when_no_labels(self) -> None:
|
||||
result = [{"metric": {}, "values": [[100, "1"]]}]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["label"] == "value"
|
||||
|
||||
def test_dedup_collisions_with_suffix(self) -> None:
|
||||
# Two series with identical visible labels get a "(1)" suffix on the 2nd.
|
||||
result = [
|
||||
{"metric": {"job": "x"}, "values": [[1, "1"]]},
|
||||
{"metric": {"job": "x"}, "values": [[1, "2"]]},
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
labels = [s["label"] for s in out]
|
||||
assert labels == ["job=x", "job=x (1)"]
|
||||
|
||||
def test_non_numeric_sentinels_become_none(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"job": "x"},
|
||||
"values": [
|
||||
[1, "NaN"],
|
||||
[2, "+Inf"],
|
||||
[3, "-Inf"],
|
||||
[4, "3.5"],
|
||||
],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1, "v": None},
|
||||
{"t": 2, "v": None},
|
||||
{"t": 3, "v": None},
|
||||
{"t": 4, "v": 3.5},
|
||||
]
|
||||
|
||||
def test_malformed_values_are_ignored_not_raised(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"job": "x"},
|
||||
"values": [
|
||||
[1, "3.5"],
|
||||
["not-a-ts", "9"], # unusable timestamp → dropped
|
||||
[3, "junk-value"], # unparseable value → v: None
|
||||
],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1, "v": 3.5},
|
||||
{"t": 3, "v": None},
|
||||
]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Unit tests for the QbittorrentClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||
|
||||
|
||||
class QbittorrentClientTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = QbittorrentClient("https://qb.example.com", "admin", "secret", timeout=5)
|
||||
self.session = MagicMock()
|
||||
self.client._session = self.session
|
||||
|
||||
def _login_response(self, text: str = "Ok.") -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.raise_for_status.return_value = None
|
||||
resp.status_code = 200
|
||||
return resp
|
||||
|
||||
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = json_data
|
||||
resp.raise_for_status.return_value = None
|
||||
resp.status_code = status_code
|
||||
resp.text = ""
|
||||
return resp
|
||||
|
||||
def test_base_url_appends_api_v2(self) -> None:
|
||||
c = QbittorrentClient("https://qb.example.com", "u", "p")
|
||||
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||
|
||||
def test_base_url_keeps_existing_api_v2(self) -> None:
|
||||
c = QbittorrentClient("https://qb.example.com/api/v2", "u", "p")
|
||||
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||
|
||||
def test_base_url_strips_trailing_slash(self) -> None:
|
||||
c = QbittorrentClient("https://qb.example.com/", "u", "p")
|
||||
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||
|
||||
def test_empty_base_url_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
QbittorrentClient("", "u", "p")
|
||||
|
||||
def test_empty_username_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
QbittorrentClient("https://qb.example.com", "", "p")
|
||||
|
||||
def test_login_posts_credentials(self) -> None:
|
||||
self.session.post.return_value = self._login_response("Ok.")
|
||||
self.client._login()
|
||||
self.session.post.assert_called_once()
|
||||
call_args = self.session.post.call_args
|
||||
self.assertIn("/auth/login", call_args.args[0])
|
||||
self.assertEqual(call_args.kwargs["data"], {"username": "admin", "password": "secret"})
|
||||
self.assertTrue(self.client._logged_in)
|
||||
|
||||
def test_login_failure_raises_runtime_error(self) -> None:
|
||||
self.session.post.return_value = self._login_response("Fails.")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.client._login()
|
||||
|
||||
def test_get_auto_logs_in_on_first_call(self) -> None:
|
||||
"""First _get triggers login, then fetches data."""
|
||||
self.session.post.return_value = self._login_response("Ok.")
|
||||
self.session.get.return_value = self._get_response({"server_state": {}, "torrents": {}})
|
||||
|
||||
result = self.client._get("/sync/maindata")
|
||||
|
||||
self.session.post.assert_called_once() # login happened
|
||||
self.assertEqual(result, {"server_state": {}, "torrents": {}})
|
||||
|
||||
def test_cookie_reuse_does_not_re_login(self) -> None:
|
||||
"""After login, subsequent _get calls do NOT re-login."""
|
||||
self.client._logged_in = True # simulate already logged in
|
||||
self.session.get.return_value = self._get_response({"data": 1})
|
||||
|
||||
self.client._get("/some/path")
|
||||
|
||||
self.session.post.assert_not_called() # no re-login
|
||||
|
||||
def test_403_triggers_re_login(self) -> None:
|
||||
"""A 403 response triggers re-login and retries the GET."""
|
||||
self.client._logged_in = True # already logged in from a prior call
|
||||
forbidden = MagicMock()
|
||||
forbidden.status_code = 403
|
||||
ok = self._get_response({"server_state": {}, "torrents": {}})
|
||||
self.session.get.side_effect = [forbidden, ok]
|
||||
self.session.post.return_value = self._login_response("Ok.")
|
||||
|
||||
result = self.client._get("/sync/maindata")
|
||||
|
||||
self.assertEqual(self.session.get.call_count, 2) # initial + retry
|
||||
self.session.post.assert_called_once() # re-login happened
|
||||
self.assertEqual(result, {"server_state": {}, "torrents": {}})
|
||||
|
||||
def test_maindata_returns_full_payload(self) -> None:
|
||||
self.client._logged_in = True
|
||||
payload = {
|
||||
"server_state": {"dl_info_speed": 12345, "up_info_speed": 6789},
|
||||
"torrents": {
|
||||
"abc": {"name": "Movie.mkv", "state": "downloading", "progress": 0.5},
|
||||
"def": {"name": "Show.mkv", "state": "uploading", "progress": 1.0},
|
||||
},
|
||||
}
|
||||
self.session.get.return_value = self._get_response(payload)
|
||||
|
||||
result = self.client.maindata()
|
||||
|
||||
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
|
||||
self.assertEqual(len(result["torrents"]), 2)
|
||||
|
||||
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
|
||||
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
|
||||
"""A network error during login propagates as requests exception."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
bad_resp = MagicMock()
|
||||
bad_resp.raise_for_status.side_effect = requests.ConnectionError("refused")
|
||||
bad_resp.text = ""
|
||||
mock_session.post.return_value = bad_resp
|
||||
|
||||
client = QbittorrentClient("https://qb.example.com", "u", "p")
|
||||
with self.assertRaises(requests.ConnectionError):
|
||||
client._login()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for ServiceDataHarness lifecycle and QbittorrentSampleStore operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.services.qbittorrent_store import (
|
||||
MAX_SAMPLES,
|
||||
QBITTORRENT_CONCERN,
|
||||
QbittorrentSampleStore,
|
||||
)
|
||||
from media_library_viewer_api.services.service_data import (
|
||||
ServiceDataHarness,
|
||||
StorageConcern,
|
||||
)
|
||||
|
||||
# A throwaway concern used to test harness lifecycle in isolation.
|
||||
_TEST_CONCERN = StorageConcern(
|
||||
concern_key="test",
|
||||
db_filename="test.db",
|
||||
migrations=[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS test_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
service_id TEXT NOT NULL,
|
||||
value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_test_service ON test_items(service_id);
|
||||
"""
|
||||
],
|
||||
tables=["test_items"],
|
||||
)
|
||||
|
||||
|
||||
class TestServiceDataHarnessMigrations:
|
||||
def test_run_migrations_creates_tables(self, tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
harness.run_migrations()
|
||||
|
||||
with harness.connect("test") as conn:
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
||||
assert "test_items" in tables
|
||||
|
||||
def test_migrations_are_idempotent(self, tmp_path):
|
||||
"""Re-running migrations on an already-migrated DB must not crash."""
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
harness.run_migrations()
|
||||
harness.run_migrations() # should not raise
|
||||
|
||||
def test_alter_table_idempotency(self, tmp_path):
|
||||
"""ALTER TABLE ADD COLUMN must be silently skipped on re-run."""
|
||||
concern = StorageConcern(
|
||||
concern_key="alter_test",
|
||||
db_filename="alter.db",
|
||||
migrations=[
|
||||
"CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY)",
|
||||
"ALTER TABLE items ADD COLUMN extra TEXT DEFAULT ''",
|
||||
],
|
||||
tables=["items"],
|
||||
)
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(concern)
|
||||
harness.run_migrations()
|
||||
harness.run_migrations() # second run: "duplicate column name" caught
|
||||
|
||||
with harness.connect("alter_test") as conn:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(items)").fetchall()}
|
||||
assert "extra" in cols
|
||||
|
||||
|
||||
class TestServiceDataHarnessCascadeDelete:
|
||||
def test_cascade_delete_removes_only_matching_service(self, tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
harness.run_migrations()
|
||||
|
||||
with harness.connect("test") as conn:
|
||||
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (1, 'svc-a', 'a1')")
|
||||
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (2, 'svc-a', 'a2')")
|
||||
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (3, 'svc-b', 'b1')")
|
||||
conn.commit()
|
||||
|
||||
harness.cascade_delete("svc-a")
|
||||
|
||||
with harness.connect("test") as conn:
|
||||
remaining = conn.execute("SELECT service_id, value FROM test_items ORDER BY id").fetchall()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0][0] == "svc-b"
|
||||
|
||||
def test_cascade_delete_skips_missing_concern_db(self, tmp_path):
|
||||
"""cascade_delete on a concern whose DB file doesn't exist should not crash."""
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
# No run_migrations → DB file doesn't exist
|
||||
harness.cascade_delete("svc-x") # should not raise
|
||||
|
||||
|
||||
class TestQbittorrentSampleStore:
|
||||
@pytest.fixture()
|
||||
def store(self, tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(QBITTORRENT_CONCERN)
|
||||
harness.run_migrations()
|
||||
return QbittorrentSampleStore(harness=harness)
|
||||
|
||||
def test_append_and_window(self, store):
|
||||
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
|
||||
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
|
||||
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
|
||||
|
||||
samples = store.window("svc-1")
|
||||
assert len(samples) == 3
|
||||
assert samples[0]["ts"] == 100
|
||||
assert samples[2]["ts"] == 300
|
||||
assert samples[1]["dl_speed"] == 600
|
||||
|
||||
def test_window_with_since_ts(self, store):
|
||||
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
|
||||
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
|
||||
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
|
||||
|
||||
samples = store.window("svc-1", since_ts=200)
|
||||
assert len(samples) == 2
|
||||
assert samples[0]["ts"] == 200
|
||||
|
||||
def test_prune_enforces_max_samples(self, store):
|
||||
for i in range(MAX_SAMPLES + 10):
|
||||
store.append("svc-1", ts=i, dl_speed=i, up_speed=i)
|
||||
|
||||
samples = store.window("svc-1")
|
||||
assert len(samples) == MAX_SAMPLES
|
||||
# The oldest 10 should have been pruned
|
||||
assert samples[0]["ts"] == 10
|
||||
assert samples[-1]["ts"] == MAX_SAMPLES + 9
|
||||
|
||||
def test_two_services_do_not_cross_contaminate(self, store):
|
||||
store.append("svc-a", ts=100, dl_speed=500, up_speed=50)
|
||||
store.append("svc-b", ts=200, dl_speed=600, up_speed=60)
|
||||
|
||||
a_samples = store.window("svc-a")
|
||||
b_samples = store.window("svc-b")
|
||||
|
||||
assert len(a_samples) == 1
|
||||
assert a_samples[0]["dl_speed"] == 500
|
||||
assert len(b_samples) == 1
|
||||
assert b_samples[0]["dl_speed"] == 600
|
||||
+119
-51
@@ -59,7 +59,6 @@ def client(tmp_path):
|
||||
|
||||
def test_registry_contains_eight_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
@@ -67,6 +66,7 @@ def test_registry_contains_eight_service_types():
|
||||
"ssh_tasks",
|
||||
"backups",
|
||||
"authentik",
|
||||
"qbittorrent",
|
||||
}
|
||||
|
||||
|
||||
@@ -99,10 +99,9 @@ def test_authentik_service_definition():
|
||||
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
assert get_service_definition("authentik").widget_kinds == []
|
||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
||||
@@ -110,13 +109,13 @@ def test_definitions_declare_widget_kinds():
|
||||
|
||||
|
||||
def test_widget_kind_lookup():
|
||||
assert get_widget_kind("grafana", "link") is not None
|
||||
assert get_widget_kind("grafana", "missing") is None
|
||||
assert get_widget_kind("unknown", "link") is None
|
||||
assert get_widget_kind("prometheus", "metric") is not None
|
||||
assert get_widget_kind("prometheus", "missing") is None
|
||||
assert get_widget_kind("unknown", "metric") is None
|
||||
|
||||
|
||||
def test_service_config_schema_is_json_schema():
|
||||
schema = get_service_definition("grafana").config_schema
|
||||
schema = get_service_definition("prometheus").config_schema
|
||||
assert schema["type"] == "object"
|
||||
assert "base_url" in schema["properties"]
|
||||
|
||||
@@ -172,19 +171,19 @@ def test_list_service_types(client):
|
||||
"alertmanager",
|
||||
"authentik",
|
||||
"backups",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"qbittorrent",
|
||||
"ssh_tasks",
|
||||
}
|
||||
|
||||
|
||||
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
response = client.get("/api/services/types")
|
||||
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
||||
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"]
|
||||
prom = next(item for item in response.json() if item["service_type"] == "prometheus")
|
||||
assert [sf["key"] for sf in prom["secret_fields"]] == ["api_key"]
|
||||
assert set(wk["kind"] for wk in prom["widget_kinds"]) == {"metric", "chart", "gauge", "mean"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,11 +191,11 @@ def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _grafana_payload(**overrides):
|
||||
def _prometheus_payload(**overrides):
|
||||
payload = {
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "secret-token"},
|
||||
"enabled": True,
|
||||
}
|
||||
@@ -205,11 +204,11 @@ def _grafana_payload(**overrides):
|
||||
|
||||
|
||||
def test_create_and_list_service(client):
|
||||
response = client.post("/api/services/instances", json=_grafana_payload())
|
||||
response = client.post("/api/services/instances", json=_prometheus_payload())
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_type"] == "grafana"
|
||||
assert created["config"]["base_url"] == "https://grafana.example.com"
|
||||
assert created["service_type"] == "prometheus"
|
||||
assert created["config"]["base_url"] == "https://prometheus.example.com"
|
||||
# Plaintext secrets are never returned.
|
||||
assert "secrets" not in created
|
||||
assert created["secrets_set"] == {"api_key": True}
|
||||
@@ -220,44 +219,44 @@ def test_create_and_list_service(client):
|
||||
|
||||
|
||||
def test_list_instances_filters_by_type(client):
|
||||
client.post("/api/services/instances", json=_grafana_payload())
|
||||
client.post("/api/services/instances", json=_prometheus_payload())
|
||||
client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "prometheus",
|
||||
"name": "Prom",
|
||||
"config": {"base_url": "http://prometheus:9090"},
|
||||
"service_type": "alertmanager",
|
||||
"name": "AM",
|
||||
"config": {"base_url": "http://am:9093"},
|
||||
},
|
||||
)
|
||||
response = client.get("/api/services/instances?service_type=grafana")
|
||||
response = client.get("/api/services/instances?service_type=prometheus")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["service_type"] == "grafana"
|
||||
assert response.json()[0]["service_type"] == "prometheus"
|
||||
|
||||
|
||||
def test_update_service_preserves_unsent_secrets(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
# Update without sending secrets; the existing key should remain set.
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Renamed Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||
"service_type": "prometheus",
|
||||
"name": "Renamed Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com", "timeout_seconds": 10},
|
||||
},
|
||||
).json()
|
||||
assert updated["name"] == "Renamed Grafana"
|
||||
assert updated["name"] == "Renamed Prometheus"
|
||||
assert updated["secrets_set"] == {"api_key": True}
|
||||
|
||||
|
||||
def test_update_service_can_clear_secret(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": ""},
|
||||
},
|
||||
).json()
|
||||
@@ -275,30 +274,28 @@ def test_unknown_service_type_rejected(client):
|
||||
def test_invalid_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"base_url": ""}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
# Force a real validation error via bad type.
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
|
||||
"bad_url", ["prometheus.example.com", "localhost:3000", "//bad.example.com", "ftp://bad.example.com"]
|
||||
)
|
||||
def test_service_base_url_requires_http_schema(bad_url):
|
||||
"""Every service base_url must include an http:// or https:// schema."""
|
||||
model = get_service_definition("grafana").config_model
|
||||
model = get_service_definition("prometheus").config_model
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
|
||||
)
|
||||
@pytest.mark.parametrize("service_type", ["prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"])
|
||||
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||
model = get_service_definition(service_type).config_model
|
||||
instance = model.model_validate({"base_url": "https://example.com"})
|
||||
@@ -309,9 +306,9 @@ def test_unknown_secret_field_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"password": "leak"},
|
||||
},
|
||||
)
|
||||
@@ -322,9 +319,9 @@ def test_credential_key_in_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
|
||||
"config": {"base_url": "https://prometheus.example.com", "api_key": "leak"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -333,22 +330,22 @@ def test_credential_key_in_config_rejected(client):
|
||||
def test_update_nonexistent_returns_404(client):
|
||||
response = client.put(
|
||||
"/api/services/instances/missing",
|
||||
json=_grafana_payload(id="missing"),
|
||||
json=_prometheus_payload(id="missing"),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_id_mismatch_returns_400(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json=_grafana_payload(id="other-id"),
|
||||
json=_prometheus_payload(id="other-id"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_delete_service(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.delete(f"/api/services/instances/{created['id']}")
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/services/instances").json() == []
|
||||
@@ -372,7 +369,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
"""
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
service = store.upsert_service(
|
||||
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
|
||||
{"service_type": "prometheus", "name": "Prometheus", "config": {"base_url": "u"}, "enabled": True}
|
||||
)
|
||||
|
||||
# Ensure the service_id column exists and seed a referencing widget.
|
||||
@@ -386,7 +383,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
enabled, sort_order, created_at, updated_at, service_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
("w1", "prometheus", "prometheus.metric", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
)
|
||||
|
||||
store.delete_service(service["id"])
|
||||
@@ -399,6 +396,77 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
assert int(remaining[0]) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harness cascade-delete (Slice 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cascade_delete_removes_harness_data_across_concerns(tmp_path, monkeypatch):
|
||||
"""Deleting a service cascades to both qbittorrent samples and media items.
|
||||
|
||||
Proves end-to-end cascade across both harness-managed concerns, and that
|
||||
deleting one service preserves another service's data (multi-instance).
|
||||
"""
|
||||
monkeypatch.setenv("BACKEND_CACHE_DIR", str(tmp_path))
|
||||
|
||||
from media_library_viewer_api.services.media_index_impl import MediaIndex
|
||||
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||
from media_library_viewer_api.services.service_data import (
|
||||
get_service_data_harness,
|
||||
reset_service_data_harness,
|
||||
)
|
||||
|
||||
reset_service_data_harness()
|
||||
harness = get_service_data_harness()
|
||||
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
# --- qBit: create service, add samples, delete, verify gone ---
|
||||
qbit_a = store.upsert_service(
|
||||
{"service_type": "qbittorrent", "name": "qA", "config": {"base_url": "http://a"}, "enabled": True}
|
||||
)
|
||||
qbit_b = store.upsert_service(
|
||||
{"service_type": "qbittorrent", "name": "qB", "config": {"base_url": "http://b"}, "enabled": True}
|
||||
)
|
||||
sample_store = QbittorrentSampleStore(harness)
|
||||
sample_store.append(qbit_a["id"], ts=1000, dl_speed=500, up_speed=100)
|
||||
sample_store.append(qbit_b["id"], ts=1000, dl_speed=200, up_speed=50)
|
||||
|
||||
assert len(sample_store.window(qbit_a["id"])) == 1
|
||||
assert len(sample_store.window(qbit_b["id"])) == 1
|
||||
|
||||
store.delete_service(qbit_a["id"])
|
||||
|
||||
assert sample_store.window(qbit_a["id"]) == []
|
||||
assert len(sample_store.window(qbit_b["id"])) == 1 # B survives
|
||||
|
||||
# --- MediaIndex: create services, add items, delete, verify scoped ---
|
||||
jelly_a = store.upsert_service(
|
||||
{"service_type": "jellyfin", "name": "jA", "config": {"base_url": "http://ja"}, "enabled": True}
|
||||
)
|
||||
jelly_b = store.upsert_service(
|
||||
{"service_type": "jellyfin", "name": "jB", "config": {"base_url": "http://jb"}, "enabled": True}
|
||||
)
|
||||
index = MediaIndex(harness.db_path("media_index"))
|
||||
index.init_schema()
|
||||
index.replace_items([{"id": "m1", "title": "A1"}], service_id=jelly_a["id"])
|
||||
index.replace_items([{"id": "m2", "title": "B1"}], service_id=jelly_b["id"])
|
||||
|
||||
rows_a, total_a = index.query(service_id=jelly_a["id"])
|
||||
rows_b, total_b = index.query(service_id=jelly_b["id"])
|
||||
assert total_a == 1 and total_b == 1
|
||||
|
||||
store.delete_service(jelly_a["id"])
|
||||
|
||||
rows_a_after, total_a_after = index.query(service_id=jelly_a["id"])
|
||||
rows_b_after, total_b_after = index.query(service_id=jelly_b["id"])
|
||||
assert total_a_after == 0 # deleted
|
||||
assert total_b_after == 1 # survives
|
||||
|
||||
reset_service_data_harness()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service task run history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+797
-78
@@ -15,7 +15,7 @@ from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
AlertmanagerWidgetSource,
|
||||
BackupsWidgetSource,
|
||||
GrafanaWidgetSource,
|
||||
JellyfinWidgetSource,
|
||||
ServiceRecord,
|
||||
StaticWidgetSource,
|
||||
)
|
||||
@@ -41,12 +41,12 @@ def client(tmp_path):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
|
||||
config = {"base_url": "https://grafana.example.com"}
|
||||
def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
|
||||
config = {"base_url": "https://prometheus.example.com"}
|
||||
config.update(config_overrides)
|
||||
return client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
|
||||
json={"service_type": "prometheus", "name": name, "config": config, "enabled": True},
|
||||
).json()
|
||||
|
||||
|
||||
@@ -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_prometheus_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": "metric",
|
||||
"title": "Dash",
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
|
||||
# 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",
|
||||
@@ -112,29 +150,29 @@ def test_credential_key_in_config_rejected(client):
|
||||
|
||||
|
||||
def test_create_service_bound_widget(client):
|
||||
service = _make_grafana_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "link"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "metric",
|
||||
"title": "Metrics",
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "metric"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "nonexistent_kind",
|
||||
"title": "x",
|
||||
"config": {},
|
||||
},
|
||||
@@ -147,33 +185,23 @@ def test_service_bound_widget_service_not_found_rejected(client):
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": "missing",
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_service_bound_widget_invalid_config_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
|
||||
},
|
||||
)
|
||||
# Empty string passes Pydantic; force a real failure with a bad type.
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": 123},
|
||||
"config": {"promql": 123}, # bad type: promql must be a string
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -241,31 +269,15 @@ def test_fetch_backups_widget_data(client):
|
||||
assert "total_jobs" in response.json()["data"]
|
||||
|
||||
|
||||
def test_fetch_grafana_link_widget_data(client):
|
||||
service = _make_grafana_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview", "panel_id": 2},
|
||||
},
|
||||
).json()
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
|
||||
|
||||
|
||||
def test_fetch_widget_service_not_found(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
# Deleting the service cascade-deletes its widgets, so the widget is gone.
|
||||
@@ -275,22 +287,22 @@ def test_fetch_widget_service_not_found(client):
|
||||
|
||||
|
||||
def test_fetch_widget_service_disabled(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
client.put(
|
||||
f"/api/services/instances/{service['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": service["name"],
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"enabled": False,
|
||||
},
|
||||
)
|
||||
@@ -308,23 +320,6 @@ def test_fetch_widget_not_found(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_builds_url():
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
|
||||
assert result["url"] == "http://g:3000/d/ov"
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
|
||||
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_missing_service():
|
||||
adapter = GrafanaWidgetSource()
|
||||
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_summarizes_alerts():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
@@ -456,3 +451,727 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
runs = store.list_service_task_runs(service_id=service["id"])
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["status"] == "success"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New widget kind tests (jellyfin now_playing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jellyfin_definition_has_now_playing_widget():
|
||||
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||
|
||||
definition = get_service_definition("jellyfin")
|
||||
kinds = {wk.kind for wk in definition.widget_kinds}
|
||||
assert "now_playing" in kinds
|
||||
assert "activity" in kinds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_runs_range_query():
|
||||
"""SC-101: chart kind hits /api/v1/query_range and returns {series}."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{
|
||||
"metric": {"__name__": "up", "instance": "h:9100"},
|
||||
"values": [[100, "1"], [130, "1"]],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
||||
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
||||
|
||||
# query_range endpoint + window-derived start/end/step params.
|
||||
call = mock_get.call_args
|
||||
assert call.args[0].endswith("/api/v1/query_range")
|
||||
params = call.kwargs["params"]
|
||||
assert params["query"] == "up"
|
||||
assert {"start", "end", "step"}.issubset(params)
|
||||
# {series} shape with the shared normalization (label drops __name__).
|
||||
assert "series" in result
|
||||
assert result["series"][0]["label"] == "instance=h:9100"
|
||||
assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_requires_promql():
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
|
||||
result = await adapter.fetch(service, "chart", {"promql": ""})
|
||||
assert result == {"error": "promql is required"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_degrades_on_http_error():
|
||||
"""SC-103: a connection error returns {error} rather than raising."""
|
||||
import requests as req_mod
|
||||
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")):
|
||||
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
||||
assert "error" in result
|
||||
assert "failed" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jellyfin_now_playing_filters_active_sessions():
|
||||
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
|
||||
adapter = JellyfinWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="jellyfin",
|
||||
name="jf",
|
||||
config={"base_url": "http://jf:8096"},
|
||||
secrets={"api_key": "k"},
|
||||
)
|
||||
playing_session = {
|
||||
"UserName": "alice",
|
||||
"NowPlayingItem": {"Name": "Movie", "Type": "Movie"},
|
||||
"PlayState": {"IsPaused": False},
|
||||
"DeviceName": "Web",
|
||||
}
|
||||
paused_session = {
|
||||
"UserName": "bob",
|
||||
"NowPlayingItem": {"Name": "Show", "Type": "Episode"},
|
||||
"PlayState": {"IsPaused": True},
|
||||
"DeviceName": "TV",
|
||||
}
|
||||
idle_session = {
|
||||
"UserName": "carol",
|
||||
"PlayState": {"IsPaused": False},
|
||||
"DeviceName": "Phone",
|
||||
}
|
||||
mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session])
|
||||
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
|
||||
result = await adapter.fetch(service, "now_playing", {})
|
||||
sessions = result["sessions"]
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["user"] == "alice"
|
||||
assert sessions[0]["state"] == "playing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jellyfin_activity_shows_all_sessions():
|
||||
"""activity (default) should include idle and paused sessions."""
|
||||
adapter = JellyfinWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="jellyfin",
|
||||
name="jf",
|
||||
config={"base_url": "http://jf:8096"},
|
||||
secrets={"api_key": "k"},
|
||||
)
|
||||
mock_client = SimpleNamespace(
|
||||
sessions=lambda: [
|
||||
{"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}},
|
||||
{"UserName": "bob", "PlayState": {"IsPaused": False}},
|
||||
]
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
|
||||
result = await adapter.fetch(service, "activity", {})
|
||||
assert len(result["sessions"]) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def widget_ref_client(monkeypatch):
|
||||
"""TestClient with an isolated SettingsStore + encryption key."""
|
||||
monkeypatch.setenv(
|
||||
"MANAGE_ENCRYPTION_KEY",
|
||||
Fernet.generate_key().decode(),
|
||||
)
|
||||
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
||||
|
||||
reset_encryption_key_cache()
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
store = SettingsStore(str(Path(tempfile.mkdtemp()) / "test.db"))
|
||||
store.ensure_defaults()
|
||||
|
||||
def get_store_override():
|
||||
return store
|
||||
|
||||
app.dependency_overrides[get_settings_store] = get_store_override
|
||||
client = TestClient(app)
|
||||
yield client, store
|
||||
app.dependency_overrides.pop(get_settings_store, None)
|
||||
|
||||
|
||||
def test_widget_reference_lifecycle(widget_ref_client):
|
||||
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
# Create a service-bound widget (simulating one on a Prometheus service).
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "CPU IOWait",
|
||||
"config": {"promql": "rate(cpu[5m])", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Reference it on "main" dashboard.
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
"sort_order": 5,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
ref = resp.json()
|
||||
assert ref["dashboard_scope"] == "main"
|
||||
assert ref["widget_id"] == widget["id"]
|
||||
ref_id = ref["id"]
|
||||
|
||||
# List references for "main" — should include our widget.
|
||||
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
|
||||
assert resp.status_code == 200
|
||||
refs = resp.json()
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["widget"]["title"] == "CPU IOWait"
|
||||
|
||||
# Delete the reference.
|
||||
resp = client.delete(f"/api/widgets/references/{ref_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deleted"
|
||||
|
||||
# Reference is gone, original widget still exists.
|
||||
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
|
||||
assert len(resp.json()) == 0
|
||||
assert store.get_widget(widget["id"]) is not None
|
||||
|
||||
|
||||
def test_widget_reference_detach(widget_ref_client):
|
||||
"""Detach clones the widget into a standalone instance and removes the reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "Memory",
|
||||
"config": {"promql": "mem", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Reference on "main".
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
},
|
||||
)
|
||||
ref_id = resp.json()["id"]
|
||||
|
||||
# Detach.
|
||||
resp = client.post(f"/api/widgets/references/{ref_id}/detach")
|
||||
assert resp.status_code == 200
|
||||
cloned = resp.json()
|
||||
assert cloned["title"] == "Memory"
|
||||
assert cloned["widget_kind"] == "chart"
|
||||
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
|
||||
assert cloned["config"]["promql"] == "mem"
|
||||
assert cloned["id"] != widget["id"] # new independent widget
|
||||
|
||||
# Reference is gone.
|
||||
refs = client.get("/api/widgets/references", params={"dashboard_scope": "main"}).json()
|
||||
assert len(refs) == 0
|
||||
# Original still exists.
|
||||
assert store.get_widget(widget["id"]) is not None
|
||||
|
||||
|
||||
def test_widget_reference_update_sort_order(widget_ref_client):
|
||||
"""PUT /references/{id} updates only the reference's sort_order (Fix 1)."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
widget_a = store.upsert_widget(
|
||||
{
|
||||
"service_id": None,
|
||||
"widget_kind": "static",
|
||||
"title": "A",
|
||||
"config": {"text": "a"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
widget_b = store.upsert_widget(
|
||||
{
|
||||
"service_id": None,
|
||||
"widget_kind": "static",
|
||||
"title": "B",
|
||||
"config": {"text": "b"},
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Two references on the same dashboard scope.
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "named:test",
|
||||
"widget_id": widget_a["id"],
|
||||
"sort_order": 0,
|
||||
},
|
||||
)
|
||||
ref_a = resp.json()
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "named:test",
|
||||
"widget_id": widget_b["id"],
|
||||
"sort_order": 1,
|
||||
},
|
||||
)
|
||||
ref_b = resp.json()
|
||||
|
||||
# Swap sort orders via PUT (per-dashboard reorder).
|
||||
resp = client.put(f"/api/widgets/references/{ref_a['id']}?sort_order=1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sort_order"] == 1
|
||||
|
||||
resp = client.put(f"/api/widgets/references/{ref_b['id']}?sort_order=0")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sort_order"] == 0
|
||||
|
||||
# Widget instances themselves are unchanged.
|
||||
assert store.get_widget(widget_a["id"])["sort_order"] == 0
|
||||
assert store.get_widget(widget_b["id"])["sort_order"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prometheus gauge + mean adapter tests (SC-109..SC-114)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_gauge_adapter_returns_scalar():
|
||||
"""SC-109: gauge kind hits /api/v1/query and returns {value, thresholds}."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"__name__": "cpu"}, "value": [100, "0.75"]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
||||
result = await adapter.fetch(
|
||||
service,
|
||||
"gauge",
|
||||
{
|
||||
"promql": "cpu_usage",
|
||||
"warn_at": 0.8,
|
||||
"crit_at": 0.95,
|
||||
"unit": "%",
|
||||
},
|
||||
)
|
||||
call = mock_get.call_args
|
||||
assert call.args[0].endswith("/api/v1/query")
|
||||
assert call.kwargs["params"]["query"] == "cpu_usage"
|
||||
assert result["value"] == 0.75
|
||||
assert result["warn_at"] == 0.8
|
||||
assert result["crit_at"] == 0.95
|
||||
assert result["unit"] == "%"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_gauge_adapter_rejects_multi_series():
|
||||
"""SC-111: gauge must be scalar-only; multi-series returns error."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"instance": "a"}, "value": [100, "1"]},
|
||||
{"metric": {"instance": "b"}, "value": [100, "2"]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "gauge", {"promql": "up"})
|
||||
assert "error" in result
|
||||
assert "single-series" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_gauge_adapter_requires_promql():
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
result = await adapter.fetch(service, "gauge", {"promql": ""})
|
||||
assert result == {"error": "promql is required"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_computes_average():
|
||||
"""SC-112: mean kind averages non-null values over the window."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{
|
||||
"metric": {"__name__": "cpu"},
|
||||
"values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
|
||||
assert result["value"] == 2.0
|
||||
assert result["unit"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_rejects_multi_series():
|
||||
"""SC-114: mean must be scalar-only; multi-series returns error."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"instance": "a"}, "values": [[100, "1"]]},
|
||||
{"metric": {"instance": "b"}, "values": [[100, "2"]]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
||||
assert "error" in result
|
||||
assert "single-series" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_skips_nan_values():
|
||||
"""SC-112: NaN / Inf values are excluded from the mean computation."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{
|
||||
"metric": {},
|
||||
"values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
||||
# (2.0 + 4.0) / 2 = 3.0 (NaN excluded)
|
||||
assert result["value"] == 3.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_requires_promql():
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
result = await adapter.fetch(service, "mean", {"promql": ""})
|
||||
assert result == {"error": "promql is required"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# qBittorrent widget source adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_qbit_maindata():
|
||||
"""Return a mock maindata response (server_state + torrents dict)."""
|
||||
return {
|
||||
"server_state": {"dl_info_speed": 500000, "up_info_speed": 100000},
|
||||
"torrents": {
|
||||
"h1": {
|
||||
"name": "Movie.mkv",
|
||||
"state": "downloading",
|
||||
"size": 1000,
|
||||
"progress": 0.5,
|
||||
"dlspeed": 500,
|
||||
"upspeed": 10,
|
||||
},
|
||||
"h2": {
|
||||
"name": "Show.mkv",
|
||||
"state": "uploading",
|
||||
"size": 2000,
|
||||
"progress": 1.0,
|
||||
"dlspeed": 0,
|
||||
"upspeed": 100,
|
||||
},
|
||||
"h3": {
|
||||
"name": "Queued",
|
||||
"state": "queuedDL",
|
||||
"size": 3000,
|
||||
"progress": 0.0,
|
||||
"dlspeed": 0,
|
||||
"upspeed": 0,
|
||||
},
|
||||
"h4": {
|
||||
"name": "Paused",
|
||||
"state": "pausedDL",
|
||||
"size": 4000,
|
||||
"progress": 0.3,
|
||||
"dlspeed": 0,
|
||||
"upspeed": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_totals_counts_all_torrents():
|
||||
"""Totals kind returns count of all listed items + by_state breakdown."""
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="svc-1",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
|
||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||
result = await adapter.fetch(service, "totals", {})
|
||||
|
||||
assert result["total"] == 4
|
||||
assert result["by_state"]["downloading"] == 1
|
||||
assert result["by_state"]["uploading"] == 1
|
||||
assert result["by_state"]["queuedDL"] == 1
|
||||
assert result["by_state"]["pausedDL"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_active_filters_dl_ul_only():
|
||||
"""Active kind returns only downloading/uploading torrents (Q3)."""
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="svc-1",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
|
||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||
result = await adapter.fetch(service, "active", {})
|
||||
|
||||
active = result["torrents"]
|
||||
assert len(active) == 2
|
||||
names = [t["name"] for t in active]
|
||||
assert "Movie.mkv" in names
|
||||
assert "Show.mkv" in names
|
||||
# Queued and paused are excluded
|
||||
assert "Queued" not in names
|
||||
assert "Paused" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
|
||||
"""Speed kind appends a sample and returns {series} with two labeled series."""
|
||||
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
|
||||
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
# Isolated harness so we don't pollute the real DB
|
||||
harness = ServiceDataHarness(base_dir=str(tmp_path))
|
||||
harness.register(QBITTORRENT_CONCERN)
|
||||
harness.run_migrations()
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="svc-speed",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with (
|
||||
patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client,
|
||||
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
|
||||
):
|
||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||
# Wire the mock store to a real isolated store
|
||||
real_store = QbittorrentSampleStore(harness)
|
||||
mock_store_cls.return_value = real_store
|
||||
result = await adapter.fetch(service, "speed", {})
|
||||
|
||||
assert "series" in result
|
||||
labels = [s["label"] for s in result["series"]]
|
||||
assert labels == ["download", "upload"]
|
||||
# The sample just appended should be present
|
||||
dl_points = result["series"][0]["points"]
|
||||
assert len(dl_points) >= 1
|
||||
# timestamps multiplied by 1000 for JS epoch
|
||||
assert dl_points[-1]["v"] == 500000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_adapter_missing_service():
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
result = await adapter.fetch(None, "totals", {})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_adapter_missing_credentials():
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080"},
|
||||
secrets={"username": "", "password": ""},
|
||||
)
|
||||
result = await adapter.fetch(service, "totals", {})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_adapter_timeout():
|
||||
"""A timeout returns {error} rather than raising."""
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 1},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _slow(*a, **kw):
|
||||
await _asyncio.sleep(10)
|
||||
|
||||
# Make to_thread hang so wait_for times out
|
||||
mock_client.return_value.maindata.side_effect = lambda: (_ for _ in ()).throw(TimeoutError())
|
||||
result = await adapter.fetch(service, "totals", {})
|
||||
assert "error" in result
|
||||
|
||||
Generated
+367
-1
@@ -20,6 +20,7 @@
|
||||
"react-dom": "^19.2.5",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"recharts": "^3.9.2",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
@@ -2997,6 +2998,32 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
@@ -3283,7 +3310,12 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
@@ -3772,6 +3804,69 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -3852,6 +3947,12 @@
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -4990,6 +5091,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
@@ -5037,6 +5259,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -5311,6 +5539,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5553,6 +5791,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -6291,6 +6535,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
||||
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -6333,6 +6587,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
@@ -8109,6 +8372,13 @@
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-oidc-context": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz",
|
||||
@@ -8122,6 +8392,29 @@
|
||||
"react": ">=16.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -8254,6 +8547,36 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
|
||||
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -8268,6 +8591,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -8286,6 +8624,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -9377,6 +9721,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"react-dom": "^19.2.5",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"recharts": "^3.9.2",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
@@ -32,7 +32,6 @@ import type {
|
||||
DashboardShortcutInput,
|
||||
AlertmanagerAlertSummary,
|
||||
AlertmanagerStatus,
|
||||
GrafanaStatus,
|
||||
PrometheusStatus,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
@@ -299,9 +298,6 @@ export const fetchAlertmanagerAlerts = () =>
|
||||
export const fetchAlertmanagerStatus = () =>
|
||||
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
||||
|
||||
export const fetchGrafanaStatus = () =>
|
||||
get<GrafanaStatus>("/api/monitoring/grafana-status");
|
||||
|
||||
export const fetchPrometheusStatus = () =>
|
||||
get<PrometheusStatus>("/api/monitoring/prometheus-status");
|
||||
|
||||
|
||||
@@ -6,14 +6,35 @@ import type {
|
||||
WidgetInstanceInput,
|
||||
} from "../types";
|
||||
|
||||
export interface WidgetReference {
|
||||
id: string;
|
||||
dashboard_scope: string;
|
||||
widget_id: string;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
widget: WidgetInstance;
|
||||
}
|
||||
|
||||
export interface WidgetReferenceInput {
|
||||
dashboard_scope: string;
|
||||
widget_id: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export async function fetchBuiltinWidgetKinds(): Promise<
|
||||
BuiltinWidgetKindInfo[]
|
||||
> {
|
||||
return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
|
||||
}
|
||||
|
||||
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
||||
return get<WidgetInstance[]>("/api/widgets/instances");
|
||||
export async function fetchWidgetInstances(
|
||||
serviceId?: string,
|
||||
scope?: "dashboard" | "service",
|
||||
): Promise<WidgetInstance[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (serviceId) params.service_id = serviceId;
|
||||
if (scope) params.scope = scope;
|
||||
return get<WidgetInstance[]>("/api/widgets/instances", params);
|
||||
}
|
||||
|
||||
export async function createWidgetInstance(
|
||||
@@ -40,3 +61,39 @@ export async function fetchWidgetData(
|
||||
): Promise<WidgetDataResponse> {
|
||||
return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`);
|
||||
}
|
||||
|
||||
export async function fetchWidgetReferences(
|
||||
dashboardScope: string,
|
||||
): Promise<WidgetReference[]> {
|
||||
return get<WidgetReference[]>("/api/widgets/references", {
|
||||
dashboard_scope: dashboardScope,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWidgetReference(
|
||||
input: WidgetReferenceInput,
|
||||
): Promise<WidgetReference> {
|
||||
return post<WidgetReference>("/api/widgets/references", input);
|
||||
}
|
||||
|
||||
export async function deleteWidgetReference(
|
||||
referenceId: string,
|
||||
): Promise<{ status: string }> {
|
||||
return del<{ status: string }>(`/api/widgets/references/${referenceId}`);
|
||||
}
|
||||
|
||||
export async function detachWidgetReference(
|
||||
referenceId: string,
|
||||
): Promise<WidgetInstance> {
|
||||
return post<WidgetInstance>(`/api/widgets/references/${referenceId}/detach`);
|
||||
}
|
||||
|
||||
export async function updateWidgetReference(
|
||||
referenceId: string,
|
||||
sortOrder: number,
|
||||
): Promise<WidgetReference> {
|
||||
return put<WidgetReference>(
|
||||
`/api/widgets/references/${referenceId}?sort_order=${sortOrder}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
export interface SeriesPoint {
|
||||
t: number;
|
||||
v: number | null;
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string;
|
||||
points: SeriesPoint[];
|
||||
}
|
||||
|
||||
/** Merge multiple time-series into a single recharts-friendly array. */
|
||||
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
for (const s of series) {
|
||||
for (const p of s.points) {
|
||||
const existing = map.get(p.t) ?? { time: p.t };
|
||||
existing[s.label] = p.v;
|
||||
map.set(p.t, existing);
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort(
|
||||
(a, b) => (a.time as number) - (b.time as number),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
"var(--color-chart-1)",
|
||||
"var(--color-chart-2)",
|
||||
"var(--color-chart-3)",
|
||||
"var(--color-chart-4)",
|
||||
"var(--color-chart-5)",
|
||||
];
|
||||
|
||||
interface LineSeriesChartProps {
|
||||
series: ChartSeries[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
|
||||
export function LineSeriesChart({
|
||||
series,
|
||||
height = 300,
|
||||
}: LineSeriesChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={mergeSeries(series)}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={formatTime}
|
||||
tick={{ fontSize: 11 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => formatTime(Number(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "0.5rem",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
}}
|
||||
/>
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -18,11 +19,24 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Link2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Split,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useCreateWidgetReference,
|
||||
useDeleteWidgetInstance,
|
||||
useDeleteWidgetReference,
|
||||
useDetachWidgetReference,
|
||||
useSaveWidgetInstance,
|
||||
useUpdateWidgetReference,
|
||||
useWidgetInstances,
|
||||
useWidgetReferences,
|
||||
} from "../hooks/useWidgets";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useTasks } from "../hooks/useSettings";
|
||||
@@ -38,6 +52,12 @@ import {
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** When set, scope the dialog to a specific service instance's widgets. */
|
||||
serviceId?: string;
|
||||
/** When set, enable widget references ("Add existing") for this dashboard scope. */
|
||||
dashboardScope?: string;
|
||||
/** When set, auto-open in edit mode for this widget id (instead of the list view). */
|
||||
editWidgetId?: string;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
@@ -135,6 +155,12 @@ function WidgetConfigEditor({
|
||||
const isNumber =
|
||||
(schema as { type?: string }).type === "integer" ||
|
||||
(schema as { type?: string }).type === "number";
|
||||
// Use a multi-line textarea for fields that tend to hold complex
|
||||
// multi-line values (PromQL, text blocks, etc.). The widget kind's
|
||||
// config schema can opt in via `format: "textarea"`; the well-known
|
||||
// `query` field is treated as textarea by default.
|
||||
const schemaFormat = (schema as { format?: string }).format;
|
||||
const isTextarea = schemaFormat === "textarea" || key === "query";
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
@@ -142,21 +168,31 @@ function WidgetConfigEditor({
|
||||
htmlFor={`widget-cfg-${key}`}
|
||||
helper={(schema as { description?: string }).description}
|
||||
>
|
||||
<Input
|
||||
id={`widget-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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
id={`widget-cfg-${key}`}
|
||||
rows={4}
|
||||
className="resize-y font-mono text-xs"
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`widget-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>
|
||||
);
|
||||
})}
|
||||
@@ -164,27 +200,56 @@ function WidgetConfigEditor({
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
const { data: instances = [] } = useWidgetInstances();
|
||||
export function WidgetConfigDialog({
|
||||
open,
|
||||
onClose,
|
||||
serviceId,
|
||||
dashboardScope,
|
||||
editWidgetId,
|
||||
}: Props) {
|
||||
// When editing a dashboard (no serviceId), scope to dashboard-only widgets
|
||||
// (service_id IS NULL) so service-scoped widgets don't leak into the list.
|
||||
const { data: instances = [] } = useWidgetInstances(
|
||||
serviceId,
|
||||
!serviceId && dashboardScope ? "dashboard" : undefined,
|
||||
);
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveWidget = useSaveWidgetInstance();
|
||||
const deleteWidget = useDeleteWidgetInstance();
|
||||
const { data: references = [] } = useWidgetReferences(dashboardScope);
|
||||
const createRef = useCreateWidgetReference();
|
||||
const deleteRef = useDeleteWidgetReference();
|
||||
const detachRef = useDetachWidgetReference();
|
||||
const updateRef = useUpdateWidgetReference();
|
||||
const { data: allWidgets = [] } = useWidgetInstances();
|
||||
const [showExisting, setShowExisting] = useState(false);
|
||||
const [existingSearch, setExistingSearch] = useState("");
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [draftBaseline, setDraftBaseline] = useState<Draft | null>(null);
|
||||
// When opened via editWidgetId, closing the edit should close the dialog
|
||||
// entirely (not fall back to the list view).
|
||||
const directEdit = Boolean(editWidgetId);
|
||||
|
||||
const sortedInstances = useMemo(
|
||||
() =>
|
||||
[...instances].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||
),
|
||||
[instances],
|
||||
);
|
||||
// When editWidgetId is set and the dialog opens, auto-enter edit mode for
|
||||
// that widget (instead of showing the list view).
|
||||
useEffect(() => {
|
||||
if (open && editWidgetId) {
|
||||
// Search both owned widgets and referenced widgets.
|
||||
const target =
|
||||
instances.find((w) => w.id === editWidgetId) ??
|
||||
references.find((r) => r.widget.id === editWidgetId)?.widget;
|
||||
if (target) {
|
||||
startEdit(target);
|
||||
}
|
||||
}
|
||||
}, [open, editWidgetId, instances, references]);
|
||||
|
||||
function startAddBuiltIn(kind: string) {
|
||||
const binding = BUILTIN_WIDGETS[kind];
|
||||
setDraft({
|
||||
serviceId: null,
|
||||
serviceId: serviceId ?? null,
|
||||
widgetKind: kind,
|
||||
title: binding?.name ?? kind,
|
||||
config: { ...(binding?.defaultConfig ?? {}) },
|
||||
@@ -208,7 +273,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
}
|
||||
|
||||
function startEdit(instance: WidgetInstance) {
|
||||
setDraft({
|
||||
const d: Draft = {
|
||||
id: instance.id,
|
||||
serviceId: instance.service_id,
|
||||
widgetKind: instance.widget_kind,
|
||||
@@ -216,11 +281,20 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
config: instance.config,
|
||||
enabled: instance.enabled,
|
||||
sortOrder: instance.sort_order,
|
||||
});
|
||||
};
|
||||
setDraft(d);
|
||||
setDraftBaseline(d);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
// If we were opened via direct edit, closing should close the dialog
|
||||
// entirely, not fall back to the list view.
|
||||
if (directEdit) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setDraft(null);
|
||||
setDraftBaseline(null);
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
@@ -252,19 +326,82 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
|
||||
async function moveInstance(index: number, direction: -1 | 1) {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
|
||||
const a = sortedInstances[index];
|
||||
const b = sortedInstances[targetIndex];
|
||||
await Promise.all([
|
||||
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }),
|
||||
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }),
|
||||
]);
|
||||
if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
|
||||
// Swap the two items in a copy, then renumber ALL items by their new
|
||||
// index position (index * 10). This guarantees the sort_order values
|
||||
// change even when both items previously shared the same value (e.g. 0).
|
||||
const reordered = [...combinedWidgets];
|
||||
const tmp = reordered[index];
|
||||
reordered[index] = reordered[targetIndex];
|
||||
reordered[targetIndex] = tmp;
|
||||
// Sequential (not Promise.all) to avoid cache-invalidation race.
|
||||
for (let i = 0; i < reordered.length; i++) {
|
||||
const item = reordered[i];
|
||||
const newSortOrder = i * 10;
|
||||
const refId = (item as { _ref_id?: string })._ref_id;
|
||||
if (refId) {
|
||||
await updateRef.mutateAsync({
|
||||
referenceId: refId,
|
||||
sortOrder: newSortOrder,
|
||||
});
|
||||
} else {
|
||||
await saveWidget.mutateAsync({ ...item, sort_order: newSortOrder });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeInstance(instance: WidgetInstance) {
|
||||
await deleteWidget.mutateAsync(instance.id);
|
||||
}
|
||||
|
||||
// Build a combined view of owned widgets + references for display.
|
||||
const owned = [...instances].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||
);
|
||||
const refs = references.map((r) => ({
|
||||
...r.widget,
|
||||
_ref_id: r.id,
|
||||
_is_reference: true as const,
|
||||
}));
|
||||
const combinedWidgets = [...owned, ...refs].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||
);
|
||||
|
||||
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
|
||||
|
||||
// Available widgets for the "Add existing" picker: all widgets not already
|
||||
// on this dashboard (owned or referenced).
|
||||
const availableWidgets = useMemo(() => {
|
||||
const onDashboard = new Set([
|
||||
...instances.map((w) => w.id),
|
||||
...referencedWidgetIds,
|
||||
]);
|
||||
const search = existingSearch.toLowerCase().trim();
|
||||
return allWidgets
|
||||
.filter((w) => !onDashboard.has(w.id))
|
||||
.filter(
|
||||
(w) =>
|
||||
!search ||
|
||||
w.title.toLowerCase().includes(search) ||
|
||||
w.widget_kind.toLowerCase().includes(search),
|
||||
);
|
||||
}, [allWidgets, instances, referencedWidgetIds, existingSearch]);
|
||||
|
||||
async function handleAddReference(widgetId: string) {
|
||||
await createRef.mutateAsync({
|
||||
dashboard_scope: dashboardScope!,
|
||||
widget_id: widgetId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemoveReference(refId: string) {
|
||||
await deleteRef.mutateAsync(refId);
|
||||
}
|
||||
|
||||
async function handleDetach(refId: string) {
|
||||
await detachRef.mutateAsync(refId);
|
||||
}
|
||||
|
||||
function handleClose(next: boolean) {
|
||||
if (!next) {
|
||||
reset();
|
||||
@@ -332,10 +469,18 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={reset} className="mobile-touch-target">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={saveDraft} disabled={saveWidget.isPending} className="mobile-touch-target">
|
||||
<Button
|
||||
onClick={saveDraft}
|
||||
disabled={saveWidget.isPending}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Save widget
|
||||
</Button>
|
||||
</div>
|
||||
@@ -343,16 +488,19 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{sortedInstances.length === 0 ? (
|
||||
{combinedWidgets.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedInstances.map((instance, index) => {
|
||||
{combinedWidgets.map((instance, index) => {
|
||||
const serviceName = instance.service_id
|
||||
? services.find((s) => s.id === instance.service_id)?.name
|
||||
: "Built-in";
|
||||
const isRef =
|
||||
(instance as { _is_reference?: boolean })._is_reference === true;
|
||||
const refId = (instance as { _ref_id?: string })._ref_id;
|
||||
return (
|
||||
<div
|
||||
key={instance.id}
|
||||
@@ -361,6 +509,12 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{instance.title}</span>
|
||||
{isRef ? (
|
||||
<Badge variant="secondary">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
linked
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline">
|
||||
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||
</Badge>
|
||||
@@ -388,30 +542,52 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mobile-touch-target h-8 w-8"
|
||||
disabled={index === sortedInstances.length - 1}
|
||||
disabled={index === combinedWidgets.length - 1}
|
||||
onClick={() => moveInstance(index, 1)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch
|
||||
className="mobile-touch-target"
|
||||
checked={instance.enabled}
|
||||
onCheckedChange={() => toggleEnabled(instance)}
|
||||
aria-label={`Toggle ${instance.title}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mobile-touch-target h-8 w-8"
|
||||
onClick={() => startEdit(instance)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{!isRef ? (
|
||||
<Switch
|
||||
className="mobile-touch-target"
|
||||
checked={instance.enabled}
|
||||
onCheckedChange={() => toggleEnabled(instance)}
|
||||
aria-label={`Toggle ${instance.title}`}
|
||||
/>
|
||||
) : null}
|
||||
{!isRef ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mobile-touch-target h-8 w-8"
|
||||
onClick={() => startEdit(instance)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{isRef && refId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mobile-touch-target h-8 w-8"
|
||||
title="Make an independent copy"
|
||||
onClick={() => handleDetach(refId)}
|
||||
>
|
||||
<Split className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mobile-touch-target h-8 w-8 text-destructive"
|
||||
onClick={() => removeInstance(instance)}
|
||||
title={
|
||||
isRef ? "Remove from this dashboard" : "Delete widget"
|
||||
}
|
||||
onClick={() =>
|
||||
isRef && refId
|
||||
? handleRemoveReference(refId)
|
||||
: removeInstance(instance)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -422,6 +598,65 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboardScope ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setShowExisting(!showExisting)}
|
||||
>
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
{showExisting ? "Hide" : "Add existing widget"}
|
||||
</Button>
|
||||
{showExisting ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
placeholder="Search widgets..."
|
||||
value={existingSearch}
|
||||
onChange={(e) => setExistingSearch(e.target.value)}
|
||||
/>
|
||||
{availableWidgets.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No widgets available to reuse.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{availableWidgets.map((w) => {
|
||||
const owner = w.service_id
|
||||
? services.find((s) => s.id === w.service_id)?.name
|
||||
: "Dashboard";
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
className="flex items-center gap-2 rounded border p-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="text-sm font-medium">{w.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{bindingLabel(w.service_id, w.widget_kind)} ·{" "}
|
||||
{owner}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mobile-touch-target"
|
||||
onClick={() => handleAddReference(w.id)}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Add widget</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -439,6 +674,9 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
))}
|
||||
{services
|
||||
.filter((s) => s.enabled)
|
||||
// When scoped to a service Overview, only show widgets for THAT
|
||||
// service instance's type (not all services' widgets).
|
||||
.filter((s) => !serviceId || s.id === serviceId)
|
||||
.flatMap((s) =>
|
||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
||||
<Button
|
||||
@@ -479,7 +717,11 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
onCancel={draft ? reset : () => handleClose(false)}
|
||||
saveLabel={draft ? "Save widget" : "Done"}
|
||||
isPending={draft ? saveWidget.isPending : false}
|
||||
isDirty={draft !== null}
|
||||
isDirty={
|
||||
draft !== null && draftBaseline !== null
|
||||
? JSON.stringify(draft) !== JSON.stringify(draftBaseline)
|
||||
: draft !== null && draft?.id === undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">{draftBody}</div>
|
||||
</SheetForm>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings2, Copy } from "lucide-react";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { resolveWidget } from "../integrations/registry";
|
||||
import type { WidgetInstance } from "../types";
|
||||
@@ -6,31 +8,69 @@ import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
/** When provided, an edit button appears in the top-right corner (hover on desktop, always on mobile). */
|
||||
onEdit?: (widgetId: string) => void;
|
||||
/** When provided, a copy/detach button appears next to edit (for referenced widgets). */
|
||||
onCopy?: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
export function WidgetInstanceCard({ widget }: Props) {
|
||||
export function WidgetInstanceCard({ widget, onEdit, onCopy }: Props) {
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const resolved = resolveWidget(widget, services);
|
||||
|
||||
// Edit + copy buttons: always visible on mobile (below md), hover-reveal on desktop.
|
||||
const actionButtons = (
|
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
|
||||
{onCopy ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 mobile-touch-target"
|
||||
onClick={() => onCopy(widget.id)}
|
||||
aria-label="Create independent copy"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onEdit ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 mobile-touch-target"
|
||||
onClick={() => onEdit(widget.id)}
|
||||
aria-label="Edit widget"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!resolved) {
|
||||
const label = widget.service_id
|
||||
? `Unknown widget: ${widget.widget_kind} (service-bound)`
|
||||
: `Unknown widget: ${widget.widget_kind} (built-in)`;
|
||||
return (
|
||||
<SectionCard title={widget.title}>
|
||||
<Alert>
|
||||
<AlertDescription>{label}</AlertDescription>
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
<div className="group relative">
|
||||
{onEdit || onCopy ? actionButtons : null}
|
||||
<SectionCard title={widget.title}>
|
||||
<Alert>
|
||||
<AlertDescription>{label}</AlertDescription>
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = resolved.component;
|
||||
return (
|
||||
<Component
|
||||
widget={widget}
|
||||
refreshIntervalMs={resolved.refreshIntervalMs}
|
||||
description={resolved.description}
|
||||
/>
|
||||
<div className="group relative">
|
||||
{onEdit || onCopy ? actionButtons : null}
|
||||
<Component
|
||||
widget={widget}
|
||||
refreshIntervalMs={resolved.refreshIntervalMs}
|
||||
description={resolved.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { LineSeriesChart } from "../LineSeriesChart";
|
||||
import type { ChartSeries } from "../LineSeriesChart";
|
||||
|
||||
describe("LineSeriesChart", () => {
|
||||
it("renders without crashing with series data", () => {
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
label: "cpu",
|
||||
points: [
|
||||
{ t: 1000, v: 0.5 },
|
||||
{ t: 2000, v: 0.8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const { container } = render(<LineSeriesChart series={series} />);
|
||||
// ResponsiveContainer renders a wrapper div even in jsdom
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders without crashing with empty series", () => {
|
||||
const { container } = render(<LineSeriesChart series={[]} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders with custom height", () => {
|
||||
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
||||
const { container } = render(
|
||||
<LineSeriesChart series={series} height={200} />,
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,13 @@ function setMatchMedia(matches: boolean) {
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
useWidgetReferences: () => ({ data: [] }),
|
||||
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||
useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||
useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
|
||||
@@ -1,166 +1,162 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
import * as React from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ export function SheetForm({
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
|
||||
className="flex h-[100dvh] w-full flex-col gap-0 p-0 data-[side=bottom]:h-[100dvh] sm:max-w-full"
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Prevent Radix's default Escape close so our guard runs instead.
|
||||
if (isDirty) {
|
||||
|
||||
@@ -14,7 +14,7 @@ function Tabs({
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +23,7 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -61,10 +61,10 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -54,6 +54,10 @@ export function useBuildIndex(jellyfinServiceId?: string) {
|
||||
onSuccess: () => {
|
||||
invalidateMedia(queryClient);
|
||||
},
|
||||
onError: () => {
|
||||
// Invalidate status so the UI reflects the current (non-building) state.
|
||||
invalidateMedia(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchAlertmanagerAlerts,
|
||||
fetchAlertmanagerStatus,
|
||||
fetchGrafanaStatus,
|
||||
fetchPrometheusStatus,
|
||||
fetchPrometheusTargets,
|
||||
fetchMonitoringMachines,
|
||||
@@ -28,16 +27,6 @@ export function useAlertmanagerStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrafanaStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "grafana-status"],
|
||||
queryFn: fetchGrafanaStatus,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrometheusStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "prometheus-status"],
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createWidgetInstance,
|
||||
createWidgetReference,
|
||||
deleteWidgetInstance,
|
||||
deleteWidgetReference,
|
||||
detachWidgetReference,
|
||||
fetchBuiltinWidgetKinds,
|
||||
fetchWidgetData,
|
||||
fetchWidgetInstances,
|
||||
fetchWidgetReferences,
|
||||
updateWidgetInstance,
|
||||
updateWidgetReference,
|
||||
} from "../api/widgets";
|
||||
import type { WidgetInstanceInput } from "../types";
|
||||
|
||||
export function useWidgetInstances() {
|
||||
export function useWidgetInstances(
|
||||
serviceId?: string,
|
||||
scope?: "dashboard" | "service",
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "instances"],
|
||||
queryFn: fetchWidgetInstances,
|
||||
queryKey: ["widgets", "instances", serviceId ?? null, scope ?? null],
|
||||
queryFn: () => fetchWidgetInstances(serviceId, scope),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -55,3 +63,58 @@ export function useBuiltinWidgetKinds() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetReferences(dashboardScope: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "references", dashboardScope ?? null],
|
||||
queryFn: () => fetchWidgetReferences(dashboardScope!),
|
||||
enabled: !!dashboardScope,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWidgetReference() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createWidgetReference,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWidgetReference() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteWidgetReference,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetachWidgetReference() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: detachWidgetReference,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWidgetReference() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
referenceId,
|
||||
sortOrder,
|
||||
}: {
|
||||
referenceId: string;
|
||||
sortOrder: number;
|
||||
}) => updateWidgetReference(referenceId, sortOrder),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #4f8cff;
|
||||
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||
/* Status semantic cues — single source of truth for Badges.
|
||||
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||
chart-4=destructive, chart-5=neutral-accent. */
|
||||
--color-chart-1: #4f8cff;
|
||||
@@ -62,7 +62,7 @@
|
||||
--color-border: #334155;
|
||||
--color-input: #334155;
|
||||
--color-ring: #4f8cff;
|
||||
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||
/* Status semantic cues — single source of truth for Badges.
|
||||
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||
chart-4=destructive, chart-5=neutral-accent. */
|
||||
--color-chart-1: #4f8cff;
|
||||
|
||||
@@ -6,33 +6,29 @@ describe("navEntries", () => {
|
||||
expect(configuredNavEntries(new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns Media when jellyfin is configured", () => {
|
||||
it("returns Jellyfin when jellyfin is configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["jellyfin"]));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe("Media");
|
||||
expect(entries[0].label).toBe("Jellyfin");
|
||||
expect(entries[0].path).toBe("/services/jellyfin");
|
||||
});
|
||||
|
||||
it("returns Files + Actions when ssh_tasks is configured", () => {
|
||||
it("returns one SSH Tasks entry when ssh_tasks is configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries.map((e) => e.label)).toEqual(["Files", "Actions"]);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe("SSH Tasks");
|
||||
});
|
||||
|
||||
it("returns all observability entries", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["alertmanager", "grafana", "prometheus"]),
|
||||
new Set(["alertmanager", "prometheus"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Alerts",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
]);
|
||||
expect(entries.map((e) => e.label)).toEqual(["Alertmanager", "Prometheus"]);
|
||||
});
|
||||
|
||||
it("returns Backups + Users when configured", () => {
|
||||
it("returns Backups + Authentik when configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
|
||||
expect(entries.map((e) => e.label)).toEqual(["Backups", "Users"]);
|
||||
expect(entries.map((e) => e.label)).toEqual(["Backups", "Authentik"]);
|
||||
});
|
||||
|
||||
it("nextcloud has no nav entries in the static map", () => {
|
||||
@@ -46,10 +42,9 @@ describe("navEntries", () => {
|
||||
new Set(["authentik", "ssh_tasks", "jellyfin"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Media",
|
||||
"Files",
|
||||
"Actions",
|
||||
"Users",
|
||||
"Jellyfin",
|
||||
"SSH Tasks",
|
||||
"Authentik",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
/**
|
||||
* Service-type → conditional nav-entry map.
|
||||
*
|
||||
* Each configured service type contributes one or more top-level nav entries
|
||||
* that appear only when at least one enabled instance of that type exists.
|
||||
* See OpenSpec change `services-as-hub-ia`, spec R1.2.
|
||||
* Each configured service type contributes ONE top-level nav entry that
|
||||
* appears only when at least one enabled instance of that type exists. The
|
||||
* label is the service TYPE name (Jellyfin, SSH Tasks), not a conceptual
|
||||
* name (Media, Files) — the service page's content tabs surface the concepts.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
FolderOpen,
|
||||
GanttChartSquare,
|
||||
Link2,
|
||||
Monitor,
|
||||
Server,
|
||||
Users,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -26,40 +25,29 @@ export interface NavEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Static mapping from service type to its conditional nav entries.
|
||||
* `nextcloud` has no entries (no operational content).
|
||||
* Static mapping from service type to its conditional nav entry.
|
||||
* Uses the service type's display name. One entry per type.
|
||||
* `nextcloud` has no entry (no operational content tabs).
|
||||
*/
|
||||
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||
{
|
||||
serviceType: "jellyfin",
|
||||
label: "Media",
|
||||
label: "Jellyfin",
|
||||
icon: Monitor,
|
||||
path: "/services/jellyfin",
|
||||
},
|
||||
{
|
||||
serviceType: "ssh_tasks",
|
||||
label: "Files",
|
||||
icon: FolderOpen,
|
||||
path: "/services/ssh_tasks",
|
||||
},
|
||||
{
|
||||
serviceType: "ssh_tasks",
|
||||
label: "Actions",
|
||||
icon: Zap,
|
||||
label: "SSH Tasks",
|
||||
icon: Server,
|
||||
path: "/services/ssh_tasks",
|
||||
},
|
||||
{
|
||||
serviceType: "alertmanager",
|
||||
label: "Alerts",
|
||||
label: "Alertmanager",
|
||||
icon: Activity,
|
||||
path: "/services/alertmanager",
|
||||
},
|
||||
{
|
||||
serviceType: "grafana",
|
||||
label: "Grafana",
|
||||
icon: Link2,
|
||||
path: "/services/grafana",
|
||||
},
|
||||
{
|
||||
serviceType: "prometheus",
|
||||
label: "Prometheus",
|
||||
@@ -74,7 +62,7 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||
},
|
||||
{
|
||||
serviceType: "authentik",
|
||||
label: "Users",
|
||||
label: "Authentik",
|
||||
icon: Users,
|
||||
path: "/services/authentik",
|
||||
},
|
||||
|
||||
@@ -12,17 +12,20 @@ describe("service registry", () => {
|
||||
it("registers the backend service types", () => {
|
||||
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||
"alertmanager",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"qbittorrent",
|
||||
"ssh_tasks",
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds widget kinds per service", () => {
|
||||
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
||||
"link",
|
||||
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
|
||||
"metric",
|
||||
"chart",
|
||||
"gauge",
|
||||
"mean",
|
||||
]);
|
||||
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
||||
"active_alerts",
|
||||
@@ -37,12 +40,12 @@ describe("service registry", () => {
|
||||
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
|
||||
});
|
||||
|
||||
it("resolves a service-bound widget via the services list", () => {
|
||||
it("resolves a prometheus metric widget via the services list", () => {
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "link",
|
||||
title: "Dashboard",
|
||||
widget_kind: "metric",
|
||||
title: "Metric",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
@@ -52,9 +55,9 @@ describe("service registry", () => {
|
||||
const services: ServiceInstance[] = [
|
||||
{
|
||||
id: "s1",
|
||||
service_type: "grafana",
|
||||
name: "Grafana",
|
||||
config: { base_url: "https://grafana.example.com" },
|
||||
service_type: "prometheus",
|
||||
name: "Prometheus",
|
||||
config: { base_url: "https://prometheus.example.com" },
|
||||
secrets_set: { api_key: true },
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
@@ -63,7 +66,7 @@ describe("service registry", () => {
|
||||
];
|
||||
const resolved = resolveWidget(widget, services);
|
||||
expect(resolved).toBeDefined();
|
||||
expect(resolved?.refreshIntervalMs).toBe(0);
|
||||
expect(resolved?.refreshIntervalMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it("resolves an alertmanager active_alerts widget", () => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
|
||||
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
|
||||
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
|
||||
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
|
||||
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
||||
import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget";
|
||||
import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget";
|
||||
import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget";
|
||||
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||
import { StaticWidget } from "../widgets/StaticWidget";
|
||||
import type {
|
||||
@@ -65,29 +71,6 @@ 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",
|
||||
@@ -106,6 +89,109 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
},
|
||||
component: PrometheusMetricWidget,
|
||||
},
|
||||
{
|
||||
kind: "chart",
|
||||
name: "Chart",
|
||||
description: "Multi-series line chart from a PromQL range query.",
|
||||
refreshIntervalMs: 60_000,
|
||||
defaultConfig: { promql: "", window: "1h" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
promql: {
|
||||
type: "string",
|
||||
description: "PromQL range query expression",
|
||||
},
|
||||
window: {
|
||||
type: "string",
|
||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
||||
},
|
||||
},
|
||||
required: ["promql"],
|
||||
},
|
||||
component: PrometheusChartWidget,
|
||||
},
|
||||
{
|
||||
kind: "gauge",
|
||||
name: "Gauge",
|
||||
description:
|
||||
"Instant query rendered as a gauge with optional threshold bands.",
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfig: { promql: "" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
promql: {
|
||||
type: "string",
|
||||
description: "PromQL instant query (must return a single scalar)",
|
||||
},
|
||||
warn_at: { type: "number", description: "Warning threshold" },
|
||||
crit_at: { type: "number", description: "Critical threshold" },
|
||||
min: { type: "number" },
|
||||
max: { type: "number" },
|
||||
unit: { type: "string" },
|
||||
},
|
||||
required: ["promql"],
|
||||
},
|
||||
component: PrometheusGaugeWidget,
|
||||
},
|
||||
{
|
||||
kind: "mean",
|
||||
name: "Mean",
|
||||
description: "Average value of a PromQL query over a time window.",
|
||||
refreshIntervalMs: 60_000,
|
||||
defaultConfig: { promql: "", window: "1h" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
promql: {
|
||||
type: "string",
|
||||
description: "PromQL range query (must return a single series)",
|
||||
},
|
||||
window: {
|
||||
type: "string",
|
||||
description: "Time window preset (1h, 6h, 24h, 7d)",
|
||||
},
|
||||
unit: { type: "string" },
|
||||
},
|
||||
required: ["promql"],
|
||||
},
|
||||
component: PrometheusMeanWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
qbittorrent: {
|
||||
serviceType: "qbittorrent",
|
||||
name: "qBittorrent",
|
||||
description: "Torrent client activity, speeds, and item counts.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "totals",
|
||||
name: "Totals",
|
||||
description: "Count of all listed torrents, broken down by state.",
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentTotalsWidget,
|
||||
},
|
||||
{
|
||||
kind: "active",
|
||||
name: "Active torrents",
|
||||
description: "Torrents currently downloading or uploading.",
|
||||
refreshIntervalMs: 15_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentActiveTorrentsWidget,
|
||||
},
|
||||
{
|
||||
kind: "speed",
|
||||
name: "Speed chart",
|
||||
description: "Live download/upload speed over a short window.",
|
||||
refreshIntervalMs: 5_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentSpeedWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
jellyfin: {
|
||||
@@ -122,6 +208,15 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: JellyfinWidget,
|
||||
},
|
||||
{
|
||||
kind: "now_playing",
|
||||
name: "Now Playing",
|
||||
description: "Only sessions actively playing media.",
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: JellyfinNowPlayingWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
nextcloud: {
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
useDeleteDashboardShortcut,
|
||||
useSaveDashboardShortcut,
|
||||
} from "../hooks/useDashboard";
|
||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||
import {
|
||||
useDetachWidgetReference,
|
||||
useWidgetInstances,
|
||||
useWidgetReferences,
|
||||
} from "../hooks/useWidgets";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type {
|
||||
@@ -61,7 +65,7 @@ const SECTION_META: Record<
|
||||
custom: { label: "Custom", icon: LayoutDashboard },
|
||||
};
|
||||
|
||||
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
|
||||
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus"]);
|
||||
|
||||
function widgetSection(
|
||||
widget: WidgetInstance,
|
||||
@@ -97,8 +101,12 @@ function groupWidgetsBySection(
|
||||
|
||||
function MobileWidgetSections({
|
||||
sections,
|
||||
onEditWidget,
|
||||
onCopyWidget,
|
||||
}: {
|
||||
sections: { id: SectionId; widgets: WidgetInstance[] }[];
|
||||
onEditWidget?: (widgetId: string) => void;
|
||||
onCopyWidget?: (widgetId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -137,7 +145,12 @@ function MobileWidgetSections({
|
||||
{SECTION_META[section.id].label}
|
||||
</h3>
|
||||
{section.widgets.map((widget) => (
|
||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||
<WidgetInstanceCard
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onEdit={onEditWidget ? (id) => onEditWidget(id) : undefined}
|
||||
onCopy={onCopyWidget ? (id) => onCopyWidget(id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
@@ -146,7 +159,6 @@ function MobileWidgetSections({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function emptyShortcut(): DashboardShortcutInput {
|
||||
return {
|
||||
id: null,
|
||||
@@ -449,17 +461,30 @@ export function Dashboard() {
|
||||
);
|
||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||
const [editWidgetId, setEditWidgetId] = useState<string | undefined>();
|
||||
const { data: widgetInstances = [] } = useWidgetInstances(
|
||||
undefined,
|
||||
"dashboard",
|
||||
);
|
||||
const { data: widgetReferences = [] } = useWidgetReferences("main");
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() =>
|
||||
widgetInstances
|
||||
.filter((w) => w.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order),
|
||||
[widgetInstances],
|
||||
const visibleWidgets = useMemo(() => {
|
||||
const refs = widgetReferences
|
||||
.filter((r) => r.widget.enabled)
|
||||
.map((r) => r.widget);
|
||||
return [...widgetInstances, ...refs]
|
||||
.filter((w) => w.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}, [widgetInstances, widgetReferences]);
|
||||
|
||||
// Track which visible widgets are references (for the copy/detach button).
|
||||
const referencedWidgetIds = useMemo(
|
||||
() => new Set(widgetReferences.map((r) => r.widget.id)),
|
||||
[widgetReferences],
|
||||
);
|
||||
const detachRef = useDetachWidgetReference();
|
||||
|
||||
const mobileSections = useMemo(
|
||||
() => groupWidgetsBySection(visibleWidgets, services),
|
||||
@@ -560,10 +585,38 @@ export function Dashboard() {
|
||||
</SectionCard>
|
||||
|
||||
{isMobile && mobileSections.length > 0 ? (
|
||||
<MobileWidgetSections sections={mobileSections} />
|
||||
<MobileWidgetSections
|
||||
sections={mobileSections}
|
||||
onEditWidget={(id) => {
|
||||
setEditWidgetId(id);
|
||||
setWidgetDialogOpen(true);
|
||||
}}
|
||||
onCopyWidget={(id) => {
|
||||
const ref = widgetReferences.find((r) => r.widget.id === id);
|
||||
if (ref) detachRef.mutate(ref.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
visibleWidgets.map((widget) => (
|
||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||
<WidgetInstanceCard
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onEdit={(id) => {
|
||||
setEditWidgetId(id);
|
||||
setWidgetDialogOpen(true);
|
||||
}}
|
||||
onCopy={
|
||||
referencedWidgetIds.has(widget.id)
|
||||
? () => {
|
||||
// Detach: find the reference and clone it.
|
||||
const ref = widgetReferences.find(
|
||||
(r) => r.widget.id === widget.id,
|
||||
);
|
||||
if (ref) detachRef.mutate(ref.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -589,7 +642,12 @@ export function Dashboard() {
|
||||
/>
|
||||
<WidgetConfigDialog
|
||||
open={widgetDialogOpen}
|
||||
onClose={() => setWidgetDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setWidgetDialogOpen(false);
|
||||
setEditWidgetId(undefined);
|
||||
}}
|
||||
dashboardScope="main"
|
||||
editWidgetId={editWidgetId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { Boxes, Settings2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDashboardBySlug } from "../hooks/useDashboards";
|
||||
import { useWidgetReferences } from "../hooks/useWidgets";
|
||||
import { PinnedServiceLink } from "../components/PinnedServiceLink";
|
||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||
|
||||
/**
|
||||
* Payload model for named dashboards (design choice: inline items, not widget
|
||||
@@ -42,12 +46,25 @@ function parseItems(payload: Record<string, unknown>): DashboardItem[] {
|
||||
export function NamedDashboardPage() {
|
||||
const { slug = "" } = useParams<{ slug: string }>();
|
||||
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
|
||||
const dashboardScope = `named:${slug}`;
|
||||
const { data: widgetRefs = [] } = useWidgetReferences(dashboardScope);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [editWidgetId, setEditWidgetId] = useState<string | undefined>();
|
||||
|
||||
const items = useMemo(
|
||||
() => parseItems(dashboard?.payload ?? {}),
|
||||
[dashboard?.payload],
|
||||
);
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() =>
|
||||
widgetRefs
|
||||
.filter((r) => r.widget.enabled)
|
||||
.map((r) => r.widget)
|
||||
.sort((a, b) => a.sort_order - b.sort_order),
|
||||
[widgetRefs],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />;
|
||||
}
|
||||
@@ -64,17 +81,43 @@ export function NamedDashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mobile-touch-target"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
Edit widgets
|
||||
</Button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
|
||||
{visibleWidgets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{visibleWidgets.map((widget) => (
|
||||
<WidgetInstanceCard
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onEdit={(id) => {
|
||||
setEditWidgetId(id);
|
||||
setConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items.length === 0 && visibleWidgets.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This dashboard has no shortcuts yet. Add pinned service links from
|
||||
the dashboard management panel on the Services page.
|
||||
This dashboard is empty. Add widgets via "Edit widgets" or pinned
|
||||
service links from the dashboard management panel on the Services
|
||||
page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
) : items.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item, index) => (
|
||||
<PinnedServiceLink
|
||||
@@ -85,7 +128,17 @@ export function NamedDashboardPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<WidgetConfigDialog
|
||||
open={configOpen}
|
||||
onClose={() => {
|
||||
setConfigOpen(false);
|
||||
setEditWidgetId(undefined);
|
||||
}}
|
||||
dashboardScope={dashboardScope}
|
||||
editWidgetId={editWidgetId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useParams, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
useServiceTypes,
|
||||
} from "../hooks/useServices";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../types";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import type { ServiceInstance } from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { getServiceBinding } from "../integrations/registry";
|
||||
import {
|
||||
OVERVIEW_TAB,
|
||||
@@ -36,79 +13,30 @@ import {
|
||||
type ContentTab,
|
||||
} from "./service-tabs";
|
||||
|
||||
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 { data: types = [] } = useServiceTypes();
|
||||
const navigate = useNavigate();
|
||||
const saveService = useSaveServiceInstance();
|
||||
const deleteService = useDeleteServiceInstance();
|
||||
|
||||
const instance = useMemo(
|
||||
() => services.find((s) => s.id === serviceId),
|
||||
[services, serviceId],
|
||||
);
|
||||
const binding = getServiceBinding(serviceType);
|
||||
const typeInfo = useMemo(
|
||||
() => types.find((t) => t.service_type === serviceType),
|
||||
[types, serviceType],
|
||||
);
|
||||
const contentTabs = useMemo(
|
||||
() => serviceContentTabs(serviceType),
|
||||
[serviceType],
|
||||
);
|
||||
const siblings = useMemo(
|
||||
() => services.filter((s) => s.service_type === serviceType),
|
||||
() => services.filter((s) => s.service_type === serviceType && s.enabled),
|
||||
[services, serviceType],
|
||||
);
|
||||
// R3.1: switcher trigger keys off ENABLED siblings (not total).
|
||||
const enabledSiblings = useMemo(
|
||||
() => siblings.filter((s) => s.enabled),
|
||||
[siblings],
|
||||
);
|
||||
const showSwitcher = enabledSiblings.length > 1;
|
||||
const showInstanceTabs = siblings.length > 1;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
|
||||
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [sheetOpen, setSheetOpen] = useState(true);
|
||||
|
||||
if (instance && !hydrated) {
|
||||
setName(instance.name);
|
||||
setEnabled(instance.enabled);
|
||||
setDraftConfig({ ...instance.config });
|
||||
setDraftSecrets({});
|
||||
setHydrated(true);
|
||||
}
|
||||
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
@@ -126,32 +54,6 @@ export function ServicePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function buildInput(): ServiceInstanceInput {
|
||||
// R2.3/R10.1: collect typed secret drafts. Empty values mean "keep the
|
||||
// existing value" so they are filtered out before sending.
|
||||
const onlyChangedSecrets = Object.fromEntries(
|
||||
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||
);
|
||||
return {
|
||||
id: instance!.id,
|
||||
service_type: instance!.service_type,
|
||||
name,
|
||||
config: draftConfig,
|
||||
secrets: onlyChangedSecrets,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await saveService.mutateAsync(buildInput());
|
||||
// Clear secret drafts after a successful save so the inputs reset to
|
||||
// "leave blank to keep" state.
|
||||
setDraftSecrets({});
|
||||
}
|
||||
|
||||
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
|
||||
|
||||
// The config + widgets body, shared between desktop tabs and mobile SheetForm.
|
||||
const widgetsContent =
|
||||
binding.widgets.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -179,107 +81,37 @@ export function ServicePage() {
|
||||
</p>
|
||||
);
|
||||
|
||||
const configBody = (
|
||||
<ConfigBody
|
||||
instance={instance}
|
||||
typeInfo={typeInfo}
|
||||
draftConfig={draftConfig}
|
||||
onConfigChange={setDraftConfig}
|
||||
draftSecrets={draftSecrets}
|
||||
onSecretsChange={setDraftSecrets}
|
||||
name={name}
|
||||
enabled={enabled}
|
||||
onNameChange={setName}
|
||||
onEnabledChange={setEnabled}
|
||||
onSave={save}
|
||||
savePending={saveService.isPending}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Mobile: render inside a SheetForm (open on mount; cancel navigates back).
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SheetForm
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={name || instance.name}
|
||||
onSave={save}
|
||||
onCancel={() => {
|
||||
setSheetOpen(false);
|
||||
navigate("/services");
|
||||
}}
|
||||
isPending={saveService.isPending}
|
||||
isDirty={
|
||||
name !== instance.name ||
|
||||
enabled !== instance.enabled ||
|
||||
JSON.stringify(draftConfig) !== JSON.stringify(instance.config)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{allTabs.map((tab) => {
|
||||
const TabComponent = tab.Component;
|
||||
return (
|
||||
<div key={tab.label}>
|
||||
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
|
||||
{tab.label}
|
||||
</h3>
|
||||
<TabComponent instance={instance} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{widgetsContent}
|
||||
{configBody}
|
||||
</div>
|
||||
</SheetForm>
|
||||
<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);
|
||||
navigate("/services");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header + instance switcher */}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showSwitcher ? (
|
||||
<Select
|
||||
value={instance.id}
|
||||
onValueChange={(id) => navigate(`/services/${serviceType}/${id}`)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siblings.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Badge variant="outline">{binding.name}</Badge>
|
||||
</div>
|
||||
<Badge variant="outline">{binding.name}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tab skeleton */}
|
||||
{/* Instance tabs (only when >1 enabled sibling) */}
|
||||
{showInstanceTabs ? (
|
||||
<Tabs value={instance.id}>
|
||||
<TabsList>
|
||||
{siblings.map((sibling: ServiceInstance) => (
|
||||
<TabsTrigger
|
||||
key={sibling.id}
|
||||
value={sibling.id}
|
||||
onClick={() =>
|
||||
navigate(`/services/${serviceType}/${sibling.id}`)
|
||||
}
|
||||
>
|
||||
{sibling.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{/* Content tabs */}
|
||||
<Tabs defaultValue="Overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Overview">Overview</TabsTrigger>
|
||||
@@ -289,7 +121,6 @@ export function ServicePage() {
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
|
||||
<TabsTrigger value="Config">Config</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{allTabs.map((tab) => {
|
||||
@@ -309,168 +140,7 @@ export function ServicePage() {
|
||||
{widgetsContent}
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="Config">{configBody}</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<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);
|
||||
navigate("/services");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigBody({
|
||||
instance,
|
||||
typeInfo,
|
||||
draftConfig,
|
||||
onConfigChange,
|
||||
draftSecrets,
|
||||
onSecretsChange,
|
||||
name,
|
||||
enabled,
|
||||
onNameChange,
|
||||
onEnabledChange,
|
||||
onSave,
|
||||
savePending,
|
||||
onDelete,
|
||||
}: {
|
||||
instance: ServiceInstance;
|
||||
typeInfo: ServiceTypeInfo | undefined;
|
||||
draftConfig: Record<string, unknown>;
|
||||
onConfigChange: (config: Record<string, unknown>) => void;
|
||||
draftSecrets: Record<string, string>;
|
||||
onSecretsChange: (secrets: Record<string, string>) => void;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
onNameChange: (name: string) => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onSave: () => void;
|
||||
savePending: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const properties =
|
||||
(
|
||||
(typeInfo?.config_schema ?? {}) as {
|
||||
properties?: Record<
|
||||
string,
|
||||
{ type?: string; description?: string; default?: unknown }
|
||||
>;
|
||||
}
|
||||
).properties ?? {};
|
||||
const configEntries: Array<
|
||||
[string, { type?: string; description?: string }]
|
||||
> =
|
||||
Object.keys(properties).length > 0
|
||||
? Object.entries(properties).map(([key, schema]) => [
|
||||
key,
|
||||
{ type: schema?.type, description: schema?.description },
|
||||
])
|
||||
: Object.entries(instance.config).map(([key, value]) => [
|
||||
key,
|
||||
{ type: typeof value === "number" ? "integer" : "string" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionCard title="Config">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="Name" htmlFor="service-name">
|
||||
<Input
|
||||
id="service-name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
/>
|
||||
<Label htmlFor="service-enabled">Enabled</Label>
|
||||
</div>
|
||||
|
||||
{configEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No connection config.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{configEntries.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(draftConfig[key] ?? "")}
|
||||
onChange={(e) =>
|
||||
onConfigChange({
|
||||
...draftConfig,
|
||||
[key]: isNumber
|
||||
? e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(instance.secrets_set).length === 0 ? null : (
|
||||
<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) =>
|
||||
onSecretsChange({
|
||||
...draftSecrets,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button onClick={onSave} disabled={savePending}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -548,8 +548,8 @@ export function ServicesPage() {
|
||||
{services.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud,
|
||||
or SSH task runner.
|
||||
No services yet. Add a Prometheus, Jellyfin, Nextcloud, or SSH
|
||||
task runner.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
|
||||
@@ -50,6 +50,17 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
useServiceTypes,
|
||||
} from "../hooks/useServices";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../types";
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ value: "monitoring", label: "Monitoring" },
|
||||
@@ -61,7 +72,7 @@ const SERVICE_OPTIONS = [
|
||||
// maps to this sentinel and converts back to "" at the draft boundary.
|
||||
const NONE = "__none__";
|
||||
|
||||
type SettingsTab = "machines" | "ssh-keys" | "danger";
|
||||
type SettingsTab = "machines" | "ssh-keys" | "services" | "danger";
|
||||
|
||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||
function FormField({
|
||||
@@ -995,6 +1006,9 @@ export function Settings() {
|
||||
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
||||
SSH Keys
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="services" value="services">
|
||||
Services
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="danger" value="danger">
|
||||
Danger Zone
|
||||
</TabsTrigger>,
|
||||
@@ -1178,6 +1192,7 @@ export function Settings() {
|
||||
onSelectKeyId={setSelectedSSHKeyId}
|
||||
/>
|
||||
)}
|
||||
{tab === "services" && <ServicesAdminCard />}
|
||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||
</TabbedCard>
|
||||
{isMobile ? (
|
||||
@@ -1311,3 +1326,270 @@ export function Settings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Services admin card for the Settings > Services tab.
|
||||
*
|
||||
* Lists all service instances grouped by type with inline config editing
|
||||
* (enable/disable, config fields, secrets, save, delete). Lifted from the
|
||||
* old ServicePage ConfigBody — the service page is now a pure operational
|
||||
* view; all administration lives here.
|
||||
*/
|
||||
function ServicesAdminCard() {
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const [selectedServiceId, setSelectedServiceId] = useState("");
|
||||
|
||||
const sortedServices = useMemo(
|
||||
() =>
|
||||
[...services].sort((a, b) =>
|
||||
`${a.service_type}:${a.name}`.localeCompare(
|
||||
`${b.service_type}:${b.name}`,
|
||||
),
|
||||
),
|
||||
[services],
|
||||
);
|
||||
|
||||
const selectedService = useMemo(
|
||||
() =>
|
||||
sortedServices.find((s) => s.id === selectedServiceId) ??
|
||||
sortedServices[0] ??
|
||||
null,
|
||||
[sortedServices, selectedServiceId],
|
||||
);
|
||||
|
||||
const selectedTypeInfo = selectedService
|
||||
? types.find((t) => t.service_type === selectedService.service_type)
|
||||
: undefined;
|
||||
|
||||
if (sortedServices.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No service instances configured. Create one from the Services page.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Services"
|
||||
description="Select a service to edit its configuration."
|
||||
minHeight={420}
|
||||
>
|
||||
{sortedServices.map((svc) => {
|
||||
const active = svc.id === (selectedService?.id ?? "");
|
||||
const typeName =
|
||||
types.find((t) => t.service_type === svc.service_type)?.name ??
|
||||
svc.service_type;
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
onClick={() => setSelectedServiceId(svc.id)}
|
||||
className={cn(
|
||||
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
|
||||
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{svc.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={svc.enabled ? "default" : "secondary"}>
|
||||
{svc.enabled ? "on" : "off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SelectionRailCard>
|
||||
<SectionCard
|
||||
title={selectedService?.name ?? "No service selected"}
|
||||
description={
|
||||
selectedTypeInfo?.description ??
|
||||
"Select a service on the left to edit its configuration."
|
||||
}
|
||||
>
|
||||
{selectedService ? (
|
||||
<ServiceConfigEditor
|
||||
instance={selectedService}
|
||||
typeInfo={selectedTypeInfo}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a service on the left.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceConfigEditor({
|
||||
instance,
|
||||
typeInfo,
|
||||
}: {
|
||||
instance: ServiceInstance;
|
||||
typeInfo: ServiceTypeInfo | undefined;
|
||||
}) {
|
||||
const saveService = useSaveServiceInstance();
|
||||
const deleteService = useDeleteServiceInstance();
|
||||
const [name, setName] = useState(instance.name);
|
||||
const [enabled, setEnabled] = useState(instance.enabled);
|
||||
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
|
||||
...instance.config,
|
||||
});
|
||||
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const properties =
|
||||
(
|
||||
(typeInfo?.config_schema ?? {}) as {
|
||||
properties?: Record<string, { type?: string; description?: string }>;
|
||||
}
|
||||
).properties ?? {};
|
||||
const configEntries: Array<
|
||||
[string, { type?: string; description?: string }]
|
||||
> =
|
||||
Object.keys(properties).length > 0
|
||||
? Object.entries(properties).map(([key, schema]) => [
|
||||
key,
|
||||
{ type: schema?.type, description: schema?.description },
|
||||
])
|
||||
: Object.entries(instance.config).map(([key, value]) => [
|
||||
key,
|
||||
{ type: typeof value === "number" ? "integer" : "string" },
|
||||
]);
|
||||
|
||||
function buildInput(): ServiceInstanceInput {
|
||||
const onlyChangedSecrets = Object.fromEntries(
|
||||
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||
);
|
||||
return {
|
||||
id: instance.id,
|
||||
service_type: instance.service_type,
|
||||
name,
|
||||
config: draftConfig,
|
||||
secrets: onlyChangedSecrets,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await saveService.mutateAsync(buildInput());
|
||||
setDraftSecrets({});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium">{instance.name}</span>
|
||||
<Badge variant={enabled ? "default" : "secondary"}>
|
||||
{enabled ? "enabled" : "disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
|
||||
<Input
|
||||
id={`svc-name-${instance.id}`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={`svc-enabled-${instance.id}`}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
|
||||
</div>
|
||||
|
||||
{configEntries.map(([key, schema]) => {
|
||||
const isNumber =
|
||||
schema.type === "integer" || schema.type === "number";
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
label={key}
|
||||
htmlFor={`svc-cfg-${instance.id}-${key}`}
|
||||
helperText={schema.description}
|
||||
>
|
||||
<Input
|
||||
id={`svc-cfg-${instance.id}-${key}`}
|
||||
type={isNumber ? "number" : "text"}
|
||||
value={String(draftConfig[key] ?? "")}
|
||||
onChange={(e) =>
|
||||
setDraftConfig({
|
||||
...draftConfig,
|
||||
[key]: isNumber
|
||||
? e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
})}
|
||||
|
||||
{Object.keys(instance.secrets_set).length === 0
|
||||
? null
|
||||
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||
<FormField
|
||||
key={key}
|
||||
label={key}
|
||||
htmlFor={`svc-secret-${instance.id}-${key}`}
|
||||
helperText="Leave blank to keep the current value."
|
||||
>
|
||||
<Input
|
||||
id={`svc-secret-${instance.id}-${key}`}
|
||||
type="password"
|
||||
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||
value={draftSecrets[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDraftSecrets({
|
||||
...draftSecrets,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
))}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saveService.isPending}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ vi.mock("../../hooks/useSettings", () => ({
|
||||
}));
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
useWidgetReferences: () => ({ data: [] }),
|
||||
useDetachWidgetReference: () => ({ mutate: () => {} }),
|
||||
}));
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NamedDashboardPage } from "../NamedDashboardPage";
|
||||
|
||||
vi.mock("../../hooks/useDashboards", () => ({
|
||||
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetReferences: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||
WidgetConfigDialog: () => <div data-testid="config-dialog-stub" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/WidgetInstance", () => ({
|
||||
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
||||
}));
|
||||
|
||||
import { useDashboardBySlug } from "../../hooks/useDashboards";
|
||||
|
||||
function renderPage(slug: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/d/${slug}`]}>
|
||||
<Routes>
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/d/${slug}`]}>
|
||||
<Routes>
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,6 +106,26 @@ describe("NamedDashboardPage", () => {
|
||||
} as never);
|
||||
renderPage("empty");
|
||||
expect(screen.getByText("Empty")).toBeInTheDocument();
|
||||
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/This dashboard is empty/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an edit-widgets button", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: {
|
||||
id: "d1",
|
||||
label: "Storage",
|
||||
slug: "storage",
|
||||
sort_order: 0,
|
||||
payload: { items: [] },
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as never);
|
||||
renderPage("storage");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Edit widgets/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServicePage } from "../ServicePage";
|
||||
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "svc-1",
|
||||
@@ -15,17 +16,7 @@ const instance: ServiceInstance = {
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
const typeInfo: ServiceTypeInfo = {
|
||||
service_type: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
description: "Media server",
|
||||
config_schema: {
|
||||
type: "object",
|
||||
properties: { base_url: { type: "string" } },
|
||||
},
|
||||
secret_fields: [{ key: "api_key", label: "API key", required: false }],
|
||||
widget_kinds: [],
|
||||
};
|
||||
// typeInfo no longer needed on ServicePage (config moved to Settings).
|
||||
|
||||
const secondInstance: ServiceInstance = {
|
||||
...instance,
|
||||
@@ -33,20 +24,23 @@ const secondInstance: ServiceInstance = {
|
||||
name: "Backup Jellyfin",
|
||||
};
|
||||
|
||||
const saveMutateAsync = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
|
||||
?.__svcInstances ?? [instance],
|
||||
}),
|
||||
useServiceTypes: () => ({ data: [typeInfo] }),
|
||||
useSaveServiceInstance: () => ({
|
||||
mutateAsync: saveMutateAsync,
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||
WidgetConfigDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/WidgetInstance", () => ({
|
||||
WidgetInstanceCard: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../integrations/registry", () => ({
|
||||
@@ -71,13 +65,19 @@ function renderServicePage(path: string) {
|
||||
}
|
||||
|
||||
describe("ServicePage tab skeleton", () => {
|
||||
it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
|
||||
it("renders Overview + Media + Requests + Widgets for jellyfin", () => {
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render Config tab (moved to Settings)", () => {
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Config" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||
@@ -93,43 +93,37 @@ describe("ServicePage tab skeleton", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows instance switcher when >1 sibling of same type", () => {
|
||||
it("shows instance tabs when >1 enabled sibling of same type", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance, secondInstance];
|
||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
||||
// The switcher renders as a Select trigger (combobox).
|
||||
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Main Jellyfin" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Backup Jellyfin" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides instance switcher when only one instance", () => {
|
||||
it("hides instance tabs when only one instance", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance];
|
||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
||||
// No select trigger rendered (only one instance).
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
container.querySelector("[role='combobox']"),
|
||||
screen.queryByRole("tab", { name: "Main Jellyfin" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
|
||||
const { userEvent } = await import("@testing-library/user-event");
|
||||
it("clicking an instance tab navigates to that instance", async () => {
|
||||
const user = userEvent.setup();
|
||||
saveMutateAsync.mockReset();
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance, secondInstance];
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
|
||||
// Open the Config tab and type a new api_key.
|
||||
await user.click(screen.getByRole("tab", { name: "Config" }));
|
||||
const secretInput = screen.getByLabelText("api_key");
|
||||
await user.type(secretInput, "new-secret-value");
|
||||
|
||||
// Save and assert the typed secret is in the payload (not secrets: {}).
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(saveMutateAsync).toHaveBeenCalledTimes(1);
|
||||
const input = saveMutateAsync.mock.calls[0][0] as {
|
||||
secrets: Record<string, string>;
|
||||
};
|
||||
expect(input.secrets).toEqual({ api_key: "new-secret-value" });
|
||||
await user.click(screen.getByRole("tab", { name: "Backup Jellyfin" }));
|
||||
// The test router would navigate; we can't assert URL directly without
|
||||
// a useNavigate mock, but the click should not throw.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Grafana Links tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Lifts the Grafana deep-link content from the old cross-service
|
||||
* ObservabilityPage into an instance-scoped tab. Shows service health + the
|
||||
* configured Grafana deep-links (node-exporter dashboard, Loki logs per
|
||||
* machine).
|
||||
*
|
||||
* The hooks (useGrafanaStatus, useMonitoringMachines) are global /
|
||||
* first-configured for now. Wiring `instance.id` into the status hook is a
|
||||
* follow-up. The machine links use the configured Grafana base_url from the
|
||||
* instance's config.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
|
||||
import {
|
||||
useGrafanaStatus,
|
||||
useMonitoringMachines,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
function GrafanaLinkCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinksTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: status, isLoading, error } = useGrafanaStatus();
|
||||
const { data: machines = [], isLoading: machinesLoading } =
|
||||
useMonitoringMachines();
|
||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||
|
||||
const grafanaBaseUrl =
|
||||
(instance.config?.base_url as string | undefined) ?? "";
|
||||
|
||||
const selectedMachine = useMemo(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const inst = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: isLoading
|
||||
? "checking…"
|
||||
: error
|
||||
? "unreachable"
|
||||
: "not configured";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Grafana {statusDetail}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Grafana</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
{machines.length > 0 ? (
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machinesLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : selectedMachine && grafanaBaseUrl ? (
|
||||
<>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||
href={nodeExporterDashboardUrl}
|
||||
/>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} logs`}
|
||||
description="Explore Loki logs for this machine in Grafana."
|
||||
href={logsUrl}
|
||||
/>
|
||||
</>
|
||||
) : !grafanaBaseUrl ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Gauge className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No Grafana base URL configured</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add a Grafana service instance to enable deep-links to
|
||||
dashboards and logs.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/services">Open Services</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<ServerOff className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No machine selected</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add monitoring machines in Settings to see Grafana drill-down
|
||||
links.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -352,6 +352,11 @@ export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
>
|
||||
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
|
||||
</Button>
|
||||
{buildIndex.isError ? (
|
||||
<span className="text-sm text-destructive">
|
||||
Build failed: {buildIndex.error instanceof Error ? buildIndex.error.message : "Unknown error"}
|
||||
</span>
|
||||
) : null}
|
||||
{buildRunning && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Configurable per-service Overview tab.
|
||||
*
|
||||
* Each service instance manages its own set of widgets on this tab. The
|
||||
* widget system is reused from the main Dashboard: widget instances with
|
||||
* a `service_id` matching this instance are fetched and rendered in a
|
||||
* responsive grid. An edit button opens the WidgetConfigDialog (same one
|
||||
* the Dashboard uses) for add/remove/reorder/enable/disable.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { useWidgetInstances } from "../../hooks/useWidgets";
|
||||
import { WidgetInstanceCard } from "../../components/WidgetInstance";
|
||||
import { WidgetConfigDialog } from "../../components/WidgetConfigDialog";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: widgets = [] } = useWidgetInstances(instance.id);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [editWidgetId, setEditWidgetId] = useState<string | undefined>();
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() =>
|
||||
widgets
|
||||
.filter((w) => w.enabled)
|
||||
.sort((a, b) => a.sort_order - b.sort_order),
|
||||
[widgets],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{instance.name} overview
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mobile-touch-target"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
Edit widgets
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{visibleWidgets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{visibleWidgets.map((widget) => (
|
||||
<WidgetInstanceCard
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onEdit={(id) => {
|
||||
setEditWidgetId(id);
|
||||
setConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
<span>
|
||||
No widgets on this overview yet. Add widgets to show key metrics
|
||||
and information for {instance.name}.
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-fit mobile-touch-target"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
Add widgets
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<WidgetConfigDialog
|
||||
open={configOpen}
|
||||
onClose={() => {
|
||||
setConfigOpen(false);
|
||||
setEditWidgetId(undefined);
|
||||
}}
|
||||
serviceId={instance.id}
|
||||
editWidgetId={editWidgetId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LinksTab } from "../LinksTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "graf-1",
|
||||
service_type: "grafana",
|
||||
name: "Main Grafana",
|
||||
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
useGrafanaStatus: () => ({
|
||||
data: {
|
||||
up: true,
|
||||
version: "11.0.0",
|
||||
service_id: "graf-1",
|
||||
name: "Main Grafana",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useMonitoringMachines: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "storage",
|
||||
mode: "ssh",
|
||||
host: "10.0.0.5",
|
||||
enabled: true,
|
||||
services: [],
|
||||
port: 22,
|
||||
username: "admin",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LinksTab", () => {
|
||||
it("renders the Grafana version and machine dashboard links", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage logs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open-in-grafana link buttons", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
const links = screen.getAllByText("Open in Grafana");
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { OverviewTab } from "../OverviewTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
// Default mock returns an empty list; individual tests override via
|
||||
// `vi.mocked()` to return widget data.
|
||||
vi.mock("../../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: vi.fn(() => ({ data: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../../../components/WidgetInstance", () => ({
|
||||
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
|
||||
<div data-testid="widget-card">{widget.title}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../components/WidgetConfigDialog", () => ({
|
||||
WidgetConfigDialog: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="config-dialog" /> : null,
|
||||
}));
|
||||
|
||||
const { useWidgetInstances } = await import("../../../hooks/useWidgets");
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "svc-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockWidgets(
|
||||
widgets: { id: string; title: string; enabled: boolean }[],
|
||||
) {
|
||||
vi.mocked(useWidgetInstances).mockReturnValue({
|
||||
data: widgets.map((w, i) => ({
|
||||
id: w.id,
|
||||
service_id: "svc-1",
|
||||
widget_kind: "activity",
|
||||
title: w.title,
|
||||
config: {},
|
||||
enabled: w.enabled,
|
||||
sort_order: i,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
})),
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("OverviewTab", () => {
|
||||
it("renders enabled widgets in a grid and hides disabled ones", () => {
|
||||
mockWidgets([
|
||||
{ id: "w1", title: "Live Sessions", enabled: true },
|
||||
{ id: "w2", title: "Disabled Widget", enabled: false },
|
||||
]);
|
||||
render(<OverviewTab instance={instance} />);
|
||||
const cards = screen.getAllByTestId("widget-card");
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(screen.getByText("Live Sessions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Disabled Widget")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state with an add button when no widgets exist", () => {
|
||||
mockWidgets([]);
|
||||
render(<OverviewTab instance={instance} />);
|
||||
expect(
|
||||
screen.getByText(/No widgets on this overview/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Add widgets/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the config dialog when Edit widgets is clicked", async () => {
|
||||
mockWidgets([{ id: "w1", title: "Live", enabled: true }]);
|
||||
render(<OverviewTab instance={instance} />);
|
||||
expect(screen.queryByTestId("config-dialog")).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /Edit widgets/i }),
|
||||
);
|
||||
expect(screen.getByTestId("config-dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,8 @@
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { OverviewTab } from "./stubs";
|
||||
import { OverviewTab } from "./OverviewTab";
|
||||
import { AlertsTab } from "./AlertsTab";
|
||||
import { LinksTab } from "./LinksTab";
|
||||
import { MetricsTab } from "./MetricsTab";
|
||||
import { MediaTab } from "./MediaTab";
|
||||
import { RequestsTab } from "./RequestsTab";
|
||||
@@ -56,8 +55,6 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
|
||||
];
|
||||
case "alertmanager":
|
||||
return [{ label: "Alerts", Component: AlertsTab }];
|
||||
case "grafana":
|
||||
return [{ label: "Links", Component: LinksTab }];
|
||||
case "prometheus":
|
||||
return [{ label: "Metrics", Component: MetricsTab }];
|
||||
default:
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Service-page content tab stubs.
|
||||
*
|
||||
* Each stub renders a "coming soon" placeholder. Slices 5–9 replace these with
|
||||
* real operational content lifted from the old top-level pages. All stubs accept
|
||||
* an `instance` prop so the real implementations can scope queries by instance.
|
||||
*/
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
function Stub({
|
||||
label,
|
||||
instance,
|
||||
}: {
|
||||
label: string;
|
||||
instance: ServiceInstance;
|
||||
}) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{label} for {instance.name} — coming soon.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Service overview" instance={instance} />;
|
||||
}
|
||||
@@ -386,14 +386,6 @@ export interface AlertmanagerStatus {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface GrafanaStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
service_id: string;
|
||||
name: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PrometheusStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { NowPlayingSession, WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function JellyfinNowPlayingWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : Array.isArray(sessions) ? (
|
||||
<SessionActivityPanel
|
||||
sessions={sessions}
|
||||
emptyMessage="No one is playing right now."
|
||||
/>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function PrometheusChartWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const series = data?.data?.series as ChartSeries[] | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : series && series.length > 0 ? (
|
||||
<LineSeriesChart series={series} />
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No data. Check your PromQL query and window in the widget config.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
import {
|
||||
RadialBarChart,
|
||||
RadialBar,
|
||||
ResponsiveContainer,
|
||||
PolarAngleAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GaugeData {
|
||||
value: number;
|
||||
warn_at?: number | null;
|
||||
crit_at?: number | null;
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
unit?: string | null;
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit?: string | null): string {
|
||||
let formatted: string;
|
||||
if (Math.abs(value) >= 100) {
|
||||
formatted = value.toFixed(0);
|
||||
} else if (Math.abs(value) >= 1) {
|
||||
formatted = value.toFixed(2).replace(/\.?0+$/, "");
|
||||
} else {
|
||||
formatted = value.toFixed(4).replace(/\.?0+$/, "");
|
||||
}
|
||||
return unit ? `${formatted} ${unit}` : formatted;
|
||||
}
|
||||
|
||||
export function PrometheusGaugeWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const gauge = data?.data as GaugeData | undefined;
|
||||
|
||||
// Compute gauge domain and threshold bands.
|
||||
const value = gauge?.value ?? 0;
|
||||
const min = gauge?.min ?? 0;
|
||||
const max =
|
||||
gauge?.max ?? Math.max(value, gauge?.warn_at ?? 0, gauge?.crit_at ?? 0, 1);
|
||||
const warnAt = gauge?.warn_at;
|
||||
const critAt = gauge?.crit_at;
|
||||
const hasBands = warnAt != null && critAt != null;
|
||||
|
||||
// recharts RadialBarChart uses a 0–100 domain for the angle axis.
|
||||
// Map our [min, max] domain to [0, 100].
|
||||
const range = max - min || 1;
|
||||
const toPercent = (v: number) => Math.round(((v - min) / range) * 100);
|
||||
const valuePct = Math.max(0, Math.min(100, toPercent(value)));
|
||||
|
||||
// Build track cells: green / amber / red when bands are set, else neutral.
|
||||
const trackCells = hasBands
|
||||
? [
|
||||
{ pct: toPercent(warnAt!), fill: "hsl(var(--chart-1))" }, // green
|
||||
{ pct: toPercent(critAt!), fill: "hsl(var(--chart-4))" }, // amber
|
||||
{ pct: 100, fill: "hsl(var(--destructive))" }, // red
|
||||
]
|
||||
: [{ pct: 100, fill: "hsl(var(--muted))" }];
|
||||
|
||||
const valueColor = hasBands
|
||||
? value >= critAt!
|
||||
? "hsl(var(--destructive))"
|
||||
: value >= warnAt!
|
||||
? "hsl(var(--chart-4))"
|
||||
: "hsl(var(--chart-1))"
|
||||
: "hsl(var(--primary))";
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : gauge ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<RadialBarChart
|
||||
innerRadius="65%"
|
||||
outerRadius="100%"
|
||||
data={[
|
||||
...trackCells.map((c) => ({
|
||||
name: "track",
|
||||
pct: c.pct,
|
||||
fill: c.fill,
|
||||
})),
|
||||
{ name: "value", pct: valuePct, fill: valueColor },
|
||||
]}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
angleAxisId={0}
|
||||
tick={false}
|
||||
/>
|
||||
<RadialBar
|
||||
background={{ fill: "hsl(var(--muted))" }}
|
||||
dataKey="pct"
|
||||
cornerRadius={6}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="-mt-12 flex flex-col items-center">
|
||||
<span className="text-2xl font-bold" style={{ color: valueColor }}>
|
||||
{formatValue(value, gauge.unit)}
|
||||
</span>
|
||||
{hasBands ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
warn {formatValue(warnAt!, gauge.unit)} · crit{" "}
|
||||
{formatValue(critAt!, gauge.unit)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No data. Check your PromQL query.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MeanData {
|
||||
value: number;
|
||||
unit?: string | null;
|
||||
}
|
||||
|
||||
function formatMean(value: number, unit?: string | null): string {
|
||||
let formatted: string;
|
||||
if (Math.abs(value) >= 1000) {
|
||||
formatted = value.toFixed(0);
|
||||
} else if (Math.abs(value) >= 1) {
|
||||
formatted = value.toFixed(2).replace(/\.?0+$/, "");
|
||||
} else {
|
||||
formatted = value.toFixed(4).replace(/\.?0+$/, "");
|
||||
}
|
||||
return unit ? `${formatted} ${unit}` : formatted;
|
||||
}
|
||||
|
||||
export function PrometheusMeanWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const mean = data?.data as MeanData | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-16 w-32" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : mean ? (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<span className="text-3xl font-bold">
|
||||
{formatMean(mean.value, mean.unit)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No data. Check your PromQL query.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ActiveTorrent {
|
||||
name: string | null;
|
||||
state: string | null;
|
||||
size: number | null;
|
||||
progress: number | null;
|
||||
dl_speed: number | null;
|
||||
up_speed: number | null;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number | null): string {
|
||||
if (bytesPerSec === null || bytesPerSec <= 0) return "—";
|
||||
const mb = bytesPerSec / 1_000_000;
|
||||
if (mb >= 1) return `${mb.toFixed(1)} MB/s`;
|
||||
return `${(bytesPerSec / 1000).toFixed(0)} KB/s`;
|
||||
}
|
||||
|
||||
export function QbittorrentActiveTorrentsWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const payload = data?.data as { torrents?: ActiveTorrent[] } | undefined;
|
||||
const torrents = payload?.torrents ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : torrents.length > 0 ? (
|
||||
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
|
||||
{torrents.map((t, i) => (
|
||||
<li
|
||||
key={`${t.name}-${i}`}
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="truncate">{t.name ?? "Unknown"}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
↓{formatSpeed(t.dl_speed)} ↑{formatSpeed(t.up_speed)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={t.state === "downloading" ? "default" : "secondary"}
|
||||
>
|
||||
{t.state ?? "?"}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">No active torrents</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
+8
-13
@@ -1,7 +1,7 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
@@ -12,32 +12,27 @@ interface Props {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function GrafanaLinkWidget({
|
||||
export function QbittorrentSpeedWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const url = data?.data?.url as string | undefined;
|
||||
const series = data?.data?.series as ChartSeries[] | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : url ? (
|
||||
<Button asChild>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
Open Grafana
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : series && series.length > 0 ? (
|
||||
<LineSeriesChart series={series} height={220} />
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No Grafana URL configured.</AlertDescription>
|
||||
<AlertDescription>No speed data yet</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function QbittorrentTotalsWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const payload = data?.data as
|
||||
| { total?: number; by_state?: Record<string, number> }
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : payload ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-3xl font-semibold">{payload.total ?? 0}</div>
|
||||
<div className="text-xs text-muted-foreground">Total torrents</div>
|
||||
</div>
|
||||
{payload.by_state && Object.keys(payload.by_state).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(payload.by_state).map(([state, count]) => (
|
||||
<Badge key={state} variant="secondary">
|
||||
{state}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { JellyfinNowPlayingWidget } from "../JellyfinNowPlayingWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "now_playing",
|
||||
title: "Now Playing",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
describe("JellyfinNowPlayingWidget", () => {
|
||||
it("renders sessions when data is present", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w1",
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
user: "alice",
|
||||
title: "Movie",
|
||||
state: "playing",
|
||||
type: "Movie",
|
||||
device: "Web",
|
||||
session_id: "s1",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText("alice")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Movie").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the now-playing empty message when no sessions", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w1",
|
||||
data: { sessions: [] },
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No one is playing right now."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state", async () => {
|
||||
const { useWidgetData } = await import("../../hooks/useWidgets");
|
||||
vi.mocked(useWidgetData).mockReturnValue({
|
||||
data: {
|
||||
widget_id: "w1",
|
||||
error: "Connection failed",
|
||||
fetched_at: 0,
|
||||
},
|
||||
isLoading: false,
|
||||
} as never);
|
||||
|
||||
render(
|
||||
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText("Connection failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PrometheusChartWidget } from "../PrometheusChartWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "chart",
|
||||
title: "CPU Usage",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "w1", error, fetched_at: 0 }
|
||||
: { widget_id: "w1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("PrometheusChartWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a chart with series data", () => {
|
||||
mockData({
|
||||
series: [
|
||||
{
|
||||
label: "cpu",
|
||||
points: [
|
||||
{ t: 1000, v: 0.5 },
|
||||
{ t: 2000, v: 0.8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
// recharts renders an SVG; the title from SectionCard should be present.
|
||||
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error Alert on error", () => {
|
||||
mockData(null, "promql is required");
|
||||
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(screen.getByText(/promql is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no series", () => {
|
||||
mockData({ series: [] });
|
||||
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(screen.getByText(/No data/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PrometheusGaugeWidget } from "../PrometheusGaugeWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "wg1",
|
||||
service_id: "s1",
|
||||
widget_kind: "gauge",
|
||||
title: "CPU Gauge",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "wg1", error, fetched_at: 0 }
|
||||
: { widget_id: "wg1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("PrometheusGaugeWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a gauge with value and threshold bands", () => {
|
||||
mockData({
|
||||
value: 0.75,
|
||||
warn_at: 0.8,
|
||||
crit_at: 0.95,
|
||||
unit: "%",
|
||||
});
|
||||
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
|
||||
expect(screen.getByText("CPU Gauge")).toBeInTheDocument();
|
||||
expect(screen.getByText(/0.75 %/)).toBeInTheDocument();
|
||||
// Threshold labels present when bands are set.
|
||||
expect(screen.getByText(/warn/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/crit/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a gauge without threshold bands (single color)", () => {
|
||||
mockData({ value: 42, unit: "req/s" });
|
||||
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
|
||||
expect(screen.getByText(/42 req\/s/)).toBeInTheDocument();
|
||||
// No threshold labels when bands are absent.
|
||||
expect(screen.queryByText(/warn/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error Alert on error", () => {
|
||||
mockData(null, "Gauge requires a single-series query");
|
||||
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
|
||||
expect(screen.getByText(/single-series/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no data", () => {
|
||||
mockData(null);
|
||||
render(<PrometheusGaugeWidget widget={widget} refreshIntervalMs={30000} />);
|
||||
expect(screen.getByText(/No data/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PrometheusMeanWidget } from "../PrometheusMeanWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "wm1",
|
||||
service_id: "s1",
|
||||
widget_kind: "mean",
|
||||
title: "Avg CPU",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "wm1", error, fetched_at: 0 }
|
||||
: { widget_id: "wm1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("PrometheusMeanWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the mean value with unit", () => {
|
||||
mockData({ value: 23.5, unit: "%" });
|
||||
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(screen.getByText("Avg CPU")).toBeInTheDocument();
|
||||
expect(screen.getByText(/23.5 %/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the mean value without unit", () => {
|
||||
mockData({ value: 1500, unit: null });
|
||||
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(screen.getByText(/1500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error Alert on error", () => {
|
||||
mockData(null, "Mean requires a single-series query");
|
||||
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(screen.getByText(/single-series/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no data", () => {
|
||||
mockData(null);
|
||||
render(<PrometheusMeanWidget widget={widget} refreshIntervalMs={60000} />);
|
||||
expect(screen.getByText(/No data/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QbittorrentActiveTorrentsWidget } from "../QbittorrentActiveTorrentsWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "active",
|
||||
title: "Active Torrents",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "w1", error, fetched_at: 0 }
|
||||
: { widget_id: "w1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("QbittorrentActiveTorrentsWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders active torrent rows", () => {
|
||||
mockData({
|
||||
torrents: [
|
||||
{
|
||||
name: "Movie.mkv",
|
||||
state: "downloading",
|
||||
size: 1000,
|
||||
progress: 0.5,
|
||||
dl_speed: 500000,
|
||||
up_speed: 1000,
|
||||
},
|
||||
{
|
||||
name: "Show.mkv",
|
||||
state: "uploading",
|
||||
size: 2000,
|
||||
progress: 1.0,
|
||||
dl_speed: 0,
|
||||
up_speed: 50000,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Movie.mkv")).toBeInTheDocument();
|
||||
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
||||
expect(screen.getByText("downloading")).toBeInTheDocument();
|
||||
expect(screen.getByText("uploading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no active torrents", () => {
|
||||
mockData({ torrents: [] });
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/No active torrents/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockData(null, "qBittorrent fetch failed");
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QbittorrentSpeedWidget } from "../QbittorrentSpeedWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "speed",
|
||||
title: "Speed Chart",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "w1", error, fetched_at: 0 }
|
||||
: { widget_id: "w1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("QbittorrentSpeedWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chart with series data", () => {
|
||||
mockData({
|
||||
series: [
|
||||
{ label: "download", points: [{ t: 1000, v: 500000 }] },
|
||||
{ label: "upload", points: [{ t: 1000, v: 100000 }] },
|
||||
],
|
||||
});
|
||||
const { container } = render(
|
||||
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
|
||||
);
|
||||
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state when no series", () => {
|
||||
mockData({ series: [] });
|
||||
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
|
||||
expect(screen.getByText(/No speed data yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockData(null, "qBittorrent fetch failed");
|
||||
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
|
||||
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QbittorrentTotalsWidget } from "../QbittorrentTotalsWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "totals",
|
||||
title: "Torrent Totals",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "w1", error, fetched_at: 0 }
|
||||
: { widget_id: "w1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("QbittorrentTotalsWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(
|
||||
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders total count and state badges", () => {
|
||||
mockData({
|
||||
total: 4,
|
||||
by_state: { downloading: 1, uploading: 1, pausedDL: 2 },
|
||||
});
|
||||
render(
|
||||
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("downloading: 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("pausedDL: 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockData(null, "qBittorrent fetch failed");
|
||||
render(
|
||||
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||
export { BackupsWidget } from "./BackupsWidget";
|
||||
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||
export { PrometheusChartWidget } from "./PrometheusChartWidget";
|
||||
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
|
||||
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
|
||||
export { JellyfinWidget } from "./JellyfinWidget";
|
||||
export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget";
|
||||
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||
export { QbittorrentActiveTorrentsWidget } from "./QbittorrentActiveTorrentsWidget";
|
||||
export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget";
|
||||
export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget";
|
||||
export { SshTaskWidget } from "./SshTaskWidget";
|
||||
export { StaticWidget } from "./StaticWidget";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Apply Progress: Prometheus Direct Charting
|
||||
|
||||
**Change:** `prometheus-direct-charting`
|
||||
**Phase:** apply-progress
|
||||
**Date:** 2026-07-08
|
||||
**Status:** complete — all 39 tasks done, all gates green, verified (see `verify-report.md`)
|
||||
|
||||
## Slices delivered
|
||||
|
||||
Three slices, each its own commit, each leaving `pytest` / `npm run build` / `npm run lint` / `ruff` green.
|
||||
|
||||
### Slice 1 — Prometheus range query + chart rebrand (commit `5dad982`, amended)
|
||||
|
||||
- Created `backend/src/media_library_viewer_api/widgets/prometheus_range.py` with `step_for_window(s)` (`max(15, round(s/200))` → ~200 pts/window) and `normalize_prometheus_matrix(result)` (shared label rule extracted from the to-be-removed Grafana path; drops `__name__`/`__*`, joins sorted `k=v`, falls back to `"value"`, dedups with `(n)`).
|
||||
- Extended `PrometheusWidgetSource.fetch` to dispatch `widget_kind == "chart"` → `_fetch_chart` hitting `/api/v1/query_range` directly, returning `{series:[...]}` (SC-101..104). Grafana path left intact at this slice.
|
||||
- Declared `chart` widget kind in `integrations/prometheus.py` (config: `promql`, `window` ∈ `{1h,6h,24h,7d}`).
|
||||
- `git mv GrafanaChartWidget.tsx → PrometheusChartWidget.tsx` (recharts body preserved verbatim; empty-state copy updated); `git mv` of its test. Rebound `chart` grafana→prometheus in both registries (SC-105..108).
|
||||
- Backend tests: new `test_prometheus_range.py` (step + normalization); chart-adapter test in `test_widgets.py`. Frontend registry test updated.
|
||||
|
||||
### Slice 2 — Gauge + mean widgets (commit `58be6e0`, amended)
|
||||
|
||||
- Extracted shared `_instant_query` helper; added `_fetch_gauge` (instant → scalar, multi-series → `{error}`) and `_fetch_mean` (range query over preset → client-side arithmetic mean of non-null values, scalar-only).
|
||||
- Declared `gauge` (`promql`, `warn_at`/`crit_at`/`min`/`max`/`unit`) and `mean` (`promql`, `window`, `unit`) kinds in `integrations/prometheus.py`.
|
||||
- Created `PrometheusGaugeWidget.tsx` (recharts `RadialBarChart`; green/amber/red threshold bands when `warn_at`+`crit_at` set; neutral single track otherwise) and `PrometheusMeanWidget.tsx` (MetricCard-style single value). Wired both into the frontend `prometheus` binding + barrel.
|
||||
- Tests: backend adapter tests (scalar-only enforcement, mean aggregation incl. NaN-skip, error cases); frontend component tests (error + rendered, gauge with/without bands, mean with/without unit).
|
||||
|
||||
### Slice 3 — Grafana removal + config + changelog (commit `ba94317`, amended)
|
||||
|
||||
- Deleted `integrations/grafana.py`, `GrafanaLinkWidget.tsx`, `service-tabs/LinksTab.tsx` (+ test). Removed `GrafanaWidgetSource` + adapter registration; `grafana` from `SERVICE_DEFINITIONS`/`SERVICE_ADAPTERS` (BE) and `SERVICE_REGISTRY`/`BUILTIN_WIDGETS` (FE); `get_grafana_status` endpoint; `useGrafanaStatus`/`fetchGrafanaStatus`/`GrafanaStatus`; nav entry; `service-tabs/index.ts` grafana case; `Dashboard.tsx` `OBSERVABILITY_TYPES` grafana member; `ServicesPage.tsx` empty-state copy; grafana tests.
|
||||
- Rewrote `openspec/config.yaml`: removed stale "Do NOT re-implement charting in-app" + "No recharts/d3" claims; states Manage renders Prometheus-backed metrics directly via recharts and that Grafana is no longer integrated.
|
||||
- Added `CHANGELOG.md` `[Unreleased]` entry: **BREAKING** — Grafana service type removed; migrate by deleting grafana instances and recreating as Prometheus; `grafana/chart` widgets → `prometheus/chart`.
|
||||
- Net: **−920 lines** across 27 files.
|
||||
|
||||
### Coverage close — SC-125 loading-state tests
|
||||
|
||||
- Added one `it("renders skeleton while loading")` case to each of the three Prometheus widget test files, asserting the `Skeleton` (`data-slot="skeleton"`) renders under `{ data: undefined, isLoading: true }`. Closes the PARTIAL finding on SC-125.
|
||||
|
||||
## Deviations from tasks.md
|
||||
|
||||
- None functional. The only textual drift is SC-118: `ObservabilityPage.tsx` had already been refactored into `service-tabs/` (the project map was stale). Removal targets adjusted to the real files (`LinksTab.tsx`, `service-tabs/index.ts`, `navEntries.ts`, `Dashboard.tsx`, `ServicesPage.tsx`); the spec was patched (SC-116/SC-118) to reflect this before apply. SC-118's *intent* (no Grafana UI surface) is fully satisfied.
|
||||
|
||||
## Final gate results (re-run after coverage close)
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `backend && PYTHONPATH=src python3 -m pytest -q` | **293 passed**, 2 warnings (pre-existing pythonjsonlogger DeprecationWarning) |
|
||||
| `backend && PYTHONPATH=src python3 -m ruff check src tests` | **All checks passed** |
|
||||
| `frontend && npm run build` (`tsc -b` + `vite build`) | **exit 0** (pre-existing chunk-size warning) |
|
||||
| `frontend && npm run lint` | **0 errors**, 1 pre-existing warning (`WidgetConfigDialog.tsx:370`, untouched) |
|
||||
| `frontend && npx vitest run` (3 Prom widget tests) | **14 passed** (11 original + 3 new loading) |
|
||||
|
||||
## Verification
|
||||
|
||||
See `verify-report.md` — adversarial fresh-context review: **26/27 fully PASS, 1 PARTIAL→PASS** (SC-125 closed here). No blocking findings remain.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Archive Report — `prometheus-direct-charting`
|
||||
|
||||
> Phase: **archive** · Change: `prometheus-direct-charting` · Repo: `/home/user/manage`
|
||||
> Mode: file-backed (`artifactStore: openspec`). No source-code edits; only OpenSpec artifacts
|
||||
> were touched. **Not committed** — the parent/orchestrator owns the archive commit. No push, no `gh`.
|
||||
|
||||
**Status: ARCHIVED.** All eight lifecycle phases are complete (proposal → spec → design → tasks →
|
||||
apply → verify → sync → **archive**). Every archive precondition is verified PASS (see §2). The
|
||||
canonical `openspec/specs/prometheus-charting/spec.md` (created by `sdd-sync`) remains in place as
|
||||
the durable end-state spec and is **not** moved (archive never moves canonical specs). The change
|
||||
folder was moved to `openspec/changes/archive/2026-07-08-prometheus-direct-charting/` via `git mv`
|
||||
to preserve history.
|
||||
|
||||
---
|
||||
|
||||
## 0. Archive disposition
|
||||
|
||||
- **Disposition: `archived`.** The folder move was performed inline as instructed (unlike a
|
||||
`documented-pending-manual` outcome): the parent explicitly requested the `git mv` and owns the
|
||||
commit, so the move is executed here and left staged for the parent's explicit-path commit.
|
||||
- **Archive convention:** OpenSpec SDD archive contract for `openspec` mode — completed file-backed
|
||||
sync → write the in-folder archive report → move the change folder to
|
||||
`openspec/changes/archive/YYYY-MM-DD-{change}/`. No standalone manifest/index exists under
|
||||
`openspec/` (only `config.yaml`, `changes/`, `specs/`), so the folder move **is** the archive
|
||||
mechanism. No `rules.archive` override exists in `openspec/config.yaml`.
|
||||
- **Target archived path:** `openspec/changes/archive/2026-07-08-prometheus-direct-charting/`
|
||||
- **Archive date:** `2026-07-08` (ISO).
|
||||
- **Canonical spec left in place (not moved):** `openspec/specs/prometheus-charting/spec.md` —
|
||||
27 requirements (SC-101 … SC-127). Verified present and untouched after the move.
|
||||
- **`openspec/specs/web-ui/spec.md` also left untouched** (the other canonical domain).
|
||||
- **Audit-trail integrity:** the change folder was moved as a whole, including the legacy flat
|
||||
`spec.md` and the per-domain delta `specs/prometheus-charting/spec.md`, which travel with the
|
||||
record. Nothing was silently deleted or rewritten. The flat `spec.md` is retained as the
|
||||
authoritative planning artifact the work was built against.
|
||||
|
||||
## 1. Native `sdd-status` read & discrepancy statement
|
||||
|
||||
The native `gentle-pi.sdd-status` engine supplied by the parent reports **non-actionable state for
|
||||
this archive** because it was resolved without a change context: `changeName: null`,
|
||||
`artifacts: all missing`, `applyState: blocked`, `dependencies.archive: blocked`,
|
||||
`blockedReasons: ["Change selection is ambiguous: mobile-responsive-parity,
|
||||
prometheus-direct-charting, service-storage-harness, services-as-hub-ia."]`,
|
||||
`isNonAuthoritative: false`. This is a **parent-resolution artifact**: the engine auto-detected four
|
||||
active changes and could not pick one. The ambiguity does **not** reflect the state of
|
||||
`prometheus-direct-charting`, which this archive task was **explicitly assigned**.
|
||||
|
||||
**Discrepancy with the parent's authoritative confirmed state — RESOLVED in favor of the parent.**
|
||||
The parent physically verified (and this executor re-confirmed directly against the filesystem in
|
||||
§2) that all 39 tasks are ticked, `apply-progress.md` exists and records the work, and the verify
|
||||
report confirms functional completion with no blocking findings. Per the archive contract's
|
||||
non-authoritative-store carve-out guidance and the parent's explicit instruction, the stale
|
||||
`archive: blocked` / "ambiguous" labels are **disregarded** and the archive **proceeds**.
|
||||
|
||||
Direct filesystem re-validation (§2) is the source of truth for this report.
|
||||
|
||||
## 2. Archive preconditions (validated directly against the filesystem)
|
||||
|
||||
| Precondition | Evidence | Result |
|
||||
|---|---|---|
|
||||
| Verify report present | `verify-report.md` | ✓ verdict **PASS** |
|
||||
| Verify clearly passing — no unresolved `FAIL`/`BLOCKED`/`CRITICAL` | verify-report §9: the sole CRITICAL was an archive-only checkbox/apply-progress gap (now reconciled); SC-125 PARTIAL→PASS closed | ✓ |
|
||||
| Sync report present & successful | `sync-report.md` → **Status: SYNCED** | ✓ |
|
||||
| Canonical spec exists (sync target) | `openspec/specs/prometheus-charting/spec.md` (27 requirements) | ✓ |
|
||||
| Change-side domain delta exists | `specs/prometheus-charting/spec.md` | ✓ |
|
||||
| Delta op-class = pure `## ADDED` (non-destructive) | ADDED=1, MODIFIED=0, REMOVED=0, RENAMED=0 (new domain) | ✓ |
|
||||
| Requirement-ID parity (flat ↔ delta ↔ canonical) | 27 == 27 == 27, identical IDs SC-101…SC-127 | ✓ |
|
||||
| proposal / design / tasks artifacts present | all populated | ✓ |
|
||||
| **Final Task Completion Gate — zero unchecked `- [ ]`** | `grep -nE '^\s*- \[ \]' tasks.md` → **NONE**; `grep -cE '^\s*- \[x\]'` → **39** | ✓ |
|
||||
| `apply-progress.md` present & records the work | present; status "complete — all 39 tasks done", 3 slices + coverage close documented | ✓ |
|
||||
| No active same-domain (`prometheus-charting`) collision | only this change carries a `prometheus-charting` delta | ✓ |
|
||||
|
||||
**Stale-checkbox reconciliation note.** At verify time, 19 implementation/verification checkboxes
|
||||
(Slice 3 §3.1–3.14 and Integration §4.1–4.5) were unchecked and `apply-progress.md` did not exist.
|
||||
That condition was reconciled **before** archive: the boxes are now all ticked and
|
||||
`apply-progress.md` was authored documenting the three landed slices and the SC-125 coverage close.
|
||||
`apply-progress.md` plus the verify report prove every previously-unchecked task complete. No
|
||||
archive-time mechanical checkbox repair was needed — the gate now passes on the persisted
|
||||
`tasks.md` as-is. No partial-archive approval applies.
|
||||
|
||||
## 3. Artifacts read (archive preflight)
|
||||
|
||||
- `openspec/changes/prometheus-direct-charting/proposal.md`
|
||||
- `openspec/changes/prometheus-direct-charting/spec.md` (flat, authoritative planning artifact — 27 requirements)
|
||||
- `openspec/changes/prometheus-direct-charting/specs/prometheus-charting/spec.md` (change-side domain delta)
|
||||
- `openspec/changes/prometheus-direct-charting/design.md`
|
||||
- `openspec/changes/prometheus-direct-charting/tasks.md`
|
||||
- `openspec/changes/prometheus-direct-charting/apply-progress.md`
|
||||
- `openspec/changes/prometheus-direct-charting/verify-report.md`
|
||||
- `openspec/changes/prometheus-direct-charting/sync-report.md`
|
||||
- `openspec/specs/prometheus-charting/spec.md` (canonical, sync target — verified present and untouched)
|
||||
- `openspec/config.yaml` (rules: proposal/tasks; no `rules.archive` override)
|
||||
|
||||
> The legacy flat `spec.md` is **not** the only spec artifact: a per-domain delta
|
||||
> (`specs/prometheus-charting/spec.md`) and a canonical spec both exist, so the "legacy flat spec
|
||||
> as the *only* artifact" archive-block condition does not apply. The flat spec travels with the
|
||||
> archived folder as part of the audit trail.
|
||||
|
||||
## 4. Domains synced & requirement delta
|
||||
|
||||
| Domain | Change-side delta | Canonical | Action |
|
||||
|---|---|---|---|
|
||||
| `prometheus-charting` | `specs/prometheus-charting/spec.md` | `openspec/specs/prometheus-charting/spec.md` | **NEW domain** — pure ADDED (27 requirements) |
|
||||
|
||||
- **ADDED (27)** — all to the new `prometheus-charting` domain (canonical did not exist pre-change).
|
||||
IDs and text preserved verbatim from the verified flat `spec.md`. Grouped logically:
|
||||
- *Direct Prometheus range query path* — SC-101, SC-102, SC-103, SC-104
|
||||
- *Prometheus chart widget (rebrand + rebind)* — SC-105, SC-106, SC-107, SC-108
|
||||
- *Prometheus gauge widget* — SC-109, SC-110, SC-111
|
||||
- *Prometheus mean widget* — SC-112, SC-113, SC-114
|
||||
- *Grafana removal* — SC-115, SC-116, SC-117, SC-118, SC-119, SC-120
|
||||
- *Configuration documentation accuracy* — SC-121, SC-122
|
||||
- *Test and build greenness* — SC-123, SC-124, SC-125
|
||||
- *Migration guidance* — SC-126, SC-127
|
||||
- **MODIFIED (0)** · **REMOVED (0)** · **RENAMED (0)** — new domain; nothing destructive.
|
||||
|
||||
> No destructive-merge guard or parent approval was triggered (zero REMOVED / zero MODIFIED). The
|
||||
> new `prometheus-charting` domain is distinct from the existing `web-ui` canonical domain.
|
||||
|
||||
## 5. Final lifecycle status (all 8 phases done)
|
||||
|
||||
| Phase | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Proposal | ✅ done | `proposal.md` |
|
||||
| Spec | ✅ done | flat `spec.md` (27) + domain delta `specs/prometheus-charting/spec.md` (27 ADDED) |
|
||||
| Design | ✅ done | `design.md` |
|
||||
| Tasks | ✅ done | `tasks.md` — **39/39** checked, zero `- [ ]` |
|
||||
| Apply | ✅ done | 3 slices delivered (commits `5dad982`, `65bae95`/`58be6e0`, `67ca0fc`/`ba94317`) |
|
||||
| Verify | ✅ PASS | `verify-report.md` — 26/27 PASS, SC-125 PARTIAL→PASS; gates green |
|
||||
| Sync | ✅ done | `sync-report.md` — SYNCED; canonical `prometheus-charting` domain created |
|
||||
| Archive | ✅ done | this report + folder move performed |
|
||||
|
||||
## 6. Gate results (run at head `67ca0fc`; per verify-report)
|
||||
|
||||
| Gate | Command | Result |
|
||||
|---|---|---|
|
||||
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** — 293 passed (2 pre-existing warnings) |
|
||||
| Backend lint | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** — All checks passed |
|
||||
| Frontend build | `cd frontend && npm run build` | **PASS** — exit 0 (pre-existing chunk-size warning) |
|
||||
| Frontend lint | `cd frontend && npm run lint` | **PASS** — 0 errors (1 pre-existing warning) |
|
||||
|
||||
## 7. Carry-over follow-ups (non-blocking; recorded per task)
|
||||
|
||||
1. **[INFO] Stale generated `.pi-map.md` still references Grafana** — generated project-map artifacts
|
||||
(`.pi-map.md` / `.pi-map.index.md`) still mention Grafana / `ObservabilityPage.tsx` /
|
||||
`GrafanaLinkWidget`. These are **generated artifacts, not deliverable source**, and are explicitly
|
||||
ignored by SC-115/SC-116 (which scope only `backend/src`/`frontend/src` source). **Out of scope
|
||||
for this change** — regenerate via `project_map_patch` / `project_map_validate` in a separate
|
||||
housekeeping pass; the project-map protocol already flags these `dirty`.
|
||||
2. **[INFO] SC-125 was PARTIAL→closed.** Verify found SC-125 (new widget kinds have tests) PARTIAL
|
||||
because no explicit `isLoading:true` loading-state test existed in any of the three Prometheus
|
||||
widget test files — a coverage gap, not a functional defect. The gap was **closed** before
|
||||
archive: `apply-progress.md` documents adding one `renders skeleton while loading` case to each
|
||||
of `PrometheusChartWidget` / `PrometheusGaugeWidget` / `PrometheusMeanWidget`, asserting the
|
||||
`Skeleton` renders under `{ data: undefined, isLoading: true }`. SC-125 is now PASS.
|
||||
3. **[INFO] `config.yaml` context block** still names `frontend/src/components/ObservabilityPage.tsx`
|
||||
(refactored away into `service-tabs/`). Not an SC-121 criterion (which targets the
|
||||
charting/grafana claims — those are fixed); minor doc staleness. Out of scope.
|
||||
4. **[INFO] Slice-2 review-budget variance** — slice 2 (~707 insertions) exceeded the ~310–400
|
||||
forecast, but is additive feature code + tests (gauge + mean); the boundary is the feature, not
|
||||
scope creep. Non-blocking; recorded for the record.
|
||||
5. **[INFO] Unrelated dirty working-tree items** predating/orthogonal to this change (an uncommitted
|
||||
cosmetic reformat of `frontend/src/pages/service-tabs/MediaTab.tsx`, untracked `.pi-tmp/*`, and
|
||||
the separate `service-storage-harness` proposal folder) were **not touched** by this archive.
|
||||
SC-127 (independence from `service-storage-harness`) holds.
|
||||
|
||||
## 8. Residual risks & destructive-merge statement
|
||||
|
||||
- **Destructive sync / merge:** **not applicable.** Zero REMOVED and zero MODIFIED requirements
|
||||
(new `prometheus-charting` domain; pure ADDED). No destructive-merge guard or parent approval was
|
||||
triggered.
|
||||
- **Backend / data-contract impact:** none — Grafana was fully excised from live code paths
|
||||
(service type, adapters, widgets, hook, API client, type, status endpoint, nav entry, service
|
||||
tab); Prometheus is the direct chart source via `/api/v1/query_range`. The `metric` widget data
|
||||
shape is preserved. Archive touched only OpenSpec docs + the folder move.
|
||||
- **No critical verification issues** remain (CRITICAL issues are non-overridable; the one verify
|
||||
CRITICAL was the reconcilable checkbox/apply-progress gap, now resolved).
|
||||
- **No browser/visual smoke** was performed (out of scope); the recharts `RadialBarChart` gauge and
|
||||
`LineChart` rendering are structurally tested only.
|
||||
- **Memory observation IDs:** none — `artifactStore: openspec`; traceability lives in the
|
||||
filesystem archive + canonical spec.
|
||||
|
||||
## 9. Move performed
|
||||
|
||||
```
|
||||
git mv openspec/changes/prometheus-direct-charting openspec/changes/archive/2026-07-08-prometheus-direct-charting
|
||||
```
|
||||
|
||||
- **All 9 artifacts confirmed present at the archived path:** `proposal.md`, `spec.md`,
|
||||
`specs/prometheus-charting/spec.md` (delta), `design.md`, `tasks.md`, `apply-progress.md`,
|
||||
`verify-report.md`, `sync-report.md`, `archive-report.md` (this file).
|
||||
- **Canonical `openspec/specs/prometheus-charting/spec.md` remains in place** (verified untouched
|
||||
after the move). `openspec/specs/web-ui/spec.md` also untouched.
|
||||
|
||||
---
|
||||
|
||||
### Appendix — Files written/moved by this archive (OpenSpec only; no source code)
|
||||
|
||||
- **Written:** `openspec/changes/prometheus-direct-charting/archive-report.md` (this file) — at the
|
||||
active path before the move; travels with the move into the archive.
|
||||
- **Moved (via `git mv`):** the entire
|
||||
`openspec/changes/prometheus-direct-charting/` directory →
|
||||
`openspec/changes/archive/2026-07-08-prometheus-direct-charting/`.
|
||||
- **Left in place (durable canonical):** `openspec/specs/prometheus-charting/spec.md`.
|
||||
- **Not committed / not pushed** — the parent owns the commit with explicit paths.
|
||||
@@ -0,0 +1,462 @@
|
||||
# SDD Design: Prometheus Direct Charting (drop Grafana middleman)
|
||||
|
||||
**Change:** `prometheus-direct-charting`
|
||||
**Phase:** design
|
||||
**Date:** 2026-07-08
|
||||
|
||||
## 0. Source findings (read before anything else)
|
||||
|
||||
The proposal and spec were written against a **stale project map**. Reading actual source surfaced deviations the design must account for. Trust source, not the map.
|
||||
|
||||
| Spec claim | Actual source reality | Design impact |
|
||||
|---|---|---|
|
||||
| SC-118: "remove the Grafana branch from `ObservabilityPage`" | **`ObservabilityPage.tsx` no longer exists.** It was refactored into a per-service-type `service-tabs/` architecture (confirmed by `LinksTab.tsx` docstring: "Lifts the Grafana deep-link content from the old cross-service ObservabilityPage into an instance-scoped tab"). The stale reference survives only in `.pi-map.md` files. | Removal targets are `service-tabs/LinksTab.tsx` + its test, the `grafana` case in `service-tabs/index.ts`, the `navEntries.ts` grafana entry, `Dashboard.tsx`'s `OBSERVABILITY_TYPES` set, `ServicesPage.tsx` empty-state text, and the `useGrafanaStatus` hook. See §5. |
|
||||
| "No recharts/d3 in use" (config rule to repeal) | `recharts ^3.9.2` is **declared, installed, and imported** by `GrafanaChartWidget.tsx`. Confirmed. | Repeal is a doc fix matching reality; recharts is the sanctioned renderer. |
|
||||
| Proposal §5.1: "extend `PrometheusWidgetSource` ... to handle `chart`" | `PrometheusWidgetSource.fetch` currently only does instant `/api/v1/query` and returns `{"result": data}`. The frontend `PrometheusMetricWidget` consumes `data.result`. | The chart/mean paths return a **different** shape (`{series}` / `{value}`); dispatch inside `.fetch()` by `widget_kind`. See §2.2. |
|
||||
| Map said registry has 7 service types | Source registry has **8**: `alertmanager, authentik, backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks`. (`authentik` and `backups` were added after the map froze.) | No design impact beyond acknowledging grafana is one of eight, not one of seven. |
|
||||
| Frontend `SERVICE_REGISTRY` (registry.ts) | Has a `grafana` binding with `link` + `chart` kinds, and a `prometheus` binding with only `metric`. The `chart` kind must move grafana→prometheus. | Confirmed; §3.2 details the rebinding. |
|
||||
|
||||
No proposal/spec scope change is required — the *intent* (remove Grafana, direct Prom charting) still holds. Only the **removal targets** differ from what SC-118 literally names. This is flagged explicitly so the tasks phase and reviewer aren't surprised.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
This change cuts Grafana out of the chart data path and makes Prometheus the direct source. The existing recharts renderer is reused unchanged for `chart`; two new widget kinds (`gauge`, `mean`) are added to the `prometheus` service. Grafana is removed entirely.
|
||||
|
||||
```
|
||||
BEFORE AFTER
|
||||
─────── ─────
|
||||
WidgetData fetch WidgetData fetch
|
||||
└► GrafanaWidgetSource └► PrometheusWidgetSource
|
||||
└► POST {grafana}/api/ds/query ├► kind=metric → /api/v1/query {result} [unchanged]
|
||||
(datasource.type=prometheus) ├► kind=chart → /api/v1/query_range {series} [NEW]
|
||||
└► normalize frames → {series} ├► kind=gauge → /api/v1/query {value,...} [NEW]
|
||||
└► kind=mean → /api/v1/query_range {value} [NEW]
|
||||
|
||||
Frontend GrafanaChartWidget (recharts) Frontend PrometheusChartWidget (recharts) [renamed, reused]
|
||||
+ PrometheusGaugeWidget (recharts RadialBarChart) [NEW]
|
||||
+ PrometheusMeanWidget (MetricCard-style) [NEW]
|
||||
|
||||
grafana service type / LinkWidget / [REMOVED entirely]
|
||||
LinksTab / navEntry / status endpoint
|
||||
```
|
||||
|
||||
**Key constraints carried from the spec:**
|
||||
|
||||
- Reuse the recharts chart renderer unchanged (SC-106) — the rename is structural.
|
||||
- Shared series normalization (SC-102) — one helper, no duplication.
|
||||
- Step derived from window presets (SC-104), users never set `step`.
|
||||
- `gauge`/`mean` are scalar-only (SC-111, SC-114); `chart` stays multi-series (SC-107).
|
||||
- Window presets: `1h`, `6h`, `24h`, `7d` (SC-108, SC-112).
|
||||
- All adapter errors return `{"error": str}`, never raise (SC-103).
|
||||
- No Grafana references remain (SC-115, SC-116, SC-117, SC-120).
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend design
|
||||
|
||||
### 2.1 New module: `widgets/prometheus_range.py` (shared helpers)
|
||||
|
||||
Holds the two pieces the spec's downstream-notes asked for: the step-derivation function (SC-104) and the shared normalization helper (SC-102). Putting them in their own module (rather than inside `sources.py`) makes them unit-testable in isolation and reusable by the future `service-storage-harness` change's in-service data path, without `sources.py` growing unbounded.
|
||||
|
||||
**Window presets and step derivation (SC-104):**
|
||||
|
||||
```python
|
||||
WINDOW_PRESETS: dict[str, int] = {
|
||||
"1h": 3_600,
|
||||
"6h": 21_600,
|
||||
"24h": 86_400,
|
||||
"7d": 604_800,
|
||||
}
|
||||
|
||||
# Target ~200 points per window. Step is clamped to >= 15s so Prometheus
|
||||
# doesn't reject sub-15s resolutions on high-cardinality queries.
|
||||
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
|
||||
return max(15, round(window_seconds / target_points))
|
||||
```
|
||||
|
||||
Resulting table (verified, all within the 100–300 target band):
|
||||
|
||||
| Preset | Window (s) | Derived step (s) | Points |
|
||||
|--------|-----------|------------------|--------|
|
||||
| `1h` | 3,600 | `max(15, round(3600/200))` = 18 | 200 |
|
||||
| `6h` | 21,600 | 108 | 200 |
|
||||
| `24h` | 86,400 | 432 | 200 |
|
||||
| `7d` | 604,800 | 3,024 | 200 |
|
||||
|
||||
(At apply time the implementer may round steps to "nicer" values like 15/60/300/1800 for cache-friendliness; the spec only requires the 100–300 band, which the formula satisfies. The formula is the source of truth; the table is illustrative.)
|
||||
|
||||
**Shared normalization (SC-102) — `normalize_prometheus_matrix`:**
|
||||
|
||||
```python
|
||||
def normalize_prometheus_matrix(
|
||||
result: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Turn a Prometheus /api/v1/query_range `data.result` matrix into
|
||||
`{label, points:[{t:int, v:float|None}]}` series — the exact shape the
|
||||
frontend chart renderer consumes.
|
||||
|
||||
Label derivation reuses the rule from the removed Grafana path:
|
||||
1. Drop __name__ from metric labels.
|
||||
2. If labels remain, join as `k=v k=v`.
|
||||
3. Else fall back to "value".
|
||||
4. Dedup collisions with a ` (n)` suffix.
|
||||
"""
|
||||
series: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
for entry in result:
|
||||
metric = entry.get("metric") or {}
|
||||
values = entry.get("values") or []
|
||||
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not k.startswith("__")]
|
||||
label = " ".join(parts) if parts else "value"
|
||||
if label in seen:
|
||||
seen[label] += 1
|
||||
label = f"{label} ({seen[label]})"
|
||||
else:
|
||||
seen[label] = 0
|
||||
points = []
|
||||
for ts, raw in values:
|
||||
v = float(raw) if raw not in (None, "NaN", "+Inf", "-Inf") else None
|
||||
points.append({"t": int(ts), "v": v})
|
||||
series.append({"label": label, "points": points})
|
||||
return series
|
||||
```
|
||||
|
||||
This is a **direct extraction** of the label/dedup logic currently inside `GrafanaWidgetSource._fetch_chart`, retargeted at the Prometheus matrix shape (`{metric, values:[[ts,"str"],...]}`) instead of Grafana frames. The dedup rule is identical so users moving a `grafana/chart` widget to `prometheus/chart` see the same labels.
|
||||
|
||||
### 2.2 `PrometheusWidgetSource` — extend `.fetch()` by `widget_kind`
|
||||
|
||||
Today `.fetch()` does only the instant-query → `{"result": ...}` path. Extend it to dispatch by `widget_kind` while preserving the existing `metric` behavior byte-for-byte:
|
||||
|
||||
```python
|
||||
class PrometheusWidgetSource:
|
||||
async def fetch(self, service, widget_kind, config) -> dict[str, Any]:
|
||||
if service is None:
|
||||
return {"error": "Prometheus widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
|
||||
if widget_kind == "metric":
|
||||
return await self._fetch_instant(base_url, timeout, config) # unchanged
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(base_url, timeout, config) # NEW
|
||||
if widget_kind == "gauge":
|
||||
return await self._fetch_gauge(base_url, timeout, config) # NEW
|
||||
if widget_kind == "mean":
|
||||
return await self._fetch_mean(base_url, timeout, config) # NEW
|
||||
return {"error": f"Unknown widget kind: {widget_kind}"}
|
||||
```
|
||||
|
||||
**`_fetch_chart` (SC-101, SC-103, SC-104):**
|
||||
|
||||
```python
|
||||
async def _fetch_chart(self, base_url, timeout, config) -> dict[str, Any]:
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
step = step_for_window(window)
|
||||
end = int(time.time())
|
||||
start = end - window
|
||||
try:
|
||||
resp = await asyncio.wait_for(asyncio.to_thread(
|
||||
requests.get, f"{base_url}/api/v1/query_range",
|
||||
params={"query": promql, "start": start, "end": end, "step": step},
|
||||
timeout=timeout,
|
||||
), timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Prometheus query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
result = payload.get("data", {}).get("result", [])
|
||||
return {"series": normalize_prometheus_matrix(result)}
|
||||
```
|
||||
|
||||
**`_fetch_gauge` (SC-109, SC-110, SC-111) — instant query, scalar-only:**
|
||||
|
||||
```python
|
||||
async def _fetch_gauge(self, base_url, timeout, config) -> dict[str, Any]:
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
raw = await self._instant_query(base_url, timeout, promql) # shared helper
|
||||
if "error" in raw:
|
||||
return raw
|
||||
result = raw["result"]
|
||||
if len(result) != 1:
|
||||
return {"error": "Gauge requires a single-series query; refine your PromQL"}
|
||||
# vector entry: {metric, value:[ts, "str"]}
|
||||
try:
|
||||
value = float(result[0]["value"][1])
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
return {"error": "Gauge query returned no scalar value"}
|
||||
return {
|
||||
"value": value,
|
||||
"warn_at": config.get("warn_at"),
|
||||
"crit_at": config.get("crit_at"),
|
||||
"min": config.get("min"),
|
||||
"max": config.get("max"),
|
||||
"unit": config.get("unit"),
|
||||
}
|
||||
```
|
||||
|
||||
`_instant_query` is extracted from the current `metric` path so `metric`/`gauge` share it; it returns `{"result": [...]}` or `{"error": ...}`.
|
||||
|
||||
**`_fetch_mean` (SC-112, SC-113, SC-114) — range query, client-side mean, scalar-only:**
|
||||
|
||||
```python
|
||||
async def _fetch_mean(self, base_url, timeout, config) -> dict[str, Any]:
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
step = step_for_window(window)
|
||||
end = int(time.time()); start = end - window
|
||||
# ... run query_range, handle errors identically to _fetch_chart ...
|
||||
result = payload.get("data", {}).get("result", [])
|
||||
if len(result) != 1:
|
||||
return {"error": "Mean requires a single-series query; refine your PromQL"}
|
||||
points = result[0].get("values") or []
|
||||
nums = [float(v) for _, v in points if v not in (None, "NaN", "+Inf", "-Inf")]
|
||||
if not nums:
|
||||
return {"error": "Mean query returned no numeric samples in the window"}
|
||||
mean = sum(nums) / len(nums)
|
||||
return {"value": mean, "unit": config.get("unit")}
|
||||
```
|
||||
|
||||
The range-query HTTP call + error handling is shared between `_fetch_chart` and `_fetch_mean` via a private `_range_query(base_url, timeout, promql, window) -> dict` returning `{"matrix": result}` or `{"error": ...}`. This keeps the chart and mean paths DRY without inventing a generic adapter registry (non-goal enforced).
|
||||
|
||||
### 2.3 `integrations/prometheus.py` — declare new widget kinds
|
||||
|
||||
Add two Pydantic widget-config models and two `widget_kind(...)` entries; leave `PrometheusMetricWidgetConfig` and the existing `metric` kind untouched (non-goal: `prometheus_metric` stays as-is):
|
||||
|
||||
```python
|
||||
class PrometheusChartWidgetConfig(WidgetConfigBase):
|
||||
promql: str
|
||||
window: str = "1h" # one of 1h/6h/24h/7d
|
||||
|
||||
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
|
||||
promql: str
|
||||
warn_at: float | None = None
|
||||
crit_at: float | None = None
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
unit: str | None = None
|
||||
|
||||
class PrometheusMeanWidgetConfig(WidgetConfigBase):
|
||||
promql: str
|
||||
window: str = "1h"
|
||||
unit: str | None = None
|
||||
```
|
||||
|
||||
`DEFINITION.widget_kinds` gains `chart`, `gauge`, `mean` (refresh 60s for chart/mean, 30s for gauge). Validation that `window ∈ {1h,6h,24h,7d}` can be a `field_validator` on the two models that use it, or simply enforced by `WINDOW_PRESETS.get(..., default)` server-side — prefer the latter (lenient) so a future preset addition doesn't require a model change.
|
||||
|
||||
### 2.4 `integrations/grafana.py` — delete; registry drops the entry
|
||||
|
||||
- Delete the file.
|
||||
- In `integrations/registry.py`, drop the `from ...grafana import DEFINITION as GRAFANA` import and the `GRAFANA.service_type: GRAFANA,` line from `SERVICE_DEFINITIONS`. No other registry change.
|
||||
|
||||
### 2.5 `widgets/sources.py` — drop Grafana; wire nothing new
|
||||
|
||||
- Delete `GrafanaWidgetSource` and its `_fetch_chart`.
|
||||
- Remove `"grafana": GrafanaWidgetSource(),` from `SERVICE_ADAPTERS`. (The label/dedup logic has already been *extracted* into `prometheus_range.normalize_prometheus_matrix` in §2.1; it is not lost when the Grafana class is deleted.)
|
||||
- `PrometheusWidgetSource` gains the three new methods from §2.2.
|
||||
|
||||
### 2.6 `routers/monitoring.py` — remove `get_grafana_status`
|
||||
|
||||
Delete the `@router.get("/grafana-status")` endpoint (lines ~175–195). No other monitoring change; `get_prometheus_status` / `get_alertmanager_status` stay.
|
||||
|
||||
### 2.7 No SettingsStore or DB schema change
|
||||
|
||||
Widget *instances* are stored generically (`service_id`, `widget_kind`, `config_json`). A `prometheus` `chart` instance is just a row with `service_id=<prom instance>` and `widget_kind="chart"`. Existing `grafana/chart` rows become orphans resolved by the existing "unknown widget" path (SC-119) — no migration code, no schema change (SC-126).
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend design
|
||||
|
||||
### 3.1 Rename + reuse: `GrafanaChartWidget` → `PrometheusChartWidget`
|
||||
|
||||
- Rename `frontend/src/widgets/GrafanaChartWidget.tsx` → `PrometheusChartWidget.tsx`; rename the exported function/component.
|
||||
- The recharts body (`LineChart`, `Line`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `ResponsiveContainer`, `mergeSeries`, `CHART_COLORS`, `formatTime`) is **preserved unchanged** (SC-106).
|
||||
- One tiny text fix: the empty-state Alert copy "Check your query and datasource_uid" becomes "Check your PromQL query and window." (`datasource_uid` no longer exists.)
|
||||
- Rename test file `__tests__/GrafanaChartWidget.test.tsx` → `PrometheusChartWidget.test.tsx`; update import + the one error-message assertion (`"Grafana api_key is required"` → a Prom error string).
|
||||
|
||||
### 3.2 `integrations/registry.ts` — rebind + add
|
||||
|
||||
- Delete the entire `grafana` key from `SERVICE_REGISTRY`.
|
||||
- In the `prometheus` binding's `widgets` array, add `chart`, `gauge`, `mean` alongside the existing `metric`. Each has a `configSchema` mirroring §2.3 (`promql`, `window` for chart/mean; `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit` for gauge). `chart`/`mean` refresh 60s; `gauge` 30s.
|
||||
- Import the renamed `PrometheusChartWidget` and the two new components.
|
||||
|
||||
### 3.3 New: `PrometheusGaugeWidget.tsx` (SC-109, SC-110, SC-111)
|
||||
|
||||
**Renderer choice: recharts `RadialBarChart`.** Justification: recharts is already a dependency (no new dep), `RadialBarChart` renders a single-value gauge with domain bands natively, and it shares tooltip/styling conventions with the chart widget — keeping the two visualizations consistent. The alternative (a ~50-line bespoke SVG gauge) was rejected because it would introduce a second rendering dialect for no benefit; the proposal's "fall back to SVG if recharts proves heavy" fallback remains documented but is not the default.
|
||||
|
||||
Threshold-band rendering: render three stacked `RadialBar` cells (green `0→warn`, amber `warn→crit`, red `crit→max`) as the track, and a fourth cell (the actual value) as the needle/bar. When `warn_at`/`crit_at` are absent, render a single neutral-color track. `min`/`max` default to `0`/`max(value, 1)` when omitted so the gauge has a sane domain. The component reuses `SectionCard` + `Alert`/`Skeleton` for loading/error states, matching every other widget.
|
||||
|
||||
Config fields surfaced to the user (matching §2.3): `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit`.
|
||||
|
||||
### 3.4 New: `PrometheusMeanWidget.tsx` (SC-112, SC-113, SC-114)
|
||||
|
||||
A single-value display reusing the existing `MetricCard` pattern (already used by `BackupDashboardWidget` / `PrometheusMetricWidget`-adjacent tiles): big number, optional `unit` suffix, optional subtext showing the window ("mean over last 1h"). Loading/error/empty states via `Skeleton`/`Alert` as usual. No charting library involvement — it's a number, deliberately.
|
||||
|
||||
### 3.5 Grafana removal on the frontend
|
||||
|
||||
Per the source findings (§0), the removal targets are **not** an `ObservabilityPage` section. They are:
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `widgets/GrafanaLinkWidget.tsx` | **delete** |
|
||||
| `widgets/GrafanaChartWidget.tsx` | **rename** to PrometheusChartWidget (§3.1) — not a pure delete |
|
||||
| `widgets/index.ts` | drop `GrafanaLinkWidget` export; rename the chart export |
|
||||
| `integrations/registry.ts` | drop `grafana` binding (§3.2) |
|
||||
| `integrations/navEntries.ts` | drop the `grafana` entry from `SERVICE_TYPE_NAV_ENTRIES`; drop the now-unused `Link2` icon import |
|
||||
| `hooks/useObservability.ts` | drop `useGrafanaStatus` + its `fetchGrafanaStatus` import |
|
||||
| `api/client.ts` | drop `fetchGrafanaStatus` |
|
||||
| `types/index.ts` | drop `GrafanaStatus` interface |
|
||||
| `pages/service-tabs/LinksTab.tsx` | **delete** (it is the grafana-specific tab; its `GrafanaLinkCard` + machine deep-links are Grafana-only) |
|
||||
| `pages/service-tabs/__tests__/LinksTab.test.tsx` | **delete** |
|
||||
| `pages/service-tabs/index.ts` | drop the `LinksTab` import and the `case "grafana":` from `serviceContentTabs` |
|
||||
| `pages/Dashboard.tsx` | drop `"grafana"` from `OBSERVABILITY_TYPES` set (line ~68) |
|
||||
| `pages/ServicesPage.tsx` | update empty-state copy "Add a Grafana, Prometheus, …" → "Add a Prometheus, …" |
|
||||
| `pages/__tests__/Dashboard.test.tsx` | the one "Grafana" label literal there is for a *shortcut* (a website link), unrelated to the Grafana service — **leave it** (grep-clean criterion SC-116 still passes; it's not a grafana service reference, just user-typed shortcut text in a test fixture). Flag for reviewer. |
|
||||
|
||||
**Spec-text note (not a design change):** SC-118 literally names "the ObservabilityPage Grafana section," which no longer exists. The *intent* of SC-118 ("Grafana status checks are removed") is satisfied by dropping `get_grafana_status` (§2.6) + `useGrafanaStatus`. The tasks phase should note this textual drift so the reviewer doesn't treat it as a missed requirement. If the parent prefers, SC-118 can be reworded in `spec.md` to name `LinksTab`/`useGrafanaStatus` instead; this design does not require that edit to proceed.
|
||||
|
||||
### 3.6 Types
|
||||
|
||||
`types/index.ts`: remove `GrafanaStatus`. No new widget-payload types — `chart` uses `{series}` (existing), `gauge` uses `{value, warn_at, crit_at, min, max, unit}` (all optional beyond `value`), `mean` uses `{value, unit?}`. These are read off `data?.data` untyped-as-before; no `WidgetDataResponse` generic change is needed (it's already `data: dict | None`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
1. **Chart** — `useWidgetData(widgetId, 60_000)` → `GET /api/widgets/instances/{id}/data` → `PrometheusWidgetSource.fetch(kind="chart")` → `_fetch_chart` → `GET {prom}/api/v1/query_range?query=...&start=...&end=...&step=...` → `normalize_prometheus_matrix` → `{"series":[...]}` → `PrometheusChartWidget` renders recharts (unchanged).
|
||||
2. **Gauge** — `useWidgetData(widgetId, 30_000)` → `fetch(kind="gauge")` → `_fetch_gauge` → instant query → scalar-only check → `{"value":..., "warn_at":..., ...}` → `PrometheusGaugeWidget` renders RadialBarChart.
|
||||
3. **Mean** — `useWidgetData(widgetId, 60_000)` → `fetch(kind="mean")` → `_fetch_mean` → range query → client-side mean → `{"value":..., "unit":...}` → `PrometheusMeanWidget` renders a MetricCard.
|
||||
4. **Metric (unchanged)** — existing path preserved byte-for-byte.
|
||||
5. **Orphaned grafana widget** — `resolveWidget` finds no `grafana` binding → returns `undefined` → `WidgetInstanceCard` renders its existing "unknown widget" Alert (SC-119). No crash, no migration.
|
||||
|
||||
Errors at any step return `{"error": str}` (SC-103); the per-widget `Alert variant="destructive"` renders it and siblings keep polling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing approach
|
||||
|
||||
### Backend
|
||||
|
||||
Extend `backend/tests/test_widgets.py` (and/or a focused `test_prometheus_range.py`):
|
||||
|
||||
- `normalize_prometheus_matrix`: feed a sample Prom `/api/v1/query_range` `data.result` (two entries, one with `__name__`, one colliding label) → assert `{series}` shape, label dedup, null handling for `"NaN"`.
|
||||
- `step_for_window`: assert the 1h/6h/24h/7d → step mapping stays within 100–300 points.
|
||||
- `PrometheusWidgetSource` chart path: mock `requests.get` → assert `query_range` URL + params (`start`/`end`/`step` present, no `from_ts`/`to_ts`) and `{"series": ...}` return.
|
||||
- Gauge scalar-only: mock instant query returning 2 series → assert `{"error": ...}`.
|
||||
- Mean scalar-only + client-side mean: mock range query returning 1 series with known values → assert the arithmetic mean; mock 2 series → assert error.
|
||||
- Error paths: timeout / `RequestException` → `{"error": ...}` (no raise).
|
||||
|
||||
### Frontend
|
||||
|
||||
- `PrometheusChartWidget.test.tsx` (renamed from GrafanaChartWidget test): loading, error, rendered-data cases (SC-125).
|
||||
- `PrometheusGaugeWidget.test.tsx`: loading, error, rendered-with-bands, rendered-without-bands.
|
||||
- `PrometheusMeanWidget.test.tsx`: loading, error, rendered value.
|
||||
- `registry.test.ts`: assert no `grafana` binding; assert `prometheus` binding has `metric`, `chart`, `gauge`, `mean`.
|
||||
|
||||
### Build/lint gates
|
||||
|
||||
Each slice: `PYTHONPATH=src pytest` (from `backend/`), `npm run build`, `npm run lint` must be green.
|
||||
|
||||
---
|
||||
|
||||
## 6. File-level plan
|
||||
|
||||
### Create
|
||||
|
||||
| File | Rationale |
|
||||
|------|-----------|
|
||||
| `backend/src/media_library_viewer_api/widgets/prometheus_range.py` | `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix` (SC-102, SC-104). |
|
||||
| `backend/tests/test_prometheus_range.py` | Unit tests for the helpers. |
|
||||
| `frontend/src/widgets/PrometheusGaugeWidget.tsx` | recharts RadialBarChart gauge (SC-109/110/111). |
|
||||
| `frontend/src/widgets/PrometheusMeanWidget.tsx` | MetricCard-style mean (SC-112/113/114). |
|
||||
| `frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx` | SC-125. |
|
||||
| `frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx` | SC-125. |
|
||||
|
||||
### Rename (git mv)
|
||||
|
||||
| From → To | Rationale |
|
||||
|-----------|-----------|
|
||||
| `widgets/GrafanaChartWidget.tsx` → `widgets/PrometheusChartWidget.tsx` | SC-105/106. Body preserved. |
|
||||
| `widgets/__tests__/GrafanaChartWidget.test.tsx` → `widgets/__tests__/PrometheusChartWidget.test.tsx` | match rename. |
|
||||
|
||||
### Modify
|
||||
|
||||
| File | Rationale |
|
||||
|------|-----------|
|
||||
| `backend/.../widgets/sources.py` | Drop `GrafanaWidgetSource`; add chart/gauge/mean to `PrometheusWidgetSource`. |
|
||||
| `backend/.../integrations/prometheus.py` | Add 3 widget-config models + 3 `widget_kind` entries. |
|
||||
| `backend/.../integrations/registry.py` | Drop grafana import + entry. |
|
||||
| `backend/.../routers/monitoring.py` | Drop `get_grafana_status`. |
|
||||
| `backend/tests/test_widgets.py` | Drop grafana adapter tests; add prom chart/gauge/mean tests. |
|
||||
| `frontend/src/integrations/registry.ts` | Drop grafana binding; add chart/gauge/mean to prometheus. |
|
||||
| `frontend/src/integrations/navEntries.ts` | Drop grafana entry. |
|
||||
| `frontend/src/hooks/useObservability.ts` | Drop `useGrafanaStatus`. |
|
||||
| `frontend/src/api/client.ts` | Drop `fetchGrafanaStatus`. |
|
||||
| `frontend/src/types/index.ts` | Drop `GrafanaStatus`. |
|
||||
| `frontend/src/widgets/index.ts` | Drop GrafanaLinkWidget export; rename chart export. |
|
||||
| `frontend/src/pages/service-tabs/index.ts` | Drop LinksTab import + grafana case. |
|
||||
| `frontend/src/pages/Dashboard.tsx` | Drop "grafana" from `OBSERVABILITY_TYPES`. |
|
||||
| `frontend/src/pages/ServicesPage.tsx` | Update empty-state copy. |
|
||||
| `openspec/config.yaml` | Repeal stale thin-dashboard/no-recharts wording (SC-121). |
|
||||
| `CHANGELOG.md` | Migration note (SC-122). |
|
||||
|
||||
### Delete
|
||||
|
||||
| File | Rationale |
|
||||
|------|-----------|
|
||||
| `backend/.../integrations/grafana.py` | SC-117. |
|
||||
| `frontend/src/widgets/GrafanaLinkWidget.tsx` | SC-117. |
|
||||
| `frontend/src/pages/service-tabs/LinksTab.tsx` | Grafana-only tab; no Prom equivalent needed (chart widget covers viz). |
|
||||
| `frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx` | matches deletion. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Slice boundaries (≤400 changed lines each)
|
||||
|
||||
Each slice leaves `pytest` + `npm run build` + `npm run lint` green and the app in a working state.
|
||||
|
||||
### Slice 1 — Prom range path + chart rebrand + shared helper (foundational)
|
||||
|
||||
**Files:** create `prometheus_range.py` + test; rename `GrafanaChartWidget`→`PrometheusChartWidget` (+ test); modify `sources.py` (`_fetch_chart`, drop nothing yet), `integrations/prometheus.py` (add `chart` kind only), `registry.ts` (move `chart` grafana→prometheus), `widgets/index.ts`, `registry.test.ts`.
|
||||
**State after S1:** a `prometheus/chart` widget renders from direct Prom `query_range`; the `grafana/chart` binding is gone but `GrafanaWidgetSource`/`GrafanaLinkWidget`/grafana service type still exist (removal is S3). Grafana link widgets still work. ~300–380 lines.
|
||||
|
||||
### Slice 2 — Gauge + mean widgets (additive)
|
||||
|
||||
**Files:** create `PrometheusGaugeWidget.tsx` (+test), `PrometheusMeanWidget.tsx` (+test); modify `sources.py` (`_fetch_gauge`, `_fetch_mean`, shared `_instant_query`/`_range_query`), `integrations/prometheus.py` (add `gauge`/`mean` kinds), `registry.ts` (add gauge/mean bindings), `test_widgets.py` (add adapter tests).
|
||||
**State after S2:** gauge + mean widgets selectable and rendering; no grafana change. ~300–380 lines.
|
||||
|
||||
### Slice 3 — Grafana removal + config + changelog (cleanup)
|
||||
|
||||
**Files:** delete `integrations/grafana.py`, `GrafanaLinkWidget.tsx`, `LinksTab.tsx` (+test); modify `sources.py` (drop `GrafanaWidgetSource` + `SERVICE_ADAPTERS` entry), `registry.py`, `monitoring.py`, `navEntries.ts`, `useObservability.ts`, `client.ts`, `types/index.ts`, `widgets/index.ts`, `service-tabs/index.ts`, `Dashboard.tsx`, `ServicesPage.tsx`, `test_widgets.py` (drop grafana tests); rewrite `config.yaml`; add `CHANGELOG.md` entry.
|
||||
**State after S3:** grep-clean (SC-115/116), config accurate (SC-121), migration documented (SC-122). ~250–350 lines (mostly deletions).
|
||||
|
||||
**Order:** S1 → S2 → S3. S1 and S2 are independently shippable; S3 must follow S1 (it removes the grafana chart binding S1 replaces).
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisions log (answers to spec downstream-notes)
|
||||
|
||||
1. **Step derivation (SC-104):** `step_for_window(window_seconds, target_points=200) = max(15, round(window_seconds/200))`, in `widgets/prometheus_range.py`. Table in §2.1.
|
||||
2. **Shared normalization (SC-102):** `normalize_prometheus_matrix(result)` in `widgets/prometheus_range.py`; extracts the dedup rule from the to-be-deleted Grafana path; retargeted at Prom matrix shape. Signature in §2.1.
|
||||
3. **Gauge renderer (SC-110):** recharts `RadialBarChart` (no new dep, consistent styling). Threshold bands via stacked track cells; neutral single-color when `warn_at`/`crit_at` absent. Config fields: `promql, warn_at, crit_at, min, max, unit`. §3.3.
|
||||
4. **Mean adapter (SC-112/113):** `_fetch_mean` runs `query_range` over the window preset, averages non-null samples of the single series client-side, returns `{value, unit?}`. Scalar-only enforced via `len(result) != 1 → error`. §2.2.
|
||||
5. **Slice plan:** 3 slices (S1 range+rebrand+helper, S2 gauge+mean, S3 grafana removal+config+changelog), each ≤400 lines, order S1→S2→S3. §7.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open items for the tasks/apply phases
|
||||
|
||||
- Implementer should `git mv` the chart widget/test so history is preserved (not delete+create).
|
||||
- Implementer should verify the recharts `RadialBarChart` gauge renders acceptably; if it proves fiddly, the proposal's SVG fallback (~50 lines) is sanctioned — but try recharts first.
|
||||
- SC-118 text names a file that no longer exists; tasks phase should record this so verify doesn't flag it as a miss. Intent is satisfied by removing `get_grafana_status` + `useGrafanaStatus` + `LinksTab`.
|
||||
- `Dashboard.test.tsx` contains the literal "Grafana" in a shortcut test fixture (not a grafana service reference) — grep for SC-116 should be scoped to service/widget references, or that line whitelisted. Flag for reviewer.
|
||||
@@ -0,0 +1,131 @@
|
||||
# SDD Proposal: Prometheus Direct Charting (drop Grafana middleman)
|
||||
|
||||
**Change:** `prometheus-direct-charting`
|
||||
**Phase:** proposal
|
||||
**Date:** 2026-07-08
|
||||
|
||||
## 1. Problem / Why Now
|
||||
|
||||
Manage already does in-app charting — but indirectly. Today the only chart path is:
|
||||
|
||||
```
|
||||
GrafanaChartWidget (recharts) ← GrafanaWidgetSource._fetch_chart
|
||||
← POST {grafana}/api/ds/query (datasource.type = "prometheus", hardcoded)
|
||||
← Grafana proxies to Prometheus
|
||||
```
|
||||
|
||||
Two problems with this:
|
||||
|
||||
1. **Grafana is a pure middleman.** The backend already hardcodes `datasource.type: "prometheus"` in every chart query. Grafana adds a hop, an API key, a different response shape (`/api/ds/query` frames), and a normalization layer — all to reach a Prometheus instance Manage could query directly via `/api/v1/query_range`. The existing `PrometheusWidgetSource` already proves Manage can talk to Prom straight; it just only does *instant* queries today.
|
||||
2. **The project's documented rules are stale.** `openspec/config.yaml` says "Do NOT re-implement charting in-app" and "No recharts/d3 is in use." Both are already false in code: `recharts ^3.9.2` is declared and imported by `GrafanaChartWidget`. The config documents a reality the code left behind.
|
||||
|
||||
Meanwhile the operator wants two more metric visualizations Manage cannot currently render: a **gauge** and a **single value / mean-over-time** widget. Both belong naturally on the `prometheus` service, which today only exposes an instant-query numeric widget.
|
||||
|
||||
The clean answer is to stop routing charts through Grafana: query Prometheus directly, reuse the rendering infrastructure already written, add the two new modes, and remove the now-redundant Grafana surface.
|
||||
|
||||
## 2. Target Users and Situations
|
||||
|
||||
- **Primary users:** Homelab operators who want metric visualizations on the Manage dashboard without bouncing to Grafana for a quick glance.
|
||||
- **Workflow moments:**
|
||||
- Glance at the dashboard: see a trend line, a gauge, or a mean value for a key PromQL query.
|
||||
- Build a custom at-a-glance panel from any PromQL expression Manage can already evaluate.
|
||||
- Decommission the Grafana hop for charts (one fewer external dependency in the chart path, one fewer API key to rotate).
|
||||
- **Urgency:** Medium. Charts already work via Grafana today; this is a simplification plus two new widget modes, not an outage fix.
|
||||
|
||||
## 3. Product Outcome
|
||||
|
||||
After this change, an authenticated user can:
|
||||
|
||||
- Place a **Prometheus Chart** widget (line chart, multi-series) backed by a direct `/api/v1/query_range` call — same look as today's Grafana chart, no Grafana required.
|
||||
- Place a **Prometheus Gauge** widget rendering an instant PromQL scalar as a gauge.
|
||||
- Place a **Prometheus Mean** widget rendering a single value aggregated over a time window (e.g. `avg_over_time(...)`, or a query_range aggregate).
|
||||
- Manage the dashboard without any Grafana service configured: the Grafana service type, link widget, chart widget, and status checks are removed.
|
||||
|
||||
## 4. Scope Boundaries and Non-Goals
|
||||
|
||||
### In scope
|
||||
|
||||
- **Direct Prometheus range query path** — backend hits `/api/v1/query_range` and returns the existing `{series:[{label,points}]}` shape so the frontend renderer is reused unchanged.
|
||||
- **Shared series normalization** — extract the metric-label → readable-label logic currently inside `GrafanaWidgetSource._fetch_chart` into a reusable helper used by both the chart path and (where relevant) the new modes.
|
||||
- **Rebrand + rebind** — `GrafanaChartWidget` → `PrometheusChartWidget`, moved from the `grafana` service to the `prometheus` service as the `chart` widget kind.
|
||||
- **New `prometheus` widget kinds:** `gauge` and `mean` (semantics confirmed in the question round).
|
||||
- **Grafana removal** — delete `integrations/grafana.py`, `GrafanaWidgetSource`, `GrafanaLinkWidget`, `get_grafana_status`, the ObservabilityPage Grafana section, both registries' `grafana` entries, nav entries, and their tests.
|
||||
- **`config.yaml` rewrite** — replace the stale thin-dashboard / no-recharts wording with the reality: in-app charting via `recharts` is the sanctioned approach for Prometheus-backed series; Grafana is no longer referenced.
|
||||
- **Migration note** — CHANGELOG entry: existing Grafana service instances must be deleted and recreated as Prometheus services (true data migration is impossible; different URLs).
|
||||
|
||||
### Non-goals (explicitly out of scope)
|
||||
|
||||
- **A general chart-widget framework with pluggable data-source middlewares.** Chart rendering is reused; data sources are not abstracted into a swappable adapter registry. Two concrete sources (Prometheus range query, and later the service-storage harness) are wired directly where needed.
|
||||
- **Grafana datasource proxy for non-Prometheus sources** (Loki, InfluxDB, Postgres). If a real non-Prom need appears later, it is a separate change.
|
||||
- **Embedding Grafana panels as images/iframes.** Grafana is removed, not embedded.
|
||||
- **Editing PromQL in a rich editor** (autocomplete, metric explorer). Plain text input only, matching today's `prometheus_metric` widget.
|
||||
- **qBittorrent widgets and the service-storage harness.** Those are a separate change (`service-storage-harness`); only the qBit speed widget *depends on* this change's chart capability.
|
||||
- **Touching `prometheus_metric` (instant numeric widget).** It stays as-is; `mean` and `chart` are sibling kinds, not modes bolted onto it.
|
||||
- **Re-indexing or migrating existing widget instance rows automatically.** Existing `grafana` `chart` widgets are orphaned by the removal and must be recreated as `prometheus` `chart` widgets by the operator (documented in CHANGELOG).
|
||||
|
||||
## 5. High-Level Approach
|
||||
|
||||
### 5.1 Backend
|
||||
|
||||
1. **Prometheus range query** — extend `PrometheusWidgetSource` (or add a sibling code path) to handle `widget_kind == "chart"`:
|
||||
- `GET {base_url}/api/v1/query_range?query=...&start=...&end=...&step=...`
|
||||
- Parse Prom `{value:[ts, val]}` matrix into the existing `{series:[{label, points}]}` shape.
|
||||
2. **Shared normalization** — move the "metric labels → readable label" logic out of `GrafanaWidgetSource._fetch_chart` into `widgets/series.py` (or similar), so the Prom path and any future consumer reuse it.
|
||||
3. **New kinds wiring** — `gauge` and `mean` resolve in `PrometheusWidgetSource.fetch`:
|
||||
- `gauge`: instant query (`/api/v1/query`), return `{value, threshold?, ...}` for a gauge renderer.
|
||||
- `mean`: range query aggregated to a single value (either PromQL `avg_over_time` via instant query, or client-side mean over a query_range window). Semantics decided in the question round.
|
||||
4. **Integration update** — `integrations/prometheus.py` declares the three widget kinds (`metric`, `chart`, `gauge`, `mean`) with config schemas (`promql`, plus range params for `chart`/`mean`).
|
||||
5. **Grafana removal** — delete `integrations/grafana.py`, drop `grafana` from `SERVICE_ADAPTERS`, `SERVICE_DEFINITIONS`, remove `_fetch_chart` and the Grafana link logic. Remove `get_grafana_status` from `routers/monitoring.py` and the Grafana branch from `ObservabilityPage`.
|
||||
6. **`config.yaml` rewrite** — replace the stale charting rules with accurate wording.
|
||||
|
||||
### 5.2 Frontend
|
||||
|
||||
1. **Rebrand** — rename `GrafanaChartWidget.tsx` → `PrometheusChartWidget.tsx`; the recharts rendering (`LineChart`/`Line`/`XAxis`/`YAxis`/`Tooltip`/`mergeSeries`/`CHART_COLORS`) stays essentially unchanged.
|
||||
2. **Registry** — in `integrations/registry.ts`, move `chart` to the `prometheus` binding and add `gauge` + `mean` bindings; delete the entire `grafana` binding.
|
||||
3. **New components:**
|
||||
- `PrometheusGaugeWidget.tsx` — recharts `<RadialBarChart>` or a small SVG gauge; instant value.
|
||||
- `PrometheusMeanWidget.tsx` — single-value display (reuses `MetricCard`-style rendering) of the windowed mean.
|
||||
4. **Nav + ObservabilityPage** — remove Grafana nav entries and the Grafana status card.
|
||||
5. **Types** — `frontend/src/types/index.ts` drops Grafana status types; no new endpoint types (data still flows through `useWidgetData`).
|
||||
|
||||
### 5.3 Type contracts
|
||||
|
||||
- Backend: update `integrations/prometheus.py` widget-kind config models; remove Grafana models.
|
||||
- Frontend: remove `GrafanaStatus` type; widget payloads stay `{series}` / `{value}` shaped.
|
||||
|
||||
## 6. Success Criteria / Acceptance Criteria
|
||||
|
||||
1. A user can configure a Prometheus service and place `chart`, `gauge`, `mean`, and `metric` widgets without any Grafana service present.
|
||||
2. The `chart` widget renders multi-series line charts from `/api/v1/query_range` with the same look as the prior Grafana-backed chart.
|
||||
3. The `gauge` widget renders an instant PromQL scalar as a gauge.
|
||||
4. The `mean` widget renders a single value aggregated over the configured window.
|
||||
5. No `grafana` references remain in `backend/src` or `frontend/src` (grep clean).
|
||||
6. `openspec/config.yaml` no longer claims "no recharts" or "do not chart in-app"; its wording matches the implementation.
|
||||
7. Existing `pytest`, `npm run build`, and `npm run lint` stay green; Grafana tests are removed, Prom chart/gauge/mean tests are added.
|
||||
8. CHANGELOG documents the migration (delete Grafana services, recreate as Prometheus).
|
||||
|
||||
## 7. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| **Response-shape regression.** Prom `/api/v1/query_range` matrix differs from Grafana `/api/ds/query` frames. | Extract normalization into a shared helper; add a backend test that feeds a sample Prom range response and asserts the `{series}` shape the frontend already consumes. |
|
||||
| **Orphaned configured widgets.** Existing `grafana/chart` widget rows break at render. | Acceptable + documented in CHANGELOG; the widget resolves to "unknown widget" gracefully (existing `WidgetInstance` error path). No silent data corruption. |
|
||||
| **Gauge rendering complexity.** recharts gauges can be fiddly. | Constrain gauge to a single scalar + optional thresholds; if recharts gauge proves heavy, fall back to a ~50-line SVG gauge (contained, no new dep). |
|
||||
| **Scope creep into a generic chart framework.** Tempting to abstract data sources. | Non-goal enforced: two direct wirings, no adapter registry. |
|
||||
| **Stale docs/tests lingering.** | "No grafana references" acceptance criterion (grep) catches leftovers. |
|
||||
| **Review budget (>400 lines).** | Slice into chained PRs (e.g. Slice 1: Prom range query + rebrand chart; Slice 2: gauge + mean; Slice 3: Grafana removal + config rewrite). Each slice leaves build/lint/test green. |
|
||||
| **DataGrid migration (config rule callout).** | Not applicable — no DataGrid work here. The key technical risk is the response-shape regression above. |
|
||||
|
||||
## 8. Open Questions (for the proposal question round)
|
||||
|
||||
- **Q1 — `mean` semantics.** Default: client-side mean over a `query_range` window (e.g. last 1h, step 30s → average all returned values). Alternative: require the user to write `avg_over_time(...)` in the PromQL and just run an instant query. Which UX do you want?
|
||||
- **Q2 — `gauge` thresholds.** Default: green/amber/red bands at user-configured thresholds (e.g. 70%/90%). Alternative: single color, no bands (simplest).
|
||||
- **Q3 — Chart time window config.** Default: expose `from`/`to`/`step` (or a simpler "window" preset like 1h/6h/24h). Confirm the granularity users can configure.
|
||||
- **Q4 — Multi-series on `mean`/`gauge`.** Default: single series only (one scalar). Confirm we do not try to render multi-series gauges.
|
||||
|
||||
## 9. Future Phases
|
||||
|
||||
1. **Service-storage harness integration** — the qBittorrent speed widget (separate `service-storage-harness` change) reuses this change's chart rendering with an in-service data source wired directly.
|
||||
2. **Rich PromQL editing** — metric explorer / autocomplete.
|
||||
3. **Non-Prom datasources** — only if a concrete need (Loki logs in-app) appears.
|
||||
4. **Threshold-based alerting from chart widgets** — surface bands/lines from Alertmanager rules.
|
||||
@@ -0,0 +1,152 @@
|
||||
# SDD Spec: Prometheus Direct Charting (drop Grafana middleman)
|
||||
|
||||
**Change:** `prometheus-direct-charting`
|
||||
**Phase:** spec
|
||||
**Date:** 2026-07-08
|
||||
|
||||
This spec defines the acceptance requirements for the change. Each requirement is testable. Requirements derived from `proposal.md` §6 (success criteria) and the resolved §8 question round.
|
||||
|
||||
## Requirement categories
|
||||
|
||||
1. Direct Prometheus range query path
|
||||
2. Prometheus chart widget (rebrand + rebind)
|
||||
3. Prometheus gauge widget
|
||||
4. Prometheus mean widget
|
||||
5. Grafana removal
|
||||
6. Configuration documentation accuracy
|
||||
7. Test and build greenness
|
||||
8. Migration guidance
|
||||
|
||||
---
|
||||
|
||||
## 1. Direct Prometheus range query path
|
||||
|
||||
### SC-101 — Prometheus range query returns the existing series shape
|
||||
|
||||
When a `prometheus` widget of kind `chart` is fetched, the backend MUST query `{base_url}/api/v1/query_range` with `query`, `start`, `end`, and `step` derived from the widget config, and return a payload of shape `{ "series": [{ "label": str, "points": [{ "t": int, "v": float|null }] }] }` — the exact shape the frontend chart renderer already consumes.
|
||||
|
||||
### SC-102 — Series label normalization is shared and Prometheus-native
|
||||
|
||||
The metric-label → readable-label normalization MUST live in a single shared helper (not duplicated in a Grafana path) and MUST produce meaningful labels for Prometheus matrix results, including deduplicating repeated labels via a `label (n)` suffix.
|
||||
|
||||
### SC-103 — Range query errors degrade gracefully
|
||||
|
||||
A Prometheus timeout, connection error, or non-2xx response MUST cause the widget data fetch to return `{ "error": str }` (not raise), so the frontend renders the standard per-widget error state and the rest of the dashboard remains functional.
|
||||
|
||||
### SC-104 — Step is derived from the window preset
|
||||
|
||||
Given a window preset (1h / 6h / 24h / 7d), the backend MUST derive a `step` that yields a reasonable number of points (target ~100–300 points). Users do not configure `step` directly.
|
||||
|
||||
## 2. Prometheus chart widget (rebrand + rebind)
|
||||
|
||||
### SC-105 — Chart widget moves from grafana to prometheus
|
||||
|
||||
A widget kind named `chart` MUST be bound to the `prometheus` service type in both the backend registry and the frontend `SERVICE_REGISTRY`. The `grafana` service type MUST NOT offer a `chart` kind.
|
||||
|
||||
### SC-106 — Chart renderer is reused unchanged
|
||||
|
||||
The recharts rendering (line chart, multi-series, axes, tooltip, `mergeSeries`, color tokens) MUST be preserved in the rebranded `PrometheusChartWidget`. The rename is structural; the rendering code is not rewritten.
|
||||
|
||||
### SC-107 — Chart supports multiple series
|
||||
|
||||
The `chart` widget MUST render all series returned by the range query, each as its own line with a distinct color. There is no single-series restriction on `chart`.
|
||||
|
||||
### SC-108 — Chart window is a preset
|
||||
|
||||
The `chart` widget config MUST expose the time window as a preset selector (`1h`, `6h`, `24h`, `7d`), not raw `from`/`to`/`step` fields. The preset is stored in widget config and resolved to `start`/`end` server-side.
|
||||
|
||||
## 3. Prometheus gauge widget
|
||||
|
||||
### SC-109 — Gauge renders an instant scalar
|
||||
|
||||
A widget kind named `gauge` MUST be bound to the `prometheus` service. Its data fetch MUST run an instant PromQL query (`/api/v1/query`) and return the scalar result for rendering as a gauge.
|
||||
|
||||
### SC-110 — Gauge supports configurable threshold bands
|
||||
|
||||
The `gauge` widget config MUST accept optional threshold values (e.g. `warn_at`, `crit_at`) and the renderer MUST display green / amber / red bands accordingly. When thresholds are omitted, the gauge renders with a single neutral color and no bands.
|
||||
|
||||
### SC-111 — Gauge is scalar-only
|
||||
|
||||
The `gauge` widget MUST render exactly one scalar value. If the instant query returns multiple series, the adapter MUST return `{ "error": str }` (not silently pick one), directing the user to refine the PromQL.
|
||||
|
||||
## 4. Prometheus mean widget
|
||||
|
||||
### SC-112 — Mean computes client-side over a window
|
||||
|
||||
A widget kind named `mean` MUST be bound to the `prometheus` service. Its data fetch MUST run a range query over the configured window preset and return the arithmetic mean of all non-null point values as a single scalar.
|
||||
|
||||
### SC-113 — Mean uses plain PromQL + window preset
|
||||
|
||||
The `mean` widget config MUST accept a plain PromQL expression (no requirement to wrap in `avg_over_time`) plus a window preset. Users do not write range-vector functions.
|
||||
|
||||
### SC-114 — Mean is scalar-only
|
||||
|
||||
The `mean` widget MUST render exactly one scalar value. If the range query returns multiple series, the adapter MUST return `{ "error": str }` (not silently aggregate across series).
|
||||
|
||||
## 5. Grafana removal
|
||||
|
||||
### SC-115 — No grafana references in backend source
|
||||
|
||||
After the change, `grep -ri grafana backend/src --include='*.py'` MUST return no matches (excluding comments/changelog that are explicitly about the removal, if any are retained — but ideally zero).
|
||||
|
||||
### SC-116 — No grafana references in frontend source
|
||||
|
||||
After the change, `grep -ri grafana frontend/src` MUST return no matches, **excluding** (a) test fixtures where "Grafana" appears as a user-authored dashboard *shortcut label* unrelated to the grafana service type (e.g. `Dashboard.test.tsx`), and (b) `LinksTab.tsx` / `service-tabs/index.ts` lines that are themselves being deleted as part of SC-118. (Source finding: `ObservabilityPage.tsx` was refactored into `service-tabs/`.)
|
||||
|
||||
### SC-117 — Grafana service type is gone from registries
|
||||
|
||||
Neither the backend `SERVICE_DEFINITIONS` / `SERVICE_ADAPTERS` nor the frontend `SERVICE_REGISTRY` / `BUILTIN_WIDGETS` MUST contain a `grafana` entry. The `integrations/grafana.py` file MUST be deleted.
|
||||
|
||||
### SC-118 — Grafana status checks and UI sections are removed
|
||||
|
||||
The `get_grafana_status` endpoint and its frontend hook (`useGrafanaStatus`) MUST be removed. The UI surface previously in `ObservabilityPage.tsx` has been refactored into a per-service-type `service-tabs/` architecture; the Grafana removal targets are therefore `service-tabs/LinksTab.tsx` + its test, the `grafana` case in `service-tabs/index.ts`, the grafana entry in `integrations/navEntries.ts`, the `grafana` member of `Dashboard.tsx`'s `OBSERVABILITY_TYPES` set, and any Grafana empty-state copy in `ServicesPage.tsx`. The literal `ObservabilityPage.tsx` no longer exists; SC-118's *intent* (no Grafana UI surface) is what is verified.
|
||||
|
||||
### SC-119 — Grafana widget instances degrade gracefully
|
||||
|
||||
An existing persisted widget row referencing a `grafana` service MUST NOT crash the dashboard. It resolves to the existing "unknown widget" error state and surfaces a clear message; the operator can then delete it.
|
||||
|
||||
### SC-120 — Grafana tests are removed
|
||||
|
||||
All Grafana-specific tests (backend and frontend) MUST be deleted; no test references grafana.
|
||||
|
||||
## 6. Configuration documentation accuracy
|
||||
|
||||
### SC-121 — config.yaml matches implementation
|
||||
|
||||
`openspec/config.yaml` MUST NOT contain the stale claims "Do NOT re-implement charting in-app" or "No recharts/d3 is in use." It MUST reflect that in-app charting via `recharts` is the sanctioned approach for Prometheus-backed series, and MUST NOT reference Grafana as a chart path.
|
||||
|
||||
### SC-122 — CHANGELOG documents the migration
|
||||
|
||||
`CHANGELOG.md` MUST include an entry instructing operators to delete existing Grafana service instances and recreate them as Prometheus services, noting that configured `grafana/chart` widgets must be recreated as `prometheus/chart` widgets.
|
||||
|
||||
## 7. Test and build greenness
|
||||
|
||||
### SC-123 — Backend tests pass
|
||||
|
||||
`pytest` run from `backend/` MUST pass, including new tests covering: Prom range query → `{series}` normalization, gauge scalar-only enforcement, mean client-side aggregation, and the shared label helper.
|
||||
|
||||
### SC-124 — Frontend typechecks, builds, and lints
|
||||
|
||||
`npm run build` (which runs `tsc -b` + `vite build`) and `npm run lint` from `frontend/` MUST pass.
|
||||
|
||||
### SC-125 — New widget kinds have tests
|
||||
|
||||
`PrometheusChartWidget`, `PrometheusGaugeWidget`, and `PrometheusMeanWidget` MUST each have a frontend test covering at least: loading state, error state, and a rendered data case.
|
||||
|
||||
## 8. Migration guidance
|
||||
|
||||
### SC-126 — No silent data migration
|
||||
|
||||
The change MUST NOT attempt to auto-migrate existing `grafana` service rows into `prometheus` rows (URLs differ; true migration is impossible). Migration is operator-driven per the CHANGELOG note.
|
||||
|
||||
### SC-127 — Non-blocking on the service-storage-harness change
|
||||
|
||||
This change MUST NOT depend on the `service-storage-harness` change. It is independently buildable, testable, and deployable. (The reverse dependency holds: the qBit speed widget depends on this change's chart capability.)
|
||||
|
||||
---
|
||||
|
||||
## Notes for downstream phases
|
||||
|
||||
- **Design (next phase)** should specify: the exact `step`-derivation function for window presets (SC-104), the shared normalization helper's location and signature (SC-102), and whether the gauge renderer uses recharts `RadialBarChart` or a contained SVG (SC-110).
|
||||
- **Tasks** should slice into chained PRs ≤400 lines per `config.yaml` rules: e.g. (1) Prom range path + chart rebrand, (2) gauge + mean, (3) Grafana removal + config rewrite + CHANGELOG.
|
||||
- The **review-budget guard** applies: if total changed lines exceed ~400, the chained-PR strategy from the `tasks` phase is mandatory.
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# Prometheus Charting — Delta (`prometheus-direct-charting`)
|
||||
|
||||
> Change: `prometheus-direct-charting` · Domain: `prometheus-charting` · Phase: **spec** (reconciled during `sdd-sync`).
|
||||
> Distilled verbatim from the verified flat `spec.md` (27 requirements, SC-101 … SC-127) of change
|
||||
> `prometheus-direct-charting`, cross-referenced against `design.md` and `verify-report.md`. Captures
|
||||
> the **durable, post-change end-state contracts** for direct Prometheus-backed metric visualization
|
||||
> and the Grafana removal that established the new model.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
> The canonical `openspec/specs/prometheus-charting/spec.md` did not exist before this change. All
|
||||
> requirements below are therefore **ADDED** to a new `prometheus-charting` domain; `sdd-sync` copies
|
||||
> them into the canonical spec (native helper rule: when the canonical spec does not exist, the
|
||||
> change spec becomes the new canonical spec).
|
||||
>
|
||||
> Requirement IDs (SC-101 … SC-127) and body text are preserved **exactly** from the verified flat
|
||||
> `spec.md`, including the patched SC-116 / SC-118 wording. Requirements are grouped logically and
|
||||
> listed in the following group order:
|
||||
>
|
||||
> - **Direct Prometheus range query path** — SC-101 … SC-104
|
||||
> - **Prometheus chart widget (rebrand + rebind)** — SC-105 … SC-108
|
||||
> - **Prometheus gauge widget** — SC-109 … SC-111
|
||||
> - **Prometheus mean widget** — SC-112 … SC-114
|
||||
> - **Grafana removal** — SC-115 … SC-120
|
||||
> - **Configuration documentation accuracy** — SC-121 … SC-122
|
||||
> - **Test and build greenness** — SC-123 … SC-125
|
||||
> - **Migration guidance** — SC-126 … SC-127
|
||||
|
||||
### Requirement: SC-101 — Prometheus range query returns the existing series shape
|
||||
|
||||
When a `prometheus` widget of kind `chart` is fetched, the backend MUST query `{base_url}/api/v1/query_range` with `query`, `start`, `end`, and `step` derived from the widget config, and return a payload of shape `{ "series": [{ "label": str, "points": [{ "t": int, "v": float|null }] }] }` — the exact shape the frontend chart renderer already consumes.
|
||||
|
||||
### Requirement: SC-102 — Series label normalization is shared and Prometheus-native
|
||||
|
||||
The metric-label → readable-label normalization MUST live in a single shared helper (not duplicated in a Grafana path) and MUST produce meaningful labels for Prometheus matrix results, including deduplicating repeated labels via a `label (n)` suffix.
|
||||
|
||||
### Requirement: SC-103 — Range query errors degrade gracefully
|
||||
|
||||
A Prometheus timeout, connection error, or non-2xx response MUST cause the widget data fetch to return `{ "error": str }` (not raise), so the frontend renders the standard per-widget error state and the rest of the dashboard remains functional.
|
||||
|
||||
### Requirement: SC-104 — Step is derived from the window preset
|
||||
|
||||
Given a window preset (1h / 6h / 24h / 7d), the backend MUST derive a `step` that yields a reasonable number of points (target ~100–300 points). Users do not configure `step` directly.
|
||||
|
||||
### Requirement: SC-105 — Chart widget moves from grafana to prometheus
|
||||
|
||||
A widget kind named `chart` MUST be bound to the `prometheus` service type in both the backend registry and the frontend `SERVICE_REGISTRY`. The `grafana` service type MUST NOT offer a `chart` kind.
|
||||
|
||||
### Requirement: SC-106 — Chart renderer is reused unchanged
|
||||
|
||||
The recharts rendering (line chart, multi-series, axes, tooltip, `mergeSeries`, color tokens) MUST be preserved in the rebranded `PrometheusChartWidget`. The rename is structural; the rendering code is not rewritten.
|
||||
|
||||
### Requirement: SC-107 — Chart supports multiple series
|
||||
|
||||
The `chart` widget MUST render all series returned by the range query, each as its own line with a distinct color. There is no single-series restriction on `chart`.
|
||||
|
||||
### Requirement: SC-108 — Chart window is a preset
|
||||
|
||||
The `chart` widget config MUST expose the time window as a preset selector (`1h`, `6h`, `24h`, `7d`), not raw `from`/`to`/`step` fields. The preset is stored in widget config and resolved to `start`/`end` server-side.
|
||||
|
||||
### Requirement: SC-109 — Gauge renders an instant scalar
|
||||
|
||||
A widget kind named `gauge` MUST be bound to the `prometheus` service. Its data fetch MUST run an instant PromQL query (`/api/v1/query`) and return the scalar result for rendering as a gauge.
|
||||
|
||||
### Requirement: SC-110 — Gauge supports configurable threshold bands
|
||||
|
||||
The `gauge` widget config MUST accept optional threshold values (e.g. `warn_at`, `crit_at`) and the renderer MUST display green / amber / red bands accordingly. When thresholds are omitted, the gauge renders with a single neutral color and no bands.
|
||||
|
||||
### Requirement: SC-111 — Gauge is scalar-only
|
||||
|
||||
The `gauge` widget MUST render exactly one scalar value. If the instant query returns multiple series, the adapter MUST return `{ "error": str }` (not silently pick one), directing the user to refine the PromQL.
|
||||
|
||||
### Requirement: SC-112 — Mean computes client-side over a window
|
||||
|
||||
A widget kind named `mean` MUST be bound to the `prometheus` service. Its data fetch MUST run a range query over the configured window preset and return the arithmetic mean of all non-null point values as a single scalar.
|
||||
|
||||
### Requirement: SC-113 — Mean uses plain PromQL + window preset
|
||||
|
||||
The `mean` widget config MUST accept a plain PromQL expression (no requirement to wrap in `avg_over_time`) plus a window preset. Users do not write range-vector functions.
|
||||
|
||||
### Requirement: SC-114 — Mean is scalar-only
|
||||
|
||||
The `mean` widget MUST render exactly one scalar value. If the range query returns multiple series, the adapter MUST return `{ "error": str }` (not silently aggregate across series).
|
||||
|
||||
### Requirement: SC-115 — No grafana references in backend source
|
||||
|
||||
After the change, `grep -ri grafana backend/src --include='*.py'` MUST return no matches (excluding comments/changelog that are explicitly about the removal, if any are retained — but ideally zero).
|
||||
|
||||
### Requirement: SC-116 — No grafana references in frontend source
|
||||
|
||||
After the change, `grep -ri grafana frontend/src` MUST return no matches, **excluding** (a) test fixtures where "Grafana" appears as a user-authored dashboard *shortcut label* unrelated to the grafana service type (e.g. `Dashboard.test.tsx`), and (b) `LinksTab.tsx` / `service-tabs/index.ts` lines that are themselves being deleted as part of SC-118. (Source finding: `ObservabilityPage.tsx` was refactored into `service-tabs/`.)
|
||||
|
||||
### Requirement: SC-117 — Grafana service type is gone from registries
|
||||
|
||||
Neither the backend `SERVICE_DEFINITIONS` / `SERVICE_ADAPTERS` nor the frontend `SERVICE_REGISTRY` / `BUILTIN_WIDGETS` MUST contain a `grafana` entry. The `integrations/grafana.py` file MUST be deleted.
|
||||
|
||||
### Requirement: SC-118 — Grafana status checks and UI sections are removed
|
||||
|
||||
The `get_grafana_status` endpoint and its frontend hook (`useGrafanaStatus`) MUST be removed. The UI surface previously in `ObservabilityPage.tsx` has been refactored into a per-service-type `service-tabs/` architecture; the Grafana removal targets are therefore `service-tabs/LinksTab.tsx` + its test, the `grafana` case in `service-tabs/index.ts`, the grafana entry in `integrations/navEntries.ts`, the `grafana` member of `Dashboard.tsx`'s `OBSERVABILITY_TYPES` set, and any Grafana empty-state copy in `ServicesPage.tsx`. The literal `ObservabilityPage.tsx` no longer exists; SC-118's *intent* (no Grafana UI surface) is what is verified.
|
||||
|
||||
### Requirement: SC-119 — Grafana widget instances degrade gracefully
|
||||
|
||||
An existing persisted widget row referencing a `grafana` service MUST NOT crash the dashboard. It resolves to the existing "unknown widget" error state and surfaces a clear message; the operator can then delete it.
|
||||
|
||||
### Requirement: SC-120 — Grafana tests are removed
|
||||
|
||||
All Grafana-specific tests (backend and frontend) MUST be deleted; no test references grafana.
|
||||
|
||||
### Requirement: SC-121 — config.yaml matches implementation
|
||||
|
||||
`openspec/config.yaml` MUST NOT contain the stale claims "Do NOT re-implement charting in-app" or "No recharts/d3 is in use." It MUST reflect that in-app charting via `recharts` is the sanctioned approach for Prometheus-backed series, and MUST NOT reference Grafana as a chart path.
|
||||
|
||||
### Requirement: SC-122 — CHANGELOG documents the migration
|
||||
|
||||
`CHANGELOG.md` MUST include an entry instructing operators to delete existing Grafana service instances and recreate them as Prometheus services, noting that configured `grafana/chart` widgets must be recreated as `prometheus/chart` widgets.
|
||||
|
||||
### Requirement: SC-123 — Backend tests pass
|
||||
|
||||
`pytest` run from `backend/` MUST pass, including new tests covering: Prom range query → `{series}` normalization, gauge scalar-only enforcement, mean client-side aggregation, and the shared label helper.
|
||||
|
||||
### Requirement: SC-124 — Frontend typechecks, builds, and lints
|
||||
|
||||
`npm run build` (which runs `tsc -b` + `vite build`) and `npm run lint` from `frontend/` MUST pass.
|
||||
|
||||
### Requirement: SC-125 — New widget kinds have tests
|
||||
|
||||
`PrometheusChartWidget`, `PrometheusGaugeWidget`, and `PrometheusMeanWidget` MUST each have a frontend test covering at least: loading state, error state, and a rendered data case.
|
||||
|
||||
### Requirement: SC-126 — No silent data migration
|
||||
|
||||
The change MUST NOT attempt to auto-migrate existing `grafana` service rows into `prometheus` rows (URLs differ; true migration is impossible). Migration is operator-driven per the CHANGELOG note.
|
||||
|
||||
### Requirement: SC-127 — Non-blocking on the service-storage-harness change
|
||||
|
||||
This change MUST NOT depend on the `service-storage-harness` change. It is independently buildable, testable, and deployable. (The reverse dependency holds: the qBit speed widget depends on this change's chart capability.)
|
||||
@@ -0,0 +1,168 @@
|
||||
# Sync Report — `prometheus-direct-charting`
|
||||
|
||||
> Phase: **sync** · Change: `prometheus-direct-charting` · Repo: `/home/user/manage`
|
||||
> Mode: file-backed (`artifactStore: openspec`). No source-code edits; only OpenSpec artifacts were
|
||||
> written. Not committed (parent owns the commit). The change folder was **not** moved (that is
|
||||
> `sdd-archive`'s job).
|
||||
|
||||
**Status: SYNCED.** A new canonical domain `openspec/specs/prometheus-charting/spec.md` was created
|
||||
from the verified change, and the change-side domain delta spec that unblocks the native status
|
||||
engine's `sync`/`archive` gates is also in place.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
The `prometheus-direct-charting` change shipped a **complete but flat** `openspec/changes/prometheus-direct-charting/spec.md`
|
||||
(27 requirements, SC-101 … SC-127) with **no** per-domain delta spec under
|
||||
`openspec/changes/prometheus-direct-charting/specs/<domain>/`. `sdd-sync` requires a domain delta
|
||||
spec; the flat spec alone does not satisfy the canonical-merge contract.
|
||||
|
||||
Verify already returned **PASS** (verdict in `verify-report.md`; all four gates green — backend
|
||||
`pytest` 293 passed, `ruff` clean, frontend `npm run build` exit 0, `npm run lint` 0 errors).
|
||||
Functional coverage was 26/27 fully PASS, with SC-125 PARTIAL on test coverage only (loading-state
|
||||
test gap) — a non-blocking coverage finding, not a functional defect; the task framing treats it as
|
||||
PARTIAL→PASS after coverage close. The verify report's single CRITICAL was an **archive** blocker
|
||||
(19 unchecked task checkboxes + missing `apply-progress.md`); the `apply-progress.md` artifact now
|
||||
exists and that condition does **not** block `sdd-sync` of the green code.
|
||||
|
||||
This sync **reconciles** the flat-spec-vs-domain-spec gap:
|
||||
|
||||
1. Authored the missing **change-side domain delta spec** —
|
||||
`openspec/changes/prometheus-direct-charting/specs/prometheus-charting/spec.md` — using a clean
|
||||
`## ADDED Requirements` structure that preserves the exact requirement IDs (SC-101 … SC-127) and
|
||||
text (including the patched SC-116 / SC-118 wording) from the verified flat `spec.md`. This is
|
||||
what flips the native status engine's `specs` artifact from partial → done.
|
||||
2. **Synced** the end-state into the **canonical store** —
|
||||
`openspec/specs/prometheus-charting/spec.md` — the actual sync target. Because the canonical
|
||||
`prometheus-charting` domain did not previously exist, the native helper rule applies: *when the
|
||||
canonical spec does not exist, the change spec becomes the new canonical spec.* The two files
|
||||
therefore carry identical requirement bodies (delta under `## ADDED Requirements`; canonical
|
||||
under `## Requirements`), verified byte-identical for the requirement region.
|
||||
|
||||
Domain name **`prometheus-charting`** was chosen (per the dispatch brief) because it covers the
|
||||
full new model: direct Prometheus range-query charting, the gauge and mean modes, and the Grafana
|
||||
removal that established the new direct-query model. It is distinct from the existing canonical
|
||||
`web-ui` domain (MUI→shadcn migration — a different concern), which was **not touched**.
|
||||
|
||||
## 2. Structured status & actionContext findings
|
||||
|
||||
The native `gentle-pi.sdd-status` passed by the parent reports `changeName: null` with
|
||||
`blockedReasons: ["Change selection is ambiguous: mobile-responsive-parity, prometheus-direct-charting,
|
||||
prometheus-direct-charting, service-storage-harness, services-as-hub-ia."]` because the engine
|
||||
auto-detected four active changes. This sync task was **explicitly assigned**
|
||||
`prometheus-direct-charting`; the ambiguity is a parent-resolution artifact and does not block this
|
||||
phase (`isNonAuthoritative: false`).
|
||||
|
||||
- `artifactStore: openspec`; change root `openspec/changes/prometheus-direct-charting/`.
|
||||
- Artifacts present: `proposal.md`, `spec.md`, `design.md`, `tasks.md`, `verify-report.md`,
|
||||
`apply-progress.md`.
|
||||
- `verify: PASS` (verify-report verdict; gates green at `67ca0fc`).
|
||||
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/manage`,
|
||||
`allowedEditRoots: ["/home/user/manage"]`, `warnings: []`. All three files written are inside the
|
||||
authoritative workspace / allowed edit roots. ✓
|
||||
- `relationships.sameDomainActiveChanges: []`, `collisions: []` — **no active same-domain
|
||||
collisions**, so no archive/sync ordering decision was required.
|
||||
- The new `prometheus-charting` domain is distinct from the existing `web-ui` canonical domain;
|
||||
`openspec/specs/web-ui/spec.md` was left untouched.
|
||||
|
||||
**Post-sync structural change:** `openspec/changes/prometheus-direct-charting/specs/prometheus-charting/spec.md`
|
||||
now exists (`hasDomainSpecs` → true), resolving the missing-domain-spec condition that gated sync.
|
||||
The flat `spec.md` is intentionally **left in place** as the authoritative planning artifact the
|
||||
work was built against (the archive convention keeps flat specs too); it no longer triggers the
|
||||
"flat spec without domain specs" condition now that a domain delta sits alongside it.
|
||||
|
||||
## 3. Domains synced & canonical files updated
|
||||
|
||||
| Domain | Change-side delta (source) | Canonical (sync target) | Action |
|
||||
|---|---|---|---|
|
||||
| `prometheus-charting` | `openspec/changes/prometheus-direct-charting/specs/prometheus-charting/spec.md` | `openspec/specs/prometheus-charting/spec.md` | **NEW domain** — `## ADDED Requirements` copied into canonical as a new spec |
|
||||
|
||||
- **Canonical file created:** `openspec/specs/prometheus-charting/spec.md` (27 requirements).
|
||||
- **Change-side delta created:** `openspec/changes/prometheus-direct-charting/specs/prometheus-charting/spec.md`
|
||||
(27 requirements, all `## ADDED Requirements`).
|
||||
|
||||
## 4. Requirement delta (ADDED / MODIFIED / REMOVED)
|
||||
|
||||
- **ADDED (27)** — all to the new `prometheus-charting` domain (canonical did not exist pre-change).
|
||||
IDs and text preserved verbatim from the verified flat `spec.md`. Grouped logically:
|
||||
- *Direct Prometheus range query path* — SC-101, SC-102, SC-103, SC-104
|
||||
- *Prometheus chart widget (rebrand + rebind)* — SC-105, SC-106, SC-107, SC-108
|
||||
- *Prometheus gauge widget* — SC-109, SC-110, SC-111
|
||||
- *Prometheus mean widget* — SC-112, SC-113, SC-114
|
||||
- *Grafana removal* — SC-115, SC-116, SC-117, SC-118, SC-119, SC-120
|
||||
- *Configuration documentation accuracy* — SC-121, SC-122
|
||||
- *Test and build greenness* — SC-123, SC-124, SC-125
|
||||
- *Migration guidance* — SC-126, SC-127
|
||||
- **MODIFIED (0)** — none (new domain; no pre-existing canonical requirements to replace).
|
||||
- **REMOVED (0)** — none.
|
||||
- **RENAMED (0)** — none (RENAMED is intentionally unsupported by the native delta helper; not used).
|
||||
|
||||
## 5. Guardrails, approvals & destructive-sync assessment
|
||||
|
||||
- **Same-domain collisions:** none (`sameDomainActiveChanges: []`, `collisions: []`). The new
|
||||
`prometheus-charting` domain does not overlap the existing `web-ui` canonical domain. No ordering
|
||||
decision was needed.
|
||||
- **Destructive sync:** **not applicable.** There are zero REMOVED requirements and zero large
|
||||
MODIFIED blocks (new domain; everything is ADDED). No destructive-sync parent approval was
|
||||
required for this sync beyond the explicit reconciliation instruction in the task.
|
||||
- **Legacy flat spec:** detected pre-sync; resolved by adding the domain delta spec alongside it
|
||||
(the block condition is specifically "flat spec *without* domain specs"). The flat spec was left
|
||||
in place as a planning artifact.
|
||||
- **`web-ui` canonical isolation:** the existing `openspec/specs/web-ui/spec.md` (the MUI→shadcn
|
||||
rework) was **not modified** — verified untouched by `git status` (mtime `2026-06-17T19:11`,
|
||||
not in the modified set). The two domains are independent.
|
||||
|
||||
## 6. Validation / checks performed (file-backed, read-only)
|
||||
|
||||
Run from `/home/user/manage` (no source edits, no test re-runs — those are owned by verify and were
|
||||
already green at `67ca0fc`):
|
||||
|
||||
| Check | Command | Result |
|
||||
|---|---|---|
|
||||
| Canonical store populated | `ls openspec/specs/prometheus-charting/spec.md` | present ✓ |
|
||||
| Change-side domain spec present | `ls openspec/changes/prometheus-direct-charting/specs/prometheus-charting/spec.md` | present ✓ |
|
||||
| Requirement-ID parity (flat ↔ delta ↔ canonical) | `grep -oE 'SC-[0-9]+'` all three files, `sort -u` | **27 == 27 == 27**, identical IDs SC-101…SC-127 ✓ |
|
||||
| Body-text parity (delta ↔ canonical) | `diff` of the `^### Requirement:` region of both files | **identical** ✓ |
|
||||
| Patched SC-116 wording present | `grep "service-tabs/"` both delta + canonical | present in both ✓ |
|
||||
| Patched SC-118 wording present | `grep "service-tabs/LinksTab.tsx"` both delta + canonical | present in both ✓ |
|
||||
| Delta is pure ADDED | count `## ADDED/MODIFIED/REMOVED/RENAMED Requirements` | ADDED=1, MODIFIED=0, REMOVED=0, RENAMED=0 ✓ (no destructive sync) |
|
||||
| `web-ui` canonical untouched | `git status --porcelain openspec/specs/web-ui/spec.md` | empty (not modified) ✓ |
|
||||
| No edits outside openspec | `git status --porcelain` (filtered) | only `openspec/specs/prometheus-charting/`, `openspec/changes/prometheus-direct-charting/specs/`, and this report added; pre-existing dirty/untracked items unrelated to this sync unchanged ✓ |
|
||||
| Markdown validity | write-time lint | all three files "Markdown clean" ✓ |
|
||||
|
||||
## 7. Carry-over items for the archive summary
|
||||
|
||||
These verify-phase findings are non-blocking for sync and should land in the archive summary:
|
||||
|
||||
1. **[INFO] SC-125 was PARTIAL in verify** (no explicit `isLoading:true` loading-state test in any of
|
||||
the three new widget test files — coverage gap, not a functional defect). The task framing treats
|
||||
this as PARTIAL→PASS after coverage close; if a loading-state case per widget has not been added,
|
||||
`sdd-archive` may want to confirm or note it.
|
||||
2. **[CRITICAL-process, archive-only] Unchecked task checkboxes.** At verify time, 19 implementation
|
||||
/ verification task checkboxes (Slice 3 §3.1–3.14 and Integration §4.1–4.5) were unchecked and
|
||||
`apply-progress.md` was missing. `apply-progress.md` now exists (created after the verify pass);
|
||||
`sdd-archive` should re-scan the native status engine to confirm `tasks: done` / `applyProgress:
|
||||
present` before moving the change to archive, and tick any remaining unchecked boxes if needed.
|
||||
3. **[INFO] Stale generated `.pi-map.md`** files still reference Grafana / `ObservabilityPage.tsx`
|
||||
(generated artifacts, not deliverable source; ignored by SC-115/116). Regenerate via
|
||||
`project_map_patch` / `project_map_validate`.
|
||||
4. **[INFO] Slice-2 review-budget variance** (~707 insertions vs ~310–400 forecast) — additive
|
||||
feature code + tests; boundary is the gauge+mean feature, not scope creep. Non-blocking; record
|
||||
in the archive summary.
|
||||
|
||||
## 8. Next recommended phase
|
||||
|
||||
→ **`sdd-archive`** (clean). Confirm the native status re-scan reports `specs: done` / `sync: ready`
|
||||
/ `archive: ready`, then move the change to
|
||||
`openspec/changes/archive/YYYY-MM-DD-prometheus-direct-charting`, carrying over the items in §7 into
|
||||
the archive summary. Do **not** commit or push — the parent owns the commit with explicit paths.
|
||||
|
||||
---
|
||||
|
||||
### Appendix — Files written by this sync (OpenSpec only; no source code)
|
||||
|
||||
- `openspec/changes/prometheus-direct-charting/specs/prometheus-charting/spec.md` — **change-side
|
||||
domain delta (`## ADDED Requirements`), 27 requirements SC-101…SC-127.**
|
||||
- `openspec/specs/prometheus-charting/spec.md` — **canonical spec (new domain), 27 requirements.**
|
||||
- `openspec/changes/prometheus-direct-charting/sync-report.md` — this report.
|
||||
@@ -0,0 +1,318 @@
|
||||
# SDD Tasks: Prometheus Direct Charting (drop Grafana middleman)
|
||||
|
||||
**Change:** `prometheus-direct-charting`
|
||||
**Phase:** tasks
|
||||
**Date:** 2026-07-08
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | ~850–1,110 (sum of three implementation slices) |
|
||||
| 400-line budget risk | Medium |
|
||||
| Chained PRs recommended | Yes |
|
||||
| Suggested split | PR 1: Prom range path + chart rebrand → PR 2: gauge + mean widgets → PR 3: Grafana removal + config rewrite |
|
||||
| Delivery strategy | auto-chain |
|
||||
| Chain strategy | stacked-to-main |
|
||||
|
||||
```text
|
||||
Decision needed before apply: No
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: stacked-to-main
|
||||
400-line budget risk: Medium
|
||||
```
|
||||
|
||||
> Each slice individually lands under the 400-line review budget. Slices are ordered S1 → S2 → S3; S1 and S2 are independently shippable, S3 must follow S1 (it removes the grafana chart binding S1 replaces). Per `openspec/config.yaml` rules, each slice leaves `npm run build` (tsc -b + vite build), `npm run lint`, and backend `pytest` green.
|
||||
|
||||
---
|
||||
|
||||
## Slice ordering rationale (critical)
|
||||
|
||||
**Slice 1 ADDS the prometheus chart capability without removing anything from grafana.** After S1:
|
||||
|
||||
- `prometheus` binding has `metric` + `chart`; `grafana` binding has only `link` (chart kind moved away).
|
||||
- `GrafanaWidgetSource` still exists (serves the `link` kind; its `_fetch_chart` is now dead but harmless).
|
||||
- `GrafanaLinkWidget` and the `grafana` service type are untouched.
|
||||
- A `prometheus/chart` widget renders from a direct `/api/v1/query_range` call.
|
||||
|
||||
This ordering ensures the chart capability is proven against Prometheus before the grafana surface is removed in S3, so the two risks (new query path + grafana removal) never compound in a single slice.
|
||||
|
||||
---
|
||||
|
||||
## Slice 1: Prometheus range query path + shared helper + chart rebrand
|
||||
|
||||
**Goal:** Make a `prometheus/chart` widget render multi-series line charts from a direct `/api/v1/query_range` call, reusing the existing recharts renderer. Extract the series-normalization and step-derivation helpers into a testable module. Move the `chart` kind from `grafana` to `prometheus` in both registries. Do NOT yet remove the grafana service type, `GrafanaWidgetSource`, or `GrafanaLinkWidget`.
|
||||
|
||||
**Satisfies:** SC-101, SC-102, SC-103, SC-104, SC-105, SC-106, SC-107, SC-108.
|
||||
|
||||
- [x] **1.1 Create shared Prometheus range helpers module**
|
||||
- Files: `backend/src/media_library_viewer_api/widgets/prometheus_range.py` (new)
|
||||
- Lines: ~60
|
||||
- Dependencies: none
|
||||
- Details: Implement `WINDOW_PRESETS = {"1h": 3600, "6h": 21600, "24h": 86400, "7d": 604800}`, `step_for_window(window_seconds, target_points=200) -> int` returning `max(15, round(window_seconds / target_points))`, and `normalize_prometheus_matrix(result: list[dict]) -> list[dict]` that converts a Prometheus `/api/v1/query_range` `data.result` matrix into `{label, points:[{t:int, v:float|None}]}` series. Label rule: drop `__name__` from metric labels; join remaining as `k=v`; fall back to `"value"`; dedup collisions with `(n)` suffix. Null handling: `"NaN"`, `"+Inf"`, `"-Inf"`, `None` → `v: None`.
|
||||
|
||||
- [x] **1.2 Add backend unit tests for range helpers**
|
||||
- Files: `backend/tests/test_prometheus_range.py` (new)
|
||||
- Lines: ~70
|
||||
- Dependencies: 1.1
|
||||
- Details: Test `step_for_window` for all four presets asserts result yields 100–300 points. Test `normalize_prometheus_matrix`: feed a two-entry sample matrix (one with `__name__`, one colliding label) → assert `{series}` shape, label dedup `(1)` suffix, null handling for `"NaN"` string.
|
||||
|
||||
- [x] **1.3 Add `_fetch_chart` + shared range-query plumbing to `PrometheusWidgetSource`**
|
||||
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
|
||||
- Lines: ~55
|
||||
- Dependencies: 1.1
|
||||
- Details: Import `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix` from `prometheus_range`. Add a private `_range_query(base_url, timeout, promql, window) -> dict` returning `{"matrix": result}` or `{"error": ...}` (shared by chart now and mean in S2). Add `_fetch_chart(self, base_url, timeout, config)` calling `_range_query` and returning `{"series": normalize_prometheus_matrix(matrix)}`. Dispatch `widget_kind == "chart"` in `.fetch()`. Existing `metric` path stays byte-for-byte unchanged. Errors (timeout, `RequestException`) → `{"error": str}`, never raise.
|
||||
|
||||
- [x] **1.4 Declare `chart` widget kind in Prometheus integration**
|
||||
- Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify)
|
||||
- Lines: ~15
|
||||
- Dependencies: 1.3
|
||||
- Details: Add `PrometheusChartWidgetConfig(WidgetConfigBase)` with `promql: str` and `window: str = "1h"`. Add a `widget_kind(...)` entry for `chart` (refresh 60s, default config `{"promql": "", "window": "1h"}`). Leave `metric` kind untouched.
|
||||
|
||||
- [x] **1.5 Rename `GrafanaChartWidget` → `PrometheusChartWidget`**
|
||||
- Files: `frontend/src/widgets/GrafanaChartWidget.tsx` → `frontend/src/widgets/PrometheusChartWidget.tsx` (git mv)
|
||||
- Lines: ~5 changed (rename export, fix empty-state copy)
|
||||
- Dependencies: none
|
||||
- Details: `git mv` to preserve history. Rename exported function `GrafanaChartWidget` → `PrometheusChartWidget`. The recharts body (`LineChart`, `Line`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `ResponsiveContainer`, `mergeSeries`, `CHART_COLORS`, `formatTime`) is preserved unchanged (SC-106). Fix empty-state copy: "Check your query and datasource_uid" → "Check your PromQL query and window."
|
||||
|
||||
- [x] **1.6 Rename chart widget test**
|
||||
- Files: `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` → `frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx` (git mv)
|
||||
- Lines: ~10 changed (import path, component name, error-message assertion)
|
||||
- Dependencies: 1.5
|
||||
- Details: `git mv`. Update import to `PrometheusChartWidget`. Update error-state assertion: the old Grafana-specific error string (`"Grafana api_key is required"`) → a Prom error string (e.g. `"promql is required"`). Keep loading + rendered-data test cases.
|
||||
|
||||
- [x] **1.7 Rebind `chart` from grafana to prometheus in frontend registry**
|
||||
- Files: `frontend/src/integrations/registry.ts` (modify)
|
||||
- Lines: ~30
|
||||
- Dependencies: 1.5
|
||||
- Details: Import `PrometheusChartWidget`. Add a `chart` entry to the `prometheus` binding's `widgets` array (kind `chart`, refresh 60s, configSchema with `promql` + `window`). Remove the `chart` entry from the `grafana` binding's `widgets` array (leave `link` intact). Do NOT delete the `grafana` binding itself.
|
||||
|
||||
- [x] **1.8 Update frontend widgets barrel export**
|
||||
- Files: `frontend/src/widgets/index.ts` (modify)
|
||||
- Lines: ~2
|
||||
- Dependencies: 1.5
|
||||
- Details: Rename the `GrafanaChartWidget` export to `PrometheusChartWidget`. Leave `GrafanaLinkWidget` export intact.
|
||||
|
||||
- [x] **1.9 Update registry tests for chart rebind**
|
||||
- Files: `frontend/src/integrations/registry.test.ts` (modify)
|
||||
- Lines: ~15
|
||||
- Dependencies: 1.7
|
||||
- Details: Assert `prometheus` binding has `metric` + `chart` kinds. Assert `grafana` binding has only `link` (no `chart`).
|
||||
|
||||
- [x] **1.10 Verify Slice 1 (build + lint + test)**
|
||||
- Run: `cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint`
|
||||
- Verify: helpers tests pass; existing widget tests pass (grafana link adapter still wired); frontend typechecks and lints; `prometheus/chart` widget resolves to `PrometheusChartWidget`.
|
||||
|
||||
**Slice 1 total:** ~260–330 changed lines.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2: Gauge + mean widgets
|
||||
|
||||
**Goal:** Add `gauge` and `mean` widget kinds to the `prometheus` service. Gauge renders an instant scalar with configurable threshold bands. Mean renders a single value computed client-side over a range-query window. Both are scalar-only.
|
||||
|
||||
**Satisfies:** SC-109, SC-110, SC-111, SC-112, SC-113, SC-114.
|
||||
|
||||
- [x] **2.1 Extract shared `_instant_query` helper + add `_fetch_gauge` to `PrometheusWidgetSource`**
|
||||
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
|
||||
- Lines: ~40
|
||||
- Dependencies: Slice 1 (1.3)
|
||||
- Details: Extract the instant-query HTTP call from the existing `metric` path into a private `_instant_query(base_url, timeout, promql) -> dict` returning `{"result": [...]}` or `{"error": ...}`. Refactor the `metric` path to use it (behavior unchanged). Add `_fetch_gauge(self, base_url, timeout, config)` using `_instant_query`: assert `len(result) == 1` (scalar-only, SC-111); parse `float(result[0]["value"][1])`; return `{"value": float, "warn_at": config.get("warn_at"), "crit_at": config.get("crit_at"), "min": config.get("min"), "max": config.get("max"), "unit": config.get("unit")}`. Dispatch `widget_kind == "gauge"` in `.fetch()`.
|
||||
|
||||
- [x] **2.2 Add `_fetch_mean` to `PrometheusWidgetSource`**
|
||||
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
|
||||
- Lines: ~30
|
||||
- Dependencies: 2.1, Slice 1 (1.3 for `_range_query`)
|
||||
- Details: Add `_fetch_mean(self, base_url, timeout, config)` using the shared `_range_query` from S1. Assert `len(result) == 1` (scalar-only, SC-114). Collect non-null numeric values from the single series; compute arithmetic mean; return `{"value": mean, "unit": config.get("unit")}`. If no numeric samples → `{"error": "..."}`. Dispatch `widget_kind == "mean"` in `.fetch()`.
|
||||
|
||||
- [x] **2.3 Declare `gauge` + `mean` widget kinds in Prometheus integration**
|
||||
- Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify)
|
||||
- Lines: ~25
|
||||
- Dependencies: 2.1, 2.2
|
||||
- Details: Add `PrometheusGaugeWidgetConfig` (`promql: str`, `warn_at: float|None`, `crit_at: float|None`, `min: float|None`, `max: float|None`, `unit: str|None`) and `PrometheusMeanWidgetConfig` (`promql: str`, `window: str = "1h"`, `unit: str|None`). Add `widget_kind(...)` entries: `gauge` (refresh 30s), `mean` (refresh 60s).
|
||||
|
||||
- [x] **2.4 Add backend tests for gauge + mean adapters**
|
||||
- Files: `backend/tests/test_widgets.py` (modify) or `backend/tests/test_prometheus_range.py` (modify)
|
||||
- Lines: ~60
|
||||
- Dependencies: 2.1, 2.2
|
||||
- Details: Mock `requests.get` for gauge: instant query returning 1 series → assert `{value, ...}` shape; returning 2 series → assert `{"error": ...}` (SC-111). Mock for mean: range query returning 1 series with known values `[1.0, 2.0, 3.0]` → assert mean `2.0`; returning 2 series → assert `{"error": ...}` (SC-114). Test timeout/RequestException → `{"error": ...}`.
|
||||
|
||||
- [x] **2.5 Create `PrometheusGaugeWidget` component**
|
||||
- Files: `frontend/src/widgets/PrometheusGaugeWidget.tsx` (new)
|
||||
- Lines: ~90
|
||||
- Dependencies: Slice 1 (1.5 for widget pattern)
|
||||
- Details: Render via recharts `RadialBarChart` (no new dep; SC-110). Threshold bands: three stacked `RadialBar` track cells (green `0→warn`, amber `warn→crit`, red `crit→max`) + a value cell. When `warn_at`/`crit_at` absent → single neutral-color track. `min`/`max` default to `0`/`max(value, 1)`. Reuse `SectionCard` + `Alert`/`Skeleton` for loading/error states. Consume `data?.data?.value`, `warn_at`, etc. off `useWidgetData`.
|
||||
|
||||
- [x] **2.6 Create `PrometheusMeanWidget` component**
|
||||
- Files: `frontend/src/widgets/PrometheusMeanWidget.tsx` (new)
|
||||
- Lines: ~50
|
||||
- Dependencies: Slice 1
|
||||
- Details: Single-value display reusing the `MetricCard` pattern (big number + optional `unit` suffix + subtext "mean over last {window}"). Loading/error/empty via `Skeleton`/`Alert`. No charting library — it's a number (SC-112).
|
||||
|
||||
- [x] **2.7 Create gauge + mean frontend tests**
|
||||
- Files: `frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx` (new), `frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx` (new)
|
||||
- Lines: ~60
|
||||
- Dependencies: 2.5, 2.6
|
||||
- Details: Each covers loading, error, and rendered-data case (SC-125). Gauge test: render with bands (`warn_at`/`crit_at` set) and without (single color). Mean test: render with `value` + `unit`.
|
||||
|
||||
- [x] **2.8 Add gauge + mean bindings to frontend registry**
|
||||
- Files: `frontend/src/integrations/registry.ts` (modify)
|
||||
- Lines: ~35
|
||||
- Dependencies: 2.5, 2.6
|
||||
- Details: Import `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Add `gauge` (refresh 30s, configSchema with `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit`) and `mean` (refresh 60s, configSchema with `promql`, `window`, `unit`) entries to the `prometheus` binding's `widgets` array alongside `metric` and `chart`.
|
||||
|
||||
- [x] **2.9 Update widgets barrel + registry tests**
|
||||
- Files: `frontend/src/widgets/index.ts` (modify), `frontend/src/integrations/registry.test.ts` (modify)
|
||||
- Lines: ~10
|
||||
- Dependencies: 2.5, 2.6, 2.8
|
||||
- Details: Export `PrometheusGaugeWidget` + `PrometheusMeanWidget`. Assert `prometheus` binding has `metric`, `chart`, `gauge`, `mean` (four kinds).
|
||||
|
||||
- [x] **2.10 Verify Slice 2 (build + lint + test)**
|
||||
- Run: `cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint`
|
||||
- Verify: gauge/mean adapter tests pass; frontend typechecks and lints; all four prometheus widget kinds resolve.
|
||||
|
||||
**Slice 2 total:** ~310–400 changed lines.
|
||||
|
||||
---
|
||||
|
||||
## Slice 3: Grafana removal + config rewrite + changelog
|
||||
|
||||
**Goal:** Remove the entire Grafana surface (service type, link widget, chart source, status checks, nav entries, UI tabs). Rewrite `config.yaml` to match reality. Add CHANGELOG migration note. Leave the app grep-clean of grafana service references.
|
||||
|
||||
**Satisfies:** SC-115, SC-116, SC-117, SC-118, SC-119, SC-120, SC-121, SC-122, SC-126.
|
||||
|
||||
> **Spec-text drift note:** SC-118 literally names `ObservabilityPage.tsx`, which was refactored into `service-tabs/`. The *intent* (no Grafana UI surface) is verified by the removals below. `Dashboard.test.tsx` contains "Grafana" as a user-authored shortcut label (unrelated to the grafana service) — SC-116 grep should not flag it; it is left intact.
|
||||
|
||||
- [x] **3.1 Delete backend Grafana integration module**
|
||||
- Files: `backend/src/media_library_viewer_api/integrations/grafana.py` (delete)
|
||||
- Lines: ~−20 (deletion)
|
||||
- Dependencies: Slice 1 (chart kind already moved to prometheus)
|
||||
- Details: Delete the file. It contains `GrafanaConfig` + `GrafanaLinkWidgetConfig`.
|
||||
|
||||
- [x] **3.2 Remove Grafana from backend integration registry**
|
||||
- Files: `backend/src/media_library_viewer_api/integrations/registry.py` (modify)
|
||||
- Lines: ~−3
|
||||
- Dependencies: 3.1
|
||||
- Details: Drop `from ...grafana import DEFINITION as GRAFANA` and the `GRAFANA.service_type: GRAFANA` entry from `SERVICE_DEFINITIONS`.
|
||||
|
||||
- [x] **3.3 Remove `GrafanaWidgetSource` + adapter registration**
|
||||
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
|
||||
- Lines: ~−100 (deletion of `GrafanaWidgetSource` class + `_fetch_chart`)
|
||||
- Dependencies: Slice 1 (normalization logic already extracted to `prometheus_range.py`)
|
||||
- Details: Delete the `GrafanaWidgetSource` class entirely (including `_fetch_chart` — its logic was extracted to `normalize_prometheus_matrix` in S1). Remove `"grafana": GrafanaWidgetSource()` from `SERVICE_ADAPTERS`.
|
||||
|
||||
- [x] **3.4 Remove `get_grafana_status` endpoint**
|
||||
- Files: `backend/src/media_library_viewer_api/routers/monitoring.py` (modify)
|
||||
- Lines: ~−25
|
||||
- Dependencies: none
|
||||
- Details: Delete the `@router.get("/grafana-status")` endpoint and its helper. Leave `get_prometheus_status` / `get_alertmanager_status` intact.
|
||||
|
||||
- [x] **3.5 Remove Grafana backend tests**
|
||||
- Files: `backend/tests/test_widgets.py` (modify), `backend/tests/test_api.py` (modify), `backend/tests/test_services.py` (modify)
|
||||
- Lines: ~−40
|
||||
- Dependencies: 3.3
|
||||
- Details: Delete grafana adapter tests (`test_grafana_adapter_*`), grafana service fixtures, and the `TestGrafanaStatus` test class. SC-120.
|
||||
|
||||
- [x] **3.6 Delete frontend `GrafanaLinkWidget` + barrel export**
|
||||
- Files: `frontend/src/widgets/GrafanaLinkWidget.tsx` (delete), `frontend/src/widgets/index.ts` (modify)
|
||||
- Lines: ~−35
|
||||
- Dependencies: none
|
||||
- Details: Delete the file. Remove the `GrafanaLinkWidget` export from `widgets/index.ts`.
|
||||
|
||||
- [x] **3.7 Remove `grafana` binding from frontend registry**
|
||||
- Files: `frontend/src/integrations/registry.ts` (modify)
|
||||
- Lines: ~−30
|
||||
- Dependencies: 3.6
|
||||
- Details: Delete the entire `grafana` key from `SERVICE_REGISTRY`. Drop the `GrafanaLinkWidget` import. SC-117.
|
||||
|
||||
- [x] **3.8 Remove Grafana nav entry**
|
||||
- Files: `frontend/src/integrations/navEntries.ts` (modify)
|
||||
- Lines: ~−5
|
||||
- Dependencies: none
|
||||
- Details: Drop the `grafana` entry from `SERVICE_TYPE_NAV_ENTRIES` and any now-unused icon import (e.g. `Link2`).
|
||||
|
||||
- [x] **3.9 Remove Grafana status hook + API client function + type**
|
||||
- Files: `frontend/src/hooks/useObservability.ts` (modify), `frontend/src/api/client.ts` (modify), `frontend/src/types/index.ts` (modify)
|
||||
- Lines: ~−15
|
||||
- Dependencies: 3.4
|
||||
- Details: Drop `useGrafanaStatus` + its `fetchGrafanaStatus` import from `useObservability.ts`. Drop `fetchGrafanaStatus` from `client.ts`. Drop `GrafanaStatus` interface from `types/index.ts`.
|
||||
|
||||
- [x] **3.10 Delete Grafana service tab + remove from tab index**
|
||||
- Files: `frontend/src/pages/service-tabs/LinksTab.tsx` (delete), `frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx` (delete), `frontend/src/pages/service-tabs/index.ts` (modify)
|
||||
- Lines: ~−80
|
||||
- Dependencies: none
|
||||
- Details: Delete `LinksTab.tsx` (grafana-specific per its docstring) and its test. Remove the `LinksTab` import and `case "grafana":` from `service-tabs/index.ts`.
|
||||
|
||||
- [x] **3.11 Remove Grafana from Dashboard + ServicesPage**
|
||||
- Files: `frontend/src/pages/Dashboard.tsx` (modify), `frontend/src/pages/ServicesPage.tsx` (modify)
|
||||
- Lines: ~−5
|
||||
- Dependencies: none
|
||||
- Details: Drop `"grafana"` from `OBSERVABILITY_TYPES` set in `Dashboard.tsx`. Update `ServicesPage.tsx` empty-state copy: "Add a Grafana, Prometheus, …" → "Add a Prometheus, …".
|
||||
|
||||
- [x] **3.12 Rewrite `openspec/config.yaml` thin-dashboard rule**
|
||||
- Files: `openspec/config.yaml` (modify)
|
||||
- Lines: ~8
|
||||
- Dependencies: none
|
||||
- Details: Remove "Do NOT re-implement charting in-app" and "No recharts/d3 is in use" claims. Replace with accurate wording: in-app charting via `recharts` is the sanctioned approach for Prometheus-backed series; Grafana is no longer referenced. SC-121.
|
||||
|
||||
- [x] **3.13 Add CHANGELOG migration note**
|
||||
- Files: `CHANGELOG.md` (modify)
|
||||
- Lines: ~8
|
||||
- Dependencies: none
|
||||
- Details: Add entry under an appropriate version heading: instruct operators to delete existing Grafana service instances and recreate them as Prometheus services; note that `grafana/chart` widgets must be recreated as `prometheus/chart` widgets. SC-122, SC-126.
|
||||
|
||||
- [x] **3.14 Verify Slice 3 (grep-clean + build + lint + test)**
|
||||
- Run: `grep -ri grafana backend/src --include='*.py'` → expect zero matches (SC-115)
|
||||
- Run: `grep -ri grafana frontend/src` → expect zero matches except `Dashboard.test.tsx` shortcut fixture (SC-116)
|
||||
- Run: `cd backend && PYTHONPATH=src pytest && cd ../frontend && npm run build && npm run lint`
|
||||
- Verify: no grafana service references remain; all tests pass; frontend builds and lints.
|
||||
|
||||
**Slice 3 total:** ~250–350 changed lines (mostly deletions).
|
||||
|
||||
---
|
||||
|
||||
## Integration and acceptance verification
|
||||
|
||||
- [x] **4.1 Full backend test run**
|
||||
- Run: `cd backend && PYTHONPATH=src pytest`
|
||||
- Verify: all tests pass; no grafana test references remain; prometheus range/gauge/mean tests pass.
|
||||
|
||||
- [x] **4.2 Full frontend build + lint**
|
||||
- Run: `cd frontend && npm run build && npm run lint`
|
||||
- Verify: TypeScript compiles; no lint failures; no grafana imports unresolved.
|
||||
|
||||
- [x] **4.3 Grep-clean verification**
|
||||
- Run: `grep -ri grafana backend/src --include='*.py'` → zero matches
|
||||
- Run: `grep -ri grafana frontend/src` → zero matches excluding `Dashboard.test.tsx` shortcut fixture
|
||||
- Verify: SC-115, SC-116 satisfied.
|
||||
|
||||
- [x] **4.4 config.yaml accuracy check**
|
||||
- Verify: `openspec/config.yaml` does not contain "Do NOT re-implement charting" or "No recharts/d3"; reflects recharts as sanctioned renderer (SC-121).
|
||||
|
||||
- [x] **4.5 CHANGELOG check**
|
||||
- Verify: `CHANGELOG.md` documents the grafana→prometheus migration (SC-122).
|
||||
|
||||
---
|
||||
|
||||
## Total estimate
|
||||
|
||||
| Slice | Changed lines | Satisfies |
|
||||
|-------|---------------|-----------|
|
||||
| Slice 1: Prom range path + chart rebrand + helper | ~260–330 | SC-101…108 |
|
||||
| Slice 2: Gauge + mean widgets | ~310–400 | SC-109…114 |
|
||||
| Slice 3: Grafana removal + config + changelog | ~250–350 | SC-115…122, 126 |
|
||||
| Integration verification | ~0 | SC-123…125, 127 |
|
||||
| **Total** | **~820–1,080** | **SC-101…127** |
|
||||
|
||||
Each slice is under the 400-line review budget. Use three chained PRs (S1 → S2 → S3), each independently buildable and green.
|
||||
|
||||
---
|
||||
|
||||
## Risk flags for the apply phase
|
||||
|
||||
1. **`git mv` for chart widget rename** — use `git mv` (not delete+create) to preserve file history (design §9).
|
||||
2. **recharts `RadialBarChart` gauge** — try recharts first; if rendering proves fiddly, the proposal sanctions a ~50-line SVG fallback (no new dep). Decision at apply time.
|
||||
3. **`Dashboard.test.tsx` "Grafana" literal** — this is a user-authored shortcut label in a test fixture, not a grafana service reference. SC-116 grep should not flag it. Flagged for reviewer awareness.
|
||||
4. **SC-118 textual drift** — `ObservabilityPage.tsx` no longer exists (refactored to `service-tabs/`). The *intent* is satisfied by removing `get_grafana_status` + `useGrafanaStatus` + `LinksTab`. Verify against intent, not literal filename.
|
||||
5. **`_range_query` / `_instant_query` refactoring timing** — `_range_query` is created in S1 (1.3) for `_fetch_chart`; `_instant_query` is extracted in S2 (2.1) when `_fetch_gauge` needs it. Both shared helpers must be in place before S3 (which deletes `GrafanaWidgetSource` and its private chart logic).
|
||||
6. **Stale project map** — the pi-map references `ObservabilityPage.tsx` and 7 service types (actual: 8, including `authentik` + `backups`). Trust source, not the map. Run `project_map_patch` after source edits and `project_map_validate` before final handoff.
|
||||
@@ -0,0 +1,336 @@
|
||||
# Verify Report — prometheus-direct-charting
|
||||
|
||||
> Phase: **verify** · Change: `prometheus-direct-charting` · Repo: `/home/user/manage`
|
||||
> FRESH-CONTEXT adversarial read-only verification of the change against
|
||||
> `proposal.md`, `spec.md`, `design.md`, and `tasks.md`. **No source edits.**
|
||||
> This verify report is the only file written.
|
||||
|
||||
**Head commit verified:** `67ca0fc` (`feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog`).
|
||||
|
||||
Three implementation slices are committed:
|
||||
|
||||
- `5dad982` slice 1 — prom range query + chart rebrand
|
||||
- `65bae95` slice 2 — gauge + mean widgets
|
||||
- `67ca0fc` slice 3 — grafana removal + config rewrite + changelog
|
||||
|
||||
> NOTE: the dispatch brief cited slice hashes `58be6e0` / `ba94317` (an earlier
|
||||
> amend state). The actual landed commits are `65bae95` / `67ca0fc`. Content of
|
||||
> all three slices matches the spec/design/tasks; this is informational, not a defect.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary / verdict
|
||||
|
||||
**VERDICT: PASS — implementation complete and green; archive BLOCKED on a
|
||||
task-hygiene / missing-`apply-progress` issue (reconcilable without code).**
|
||||
|
||||
Every functional requirement **SC-101 … SC-127** was checked against source and
|
||||
**passes**. Grafana is fully excised from the live code paths (service type,
|
||||
adapters, widgets, hook, API client, type, status endpoint, nav entry, service
|
||||
tab). Prometheus is the direct chart source: `chart` (multi-series recharts line
|
||||
chart via `/api/v1/query_range`), `gauge` (instant scalar + threshold bands via
|
||||
recharts `RadialBarChart`), and `mean` (client-side average) are wired in both
|
||||
registries with consistent schemas. The shared `normalize_prometheus_matrix` /
|
||||
`step_for_window` helpers are extracted and unit-tested. All four gates are
|
||||
green: backend `pytest` (**293 passed**), `ruff` (**clean**), frontend
|
||||
`npm run build` (**exit 0**), `npm run lint` (**0 errors**).
|
||||
|
||||
Findings:
|
||||
|
||||
- **[CRITICAL — archive blocker, NOT a code defect]** **19 unchecked
|
||||
implementation/verification task checkboxes** remain in `tasks.md` (all of
|
||||
Slice 3 §3.1–3.14 and Integration §4.1–4.5), and **`apply-progress.md` does not
|
||||
exist** to reconcile them. The underlying work *is* done and verified complete
|
||||
against source; the blocker is that the task tracker was never updated and no
|
||||
apply-progress artifact was produced. Reconciliation = tick the boxes + write
|
||||
`apply-progress.md` (no code change). See §4.
|
||||
- **[WARNING]** SC-125 is **PARTIAL**: none of the three new widget tests
|
||||
(`PrometheusChartWidget` / `PrometheusGaugeWidget` / `PrometheusMeanWidget`)
|
||||
exercises an explicit **loading** state (`isLoading:true` → Skeleton). Error
|
||||
and rendered-data cases exist for all three. The loading branch exists in each
|
||||
component; the gap is test coverage, not functionality. See §5.
|
||||
- **[INFO]** Stale generated `.pi-map.md` files still reference Grafana /
|
||||
`ObservabilityPage.tsx`; these are not deliverable source and are ignored by
|
||||
|
||||
> SC-115/116, but should be regenerated (`project_map_patch`/`validate`). The
|
||||
> `config.yaml` context block also still names `ObservabilityPage.tsx` (refactored
|
||||
away into `service-tabs/`) — not an SC-121 criterion, minor doc staleness.
|
||||
|
||||
- **[INFO]** Working tree is not pristine: an uncommitted cosmetic reformat of
|
||||
`frontend/src/pages/service-tabs/MediaTab.tsx` (unrelated to this change) plus
|
||||
untracked `.pi-tmp/*` and `openspec/changes/service-storage-harness/` (the
|
||||
proposal for the *separate* change SC-127 requires independence from).
|
||||
|
||||
---
|
||||
|
||||
## 1. Structured status & actionContext findings
|
||||
|
||||
The native `gentle-pi.sdd-status` reports `changeName: null` /
|
||||
`blockedReasons: ["Change selection is ambiguous: …"]` because the engine
|
||||
auto-detected four active changes. This verify task was **explicitly assigned**
|
||||
`prometheus-direct-charting`; the ambiguity is a parent-resolution artifact and
|
||||
does not block this phase.
|
||||
|
||||
- `artifactStore: openspec`; change root
|
||||
`openspec/changes/prometheus-direct-charting/`.
|
||||
- Artifacts present: `proposal.md`, `spec.md`, `design.md`, `tasks.md`.
|
||||
- **`apply-progress.md`: MISSING** (confirmed: directory contains only the four
|
||||
planning docs + this report). This is the root cause of the §4 archive blocker.
|
||||
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/manage`,
|
||||
`allowedEditRoots: ["/home/user/manage"]`, `warnings: []`. Implementation
|
||||
ownership and all target files are provably inside the authoritative workspace. ✓
|
||||
- The `service-storage-harness` change exists only as an untracked proposal
|
||||
folder; SC-127 (independence) holds — this change builds/tests green without it.
|
||||
|
||||
## 2. Gate results (actual output, run at `67ca0fc`)
|
||||
|
||||
| Gate | Command | Result | Evidence |
|
||||
|------|---------|--------|----------|
|
||||
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** | **293 passed, 2 warnings** in 36.26s. Includes `test_prometheus_range.py` (9) + chart/gauge/mean adapter tests. |
|
||||
| Backend lint | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** | `All checks passed!` |
|
||||
| Frontend build | `cd frontend && npm run build` (`tsc -b` + `vite build`) | **PASS** exit 0 | `✓ built in 1.08s`; 2543 modules transformed. Non-fatal `>500 kB` chunk-size warning (pre-existing). |
|
||||
| Frontend lint | `cd frontend && npm run lint` (`eslint .`) | **PASS** exit 0 | `0 errors, 1 warning`. The warning is `react-hooks/exhaustive-deps` in `WidgetConfigDialog.tsx:370` — **pre-existing, untouched by this change** (no slice modified that file). |
|
||||
|
||||
### Grep gates (SC-115 / SC-116 / SC-117 / SC-120)
|
||||
|
||||
```
|
||||
grep -rin grafana backend/src --include='*.py'
|
||||
→ 3 matches, ALL in prometheus_range.py docstrings/comments describing the
|
||||
extraction/removal (explicitly allowed by SC-115). No live grafana code.
|
||||
|
||||
grep -rin grafana frontend/src
|
||||
→ matches ONLY in: stale generated .pi-map.md / .pi-map.index.md files
|
||||
(ignored — generated artifacts), and Dashboard.test.tsx lines 108/115
|
||||
(the whitelisted user-authored shortcut *label* "Grafana"). No live
|
||||
grafana code, no grafana widget/component/hook.
|
||||
|
||||
grep -rin grafana backend/tests --include='*.py'
|
||||
→ ZERO matches (SC-120). (.pyc cache + .pi-map.md are stale; .py sources clean.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Spec coverage (SC-101 … SC-127)
|
||||
|
||||
| SC | Requirement | Verdict | Evidence |
|
||||
|----|-------------|---------|----------|
|
||||
| SC-101 | range query → `{series}` shape | **PASS** | `sources.py:_fetch_chart` → `_range_query` hits `/api/v1/query_range` with `query/start/end/step`; returns `{"series": normalize_prometheus_matrix(matrix)}` = `{label, points:[{t:int, v:float\|null}]}`. Test `test_prometheus_chart_adapter_runs_range_query` asserts endpoint + params + shape. |
|
||||
| SC-102 | shared, Prometheus-native label normalization | **PASS** | Single helper `normalize_prometheus_matrix` in `prometheus_range.py`; drops `__name__`/`__*`, joins `k=v` (sorted), falls back to `"value"`, dedups with `(n)`. No duplication. `test_prometheus_range.py` covers all rules. |
|
||||
| SC-103 | range-query errors degrade gracefully | **PASS** | `_range_query`/`_instant_query` catch `asyncio.TimeoutError` + `requests.RequestException` → `{"error": ...}`; `.fetch()` wraps the whole body in `try/except → {"error"}`. Never raises. Test `test_prometheus_chart_adapter_degrades_on_http_error`. |
|
||||
| SC-104 | step derived from window (100–300 pts) | **PASS** | `step_for_window = max(15, round(window/200))`. Parametrized test asserts 100–300 points for `1h/6h/24h/7d` + 15s floor + custom target. |
|
||||
| SC-105 | `chart` rebind grafana→prometheus (both registries) | **PASS** | Backend `prometheus.py` declares `chart`; frontend `registry.ts` `prometheus` binding has `chart`. Grafana is gone entirely (so trivially offers no `chart`). |
|
||||
| SC-106 | chart renderer reused unchanged | **PASS** | `PrometheusChartWidget.tsx` preserves `LineChart/Line/XAxis/YAxis/CartesianGrid/Tooltip/ResponsiveContainer/mergeSeries/CHART_COLORS/formatTime`; rename-only + empty-state copy fix. |
|
||||
| SC-107 | chart multi-series | **PASS** | `series.map((s,i) => <Line dataKey={s.label} .../>)` renders every series; no single-series restriction. |
|
||||
| SC-108 | chart window is a preset | **PASS** | chart config = `{promql, window}`; `window ∈ {1h,6h,24h,7d}` resolved via `WINDOW_PRESETS` server-side; no raw from/to/step. |
|
||||
| SC-109 | gauge = instant scalar | **PASS** | `_fetch_gauge` → instant `/api/v1/query` → parses `result[0]["value"][1]` → `{"value":...}`. Bound to `prometheus` in both registries. |
|
||||
| SC-110 | gauge threshold bands | **PASS** | `PrometheusGaugeWidget` (recharts `RadialBarChart`) renders green/amber/red track cells when `warn_at`+`crit_at` set, single neutral track otherwise; `min/max` default to `0`/`max(value,1)`. Config accepts `warn_at/crit_at/min/max/unit`. |
|
||||
| SC-111 | gauge scalar-only | **PASS** | `len(result) != 1 → {"error": "Gauge requires a single-series query; refine your PromQL"}`. Test `test_prometheus_gauge_adapter_rejects_multi_series`. |
|
||||
| SC-112 | mean = client-side mean over window | **PASS** | `_fetch_mean` → `_range_query` → averages non-null samples of the single series → `{"value": mean, "unit?"}`. Test asserts mean of `[1,2,3]=2.0`, NaN-skip `[2,NaN,4]=3.0`. |
|
||||
| SC-113 | mean = plain PromQL + preset | **PASS** | mean config = `{promql, window, unit?}`; user supplies plain PromQL (no `avg_over_time`); window is a preset. |
|
||||
| SC-114 | mean scalar-only | **PASS** | `len(result) != 1 → {"error": ...}`. Test `test_prometheus_mean_adapter_rejects_multi_series`. |
|
||||
| SC-115 | no grafana in backend src | **PASS** | grep → only `prometheus_range.py` docstrings (allowed). `integrations/grafana.py` deleted; no `GrafanaWidgetSource`. |
|
||||
| SC-116 | no grafana in frontend src | **PASS** | grep → only `Dashboard.test.tsx` whitelisted shortcut fixture + stale `.pi-map.md` (ignored). No live code. |
|
||||
| SC-117 | grafana gone from registries | **PASS** | No `grafana` in backend `SERVICE_DEFINITIONS`/`SERVICE_ADAPTERS` or frontend `SERVICE_REGISTRY`/`BUILTIN_WIDGETS`; `integrations/grafana.py` deleted. `registry.test.ts` asserts 5 service types (no grafana). |
|
||||
| SC-118 | grafana status + UI removed | **PASS (intent)** | `get_grafana_status` removed (`monitoring.py`); `useGrafanaStatus`/`fetchGrafanaStatus`/`GrafanaStatus` type removed; `LinksTab.tsx`+test deleted; `service-tabs/index.ts` grafana case removed; `navEntries.ts` grafana entry removed; `Dashboard.tsx` `OBSERVABILITY_TYPES` = `{alertmanager, prometheus}`; `ServicesPage.tsx` copy updated. `ObservabilityPage.tsx` was already refactored into `service-tabs/` (spec drift acknowledged in design §0 / spec SC-118 note). |
|
||||
| SC-119 | orphaned grafana widget degrades | **PASS** | `WidgetInstanceCard`: `resolveWidget` → `undefined` for a grafana-bound widget (no binding) → renders `"Unknown widget: <kind> (service-bound)"` `Alert`. No crash. |
|
||||
| SC-120 | grafana tests removed | **PASS** | No grafana in backend test `.py` or frontend widget tests. Only stale `.pyc` cache + `.pi-map.md`. |
|
||||
| SC-121 | config.yaml accurate | **PASS** | Stale "Do NOT re-implement charting in-app" / "No recharts/d3" claims gone; OBSERVABILITY MODEL states "Manage renders Prometheus-backed metrics directly via recharts … Grafana is no longer integrated." No grafana-as-chart-path. |
|
||||
| SC-122 | CHANGELOG migration note | **PASS** | `[Unreleased]` has "Direct Prometheus charting" + "**BREAKING** — Grafana service type removed" with migration: delete grafana instances → recreate as Prometheus; `grafana/chart` widgets → `prometheus/chart`. |
|
||||
| SC-123 | backend tests pass | **PASS** | `pytest` → 293 passed; covers range→`{series}`, gauge scalar-only, mean aggregation, shared label helper. |
|
||||
| SC-124 | frontend build + lint | **PASS** | `npm run build` exit 0; `npm run lint` 0 errors (1 pre-existing warning). |
|
||||
| SC-125 | new widget kinds have tests | **PARTIAL** | All three test files exist with **error + rendered** cases (gauge has with/without bands; mean has with/without unit). **No explicit loading-state case** (`isLoading:true`) in any of the three — see §5. |
|
||||
| SC-126 | no silent data migration | **PASS** | No grafana→prometheus row transformation code anywhere; only docstring references to manual migration. `settings_store.py` migration is unrelated (`jellyseerr→jellyfin`). |
|
||||
| SC-127 | independent of service-storage-harness | **PASS** | No reference to it in source (only a `prometheus_range.py` docstring noting future reuse); builds/tests green without it; that change exists only as a proposal folder. |
|
||||
|
||||
**Functional spec coverage: 26/27 fully PASS, 1 PARTIAL (SC-125, test-coverage only).**
|
||||
|
||||
---
|
||||
|
||||
## 4. Task completion status — ⚠ archive blocker (reconcilable)
|
||||
|
||||
`tasks.md` checkbox state:
|
||||
|
||||
- **Slice 1 (§1.1–1.10): all 10 `[x]`** ✓
|
||||
- **Slice 2 (§2.1–2.10): all 10 `[x]`** ✓
|
||||
- **Slice 3 (§3.1–3.14): all 14 `[ ]` — UNCHECKED**
|
||||
- **Integration (§4.1–4.5): all 5 `[ ]` — UNCHECKED**
|
||||
|
||||
Total: 20 checked, **19 unchecked**.
|
||||
|
||||
**This is a CRITICAL completeness issue for the archive gate per the verify
|
||||
contract.** However — and this is the important reconciliation — **the Slice 3
|
||||
and Integration work is verifiably DONE against source**:
|
||||
|
||||
| Unchecked task | Actual state (verified) |
|
||||
|----------------|-------------------------|
|
||||
| 3.1 delete `integrations/grafana.py` | **deleted** (`ls` → ENOENT; slice-3 diff `73 ------`) |
|
||||
| 3.2 drop grafana from backend registry | **done** (`registry.py` has no grafana import/entry) |
|
||||
| 3.3 remove `GrafanaWidgetSource` + adapter | **done** (`sources.py` has no class; `SERVICE_ADAPTERS` no grafana; diff `113 ---------`) |
|
||||
| 3.4 remove `get_grafana_status` | **done** (`monitoring.py` grep clean; diff `-25`) |
|
||||
| 3.5 remove grafana backend tests | **done** (`.py` test grep clean) |
|
||||
| 3.6 delete `GrafanaLinkWidget` + barrel | **done** (file gone; `index.ts` export dropped) |
|
||||
| 3.7 remove grafana frontend binding | **done** (`registry.ts` no grafana key; registry.test asserts) |
|
||||
| 3.8 remove grafana nav entry | **done** (`navEntries.ts` grep clean) |
|
||||
| 3.9 remove hook + api client + type | **done** (`useObservability.ts`/`client.ts`/`types/index.ts` grep clean) |
|
||||
| 3.10 delete `LinksTab` + tab-index case | **done** (file + test deleted; `index.ts` grep clean) |
|
||||
| 3.11 Dashboard + ServicesPage | **done** (`OBSERVABILITY_TYPES` = `{alertmanager,prometheus}`; ServicesPage copy updated) |
|
||||
| 3.12 rewrite `config.yaml` | **done** (stale claims gone) |
|
||||
| 3.13 CHANGELOG note | **done** (migration entry present) |
|
||||
| 3.14 verify slice 3 (grep + build + lint + test) | **done** (all green — §2) |
|
||||
| 4.1 full backend pytest | **done** (293 passed) |
|
||||
| 4.2 frontend build + lint | **done** (exit 0 / 0 errors) |
|
||||
| 4.3 grep-clean | **done** (SC-115/116 satisfied) |
|
||||
| 4.4 config.yaml check | **done** (SC-121 satisfied) |
|
||||
| 4.5 CHANGELOG check | **done** (SC-122 satisfied) |
|
||||
|
||||
The unchecked boxes are **stale** (work performed, tracker not updated), and
|
||||
**no `apply-progress.md` exists** to serve as the stale-checkbox reconciliation
|
||||
record the contract permits. Resolution is a **documentation-only** step:
|
||||
tick §3.1–3.14 and §4.1–4.5, and author `apply-progress.md` describing the three
|
||||
landed slices. **No code change is required.**
|
||||
|
||||
> Per the verify contract, an unchecked implementation-task line is an archive
|
||||
> blocker until reconciled. Because the implementation is verified complete, this
|
||||
> blocks **archive** but does **not** block `sdd-sync` of the green code.
|
||||
|
||||
---
|
||||
|
||||
## 5. TDD compliance & assertion-quality assessment
|
||||
|
||||
Strict-TDD was **not** declared active for this change in `config.yaml` /
|
||||
parent prompt / (absent) `apply-progress.md`, so the formal TDD-cycle-evidence
|
||||
check is **not applicable**. Assertion quality was still audited adversarially.
|
||||
|
||||
**Backend assertions — GENUINELY BEHAVIORAL (good).** Spot-checked:
|
||||
|
||||
- `test_prometheus_range.py`: asserts exact label strings (`instance=h:9100 mode=idle`), dedup suffix (`job=x (1)`), null sentinels → `None`, malformed-timestamp drop, point-count band per preset, 15s floor. No tautologies.
|
||||
- `test_widgets.py` adapters: assert the **called URL** (`endswith("/api/v1/query_range")` / `/api/v1/query"`), the **params** (`{start,end,step} ⊆ params`, `query=="up"`), the **return shape** (`result["series"][0]["label"]`, `result["value"]==0.75`), multi-series → `{"error" ... "single-series"}`, and mean arithmetic (`2.0`, NaN-skip `3.0`). These verify real behavior, not smoke.
|
||||
|
||||
**Frontend assertions — adequate, one coverage gap.**
|
||||
|
||||
- Gauge test asserts the **rendered value text** (`0.75 %`, `42 req/s`) and threshold-label presence/absence — meaningful.
|
||||
- Mean test asserts the **rendered value** (`23.5 %`, `1500`) — meaningful.
|
||||
- Chart rendered-case asserts only the **SectionCard title** (`CPU Usage`), not that recharts drew the series SVG — a weak/title-only assertion (acceptable as a "rendered data case" since the component mounts with series data, but it does not prove the lines rendered).
|
||||
|
||||
**[WARNING] SC-125 loading-state gap (see §3).** None of the three widget tests
|
||||
sets `isLoading:true`. The `mockData` helper hard-codes `isLoading:false`, and no
|
||||
case asserts the `<Skeleton>` loading branch. Each component's loading branch
|
||||
exists and is structurally identical to sibling widgets, so this is a
|
||||
**test-coverage gap, not a functional defect**. Recommend adding one
|
||||
`isLoading:true` case per widget to fully satisfy SC-125's enumerated
|
||||
"loading state" requirement. Non-blocking.
|
||||
|
||||
No ghost loops, no type-only assertions, no implementation-detail CSS assertions
|
||||
found. No test mocks grafana anywhere (only the whitelisted `Dashboard.test.tsx`
|
||||
shortcut label).
|
||||
|
||||
## 6. Review-workload / PR-boundary findings
|
||||
|
||||
Per-slice changed lines (numstat, excluding `tasks.md` doc churn):
|
||||
|
||||
| Commit | Slice | Source Δ | Over 400? | Verdict |
|
||||
|--------|-------|----------|-----------|---------|
|
||||
| `5dad982` | 1 range+rebrand+helper | ~333 ins / ~56 del | under (337 net w/ test) | **OK** |
|
||||
| `65bae95` | 2 gauge+mean | ~707 ins / ~32 del | **over** (largely new widgets+tests: gauge 137, mean 59, tests 124, backend tests 197) | **OK** — additive feature code+tests; forecast rated "Medium"; boundary is the feature, not a missed split |
|
||||
| `67ca0fc` | 3 grafana removal+config+changelog | ~123 ins / **~926 del** | under (net negative) | **OK** — mostly deletions |
|
||||
|
||||
The `tasks.md` Review Workload Forecast (`stacked-to-main`, 3 slices, ~850–1,110
|
||||
total) was followed: S1→S2→S3, each independently green. Slice 2's +707 insertions
|
||||
exceed the 400-line *added* budget but are dominated by two new components + their
|
||||
tests + new adapter tests (no `size:exception` flag was recorded, and the forecast
|
||||
itself rated slice 2 "~310–400 changed lines" which under-counts the test volume).
|
||||
This is a **minor forecast-vs-actual variance on an additive slice**, not scope
|
||||
creep — the boundary is exactly the gauge+mean feature, no unrelated files touched.
|
||||
Recommend recording the slice-2 actual in the archive summary. **Non-blocking.**
|
||||
|
||||
Scope was honored: no backend API/type-contract widening, no `service-storage-harness`
|
||||
coupling, `metric` path preserved (`PrometheusMetricWidget` reads `data?.data?.result`
|
||||
as the PromQL data object; `_instant_query` returns `{"result": payload.get("data",{})}`
|
||||
— identical shape).
|
||||
|
||||
## 7. Adversarial checks
|
||||
|
||||
- **Dead imports after grafana removal?** None. Backend `ruff` (catches unused
|
||||
imports) is clean; `sources.py` imports `WINDOW_PRESETS/normalize_prometheus_matrix/
|
||||
step_for_window` — all used. Frontend `tsc`/`eslint` clean (unresolved imports
|
||||
would fail the build).
|
||||
- **`PrometheusWidgetSource.fetch` dispatch cross-contamination?** None. Clean
|
||||
`widget_kind` dispatch: `chart`→`{series}`, `gauge`→`{value,...}`, `mean`→`{value}`,
|
||||
default→`{result}` (metric). Distinct shapes; shared `_range_query`/`_instant_query`
|
||||
only do HTTP + error mapping.
|
||||
- **Config-schema consistency (backend models vs frontend registry)?** Consistent.
|
||||
gauge: backend `{promql, warn_at, crit_at, min, max, unit}` ↔ frontend configSchema
|
||||
`{promql(req), warn_at, crit_at, min, max, unit}`. chart: `{promql, window}` ↔
|
||||
`{promql(req), window}`. mean: `{promql, window, unit}` ↔ `{promql(req), window, unit}`.
|
||||
- **Lingering grafana mocks?** None in `.py`/`.tsx` source. Only stale `.pyc`
|
||||
bytecode cache + `.pi-map.md`.
|
||||
|
||||
## 8. Residual risks / non-blocking findings
|
||||
|
||||
1. **[CRITICAL-process] 19 unchecked tasks + missing `apply-progress.md`** (§4) — archive blocker; reconciliation is doc-only.
|
||||
2. **[WARNING] SC-125 loading-state tests missing** (§5) — coverage gap, not a defect.
|
||||
3. **[INFO] Stale generated `.pi-map.md`** files reference Grafana / `ObservabilityPage.tsx` / `TestGrafanaStatus` / `GrafanaLinkWidget`. Not deliverable source; ignored by SC-115/116. Regenerate via `project_map_patch`/`project_map_validate` (the project-map protocol flags these `dirty`).
|
||||
4. **[INFO] `config.yaml` context** still names `frontend/src/components/ObservabilityPage.tsx` (refactored away into `service-tabs/`). Not an SC-121 criterion (which targets the charting/grafana claims, which are fixed); minor doc staleness.
|
||||
5. **[INFO] Uncommitted `MediaTab.tsx`** cosmetic reformat (Prettier line-wrap of a ternary) — unrelated to this change, predates/orthogonal. Dirty working tree; no files are staged.
|
||||
6. **[INFO] Chunk-size build warning** (~1.1 MB JS) — non-fatal, pre-existing, orthogonal.
|
||||
7. **No browser/visual smoke** performed (out of scope); the recharts `RadialBarChart` gauge and `LineChart` rendering are only structurally tested.
|
||||
|
||||
## 9. Exact blockers
|
||||
|
||||
- **BLOCKER (archive only, doc-reconcilable):** 19 unchecked implementation/verification
|
||||
tasks (§3.1–3.14, §4.1–4.5) and absent `apply-progress.md`. Implementation is
|
||||
verified complete; resolution = tick boxes + write `apply-progress.md`.
|
||||
|
||||
No code-level blockers. All functional requirements SC-101…SC-127 pass (SC-125
|
||||
PARTIAL on test coverage only). All four gates green. **Code is ready for
|
||||
`sdd-sync`; archive requires the checkbox/apply-progress reconciliation.**
|
||||
|
||||
## 10. Recommended next phase
|
||||
|
||||
→ **`sdd-sync`** (code PASS). Concurrently/after: author `apply-progress.md`
|
||||
documenting the three landed slices, and tick §3.1–3.14 + §4.1–4.5 in
|
||||
`tasks.md` to clear the archive blocker. Optionally add three `isLoading:true`
|
||||
widget tests to move SC-125 PARTIAL→PASS, and regenerate the stale `.pi-map.md`.
|
||||
|
||||
---
|
||||
|
||||
### Appendix A — Verification commands run (at `67ca0fc`)
|
||||
|
||||
```
|
||||
cd backend && PYTHONPATH=src python3 -m pytest -q → 293 passed (2 warnings)
|
||||
cd backend && PYTHONPATH=src python3 -m ruff check src tests → All checks passed!
|
||||
cd frontend && npm run build → exit 0 (✓ built; >500kB warning pre-existing)
|
||||
cd frontend && npm run lint → exit 0 (0 errors, 1 pre-existing warning)
|
||||
grep -rin grafana backend/src --include='*.py' → 3 docstring hits in prometheus_range.py (allowed)
|
||||
grep -rin grafana frontend/src → Dashboard.test.tsx fixture + stale .pi-map.md only
|
||||
grep -rin grafana backend/tests --include='*.py' → ZERO
|
||||
ls backend/src/media_library_viewer_api/integrations/grafana.py → ENOENT (deleted)
|
||||
grep -nE '^\s*- \[ \]' tasks.md → 19 unchecked (§3.1–3.14, §4.1–4.5)
|
||||
```
|
||||
|
||||
### Appendix B — Files substantively changed
|
||||
|
||||
**Backend**
|
||||
|
||||
- `widgets/prometheus_range.py` (new) — `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix`.
|
||||
- `widgets/sources.py` — dropped `GrafanaWidgetSource`; `PrometheusWidgetSource` gains `_fetch_chart/_fetch_gauge/_fetch_mean` + shared `_range_query/_instant_query`.
|
||||
- `integrations/prometheus.py` — `metric`+`chart`+`gauge`+`mean` widget kinds + config models.
|
||||
- `integrations/registry.py` — grafana import/entry removed.
|
||||
- `integrations/grafana.py` — **deleted**.
|
||||
- `routers/monitoring.py` — `get_grafana_status` removed.
|
||||
- `tests/test_prometheus_range.py` (new), `tests/test_widgets.py` (chart/gauge/mean adapter tests; grafana tests removed), `tests/test_api.py` + `tests/test_services.py` (grafana fixtures/tests removed).
|
||||
|
||||
**Frontend**
|
||||
|
||||
- `widgets/PrometheusChartWidget.tsx` (renamed from GrafanaChartWidget), `PrometheusGaugeWidget.tsx` (new), `PrometheusMeanWidget.tsx` (new) + their tests.
|
||||
- `widgets/GrafanaLinkWidget.tsx` — **deleted**; `widgets/index.ts` barrel updated.
|
||||
- `integrations/registry.ts` — grafana binding removed; chart/gauge/mean added to prometheus; `registry.test.ts` updated.
|
||||
- `integrations/navEntries.ts` — grafana entry removed.
|
||||
- `hooks/useObservability.ts`, `api/client.ts`, `types/index.ts` — grafana hook/client/type removed.
|
||||
- `pages/service-tabs/LinksTab.tsx` (+test) — **deleted**; `service-tabs/index.ts` grafana case removed.
|
||||
- `pages/Dashboard.tsx` (`OBSERVABILITY_TYPES`), `pages/ServicesPage.tsx` (copy) — grafana removed.
|
||||
|
||||
**Docs**
|
||||
|
||||
- `openspec/config.yaml` — stale thin-dashboard/no-recharts claims repealed.
|
||||
- `CHANGELOG.md` — direct-Prometheus-charting + grafana-removal migration note.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Apply Progress: Service Storage Harness
|
||||
|
||||
**Change:** `service-storage-harness`
|
||||
**Phase:** apply-progress
|
||||
**Date:** 2026-07-09
|
||||
**Status:** complete — all 35 tasks done, all gates green, verified (see `verify-report.md`)
|
||||
|
||||
## Slices delivered
|
||||
|
||||
Four slices, each its own commit, each leaving `pytest` / `npm run build` / `npm run lint` / `ruff` green.
|
||||
|
||||
### Slice 1 — Harness + qBit store + client + integration (commit `e7bd0af`, amended)
|
||||
|
||||
- `services/service_data.py` — `ServiceDataHarness`: lifecycle-only (concern registration via dataclass, idempotent `run_migrations` catching "duplicate column name" per-statement, `cascade_delete(service_id)` iterating owned tables). No generic data ops (SS-101..104).
|
||||
- `services/qbittorrent_store.py` — `QbittorrentSampleStore`: `qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)` + index in dedicated `qbittorrent.db`; `append/window/prune`, MAX_SAMPLES=120; registered as a harness concern (SS-105..107).
|
||||
- `clients/qbittorrent.py` — `QbittorrentClient`: cookie login via `/api/v2/auth/login`, 403 re-login+retry, `maindata()` via `/api/v2/sync/maindata` (SS-108..110).
|
||||
- `integrations/qbittorrent.py` + registry entry — config (base_url, timeout_seconds) + secret (username, password) schema; 3 widget kinds declared.
|
||||
- Initialized in `main.py` lifespan.
|
||||
- Tests: `test_service_data.py` (harness lifecycle), `test_qbittorrent_store.py`, `test_qbittorrent_client.py`.
|
||||
|
||||
### Slice 2 — qBit widgets + LineSeriesChart extract (commit `8b0e7ea`, amended)
|
||||
|
||||
- `QbittorrentWidgetSource` in `widgets/sources.py` — 3 branches: `totals` (item count from maindata), `active` (filter state ∈ {downloading, uploading}), `speed` (append sample + return `{series}` from `.window()`, ts×1000 for JS ms). Errors → `{error}`. Registered in `SERVICE_ADAPTERS` (SS-111..115).
|
||||
- `frontend/src/components/LineSeriesChart.tsx` — shared recharts renderer extracted from `PrometheusChartWidget` (~40 lines, props `{series, height?}`); `PrometheusChartWidget` becomes a thin wrapper. **Extraction non-regressive: 4 PrometheusChartWidget tests stay green** (SS-118).
|
||||
- `QbittorrentTotalsWidget` (count), `QbittorrentActiveTorrentsWidget` (list), `QbittorrentSpeedWidget` (uses LineSeriesChart). Registry binding + barrel + tests (SS-116..117).
|
||||
|
||||
### Slice 3 — MediaIndex migration (commit `c87f398`) — LOAD-BEARING
|
||||
|
||||
- Idempotent harness migration: `ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''`. `media_index.db` file location UNCHANGED. Existing rows backfill to `service_id=''` via DEFAULT (SS-119, SS-120, SS-124).
|
||||
- **Scoped `replace_items`** — `DELETE FROM media_items WHERE service_id = ?` replaces the prior global `DELETE FROM media_items`. **FIXES the latent global-clear bug** where rebuilding for one Jellyfin wiped another's rows. `service_id` stamped into inserted rows. Regression test `test_replace_scoped_by_service_id_preserves_other_services` proves svc-A survives svc-B's rebuild (SS-121).
|
||||
- `query(service_id="")` shows all rows (backward-compat); `query(service_id="X")` scopes. **Existing MediaIndex + API tests pass unchanged** (62 passed) (SS-122).
|
||||
- Worker threads the real `service_id` (already plumbed via `--service-id`) into `replace_items` so new rows are stamped (SS-123).
|
||||
|
||||
### Slice 4 — Cascade-delete wiring (commit `75c949a`)
|
||||
|
||||
- `settings_store.delete_service` calls `ServiceDataHarness.cascade_delete(service_id)` after existing cleanup, best-effort try/except (failure logs, doesn't crash the delete) (SS-125).
|
||||
- Integration test proves end-to-end cascade across BOTH concerns (qBit samples + media items) with multi-instance preservation (SS-126).
|
||||
|
||||
## Deviations from tasks.md
|
||||
|
||||
- **Slice 2 over the 400-line review budget** (verify-report flagged +644 source lines). The slice is additive (3 new widgets + extraction + tests), no scope creep, but the per-slice budget from `openspec/config.yaml` was exceeded. Retrospectively this could have been split (extraction in one slice, qBit widgets in another). No code defect; recorded here as a process note for future slicing. The verify agent flagged it WARNING, not blocking.
|
||||
|
||||
## Final gate results
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `backend && PYTHONPATH=src python3 -m pytest -q` | **322 passed**, 2 warnings (pre-existing pythonjsonlogger DeprecationWarning) |
|
||||
| `backend && PYTHONPATH=src python3 -m ruff check src tests` | **All checks passed** |
|
||||
| `frontend && npm run build` | **exit 0** (pre-existing chunk-size warning) |
|
||||
| `frontend && npm run lint` | **0 errors**, 1 pre-existing warning (`WidgetConfigDialog.tsx`, untouched) |
|
||||
| `frontend && npx vitest run PrometheusChartWidget.test.tsx` | **4 passed** (extraction non-regression confirmed) |
|
||||
|
||||
## Verification
|
||||
|
||||
See `verify-report.md` — adversarial fresh-context review: **28/28 PASS**. No blocking code findings. Archive blocker is doc-only (this file + the ticked tasks.md clear it).
|
||||
@@ -0,0 +1,220 @@
|
||||
# Archive Report — `service-storage-harness`
|
||||
|
||||
> Phase: **archive** · Change: `service-storage-harness` · Repo: `/home/user/manage`
|
||||
> Mode: file-backed (`artifactStore: openspec`). No source-code edits; only OpenSpec artifacts
|
||||
> were touched. **Not committed** — the parent/orchestrator owns the archive commit. No push, no `gh`.
|
||||
|
||||
**Status: ARCHIVED.** All eight lifecycle phases are complete (proposal → spec → design → tasks →
|
||||
apply → verify → sync → **archive**). Every archive precondition is verified PASS (see §2). The
|
||||
canonical `openspec/specs/service-storage/spec.md` (created by `sdd-sync`) remains in place as the
|
||||
durable end-state spec and is **not** moved (archive never moves canonical specs). The change folder
|
||||
was moved to `openspec/changes/archive/2026-07-09-service-storage-harness/` via `git mv` to preserve
|
||||
history (the renames were then unstaged so the parent commits from a clean index with explicit paths).
|
||||
|
||||
---
|
||||
|
||||
## 0. Archive disposition
|
||||
|
||||
- **Disposition: `archived`.** The folder move was performed inline as instructed (unlike a
|
||||
`documented-pending-manual` outcome): the parent explicitly requested the `git mv` and owns the
|
||||
commit, so the move is executed here and left as working-tree changes for the parent's
|
||||
explicit-path commit.
|
||||
- **Archive convention:** OpenSpec SDD archive contract for `openspec` mode — completed file-backed
|
||||
sync → write the in-folder archive report → move the change folder to
|
||||
`openspec/changes/archive/YYYY-MM-DD-{change}/`. No standalone manifest/index exists under
|
||||
`openspec/` (only `config.yaml`, `changes/`, `specs/`), so the folder move **is** the archive
|
||||
mechanism. No `rules.archive` override exists in `openspec/config.yaml`.
|
||||
- **Target archived path:** `openspec/changes/archive/2026-07-09-service-storage-harness/`
|
||||
- **Archive date:** `2026-07-09` (ISO).
|
||||
- **Canonical spec left in place (not moved):** `openspec/specs/service-storage/spec.md` —
|
||||
28 requirements (SS-101 … SS-128). Verified present and byte-identical before/after the move
|
||||
(sha256 `6629e307…` unchanged). The other canonical domains (`web-ui`, `prometheus-charting`)
|
||||
were also left untouched.
|
||||
- **Audit-trail integrity:** the change folder was moved as a whole, including the legacy flat
|
||||
`spec.md` and the per-domain delta `specs/service-storage/spec.md`, which travel with the
|
||||
record. Nothing was silently deleted or rewritten. The flat `spec.md` is retained as the
|
||||
authoritative planning artifact the work was built against.
|
||||
|
||||
## 1. Native `sdd-status` read & discrepancy statement
|
||||
|
||||
The native `gentle-pi.sdd-status` engine supplied by the parent reports **non-actionable state for
|
||||
this archive** because it was resolved without a change context: `changeName: null`,
|
||||
`artifacts: all missing`, `applyState: blocked`, `dependencies.archive: blocked`,
|
||||
`blockedReasons: ["Change selection is ambiguous: mobile-responsive-parity, service-storage-harness,
|
||||
services-as-hub-ia."]`, `isNonAuthoritative: false`. This is a **parent-resolution artifact**: the
|
||||
engine auto-detected three active changes and could not pick one. The ambiguity does **not** reflect
|
||||
the state of `service-storage-harness`, which this archive task was **explicitly assigned**.
|
||||
|
||||
**Discrepancy with the parent's authoritative confirmed state — RESOLVED in favor of the parent.**
|
||||
The parent physically verified (and this executor re-confirmed directly against the filesystem in
|
||||
§2) that all 35 tasks are ticked, `apply-progress.md` exists and records the four landed slices, the
|
||||
verify report is clearly passing (28/28 PASS, all four gates green), and `sync-report.md` records a
|
||||
completed sync. Per the archive contract's non-authoritative-store carve-out guidance and the
|
||||
parent's explicit instruction ("DISREGARD; PROCEED"), the stale `archive: blocked` / "ambiguous"
|
||||
labels are **disregarded** and the archive **proceeds**.
|
||||
|
||||
Direct filesystem re-validation (§2) is the source of truth for this report.
|
||||
|
||||
## 2. Archive preconditions (validated directly against the filesystem)
|
||||
|
||||
| Precondition | Evidence | Result |
|
||||
|---|---|---|
|
||||
| Verify report present | `verify-report.md` | ✓ verdict **PASS** |
|
||||
| Verify clearly passing — no unresolved `FAIL`/`BLOCKED`/`CRITICAL` | verify-report: **28/28 PASS** (SS-101…SS-128); the sole CRITICAL was an archive-only checkbox/apply-progress gap (now reconciled) | ✓ |
|
||||
| Sync report present & successful | `sync-report.md` → **Status: SYNCED** | ✓ |
|
||||
| Canonical spec exists (sync target) | `openspec/specs/service-storage/spec.md` (28 requirements) | ✓ |
|
||||
| Change-side domain delta exists | `specs/service-storage/spec.md` | ✓ |
|
||||
| Delta op-class = pure `## ADDED` (non-destructive) | ADDED=1, MODIFIED=0, REMOVED=0, RENAMED=0 (new domain) | ✓ |
|
||||
| Requirement-ID parity (flat ↔ delta ↔ canonical) | 28 == 28 == 28, identical IDs SS-101…SS-128 | ✓ |
|
||||
| proposal / design / tasks artifacts present | all populated | ✓ |
|
||||
| **Final Task Completion Gate — zero unchecked `- [ ]`** | `grep -nE '^\s*- \[ \]' tasks.md` → **NONE**; `grep -cE '^\s*- \[x\]'` → **35** | ✓ |
|
||||
| `apply-progress.md` present & records the work | present; status "complete — all 35 tasks done", 4 slices + final gates documented | ✓ |
|
||||
| No active same-domain (`service-storage`) collision | only this change carries a `service-storage` delta; `web-ui`/`prometheus-charting` untouched | ✓ |
|
||||
| Canonical untouched by the move | sha256 of `openspec/specs/service-storage/spec.md` identical before/after the `git mv` | ✓ |
|
||||
|
||||
**Stale-checkbox reconciliation note.** At verify time, 30 implementation/verification checkboxes
|
||||
(Slices §1.1–1.9, §2.1–2.9, §3.1–3.9, §4.1–4.3 and Integration §5.1–5.5) were unchecked and
|
||||
`apply-progress.md` did not exist. That condition was reconciled **before** archive: the boxes are
|
||||
now all ticked (re-confirmed directly: `grep -nE '^\s*- \[ \]' tasks.md` → none; 35 `[x]`) and
|
||||
`apply-progress.md` was authored documenting the four landed slices. `apply-progress.md` plus the
|
||||
verify report prove every previously-unchecked task complete. No archive-time mechanical checkbox
|
||||
repair was needed — the gate now passes on the persisted `tasks.md` as-is. No partial-archive
|
||||
approval applies.
|
||||
|
||||
## 3. Artifacts read (archive preflight)
|
||||
|
||||
- `openspec/changes/service-storage-harness/proposal.md`
|
||||
- `openspec/changes/service-storage-harness/spec.md` (flat, authoritative planning artifact — 28 requirements)
|
||||
- `openspec/changes/service-storage-harness/specs/service-storage/spec.md` (change-side domain delta)
|
||||
- `openspec/changes/service-storage-harness/design.md`
|
||||
- `openspec/changes/service-storage-harness/tasks.md`
|
||||
- `openspec/changes/service-storage-harness/apply-progress.md`
|
||||
- `openspec/changes/service-storage-harness/verify-report.md`
|
||||
- `openspec/changes/service-storage-harness/sync-report.md`
|
||||
- `openspec/specs/service-storage/spec.md` (canonical, sync target — verified present and byte-identical after the move)
|
||||
- `openspec/config.yaml` (rules: proposal/tasks; no `rules.archive` override)
|
||||
|
||||
> The legacy flat `spec.md` is **not** the only spec artifact: a per-domain delta
|
||||
> (`specs/service-storage/spec.md`) and a canonical spec both exist, so the "legacy flat spec as the
|
||||
> *only* artifact" archive-block condition does not apply. The flat spec travels with the archived
|
||||
> folder as part of the audit trail.
|
||||
|
||||
## 4. Domains synced & requirement delta
|
||||
|
||||
| Domain | Change-side delta | Canonical | Action |
|
||||
|---|---|---|---|
|
||||
| `service-storage` | `specs/service-storage/spec.md` | `openspec/specs/service-storage/spec.md` | **NEW domain** — pure ADDED (28 requirements) |
|
||||
|
||||
- **ADDED (28)** — all to the new `service-storage` domain (canonical did not exist pre-change). IDs
|
||||
and text preserved verbatim from the verified flat `spec.md`. Grouped logically:
|
||||
- *ServiceDataHarness (lifecycle layer)* — SS-101, SS-102, SS-103, SS-104
|
||||
- *QbittorrentSampleStore* — SS-105, SS-106, SS-107
|
||||
- *QbittorrentClient* — SS-108, SS-109, SS-110
|
||||
- *qBittorrent widget source adapter* — SS-111, SS-112, SS-113, SS-114, SS-115
|
||||
- *qBittorrent frontend widgets* — SS-116, SS-117, SS-118
|
||||
- *MediaIndex migration onto harness* — SS-119, SS-120, SS-121, SS-122, SS-123, SS-124
|
||||
- *Cascade-delete wiring* — SS-125, SS-126
|
||||
- *Test and build greenness* — SS-127, SS-128
|
||||
- **MODIFIED (0)** · **REMOVED (0)** · **RENAMED (0)** — new domain; nothing destructive.
|
||||
|
||||
> No destructive-merge guard or parent approval was triggered (zero REMOVED / zero MODIFIED). The
|
||||
> new `service-storage` domain is distinct from the existing `web-ui` and `prometheus-charting`
|
||||
> canonical domains, neither of which was touched.
|
||||
|
||||
## 5. Final lifecycle status (all 8 phases done)
|
||||
|
||||
| Phase | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Proposal | ✅ done | `proposal.md` |
|
||||
| Spec | ✅ done | flat `spec.md` (28) + domain delta `specs/service-storage/spec.md` (28 ADDED) |
|
||||
| Design | ✅ done | `design.md` |
|
||||
| Tasks | ✅ done | `tasks.md` — **35/35** checked, zero `- [ ]` |
|
||||
| Apply | ✅ done | 4 slices delivered (`e7bd0af`, `1fb12b8`, `c87f398`, `75c949a`) |
|
||||
| Verify | ✅ PASS | `verify-report.md` — 28/28 PASS; gates green |
|
||||
| Sync | ✅ done | `sync-report.md` — SYNCED; canonical `service-storage` domain created |
|
||||
| Archive | ✅ done | this report + folder move performed |
|
||||
|
||||
## 6. Gate results (per verify-report; head `c9404f0`)
|
||||
|
||||
| Gate | Command | Result |
|
||||
|---|---|---|
|
||||
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** — 322 passed (2 pre-existing warnings) |
|
||||
| Backend lint | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** — All checks passed |
|
||||
| Frontend build | `cd frontend && npm run build` | **PASS** — exit 0 (pre-existing chunk-size warning) |
|
||||
| Frontend lint | `cd frontend && npm run lint` | **PASS** — 0 errors (1 pre-existing warning) |
|
||||
| Extraction non-regression | `npx vitest run …/PrometheusChartWidget.test.tsx` | **PASS** — 4 passed (SS-118) |
|
||||
| New FE widget/chart tests | `npx vitest run …/widgets/__tests__ …/LineSeriesChart.test.tsx` | **PASS** — 8 files, 31 tests passed |
|
||||
|
||||
## 7. Carry-over follow-ups (non-blocking; recorded per verify/apply-progress)
|
||||
|
||||
1. **[WARNING] Slice-2 review-budget variance** — slice 2 (`1fb12b8`) lands **~644 non-test source
|
||||
lines** (qBit widget adapter + `LineSeriesChart` extraction + 3 FE widgets), above the 400-line
|
||||
per-slice budget (`openspec/config.yaml`) and above the slice-2 "~350–400" forecast. No
|
||||
`size:exception` was recorded. This is a forecast-vs-actual variance on an **additive** slice —
|
||||
the boundary is exactly the qBit-widget feature (no unrelated files, no scope creep). Non-blocking;
|
||||
recorded for the record (could have split extraction into its own slice). The verify agent rated
|
||||
it WARNING, not blocking.
|
||||
2. **[INFO] Slice-2 commit-hash drift** — the brief/apply-progress cited `8b0e7ea` (an earlier amend
|
||||
state); the actual landed commit is `1fb12b8`. Content matches spec/design/tasks. Informational.
|
||||
3. **[INFO] Stale generated `.pi-map.md`** — generated project-map artifacts (`.pi-map.md` /
|
||||
`.pi-map.index.md`) predate the new modules (`service_data.py`, `qbittorrent_store.py`,
|
||||
`clients/qbittorrent.py`, `Qbittorrent*.tsx`, `LineSeriesChart.tsx`). These are **generated
|
||||
artifacts, not deliverable source**, and are out of scope for this change — regenerate via
|
||||
`project_map_patch` / `project_map_validate` in a separate housekeeping pass; the project-map
|
||||
protocol already flags these `dirty`.
|
||||
4. **[INFO] `LineSeriesChart.test.tsx` smoke-only** — asserts the component mounts
|
||||
(`container.firstChild` non-null) but does not assert the recharts `<Line>` SVG series rendered.
|
||||
Acceptable as a crash-guard; non-blocking coverage note.
|
||||
5. **[INFO] qBit store prune edge case** — `QbittorrentSampleStore.append` prunes via
|
||||
`ts NOT IN (SELECT ts … LIMIT 120)`; in the degenerate case of two samples sharing an identical
|
||||
`ts` the keep-set could exceed `MAX_SAMPLES=120`. At a 5 s poll the probability is effectively nil
|
||||
and the per-service cap holds in all realistic operation. Non-blocking.
|
||||
6. **[INFO] Orthogonal dirty working-tree items** — uncommitted cosmetic reformat of
|
||||
`frontend/src/pages/service-tabs/MediaTab.tsx` and untracked `.pi-tmp/*` (predating/orthogonal to
|
||||
this change) were **not touched** by this archive.
|
||||
|
||||
## 8. Residual risks & destructive-merge statement
|
||||
|
||||
- **Destructive sync / merge:** **not applicable.** Zero REMOVED and zero MODIFIED requirements (new
|
||||
`service-storage` domain; pure ADDED). No destructive-merge guard or parent approval was triggered.
|
||||
- **Backend / data-contract impact:** none beyond the planned change. The `ServiceDataHarness` is
|
||||
lifecycle-only (provisioning, idempotent migrations, `service_id` cascade-delete) with **no**
|
||||
generic value table or CRUD. The load-bearing MediaIndex migration (SS-119..SS-124) is correct:
|
||||
`replace_items` is scoped `WHERE service_id = ?` (fixing the latent global-clear bug), with a real
|
||||
regression test; the `media_index.db` file location is unchanged; existing callers are
|
||||
backward-compatible via the `service_id=""` default. Archive touched only OpenSpec docs + the folder
|
||||
move.
|
||||
- **No critical verification issues** remain (CRITICAL issues are non-overridable; the one verify
|
||||
CRITICAL was the reconcilable checkbox/apply-progress gap, now resolved).
|
||||
- **No browser/visual smoke** was performed (out of scope); the qBit speed recharts line and the
|
||||
Prometheus/gauge/mean charts are only structurally tested.
|
||||
- **Memory observation IDs:** none — `artifactStore: openspec`; traceability lives in the filesystem
|
||||
archive + canonical spec.
|
||||
|
||||
## 9. Move performed
|
||||
|
||||
```
|
||||
git mv openspec/changes/service-storage-harness openspec/changes/archive/2026-07-09-service-storage-harness
|
||||
```
|
||||
|
||||
- **All 9 artifacts confirmed present at the archived path:** `proposal.md`, `spec.md` (flat),
|
||||
`specs/service-storage/spec.md` (delta), `design.md`, `tasks.md`, `apply-progress.md`,
|
||||
`verify-report.md`, `sync-report.md`, `archive-report.md` (this file).
|
||||
- Of the 9, **8 were git-tracked** (renamed by `git mv`, preserving history via git rename
|
||||
detection) and **1** (`archive-report.md`) is newly written and traveled with the directory move.
|
||||
- **Canonical `openspec/specs/service-storage/spec.md` remains in place** — verified byte-identical
|
||||
(sha256 `6629e307…`) before and after the move. `openspec/specs/web-ui/spec.md` and
|
||||
`openspec/specs/prometheus-charting/spec.md` also untouched.
|
||||
- **Not committed / not pushed** — the parent owns the commit with explicit paths.
|
||||
|
||||
---
|
||||
|
||||
### Appendix — Files written/moved by this archive (OpenSpec only; no source code)
|
||||
|
||||
- **Written:** `openspec/changes/service-storage-harness/archive-report.md` (this file) — at the
|
||||
active path before the move; travels with the move into the archive.
|
||||
- **Moved (via `git mv`):** the entire
|
||||
`openspec/changes/service-storage-harness/` directory →
|
||||
`openspec/changes/archive/2026-07-09-service-storage-harness/`.
|
||||
- **Left in place (durable canonical):** `openspec/specs/service-storage/spec.md`.
|
||||
- **Not committed / not pushed** — the parent owns the commit with explicit paths.
|
||||
@@ -0,0 +1,863 @@
|
||||
# SDD Design: Service Storage Harness (with qBittorrent widgets + MediaIndex migration)
|
||||
|
||||
**Change:** `service-storage-harness`
|
||||
**Phase:** design
|
||||
**Date:** 2026-07-09
|
||||
|
||||
> Grounded in `proposal.md` (Q1–Q5 resolved) and the already-archived
|
||||
> `prometheus-direct-charting` change. No source changes in this phase.
|
||||
|
||||
## 0. Source findings (read before anything else)
|
||||
|
||||
The proposal was written against a stale project map. Reading actual source
|
||||
surfaced three findings that shape the design. Trust source, not the map.
|
||||
|
||||
### 0.1 The media worker ALREADY threads `service_id` — but it is NOT persisted in rows
|
||||
|
||||
`workers/media_index_worker.py` already accepts `--service-id` via argparse
|
||||
(`run_build(final_index_path, staging_index_path, service_id="")`), and
|
||||
`_resolve_jellyfin(service_id)` resolves the Jellyfin client + user_id from the
|
||||
settings store. The media router's `_start_worker(index, service_id)` and
|
||||
`post_build_index` already pass `jellyfin_service_id` through to the worker.
|
||||
|
||||
**But:** `service_id` is used ONLY to pick the Jellyfin connection. It is never
|
||||
stored in the `media_items` table (which has no `service_id` column). The
|
||||
migration must close this gap: `replace_items` must scope its DELETE+INSERT by
|
||||
`service_id`, and `query` must filter by it. The plumbing to get `service_id`
|
||||
into the worker already exists — only the storage layer is missing.
|
||||
|
||||
### 0.2 `media_items` is cleared globally on every build
|
||||
|
||||
`replace_items` does `DELETE FROM media_items` (no WHERE clause). This means a
|
||||
build for Jellyfin instance A wipes instance B's rows. After migration this
|
||||
becomes `DELETE FROM media_items WHERE service_id = ?`.
|
||||
|
||||
### 0.3 The `delete_service` cascade already exists — the harness hooks into it
|
||||
|
||||
`SettingsStore.delete_service` already cascade-deletes `dashboard_widgets WHERE
|
||||
service_id = ?` (with a PRAGMA-guarded column check). The harness cascade-delete
|
||||
hooks into the same place: after the service row is deleted, iterate registered
|
||||
concerns and delete that `service_id` from each owned table.
|
||||
|
||||
### 0.4 DB path is a module constant, not in config.py
|
||||
|
||||
`DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")` in
|
||||
`media_index_impl.py`. The file stays at this path (per the locked topology
|
||||
decision). The harness must not move it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ ServiceDataHarness │
|
||||
│ (services/service_data.py — LIFECYCLE ONLY) │
|
||||
│ │
|
||||
│ • register_concern(db_filename, migrations[], tables[], │
|
||||
│ service_id_column="service_id") │
|
||||
│ • run_migrations() — on startup, per concern DB │
|
||||
│ • cascade_delete(service_id) — iterate concerns, DELETE rows │
|
||||
│ • connect(db_filename) → sqlite3.Connection (per-concern) │
|
||||
└──────────────┬───────────────────────────┬───────────────────────┘
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌─────────▼──────────────┐
|
||||
│ QbittorrentStore │ │ MediaIndex │
|
||||
│ (services/ │ │ (services/ │
|
||||
│ qbittorrent_store) │ │ media_index_impl) │
|
||||
│ │ │ │
|
||||
│ qbittorrent.db │ │ media_index.sqlite │
|
||||
│ └ qbittorrent_ │ │ └ media_items │
|
||||
│ speed_samples │ │ (+ service_id col) │
|
||||
│ (service_id, ts, │ │ └ index_metadata │
|
||||
│ dl_speed, │ │ │
|
||||
│ up_speed) │ │ bespoke: replace_items │
|
||||
│ │ │ (scoped), query │
|
||||
│ bespoke: append, │ │ (scoped), status │
|
||||
│ window, prune │ │ │
|
||||
└──────────────────────┘ └────────────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ QbittorrentClient │
|
||||
│ (clients/ │
|
||||
│ qbittorrent) │
|
||||
│ │
|
||||
│ login → cookie │
|
||||
│ sync/maindata │
|
||||
│ (totals + active + │
|
||||
│ speeds) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
**Key constraints carried from the proposal:**
|
||||
|
||||
- Harness is lifecycle-only: migrations, service_id scoping, cascade-delete. No generic value table, no generic CRUD (D2).
|
||||
- Per-concern DB files: `media_index.sqlite` stays put; new `qbittorrent.db` (D4).
|
||||
- MediaIndex migration is sequenced after harness + qBit are proven (D3).
|
||||
- qBit speed chart reuses the `PrometheusChartWidget` recharts renderer fed from an InService data path returning `{series}` (Q1).
|
||||
- Totals = count of listed items, NOT transfer bytes (Q2).
|
||||
- Active = state downloading|uploading (Q3). N instances (Q4). Username/password → cookie (Q5).
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend design
|
||||
|
||||
### 2.1 `ServiceDataHarness` (`services/service_data.py`)
|
||||
|
||||
A lifecycle-only registry of storage concerns. Each concern declares its own DB
|
||||
filename, ordered migrations, owned tables, and the column used for service
|
||||
scoping.
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class StorageConcern:
|
||||
"""A per-integration storage namespace registered with the harness."""
|
||||
|
||||
concern_key: str # e.g. "qbittorrent", "media_index"
|
||||
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
|
||||
migrations: list[str] # ordered CREATE/ALTER statements (idempotent)
|
||||
tables: list[str] # tables owned by this concern (for cascade)
|
||||
service_id_column: str = "service_id"
|
||||
|
||||
|
||||
class ServiceDataHarness:
|
||||
"""Lifecycle-only registry of per-concern storage.
|
||||
|
||||
Owns: DB provisioning, per-concern migrations, service_id cascade-delete.
|
||||
Does NOT own: data operations (each store keeps bespoke append/window/query/etc.).
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path) -> None:
|
||||
self._base_dir = Path(base_dir)
|
||||
self._concerns: dict[str, StorageConcern] = {}
|
||||
|
||||
def register(self, concern: StorageConcern) -> None:
|
||||
"""Register a storage concern. Called at module import / startup."""
|
||||
self._concerns[concern.concern_key] = concern
|
||||
|
||||
def db_path(self, concern_key: str) -> Path:
|
||||
"""Return the absolute path to a concern's DB file."""
|
||||
concern = self._concerns[concern_key]
|
||||
return self._base_dir / concern.db_filename
|
||||
|
||||
def connect(self, concern_key: str) -> sqlite3.Connection:
|
||||
"""Open a WAL-mode connection to a concern's DB."""
|
||||
path = self.db_path(concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
def run_migrations(self) -> None:
|
||||
"""Run pending migrations for every registered concern."""
|
||||
for concern in self._concerns.values():
|
||||
path = self.db_path(concern.concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(path, timeout=30) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript(";".join(concern.migrations))
|
||||
|
||||
def cascade_delete(self, service_id: str) -> None:
|
||||
"""Delete all rows for a service_id across every concern's tables.
|
||||
|
||||
Called from SettingsStore.delete_service after the service row is removed.
|
||||
"""
|
||||
for concern in self._concerns.values():
|
||||
col = concern.service_id_column
|
||||
with sqlite3.connect(self.db_path(concern.concern_key), timeout=30) as conn:
|
||||
for table in concern.tables:
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if col in cols:
|
||||
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
|
||||
```
|
||||
|
||||
**Module-level singleton + registration:**
|
||||
|
||||
```python
|
||||
_HARNESS: ServiceDataHarness | None = None
|
||||
|
||||
def get_service_data_harness() -> ServiceDataHarness:
|
||||
global _HARNESS
|
||||
if _HARNESS is None:
|
||||
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
|
||||
_HARNESS = ServiceDataHarness(base_dir)
|
||||
# Register built-in concerns (each store module calls register on import)
|
||||
_HARNESS.register(QBITTORRENT_CONCERN)
|
||||
_HARNESS.register(MEDIA_INDEX_CONCERN)
|
||||
_HARNESS.run_migrations()
|
||||
return _HARNESS
|
||||
```
|
||||
|
||||
**Startup hook:** called from `main.py` lifespan alongside `get_settings_store().ensure_defaults()`. The harness is lazy-initialized on first access (like `SettingsStore`), so tests can override the base dir via env.
|
||||
|
||||
### 2.2 `QbittorrentSampleStore` (`services/qbittorrent_store.py`)
|
||||
|
||||
Speed-sample storage for the qBit speed widget. Registered as a concern with the
|
||||
harness.
|
||||
|
||||
```python
|
||||
QBITTORRENT_CONCERN = StorageConcern(
|
||||
concern_key="qbittorrent",
|
||||
db_filename="qbittorrent.db",
|
||||
migrations=[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples (
|
||||
service_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
dl_speed INTEGER NOT NULL DEFAULT 0,
|
||||
up_speed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts
|
||||
ON qbittorrent_speed_samples(service_id, ts);
|
||||
"""
|
||||
],
|
||||
tables=["qbittorrent_speed_samples"],
|
||||
)
|
||||
|
||||
MAX_SAMPLES = 120 # ~2 min at 1s poll, ~4 min at 2s poll
|
||||
|
||||
|
||||
class QbittorrentSampleStore:
|
||||
"""Bespoke speed-sample store for qBittorrent widgets."""
|
||||
|
||||
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
|
||||
self._harness = harness or get_service_data_harness()
|
||||
|
||||
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
|
||||
"""Append a sample and prune old entries beyond MAX_SAMPLES."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
|
||||
(service_id, ts, dl_speed, up_speed),
|
||||
)
|
||||
# Prune: keep only the most recent MAX_SAMPLES rows for this service
|
||||
conn.execute(
|
||||
"""DELETE FROM qbittorrent_speed_samples
|
||||
WHERE service_id = ? AND ts NOT IN (
|
||||
SELECT ts FROM qbittorrent_speed_samples
|
||||
WHERE service_id = ?
|
||||
ORDER BY ts DESC LIMIT ?
|
||||
)""",
|
||||
(service_id, service_id, MAX_SAMPLES),
|
||||
)
|
||||
|
||||
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return all samples for a service since a timestamp (or all if None)."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
if since_ts is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? AND ts >= ? ORDER BY ts ASC",
|
||||
(service_id, since_ts),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC",
|
||||
(service_id,),
|
||||
).fetchall()
|
||||
return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows]
|
||||
```
|
||||
|
||||
### 2.3 `QbittorrentClient` (`clients/qbittorrent.py`)
|
||||
|
||||
Cookie-session HTTP client modeled on `JellyfinClient`'s session pattern.
|
||||
|
||||
```python
|
||||
class QbittorrentClient:
|
||||
"""Minimal qBittorrent Web API client (read-only: sync/maindata only)."""
|
||||
|
||||
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if not self.base_url.endswith("/api/v2"):
|
||||
self.base_url += "/api/v2"
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._timeout = timeout
|
||||
self._session = requests.Session()
|
||||
self._logged_in = False
|
||||
|
||||
def _login(self) -> None:
|
||||
"""POST username/password to /auth/login; store the SID cookie."""
|
||||
resp = self._session.post(
|
||||
f"{self.base_url}/auth/login",
|
||||
data={"username": self._username, "password": self._password},
|
||||
timeout=self._timeout,
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
if resp.text.strip() != "Ok.":
|
||||
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
|
||||
self._logged_in = True
|
||||
|
||||
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||
"""GET with auto re-login on 403."""
|
||||
if not self._logged_in:
|
||||
self._login()
|
||||
url = f"{self.base_url}{path}"
|
||||
resp = self._session.get(url, params=params, timeout=self._timeout)
|
||||
if resp.status_code == 403:
|
||||
self._logged_in = False
|
||||
self._login()
|
||||
resp = self._session.get(url, params=params, timeout=self._timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def maindata(self) -> dict[str, Any]:
|
||||
"""Fetch /sync/maindata — returns server_state + torrents dict.
|
||||
|
||||
server_state contains: dl_info_speed (bytes/s), up_info_speed (bytes/s), etc.
|
||||
torrents is a dict of {hash: {name, state, progress, ...}}.
|
||||
"""
|
||||
return self._get("/sync/maindata")
|
||||
```
|
||||
|
||||
**Endpoints used (all from `/sync/maindata` — single call covers all three widgets):**
|
||||
|
||||
| Widget | Data extracted from `maindata()` |
|
||||
|---|---|
|
||||
| **totals** | `len(response["torrents"])` — count of all listed torrents |
|
||||
| **active** | `filter(t for t in response["torrents"].values() if t["state"] in {"downloading","uploading"})` |
|
||||
| **speed** | `response["server_state"]["dl_info_speed"]` + `["up_info_speed"]` (current instant speed, appended to store) |
|
||||
|
||||
**Note:** `/api/v2/transfer/info` is NOT needed (totals = item count per Q2, not transfer bytes).
|
||||
|
||||
### 2.4 Widget source adapter — `QbittorrentWidgetSource`
|
||||
|
||||
Lives in `widgets/sources.py`, implements `WidgetSource.fetch(service, widget_kind, config)`.
|
||||
Resolves the client inline from `ServiceRecord` (like `PrometheusWidgetSource` does — no new
|
||||
dependency-injection helper needed).
|
||||
|
||||
```python
|
||||
class QbittorrentWidgetSource:
|
||||
"""Fetch qBittorrent data for totals, active, and speed widgets."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "qBittorrent widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "")
|
||||
username = str(service.secrets.get("username") or "")
|
||||
password = str(service.secrets.get("password") or "")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
if not base_url or not username or not password:
|
||||
return {"error": "qBittorrent service is missing base_url, username, or password"}
|
||||
|
||||
client = QbittorrentClient(base_url, username, password, timeout)
|
||||
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
|
||||
server_state = data.get("server_state", {})
|
||||
torrents = data.get("torrents", {})
|
||||
|
||||
if widget_kind == "totals":
|
||||
# Q2: count of listed items, broken down by state
|
||||
by_state: dict[str, int] = {}
|
||||
for t in torrents.values():
|
||||
state = str(t.get("state", "unknown"))
|
||||
by_state[state] = by_state.get(state, 0) + 1
|
||||
return {"total": len(torrents), "by_state": by_state}
|
||||
|
||||
if widget_kind == "active":
|
||||
# Q3: downloading or uploading only
|
||||
active = [
|
||||
{"name": t.get("name"), "state": t.get("state"),
|
||||
"size": t.get("size"), "progress": t.get("progress"),
|
||||
"dl_speed": t.get("dlspeed"), "up_speed": t.get("upspeed")}
|
||||
for t in torrents.values()
|
||||
if str(t.get("state", "")) in {"downloading", "uploading"}
|
||||
]
|
||||
return {"torrents": active}
|
||||
|
||||
if widget_kind == "speed":
|
||||
# Q1: append sample + return window as {series} shape
|
||||
# matching PrometheusChartWidget's expected format
|
||||
dl = int(server_state.get("dl_info_speed", 0))
|
||||
up = int(server_state.get("up_info_speed", 0))
|
||||
ts = int(time.time())
|
||||
store = QbittorrentSampleStore()
|
||||
store.append(service.id, ts, dl, up)
|
||||
samples = store.window(service.id)
|
||||
series = [
|
||||
{"label": "download",
|
||||
"points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
|
||||
{"label": "upload",
|
||||
"points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
|
||||
]
|
||||
return {"series": series}
|
||||
|
||||
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "qBittorrent data fetch timed out"}
|
||||
except Exception as exc:
|
||||
logger.exception("qbittorrent adapter failed")
|
||||
return {"error": f"qBittorrent fetch failed: {exc}"}
|
||||
```
|
||||
|
||||
**Registered in `SERVICE_ADAPTERS`:**
|
||||
|
||||
```python
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"qbittorrent": QbittorrentWidgetSource(), # NEW
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"ssh_tasks": SshTaskWidgetSource(),
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 Integration registration — `integrations/qbittorrent.py`
|
||||
|
||||
Models config + secret schema on `prometheus.py`.
|
||||
|
||||
```python
|
||||
class QbittorrentConfig(ServiceConfigBase):
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
# No per-widget config needed for any of the three kinds
|
||||
# (all derive from the service connection).
|
||||
class QbittorrentWidgetConfig(WidgetConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="qbittorrent",
|
||||
name="qBittorrent",
|
||||
description="Torrent client activity, speeds, and item counts.",
|
||||
config_model=QbittorrentConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="username", label="Username", required=True),
|
||||
SecretField(key="password", label="Password", required=True, helper="Stored encrypted"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(kind="totals", name="Totals",
|
||||
description="Count of all listed torrents, broken down by state.",
|
||||
model_cls=QbittorrentWidgetConfig, default_config={},
|
||||
refresh_interval_ms=30_000),
|
||||
widget_kind(kind="active", name="Active torrents",
|
||||
description="Torrents currently downloading or uploading.",
|
||||
model_cls=QbittorrentWidgetConfig, default_config={},
|
||||
refresh_interval_ms=15_000),
|
||||
widget_kind(kind="speed", name="Speed chart",
|
||||
description="Live download/upload speed over a short window.",
|
||||
model_cls=QbittorrentWidgetConfig, default_config={},
|
||||
refresh_interval_ms=5_000),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
**Registered in `integrations/registry.py`:**
|
||||
|
||||
```python
|
||||
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
|
||||
SERVICE_DEFINITIONS["qbittorrent"] = QBITTORRENT
|
||||
```
|
||||
|
||||
### 2.6 Cascade-delete wiring
|
||||
|
||||
`SettingsStore.delete_service` gains a harness call after the service row is
|
||||
deleted:
|
||||
|
||||
```python
|
||||
def delete_service(self, service_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
# existing widget cascade ...
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||
# NEW: cascade-delete harness-managed data
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
get_service_data_harness().cascade_delete(service_id)
|
||||
except Exception:
|
||||
logger.exception("harness cascade-delete failed for service %s", service_id)
|
||||
```
|
||||
|
||||
The try/except guard prevents a harness failure from blocking service deletion
|
||||
(data cleanup is best-effort; the service row is already gone).
|
||||
|
||||
---
|
||||
|
||||
## 3. MediaIndex migration design (load-bearing)
|
||||
|
||||
### 3.1 Schema migration: add `service_id` column
|
||||
|
||||
The `init_schema` method's `CREATE TABLE IF NOT EXISTS` gains the new column.
|
||||
For existing databases, a migration adds it:
|
||||
|
||||
```sql
|
||||
-- In MEDIA_INDEX_CONCERN.migrations (runs via harness on startup):
|
||||
-- The init_schema already creates the table for new installs WITH service_id.
|
||||
-- This migration handles existing DBs that lack the column.
|
||||
|
||||
-- media_index_impl.py init_schema: add service_id to the CREATE TABLE columns.
|
||||
-- Harness migration (runs on existing DBs):
|
||||
ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
The `DEFAULT ''` backfills all existing rows to empty string (the legacy
|
||||
sentinel — see §3.3 for how this is resolved).
|
||||
|
||||
### 3.2 `replace_items` — scoped delete + insert
|
||||
|
||||
```python
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]], service_id: str = "") -> int:
|
||||
self.init_schema()
|
||||
row_list = list(rows)
|
||||
# ... columns list gains "service_id" ...
|
||||
with self.connect() as conn:
|
||||
# SCOPED: only delete this service's rows
|
||||
conn.execute("DELETE FROM media_items WHERE service_id = ?", (service_id,))
|
||||
conn.executemany(
|
||||
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
|
||||
[[service_id] + [row.get(column) for column in columns_without_service_id] for row in row_list],
|
||||
)
|
||||
# ... metadata ...
|
||||
return len(row_list)
|
||||
```
|
||||
|
||||
### 3.3 `query` — scoped filter
|
||||
|
||||
```python
|
||||
def query(self, service_id: str = "", ...) -> tuple[list[dict[str, Any]], int]:
|
||||
# ... existing where clauses ...
|
||||
# Add service_id filter: if non-empty, scope; if empty (legacy), show all
|
||||
# (backward-compatibility for the period before multi-instance is wired in UI)
|
||||
if service_id:
|
||||
where.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
# ... rest unchanged ...
|
||||
```
|
||||
|
||||
**Backfill semantics:** existing rows get `service_id = ''` (empty string). When
|
||||
`service_id` is empty string in the query, the filter is skipped, so the Media
|
||||
page shows all items (backward-compatible behavior). When a specific Jellyfin
|
||||
service triggers a rebuild, `replace_items(rows, service_id=that_service)` scopes
|
||||
the delete + insert. New builds set the real service_id; legacy rows remain
|
||||
visible until a rebuild replaces them.
|
||||
|
||||
### 3.4 `build_media_index` — thread service_id
|
||||
|
||||
`build_media_index` already receives no `service_id` today. Add it as a
|
||||
parameter and pass it through to `replace_items`:
|
||||
|
||||
```python
|
||||
def build_media_index(
|
||||
client, user_id, libraries, index=None, page_size=500,
|
||||
media_root="", fallback_prefix="",
|
||||
progress_callback=None, should_cancel=None,
|
||||
service_id: str = "", # NEW
|
||||
) -> int:
|
||||
# ... existing logic ...
|
||||
# Pass service_id to replace_items:
|
||||
processed_total = index.replace_items(normalized_rows, service_id=service_id)
|
||||
```
|
||||
|
||||
### 3.5 Worker — already threads `service_id`, just pass it to `build_media_index`
|
||||
|
||||
The worker's `run_build(final_index_path, staging_index_path, service_id="")`
|
||||
already receives `service_id` from argparse. The only change: pass it to
|
||||
`build_media_index(..., service_id=service_id)`.
|
||||
|
||||
### 3.6 Media router — thread `service_id` into `query_media`
|
||||
|
||||
`query_media` already resolves `client: JellyfinClient = Depends(get_jellyfin_client)`.
|
||||
The service_id is available via request query param (the dependency layer resolves
|
||||
it from `?jellyfin_service_id=...`). Add it to the query call:
|
||||
|
||||
```python
|
||||
@router.get("/query")
|
||||
def query_media(
|
||||
...,
|
||||
jellyfin_service_id: str | None = None, # already available pattern
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
...
|
||||
rows, total = index.query(service_id=jellyfin_service_id or "", ...)
|
||||
```
|
||||
|
||||
### 3.7 MediaIndex concern registration
|
||||
|
||||
```python
|
||||
MEDIA_INDEX_CONCERN = StorageConcern(
|
||||
concern_key="media_index",
|
||||
db_filename="media_index.sqlite", # SAME FILE, unchanged path
|
||||
migrations=[
|
||||
"ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''",
|
||||
# This ALTER is idempotent-safe: init_schema creates the table WITH
|
||||
# service_id for new installs; this migration adds it to existing DBs.
|
||||
# SQLite ALTER TABLE ADD COLUMN is a no-op if the column already exists
|
||||
# (we guard with a PRAGMA check in run_migrations, or catch the error).
|
||||
],
|
||||
tables=["media_items"],
|
||||
)
|
||||
```
|
||||
|
||||
**IMPORTANT — ALTER TABLE idempotency:** SQLite raises an error if the column
|
||||
already exists. The `run_migrations` method should catch this per-statement or
|
||||
pre-check via `PRAGMA table_info`. Design choice: wrap each migration in a
|
||||
try/except for "duplicate column name" errors:
|
||||
|
||||
```python
|
||||
def run_migrations(self) -> None:
|
||||
for concern in self._concerns.values():
|
||||
path = self.db_path(concern.concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(path, timeout=30) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
for stmt in concern.migrations:
|
||||
try:
|
||||
conn.executescript(stmt)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "duplicate column name" not in str(exc).lower():
|
||||
raise
|
||||
```
|
||||
|
||||
### 3.8 File location confirmation
|
||||
|
||||
`DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")` —
|
||||
unchanged. The harness `base_dir` defaults to `Path(".cache/media_library_viewer")`
|
||||
(the same parent). The `db_filename = "media_index.sqlite"` matches. No data move.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend design
|
||||
|
||||
### 4.1 Three widget components
|
||||
|
||||
All three live in `frontend/src/widgets/`, modeled on existing patterns
|
||||
(`AlertmanagerAlertsWidget`, `PrometheusChartWidget`, `MetricCard`).
|
||||
|
||||
**`QbittorrentTotalsWidget.tsx`** — MetricCard-style count tile:
|
||||
|
||||
```tsx
|
||||
// Renders {total: number, by_state: {...}} from useWidgetData.
|
||||
// Uses SectionCard + numeric display (like BackupsWidget / MetricCard).
|
||||
// Shows total count prominently + state breakdown badges.
|
||||
```
|
||||
|
||||
**`QbittorrentActiveTorrentsWidget.tsx`** — active torrents list:
|
||||
|
||||
```tsx
|
||||
// Renders {torrents: [{name, state, size, progress, dl_speed, up_speed}]}.
|
||||
// Uses SectionCard + a compact list/table (DataTable or manual Table rows).
|
||||
// Shows name, state badge, progress bar, speeds. Max 10 rows with scroll.
|
||||
```
|
||||
|
||||
**`QbittorrentSpeedWidget.tsx`** — speed chart reusing Change A's renderer:
|
||||
|
||||
```tsx
|
||||
// Renders {series: [{label, points:[{t,v}]}]} — IDENTICAL to PrometheusChartWidget.
|
||||
// Two options:
|
||||
// (a) Import PrometheusChartWidget directly and pass props (if its props accept
|
||||
// the series externally rather than via useWidgetData).
|
||||
// (b) Extract the recharts rendering into a shared <LineSeriesChart series={...} />
|
||||
// component that both PrometheusChartWidget and QbittorrentSpeedWidget use.
|
||||
//
|
||||
// RECOMMENDED: option (b) — extract a shared LineSeriesChart component (~40 lines)
|
||||
// into frontend/src/components/LineSeriesChart.tsx. Both widgets call useWidgetData
|
||||
// independently (different refresh intervals) but share the renderer.
|
||||
```
|
||||
|
||||
**`LineSeriesChart.tsx`** (shared renderer extraction):
|
||||
|
||||
```tsx
|
||||
// Extracts: mergeSeries, formatTime, CHART_COLORS, and the <ResponsiveContainer>
|
||||
// + <LineChart> JSX from PrometheusChartWidget.
|
||||
// Props: { series: ChartSeries[], height?: number }
|
||||
// PrometheusChartWidget becomes a thin wrapper: useWidgetData → <LineSeriesChart series={data.data.series} />
|
||||
// QbittorrentSpeedWidget: same pattern, different refresh interval (5s vs 60s).
|
||||
```
|
||||
|
||||
This keeps the recharts rendering in ONE place (no duplication) while letting each
|
||||
widget own its polling lifecycle.
|
||||
|
||||
### 4.2 Frontend registry binding (`integrations/registry.ts`)
|
||||
|
||||
```typescript
|
||||
qbittorrent: {
|
||||
serviceType: "qbittorrent",
|
||||
name: "qBittorrent",
|
||||
description: "Torrent client activity, speeds, and item counts.",
|
||||
widgets: [
|
||||
{ kind: "totals", name: "Totals", description: "...",
|
||||
refreshIntervalMs: 30_000, defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentTotalsWidget },
|
||||
{ kind: "active", name: "Active torrents", description: "...",
|
||||
refreshIntervalMs: 15_000, defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentActiveTorrentsWidget },
|
||||
{ kind: "speed", name: "Speed chart", description: "...",
|
||||
refreshIntervalMs: 5_000, defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentSpeedWidget },
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
### 4.3 Types
|
||||
|
||||
No new TypeScript types needed for widget payloads — data flows through the
|
||||
existing `WidgetDataResponse` + `useWidgetData` polling. The series shape
|
||||
(`{series:[{label,points:[{t,v}]}]}`) is already used by `PrometheusChartWidget`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tests
|
||||
|
||||
### 5.1 Backend tests
|
||||
|
||||
| Test file | Coverage |
|
||||
|---|---|
|
||||
| `backend/tests/test_service_data.py` (NEW) | Harness: register, run_migrations (creates tables), cascade_delete (removes rows by service_id), migration idempotency (ALTER doesn't crash on re-run) |
|
||||
| `backend/tests/test_qbittorrent_store.py` (NEW) | Store: append + prune (MAX_SAMPLES cap), window (returns samples in order), service_id isolation (two services don't cross-contaminate) |
|
||||
| `backend/tests/test_qbittorrent_client.py` (NEW) | Client: login flow (POST /auth/login → Ok.), cookie reuse, 403 → re-login, maindata parsing, timeout handling |
|
||||
| `backend/tests/test_widgets.py` (extend) | `QbittorrentWidgetSource`: totals (counts all torrents), active (filters state), speed (appends sample + returns {series}), missing-service error, timeout error |
|
||||
|
||||
### 5.2 Frontend tests
|
||||
|
||||
| Test file | Coverage |
|
||||
|---|---|
|
||||
| `QbittorrentTotalsWidget.test.tsx` (NEW) | Loading skeleton, error alert, rendered count + state badges |
|
||||
| `QbittorrentActiveTorrentsWidget.test.tsx` (NEW) | Loading, error, rendered torrent rows |
|
||||
| `QbittorrentSpeedWidget.test.tsx` (NEW) | Loading, error, rendered chart (series present) |
|
||||
| `LineSeriesChart.test.tsx` (NEW) | Renders lines from series data, empty state |
|
||||
|
||||
### 5.3 Existing MediaIndex tests must stay green
|
||||
|
||||
`backend/tests/test_media_index.py` exercises `replace_items`, `query`, `status`.
|
||||
After migration, these call with the new `service_id=""` default (backward-compatible).
|
||||
The tests pass unchanged because empty-string service_id shows all rows.
|
||||
|
||||
---
|
||||
|
||||
## 6. Slice plan (for tasks.md)
|
||||
|
||||
Four slices, each ≤400 changed lines, each leaving `pytest` + `npm run build` +
|
||||
`npm run lint` green. Slices 1–2 prove the harness; Slice 3 migrates MediaIndex;
|
||||
Slice 4 wires cascade-delete end-to-end.
|
||||
|
||||
### Slice 1: Harness + QbittorrentSampleStore + QbittorrentClient + integration (~280–350 lines)
|
||||
|
||||
**Files:**
|
||||
|
||||
- `backend/src/media_library_viewer_api/services/service_data.py` (NEW — harness + StorageConcern)
|
||||
- `backend/src/media_library_viewer_api/services/qbittorrent_store.py` (NEW — store + concern)
|
||||
- `backend/src/media_library_viewer_api/clients/qbittorrent.py` (NEW — client)
|
||||
- `backend/src/media_library_viewer_api/integrations/qbittorrent.py` (NEW — definition)
|
||||
- `backend/src/media_library_viewer_api/integrations/registry.py` (MODIFY — add qbittorrent)
|
||||
- `backend/src/media_library_viewer_api/main.py` (MODIFY — call harness init in lifespan)
|
||||
- `backend/tests/test_service_data.py` (NEW)
|
||||
- `backend/tests/test_qbittorrent_store.py` (NEW)
|
||||
- `backend/tests/test_qbittorrent_client.py` (NEW)
|
||||
|
||||
**Exit gate:** harness creates tables + runs migrations + cascade_delete works in
|
||||
tests. qBit client login/maindata tested with mocked HTTP. No frontend changes yet.
|
||||
|
||||
### Slice 2: Widget adapter + frontend widgets + registry binding (~320–400 lines)
|
||||
|
||||
**Files:**
|
||||
|
||||
- `backend/src/media_library_viewer_api/widgets/sources.py` (MODIFY — add QbittorrentWidgetSource + SERVICE_ADAPTERS entry)
|
||||
- `backend/tests/test_widgets.py` (EXTEND — qBit adapter tests)
|
||||
- `frontend/src/components/LineSeriesChart.tsx` (NEW — shared renderer extracted from PrometheusChartWidget)
|
||||
- `frontend/src/widgets/PrometheusChartWidget.tsx` (MODIFY — use LineSeriesChart)
|
||||
- `frontend/src/widgets/QbittorrentTotalsWidget.tsx` (NEW)
|
||||
- `frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx` (NEW)
|
||||
- `frontend/src/widgets/QbittorrentSpeedWidget.tsx` (NEW)
|
||||
- `frontend/src/widgets/index.ts` (MODIFY — barrel exports)
|
||||
- `frontend/src/integrations/registry.ts` (MODIFY — add qbittorrent binding)
|
||||
- `frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx` (NEW)
|
||||
- `frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx` (NEW)
|
||||
- `frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx` (NEW)
|
||||
- `frontend/src/components/__tests__/LineSeriesChart.test.tsx` (NEW)
|
||||
|
||||
**Exit gate:** qBit widgets render loading/error/data states; speed widget shows
|
||||
a recharts line chart from {series}; LineSeriesChart is shared with PrometheusChartWidget.
|
||||
|
||||
### Slice 3: MediaIndex migration onto harness (~200–280 lines)
|
||||
|
||||
**Files:**
|
||||
|
||||
- `backend/src/media_library_viewer_api/services/service_data.py` (MODIFY — register MEDIA_INDEX_CONCERN)
|
||||
- `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY — add service_id to schema, replace_items, query)
|
||||
- `backend/src/media_library_viewer_api/services/media_index.py` (re-export unchanged)
|
||||
- `backend/src/media_library_viewer_api/workers/media_index_worker.py` (MODIFY — pass service_id to build_media_index)
|
||||
- `backend/src/media_library_viewer_api/routers/media.py` (MODIFY — thread jellyfin_service_id into query_media)
|
||||
- `backend/tests/test_media_index.py` (EXTEND — service_id-scoped replace_items + query)
|
||||
|
||||
**Exit gate:** all existing MediaIndex tests pass unchanged (empty-string default);
|
||||
new tests verify scoped delete/insert/query. Media page works identically.
|
||||
|
||||
### Slice 4: Cascade-delete end-to-end + integration test (~80–120 lines)
|
||||
|
||||
**Files:**
|
||||
|
||||
- `backend/src/media_library_viewer_api/services/settings_store.py` (MODIFY — call harness.cascade_delete in delete_service)
|
||||
- `backend/tests/test_services.py` (EXTEND — verify cascade-delete removes qBit samples when service is deleted)
|
||||
|
||||
**Exit gate:** deleting a qBittorrent service removes its speed samples; deleting
|
||||
a Jellyfin service removes its media items.
|
||||
|
||||
---
|
||||
|
||||
## 7. Key design decisions summary
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Harness is lifecycle-only (migrations, service_id, cascade) | n=2 justifies abstraction, but data-ops differ wildly (append/window vs replace_all/query). Generalize only the shared lifecycle. |
|
||||
| D2 | Per-concern DB files via harness `base_dir` + `db_filename` | media_index.sqlite stays put (no data move); qbittorrent.db is new. Separate writers. |
|
||||
| D3 | `run_migrations` catches "duplicate column name" per-statement | SQLite ALTER TABLE ADD COLUMN is not idempotent; migrations must not crash on re-run. |
|
||||
| D4 | Speed widget extracts a shared `LineSeriesChart` component | Both PrometheusChartWidget and QbittorrentSpeedWidget need the same recharts renderer; extract it once (DRY) rather than duplicating or tightly coupling. |
|
||||
| D5 | qBit adapter resolves client inline from ServiceRecord (like PrometheusWidgetSource) | No new dependency-injection helper; the adapter gets config+secrets from the service record. |
|
||||
| D6 | MediaIndex backfill uses `service_id = ''` (empty string) sentinel | Backward-compatible: existing tests + Media page work unchanged; empty-string queries skip the filter (show all). Real service_ids overwrite on next rebuild. |
|
||||
| D7 | `replace_items` deletes `WHERE service_id = ?` instead of all | Fixes the existing global-clear bug (building for one Jellyfin wipes others). |
|
||||
| D8 | Cascade-delete is best-effort (try/except) in delete_service | Harness failure must not block service deletion; data cleanup is non-critical. |
|
||||
| D9 | Speed sample timestamps multiplied by 1000 for frontend | PrometheusChartWidget expects `t` in milliseconds (JS epoch); SQLite stores Unix seconds. |
|
||||
| D10 | MAX_SAMPLES = 120 cap with per-append prune | ~2 min history at 1s poll; bounded DB growth; single-statement prune. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| **MediaIndex migration breaks existing tests.** | `service_id` defaults to `""`; empty-string queries skip the filter → all rows visible. Existing tests pass unchanged. |
|
||||
| **ALTER TABLE fails on fresh installs** where init_schema already created the column. | `run_migrations` catches "duplicate column name" errors per-statement. |
|
||||
| **Harness lazy-init hides migration failures.** | `run_migrations` runs on first access (startup lifespan); failures raise (not swallowed) except the expected duplicate-column case. |
|
||||
| **Speed widget poll interval (5s) too aggressive.** | 5s is the default; configurable via widget config if needed. MAX_SAMPLES cap bounds storage. |
|
||||
| **qBit cookie expiry between polls.** | Client re-logins on 403 transparently; persistent auth failure surfaces as widget error state. |
|
||||
| **LineSeriesChart extraction breaks PrometheusChartWidget.** | Slice 2 includes tests for both; extraction is mechanical (move JSX + helpers, pass series as prop). |
|
||||
| **Review budget (>400 lines).** | Four slices, each ≤400 lines. Slices 1–2 are independently shippable (qBit works without MediaIndex migration). |
|
||||
|
||||
---
|
||||
|
||||
## 9. Data flow diagrams
|
||||
|
||||
### 9.1 Speed widget data flow (InService path)
|
||||
|
||||
```
|
||||
Dashboard poll (useWidgetData, 5s)
|
||||
└► GET /api/widgets/instances/{id}/data
|
||||
└► QbittorrentWidgetSource.fetch(service, "speed", {})
|
||||
├► QbittorrentClient.maindata()
|
||||
│ └► /api/v2/sync/maindata → {server_state:{dl_info_speed, up_info_speed}}
|
||||
├► QbittorrentSampleStore.append(service.id, ts, dl, up)
|
||||
│ └► INSERT + prune (keep 120)
|
||||
├► QbittorrentSampleStore.window(service.id)
|
||||
│ └► SELECT ts, dl_speed, up_speed → [{ts, dl_speed, up_speed}]
|
||||
└► return {series: [{label:"download", points:[{t,v}]}, {label:"upload", points:[{t,v}]}]}
|
||||
└► Frontend: QbittorrentSpeedWidget → <LineSeriesChart series={...} />
|
||||
```
|
||||
|
||||
### 9.2 Cascade-delete data flow
|
||||
|
||||
```
|
||||
DELETE /api/services/instances/{id}
|
||||
└► SettingsStore.delete_service(id)
|
||||
├► DELETE FROM dashboard_widgets WHERE service_id = ?
|
||||
├► DELETE FROM services WHERE id = ?
|
||||
└► ServiceDataHarness.cascade_delete(id)
|
||||
├► DELETE FROM qbittorrent_speed_samples WHERE service_id = ?
|
||||
└► DELETE FROM media_items WHERE service_id = ?
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
# SDD Proposal: Service Storage Harness (with qBittorrent widgets + MediaIndex migration)
|
||||
|
||||
**Change:** `service-storage-harness`
|
||||
**Phase:** proposal
|
||||
**Date:** 2026-07-08
|
||||
|
||||
## 1. Problem / Why Now
|
||||
|
||||
Two things are happening at once, and this change addresses both:
|
||||
|
||||
1. **Feature request — qBittorrent widgets.** The operator wants at-a-glance qBittorrent visibility on the Manage dashboard: active downloads/uploads, total bytes transferred, and a download/upload speed indicator. qBittorrent is currently a blind spot — it is not modeled in the service registry and exposes no widgets.
|
||||
|
||||
2. **Platform gap — services have no owned persistence.** Manage already has a service registry (machines, Jellyfin, Grafana, Prometheus, Alertmanager, Jellyseerr, ssh_tasks) and a configurable widget system. But when a service needs to *remember operational data over time*, there is no shared answer. `MediaIndex` is a one-off: it owns its own `media_index.db`, runs its own schema, and is built by a dedicated subprocess worker. It is a "special snowflake." Rather than add a *second* snowflake for qBittorrent speed history, we extract the shared lifecycle into a small **`ServiceDataHarness`** and prove it with qBittorrent, then migrate `MediaIndex` onto it so the pattern has two evidence-based consumers.
|
||||
|
||||
The two are bundled because the second service (qBittorrent) is the one that justifies generalizing from the first (`MediaIndex`) — n=2 is what makes the abstraction worth its cost.
|
||||
|
||||
## 2. Target Users and Situations
|
||||
|
||||
- **Primary users:** Homelab operators running qBittorrent alongside Manage, who want download/upload activity visible without opening the qBittorrent UI.
|
||||
- **Workflow moments:**
|
||||
- Glance at the dashboard: "is a download running, how fast, how much has been transferred?"
|
||||
- Decide whether qBittorrent is healthy without leaving Manage.
|
||||
- Browse the Jellyfin media catalog (unchanged UX) — which now runs on the same storage harness, validating the abstraction.
|
||||
- **Urgency:** Medium. The feature is valuable but not breaking; the platform refactor is opportunistic (do it now while only two consumers exist, before a third snowflake appears).
|
||||
|
||||
## 3. Product Outcome
|
||||
|
||||
After this change, an authenticated user can:
|
||||
|
||||
- Register one or more **qBittorrent service instances** in the existing Services UI (URL + username + password, stored as encrypted secrets — same posture as Grafana/Prometheus).
|
||||
- Place three discrete **qBittorrent widget kinds** on the dashboard:
|
||||
1. **Totals tile** — count of currently-listed torrents (total items in the qBittorrent list, which may exceed the active count because rate/connection limits leave some torrents non-transferring). NOT cumulative bytes transferred.
|
||||
2. **Active torrents list** — torrents whose state is `downloading` or `uploading`.
|
||||
3. **Speed chart** — live download/upload speed over a short rolling window, rendered with the **`PrometheusChartWidget` recharts renderer established by the `prometheus-direct-charting` change** (fed from the `QbittorrentSampleStore` via an InService-style data path returning the same `{series}` shape). No hand-rolled SVG sparkline; the thin-dashboard rule was already repealed for recharts charting by the foundational change.
|
||||
- Continue using the Jellyfin **Media page** exactly as before; its underlying storage moves onto the harness transparently and additionally becomes **multi-instance capable** (scoped per Jellyfin service).
|
||||
|
||||
## 4. Scope Boundaries and Non-Goals
|
||||
|
||||
### In scope
|
||||
|
||||
- **`ServiceDataHarness`** — a general lifecycle layer: per-concern DB files, per-integration schema migrations, `service_id` scoping of all tables, and cascade-delete when a service instance is removed.
|
||||
- **qBittorrent integration** — new `integrations/qbittorrent.py` (config + secret schema + widget kinds), new `clients/qbittorrent.py` (Web API client, cookie login), a `QbittorrentSampleStore` (speed samples), widget source adapter(s) in `widgets/sources.py`, registry entry.
|
||||
- **qBittorrent frontend** — three widget components under `frontend/src/widgets/`, binding in `integrations/registry.ts`, types, API client functions.
|
||||
- **MediaIndex migration** — `MediaIndex` registered with the harness, `media_items` scoped by `service_id`, subprocess worker updated. The `media_index.db` file location is unchanged (only gains a column + harness registration) to minimize churn on a load-bearing feature.
|
||||
- **Cascade-delete wiring** — removing a service instance cleans up its owned data in every harness-managed table.
|
||||
|
||||
### Non-goals (explicitly out of scope)
|
||||
|
||||
- **Torrent management UI** — no add/pause/delete/recheck/priority UI. Read-only visibility only.
|
||||
- **A generic time-series database.** The harness owns *lifecycle*, not a generic `(service_id, key, ts, value)` table. Each integration owns its own schema and operations.
|
||||
- **A generic CRUD/ORM layer.** Stores keep bespoke operations (`append/window` vs `replace_all/query`); only the lifecycle is shared.
|
||||
- **Per-user or per-tenant storage partitioning.** Storage is scoped by `service_id` only.
|
||||
- **Re-indexing/migrating existing media data.** The `media_index.db` file stays in place; the migration is a schema column add + harness registration, not a data move.
|
||||
- **WebSocket / push updates.** Polling via existing `useWidgetData(widgetId, refreshIntervalMs)` is sufficient.
|
||||
- **Transfer-byte totals.** The totals widget counts torrent *items*, not cumulative bytes uploaded/downloaded (per the §8 Q2 resolution). Byte totals are out of scope.
|
||||
- **qBittorrent Prometheus exporter.** Not built; the speed chart is sourced from the local `QbittorrentSampleStore`, not Prometheus.
|
||||
|
||||
## 5. High-Level Approach
|
||||
|
||||
### 5.1 Architectural decisions (locked during grilling)
|
||||
|
||||
| Decision | Outcome |
|
||||
|---|---|
|
||||
| **Storage tech** | SQLite. Low volume; proven in stack. |
|
||||
| **Abstraction level** | `ServiceDataHarness` owns the *lifecycle* (DB filename, migrations, `service_id` scoping, cascade-delete). Each integration owns its *operations* in a bespoke Store. General where shared; bespoke where not. |
|
||||
| **MediaIndex migration** | Included in this change, but **sequenced**: harness + qBit proven first; MediaIndex folded in after. Inside one change, never two simultaneous risks. |
|
||||
| **DB topology** | Per-concern DB files. `media_index.db` keeps its file (gains `service_id` + registration); new `qbittorrent.db`. |
|
||||
|
||||
### 5.2 Backend
|
||||
|
||||
1. **`ServiceDataHarness`** (new, `services/service_data.py` or similar):
|
||||
- A registry of *concerns*: each integration declares a DB filename, an ordered list of migration SQL statements, the tables it owns, and a `service_id` column convention.
|
||||
- On startup: runs pending migrations per concern DB.
|
||||
- On service deletion: cascades — for each concern table, `DELETE FROM <table> WHERE service_id = ?`.
|
||||
- Provides connection management per concern DB (separate connections → bulk media writes don't share a writer with chatty qBit appends).
|
||||
|
||||
2. **qBittorrent client** (`clients/qbittorrent.py`):
|
||||
- Login via `/api/v2/auth/login` → cookie session; reuse cookie across calls; re-login on 403.
|
||||
- Endpoints used: `/api/v2/transfer/info` (global totals), `/api/v2/sync/maindata` (active torrents + current speeds), `/api/v2/torrents/info` (filtered lists if needed).
|
||||
- Timeout + reachability handling consistent with `JellyfinClient`/`GrafanaWidgetSource`.
|
||||
|
||||
3. **`QbittorrentSampleStore`** (new):
|
||||
- Table `qbittorrent_speed_samples(service_id TEXT, ts INTEGER, dl_speed INTEGER, up_speed INTEGER)` in `qbittorrent.db`.
|
||||
- Operations: `append(service_id, ts, dl, up)`, `window(service_id, since_ts)`, `prune(service_id, older_than_ts)`.
|
||||
- Pruning runs on each append (cap retention to the configured window, e.g. 120 samples).
|
||||
|
||||
4. **Widget source adapter** — `QbittorrentWidgetSource` in `widgets/sources.py` implementing the existing `WidgetSource.fetch(service, widget_kind, config)` contract. For the speed kind, `fetch` appends a sample to the store and returns the current window for rendering.
|
||||
|
||||
5. **Integration registration** — add `qbittorrent` to `integrations/registry.py` with config schema (base URL, timeout) and secret schema (username, password); declare widget kinds.
|
||||
|
||||
6. **MediaIndex migration** — register `MediaIndex` as a concern; add `service_id` column (backfill existing rows with the default/local Jellyfin service id); update `replace_items`/`query`/worker to scope by `service_id`; route through the harness connection.
|
||||
|
||||
### 5.3 Frontend
|
||||
|
||||
1. **Three widget components** under `frontend/src/widgets/`:
|
||||
- `QbittorrentTotalsWidget.tsx` — numeric tiles for all-time bytes (reuses `MetricCard`/`SectionCard`).
|
||||
- `QbittorrentActiveTorrentsWidget.tsx` — list/table of active torrents (reuses the already-migrated `DataTable` + `@tanstack/react-table`; **no new DataGrid migration risk**).
|
||||
- `QbittorrentSpeedWidget.tsx` — visual form decided by §8 Q1 (numeric tiles + delta, or sparkline if an exception is granted, or a PrometheusMetricWidget binding if the exporter path is chosen).
|
||||
2. **Registry binding** — add a `qbittorrent` entry to `SERVICE_REGISTRY` in `frontend/src/integrations/registry.ts` mapping the three widget kinds to components + config schemas.
|
||||
3. **Types + API client** — `frontend/src/types/index.ts` and `frontend/src/api/client.ts` gain qBittorrent-aware widget kinds only (data flows through the existing `useWidgetData` polling; no new endpoints beyond widget CRUD).
|
||||
|
||||
### 5.4 Type contracts
|
||||
|
||||
- Backend Pydantic models for qBittorrent config/secret schema in `integrations/qbittorrent.py`.
|
||||
- Frontend TypeScript interfaces for qBittorrent widget payloads.
|
||||
- Harness has no data-shape types of its own (it is lifecycle-only) — keeping the seam clean.
|
||||
|
||||
## 6. Success Criteria / Acceptance Criteria
|
||||
|
||||
1. A user can register a qBittorrent service instance (URL + username + password) and the three widget kinds appear as bindable on the dashboard.
|
||||
2. Totals tile shows all-time bytes from `transferInfo`; active list shows downloading/uploading torrents; speed indicator reflects current rates.
|
||||
3. `ServiceDataHarness` runs migrations on startup and cascades deletes across qBittorrent samples *and* media items when a service is removed.
|
||||
4. `MediaIndex` continues to power the Media page identically (all existing Media tests green) and is now scoped by `service_id`.
|
||||
5. Removing a Jellyfin service removes only that service's media rows; removing a qBittorrent service removes only that service's speed samples.
|
||||
6. A misconfigured/unreachable qBittorrent instance degrades gracefully per-widget (error state), the rest of the dashboard renders.
|
||||
7. Backend tests (`pytest`) and frontend `npm run build` + `npm run lint` stay green.
|
||||
8. No secrets land in widget `config_json`; qBittorrent password is encrypted via the existing Fernet path.
|
||||
9. No new charting dependency is added unless §8 Q1 grants an explicit exception.
|
||||
|
||||
## 7. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| **Compound risk: new abstraction × load-bearing refactor.** A wrong harness shape breaks the Media page. | Sequence inside the change: harness + qBit land and pass tests first; MediaIndex migrates only after the harness shape is settled in code. |
|
||||
| **Harness over-generalization.** Building a meta-framework from n=2. | Keep the harness lifecycle-only; do NOT add a generic value table or generic CRUD. Each store keeps bespoke operations. Extract further only when a third shape appears. |
|
||||
| **Thin-dashboard rule collision (§8 Q1).** In-app speed graph violates the stated "no in-app charting" rule. | Resolve in the question round before spec; default to the rule-compliant numeric-tile + Grafana deep-link option unless an explicit exception is granted. |
|
||||
| **qBittorrent auth lifecycle.** Cookie expiry / 403 handling. | Re-login transparently on 403; short request timeout; surface persistent auth failure as widget error state. |
|
||||
| **MediaIndex backfill correctness.** Adding `service_id` to existing rows. | Backfill all existing rows to the default/local Jellyfin service id; migration is additive; existing Media tests must pass unchanged. |
|
||||
| **Review budget (>400 changed lines).** Bundled scope is large. | Slice into chained PRs (Slice 1: harness + qBit; Slice 2: MediaIndex migration). Each slice leaves `npm run build` + `npm run lint` + `pytest` green. |
|
||||
| **DataGrid migration (config rule callout).** | Not a risk here: the active-torrents list reuses the already-migrated `DataTable` (`@tanstack/react-table`). No new DataGrid migration is introduced. The key technical risks are the harness abstraction and the MediaIndex refactor, above. |
|
||||
|
||||
## 8. Resolved Questions (question round complete)
|
||||
|
||||
All five product/semantic questions resolved during grilling + the Change A (`prometheus-direct-charting`) lifecycle:
|
||||
|
||||
- **Q1 — Speed visualization.** RESOLVED: the thin-dashboard rule was repealed by Change A; in-app recharts charting is now sanctioned. The qBit speed widget reuses the `PrometheusChartWidget` recharts renderer, fed from `QbittorrentSampleStore.window()` via an InService data path returning the same `{series}` shape. No SVG sparkline, no Grafana dependency.
|
||||
- **Q2 — "Totals" semantics.** RESOLVED: count of currently-listed torrent items (total in the qBittorrent list), which may exceed the active count due to rate/connection limits. NOT transfer bytes, NOT per-session counters.
|
||||
- **Q3 — "Active" definition.** RESOLVED: torrents in state `downloading` or `uploading` only.
|
||||
- **Q4 — Instances.** RESOLVED: support N qBittorrent instances, each a service row, independently scoped by `service_id`.
|
||||
- **Q5 — Auth model.** RESOLVED: username/password login → cookie session, encrypted via Fernet. No reverse-proxy no-auth flag in this pass.
|
||||
|
||||
## 9. Future Phases
|
||||
|
||||
1. **Third service consumer** — when a service with a genuinely new storage shape arrives, reconsider promoting the harness toward a broader abstraction (evidence-based, n=3).
|
||||
2. **Torrent management** — add/pause/delete actions (would require write endpoints + confirmation UX).
|
||||
3. **Storage admin UI** — surface harness-managed table sizes and a "clear cached data" action per service.
|
||||
4. **Reverse-proxy / no-auth qBittorrent flag** — for setups behind Authentik where native login is bypassed.
|
||||
5. **Transfer-byte totals** — if desired later, add a separate widget using `transferInfo.globalUploaded/Downloaded`.
|
||||
@@ -0,0 +1,155 @@
|
||||
# SDD Spec: Service Storage Harness (qBittorrent widgets + MediaIndex migration)
|
||||
|
||||
**Change:** `service-storage-harness`
|
||||
**Phase:** spec
|
||||
**Date:** 2026-07-09
|
||||
|
||||
This spec defines the acceptance requirements for the change, derived from the reconciled `proposal.md` and `design.md`. Requirements are testable.
|
||||
|
||||
## Requirement categories
|
||||
|
||||
1. ServiceDataHarness (lifecycle layer)
|
||||
2. QbittorrentSampleStore
|
||||
3. QbittorrentClient
|
||||
4. qBittorrent widget source adapter
|
||||
5. qBittorrent frontend widgets
|
||||
6. MediaIndex migration onto harness
|
||||
7. Cascade-delete wiring
|
||||
8. Test + build greenness
|
||||
|
||||
---
|
||||
|
||||
## 1. ServiceDataHarness (lifecycle layer)
|
||||
|
||||
### SS-101 — Harness is lifecycle-only
|
||||
|
||||
A `ServiceDataHarness` class exists in `backend/src/media_library_viewer_api/services/service_data.py` that owns ONLY lifecycle concerns: per-concern DB provisioning, per-integration migrations, `service_id` cascade-delete. It MUST NOT provide generic data operations (no generic value table, no generic CRUD).
|
||||
|
||||
### SS-102 — Concern registration
|
||||
|
||||
An integration/concern registers via a dataclass carrying: DB filename, ordered migration SQL list, owned-tables list, and `service_id` column name. The harness stores registered concerns.
|
||||
|
||||
### SS-103 — Idempotent migrations
|
||||
|
||||
`run_migrations` runs each concern's migration statements and MUST be idempotent — specifically, re-running `ALTER TABLE ... ADD COLUMN` on an already-migrated DB MUST NOT raise (the harness catches "duplicate column name" per-statement).
|
||||
|
||||
### SS-104 — Cascade-delete across concerns
|
||||
|
||||
`cascade_delete(service_id)` iterates every registered concern and, for each owned table, executes `DELETE FROM <table> WHERE <service_id_column> = ?`. It MUST cover every registered concern (qBittorrent samples + media items after this change).
|
||||
|
||||
## 2. QbittorrentSampleStore
|
||||
|
||||
### SS-105 — Schema
|
||||
|
||||
`QbittorrentSampleStore` owns a `qbittorrent_speed_samples` table with columns `(service_id, ts, dl_speed, up_speed)` and an index on `(service_id, ts)`, in a dedicated `qbittorrent.db` file (per-concern topology).
|
||||
|
||||
### SS-106 — append/window/prune operations
|
||||
|
||||
The store exposes `append(service_id, ts, dl_speed, up_speed)`, `window(service_id, since_ts)` returning ordered rows, and prunes per-append to `MAX_SAMPLES = 120`.
|
||||
|
||||
### SS-107 — Registered as a harness concern
|
||||
|
||||
The qBittorrent sample store is registered with the harness so its table participates in cascade-delete (SS-104).
|
||||
|
||||
## 3. QbittorrentClient
|
||||
|
||||
### SS-108 — Cookie login
|
||||
|
||||
`QbittorrentClient` authenticates via `POST /api/v2/auth/login` with username/password, stores the resulting cookie, and reuses it for subsequent requests. Credentials are resolved from the `ServiceRecord` secrets (Fernet-encrypted at rest).
|
||||
|
||||
### SS-109 — 403 re-login
|
||||
|
||||
On HTTP 403 the client MUST transparently re-login once and retry the request.
|
||||
|
||||
### SS-110 — maindata fetch
|
||||
|
||||
The client exposes `maindata()` calling `/api/v2/sync/maindata` and returning its dict. Errors (timeout, connection, non-2xx) propagate as exceptions for the adapter to catch.
|
||||
|
||||
## 4. qBittorrent widget source adapter
|
||||
|
||||
### SS-111 — Three widget kinds dispatched
|
||||
|
||||
`QbittorrentWidgetSource.fetch(service, widget_kind, config)` dispatches on `widget_kind` ∈ {`totals`, `active`, `speed`}, resolving the client inline from `ServiceRecord` (same pattern as `PrometheusWidgetSource`).
|
||||
|
||||
### SS-112 — totals = item count
|
||||
|
||||
`totals` returns the count of currently-listed torrents from `maindata()` as `{total: int}`. It is NOT cumulative transfer bytes.
|
||||
|
||||
### SS-113 — active = downloading/uploading filter
|
||||
|
||||
`active` returns the subset of torrents whose `state` is `downloading` or `uploading` as `{torrents: [...]}`.
|
||||
|
||||
### SS-114 — speed appends sample + returns series
|
||||
|
||||
`speed` reads the current dl/up speeds from `maindata()`, appends a sample via `QbittorrentSampleStore.append`, and returns `{series: [{label: "download", points: [...]}, {label: "upload", points: [...]}]}` from `.window()` — the exact shape `LineSeriesChart` consumes (timestamps in JS milliseconds).
|
||||
|
||||
### SS-115 — Errors degrade gracefully
|
||||
|
||||
Adapter errors (auth failure, timeout, connection) return `{error: str}` and MUST NOT raise.
|
||||
|
||||
## 5. qBittorrent frontend widgets
|
||||
|
||||
### SS-116 — Three widget components
|
||||
|
||||
`QbittorrentTotalsWidget`, `QbittorrentActiveTorrentsWidget`, `QbittorrentSpeedWidget` exist under `frontend/src/widgets/` and are bound to the `qbittorrent` service binding in `integrations/registry.ts` with their respective kinds.
|
||||
|
||||
### SS-117 — Speed widget reuses shared renderer
|
||||
|
||||
`QbittorrentSpeedWidget` renders via the shared `LineSeriesChart` component (extracted from `PrometheusChartWidget`). No new charting code or charting dependency.
|
||||
|
||||
### SS-118 — LineSeriesChart extraction is non-regressive
|
||||
|
||||
The extraction of the recharts body into `frontend/src/components/LineSeriesChart.tsx` MUST leave `PrometheusChartWidget`'s behavior and tests green; `PrometheusChartWidget` becomes a thin wrapper.
|
||||
|
||||
## 6. MediaIndex migration onto harness
|
||||
|
||||
### SS-119 — service_id column added
|
||||
|
||||
`media_items` gains a `service_id TEXT NOT NULL DEFAULT ''` column via an idempotent harness migration. The `media_index.db` file location is UNCHANGED.
|
||||
|
||||
### SS-120 — Existing rows backfill
|
||||
|
||||
All pre-existing `media_items` rows receive `service_id = ''` (via the column DEFAULT), preserving their visibility.
|
||||
|
||||
### SS-121 — replace_items is scoped (bug fix)
|
||||
|
||||
`replace_items(service_id=...)` deletes only `WHERE service_id = ?` (not a global `DELETE FROM media_items`). This FIXES the latent global-clear bug where rebuilding for one Jellyfin instance wiped another's rows. A regression test MUST prove service A's rows survive service B's rebuild.
|
||||
|
||||
### SS-122 — query is backward-compatible
|
||||
|
||||
`query(service_id="")` returns all rows (no filter); `query(service_id="X")` scopes to service X. Existing tests that pass no `service_id` MUST continue to pass unchanged.
|
||||
|
||||
### SS-123 — Worker threads service_id into rows
|
||||
|
||||
The media index worker (which already receives `--service-id`) passes it into `replace_items` so newly-built rows are stamped with the real service_id.
|
||||
|
||||
### SS-124 — Registered as a harness concern
|
||||
|
||||
MediaIndex registers `media_items` as a harness-owned table so it participates in cascade-delete.
|
||||
|
||||
## 7. Cascade-delete wiring
|
||||
|
||||
### SS-125 — delete_service triggers harness cascade
|
||||
|
||||
`settings_store.delete_service` calls `ServiceDataHarness.cascade_delete(service_id)` after its existing cleanup, in a best-effort try/except (a cascade failure MUST NOT crash the service deletion; it logs and continues).
|
||||
|
||||
### SS-126 — End-to-end cascade across both concerns
|
||||
|
||||
Deleting a service removes both its qBittorrent samples AND its media rows. An integration test MUST prove this across both concerns, and MUST prove rows of OTHER services are preserved.
|
||||
|
||||
## 8. Test + build greenness
|
||||
|
||||
### SS-127 — Backend tests + lint green
|
||||
|
||||
`PYTHONPATH=src python3 -m pytest -q` and `PYTHONPATH=src python3 -m ruff check src tests` from `backend/` MUST pass, including new tests for: harness lifecycle, qBit store, qBit client (login/403/maindata), qBit widget adapter (3 branches), MediaIndex scoped replace_items regression, and cascade-delete integration.
|
||||
|
||||
### SS-128 — Frontend build + lint green
|
||||
|
||||
`npm run build` and `npm run lint` from `frontend/` MUST pass (0 errors). New widget tests cover loading/error/rendered states; PrometheusChartWidget tests stay green (SS-118).
|
||||
|
||||
---
|
||||
|
||||
## Notes for downstream phases
|
||||
|
||||
- **Verify** should confirm SS-101..SS-128 against source; the load-bearing items are SS-121 (scoped replace_items bug fix), SS-122/SS-123 (MediaIndex backward-compat), and SS-118 (non-regressive extraction).
|
||||
- **Sync + archive** follow the Change A pattern (delta into a new canonical domain `service-storage`, then archive to `openspec/changes/archive/2026-07-09-service-storage-harness/`).
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# Service Storage — Delta (`service-storage-harness`)
|
||||
|
||||
> Change: `service-storage-harness` · Domain: `service-storage` · Phase: **spec** (reconciled during `sdd-sync`).
|
||||
> Distilled verbatim from the verified flat `spec.md` (28 requirements, SS-101 … SS-128) of change
|
||||
> `service-storage-harness`, cross-referenced against `design.md` and `verify-report.md`. Captures
|
||||
> the **durable, post-change end-state contracts** for the lifecycle-only `ServiceDataHarness`, the
|
||||
> qBittorrent store/client/widget stack built on it, the MediaIndex migration onto the harness, and
|
||||
> the cross-concern cascade-delete wiring.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
> The canonical `openspec/specs/service-storage/spec.md` did not exist before this change. All
|
||||
> requirements below are therefore **ADDED** to a new `service-storage` domain; `sdd-sync` copies
|
||||
> them into the canonical spec (native helper rule: when the canonical spec does not exist, the
|
||||
> change spec becomes the new canonical spec).
|
||||
>
|
||||
> Requirement IDs (SS-101 … SS-128) and body text are preserved **exactly** from the verified flat
|
||||
> `spec.md`. Requirements are grouped logically and listed in the following group order:
|
||||
>
|
||||
> - **ServiceDataHarness (lifecycle layer)** — SS-101 … SS-104
|
||||
> - **QbittorrentSampleStore** — SS-105 … SS-107
|
||||
> - **QbittorrentClient** — SS-108 … SS-110
|
||||
> - **qBittorrent widget source adapter** — SS-111 … SS-115
|
||||
> - **qBittorrent frontend widgets** — SS-116 … SS-118
|
||||
> - **MediaIndex migration onto harness** — SS-119 … SS-124
|
||||
> - **Cascade-delete wiring** — SS-125 … SS-126
|
||||
> - **Test and build greenness** — SS-127 … SS-128
|
||||
|
||||
### Requirement: SS-101 — Harness is lifecycle-only
|
||||
|
||||
A `ServiceDataHarness` class exists in `backend/src/media_library_viewer_api/services/service_data.py` that owns ONLY lifecycle concerns: per-concern DB provisioning, per-integration migrations, `service_id` cascade-delete. It MUST NOT provide generic data operations (no generic value table, no generic CRUD).
|
||||
|
||||
### Requirement: SS-102 — Concern registration
|
||||
|
||||
An integration/concern registers via a dataclass carrying: DB filename, ordered migration SQL list, owned-tables list, and `service_id` column name. The harness stores registered concerns.
|
||||
|
||||
### Requirement: SS-103 — Idempotent migrations
|
||||
|
||||
`run_migrations` runs each concern's migration statements and MUST be idempotent — specifically, re-running `ALTER TABLE ... ADD COLUMN` on an already-migrated DB MUST NOT raise (the harness catches "duplicate column name" per-statement).
|
||||
|
||||
### Requirement: SS-104 — Cascade-delete across concerns
|
||||
|
||||
`cascade_delete(service_id)` iterates every registered concern and, for each owned table, executes `DELETE FROM <table> WHERE <service_id_column> = ?`. It MUST cover every registered concern (qBittorrent samples + media items after this change).
|
||||
|
||||
### Requirement: SS-105 — Schema
|
||||
|
||||
`QbittorrentSampleStore` owns a `qbittorrent_speed_samples` table with columns `(service_id, ts, dl_speed, up_speed)` and an index on `(service_id, ts)`, in a dedicated `qbittorrent.db` file (per-concern topology).
|
||||
|
||||
### Requirement: SS-106 — append/window/prune operations
|
||||
|
||||
The store exposes `append(service_id, ts, dl_speed, up_speed)`, `window(service_id, since_ts)` returning ordered rows, and prunes per-append to `MAX_SAMPLES = 120`.
|
||||
|
||||
### Requirement: SS-107 — Registered as a harness concern
|
||||
|
||||
The qBittorrent sample store is registered with the harness so its table participates in cascade-delete (SS-104).
|
||||
|
||||
### Requirement: SS-108 — Cookie login
|
||||
|
||||
`QbittorrentClient` authenticates via `POST /api/v2/auth/login` with username/password, stores the resulting cookie, and reuses it for subsequent requests. Credentials are resolved from the `ServiceRecord` secrets (Fernet-encrypted at rest).
|
||||
|
||||
### Requirement: SS-109 — 403 re-login
|
||||
|
||||
On HTTP 403 the client MUST transparently re-login once and retry the request.
|
||||
|
||||
### Requirement: SS-110 — maindata fetch
|
||||
|
||||
The client exposes `maindata()` calling `/api/v2/sync/maindata` and returning its dict. Errors (timeout, connection, non-2xx) propagate as exceptions for the adapter to catch.
|
||||
|
||||
### Requirement: SS-111 — Three widget kinds dispatched
|
||||
|
||||
`QbittorrentWidgetSource.fetch(service, widget_kind, config)` dispatches on `widget_kind` ∈ {`totals`, `active`, `speed`}, resolving the client inline from `ServiceRecord` (same pattern as `PrometheusWidgetSource`).
|
||||
|
||||
### Requirement: SS-112 — totals = item count
|
||||
|
||||
`totals` returns the count of currently-listed torrents from `maindata()` as `{total: int}`. It is NOT cumulative transfer bytes.
|
||||
|
||||
### Requirement: SS-113 — active = downloading/uploading filter
|
||||
|
||||
`active` returns the subset of torrents whose `state` is `downloading` or `uploading` as `{torrents: [...]}`.
|
||||
|
||||
### Requirement: SS-114 — speed appends sample + returns series
|
||||
|
||||
`speed` reads the current dl/up speeds from `maindata()`, appends a sample via `QbittorrentSampleStore.append`, and returns `{series: [{label: "download", points: [...]}, {label: "upload", points: [...]}]}` from `.window()` — the exact shape `LineSeriesChart` consumes (timestamps in JS milliseconds).
|
||||
|
||||
### Requirement: SS-115 — Errors degrade gracefully
|
||||
|
||||
Adapter errors (auth failure, timeout, connection) return `{error: str}` and MUST NOT raise.
|
||||
|
||||
### Requirement: SS-116 — Three widget components
|
||||
|
||||
`QbittorrentTotalsWidget`, `QbittorrentActiveTorrentsWidget`, `QbittorrentSpeedWidget` exist under `frontend/src/widgets/` and are bound to the `qbittorrent` service binding in `integrations/registry.ts` with their respective kinds.
|
||||
|
||||
### Requirement: SS-117 — Speed widget reuses shared renderer
|
||||
|
||||
`QbittorrentSpeedWidget` renders via the shared `LineSeriesChart` component (extracted from `PrometheusChartWidget`). No new charting code or charting dependency.
|
||||
|
||||
### Requirement: SS-118 — LineSeriesChart extraction is non-regressive
|
||||
|
||||
The extraction of the recharts body into `frontend/src/components/LineSeriesChart.tsx` MUST leave `PrometheusChartWidget`'s behavior and tests green; `PrometheusChartWidget` becomes a thin wrapper.
|
||||
|
||||
### Requirement: SS-119 — service_id column added
|
||||
|
||||
`media_items` gains a `service_id TEXT NOT NULL DEFAULT ''` column via an idempotent harness migration. The `media_index.db` file location is UNCHANGED.
|
||||
|
||||
### Requirement: SS-120 — Existing rows backfill
|
||||
|
||||
All pre-existing `media_items` rows receive `service_id = ''` (via the column DEFAULT), preserving their visibility.
|
||||
|
||||
### Requirement: SS-121 — replace_items is scoped (bug fix)
|
||||
|
||||
`replace_items(service_id=...)` deletes only `WHERE service_id = ?` (not a global `DELETE FROM media_items`). This FIXES the latent global-clear bug where rebuilding for one Jellyfin instance wiped another's rows. A regression test MUST prove service A's rows survive service B's rebuild.
|
||||
|
||||
### Requirement: SS-122 — query is backward-compatible
|
||||
|
||||
`query(service_id="")` returns all rows (no filter); `query(service_id="X")` scopes to service X. Existing tests that pass no `service_id` MUST continue to pass unchanged.
|
||||
|
||||
### Requirement: SS-123 — Worker threads service_id into rows
|
||||
|
||||
The media index worker (which already receives `--service-id`) passes it into `replace_items` so newly-built rows are stamped with the real service_id.
|
||||
|
||||
### Requirement: SS-124 — Registered as a harness concern
|
||||
|
||||
MediaIndex registers `media_items` as a harness-owned table so it participates in cascade-delete.
|
||||
|
||||
### Requirement: SS-125 — delete_service triggers harness cascade
|
||||
|
||||
`settings_store.delete_service` calls `ServiceDataHarness.cascade_delete(service_id)` after its existing cleanup, in a best-effort try/except (a cascade failure MUST NOT crash the service deletion; it logs and continues).
|
||||
|
||||
### Requirement: SS-126 — End-to-end cascade across both concerns
|
||||
|
||||
Deleting a service removes both its qBittorrent samples AND its media rows. An integration test MUST prove this across both concerns, and MUST prove rows of OTHER services are preserved.
|
||||
|
||||
### Requirement: SS-127 — Backend tests + lint green
|
||||
|
||||
`PYTHONPATH=src python3 -m pytest -q` and `PYTHONPATH=src python3 -m ruff check src tests` from `backend/` MUST pass, including new tests for: harness lifecycle, qBit store, qBit client (login/403/maindata), qBit widget adapter (3 branches), MediaIndex scoped replace_items regression, and cascade-delete integration.
|
||||
|
||||
### Requirement: SS-128 — Frontend build + lint green
|
||||
|
||||
`npm run build` and `npm run lint` from `frontend/` MUST pass (0 errors). New widget tests cover loading/error/rendered states; PrometheusChartWidget tests stay green (SS-118).
|
||||
@@ -0,0 +1,166 @@
|
||||
# Sync Report — `service-storage-harness`
|
||||
|
||||
> Phase: **sync** · Change: `service-storage-harness` · Repo: `/home/user/manage`
|
||||
> Mode: file-backed (`artifactStore: openspec`). No source-code edits; only OpenSpec artifacts were
|
||||
> written. Not committed (parent owns the commit). The change folder was **not** moved (that is
|
||||
> `sdd-archive`'s job).
|
||||
|
||||
**Status: SYNCED.** A new canonical domain `openspec/specs/service-storage/spec.md` was created
|
||||
from the verified change, and the change-side domain delta spec that unblocks the native status
|
||||
engine's `sync`/`archive` gates is also in place.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
The `service-storage-harness` change shipped a **complete but flat** `openspec/changes/service-storage-harness/spec.md`
|
||||
(28 requirements, SS-101 … SS-128) with **no** per-domain delta spec under
|
||||
`openspec/changes/service-storage-harness/specs/<domain>/`. `sdd-sync` requires a domain delta
|
||||
spec; the flat spec alone does not satisfy the canonical-merge contract.
|
||||
|
||||
Verify already returned **PASS** (verdict in `verify-report.md`; all four gates green — backend
|
||||
`pytest` 322 passed, `ruff` clean, frontend `npm run build` exit 0, `npm run lint` 0 errors).
|
||||
Functional coverage was **28/28 fully PASS**. The verify report's single CRITICAL was an **archive**
|
||||
blocker (30 unchecked task checkboxes + missing `apply-progress.md`); `apply-progress.md` now exists
|
||||
and reconciles the 35 tasks (the task tracker condition does **not** block `sdd-sync` of the green
|
||||
code).
|
||||
|
||||
This sync **reconciles** the flat-spec-vs-domain-spec gap:
|
||||
|
||||
1. Authored the missing **change-side domain delta spec** —
|
||||
`openspec/changes/service-storage-harness/specs/service-storage/spec.md` — using a clean
|
||||
`## ADDED Requirements` structure that preserves the exact requirement IDs (SS-101 … SS-128) and
|
||||
text from the verified flat `spec.md`. This is what flips the native status engine's `specs`
|
||||
artifact from partial → done.
|
||||
2. **Synced** the end-state into the **canonical store** —
|
||||
`openspec/specs/service-storage/spec.md` — the actual sync target. Because the canonical
|
||||
`service-storage` domain did not previously exist, the native helper rule applies: *when the
|
||||
canonical spec does not exist, the change spec becomes the new canonical spec.* The two files
|
||||
therefore carry identical requirement bodies (delta under `## ADDED Requirements`; canonical
|
||||
under `## Requirements`).
|
||||
|
||||
Domain name **`service-storage`** was chosen (per the dispatch brief) because it covers the full
|
||||
new model: the lifecycle-only `ServiceDataHarness` layer, the qBittorrent store + client + widget
|
||||
stack, and the MediaIndex migration that established the per-service storage pattern. It is distinct
|
||||
from the existing canonical domains `web-ui` (MUI→shadcn migration) and `prometheus-charting`
|
||||
(direct Prometheus metric visualization), neither of which was **touched**.
|
||||
|
||||
## 2. Structured status & actionContext findings
|
||||
|
||||
The native `gentle-pi.sdd-status` passed by the parent reports `changeName: null` with
|
||||
`blockedReasons: ["Change selection is ambiguous: mobile-responsive-parity, service-storage-harness,
|
||||
services-as-hub-ia."]` because the engine auto-detected three active changes. This sync task was
|
||||
**explicitly assigned** `service-storage-harness`; the ambiguity is a parent-resolution artifact
|
||||
and does not block this phase (`isNonAuthoritative: false`).
|
||||
|
||||
- `artifactStore: openspec`; change root `openspec/changes/service-storage-harness/`.
|
||||
- Artifacts present: `proposal.md`, `spec.md`, `design.md`, `tasks.md`, `verify-report.md`,
|
||||
`apply-progress.md`.
|
||||
- `verify: PASS` (verify-report verdict; gates green at `c9404f0`).
|
||||
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/manage`,
|
||||
`allowedEditRoots: ["/home/user/manage"]`, `warnings: []`. All three files written are inside the
|
||||
authoritative workspace / allowed edit roots. ✓
|
||||
- `relationships.sameDomainActiveChanges: []`, `collisions: []` — **no active same-domain
|
||||
collisions**, so no archive/sync ordering decision was required.
|
||||
- The new `service-storage` domain is distinct from the existing `web-ui` and `prometheus-charting`
|
||||
canonical domains; both were left untouched.
|
||||
|
||||
**Post-sync structural change:** `openspec/changes/service-storage-harness/specs/service-storage/spec.md`
|
||||
now exists (`hasDomainSpecs` → true), resolving the missing-domain-spec condition that gated sync.
|
||||
The flat `spec.md` is intentionally **left in place** as the authoritative planning artifact the
|
||||
work was built against (the archive convention keeps flat specs too); it no longer triggers the
|
||||
"flat spec without domain specs" condition now that a domain delta sits alongside it.
|
||||
|
||||
## 3. Domains synced & canonical files updated
|
||||
|
||||
| Domain | Change-side delta (source) | Canonical (sync target) | Action |
|
||||
|---|---|---|---|
|
||||
| `service-storage` | `openspec/changes/service-storage-harness/specs/service-storage/spec.md` | `openspec/specs/service-storage/spec.md` | **NEW domain** — `## ADDED Requirements` copied into canonical as a new spec |
|
||||
|
||||
- **Canonical file created:** `openspec/specs/service-storage/spec.md` (28 requirements).
|
||||
- **Change-side delta created:** `openspec/changes/service-storage-harness/specs/service-storage/spec.md`
|
||||
(28 requirements, all `## ADDED Requirements`).
|
||||
|
||||
## 4. Requirement delta (ADDED / MODIFIED / REMOVED)
|
||||
|
||||
- **ADDED (28)** — all to the new `service-storage` domain (canonical did not exist pre-change).
|
||||
IDs and text preserved verbatim from the verified flat `spec.md`. Grouped logically:
|
||||
- *ServiceDataHarness (lifecycle layer)* — SS-101, SS-102, SS-103, SS-104
|
||||
- *QbittorrentSampleStore* — SS-105, SS-106, SS-107
|
||||
- *QbittorrentClient* — SS-108, SS-109, SS-110
|
||||
- *qBittorrent widget source adapter* — SS-111, SS-112, SS-113, SS-114, SS-115
|
||||
- *qBittorrent frontend widgets* — SS-116, SS-117, SS-118
|
||||
- *MediaIndex migration onto harness* — SS-119, SS-120, SS-121, SS-122, SS-123, SS-124
|
||||
- *Cascade-delete wiring* — SS-125, SS-126
|
||||
- *Test and build greenness* — SS-127, SS-128
|
||||
- **MODIFIED (0)** — none (new domain; no pre-existing canonical requirements to replace).
|
||||
- **REMOVED (0)** — none.
|
||||
- **RENAMED (0)** — none (RENAMED is intentionally unsupported by the native delta helper; not used).
|
||||
|
||||
## 5. Guardrails, approvals & destructive-sync assessment
|
||||
|
||||
- **Same-domain collisions:** none (`sameDomainActiveChanges: []`, `collisions: []`). The new
|
||||
`service-storage` domain does not overlap the existing `web-ui` or `prometheus-charting`
|
||||
canonical domains. No ordering decision was needed.
|
||||
- **Destructive sync:** **not applicable.** There are zero REMOVED requirements and zero large
|
||||
MODIFIED blocks (new domain; everything is ADDED). No destructive-sync parent approval was
|
||||
required beyond the explicit reconciliation instruction in the task.
|
||||
- **Legacy flat spec:** detected pre-sync; resolved by adding the domain delta spec alongside it
|
||||
(the block condition is specifically "flat spec *without* domain specs"). The flat spec was left
|
||||
in place as a planning artifact.
|
||||
- **`web-ui` / `prometheus-charting` canonical isolation:** the existing
|
||||
`openspec/specs/web-ui/spec.md` (MUI→shadcn rework) and `openspec/specs/prometheus-charting/spec.md`
|
||||
(direct Prometheus charting) were **not modified** — verified untouched by `git status`. The three
|
||||
domains are independent.
|
||||
|
||||
## 6. Validation / checks performed (file-backed, read-only)
|
||||
|
||||
Run from `/home/user/manage` (no source edits, no test re-runs — those are owned by verify and were
|
||||
already green at `c9404f0`):
|
||||
|
||||
| Check | Command | Result |
|
||||
|---|---|---|
|
||||
| Canonical store populated | `ls openspec/specs/service-storage/spec.md` | present ✓ |
|
||||
| Change-side domain spec present | `ls openspec/changes/service-storage-harness/specs/service-storage/spec.md` | present ✓ |
|
||||
| Requirement-ID parity (flat ↔ delta ↔ canonical) | `grep -oE 'SS-[0-9]+'` all three files, `sort -u` | **28 == 28 == 28**, identical IDs SS-101…SS-128 ✓ |
|
||||
| Body-text parity (delta ↔ canonical) | `diff` of the `^### Requirement:` region of both files | **identical** ✓ |
|
||||
| Delta is pure ADDED | count `## ADDED/MODIFIED/REMOVED/RENAMED Requirements` | ADDED=1, MODIFIED=0, REMOVED=0, RENAMED=0 ✓ (no destructive sync) |
|
||||
| Other canonicals untouched | `git status --porcelain openspec/specs/web-ui openspec/specs/prometheus-charting` | empty (not modified) ✓ |
|
||||
| No edits outside openspec | `git status --porcelain` (filtered) | only `openspec/specs/service-storage/`, `openspec/changes/service-storage-harness/specs/`, and this report added ✓ |
|
||||
| Markdown validity | write-time lint | all three files "Markdown clean" ✓ |
|
||||
|
||||
## 7. Carry-over items for the archive summary
|
||||
|
||||
These verify-phase findings are non-blocking for sync and should land in the archive summary:
|
||||
|
||||
1. **[CRITICAL-process, archive-only] Unchecked task checkboxes.** At verify time, 30
|
||||
implementation/verification task checkboxes (§1.1–5.5) were unchecked and `apply-progress.md`
|
||||
was missing. `apply-progress.md` now exists (created after the verify pass, reconciling all 35
|
||||
tasks). `sdd-archive` should re-scan the native status engine to confirm `tasks: done` /
|
||||
`applyProgress: present` before moving the change to archive, and tick any remaining unchecked
|
||||
boxes if needed.
|
||||
2. **[WARNING] Slice 2 over the 400-line review budget** (~644 source insertions vs the 400-line
|
||||
budget / ~350–400 forecast). Additive feature slice (3 widgets + `LineSeriesChart` extraction +
|
||||
tests); boundary is exactly the qBit-widget feature, no scope creep. No `size:exception`
|
||||
recorded; non-blocking — record the actual in the archive summary.
|
||||
3. **[INFO] `LineSeriesChart.test.tsx` is smoke-only** (asserts mount, not rendered `<Line>` series).
|
||||
Non-blocking coverage note.
|
||||
4. **[INFO] Stale generated `.pi-map.md`** files predate the new modules (`service_data.py`,
|
||||
`qbittorrent_store.py`, `clients/qbittorrent.py`, `Qbittorrent*.tsx`, `LineSeriesChart.tsx`);
|
||||
not deliverable source. Regenerate via `project_map_patch` / `project_map_validate`.
|
||||
|
||||
## 8. Next recommended phase
|
||||
|
||||
→ **`sdd-archive`** (clean). Confirm the native status re-scan reports `specs: done` / `sync: ready`
|
||||
/ `archive: ready`, then move the change to
|
||||
`openspec/changes/archive/2026-07-09-service-storage-harness`, carrying over the items in §7 into
|
||||
the archive summary. Do **not** commit or push — the parent owns the commit with explicit paths.
|
||||
|
||||
---
|
||||
|
||||
### Appendix — Files written by this sync (OpenSpec only; no source code)
|
||||
|
||||
- `openspec/changes/service-storage-harness/specs/service-storage/spec.md` — **change-side
|
||||
domain delta (`## ADDED Requirements`), 28 requirements SS-101…SS-128.**
|
||||
- `openspec/specs/service-storage/spec.md` — **canonical spec (new domain), 28 requirements.**
|
||||
- `openspec/changes/service-storage-harness/sync-report.md` — this report.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user