Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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)."
|
||||||
|
}
|
||||||
@@ -26,6 +26,17 @@ class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
|||||||
panel_id: int | None = None
|
panel_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GrafanaChartWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Render a time-series chart from a Grafana datasource query."""
|
||||||
|
|
||||||
|
datasource_uid: str = "prometheus"
|
||||||
|
query: str = ""
|
||||||
|
from_ts: str = "now-1h"
|
||||||
|
to_ts: str = "now"
|
||||||
|
interval_ms: int = 30_000
|
||||||
|
max_data_points: int = 100
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="grafana",
|
service_type="grafana",
|
||||||
name="Grafana",
|
name="Grafana",
|
||||||
@@ -43,5 +54,20 @@ DEFINITION = ServiceDefinition(
|
|||||||
default_config={"dashboard_uid": ""},
|
default_config={"dashboard_uid": ""},
|
||||||
refresh_interval_ms=0,
|
refresh_interval_ms=0,
|
||||||
),
|
),
|
||||||
|
widget_kind(
|
||||||
|
kind="chart",
|
||||||
|
name="Chart",
|
||||||
|
description="Live time-series chart from a Grafana datasource query.",
|
||||||
|
model_cls=GrafanaChartWidgetConfig,
|
||||||
|
default_config={
|
||||||
|
"datasource_uid": "prometheus",
|
||||||
|
"query": "",
|
||||||
|
"from_ts": "now-1h",
|
||||||
|
"to_ts": "now",
|
||||||
|
"interval_ms": 30_000,
|
||||||
|
"max_data_points": 100,
|
||||||
|
},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Only show sessions with active playback (not idle/paused)."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
DEFINITION = ServiceDefinition(
|
DEFINITION = ServiceDefinition(
|
||||||
service_type="jellyfin",
|
service_type="jellyfin",
|
||||||
name="Jellyfin",
|
name="Jellyfin",
|
||||||
@@ -53,5 +59,13 @@ DEFINITION = ServiceDefinition(
|
|||||||
default_config={},
|
default_config={},
|
||||||
refresh_interval_ms=30_000,
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
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.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.integrations.base import validate_config
|
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,
|
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"])
|
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -103,10 +113,18 @@ def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
|||||||
|
|
||||||
@router.get("/instances")
|
@router.get("/instances")
|
||||||
def list_instances(
|
def list_instances(
|
||||||
|
service_id: str | None = None,
|
||||||
|
scope: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return all persisted widget instances."""
|
"""Return widget instances, optionally filtered.
|
||||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
|
||||||
|
- ``?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)
|
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -213,3 +231,52 @@ async def fetch_data(
|
|||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
fetched_at=int(time.time()),
|
fetched_at=int(time.time()),
|
||||||
).model_dump()
|
).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.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
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ class SettingsStore:
|
|||||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||||
if "widget_kind" not in widget_cols:
|
if "widget_kind" not in widget_cols:
|
||||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
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("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -1403,10 +1416,36 @@ class SettingsStore:
|
|||||||
"sort_order": sort_order,
|
"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()
|
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:
|
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]
|
return [self._row_to_widget(row) for row in rows]
|
||||||
|
|
||||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||||
@@ -1465,6 +1504,98 @@ class SettingsStore:
|
|||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
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 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: new widget with service_id=NULL (dashboard scope), same config/kind/title.
|
||||||
|
cloned = self.upsert_widget(
|
||||||
|
{
|
||||||
|
"service_id": None,
|
||||||
|
"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
|
# Service registry
|
||||||
|
|||||||
@@ -97,13 +97,20 @@ class StaticWidgetSource:
|
|||||||
|
|
||||||
|
|
||||||
class GrafanaWidgetSource:
|
class GrafanaWidgetSource:
|
||||||
"""Build a Grafana deep-link (no embedding)."""
|
"""Build a Grafana deep-link or query datasource for a chart."""
|
||||||
|
|
||||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
if service is None:
|
if service is None:
|
||||||
return {"error": "Grafana widget is missing its service"}
|
return {"error": "Grafana widget is missing its service"}
|
||||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
|
api_key = str(service.secrets.get("api_key") or "")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||||
|
|
||||||
|
if widget_kind == "chart":
|
||||||
|
return await self._fetch_chart(base_url, api_key, timeout, config)
|
||||||
|
|
||||||
|
# Default: deep-link
|
||||||
dashboard_uid = config.get("dashboard_uid")
|
dashboard_uid = config.get("dashboard_uid")
|
||||||
if not dashboard_uid:
|
if not dashboard_uid:
|
||||||
return {"error": "dashboard_uid is required"}
|
return {"error": "dashboard_uid is required"}
|
||||||
@@ -116,6 +123,90 @@ class GrafanaWidgetSource:
|
|||||||
logger.exception("grafana adapter failed")
|
logger.exception("grafana adapter failed")
|
||||||
return {"error": f"Grafana link failed: {exc}"}
|
return {"error": f"Grafana link failed: {exc}"}
|
||||||
|
|
||||||
|
async def _fetch_chart(self, base_url: str, api_key: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Query Grafana /api/ds/query and normalize to {series: [...]}."""
|
||||||
|
if not api_key:
|
||||||
|
return {"error": "Grafana api_key is required for chart queries"}
|
||||||
|
query = config.get("query", "")
|
||||||
|
if not query:
|
||||||
|
return {"error": "query is required"}
|
||||||
|
|
||||||
|
datasource_uid = config.get("datasource_uid", "prometheus")
|
||||||
|
body = {
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||||
|
"expr": query,
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalMs": int(config.get("interval_ms", 30_000)),
|
||||||
|
"maxDataPoints": int(config.get("max_data_points", 100)),
|
||||||
|
"refId": "A",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"from": config.get("from_ts", "now-1h"),
|
||||||
|
"to": config.get("to_ts", "now"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _do_post() -> dict[str, Any]:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{base_url}/api/ds/query",
|
||||||
|
json=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Grafana query timed out"}
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return {"error": f"Grafana query failed: {exc}"}
|
||||||
|
|
||||||
|
# Normalize Grafana's /api/ds/query response into series.
|
||||||
|
series: list[dict[str, Any]] = []
|
||||||
|
results = raw.get("results", {})
|
||||||
|
seen_labels: dict[str, int] = {}
|
||||||
|
for ref_id, ref_data in results.items():
|
||||||
|
for frame in ref_data.get("frames", []):
|
||||||
|
values = frame.get("data", {}).get("values", [])
|
||||||
|
if len(values) < 2:
|
||||||
|
continue
|
||||||
|
timestamps = values[0]
|
||||||
|
vals = values[1]
|
||||||
|
# Derive a meaningful series label from the frame metadata.
|
||||||
|
# Prometheus frames carry metric labels in schema.fields[-1].labels.
|
||||||
|
fields = frame.get("schema", {}).get("fields", [])
|
||||||
|
value_field = fields[-1] if fields else {}
|
||||||
|
# Prefer displayName (explicitly set in Grafana), then Prometheus
|
||||||
|
# labels (e.g. {instance: "server:9100", mode: "iowait"}), then
|
||||||
|
# the field name as a last resort.
|
||||||
|
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
|
||||||
|
frame_labels = value_field.get("labels") or {}
|
||||||
|
if display_name:
|
||||||
|
label = str(display_name)
|
||||||
|
elif frame_labels:
|
||||||
|
# Build a readable label from the Prometheus labels, excluding
|
||||||
|
# redundant ones like __name__.
|
||||||
|
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not k.startswith("__")]
|
||||||
|
label = " ".join(parts) if parts else "value"
|
||||||
|
else:
|
||||||
|
label = value_field.get("name", "value")
|
||||||
|
# Ensure unique labels when multiple series share the same name.
|
||||||
|
if label in seen_labels:
|
||||||
|
seen_labels[label] += 1
|
||||||
|
label = f"{label} ({seen_labels[label]})"
|
||||||
|
else:
|
||||||
|
seen_labels[label] = 0
|
||||||
|
points = [{"t": int(t), "v": float(v) if v is not None else None} for t, v in zip(timestamps, vals)]
|
||||||
|
series.append({"label": label, "points": points})
|
||||||
|
|
||||||
|
return {"series": series}
|
||||||
|
|
||||||
|
|
||||||
class PrometheusWidgetSource:
|
class PrometheusWidgetSource:
|
||||||
"""Run a PromQL instant query against a Prometheus service."""
|
"""Run a PromQL instant query against a Prometheus service."""
|
||||||
@@ -208,6 +299,10 @@ class JellyfinWidgetSource:
|
|||||||
asyncio.to_thread(client.sessions),
|
asyncio.to_thread(client.sessions),
|
||||||
timeout=timeout,
|
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)
|
rows = _map_sessions_to_activity_rows(sessions)
|
||||||
return {"sessions": rows}
|
return {"sessions": rows}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ def test_authentik_service_definition():
|
|||||||
|
|
||||||
|
|
||||||
def test_definitions_declare_widget_kinds():
|
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("grafana").widget_kinds} == {"link", "chart"}
|
||||||
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"}
|
||||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
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("nextcloud").widget_kinds == []
|
||||||
assert get_service_definition("authentik").widget_kinds == []
|
assert get_service_definition("authentik").widget_kinds == []
|
||||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
||||||
@@ -184,7 +184,7 @@ def test_service_type_includes_secret_and_widget_metadata(client):
|
|||||||
response = client.get("/api/services/types")
|
response = client.get("/api/services/types")
|
||||||
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
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 [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||||
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"]
|
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link", "chart"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from media_library_viewer_api.widgets.sources import (
|
|||||||
AlertmanagerWidgetSource,
|
AlertmanagerWidgetSource,
|
||||||
BackupsWidgetSource,
|
BackupsWidgetSource,
|
||||||
GrafanaWidgetSource,
|
GrafanaWidgetSource,
|
||||||
|
JellyfinWidgetSource,
|
||||||
ServiceRecord,
|
ServiceRecord,
|
||||||
StaticWidgetSource,
|
StaticWidgetSource,
|
||||||
)
|
)
|
||||||
@@ -90,6 +91,44 @@ def test_create_backups_widget(client):
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_filtering_by_service_id_and_scope(client):
|
||||||
|
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
|
||||||
|
service = _make_grafana_service(client)
|
||||||
|
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
|
||||||
|
client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "link",
|
||||||
|
"title": "Dash",
|
||||||
|
"config": {"dashboard_uid": "o"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# No filter: both widgets.
|
||||||
|
all_widgets = client.get("/api/widgets/instances").json()
|
||||||
|
assert len(all_widgets) == 2
|
||||||
|
|
||||||
|
# Filter by service_id: only the service-scoped one.
|
||||||
|
by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
|
||||||
|
assert len(by_service) == 1
|
||||||
|
assert by_service[0]["service_id"] == service["id"]
|
||||||
|
|
||||||
|
# scope=dashboard: only the built-in (NULL service_id).
|
||||||
|
dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
|
||||||
|
assert len(dashboard_scope) == 1
|
||||||
|
assert dashboard_scope[0]["service_id"] is None
|
||||||
|
|
||||||
|
# scope=service: only the non-null service_id widget.
|
||||||
|
service_scope = client.get("/api/widgets/instances?scope=service").json()
|
||||||
|
assert len(service_scope) == 1
|
||||||
|
assert service_scope[0]["service_id"] == service["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_builtin_kind_rejected(client):
|
def test_unknown_builtin_kind_rejected(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
@@ -456,3 +495,362 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
|||||||
runs = store.list_service_task_runs(service_id=service["id"])
|
runs = store.list_service_task_runs(service_id=service["id"])
|
||||||
assert len(runs) == 1
|
assert len(runs) == 1
|
||||||
assert runs[0]["status"] == "success"
|
assert runs[0]["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# New widget kind tests (jellyfin now_playing + grafana panel)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_grafana_definition_has_chart_widget():
|
||||||
|
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||||
|
|
||||||
|
definition = get_service_definition("grafana")
|
||||||
|
kinds = {wk.kind for wk in definition.widget_kinds}
|
||||||
|
assert "chart" in kinds
|
||||||
|
assert "link" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grafana_adapter_chart_queries_datasource():
|
||||||
|
"""Chart widget should POST to /api/ds/query and normalize the response."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="s",
|
||||||
|
service_type="grafana",
|
||||||
|
name="g",
|
||||||
|
config={"base_url": "http://g:3000", "timeout_seconds": 5},
|
||||||
|
secrets={"api_key": "tok"},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": [[1000, 2000], [0.5, 0.8]]},
|
||||||
|
"schema": {"fields": [{"name": "Time"}, {"name": "cpu_usage"}]},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=mock_resp):
|
||||||
|
result = await adapter.fetch(
|
||||||
|
service,
|
||||||
|
"chart",
|
||||||
|
{"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "series" in result
|
||||||
|
assert len(result["series"]) == 1
|
||||||
|
assert result["series"][0]["label"] == "cpu_usage"
|
||||||
|
assert result["series"][0]["points"] == [
|
||||||
|
{"t": 1000, "v": 0.5},
|
||||||
|
{"t": 2000, "v": 0.8},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grafana_adapter_chart_extracts_prometheus_labels():
|
||||||
|
"""Multiple Prometheus series should get unique labels from frame metadata."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="s",
|
||||||
|
service_type="grafana",
|
||||||
|
name="g",
|
||||||
|
config={"base_url": "http://g:3000", "timeout_seconds": 5},
|
||||||
|
secrets={"api_key": "tok"},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {
|
||||||
|
"results": {
|
||||||
|
"A": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"data": {"values": [[1000], [0.5]]},
|
||||||
|
"schema": {
|
||||||
|
"fields": [
|
||||||
|
{"name": "Time"},
|
||||||
|
{
|
||||||
|
"name": "Value",
|
||||||
|
"labels": {
|
||||||
|
"instance": "server1:9100",
|
||||||
|
"mode": "iowait",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {"values": [[1000], [0.3]]},
|
||||||
|
"schema": {
|
||||||
|
"fields": [
|
||||||
|
{"name": "Time"},
|
||||||
|
{
|
||||||
|
"name": "Value",
|
||||||
|
"labels": {
|
||||||
|
"instance": "server2:9100",
|
||||||
|
"mode": "iowait",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=mock_resp):
|
||||||
|
result = await adapter.fetch(service, "chart", {"query": "rate(cpu[5m])"})
|
||||||
|
|
||||||
|
assert len(result["series"]) == 2
|
||||||
|
assert result["series"][0]["label"] == "instance=server1:9100 mode=iowait"
|
||||||
|
assert result["series"][1]["label"] == "instance=server2:9100 mode=iowait"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grafana_adapter_chart_requires_api_key():
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="s",
|
||||||
|
service_type="grafana",
|
||||||
|
name="g",
|
||||||
|
config={"base_url": "http://g:3000"},
|
||||||
|
)
|
||||||
|
result = await adapter.fetch(service, "chart", {"query": "up"})
|
||||||
|
assert "error" in result
|
||||||
|
assert "api_key" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grafana_adapter_chart_handles_http_failure():
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import requests as req_mod
|
||||||
|
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
service = ServiceRecord(
|
||||||
|
id="s",
|
||||||
|
service_type="grafana",
|
||||||
|
name="g",
|
||||||
|
config={"base_url": "http://g:3000", "timeout_seconds": 2},
|
||||||
|
secrets={"api_key": "tok"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"media_library_viewer_api.widgets.sources.requests.post",
|
||||||
|
side_effect=req_mod.ConnectionError("refused"),
|
||||||
|
):
|
||||||
|
result = await adapter.fetch(service, "chart", {"query": "up"})
|
||||||
|
|
||||||
|
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 Grafana Overview).
|
||||||
|
store.upsert_service(
|
||||||
|
{
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"api_key": "tok"},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = store.list_services("grafana")[0]
|
||||||
|
widget = store.upsert_widget({
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "chart",
|
||||||
|
"title": "CPU IOWait",
|
||||||
|
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||||
|
"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": "grafana",
|
||||||
|
"name": "Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"api_key": "tok"},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = store.list_services("grafana")[0]
|
||||||
|
widget = store.upsert_widget({
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "chart",
|
||||||
|
"title": "Memory",
|
||||||
|
"config": {"query": "mem", "datasource_uid": "prometheus"},
|
||||||
|
"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"] is None # dashboard-scoped clone
|
||||||
|
assert cloned["config"]["query"] == "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
|
||||||
|
|||||||
Generated
+367
-1
@@ -20,6 +20,7 @@
|
|||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-oidc-context": "^3.3.1",
|
"react-oidc-context": "^3.3.1",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
|
"recharts": "^3.9.2",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
@@ -2997,6 +2998,32 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.0-rc.17",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
"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",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
@@ -3772,6 +3804,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3852,6 +3947,12 @@
|
|||||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
@@ -4990,6 +5091,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
@@ -5037,6 +5259,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -5311,6 +5539,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -5553,6 +5791,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/eventsource": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
@@ -6291,6 +6535,16 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -6333,6 +6587,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
@@ -8109,6 +8372,13 @@
|
|||||||
"react": "^19.2.5"
|
"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": {
|
"node_modules/react-oidc-context": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz",
|
||||||
@@ -8122,6 +8392,29 @@
|
|||||||
"react": ">=16.14.0"
|
"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": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -8254,6 +8547,36 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -8268,6 +8591,21 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -8286,6 +8624,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -9377,6 +9721,28 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-oidc-context": "^3.3.1",
|
"react-oidc-context": "^3.3.1",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
|
"recharts": "^3.9.2",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
|
|||||||
@@ -6,14 +6,35 @@ import type {
|
|||||||
WidgetInstanceInput,
|
WidgetInstanceInput,
|
||||||
} from "../types";
|
} 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<
|
export async function fetchBuiltinWidgetKinds(): Promise<
|
||||||
BuiltinWidgetKindInfo[]
|
BuiltinWidgetKindInfo[]
|
||||||
> {
|
> {
|
||||||
return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
|
return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
export async function fetchWidgetInstances(
|
||||||
return get<WidgetInstance[]>("/api/widgets/instances");
|
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(
|
export async function createWidgetInstance(
|
||||||
@@ -40,3 +61,29 @@ export async function fetchWidgetData(
|
|||||||
): Promise<WidgetDataResponse> {
|
): Promise<WidgetDataResponse> {
|
||||||
return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`);
|
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`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
@@ -18,11 +19,23 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Link2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Split,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCreateWidgetReference,
|
||||||
useDeleteWidgetInstance,
|
useDeleteWidgetInstance,
|
||||||
|
useDeleteWidgetReference,
|
||||||
|
useDetachWidgetReference,
|
||||||
useSaveWidgetInstance,
|
useSaveWidgetInstance,
|
||||||
useWidgetInstances,
|
useWidgetInstances,
|
||||||
|
useWidgetReferences,
|
||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useTasks } from "../hooks/useSettings";
|
import { useTasks } from "../hooks/useSettings";
|
||||||
@@ -38,6 +51,10 @@ import {
|
|||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
@@ -135,6 +152,12 @@ function WidgetConfigEditor({
|
|||||||
const isNumber =
|
const isNumber =
|
||||||
(schema as { type?: string }).type === "integer" ||
|
(schema as { type?: string }).type === "integer" ||
|
||||||
(schema as { type?: string }).type === "number";
|
(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 (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
@@ -142,6 +165,15 @@ function WidgetConfigEditor({
|
|||||||
htmlFor={`widget-cfg-${key}`}
|
htmlFor={`widget-cfg-${key}`}
|
||||||
helper={(schema as { description?: string }).description}
|
helper={(schema as { description?: string }).description}
|
||||||
>
|
>
|
||||||
|
{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
|
<Input
|
||||||
id={`widget-cfg-${key}`}
|
id={`widget-cfg-${key}`}
|
||||||
type={isNumber ? "number" : "text"}
|
type={isNumber ? "number" : "text"}
|
||||||
@@ -157,6 +189,7 @@ function WidgetConfigEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -164,12 +197,24 @@ function WidgetConfigEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WidgetConfigDialog({ open, onClose }: Props) {
|
export function WidgetConfigDialog({
|
||||||
const { data: instances = [] } = useWidgetInstances();
|
open,
|
||||||
|
onClose,
|
||||||
|
serviceId,
|
||||||
|
dashboardScope,
|
||||||
|
}: Props) {
|
||||||
|
const { data: instances = [] } = useWidgetInstances(serviceId);
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const { data: tasks = [] } = useTasks();
|
const { data: tasks = [] } = useTasks();
|
||||||
const saveWidget = useSaveWidgetInstance();
|
const saveWidget = useSaveWidgetInstance();
|
||||||
const deleteWidget = useDeleteWidgetInstance();
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
|
const { data: references = [] } = useWidgetReferences(dashboardScope);
|
||||||
|
const createRef = useCreateWidgetReference();
|
||||||
|
const deleteRef = useDeleteWidgetReference();
|
||||||
|
const detachRef = useDetachWidgetReference();
|
||||||
|
const { data: allWidgets = [] } = useWidgetInstances();
|
||||||
|
const [showExisting, setShowExisting] = useState(false);
|
||||||
|
const [existingSearch, setExistingSearch] = useState("");
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
|
||||||
@@ -184,7 +229,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
function startAddBuiltIn(kind: string) {
|
function startAddBuiltIn(kind: string) {
|
||||||
const binding = BUILTIN_WIDGETS[kind];
|
const binding = BUILTIN_WIDGETS[kind];
|
||||||
setDraft({
|
setDraft({
|
||||||
serviceId: null,
|
serviceId: serviceId ?? null,
|
||||||
widgetKind: kind,
|
widgetKind: kind,
|
||||||
title: binding?.name ?? kind,
|
title: binding?.name ?? kind,
|
||||||
config: { ...(binding?.defaultConfig ?? {}) },
|
config: { ...(binding?.defaultConfig ?? {}) },
|
||||||
@@ -255,16 +300,65 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
|
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
|
||||||
const a = sortedInstances[index];
|
const a = sortedInstances[index];
|
||||||
const b = sortedInstances[targetIndex];
|
const b = sortedInstances[targetIndex];
|
||||||
await Promise.all([
|
// Sequential (not Promise.all) to avoid a race where the first mutation's
|
||||||
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }),
|
// cache invalidation refetches before the second completes, reverting the swap.
|
||||||
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }),
|
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order });
|
||||||
]);
|
await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeInstance(instance: WidgetInstance) {
|
async function removeInstance(instance: WidgetInstance) {
|
||||||
await deleteWidget.mutateAsync(instance.id);
|
await deleteWidget.mutateAsync(instance.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a combined view of owned widgets + references for display.
|
||||||
|
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
|
||||||
|
const combinedWidgets = useMemo(() => {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
return [...owned, ...refs].sort(
|
||||||
|
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||||
|
);
|
||||||
|
}, [instances, references]);
|
||||||
|
|
||||||
|
// 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) {
|
function handleClose(next: boolean) {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
reset();
|
reset();
|
||||||
@@ -332,10 +426,18 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
{!isMobile ? (
|
{!isMobile ? (
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={saveDraft} disabled={saveWidget.isPending} className="mobile-touch-target">
|
<Button
|
||||||
|
onClick={saveDraft}
|
||||||
|
disabled={saveWidget.isPending}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Save widget
|
Save widget
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,16 +445,19 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{sortedInstances.length === 0 ? (
|
{combinedWidgets.length === 0 ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{sortedInstances.map((instance, index) => {
|
{combinedWidgets.map((instance, index) => {
|
||||||
const serviceName = instance.service_id
|
const serviceName = instance.service_id
|
||||||
? services.find((s) => s.id === instance.service_id)?.name
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
: "Built-in";
|
: "Built-in";
|
||||||
|
const isRef =
|
||||||
|
(instance as { _is_reference?: boolean })._is_reference === true;
|
||||||
|
const refId = (instance as { _ref_id?: string })._ref_id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={instance.id}
|
key={instance.id}
|
||||||
@@ -361,6 +466,12 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
<div className="flex flex-1 flex-col gap-1">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{instance.title}</span>
|
<span className="font-medium">{instance.title}</span>
|
||||||
|
{isRef ? (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Link2 className="mr-1 h-3 w-3" />
|
||||||
|
linked
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{bindingLabel(instance.service_id, instance.widget_kind)}
|
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -388,17 +499,20 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mobile-touch-target h-8 w-8"
|
className="mobile-touch-target h-8 w-8"
|
||||||
disabled={index === sortedInstances.length - 1}
|
disabled={index === combinedWidgets.length - 1}
|
||||||
onClick={() => moveInstance(index, 1)}
|
onClick={() => moveInstance(index, 1)}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isRef ? (
|
||||||
<Switch
|
<Switch
|
||||||
className="mobile-touch-target"
|
className="mobile-touch-target"
|
||||||
checked={instance.enabled}
|
checked={instance.enabled}
|
||||||
onCheckedChange={() => toggleEnabled(instance)}
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
aria-label={`Toggle ${instance.title}`}
|
aria-label={`Toggle ${instance.title}`}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{!isRef ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -407,11 +521,30 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mobile-touch-target h-8 w-8 text-destructive"
|
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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -422,6 +555,65 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-sm font-medium">Add widget</p>
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ function setMatchMedia(matches: boolean) {
|
|||||||
|
|
||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useWidgetReferences: () => ({ data: [] }),
|
||||||
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useServices", () => ({
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function Tabs({
|
|||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -23,7 +23,7 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -61,10 +61,10 @@ function TabsTrigger({
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
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",
|
"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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createWidgetInstance,
|
createWidgetInstance,
|
||||||
|
createWidgetReference,
|
||||||
deleteWidgetInstance,
|
deleteWidgetInstance,
|
||||||
|
deleteWidgetReference,
|
||||||
|
detachWidgetReference,
|
||||||
fetchBuiltinWidgetKinds,
|
fetchBuiltinWidgetKinds,
|
||||||
fetchWidgetData,
|
fetchWidgetData,
|
||||||
fetchWidgetInstances,
|
fetchWidgetInstances,
|
||||||
|
fetchWidgetReferences,
|
||||||
updateWidgetInstance,
|
updateWidgetInstance,
|
||||||
} from "../api/widgets";
|
} from "../api/widgets";
|
||||||
import type { WidgetInstanceInput } from "../types";
|
import type { WidgetInstanceInput } from "../types";
|
||||||
|
|
||||||
export function useWidgetInstances() {
|
export function useWidgetInstances(
|
||||||
|
serviceId?: string,
|
||||||
|
scope?: "dashboard" | "service",
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["widgets", "instances"],
|
queryKey: ["widgets", "instances", serviceId ?? null, scope ?? null],
|
||||||
queryFn: fetchWidgetInstances,
|
queryFn: () => fetchWidgetInstances(serviceId, scope),
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,3 +62,42 @@ export function useBuiltinWidgetKinds() {
|
|||||||
staleTime: 5 * 60 * 1000,
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ describe("navEntries", () => {
|
|||||||
expect(configuredNavEntries(new Set())).toEqual([]);
|
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"]));
|
const entries = configuredNavEntries(new Set(["jellyfin"]));
|
||||||
expect(entries).toHaveLength(1);
|
expect(entries).toHaveLength(1);
|
||||||
expect(entries[0].label).toBe("Media");
|
expect(entries[0].label).toBe("Jellyfin");
|
||||||
expect(entries[0].path).toBe("/services/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"]));
|
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
|
||||||
expect(entries).toHaveLength(2);
|
expect(entries).toHaveLength(1);
|
||||||
expect(entries.map((e) => e.label)).toEqual(["Files", "Actions"]);
|
expect(entries[0].label).toBe("SSH Tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns all observability entries", () => {
|
it("returns all observability entries", () => {
|
||||||
@@ -24,15 +24,15 @@ describe("navEntries", () => {
|
|||||||
new Set(["alertmanager", "grafana", "prometheus"]),
|
new Set(["alertmanager", "grafana", "prometheus"]),
|
||||||
);
|
);
|
||||||
expect(entries.map((e) => e.label)).toEqual([
|
expect(entries.map((e) => e.label)).toEqual([
|
||||||
"Alerts",
|
"Alertmanager",
|
||||||
"Grafana",
|
"Grafana",
|
||||||
"Prometheus",
|
"Prometheus",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns Backups + Users when configured", () => {
|
it("returns Backups + Authentik when configured", () => {
|
||||||
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
|
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", () => {
|
it("nextcloud has no nav entries in the static map", () => {
|
||||||
@@ -46,10 +46,9 @@ describe("navEntries", () => {
|
|||||||
new Set(["authentik", "ssh_tasks", "jellyfin"]),
|
new Set(["authentik", "ssh_tasks", "jellyfin"]),
|
||||||
);
|
);
|
||||||
expect(entries.map((e) => e.label)).toEqual([
|
expect(entries.map((e) => e.label)).toEqual([
|
||||||
"Media",
|
"Jellyfin",
|
||||||
"Files",
|
"SSH Tasks",
|
||||||
"Actions",
|
"Authentik",
|
||||||
"Users",
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Service-type → conditional nav-entry map.
|
* Service-type → conditional nav-entry map.
|
||||||
*
|
*
|
||||||
* Each configured service type contributes one or more top-level nav entries
|
* Each configured service type contributes ONE top-level nav entry that
|
||||||
* that appear only when at least one enabled instance of that type exists.
|
* appears only when at least one enabled instance of that type exists. The
|
||||||
* See OpenSpec change `services-as-hub-ia`, spec R1.2.
|
* 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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
DatabaseBackup,
|
DatabaseBackup,
|
||||||
FolderOpen,
|
|
||||||
GanttChartSquare,
|
GanttChartSquare,
|
||||||
Link2,
|
Link2,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
Server,
|
||||||
Users,
|
Users,
|
||||||
Zap,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -26,31 +26,26 @@ export interface NavEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static mapping from service type to its conditional nav entries.
|
* Static mapping from service type to its conditional nav entry.
|
||||||
* `nextcloud` has no entries (no operational content).
|
* 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[] = [
|
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||||
{
|
{
|
||||||
serviceType: "jellyfin",
|
serviceType: "jellyfin",
|
||||||
label: "Media",
|
label: "Jellyfin",
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
path: "/services/jellyfin",
|
path: "/services/jellyfin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serviceType: "ssh_tasks",
|
serviceType: "ssh_tasks",
|
||||||
label: "Files",
|
label: "SSH Tasks",
|
||||||
icon: FolderOpen,
|
icon: Server,
|
||||||
path: "/services/ssh_tasks",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serviceType: "ssh_tasks",
|
|
||||||
label: "Actions",
|
|
||||||
icon: Zap,
|
|
||||||
path: "/services/ssh_tasks",
|
path: "/services/ssh_tasks",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serviceType: "alertmanager",
|
serviceType: "alertmanager",
|
||||||
label: "Alerts",
|
label: "Alertmanager",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
path: "/services/alertmanager",
|
path: "/services/alertmanager",
|
||||||
},
|
},
|
||||||
@@ -74,7 +69,7 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
serviceType: "authentik",
|
serviceType: "authentik",
|
||||||
label: "Users",
|
label: "Authentik",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
path: "/services/authentik",
|
path: "/services/authentik",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe("service registry", () => {
|
|||||||
it("binds widget kinds per service", () => {
|
it("binds widget kinds per service", () => {
|
||||||
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
||||||
"link",
|
"link",
|
||||||
|
"chart",
|
||||||
]);
|
]);
|
||||||
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
|
||||||
"active_alerts",
|
"active_alerts",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import type { ComponentType } from "react";
|
|||||||
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
||||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||||
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||||
|
import { GrafanaChartWidget } from "../widgets/GrafanaChartWidget";
|
||||||
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||||
|
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
|
||||||
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
||||||
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||||
import { StaticWidget } from "../widgets/StaticWidget";
|
import { StaticWidget } from "../widgets/StaticWidget";
|
||||||
@@ -86,6 +88,39 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
},
|
},
|
||||||
component: GrafanaLinkWidget,
|
component: GrafanaLinkWidget,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: "chart",
|
||||||
|
name: "Chart",
|
||||||
|
description: "Live time-series chart from a Grafana datasource query.",
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfig: {
|
||||||
|
datasource_uid: "prometheus",
|
||||||
|
query: "",
|
||||||
|
from_ts: "now-1h",
|
||||||
|
to_ts: "now",
|
||||||
|
interval_ms: 30_000,
|
||||||
|
max_data_points: 100,
|
||||||
|
},
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
datasource_uid: {
|
||||||
|
type: "string",
|
||||||
|
description: "Grafana datasource UID (e.g. 'prometheus')",
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Query expression (e.g. PromQL)",
|
||||||
|
},
|
||||||
|
from_ts: { type: "string" },
|
||||||
|
to_ts: { type: "string" },
|
||||||
|
interval_ms: { type: "integer" },
|
||||||
|
max_data_points: { type: "integer" },
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
component: GrafanaChartWidget,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
prometheus: {
|
prometheus: {
|
||||||
@@ -122,6 +157,15 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
|||||||
configSchema: { type: "object", properties: {}, required: [] },
|
configSchema: { type: "object", properties: {}, required: [] },
|
||||||
component: JellyfinWidget,
|
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: {
|
nextcloud: {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
useDeleteDashboardShortcut,
|
useDeleteDashboardShortcut,
|
||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
import { useWidgetInstances, useWidgetReferences } from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useIsMobile } from "../hooks/useIsMobile";
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type {
|
import type {
|
||||||
@@ -146,7 +146,6 @@ function MobileWidgetSections({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function emptyShortcut(): DashboardShortcutInput {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -449,17 +448,22 @@ export function Dashboard() {
|
|||||||
);
|
);
|
||||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
const { data: widgetInstances = [] } = useWidgetInstances(
|
||||||
|
undefined,
|
||||||
|
"dashboard",
|
||||||
|
);
|
||||||
|
const { data: widgetReferences = [] } = useWidgetReferences("main");
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(() => {
|
||||||
() =>
|
const refs = widgetReferences
|
||||||
widgetInstances
|
.filter((r) => r.widget.enabled)
|
||||||
|
.map((r) => r.widget);
|
||||||
|
return [...widgetInstances, ...refs]
|
||||||
.filter((w) => w.enabled)
|
.filter((w) => w.enabled)
|
||||||
.sort((a, b) => a.sort_order - b.sort_order),
|
.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
[widgetInstances],
|
}, [widgetInstances, widgetReferences]);
|
||||||
);
|
|
||||||
|
|
||||||
const mobileSections = useMemo(
|
const mobileSections = useMemo(
|
||||||
() => groupWidgetsBySection(visibleWidgets, services),
|
() => groupWidgetsBySection(visibleWidgets, services),
|
||||||
@@ -590,6 +594,7 @@ export function Dashboard() {
|
|||||||
<WidgetConfigDialog
|
<WidgetConfigDialog
|
||||||
open={widgetDialogOpen}
|
open={widgetDialogOpen}
|
||||||
onClose={() => setWidgetDialogOpen(false)}
|
onClose={() => setWidgetDialogOpen(false)}
|
||||||
|
dashboardScope="main"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
Select,
|
import type { ServiceInstance } from "../types";
|
||||||
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 { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
|
||||||
import { getServiceBinding } from "../integrations/registry";
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
import {
|
import {
|
||||||
OVERVIEW_TAB,
|
OVERVIEW_TAB,
|
||||||
@@ -36,79 +13,30 @@ import {
|
|||||||
type ContentTab,
|
type ContentTab,
|
||||||
} from "./service-tabs";
|
} 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() {
|
export function ServicePage() {
|
||||||
const { serviceType = "", serviceId = "" } = useParams<{
|
const { serviceType = "", serviceId = "" } = useParams<{
|
||||||
serviceType: string;
|
serviceType: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
}>();
|
}>();
|
||||||
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||||
const { data: types = [] } = useServiceTypes();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const saveService = useSaveServiceInstance();
|
|
||||||
const deleteService = useDeleteServiceInstance();
|
|
||||||
|
|
||||||
const instance = useMemo(
|
const instance = useMemo(
|
||||||
() => services.find((s) => s.id === serviceId),
|
() => services.find((s) => s.id === serviceId),
|
||||||
[services, serviceId],
|
[services, serviceId],
|
||||||
);
|
);
|
||||||
const binding = getServiceBinding(serviceType);
|
const binding = getServiceBinding(serviceType);
|
||||||
const typeInfo = useMemo(
|
|
||||||
() => types.find((t) => t.service_type === serviceType),
|
|
||||||
[types, serviceType],
|
|
||||||
);
|
|
||||||
const contentTabs = useMemo(
|
const contentTabs = useMemo(
|
||||||
() => serviceContentTabs(serviceType),
|
() => serviceContentTabs(serviceType),
|
||||||
[serviceType],
|
[serviceType],
|
||||||
);
|
);
|
||||||
const siblings = useMemo(
|
const siblings = useMemo(
|
||||||
() => services.filter((s) => s.service_type === serviceType),
|
() => services.filter((s) => s.service_type === serviceType && s.enabled),
|
||||||
[services, serviceType],
|
[services, serviceType],
|
||||||
);
|
);
|
||||||
// R3.1: switcher trigger keys off ENABLED siblings (not total).
|
const showInstanceTabs = siblings.length > 1;
|
||||||
const enabledSiblings = useMemo(
|
|
||||||
() => siblings.filter((s) => s.enabled),
|
|
||||||
[siblings],
|
|
||||||
);
|
|
||||||
const showSwitcher = enabledSiblings.length > 1;
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return (
|
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 =
|
const widgetsContent =
|
||||||
binding.widgets.length > 0 ? (
|
binding.widgets.length > 0 ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -179,107 +81,37 @@ export function ServicePage() {
|
|||||||
</p>
|
</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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SheetForm
|
{/* Header */}
|
||||||
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 */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||||
</div>
|
</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>
|
<Badge variant="outline">{binding.name}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<Tabs defaultValue="Overview">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="Overview">Overview</TabsTrigger>
|
<TabsTrigger value="Overview">Overview</TabsTrigger>
|
||||||
@@ -289,7 +121,6 @@ export function ServicePage() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
|
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
|
||||||
<TabsTrigger value="Config">Config</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{allTabs.map((tab) => {
|
{allTabs.map((tab) => {
|
||||||
@@ -309,168 +140,7 @@ export function ServicePage() {
|
|||||||
{widgetsContent}
|
{widgetsContent}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="Config">{configBody}</TabsContent>
|
|
||||||
</Tabs>
|
</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>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { TabsTrigger } from "@/components/ui/tabs";
|
import { TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
useDeleteServiceInstance,
|
||||||
|
useSaveServiceInstance,
|
||||||
|
useServiceInstances,
|
||||||
|
useServiceTypes,
|
||||||
|
} from "../hooks/useServices";
|
||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
const SERVICE_OPTIONS = [
|
const SERVICE_OPTIONS = [
|
||||||
{ value: "monitoring", label: "Monitoring" },
|
{ value: "monitoring", label: "Monitoring" },
|
||||||
@@ -61,7 +72,7 @@ const SERVICE_OPTIONS = [
|
|||||||
// maps to this sentinel and converts back to "" at the draft boundary.
|
// maps to this sentinel and converts back to "" at the draft boundary.
|
||||||
const NONE = "__none__";
|
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. */
|
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||||
function FormField({
|
function FormField({
|
||||||
@@ -995,6 +1006,9 @@ export function Settings() {
|
|||||||
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
||||||
SSH Keys
|
SSH Keys
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
|
<TabsTrigger key="services" value="services">
|
||||||
|
Services
|
||||||
|
</TabsTrigger>,
|
||||||
<TabsTrigger key="danger" value="danger">
|
<TabsTrigger key="danger" value="danger">
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
@@ -1178,6 +1192,7 @@ export function Settings() {
|
|||||||
onSelectKeyId={setSelectedSSHKeyId}
|
onSelectKeyId={setSelectedSSHKeyId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{tab === "services" && <ServicesAdminCard />}
|
||||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -1311,3 +1326,226 @@ export function Settings() {
|
|||||||
</div>
|
</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();
|
||||||
|
|
||||||
|
// Group by service_type, alphabetical.
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, ServiceInstance[]>();
|
||||||
|
for (const svc of services) {
|
||||||
|
const list = map.get(svc.service_type) ?? [];
|
||||||
|
list.push(svc);
|
||||||
|
map.set(svc.service_type, list);
|
||||||
|
}
|
||||||
|
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [services]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No service instances configured. Create one from the Services page.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
grouped.map(([serviceType, instances]) => {
|
||||||
|
const typeInfo = types.find((t) => t.service_type === serviceType);
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
key={serviceType}
|
||||||
|
title={typeInfo?.name ?? serviceType}
|
||||||
|
description={typeInfo?.description ?? ""}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{instances.map((svc) => (
|
||||||
|
<ServiceConfigEditor
|
||||||
|
key={svc.id}
|
||||||
|
instance={svc}
|
||||||
|
typeInfo={typeInfo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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={instance.enabled ? "default" : "secondary"}>
|
||||||
|
{instance.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,7 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useWidgetReferences: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../hooks/useServices", () => ({
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
useServiceInstances: () => ({ data: [] }),
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { ServicePage } from "../ServicePage";
|
import { ServicePage } from "../ServicePage";
|
||||||
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
|
import type { ServiceInstance } from "../../types";
|
||||||
|
|
||||||
const instance: ServiceInstance = {
|
const instance: ServiceInstance = {
|
||||||
id: "svc-1",
|
id: "svc-1",
|
||||||
@@ -15,17 +16,7 @@ const instance: ServiceInstance = {
|
|||||||
updated_at: 1_700_000_000,
|
updated_at: 1_700_000_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeInfo: ServiceTypeInfo = {
|
// typeInfo no longer needed on ServicePage (config moved to Settings).
|
||||||
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: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondInstance: ServiceInstance = {
|
const secondInstance: ServiceInstance = {
|
||||||
...instance,
|
...instance,
|
||||||
@@ -33,20 +24,23 @@ const secondInstance: ServiceInstance = {
|
|||||||
name: "Backup Jellyfin",
|
name: "Backup Jellyfin",
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveMutateAsync = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useServices", () => ({
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
useServiceInstances: () => ({
|
useServiceInstances: () => ({
|
||||||
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
|
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
|
||||||
?.__svcInstances ?? [instance],
|
?.__svcInstances ?? [instance],
|
||||||
}),
|
}),
|
||||||
useServiceTypes: () => ({ data: [typeInfo] }),
|
}));
|
||||||
useSaveServiceInstance: () => ({
|
|
||||||
mutateAsync: saveMutateAsync,
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
mutate: vi.fn(),
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
isPending: false,
|
}));
|
||||||
}),
|
|
||||||
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||||
|
WidgetConfigDialog: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/WidgetInstance", () => ({
|
||||||
|
WidgetInstanceCard: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../integrations/registry", () => ({
|
vi.mock("../../integrations/registry", () => ({
|
||||||
@@ -71,13 +65,19 @@ function renderServicePage(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ServicePage tab skeleton", () => {
|
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");
|
renderServicePage("/services/jellyfin/svc-1");
|
||||||
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
|
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("tab", { name: "Widgets" })).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", () => {
|
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||||
@@ -93,43 +93,37 @@ describe("ServicePage tab skeleton", () => {
|
|||||||
).not.toBeInTheDocument();
|
).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[] }
|
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||||
).__svcInstances = [instance, secondInstance];
|
).__svcInstances = [instance, secondInstance];
|
||||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
renderServicePage("/services/jellyfin/svc-1");
|
||||||
// The switcher renders as a Select trigger (combobox).
|
expect(
|
||||||
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
|
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[] }
|
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||||
).__svcInstances = [instance];
|
).__svcInstances = [instance];
|
||||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
renderServicePage("/services/jellyfin/svc-1");
|
||||||
// No select trigger rendered (only one instance).
|
|
||||||
expect(
|
expect(
|
||||||
container.querySelector("[role='combobox']"),
|
screen.queryByRole("tab", { name: "Main Jellyfin" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
|
it("clicking an instance tab navigates to that instance", async () => {
|
||||||
const { userEvent } = await import("@testing-library/user-event");
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
saveMutateAsync.mockReset();
|
(
|
||||||
|
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||||
|
).__svcInstances = [instance, secondInstance];
|
||||||
renderServicePage("/services/jellyfin/svc-1");
|
renderServicePage("/services/jellyfin/svc-1");
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Backup Jellyfin" }));
|
||||||
// Open the Config tab and type a new api_key.
|
// The test router would navigate; we can't assert URL directly without
|
||||||
await user.click(screen.getByRole("tab", { name: "Config" }));
|
// a useNavigate mock, but the click should not throw.
|
||||||
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" });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 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 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} />
|
||||||
|
))}
|
||||||
|
</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)}
|
||||||
|
serviceId={instance.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { ServiceInstance } from "../../types";
|
import type { ServiceInstance } from "../../types";
|
||||||
import { OverviewTab } from "./stubs";
|
import { OverviewTab } from "./OverviewTab";
|
||||||
import { AlertsTab } from "./AlertsTab";
|
import { AlertsTab } from "./AlertsTab";
|
||||||
import { LinksTab } from "./LinksTab";
|
import { LinksTab } from "./LinksTab";
|
||||||
import { MetricsTab } from "./MetricsTab";
|
import { MetricsTab } from "./MetricsTab";
|
||||||
|
|||||||
@@ -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} />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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 {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeriesPoint {
|
||||||
|
t: number;
|
||||||
|
v: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GrafanaChartWidget({
|
||||||
|
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 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
No data. Check your query and datasource_uid in the widget config.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,61 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { GrafanaChartWidget } from "../GrafanaChartWidget";
|
||||||
|
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("GrafanaChartWidget", () => {
|
||||||
|
it("renders a chart with series data", () => {
|
||||||
|
mockData({
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
label: "cpu",
|
||||||
|
points: [
|
||||||
|
{ t: 1000, v: 0.5 },
|
||||||
|
{ t: 2000, v: 0.8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<GrafanaChartWidget 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, "Grafana api_key is required for chart queries");
|
||||||
|
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||||
|
expect(screen.getByText(/api_key is required/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty state when no series", () => {
|
||||||
|
mockData({ series: [] });
|
||||||
|
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
|
||||||
|
expect(screen.getByText(/No data/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||||
export { BackupsWidget } from "./BackupsWidget";
|
export { BackupsWidget } from "./BackupsWidget";
|
||||||
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||||
|
export { GrafanaChartWidget } from "./GrafanaChartWidget";
|
||||||
export { JellyfinWidget } from "./JellyfinWidget";
|
export { JellyfinWidget } from "./JellyfinWidget";
|
||||||
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
export { SshTaskWidget } from "./SshTaskWidget";
|
export { SshTaskWidget } from "./SshTaskWidget";
|
||||||
|
|||||||
Reference in New Issue
Block a user