Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d2e4c9bfd | |||
| fef0ded76f | |||
| f7f590fa47 | |||
| 01527ae4f0 |
@@ -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,115 @@
|
||||
"""Authentik directory API client.
|
||||
|
||||
Authentik is the user-directory source (replacing the Jellyfin-backed Users
|
||||
page). This client wraps the Authentik REST API for browsing the user directory
|
||||
with pagination and search. OIDC authentication is unchanged — this client is
|
||||
for the directory, not SSO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthentikClient:
|
||||
"""Small wrapper around the Authentik core directory API."""
|
||||
|
||||
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
|
||||
if not base_url:
|
||||
raise ValueError("Authentik base_url is required")
|
||||
if not api_token:
|
||||
raise ValueError("Authentik API token is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/api/v3"):
|
||||
self.base_url = self.base_url[:-7]
|
||||
self.api_token = api_token
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def get(self, path: str, **params: Any) -> Any:
|
||||
"""GET an Authentik endpoint and include useful response text on errors."""
|
||||
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/api/v3{path}",
|
||||
params=clean_params,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
logger.warning(
|
||||
"Authentik GET %s failed status=%s url=%s",
|
||||
path,
|
||||
response.status_code,
|
||||
response.url,
|
||||
)
|
||||
raise requests.HTTPError(
|
||||
f"{response.status_code} for {response.url}: {detail}",
|
||||
response=response,
|
||||
) from exc
|
||||
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
|
||||
return response.json()
|
||||
|
||||
def users(
|
||||
self,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a normalized page of Authentik users.
|
||||
|
||||
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
|
||||
Authentik response into ``{items, total, page, page_size}``. Each item
|
||||
is the raw Authentik user dict (pk, username, name, email, avatar, …)
|
||||
so the frontend can pick the fields it needs.
|
||||
"""
|
||||
payload = self.get(
|
||||
"/core/users/",
|
||||
search=search,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
results = payload.get("results")
|
||||
items: list[dict[str, Any]] = (
|
||||
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
||||
)
|
||||
|
||||
pagination = payload.get("pagination") or {}
|
||||
total = 0
|
||||
if isinstance(pagination, dict):
|
||||
try:
|
||||
total = int(pagination.get("count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
|
||||
logger.info(
|
||||
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
|
||||
page,
|
||||
page_size,
|
||||
len(items),
|
||||
total,
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
@@ -18,7 +18,6 @@ from typing import Any
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
@@ -178,22 +177,6 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||
return _jellyfin_client_for(cache_key)
|
||||
|
||||
|
||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
||||
store = get_settings_store()
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyseerr", service_id)
|
||||
if service is None:
|
||||
logger.info("Jellyseerr client not configured (no jellyseerr service)")
|
||||
return None
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
logger.info("Jellyseerr service is missing base_url or api_key")
|
||||
return None
|
||||
return JellyseerrClient(base_url, api_key)
|
||||
|
||||
|
||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||
"""Build a RemoteSSHClient from a machine config dict."""
|
||||
store = store or get_settings_store()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Authentik service definition.
|
||||
|
||||
Authentik is the user-directory source (replacing the Jellyfin-backed Users
|
||||
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
|
||||
on the Authentik service page (Users + Messaging tabs). OIDC authentication
|
||||
is unchanged -- this service type is for the directory, not SSO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
|
||||
|
||||
class AuthentikConfig(ServiceConfigBase):
|
||||
"""Non-secret Authentik connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="authentik",
|
||||
name="Authentik",
|
||||
description="User directory and identity provider integration.",
|
||||
config_model=AuthentikConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_token", label="API token", required=True),
|
||||
],
|
||||
widget_kinds=[],
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Backups service definition.
|
||||
|
||||
Backups is modeled as a service type so it can be configured, named, and
|
||||
multi-instanced like other services. Reports arrive via the existing REST
|
||||
report endpoint; the ``ingestion_label`` disambiguates multi-instance
|
||||
ingestion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class BackupsConfig(ServiceConfigBase):
|
||||
"""Non-secret Backups connection config."""
|
||||
|
||||
ingestion_label: str = "default"
|
||||
|
||||
|
||||
class BackupsSummaryWidgetConfig(WidgetConfigBase):
|
||||
"""Backup dashboard summary (jobs, runs, alerts)."""
|
||||
|
||||
# No user-overridable fields; the widget reads the internal backup tables.
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="backups",
|
||||
name="Backups",
|
||||
description="Backup job monitoring, run history, and alerting.",
|
||||
config_model=BackupsConfig,
|
||||
secret_fields=[],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="summary",
|
||||
name="Summary",
|
||||
description="Backup job summary and active alerts.",
|
||||
model_cls=BackupsSummaryWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
|
||||
|
||||
|
||||
class JellyfinConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyfin connection config."""
|
||||
"""Non-secret Jellyfin connection config.
|
||||
|
||||
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
|
||||
paired Jellyseerr companion config, absorbed from the former standalone
|
||||
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
|
||||
When both are set, the Jellyfin service page renders a Requests tab backed
|
||||
by Jellyseerr.
|
||||
"""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 10
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
|
||||
|
||||
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Jellyseerr service definition.
|
||||
|
||||
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
|
||||
own service type so multiple Jellyseerr instances are supported independently of
|
||||
Jellyfin. It provides no dashboard widgets today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
|
||||
|
||||
class JellyseerrConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyseerr connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="jellyseerr",
|
||||
name="Jellyseerr",
|
||||
description="Request management companion to Jellyfin.",
|
||||
config_model=JellyseerrConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", required=True),
|
||||
],
|
||||
widget_kinds=[],
|
||||
)
|
||||
@@ -7,10 +7,11 @@ There is no runtime plugin loading.
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
@@ -20,9 +21,10 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
JELLYSEERR.service_type: JELLYSEERR,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
SSH_TASKS.service_type: SSH_TASKS,
|
||||
BACKUPS.service_type: BACKUPS,
|
||||
AUTHENTIK.service_type: AUTHENTIK,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,8 +21,12 @@ from media_library_viewer_api.observability import (
|
||||
record_request,
|
||||
set_current_request_id,
|
||||
)
|
||||
from media_library_viewer_api.routers import (
|
||||
authentik_users as authentik_users_router,
|
||||
)
|
||||
from media_library_viewer_api.routers import backups as backups_router
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
|
||||
from media_library_viewer_api.routers import dashboards as dashboards_router
|
||||
from media_library_viewer_api.routers import services as services_router
|
||||
from media_library_viewer_api.routers import widgets as widgets_router
|
||||
from media_library_viewer_api.routers.settings import router as settings_router
|
||||
@@ -136,12 +140,13 @@ app.include_router(monitoring.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(backups_router.router)
|
||||
app.include_router(widgets_router.router)
|
||||
app.include_router(dashboards_router.router)
|
||||
app.include_router(services_router.router)
|
||||
app.include_router(authentik_users_router.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Pydantic models for the named-dashboards API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NamedDashboardInput(BaseModel):
|
||||
"""Input for create/update of a named dashboard."""
|
||||
|
||||
id: str | None = None
|
||||
label: str = Field(default="Dashboard")
|
||||
slug: str | None = None
|
||||
sort_order: int = 0
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NamedDashboard(BaseModel):
|
||||
"""A named dashboard record."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
slug: str
|
||||
sort_order: int
|
||||
payload: dict[str, Any]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Authentik directory + messaging router.
|
||||
|
||||
Resolves an ``authentik`` service instance from the registry, builds an
|
||||
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
|
||||
proxies paginated directory queries plus message-compose (email enqueue).
|
||||
Graceful "not configured" / "unreachable" payloads (matching the monitoring
|
||||
router's pattern) so the UI always renders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
"""Compose-request body for the Authentik messaging endpoint."""
|
||||
|
||||
recipient_emails: list[str]
|
||||
subject: str
|
||||
html_body: str
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested authentik instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled ``authentik`` instance is configured.
|
||||
"""
|
||||
service_type = "authentik"
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_token = str(service.secrets.get("api_token") or "")
|
||||
try:
|
||||
timeout = float(service.config.get("timeout_seconds") or 10)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 10.0
|
||||
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
|
||||
|
||||
|
||||
def _empty(error: str) -> dict[str, Any]:
|
||||
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
|
||||
|
||||
|
||||
@router.get("/{service_id}/users")
|
||||
def get_authentik_users(
|
||||
service_id: str,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Paginated Authentik user directory for a specific service instance."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
||||
return _empty("Authentik service not configured")
|
||||
|
||||
try:
|
||||
client = _build_client(service)
|
||||
return client.users(search=search, page=page, page_size=page_size)
|
||||
except Exception:
|
||||
logger.exception("Authentik users query failed for service %s", service_id)
|
||||
return _empty("Authentik is unreachable")
|
||||
|
||||
|
||||
@router.get("/{service_id}/message/status")
|
||||
def get_authentik_message_status(
|
||||
service_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||
return mail_queue.status()
|
||||
|
||||
|
||||
@router.post("/{service_id}/message")
|
||||
def post_authentik_message(
|
||||
service_id: str,
|
||||
body: MessageRequest,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
return {"status": "error", "error": "Authentik service not configured"}
|
||||
|
||||
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
|
||||
if not recipients:
|
||||
return {"status": "error", "error": "No recipients with valid email addresses."}
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
validate_smtp_settings(settings)
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
|
||||
|
||||
request_id = mail_queue.enqueue(
|
||||
settings=settings,
|
||||
recipients=recipients,
|
||||
subject=body.subject,
|
||||
html_body=body.html_body,
|
||||
)
|
||||
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
|
||||
return {
|
||||
"status": "queued",
|
||||
"request_id": request_id,
|
||||
"recipient_count": len(recipients),
|
||||
}
|
||||
@@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store
|
||||
router = APIRouter(prefix="/api/backups", tags=["backups"])
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
|
||||
def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
|
||||
"""Return the service_id for backup attribution.
|
||||
|
||||
First-wins: if no explicit service_id is given, pick the first enabled
|
||||
``backups`` service instance (spec R6.1). Returns an empty string when
|
||||
none is configured (backward-compatible with pre-service reports).
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
candidates = store.list_services("backups")
|
||||
for svc in candidates:
|
||||
if svc.get("enabled"):
|
||||
return svc["id"]
|
||||
return ""
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]:
|
||||
job = store.get_backup_job_by_name(report.name)
|
||||
if not job:
|
||||
job = store.upsert_backup_job(
|
||||
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
elif report.schedule_interval_seconds:
|
||||
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
job = store.get_backup_job(job["id"])
|
||||
@@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
@router.post("/report")
|
||||
def post_backup_report(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
# Check for duplicate (same job + started_at within 1s)
|
||||
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
|
||||
@@ -88,10 +108,12 @@ def post_backup_report(
|
||||
@router.post("/report/start")
|
||||
def post_backup_start(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
run_data = {
|
||||
"job_id": job["id"],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Named dashboards CRUD router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/dashboards", tags=["dashboards"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]:
|
||||
rows = store.list_dashboards()
|
||||
return [NamedDashboard(**row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/slug/{slug}")
|
||||
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.get_dashboard_by_slug(slug)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.upsert_dashboard(body.model_dump())
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.put("/{dashboard_id}")
|
||||
def update_dashboard(
|
||||
dashboard_id: str,
|
||||
body: NamedDashboardInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> NamedDashboard:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
if body.id and body.id != dashboard_id:
|
||||
raise HTTPException(status_code=400, detail="ID mismatch")
|
||||
row = store.upsert_dashboard(body.model_dump(), dashboard_id)
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.delete("/{dashboard_id}")
|
||||
def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
store.delete_dashboard(dashboard_id)
|
||||
return {"status": "deleted"}
|
||||
@@ -1 +0,0 @@
|
||||
from .users_impl import * # noqa: F401,F403
|
||||
@@ -1,389 +0,0 @@
|
||||
"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
)
|
||||
from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
_PERMISSION_FLAGS = [
|
||||
(2, "admin"),
|
||||
(4, "manage_settings"),
|
||||
(8, "manage_users"),
|
||||
(16, "manage_requests"),
|
||||
(32, "request"),
|
||||
(64, "vote"),
|
||||
(128, "auto_approve"),
|
||||
(256, "auto_approve_movie"),
|
||||
(512, "auto_approve_tv"),
|
||||
(1024, "request_4k"),
|
||||
(2048, "request_4k_movie"),
|
||||
(4096, "request_4k_tv"),
|
||||
(8192, "request_advanced"),
|
||||
(16384, "request_view"),
|
||||
(32768, "auto_approve_4k"),
|
||||
(65536, "auto_approve_4k_movie"),
|
||||
(131072, "auto_approve_4k_tv"),
|
||||
(262144, "request_movie"),
|
||||
(524288, "request_tv"),
|
||||
(1048576, "manage_issues"),
|
||||
(2097152, "view_issues"),
|
||||
]
|
||||
|
||||
_USER_TYPES = {
|
||||
1: "plex",
|
||||
2: "local",
|
||||
3: "jellyfin",
|
||||
4: "emby",
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _permission_labels(permissions: int) -> list[str]:
|
||||
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
|
||||
return labels or ["none"]
|
||||
|
||||
|
||||
def _role_label(permissions: int) -> str:
|
||||
if permissions & 2:
|
||||
return "admin"
|
||||
if permissions & (4 | 8 | 16):
|
||||
return "manager"
|
||||
if permissions & (32 | 64 | 128):
|
||||
return "requester"
|
||||
return "user"
|
||||
|
||||
|
||||
def _account_type(user_type: Any) -> str:
|
||||
return _USER_TYPES.get(_safe_int(user_type), "unknown")
|
||||
|
||||
|
||||
def _merge_users(
|
||||
jellyfin_users: list[dict[str, Any]],
|
||||
jellyseerr_jellyfin_users: list[dict[str, Any]] | None,
|
||||
jellyseerr_users: list[dict[str, Any]] | None,
|
||||
jellyseerr_client: JellyseerrClient | None,
|
||||
) -> dict[str, Any]:
|
||||
def _normalize(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
def _looks_like_email(value: Any) -> bool:
|
||||
text = str(value or "").strip()
|
||||
return bool(text and "@" in text and " " not in text)
|
||||
|
||||
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
|
||||
for source, value in candidates:
|
||||
if _looks_like_email(value):
|
||||
return source, str(value).strip()
|
||||
return "", ""
|
||||
|
||||
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
|
||||
for source, value in candidates:
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return source, text
|
||||
return "", ""
|
||||
|
||||
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
|
||||
return ", ".join(
|
||||
[
|
||||
f"name={name_source or 'none'}",
|
||||
f"email={email_source or 'none'}",
|
||||
f"avatar={avatar_source or 'none'}",
|
||||
f"access={access_source or 'none'}",
|
||||
]
|
||||
)
|
||||
|
||||
def _lookup_keys(item: dict[str, Any]) -> list[str]:
|
||||
return [
|
||||
_normalize(item.get("id")),
|
||||
_normalize(item.get("Id")),
|
||||
_normalize(item.get("userId")),
|
||||
_normalize(item.get("user_id")),
|
||||
_normalize(item.get("jellyfinUserId")),
|
||||
_normalize(item.get("jellyfin_user_id")),
|
||||
_normalize(item.get("jellyfinUsername")),
|
||||
_normalize(item.get("jellyfin_username")),
|
||||
_normalize(item.get("username")),
|
||||
_normalize(item.get("displayName")),
|
||||
_normalize(item.get("display_name")),
|
||||
]
|
||||
|
||||
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
|
||||
for item in jellyseerr_jellyfin_users or []:
|
||||
for key in (
|
||||
item.get("id"),
|
||||
item.get("Id"),
|
||||
item.get("userId"),
|
||||
item.get("user_id"),
|
||||
item.get("jellyfinUserId"),
|
||||
item.get("jellyfin_user_id"),
|
||||
):
|
||||
normalized = _normalize(key)
|
||||
if normalized:
|
||||
linked_by_jellyfin_id[normalized] = item
|
||||
|
||||
seerr_by_key: dict[str, dict[str, Any]] = {}
|
||||
for item in jellyseerr_users or []:
|
||||
for key in _lookup_keys(item):
|
||||
if key:
|
||||
seerr_by_key[key] = item
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
enriched_count = 0
|
||||
for user in jellyfin_users:
|
||||
jellyfin_id = str(user.get("Id") or user.get("id") or "")
|
||||
jellyfin_name = str(user.get("Name") or user.get("name") or "")
|
||||
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
|
||||
|
||||
seerr_user = None
|
||||
for candidate in [
|
||||
jellyfin_name,
|
||||
(jf_link or {}).get("jellyfinUsername"),
|
||||
(jf_link or {}).get("jellyfin_username"),
|
||||
(jf_link or {}).get("username"),
|
||||
(jf_link or {}).get("displayName"),
|
||||
(jf_link or {}).get("display_name"),
|
||||
]:
|
||||
seerr_user = seerr_by_key.get(_normalize(candidate))
|
||||
if seerr_user:
|
||||
break
|
||||
|
||||
email_source, email = _pick_source_and_value(
|
||||
[
|
||||
("jellyseerr:user", (seerr_user or {}).get("email")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
|
||||
]
|
||||
)
|
||||
avatar_source, avatar = _first_value(
|
||||
[
|
||||
("jellyseerr:user", (seerr_user or {}).get("avatar")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
|
||||
]
|
||||
)
|
||||
if avatar and jellyseerr_client:
|
||||
avatar = jellyseerr_client.absolute_url(avatar)
|
||||
|
||||
permissions = _safe_int((seerr_user or {}).get("permissions"))
|
||||
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
|
||||
role = _role_label(permissions)
|
||||
access_source = "jellyseerr:user" if seerr_user else ""
|
||||
name_source = "jellyfin"
|
||||
summary = _source_summary(name_source, email_source, avatar_source, access_source)
|
||||
|
||||
if seerr_user or jf_link:
|
||||
enriched_count += 1
|
||||
|
||||
items.append(
|
||||
{
|
||||
"jellyfin_id": jellyfin_id,
|
||||
"username": jellyfin_name,
|
||||
"display_name": jellyfin_name,
|
||||
"email": email,
|
||||
"email_source": email_source,
|
||||
"avatar": avatar,
|
||||
"avatar_source": avatar_source,
|
||||
"contactable": bool(email),
|
||||
"source": summary,
|
||||
"source_summary": summary,
|
||||
"name_source": name_source,
|
||||
"access_source": access_source,
|
||||
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId"))
|
||||
or None,
|
||||
"jellyseerr_username": str(
|
||||
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
|
||||
),
|
||||
"user_type": user_type or None,
|
||||
"user_type_label": _account_type(user_type),
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
"permissions_label": ", ".join(_permission_labels(permissions)),
|
||||
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
|
||||
len(jellyfin_users),
|
||||
len(jellyseerr_jellyfin_users or []),
|
||||
len(jellyseerr_users or []),
|
||||
enriched_count,
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"jellyseerr_configured": jellyseerr_client is not None,
|
||||
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
|
||||
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
|
||||
"jellyseerr_user_count": len(jellyseerr_users or []),
|
||||
"enriched_count": enriched_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_users(
|
||||
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
|
||||
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
|
||||
) -> dict[str, Any]:
|
||||
"""Return the known users, enriched with Jellyseerr data when available."""
|
||||
jellyfin_users = jellyfin.users()
|
||||
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
|
||||
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
|
||||
jellyseerr_users: list[dict[str, Any]] | None = None
|
||||
jellyseerr_error = ""
|
||||
if jellyseerr:
|
||||
try:
|
||||
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
|
||||
except Exception as exc: # pragma: no cover - network fallback
|
||||
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
|
||||
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
|
||||
try:
|
||||
jellyseerr_users = jellyseerr.users()
|
||||
except Exception as exc: # pragma: no cover - network fallback
|
||||
logger.exception("Jellyseerr user list fetch failed")
|
||||
jellyseerr_error = (
|
||||
f"{jellyseerr_error}; " if jellyseerr_error else ""
|
||||
) + f"Jellyseerr user list fetch failed: {exc}"
|
||||
|
||||
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
|
||||
result["jellyseerr_error"] = jellyseerr_error
|
||||
logger.info(
|
||||
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
|
||||
result["total"],
|
||||
result["jellyseerr_configured"],
|
||||
result["jellyseerr_available"],
|
||||
result["enriched_count"],
|
||||
bool(jellyseerr_error),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/message/status")
|
||||
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
|
||||
"""Return the current background email queue status."""
|
||||
return mail_queue.status()
|
||||
|
||||
|
||||
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def post_user_message(
|
||||
recipient_ids: str = Form(...),
|
||||
subject: str = Form(...),
|
||||
html_body: str = Form(""),
|
||||
text_body: str = Form(""),
|
||||
attachments: list[UploadFile] | None = File(default=None),
|
||||
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
|
||||
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
|
||||
mail_queue=Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a single email to the selected users without blocking the API."""
|
||||
try:
|
||||
requested_ids = json.loads(recipient_ids)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(requested_ids, list):
|
||||
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
|
||||
|
||||
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
|
||||
if not cleaned_ids:
|
||||
raise HTTPException(status_code=400, detail="At least one recipient is required")
|
||||
|
||||
subject = subject.strip()
|
||||
if not subject:
|
||||
raise HTTPException(status_code=400, detail="Subject is required")
|
||||
|
||||
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
|
||||
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
|
||||
|
||||
recipients: list[str] = []
|
||||
recipient_labels: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for user_id in cleaned_ids:
|
||||
item = users_by_id.get(user_id)
|
||||
if not item:
|
||||
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
|
||||
continue
|
||||
email = str(item.get("email") or "").strip()
|
||||
if not email:
|
||||
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
|
||||
continue
|
||||
recipients.append(email)
|
||||
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
|
||||
|
||||
if not recipients:
|
||||
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
|
||||
|
||||
settings = get_settings()
|
||||
validate_smtp_settings(settings)
|
||||
|
||||
queue_status = mail_queue.status()
|
||||
if not queue_status["worker_running"]:
|
||||
raise HTTPException(status_code=503, detail="Email queue worker is not running")
|
||||
|
||||
attachment_payloads: list[EmailAttachment] = []
|
||||
for upload in attachments or []:
|
||||
data = await upload.read()
|
||||
if not data:
|
||||
continue
|
||||
attachment_payloads.append(
|
||||
EmailAttachment(
|
||||
filename=upload.filename or "attachment",
|
||||
content_type=upload.content_type or "application/octet-stream",
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
request_id = mail_queue.enqueue(
|
||||
settings=settings,
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
text_body=text_body,
|
||||
attachments=attachment_payloads,
|
||||
)
|
||||
from_address = (
|
||||
str(getattr(settings, "smtp_from_address", "") or "").strip()
|
||||
or str(getattr(settings, "smtp_username", "") or "").strip()
|
||||
)
|
||||
logger.info(
|
||||
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
|
||||
request_id,
|
||||
subject,
|
||||
len(recipients),
|
||||
len(attachment_payloads),
|
||||
len(skipped),
|
||||
)
|
||||
return {
|
||||
"status": "queued",
|
||||
"request_id": request_id,
|
||||
"from_address": from_address,
|
||||
"recipient_count": len(recipients),
|
||||
"attachment_count": len(attachment_payloads),
|
||||
"subject": subject,
|
||||
"recipient_labels": recipient_labels,
|
||||
"skipped": skipped,
|
||||
}
|
||||
@@ -103,10 +103,18 @@ def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
||||
|
||||
@router.get("/instances")
|
||||
def list_instances(
|
||||
service_id: str | None = None,
|
||||
scope: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all persisted widget instances."""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
||||
"""Return widget instances, optionally filtered.
|
||||
|
||||
- ``?service_id=X``: only widgets for service X.
|
||||
- ``?scope=dashboard``: only widgets with NULL service_id.
|
||||
- ``?scope=service``: only widgets with a non-null service_id.
|
||||
- No params: all widgets (backward-compatible).
|
||||
"""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
|
||||
|
||||
|
||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -8,6 +8,7 @@ in the same UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
@@ -19,6 +20,8 @@ import paramiko
|
||||
|
||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
DEFAULT_SERVICES = ["monitoring", "files"]
|
||||
@@ -172,6 +175,9 @@ class SettingsStore:
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()}
|
||||
if "service_id" not in backup_job_cols:
|
||||
conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -244,6 +250,19 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS named_dashboards (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
@@ -415,6 +434,75 @@ class SettingsStore:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if not row or int(row[0]) == 0:
|
||||
self._seed_local_machine()
|
||||
self._migrate_jellyseerr_into_jellyfin()
|
||||
|
||||
def _migrate_jellyseerr_into_jellyfin(self) -> None:
|
||||
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
|
||||
|
||||
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
|
||||
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
|
||||
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
|
||||
paired -> drop with a logged warning.
|
||||
"""
|
||||
from media_library_viewer_api.services.secrets import decrypt_value
|
||||
|
||||
self.init_schema()
|
||||
jellyseerr_rows: list[sqlite3.Row] = []
|
||||
with self.connect() as conn:
|
||||
jellyseerr_rows = conn.execute(
|
||||
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
|
||||
).fetchall()
|
||||
if not jellyseerr_rows:
|
||||
return
|
||||
|
||||
jellyfin_rows = self.list_services("jellyfin")
|
||||
for js_row in jellyseerr_rows:
|
||||
js_config = json.loads(js_row["config_json"] or "{}")
|
||||
js_secrets = json.loads(js_row["secrets_json"] or "{}")
|
||||
js_url = str(js_config.get("base_url", "")).strip()
|
||||
js_api_key = str(js_secrets.get("api_key", "")).strip()
|
||||
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
|
||||
if js_api_key:
|
||||
try:
|
||||
js_api_key = decrypt_value(js_api_key)
|
||||
except Exception:
|
||||
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
|
||||
js_api_key = ""
|
||||
js_name = js_row["name"]
|
||||
|
||||
target = None
|
||||
if len(jellyfin_rows) == 1:
|
||||
target = jellyfin_rows[0]
|
||||
elif len(jellyfin_rows) > 1:
|
||||
for jf in jellyfin_rows:
|
||||
if not str(jf["config"].get("jellyseerr_url", "")).strip():
|
||||
target = jf
|
||||
break
|
||||
|
||||
if target:
|
||||
merged_config = dict(target["config"])
|
||||
merged_config["jellyseerr_url"] = js_url
|
||||
merged_config["jellyseerr_api_key"] = js_api_key
|
||||
self.upsert_service(
|
||||
{
|
||||
"id": target["id"],
|
||||
"service_type": "jellyfin",
|
||||
"name": target["name"],
|
||||
"config": merged_config,
|
||||
"enabled": target["enabled"],
|
||||
},
|
||||
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
|
||||
)
|
||||
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
|
||||
else:
|
||||
logger.warning(
|
||||
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
|
||||
js_name,
|
||||
)
|
||||
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
|
||||
conn.commit()
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
@@ -892,6 +980,7 @@ class SettingsStore:
|
||||
"source": row["source"],
|
||||
"target": row["target"],
|
||||
"schedule_interval_seconds": row["schedule_interval_seconds"],
|
||||
"service_id": row["service_id"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
@@ -910,12 +999,18 @@ class SettingsStore:
|
||||
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
|
||||
if schedule_interval_seconds is not None:
|
||||
schedule_interval_seconds = int(schedule_interval_seconds)
|
||||
service_id = str(
|
||||
payload.get("service_id")
|
||||
if payload.get("service_id") is not None
|
||||
else (current or {}).get("service_id", "") or ""
|
||||
).strip()
|
||||
return {
|
||||
"id": job_id,
|
||||
"name": name,
|
||||
"source": source,
|
||||
"target": target,
|
||||
"schedule_interval_seconds": schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
|
||||
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
@@ -933,17 +1028,19 @@ class SettingsStore:
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
@@ -951,6 +1048,7 @@ class SettingsStore:
|
||||
job["source"],
|
||||
job["target"],
|
||||
job["schedule_interval_seconds"],
|
||||
job["service_id"],
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
@@ -1305,10 +1403,36 @@ class SettingsStore:
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
|
||||
def list_widgets(self) -> list[dict[str, Any]]:
|
||||
def list_widgets(
|
||||
self,
|
||||
service_id: str | None = None,
|
||||
*,
|
||||
scope: str | None = None,
|
||||
all_widgets: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List widget instances, optionally filtered.
|
||||
|
||||
- ``service_id=X``: only widgets for service X.
|
||||
- ``scope="dashboard"``: only widgets with NULL service_id.
|
||||
- ``scope="service"``: only widgets with a non-null service_id.
|
||||
- ``all_widgets=True, service_id=None, scope=None``: all widgets.
|
||||
"""
|
||||
self.init_schema()
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if service_id is not None:
|
||||
clauses.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
if scope == "dashboard":
|
||||
clauses.append("service_id IS NULL")
|
||||
elif scope == "service":
|
||||
clauses.append("service_id IS NOT NULL")
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._row_to_widget(row) for row in rows]
|
||||
|
||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||
@@ -1566,6 +1690,113 @@ class SettingsStore:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Named dashboards
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _slugify(label: str) -> str:
|
||||
import re
|
||||
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
|
||||
return slug or "dashboard"
|
||||
|
||||
def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str:
|
||||
self.init_schema()
|
||||
base = slug
|
||||
suffix = 1
|
||||
with self.connect() as conn:
|
||||
while True:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM named_dashboards WHERE slug = ? AND id != ?",
|
||||
(slug, exclude_id or ""),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return slug
|
||||
suffix += 1
|
||||
slug = f"{base}-{suffix}"
|
||||
|
||||
def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"label": row["label"],
|
||||
"slug": row["slug"],
|
||||
"sort_order": row["sort_order"],
|
||||
"payload": json.loads(row["payload_json"] or "{}"),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def list_dashboards(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [self._row_to_dashboard(row) for row in rows]
|
||||
|
||||
def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None:
|
||||
if not dashboard_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None:
|
||||
if not slug:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
current = self.get_dashboard(dashboard_id) if dashboard_id else None
|
||||
dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip()
|
||||
label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip()
|
||||
slug = str(payload.get("slug") or "").strip() or self._slugify(label)
|
||||
slug = self._unique_slug(slug, exclude_id=dash_id)
|
||||
sort_order = payload.get("sort_order")
|
||||
if sort_order is None:
|
||||
sort_order = (current or {}).get("sort_order", 0)
|
||||
sort_order = int(sort_order)
|
||||
payload_data = payload.get("payload")
|
||||
if payload_data is None:
|
||||
payload_data = (current or {}).get("payload", {})
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
slug = excluded.slug,
|
||||
sort_order = excluded.sort_order,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
dash_id,
|
||||
label,
|
||||
slug,
|
||||
sort_order,
|
||||
json.dumps(payload_data),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug}
|
||||
|
||||
def delete_dashboard(self, dashboard_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,))
|
||||
|
||||
|
||||
_store: SettingsStore | None = None
|
||||
|
||||
|
||||
+1
-152
@@ -14,8 +14,6 @@ from fastapi.testclient import TestClient
|
||||
from media_library_viewer_api.clients.ssh import CommandResult
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
get_settings_store,
|
||||
get_ssh_client,
|
||||
get_user_id,
|
||||
@@ -70,38 +68,6 @@ def mock_jellyfin():
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jellyseerr():
|
||||
"""Mock Jellyseerr client."""
|
||||
client = MagicMock()
|
||||
client.jellyfin_users.return_value = [
|
||||
{"id": "jf1", "username": "alex", "thumb": "/avatarproxy/alex", "email": "alex@example.com"},
|
||||
{"id": "jf2", "username": "sam", "thumb": "/avatarproxy/sam", "email": "sam@example.com"},
|
||||
]
|
||||
client.users.return_value = [
|
||||
{
|
||||
"id": 7,
|
||||
"username": "alex",
|
||||
"email": "alex@example.com",
|
||||
"avatar": "/avatarproxy/alex",
|
||||
"userType": 3,
|
||||
"permissions": 10,
|
||||
"requestCount": 3,
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"username": "sam",
|
||||
"email": "sam@example.com",
|
||||
"avatar": "/avatarproxy/sam",
|
||||
"userType": 2,
|
||||
"permissions": 32,
|
||||
"requestCount": 1,
|
||||
},
|
||||
]
|
||||
client.absolute_url.side_effect = lambda path: f"https://requests.example.com{path}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssh():
|
||||
"""Mock SSH client."""
|
||||
@@ -132,10 +98,9 @@ def mock_ssh():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh, tmp_path):
|
||||
def test_client(mock_jellyfin, mock_ssh, tmp_path):
|
||||
"""FastAPI test client with mocked dependencies."""
|
||||
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
|
||||
app.dependency_overrides[get_jellyseerr_client] = lambda: mock_jellyseerr
|
||||
app.dependency_overrides[get_ssh_client] = lambda: mock_ssh
|
||||
app.dependency_overrides[get_user_id] = lambda: "user123"
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
@@ -293,122 +258,6 @@ class TestSettingsReset:
|
||||
assert len(store.list_machines()) == 0
|
||||
|
||||
|
||||
# --- Users ---
|
||||
|
||||
|
||||
class TestUsers:
|
||||
def test_users_list_enriched(self, test_client):
|
||||
response = test_client.get("/api/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert data["jellyseerr_configured"] is True
|
||||
assert data["jellyseerr_available"] is True
|
||||
assert data["jellyseerr_error"] == ""
|
||||
|
||||
alex = next(item for item in data["items"] if item["username"] == "alex")
|
||||
assert alex["email"] == "alex@example.com"
|
||||
assert alex["email_source"] == "jellyseerr:user"
|
||||
assert alex["contactable"] is True
|
||||
assert alex["avatar"].startswith("https://requests.example.com/")
|
||||
assert alex["avatar_source"] == "jellyseerr:user"
|
||||
assert alex["permissions"] == 10
|
||||
assert alex["permissions_label"] == "admin, manage_users"
|
||||
assert alex["role"] == "admin"
|
||||
assert alex["user_type_label"] == "jellyfin"
|
||||
assert alex["request_count"] == 3
|
||||
assert "name=jellyfin" in alex["source_summary"]
|
||||
assert "email=jellyseerr:user" in alex["source_summary"]
|
||||
|
||||
sam = next(item for item in data["items"] if item["username"] == "sam")
|
||||
assert sam["role"] == "requester"
|
||||
assert sam["user_type_label"] == "local"
|
||||
assert sam["email"] == "sam@example.com"
|
||||
|
||||
def test_users_message_status(self, test_client):
|
||||
mail_queue = MagicMock()
|
||||
mail_queue.status.return_value = {
|
||||
"state": "idle",
|
||||
"worker_running": True,
|
||||
"stop_requested": False,
|
||||
"pending_count": 0,
|
||||
"active_request_id": None,
|
||||
"last_request_id": None,
|
||||
"last_result": None,
|
||||
"last_error": "",
|
||||
"last_error_at": None,
|
||||
"last_success_at": None,
|
||||
"last_activity_at": None,
|
||||
"sent_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
|
||||
try:
|
||||
response = test_client.get("/api/users/message/status")
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["state"] == "idle"
|
||||
assert response.json()["pending_count"] == 0
|
||||
|
||||
def test_users_message_is_queued(self, test_client):
|
||||
mail_queue = MagicMock()
|
||||
mail_queue.status.return_value = {
|
||||
"state": "idle",
|
||||
"worker_running": True,
|
||||
"stop_requested": False,
|
||||
"pending_count": 0,
|
||||
"active_request_id": None,
|
||||
"last_request_id": None,
|
||||
"last_result": None,
|
||||
"last_error": "",
|
||||
"last_error_at": None,
|
||||
"last_success_at": None,
|
||||
"last_activity_at": None,
|
||||
"sent_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
mail_queue.enqueue.return_value = "mail-123456"
|
||||
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="mailer@example.com",
|
||||
smtp_from_name="Manage",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("media_library_viewer_api.routers.users_impl.get_settings", return_value=settings):
|
||||
response = test_client.post(
|
||||
"/api/users/message",
|
||||
data={
|
||||
"recipient_ids": json.dumps(["jf1", "jf2"]),
|
||||
"subject": "Hello team",
|
||||
"html_body": "<p>Hi there</p>",
|
||||
"text_body": "Hi there",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["status"] == "queued"
|
||||
assert data["request_id"] == "mail-123456"
|
||||
assert data["recipient_count"] == 2
|
||||
assert data["attachment_count"] == 0
|
||||
mail_queue.enqueue.assert_called_once()
|
||||
kwargs = mail_queue.enqueue.call_args.kwargs
|
||||
assert kwargs["recipients"] == ["alex@example.com", "sam@example.com"]
|
||||
assert kwargs["subject"] == "Hello team"
|
||||
assert kwargs["settings"] is settings
|
||||
|
||||
|
||||
# --- Files ---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for AuthentikClient and the directory endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
TEST_KEY = Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
||||
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||
reset_encryption_key_cache()
|
||||
yield
|
||||
reset_encryption_key_cache()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path: Path) -> SettingsStore:
|
||||
s = SettingsStore(tmp_path / "settings.sqlite")
|
||||
s.ensure_defaults()
|
||||
app.dependency_overrides[get_settings_store] = lambda: s
|
||||
yield s
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthentikClient:
|
||||
def test_base_url_normalizes_trailing_slash(self) -> None:
|
||||
c = AuthentikClient(base_url="https://auth.example.com/", api_token="t")
|
||||
assert c.base_url == "https://auth.example.com"
|
||||
|
||||
def test_base_url_strips_api_v3_suffix(self) -> None:
|
||||
c = AuthentikClient(base_url="https://auth.example.com/api/v3", api_token="t")
|
||||
assert c.base_url == "https://auth.example.com"
|
||||
|
||||
def test_bearer_header_is_set(self) -> None:
|
||||
c = AuthentikClient(base_url="https://auth.example.com", api_token="tok")
|
||||
assert c.session.headers["Authorization"] == "Bearer tok"
|
||||
|
||||
def test_empty_base_url_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AuthentikClient(base_url="", api_token="t")
|
||||
|
||||
def test_empty_api_token_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AuthentikClient(base_url="https://auth.example.com", api_token="")
|
||||
|
||||
@patch.object(AuthentikClient, "get")
|
||||
def test_users_normalizes_pagination(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = {
|
||||
"pagination": {"count": 42, "next": 2, "previous": 0, "current": 1},
|
||||
"results": [
|
||||
{"pk": 1, "username": "alice", "email": "alice@example.com"},
|
||||
{"pk": 2, "username": "bob", "email": "bob@example.com"},
|
||||
],
|
||||
}
|
||||
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
result = client.users(search="ali", page=1, page_size=2)
|
||||
assert result["total"] == 42
|
||||
assert result["page"] == 1
|
||||
assert result["page_size"] == 2
|
||||
assert len(result["items"]) == 2
|
||||
assert result["items"][0]["username"] == "alice"
|
||||
|
||||
@patch.object(AuthentikClient, "get")
|
||||
def test_users_handles_empty_results(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = {"pagination": {"count": 0}, "results": []}
|
||||
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
result = client.users()
|
||||
assert result["items"] == []
|
||||
assert result["total"] == 0
|
||||
|
||||
@patch.object(AuthentikClient, "get")
|
||||
def test_users_handles_non_dict_payload(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = []
|
||||
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
result = client.users()
|
||||
assert result["items"] == []
|
||||
assert result["total"] == 0
|
||||
|
||||
@patch("media_library_viewer_api.clients.authentik.requests.Session")
|
||||
def test_get_sends_correct_url_and_params(self, mock_session_cls: MagicMock) -> None:
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
c = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
c.get("/core/users/", search="x", page=2)
|
||||
|
||||
call_args = mock_session.get.call_args
|
||||
assert call_args.kwargs["params"] == {"search": "x", "page": 2}
|
||||
assert call_args.args[0] == "https://auth.example.com/api/v3/core/users/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthentikUsersEndpoint:
|
||||
def test_not_configured_returns_empty_with_error(self, store: SettingsStore) -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/services/authentik/nonexistent/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
assert "error" in data
|
||||
|
||||
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
|
||||
def test_success_returns_users(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.users.return_value = {
|
||||
"items": [{"pk": 1, "username": "alice"}],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 50,
|
||||
}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
created = store.upsert_service(
|
||||
{
|
||||
"service_type": "authentik",
|
||||
"name": "Main",
|
||||
"config": {"base_url": "https://auth.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_token": "secret-token"},
|
||||
)
|
||||
service_id = created["id"]
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/api/services/authentik/{service_id}/users?search=ali")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["username"] == "alice"
|
||||
assert data["total"] == 1
|
||||
assert "error" not in data
|
||||
|
||||
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
|
||||
def test_unreachable_returns_error(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.users.side_effect = ConnectionError("refused")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
created = store.upsert_service(
|
||||
{
|
||||
"service_type": "authentik",
|
||||
"name": "Main",
|
||||
"config": {"base_url": "https://auth.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_token": "secret-token"},
|
||||
)
|
||||
service_id = created["id"]
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/api/services/authentik/{service_id}/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert "error" in data
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for named-dashboards CRUD + slug uniqueness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def _client(tmp_path: Path) -> TestClient:
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
app.dependency_overrides[get_settings_store] = lambda: store
|
||||
client = TestClient(app)
|
||||
client.store = store # type: ignore[attr-defined]
|
||||
return client
|
||||
|
||||
|
||||
def test_create_and_list_dashboards(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/dashboards",
|
||||
json={"label": "Storage Overview", "payload": {"widgets": []}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
created = resp.json()
|
||||
assert created["label"] == "Storage Overview"
|
||||
assert created["slug"] == "storage-overview"
|
||||
assert created["payload"] == {"widgets": []}
|
||||
|
||||
listed = client.get("/api/dashboards").json()
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["id"] == created["id"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_update_dashboard(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
created = client.post("/api/dashboards", json={"label": "First"}).json()
|
||||
updated = client.put(
|
||||
f"/api/dashboards/{created['id']}",
|
||||
json={"label": "Renamed", "payload": {"widgets": ["w1"]}},
|
||||
).json()
|
||||
assert updated["label"] == "Renamed"
|
||||
assert updated["payload"] == {"widgets": ["w1"]}
|
||||
assert updated["slug"] == "renamed"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_delete_dashboard(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
created = client.post("/api/dashboards", json={"label": "Temp"}).json()
|
||||
resp = client.delete(f"/api/dashboards/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/dashboards").json() == []
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_slug_collision_appends_suffix(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
first = client.post("/api/dashboards", json={"label": "Overview"}).json()
|
||||
second = client.post("/api/dashboards", json={"label": "Overview"}).json()
|
||||
assert first["slug"] == "overview"
|
||||
assert second["slug"] == "overview-2"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_explicit_slug_respected(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
created = client.post(
|
||||
"/api/dashboards",
|
||||
json={"label": "My Dashboard", "slug": "custom-slug"},
|
||||
).json()
|
||||
assert created["slug"] == "custom-slug"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_update_nonexistent_returns_404(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
resp = client.put("/api/dashboards/nope", json={"label": "X"})
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -57,24 +57,55 @@ def client(tmp_path):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_contains_seven_service_types():
|
||||
def test_registry_contains_eight_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
"jellyseerr",
|
||||
"nextcloud",
|
||||
"ssh_tasks",
|
||||
"backups",
|
||||
"authentik",
|
||||
}
|
||||
|
||||
|
||||
def test_jellyseerr_absorbed_into_jellyfin():
|
||||
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
|
||||
assert "jellyseerr" not in SERVICE_DEFINITIONS
|
||||
jellyfin_config = get_service_definition("jellyfin").config_schema["properties"]
|
||||
assert "jellyseerr_url" in jellyfin_config
|
||||
assert "jellyseerr_api_key" in jellyfin_config
|
||||
|
||||
|
||||
def test_backups_service_definition():
|
||||
definition = get_service_definition("backups")
|
||||
assert definition is not None
|
||||
assert definition.secret_fields == []
|
||||
assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
|
||||
schema = definition.config_schema
|
||||
assert "ingestion_label" in schema["properties"]
|
||||
|
||||
|
||||
def test_authentik_service_definition():
|
||||
definition = get_service_definition("authentik")
|
||||
assert definition is not None
|
||||
assert {sf.key for sf in definition.secret_fields} == {"api_token"}
|
||||
assert definition.secret_fields[0].required is True
|
||||
assert definition.widget_kinds == []
|
||||
schema = definition.config_schema
|
||||
assert "base_url" in schema["properties"]
|
||||
assert "timeout_seconds" in schema["properties"]
|
||||
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
assert get_service_definition("authentik").widget_kinds == []
|
||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
||||
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
||||
|
||||
|
||||
@@ -139,9 +170,10 @@ def test_list_service_types(client):
|
||||
types = {item["service_type"] for item in response.json()}
|
||||
assert types == {
|
||||
"alertmanager",
|
||||
"authentik",
|
||||
"backups",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"jellyseerr",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"ssh_tasks",
|
||||
@@ -265,7 +297,7 @@ def test_service_base_url_requires_http_schema(bad_url):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"]
|
||||
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
|
||||
)
|
||||
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||
model = get_service_definition(service_type).config_model
|
||||
@@ -390,3 +422,99 @@ def test_record_and_list_service_task_runs(client):
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["status"] == "success"
|
||||
assert runs[0]["stdout_tail"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jellyseerr → Jellyfin migration (Slice 1.4 / 1.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
|
||||
"""A standalone jellyseerr service merges into the only jellyfin instance."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
jellyfin = store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyfin",
|
||||
"name": "Main Jellyfin",
|
||||
"config": {"base_url": "https://jellyfin.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "jf-key"},
|
||||
)
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyseerr",
|
||||
"name": "Main Jellyseerr",
|
||||
"config": {"base_url": "https://jellyseerr.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "js-key"},
|
||||
)
|
||||
|
||||
# Run migration via ensure_defaults (idempotent entry point).
|
||||
store.ensure_defaults()
|
||||
|
||||
# Jellyseerr row is gone.
|
||||
assert store.list_services("jellyseerr") == []
|
||||
|
||||
# Jellyfin config gained the absorbed fields.
|
||||
migrated = store.get_service(jellyfin["id"])
|
||||
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
|
||||
assert migrated["config"]["jellyseerr_api_key"] == "js-key"
|
||||
|
||||
|
||||
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):
|
||||
"""An unpaired jellyseerr (no jellyfin) is dropped with a warning, no crash."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyseerr",
|
||||
"name": "Orphan Jellyseerr",
|
||||
"config": {"base_url": "https://jellyseerr.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "js-key"},
|
||||
)
|
||||
|
||||
store.ensure_defaults()
|
||||
|
||||
assert store.list_services("jellyseerr") == []
|
||||
assert store.list_services("jellyfin") == []
|
||||
|
||||
|
||||
def test_jellyseerr_migration_is_idempotent(tmp_path):
|
||||
"""Running ensure_defaults twice does nothing the second time."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyfin",
|
||||
"name": "JF",
|
||||
"config": {"base_url": "https://jellyfin.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "k"},
|
||||
)
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyseerr",
|
||||
"name": "JS",
|
||||
"config": {"base_url": "https://jellyseerr.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "k"},
|
||||
)
|
||||
|
||||
store.ensure_defaults()
|
||||
first_jellyfin = store.list_services("jellyfin")[0]
|
||||
first_url = first_jellyfin["config"]["jellyseerr_url"]
|
||||
|
||||
store.ensure_defaults() # second run
|
||||
second_jellyfin = store.list_services("jellyfin")[0]
|
||||
assert second_jellyfin["config"]["jellyseerr_url"] == first_url
|
||||
assert store.list_services("jellyseerr") == []
|
||||
|
||||
@@ -90,6 +90,44 @@ def test_create_backups_widget(client):
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
def test_widget_filtering_by_service_id_and_scope(client):
|
||||
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
|
||||
service = _make_grafana_service(client)
|
||||
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
|
||||
client.post(
|
||||
"/api/widgets/instances",
|
||||
json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
|
||||
)
|
||||
client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dash",
|
||||
"config": {"dashboard_uid": "o"},
|
||||
},
|
||||
)
|
||||
|
||||
# No filter: both widgets.
|
||||
all_widgets = client.get("/api/widgets/instances").json()
|
||||
assert len(all_widgets) == 2
|
||||
|
||||
# Filter by service_id: only the service-scoped one.
|
||||
by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
|
||||
assert len(by_service) == 1
|
||||
assert by_service[0]["service_id"] == service["id"]
|
||||
|
||||
# scope=dashboard: only the built-in (NULL service_id).
|
||||
dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
|
||||
assert len(dashboard_scope) == 1
|
||||
assert dashboard_scope[0]["service_id"] is None
|
||||
|
||||
# scope=service: only the non-null service_id widget.
|
||||
service_scope = client.get("/api/widgets/instances?scope=service").json()
|
||||
assert len(service_scope) == 1
|
||||
assert service_scope[0]["service_id"] == service["id"]
|
||||
|
||||
|
||||
def test_unknown_builtin_kind_rejected(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
|
||||
+71
-59
@@ -5,7 +5,6 @@ import {
|
||||
NavLink,
|
||||
useLocation,
|
||||
Outlet,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
QueryClient,
|
||||
@@ -13,22 +12,22 @@ import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { AuthProvider, useAuth } from "react-oidc-context";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Applications } from "./pages/Applications";
|
||||
import { NamedDashboardPage } from "./pages/NamedDashboardPage";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { UsersPage } from "./pages/Users";
|
||||
import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { ServicePage } from "./pages/ServicePage";
|
||||
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
||||
import { ServicesPage } from "./pages/ServicesPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
import { usePersistentState } from "./hooks/usePersistentState";
|
||||
import { useIsMobile } from "./hooks/useIsMobile";
|
||||
import { useServiceInstances } from "./hooks/useServices";
|
||||
import { useDashboards } from "./hooks/useDashboards";
|
||||
import { configuredNavEntries } from "./integrations/navEntries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -45,12 +44,6 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
Monitor,
|
||||
Users,
|
||||
Zap,
|
||||
FolderOpen,
|
||||
Settings as SettingsIcon,
|
||||
Menu,
|
||||
Sun,
|
||||
@@ -59,6 +52,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Boxes,
|
||||
LayoutTemplate,
|
||||
} from "lucide-react";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -66,9 +60,6 @@ const queryClient = new QueryClient({
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
// Pause interval-based refetches (widgets ~30s, queue status 5s,
|
||||
// media build progress 1s) when the tab is hidden. Saves battery on
|
||||
// mobile (D8 follow-up). Build progress polls resume on return.
|
||||
refetchIntervalInBackground: false,
|
||||
},
|
||||
},
|
||||
@@ -92,18 +83,40 @@ function useDarkMode() {
|
||||
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
|
||||
}
|
||||
|
||||
// Navigation items for sidebar
|
||||
const navItems = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/observability", label: "Observability", icon: Activity },
|
||||
{ path: "/media", label: "Media", icon: Monitor },
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ path: "/users", label: "Users", icon: Users },
|
||||
{ path: "/actions", label: "Actions", icon: Zap },
|
||||
{ path: "/services", label: "Services", icon: Boxes },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
// Navigation items are data-driven (spec R1). Built from configured services + dashboards.
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
function useNavItems() {
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: dashboards = [] } = useDashboards();
|
||||
|
||||
return useMemo<NavItem[]>(() => {
|
||||
const configuredTypes = new Set(
|
||||
services.filter((s) => s.enabled).map((s) => s.service_type),
|
||||
);
|
||||
const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({
|
||||
path: e.path,
|
||||
label: e.label,
|
||||
icon: e.icon,
|
||||
}));
|
||||
const dashboardEntries = dashboards.map((d) => ({
|
||||
path: `/d/${d.slug}`,
|
||||
label: d.label,
|
||||
icon: LayoutTemplate,
|
||||
}));
|
||||
return [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
...dashboardEntries,
|
||||
...serviceEntries,
|
||||
{ path: "/services", label: "Services", icon: Boxes },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
}, [services, dashboards]);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
collapsed,
|
||||
@@ -115,6 +128,7 @@ function Sidebar({
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const navItems = useNavItems();
|
||||
|
||||
if (isMobile) return null;
|
||||
|
||||
@@ -199,11 +213,12 @@ function Sidebar({
|
||||
function MobileDrawer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navItems = useNavItems();
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="mobile-touch-target md:hidden">
|
||||
<Button variant="ghost" size="icon" className="md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
@@ -263,6 +278,7 @@ function TopBar({
|
||||
});
|
||||
const backendLabel = appVersion?.backend_label || "…";
|
||||
|
||||
const navItems = useNavItems();
|
||||
const pageTitle =
|
||||
navItems.find((item) => item.path === location.pathname)?.label ||
|
||||
"Dashboard";
|
||||
@@ -290,7 +306,7 @@ function TopBar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleDarkMode}
|
||||
className="mobile-touch-target h-8 w-8"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
@@ -303,7 +319,7 @@ function TopBar({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSignOut}
|
||||
className="mobile-touch-target gap-2"
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
@@ -428,6 +444,18 @@ function AuthenticatedApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<h2 className="text-xl font-semibold">Not found</h2>
|
||||
<p className="text-sm text-muted-foreground">This page doesn't exist.</p>
|
||||
<Button asChild>
|
||||
<NavLink to="/">Back to dashboard</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const [darkMode, toggleDarkMode] = useDarkMode();
|
||||
|
||||
@@ -439,26 +467,18 @@ function AppInner() {
|
||||
<Routes>
|
||||
<Route element={<AuthenticatedApp />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route
|
||||
path="/monitoring"
|
||||
element={<Navigate to="/observability" replace />}
|
||||
/>
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route
|
||||
path="/applications"
|
||||
element={<Navigate to="/media" replace />}
|
||||
/>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType"
|
||||
element={<ServiceTypePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -475,26 +495,18 @@ function AppInner() {
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route
|
||||
path="/monitoring"
|
||||
element={<Navigate to="/observability" replace />}
|
||||
/>
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route
|
||||
path="/applications"
|
||||
element={<Navigate to="/media" replace />}
|
||||
/>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType"
|
||||
element={<ServiceTypePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** API client for the Authentik service (directory + messaging). */
|
||||
import { get, post } from "./shared";
|
||||
|
||||
export interface AuthentikUser {
|
||||
pk: number;
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
avatar: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuthentikUsersResponse {
|
||||
items: AuthentikUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchAuthentikUsers(
|
||||
serviceId: string,
|
||||
params: { search?: string; page?: number; page_size?: number },
|
||||
): Promise<AuthentikUsersResponse> {
|
||||
return get<AuthentikUsersResponse>(
|
||||
`/api/services/authentik/${serviceId}/users`,
|
||||
{
|
||||
search: params.search ?? "",
|
||||
page: String(params.page ?? 1),
|
||||
page_size: String(params.page_size ?? 50),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface AuthentikMessageInput {
|
||||
recipient_emails: string[];
|
||||
subject: string;
|
||||
html_body: string;
|
||||
}
|
||||
|
||||
export interface AuthentikMessageResponse {
|
||||
status: string;
|
||||
request_id?: string;
|
||||
recipient_count?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function sendAuthentikMessage(
|
||||
serviceId: string,
|
||||
input: AuthentikMessageInput,
|
||||
): Promise<AuthentikMessageResponse> {
|
||||
return post<AuthentikMessageResponse>(
|
||||
`/api/services/authentik/${serviceId}/message`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAuthentikMessageStatus(
|
||||
serviceId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return get<Record<string, unknown>>(
|
||||
`/api/services/authentik/${serviceId}/message/status`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* API client for the named-dashboards backend (Slice 3).
|
||||
*/
|
||||
import { del, get, post, put } from "./shared";
|
||||
|
||||
export interface NamedDashboard {
|
||||
id: string;
|
||||
label: string;
|
||||
slug: string;
|
||||
sort_order: number;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NamedDashboardInput {
|
||||
id?: string | null;
|
||||
label: string;
|
||||
slug?: string;
|
||||
sort_order: number;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchDashboards(): Promise<NamedDashboard[]> {
|
||||
return get<NamedDashboard[]>("/api/dashboards");
|
||||
}
|
||||
|
||||
export async function fetchDashboardBySlug(
|
||||
slug: string,
|
||||
): Promise<NamedDashboard> {
|
||||
return get<NamedDashboard>(
|
||||
`/api/dashboards/slug/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createDashboard(
|
||||
input: NamedDashboardInput,
|
||||
): Promise<NamedDashboard> {
|
||||
return post<NamedDashboard>("/api/dashboards", input);
|
||||
}
|
||||
|
||||
export async function updateDashboard(
|
||||
input: NamedDashboardInput,
|
||||
): Promise<NamedDashboard> {
|
||||
return put<NamedDashboard>(`/api/dashboards`, input);
|
||||
}
|
||||
|
||||
export async function deleteDashboard(id: string): Promise<{ status: string }> {
|
||||
return del<{ status: string }>(`/api/dashboards/${id}`);
|
||||
}
|
||||
@@ -12,8 +12,14 @@ export async function fetchBuiltinWidgetKinds(): Promise<
|
||||
return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
|
||||
}
|
||||
|
||||
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
||||
return get<WidgetInstance[]>("/api/widgets/instances");
|
||||
export async function fetchWidgetInstances(
|
||||
serviceId?: string,
|
||||
scope?: "dashboard" | "service",
|
||||
): Promise<WidgetInstance[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (serviceId) params.service_id = serviceId;
|
||||
if (scope) params.scope = scope;
|
||||
return get<WidgetInstance[]>("/api/widgets/instances", params);
|
||||
}
|
||||
|
||||
export async function createWidgetInstance(
|
||||
|
||||
@@ -1,667 +0,0 @@
|
||||
import { useMemo, useState, type ElementType, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Gauge,
|
||||
Inbox,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
ServerOff,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAlertmanagerAlerts,
|
||||
useAlertmanagerStatus,
|
||||
useGrafanaStatus,
|
||||
usePrometheusStatus,
|
||||
usePrometheusTargets,
|
||||
useMonitoringMachines,
|
||||
} from "../hooks/useObservability";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type {
|
||||
AlertmanagerAlert,
|
||||
MonitoringMachine,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
|
||||
function severityVariant(
|
||||
severity: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "info":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function HealthCard({
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
icon: Icon,
|
||||
isLoading,
|
||||
}: {
|
||||
title: string;
|
||||
status: "ok" | "warning" | "error" | "unknown";
|
||||
detail: string;
|
||||
icon: ElementType;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const statusIcon =
|
||||
status === "ok" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : status === "warning" ? (
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
) : status === "error" ? (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? <Skeleton className="h-5 w-5" /> : statusIcon}
|
||||
<span className="text-2xl font-bold capitalize">{status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryError({
|
||||
label,
|
||||
error,
|
||||
refetch,
|
||||
}: {
|
||||
label: string;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{label} failed</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="break-words">{error.message}</span>
|
||||
<Button variant="outline" size="sm" className="mobile-touch-target" onClick={() => refetch()}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-sm">{alert.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{alert.summary || alert.description}
|
||||
</div>
|
||||
{alert.active_since && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||
Since {new Date(alert.active_since).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
|
||||
{alert.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description:</span>{" "}
|
||||
{alert.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{alert.job_name && (
|
||||
<div>
|
||||
<span className="font-medium">Job:</span> {alert.job_name}
|
||||
</div>
|
||||
)}
|
||||
{alert.category && (
|
||||
<div>
|
||||
<span className="font-medium">Category:</span> {alert.category}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">State:</span> {alert.state}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Since:</span>{" "}
|
||||
{alert.active_since
|
||||
? new Date(alert.active_since).toLocaleString()
|
||||
: "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{Object.entries(alert.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="secondary" className="text-[10px]">
|
||||
{key}={value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-3">
|
||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(target.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px]">
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrafanaLinkCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservabilityPage() {
|
||||
const {
|
||||
data: alertsSummary,
|
||||
isLoading: alertsLoading,
|
||||
error: alertsError,
|
||||
refetch: refetchAlerts,
|
||||
} = useAlertmanagerAlerts();
|
||||
const {
|
||||
data: alertmanagerStatus,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
refetch: refetchStatus,
|
||||
} = useAlertmanagerStatus();
|
||||
const {
|
||||
data: grafanaStatus,
|
||||
isLoading: grafanaLoading,
|
||||
error: grafanaError,
|
||||
refetch: refetchGrafana,
|
||||
} = useGrafanaStatus();
|
||||
const {
|
||||
data: prometheusStatus,
|
||||
isLoading: prometheusLoading,
|
||||
error: prometheusError,
|
||||
refetch: refetchPrometheus,
|
||||
} = usePrometheusStatus();
|
||||
const {
|
||||
data: prometheusTargets,
|
||||
isLoading: targetsLoading,
|
||||
error: targetsError,
|
||||
refetch: refetchTargets,
|
||||
} = usePrometheusTargets();
|
||||
const {
|
||||
data: machines = [],
|
||||
isLoading: machinesLoading,
|
||||
error: machinesError,
|
||||
refetch: refetchMachines,
|
||||
} = useMonitoringMachines();
|
||||
const { data: grafanaServices = [] } = useServiceInstances("grafana");
|
||||
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
|
||||
|
||||
const grafanaService =
|
||||
grafanaServices.find((s) => s.enabled) ?? grafanaServices[0];
|
||||
const GRAFANA_BASE_URL =
|
||||
(grafanaService?.config?.base_url as string | undefined) ?? "";
|
||||
|
||||
const selectedMachine = useMemo<MonitoringMachine | null>(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||
const instance = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
|
||||
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||
|
||||
const alertmanagerStatusDetail = alertmanagerStatus?.up
|
||||
? alertmanagerStatus.version
|
||||
? `version ${alertmanagerStatus.version}`
|
||||
: "reachable"
|
||||
: "unreachable";
|
||||
|
||||
const targetsCount = prometheusTargets?.length ?? 0;
|
||||
const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading
|
||||
? "unknown"
|
||||
: targetsError
|
||||
? "error"
|
||||
: targetsCount > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading
|
||||
? "unknown"
|
||||
: alertsError
|
||||
? "error"
|
||||
: (alertsSummary?.total ?? 0) > 0
|
||||
? alertsSummary?.alerts.some((a) => a.severity === "critical")
|
||||
? "error"
|
||||
: "warning"
|
||||
: "ok";
|
||||
|
||||
const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading
|
||||
? "unknown"
|
||||
: machinesError
|
||||
? "error"
|
||||
: machines.length > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Observability</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unified view of metrics, logs, and alerts from Prometheus, Loki, and
|
||||
Alertmanager. Deep dashboards live in Grafana.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<HealthCard
|
||||
title="Alertmanager"
|
||||
status={
|
||||
statusError
|
||||
? "error"
|
||||
: alertmanagerStatus?.up
|
||||
? "ok"
|
||||
: statusLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={alertmanagerStatusDetail}
|
||||
icon={Bell}
|
||||
isLoading={statusLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Active Alerts"
|
||||
status={alertStatus}
|
||||
detail={`${alertsSummary?.total ?? 0} firing alert${(alertsSummary?.total ?? 0) === 1 ? "" : "s"}`}
|
||||
icon={AlertTriangle}
|
||||
isLoading={alertsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Prometheus Targets"
|
||||
status={targetsStatus}
|
||||
detail={`${targetsCount} remote Node Exporter target${targetsCount === 1 ? "" : "s"}`}
|
||||
icon={Radio}
|
||||
isLoading={targetsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Machines"
|
||||
status={machinesStatus}
|
||||
detail={`${machines.length} monitoring machine${machines.length === 1 ? "" : "s"}`}
|
||||
icon={Server}
|
||||
isLoading={machinesLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Grafana"
|
||||
status={
|
||||
grafanaError
|
||||
? "error"
|
||||
: grafanaStatus?.up
|
||||
? "ok"
|
||||
: grafanaLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={
|
||||
grafanaStatus?.up
|
||||
? grafanaStatus.version
|
||||
? `version ${grafanaStatus.version}`
|
||||
: "reachable"
|
||||
: grafanaStatus?.error === "no_service_configured"
|
||||
? "not configured"
|
||||
: "unreachable"
|
||||
}
|
||||
icon={Gauge}
|
||||
isLoading={grafanaLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Prometheus"
|
||||
status={
|
||||
prometheusError
|
||||
? "error"
|
||||
: prometheusStatus?.up
|
||||
? "ok"
|
||||
: prometheusLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={
|
||||
prometheusStatus?.up
|
||||
? prometheusStatus.version
|
||||
? `version ${prometheusStatus.version}`
|
||||
: "reachable"
|
||||
: prometheusStatus?.error === "no_service_configured"
|
||||
? "not configured"
|
||||
: "unreachable"
|
||||
}
|
||||
icon={Radio}
|
||||
isLoading={prometheusLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{statusError && (
|
||||
<QueryError
|
||||
label="Alertmanager status"
|
||||
error={statusError}
|
||||
refetch={refetchStatus}
|
||||
/>
|
||||
)}
|
||||
{alertsError && (
|
||||
<QueryError
|
||||
label="Active alerts"
|
||||
error={alertsError}
|
||||
refetch={refetchAlerts}
|
||||
/>
|
||||
)}
|
||||
{targetsError && (
|
||||
<QueryError
|
||||
label="Prometheus targets"
|
||||
error={targetsError}
|
||||
refetch={refetchTargets}
|
||||
/>
|
||||
)}
|
||||
{machinesError && (
|
||||
<QueryError
|
||||
label="Monitoring machines"
|
||||
error={machinesError}
|
||||
refetch={refetchMachines}
|
||||
/>
|
||||
)}
|
||||
{grafanaError && (
|
||||
<QueryError
|
||||
label="Grafana status"
|
||||
error={grafanaError}
|
||||
refetch={refetchGrafana}
|
||||
/>
|
||||
)}
|
||||
{prometheusError && (
|
||||
<QueryError
|
||||
label="Prometheus status"
|
||||
error={prometheusError}
|
||||
refetch={refetchPrometheus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{alertsSummary?.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Alertmanager unreachable</AlertTitle>
|
||||
<AlertDescription>
|
||||
The UI cannot reach Alertmanager right now. Alerts shown here may be
|
||||
stale.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Recent Alerts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alertsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !alertsSummary || alertsSummary.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No active alerts"
|
||||
description="Everything looks quiet. Alertmanager will list firing alerts here when they occur."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{alertsSummary.alerts.map((alert, idx) => (
|
||||
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
|
||||
))}
|
||||
{alertsSummary.total > alertsSummary.alerts.length && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
{alertsSummary.total - alertsSummary.alerts.length} more
|
||||
alert
|
||||
{alertsSummary.total - alertsSummary.alerts.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
in Alertmanager
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus Targets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targetsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !prometheusTargets || prometheusTargets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Radio}
|
||||
title="No Node Exporter targets"
|
||||
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
||||
action={
|
||||
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TargetsTable targets={prometheusTargets} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machines.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedMachine ? (
|
||||
GRAFANA_BASE_URL ? (
|
||||
<>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||
href={nodeExporterDashboardUrl}
|
||||
/>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} logs`}
|
||||
description="Explore Loki logs for this machine in Grafana."
|
||||
href={logsUrl}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Gauge}
|
||||
title="No Grafana service configured"
|
||||
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
||||
action={
|
||||
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||
<Link to="/services">Open Services</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title="No machine selected"
|
||||
description="Add monitoring machines in Settings to see Grafana drill-down links."
|
||||
action={
|
||||
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Boxes, ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Pinned service link rendered on named dashboards. A card-shaped shortcut
|
||||
* that navigates to a service page (or a specific tab via query param).
|
||||
*
|
||||
* The `target` is a route path like `/services/jellyfin/svc-1` or
|
||||
* `/services/ssh_tasks/svc-2?tab=Files`.
|
||||
*/
|
||||
export interface PinnedServiceLinkProps {
|
||||
label: string;
|
||||
target: string;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PinnedServiceLink({
|
||||
label,
|
||||
target,
|
||||
icon: Icon = Boxes,
|
||||
className,
|
||||
}: PinnedServiceLinkProps) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(target)}
|
||||
className={cn(
|
||||
"mobile-touch-target group flex min-h-16 w-full items-center justify-between rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper: build a target path for a pinned service link.
|
||||
* Returns `/services/:type/:id` or with a `?tab=` suffix when provided.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function serviceLinkTarget(
|
||||
serviceType: string,
|
||||
serviceId: string,
|
||||
tab?: string,
|
||||
): string {
|
||||
const base = `/services/${serviceType}/${serviceId}`;
|
||||
return tab ? `${base}?tab=${tab}` : base;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PinnedServiceLink } from "../PinnedServiceLink";
|
||||
|
||||
function renderLink() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PinnedServiceLink
|
||||
label="My Jellyfin"
|
||||
target="/services/jellyfin/svc-1"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/services/jellyfin/svc-1"
|
||||
element={<div>target page</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PinnedServiceLink", () => {
|
||||
it("renders the label", () => {
|
||||
renderLink();
|
||||
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to the target on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLink();
|
||||
await user.click(screen.getByText("My Jellyfin"));
|
||||
expect(screen.getByText("target page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ function Tabs({
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -23,7 +23,7 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -61,10 +61,10 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Hooks for the Authentik directory + messaging tabs. */
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchAuthentikMessageStatus,
|
||||
fetchAuthentikUsers,
|
||||
sendAuthentikMessage,
|
||||
} from "../api/authentik";
|
||||
|
||||
export function useAuthentikUsers(
|
||||
serviceId: string,
|
||||
params: { search?: string; page?: number; page_size?: number },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["authentik", "users", serviceId, params],
|
||||
queryFn: () => fetchAuthentikUsers(serviceId, params),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendAuthentikMessage(serviceId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: {
|
||||
recipient_emails: string[];
|
||||
subject: string;
|
||||
html_body: string;
|
||||
}) => sendAuthentikMessage(serviceId, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["authentik", "message-status", serviceId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthentikMessageStatus(serviceId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["authentik", "message-status", serviceId],
|
||||
queryFn: () => fetchAuthentikMessageStatus(serviceId),
|
||||
refetchInterval: 5_000,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createDashboard,
|
||||
deleteDashboard,
|
||||
fetchDashboardBySlug,
|
||||
fetchDashboards,
|
||||
updateDashboard,
|
||||
type NamedDashboardInput,
|
||||
} from "../api/dashboards";
|
||||
|
||||
export function useDashboards() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboards"],
|
||||
queryFn: fetchDashboards,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDashboardBySlug(slug: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboards", "slug", slug],
|
||||
queryFn: () => fetchDashboardBySlug(slug!),
|
||||
enabled: !!slug,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: NamedDashboardInput) =>
|
||||
input.id ? updateDashboard(input) : createDashboard(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteDashboard(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUsers } from "../api/client";
|
||||
import type { UserDirectoryResponse } from "../types";
|
||||
|
||||
export function useUsers(jellyfinServiceId?: string) {
|
||||
return useQuery<UserDirectoryResponse>({
|
||||
queryKey: ["users", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchUsers(jellyfinServiceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
} from "../api/widgets";
|
||||
import type { WidgetInstanceInput } from "../types";
|
||||
|
||||
export function useWidgetInstances() {
|
||||
export function useWidgetInstances(
|
||||
serviceId?: string,
|
||||
scope?: "dashboard" | "service",
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "instances"],
|
||||
queryFn: fetchWidgetInstances,
|
||||
queryKey: ["widgets", "instances", serviceId ?? null, scope ?? null],
|
||||
queryFn: () => fetchWidgetInstances(serviceId, scope),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
|
||||
|
||||
describe("navEntries", () => {
|
||||
it("returns no entries when no types are configured", () => {
|
||||
expect(configuredNavEntries(new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns Jellyfin when jellyfin is configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["jellyfin"]));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe("Jellyfin");
|
||||
expect(entries[0].path).toBe("/services/jellyfin");
|
||||
});
|
||||
|
||||
it("returns one SSH Tasks entry when ssh_tasks is configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe("SSH Tasks");
|
||||
});
|
||||
|
||||
it("returns all observability entries", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["alertmanager", "grafana", "prometheus"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Alertmanager",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns Backups + Authentik when configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
|
||||
expect(entries.map((e) => e.label)).toEqual(["Backups", "Authentik"]);
|
||||
});
|
||||
|
||||
it("nextcloud has no nav entries in the static map", () => {
|
||||
expect(
|
||||
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves declaration order across mixed types", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["authentik", "ssh_tasks", "jellyfin"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Jellyfin",
|
||||
"SSH Tasks",
|
||||
"Authentik",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Service-type → conditional nav-entry map.
|
||||
*
|
||||
* Each configured service type contributes ONE top-level nav entry that
|
||||
* appears only when at least one enabled instance of that type exists. The
|
||||
* label is the service TYPE name (Jellyfin, SSH Tasks), not a conceptual
|
||||
* name (Media, Files) — the service page's content tabs surface the concepts.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
GanttChartSquare,
|
||||
Link2,
|
||||
Monitor,
|
||||
Server,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavEntry {
|
||||
serviceType: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Route path for this entry. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static mapping from service type to its conditional nav entry.
|
||||
* Uses the service type's display name. One entry per type.
|
||||
* `nextcloud` has no entry (no operational content tabs).
|
||||
*/
|
||||
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||
{
|
||||
serviceType: "jellyfin",
|
||||
label: "Jellyfin",
|
||||
icon: Monitor,
|
||||
path: "/services/jellyfin",
|
||||
},
|
||||
{
|
||||
serviceType: "ssh_tasks",
|
||||
label: "SSH Tasks",
|
||||
icon: Server,
|
||||
path: "/services/ssh_tasks",
|
||||
},
|
||||
{
|
||||
serviceType: "alertmanager",
|
||||
label: "Alertmanager",
|
||||
icon: Activity,
|
||||
path: "/services/alertmanager",
|
||||
},
|
||||
{
|
||||
serviceType: "grafana",
|
||||
label: "Grafana",
|
||||
icon: Link2,
|
||||
path: "/services/grafana",
|
||||
},
|
||||
{
|
||||
serviceType: "prometheus",
|
||||
label: "Prometheus",
|
||||
icon: GanttChartSquare,
|
||||
path: "/services/prometheus",
|
||||
},
|
||||
{
|
||||
serviceType: "backups",
|
||||
label: "Backups",
|
||||
icon: DatabaseBackup,
|
||||
path: "/services/backups",
|
||||
},
|
||||
{
|
||||
serviceType: "authentik",
|
||||
label: "Authentik",
|
||||
icon: Users,
|
||||
path: "/services/authentik",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter the static entries to those whose service type is configured (present
|
||||
* in the `configuredTypes` set). Returns a flat list in declaration order.
|
||||
*/
|
||||
export function configuredNavEntries(configuredTypes: Set<string>): NavEntry[] {
|
||||
return SERVICE_TYPE_NAV_ENTRIES.filter((e) =>
|
||||
configuredTypes.has(e.serviceType),
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Media } from "./Media";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
|
||||
function JellyfinLibraryStats() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||
const selectedServiceId =
|
||||
searchParams.get("jellyfin_service_id") ||
|
||||
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||
"";
|
||||
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Library stats"
|
||||
description="Compact Jellyfin summary for the selected machine."
|
||||
action={
|
||||
<Badge variant="outline">
|
||||
{selectedServiceId ? "Selected service" : "Default service"}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{counts ? (
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Total</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{(
|
||||
counts.movies +
|
||||
counts.series +
|
||||
counts.episodes
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Movies</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.movies.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Series</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.series.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Episodes</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.episodes.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{libraries?.length ? (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{libraries.map((library) => (
|
||||
<div
|
||||
key={library.library}
|
||||
className="rounded-lg border bg-card px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{library.library}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total {library.total.toLocaleString()} · Movies{" "}
|
||||
{library.movies.toLocaleString()} · Series{" "}
|
||||
{library.series.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function Applications() {
|
||||
const [tab, setTab] = useState("jellyfin");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Applications</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse application-specific tools from a compact tabbed workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TabbedCard
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||
Jellyfin
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||
Nextcloud
|
||||
</TabsTrigger>,
|
||||
]}
|
||||
>
|
||||
{tab === "jellyfin" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<JellyfinLibraryStats />
|
||||
<Media />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Nextcloud support will be added in a future update.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ import { DialogFooter } from "../components/DialogFooter";
|
||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||
|
||||
// --- Mobile section grouping (spec R7.2) ---
|
||||
// --- Mobile section grouping (mobile-parity) ---
|
||||
|
||||
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
|
||||
type SectionId = (typeof SECTION_ORDER)[number];
|
||||
@@ -102,7 +102,6 @@ function MobileWidgetSections({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Anchor bar — horizontally scrollable pills (spec R7.2, md:hidden) */}
|
||||
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||
{sections.map((section) => {
|
||||
const meta = SECTION_META[section.id];
|
||||
@@ -127,7 +126,6 @@ function MobileWidgetSections({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Sectioned widgets — single column (spec R7.1) */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
@@ -354,7 +352,6 @@ function ShortcutDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="shortcut-enabled"
|
||||
className="mobile-touch-target"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...draft, enabled: checked })
|
||||
@@ -425,24 +422,13 @@ function ShortcutCard({
|
||||
size="sm"
|
||||
disabled={!shortcut.enabled || !href}
|
||||
onClick={onOpen}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onEdit}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={onDelete}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
@@ -462,7 +448,10 @@ export function Dashboard() {
|
||||
);
|
||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||
const { data: widgetInstances = [] } = useWidgetInstances(
|
||||
undefined,
|
||||
"dashboard",
|
||||
);
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -508,23 +497,36 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{services.length === 0 ? (
|
||||
<SectionCard
|
||||
title="Welcome to Manage"
|
||||
description="Add a service to get started."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No services configured yet. Add a Jellyfin, SSH target, Authentik,
|
||||
or observability service to populate the navigation and
|
||||
dashboards.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/services")}
|
||||
className="w-fit"
|
||||
>
|
||||
Add a service
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
<SectionCard
|
||||
title="Shortcuts"
|
||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mobile-touch-target"
|
||||
onClick={() => setWidgetDialogOpen(true)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
|
||||
Edit dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mobile-touch-target"
|
||||
onClick={openCreateShortcut}
|
||||
>
|
||||
<Button variant="outline" onClick={openCreateShortcut}>
|
||||
Add shortcut
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { FileBrowser } from "./FileBrowser.impl";
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDashboardBySlug } from "../hooks/useDashboards";
|
||||
import { PinnedServiceLink } from "../components/PinnedServiceLink";
|
||||
|
||||
/**
|
||||
* Payload model for named dashboards (design choice: inline items, not widget
|
||||
* instance ids). The payload stores an ordered list of items:
|
||||
*
|
||||
* ```
|
||||
* { items: DashboardItem[] }
|
||||
* ```
|
||||
*
|
||||
* Where `DashboardItem` is either a pinned service link (this slice) or a
|
||||
* future widget reference (follow-up). Widget composition on named dashboards
|
||||
* is deferred — the main Dashboard already has the rich widget config dialog.
|
||||
*/
|
||||
interface LinkItem {
|
||||
type: "link";
|
||||
label: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
type DashboardItem = LinkItem;
|
||||
|
||||
function parseItems(payload: Record<string, unknown>): DashboardItem[] {
|
||||
const items = payload.items;
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.filter(
|
||||
(item): item is LinkItem =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
item.type === "link" &&
|
||||
typeof item.label === "string" &&
|
||||
typeof item.target === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function NamedDashboardPage() {
|
||||
const { slug = "" } = useParams<{ slug: string }>();
|
||||
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
|
||||
|
||||
const items = useMemo(
|
||||
() => parseItems(dashboard?.payload ?? {}),
|
||||
[dashboard?.payload],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Dashboard not found. It may have been deleted or the link is invalid.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This dashboard has no shortcuts yet. Add pinned service links from
|
||||
the dashboard management panel on the Services page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item, index) => (
|
||||
<PinnedServiceLink
|
||||
key={`${item.target}-${index}`}
|
||||
label={item.label}
|
||||
target={item.target}
|
||||
icon={Boxes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,17 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
useServiceTypes,
|
||||
} from "../hooks/useServices";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../types";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import type { ServiceInstance } from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import { getServiceBinding } from "../integrations/registry";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import {
|
||||
OVERVIEW_TAB,
|
||||
serviceContentTabs,
|
||||
type ContentTab,
|
||||
} from "./service-tabs";
|
||||
|
||||
export function ServicePage() {
|
||||
const { serviceType = "", serviceId = "" } = useParams<{
|
||||
@@ -51,39 +19,24 @@ export function ServicePage() {
|
||||
serviceId: string;
|
||||
}>();
|
||||
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const saveService = useSaveServiceInstance();
|
||||
const deleteService = useDeleteServiceInstance();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const instance = useMemo(
|
||||
() => services.find((s) => s.id === serviceId),
|
||||
[services, serviceId],
|
||||
);
|
||||
const binding = getServiceBinding(serviceType);
|
||||
const typeInfo = useMemo(
|
||||
() => types.find((t) => t.service_type === serviceType),
|
||||
[types, serviceType],
|
||||
const contentTabs = useMemo(
|
||||
() => serviceContentTabs(serviceType),
|
||||
[serviceType],
|
||||
);
|
||||
const siblings = useMemo(
|
||||
() => services.filter((s) => s.service_type === serviceType && s.enabled),
|
||||
[services, serviceType],
|
||||
);
|
||||
const showInstanceTabs = siblings.length > 1;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
// The mobile SheetForm opens by default when the page loads: this page is
|
||||
// reached via /services/:serviceType/:serviceId, always editing an existing
|
||||
// instance, so there is no separate "open edit" trigger on mobile.
|
||||
const [sheetOpen, setSheetOpen] = useState(true);
|
||||
|
||||
// Hydrate local form state once the instance loads.
|
||||
if (instance && !hydrated) {
|
||||
setName(instance.name);
|
||||
setEnabled(instance.enabled);
|
||||
setDraftConfig({ ...instance.config });
|
||||
setHydrated(true);
|
||||
}
|
||||
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
@@ -101,328 +54,93 @@ export function ServicePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function buildInput(): ServiceInstanceInput {
|
||||
return {
|
||||
id: instance!.id,
|
||||
service_type: instance!.service_type,
|
||||
name,
|
||||
config: draftConfig,
|
||||
secrets: {}, // secrets are managed via the dedicated inputs below
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await saveService.mutateAsync(buildInput());
|
||||
// R4.5: close the sheet on successful save and return to the services list
|
||||
// (on mobile the sheet IS the page, so closing it would strand the user).
|
||||
if (isMobile) {
|
||||
setSheetOpen(false);
|
||||
navigate("/services");
|
||||
}
|
||||
}
|
||||
|
||||
const configFields = (
|
||||
<ServiceConnectionFields
|
||||
instance={instance}
|
||||
typeInfo={typeInfo}
|
||||
draftConfig={draftConfig}
|
||||
onConfigChange={setDraftConfig}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
|
||||
const widgetsCard =
|
||||
const widgetsContent =
|
||||
binding.widgets.length > 0 ? (
|
||||
<SectionCard
|
||||
title="Widgets"
|
||||
description="Widget kinds this service provides."
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{binding.widgets.map((w) => (
|
||||
<div
|
||||
key={w.kind}
|
||||
className="flex items-center justify-between rounded border p-2"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{w.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{w.description}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{binding.widgets.map((w) => (
|
||||
<div
|
||||
key={w.kind}
|
||||
className="flex items-center justify-between rounded border p-2"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{w.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{w.description}
|
||||
</div>
|
||||
<Badge variant="outline">{w.kind}</Badge>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add these to the dashboard from the dashboard's edit dialog.
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null;
|
||||
|
||||
const confirmDelete = (
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Dirty when any editable field diverges from the persisted instance (mobile SheetForm R4.5 guard).
|
||||
const isDirty =
|
||||
name !== instance.name ||
|
||||
enabled !== instance.enabled ||
|
||||
JSON.stringify(draftConfig) !== JSON.stringify(instance.config);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SheetForm
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={name || instance.name}
|
||||
onSave={save}
|
||||
onCancel={() => {
|
||||
setSheetOpen(false);
|
||||
navigate("/services");
|
||||
}}
|
||||
isPending={saveService.isPending}
|
||||
isDirty={isDirty}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Field label="Name" htmlFor="service-name">
|
||||
<Input
|
||||
id="service-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor="service-enabled">Enabled</Label>
|
||||
</div>
|
||||
{configFields}
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete service
|
||||
</Button>
|
||||
{widgetsCard}
|
||||
<Badge variant="outline">{w.kind}</Badge>
|
||||
</div>
|
||||
</SheetForm>
|
||||
{confirmDelete}
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add these to the dashboard from the dashboard's edit dialog.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No widget kinds for this service type.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{binding.name}</Badge>
|
||||
</div>
|
||||
|
||||
<SectionCard title="General">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="Name" htmlFor="service-name">
|
||||
<Input
|
||||
id="service-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
className="mobile-touch-target"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor="service-enabled">Enabled</Label>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
onClick={save}
|
||||
disabled={saveService.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{configFields}
|
||||
|
||||
{widgetsCard}
|
||||
|
||||
{confirmDelete}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceConnectionFields({
|
||||
instance,
|
||||
typeInfo,
|
||||
draftConfig,
|
||||
onConfigChange,
|
||||
isMobile,
|
||||
}: {
|
||||
instance: ServiceInstance;
|
||||
typeInfo: ServiceTypeInfo | undefined;
|
||||
draftConfig: Record<string, unknown>;
|
||||
onConfigChange: (config: Record<string, unknown>) => void;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const saveService = useSaveServiceInstance();
|
||||
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
||||
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||
|
||||
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" },
|
||||
]);
|
||||
|
||||
function handleUpdateConnection() {
|
||||
const onlyChanged = Object.fromEntries(
|
||||
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||
);
|
||||
saveService.mutate({
|
||||
id: instance.id,
|
||||
service_type: instance.service_type,
|
||||
name: instance.name,
|
||||
config: draftConfig,
|
||||
secrets: onlyChanged,
|
||||
enabled: instance.enabled,
|
||||
});
|
||||
setDraftSecrets({});
|
||||
}
|
||||
|
||||
const fields = (
|
||||
<div className="flex flex-col gap-3">
|
||||
{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}
|
||||
{/* 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}`)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{sibling.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{Object.keys(instance.secrets_set).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
||||
) : (
|
||||
<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) =>
|
||||
setDraftSecrets({
|
||||
...draftSecrets,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||
</div>
|
||||
{/* Content tabs */}
|
||||
<Tabs defaultValue="Overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Overview">Overview</TabsTrigger>
|
||||
{contentTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Button className="mobile-touch-target" onClick={handleUpdateConnection}>
|
||||
Update connection
|
||||
</Button>
|
||||
{allTabs.map((tab) => {
|
||||
const TabComponent = tab.Component;
|
||||
return (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<TabComponent instance={instance} />
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
|
||||
<TabsContent value="Widgets">
|
||||
<SectionCard
|
||||
title="Widgets"
|
||||
description="Widget kinds this service provides."
|
||||
>
|
||||
{widgetsContent}
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// On mobile the fields render inside the SheetForm body without a card
|
||||
// wrapper (the SheetForm already provides the container). On desktop they
|
||||
// keep their original SectionCard framing.
|
||||
if (isMobile) {
|
||||
return <div className="flex flex-col gap-3">{fields}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Connection"
|
||||
description="Edit non-secret connection config and secret values."
|
||||
>
|
||||
{fields}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Handles `/services/:type` (no instance id). Resolves the first enabled
|
||||
* instance and redirects. Shows an empty state if none are configured.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams, Navigate } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
|
||||
export function ServiceTypePage() {
|
||||
const { serviceType = "" } = useParams<{ serviceType: string }>();
|
||||
const { data: instances = [], isLoading } = useServiceInstances(
|
||||
serviceType || undefined,
|
||||
);
|
||||
|
||||
const firstEnabled = useMemo(
|
||||
() => instances.find((s) => s.enabled) ?? instances[0],
|
||||
[instances],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (firstEnabled) {
|
||||
return (
|
||||
<Navigate to={`/services/${serviceType}/${firstEnabled.id}`} replace />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
<span>No {serviceType} service configured.</span>
|
||||
<Button asChild className="w-fit">
|
||||
<Link to="/services">Add a service</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,31 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
} from "../hooks/useServices";
|
||||
import { useServiceTypes } from "../hooks/useServices";
|
||||
import {
|
||||
useDashboards,
|
||||
useDeleteDashboard,
|
||||
useSaveDashboard,
|
||||
} from "../hooks/useDashboards";
|
||||
import type {
|
||||
SecretFieldInfo,
|
||||
ServiceInstance,
|
||||
@@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { getServiceBinding } from "../integrations/registry";
|
||||
import { serviceLinkTarget } from "../components/PinnedServiceLink";
|
||||
import type { NamedDashboardInput } from "../api/dashboards";
|
||||
|
||||
interface CreateDraft {
|
||||
serviceType: string;
|
||||
@@ -195,7 +215,7 @@ function CreateServiceDialog({
|
||||
{!draft ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{types.map((t) => (
|
||||
<Button className="mobile-touch-target"
|
||||
<Button
|
||||
key={t.service_type}
|
||||
variant="outline"
|
||||
onClick={() => setDraft(emptyDraft(t.service_type))}
|
||||
@@ -234,7 +254,6 @@ function CreateServiceDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
className="mobile-touch-target"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft({ ...draft, enabled: checked })
|
||||
@@ -258,6 +277,239 @@ function CreateServiceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Named dashboards management (Slice 10.3) ---
|
||||
|
||||
function DashboardManagementCard() {
|
||||
const { data: dashboards = [] } = useDashboards();
|
||||
const saveDashboard = useSaveDashboard();
|
||||
const deleteDashboard = useDeleteDashboard();
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [linkDashId, setLinkDashId] = useState<string | null>(null);
|
||||
const [linkLabel, setLinkLabel] = useState("");
|
||||
const [linkTarget, setLinkTarget] = useState("");
|
||||
|
||||
const enabledServices = useMemo(
|
||||
() => services.filter((s) => s.enabled),
|
||||
[services],
|
||||
);
|
||||
|
||||
function createDashboard() {
|
||||
if (!newLabel.trim()) return;
|
||||
const input: NamedDashboardInput = {
|
||||
label: newLabel.trim(),
|
||||
sort_order: dashboards.length,
|
||||
payload: { items: [] },
|
||||
};
|
||||
saveDashboard.mutate(input);
|
||||
setNewLabel("");
|
||||
setCreateOpen(false);
|
||||
}
|
||||
|
||||
function reorder(dashId: string, direction: -1 | 1) {
|
||||
const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const idx = sorted.findIndex((d) => d.id === dashId);
|
||||
const swapIdx = idx + direction;
|
||||
if (swapIdx < 0 || swapIdx >= sorted.length) return;
|
||||
const a = sorted[idx];
|
||||
const b = sorted[swapIdx];
|
||||
saveDashboard.mutate({
|
||||
...a,
|
||||
sort_order: b.sort_order,
|
||||
payload: a.payload,
|
||||
});
|
||||
saveDashboard.mutate({
|
||||
...b,
|
||||
sort_order: a.sort_order,
|
||||
payload: b.payload,
|
||||
});
|
||||
}
|
||||
|
||||
function addPinnedLink() {
|
||||
if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return;
|
||||
const dash = dashboards.find((d) => d.id === linkDashId);
|
||||
if (!dash) return;
|
||||
const items = Array.isArray(dash.payload.items)
|
||||
? (dash.payload.items as unknown[])
|
||||
: [];
|
||||
items.push({ type: "link", label: linkLabel.trim(), target: linkTarget });
|
||||
saveDashboard.mutate({
|
||||
id: dash.id,
|
||||
label: dash.label,
|
||||
sort_order: dash.sort_order,
|
||||
payload: { items },
|
||||
});
|
||||
setLinkLabel("");
|
||||
setLinkTarget("");
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Dashboards"
|
||||
description="Named dashboards appear in the top nav. Compose them from pinned service links."
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
New dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No named dashboards yet. Create one to add pinned service links.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...dashboards]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((d, idx, arr) => (
|
||||
<div key={d.id} className="rounded border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{d.label}</span>
|
||||
<Badge variant="outline">/{d.slug}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={idx === 0}
|
||||
onClick={() => reorder(d.id, -1)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={idx === arr.length - 1}
|
||||
onClick={() => reorder(d.id, 1)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => setDeleteId(d.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{Array.isArray(d.payload.items) &&
|
||||
(d.payload.items as unknown[]).length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(d.payload.items as unknown[]).length} pinned link(s)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
No links yet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<Field label="Link label" htmlFor={`link-label-${d.id}`}>
|
||||
<Input
|
||||
id={`link-label-${d.id}`}
|
||||
className="w-40"
|
||||
placeholder="My Jellyfin"
|
||||
value={linkDashId === d.id ? linkLabel : ""}
|
||||
onChange={(e) => {
|
||||
setLinkDashId(d.id);
|
||||
setLinkLabel(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`link-target-${d.id}`}>Service</Label>
|
||||
<Select
|
||||
value={linkDashId === d.id ? linkTarget : ""}
|
||||
onValueChange={(v) => {
|
||||
setLinkDashId(d.id);
|
||||
setLinkTarget(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`link-target-${d.id}`}
|
||||
className="w-56"
|
||||
>
|
||||
<SelectValue placeholder="Pick a service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledServices.map((s) => (
|
||||
<SelectItem
|
||||
key={s.id}
|
||||
value={serviceLinkTarget(s.service_type, s.id)}
|
||||
>
|
||||
{s.name} ({s.service_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
linkDashId !== d.id ||
|
||||
!linkLabel.trim() ||
|
||||
!linkTarget.trim()
|
||||
}
|
||||
onClick={addPinnedLink}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New dashboard</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Field label="Label" htmlFor="dash-label">
|
||||
<Input
|
||||
id="dash-label"
|
||||
placeholder="Storage overview"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") createDashboard();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<DialogFooter
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onConfirm={createDashboard}
|
||||
confirmLabel="Create"
|
||||
confirmDisabled={!newLabel.trim() || saveDashboard.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteId)}
|
||||
title="Delete dashboard?"
|
||||
message="This removes the named dashboard and its pinned links."
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setDeleteId(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteId) deleteDashboard.mutate(deleteId);
|
||||
setDeleteId(null);
|
||||
}}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
@@ -287,7 +539,7 @@ export function ServicesPage() {
|
||||
title="Services"
|
||||
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)} className="mobile-touch-target">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add service
|
||||
</Button>
|
||||
@@ -329,7 +581,6 @@ export function ServicesPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mobile-touch-target"
|
||||
onClick={() =>
|
||||
navigate(`/services/${s.service_type}/${s.id}`)
|
||||
}
|
||||
@@ -339,7 +590,7 @@ export function ServicesPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mobile-touch-target h-8 w-8 text-destructive"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(s.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -354,6 +605,8 @@ export function ServicesPage() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<DashboardManagementCard />
|
||||
|
||||
<CreateServiceDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
|
||||
@@ -50,6 +50,17 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
useServiceTypes,
|
||||
} from "../hooks/useServices";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../types";
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ value: "monitoring", label: "Monitoring" },
|
||||
@@ -61,7 +72,7 @@ const SERVICE_OPTIONS = [
|
||||
// maps to this sentinel and converts back to "" at the draft boundary.
|
||||
const NONE = "__none__";
|
||||
|
||||
type SettingsTab = "machines" | "ssh-keys" | "danger";
|
||||
type SettingsTab = "machines" | "ssh-keys" | "services" | "danger";
|
||||
|
||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||
function FormField({
|
||||
@@ -995,6 +1006,9 @@ export function Settings() {
|
||||
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
||||
SSH Keys
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="services" value="services">
|
||||
Services
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="danger" value="danger">
|
||||
Danger Zone
|
||||
</TabsTrigger>,
|
||||
@@ -1178,6 +1192,7 @@ export function Settings() {
|
||||
onSelectKeyId={setSelectedSSHKeyId}
|
||||
/>
|
||||
)}
|
||||
{tab === "services" && <ServicesAdminCard />}
|
||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||
</TabbedCard>
|
||||
{isMobile ? (
|
||||
@@ -1311,3 +1326,226 @@ export function Settings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Services admin card for the Settings > Services tab.
|
||||
*
|
||||
* Lists all service instances grouped by type with inline config editing
|
||||
* (enable/disable, config fields, secrets, save, delete). Lifted from the
|
||||
* old ServicePage ConfigBody — the service page is now a pure operational
|
||||
* view; all administration lives here.
|
||||
*/
|
||||
function ServicesAdminCard() {
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
|
||||
// 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { UsersPage } from "./UsersPage.impl";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Actions } from "../Actions";
|
||||
import type { SavedTask, ServiceInstance } from "../../types";
|
||||
|
||||
const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||
id: "t1",
|
||||
name: "Restart svc",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
});
|
||||
const deleteTaskMutate = vi.fn();
|
||||
const runTaskMutate = vi.fn().mockResolvedValue({});
|
||||
|
||||
let sshServices: ServiceInstance[] = [];
|
||||
let tasks: SavedTask[] = [];
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useTasks: () => ({ data: tasks }),
|
||||
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
|
||||
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: sshServices }),
|
||||
}));
|
||||
|
||||
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
|
||||
return {
|
||||
id: "s1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Box",
|
||||
config: { host: "box", username: "u" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as ServiceInstance;
|
||||
}
|
||||
|
||||
function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Restart svc",
|
||||
task_type: "shell",
|
||||
content: "systemctl restart foo",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as SavedTask;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveTaskMutate.mockClear();
|
||||
deleteTaskMutate.mockClear();
|
||||
runTaskMutate.mockClear();
|
||||
sshServices = [];
|
||||
tasks = [];
|
||||
});
|
||||
|
||||
describe("Actions", () => {
|
||||
it("shows the empty state and creates a task via the editor dialog", async () => {
|
||||
render(<Actions />);
|
||||
|
||||
expect(screen.getByText("No action selected")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add action" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
|
||||
// Editor dialog opened (Name field is unique to the editor).
|
||||
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
|
||||
// Controlled input parity: name + default shell type flow through.
|
||||
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
|
||||
|
||||
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
|
||||
const saved = saveTaskMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Restart svc");
|
||||
expect(saved.task_type).toBe("shell");
|
||||
expect(saved.default_service_id).toBe("");
|
||||
});
|
||||
|
||||
it("disables the Run button until a run service is selected", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
// Selecting a saved task tab exposes the detail + Run control.
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
|
||||
const runButton = screen.getByRole("button", { name: "Run action" });
|
||||
expect(runButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("runs a task on the selected SSH task service", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", { name: "Run on SSH task service" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("option", { name: "Box" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(runTaskMutate).toHaveBeenCalledTimes(1);
|
||||
expect(runTaskMutate).toHaveBeenCalledWith({
|
||||
taskId: "t1",
|
||||
serviceId: "s1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Applications } from "../Applications";
|
||||
|
||||
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
|
||||
// so this slice-4 test stays focused on the migrated Applications shell and
|
||||
// does not pull the still-MUI DataGrid into the jsdom render.
|
||||
vi.mock("../Media", () => ({
|
||||
Media: () => <div data-testid="media-child">Media</div>,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "Main",
|
||||
enabled: true,
|
||||
services: ["jellyfin"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: [
|
||||
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({
|
||||
data: { movies: 10, series: 5, episodes: 100 },
|
||||
}),
|
||||
useLibraries: () => ({
|
||||
data: [
|
||||
{ library: "Movies", total: 10, movies: 10, series: 0 },
|
||||
{ library: "Shows", total: 5, movies: 0, series: 5 },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Applications", () => {
|
||||
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
|
||||
render(<Applications />);
|
||||
|
||||
// Library stats header.
|
||||
expect(screen.getByText("Library stats")).toBeInTheDocument();
|
||||
|
||||
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
|
||||
expect(screen.getByText("115")).toBeInTheDocument();
|
||||
expect(screen.getByText("Episodes")).toBeInTheDocument();
|
||||
|
||||
// Library rows render their per-library totals (unique strings).
|
||||
expect(
|
||||
screen.getByText(/Total 10 · Movies 10 · Series 0/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Total 5 · Movies 0 · Series 5/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Tabs present.
|
||||
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
|
||||
|
||||
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
|
||||
expect(screen.getByTestId("media-child")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Dashboard } from "../Dashboard";
|
||||
import type {
|
||||
DashboardShortcut,
|
||||
ServiceInstance,
|
||||
WidgetInstance,
|
||||
} from "../../types";
|
||||
import type { DashboardShortcut } from "../../types";
|
||||
|
||||
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||
// (shortcut CRUD) without rendering widgets or their data queries.
|
||||
vi.mock("../../components/WidgetInstance", () => ({
|
||||
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
|
||||
<div data-testid="widget-stub">{widget.title}</div>
|
||||
),
|
||||
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
||||
}));
|
||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
||||
@@ -27,15 +21,11 @@ vi.mock("react-router-dom", () => ({
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
// --- Dynamic mock state (reset in beforeEach) ---
|
||||
let widgetInstances: WidgetInstance[] = [];
|
||||
let serviceInstances: ServiceInstance[] = [];
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: widgetInstances }),
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: serviceInstances }),
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||
@@ -75,26 +65,8 @@ beforeEach(() => {
|
||||
saveShortcutMutate.mockClear();
|
||||
deleteShortcutMutate.mockClear();
|
||||
shortcuts = [];
|
||||
widgetInstances = [];
|
||||
serviceInstances = [];
|
||||
setMatchMedia(false); // desktop by default
|
||||
});
|
||||
|
||||
// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
|
||||
|
||||
function setMatchMedia(matches: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query === "(max-width: 768px)" ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
describe("Dashboard", () => {
|
||||
it("shows the empty-state alert when there are no shortcuts", () => {
|
||||
render(<Dashboard />);
|
||||
@@ -142,142 +114,3 @@ describe("Dashboard", () => {
|
||||
expect(saved.shortcut_type).toBe("website");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Mobile layout tests (spec R7.1, R7.2) ---
|
||||
|
||||
function makeWidget(overrides: Partial<WidgetInstance> = {}): WidgetInstance {
|
||||
return {
|
||||
id: "w1",
|
||||
service_id: null,
|
||||
widget_kind: "static",
|
||||
title: "Widget 1",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(
|
||||
overrides: Partial<ServiceInstance> = {},
|
||||
): ServiceInstance {
|
||||
return {
|
||||
id: "svc1",
|
||||
service_type: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Dashboard mobile layout", () => {
|
||||
it("renders widgets in a single column with an anchor bar below md", () => {
|
||||
setMatchMedia(true); // mobile
|
||||
serviceInstances = [
|
||||
makeService({ id: "graf", service_type: "grafana" }),
|
||||
makeService({ id: "jelly", service_type: "jellyfin" }),
|
||||
];
|
||||
widgetInstances = [
|
||||
makeWidget({
|
||||
id: "w-obs",
|
||||
service_id: "graf",
|
||||
widget_kind: "link",
|
||||
title: "Grafana Link",
|
||||
}),
|
||||
makeWidget({
|
||||
id: "w-media",
|
||||
service_id: "jelly",
|
||||
widget_kind: "activity",
|
||||
title: "Jellyfin Activity",
|
||||
}),
|
||||
makeWidget({
|
||||
id: "w-backup",
|
||||
service_id: null,
|
||||
widget_kind: "backups",
|
||||
title: "Backup Summary",
|
||||
}),
|
||||
];
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
// Anchor bar pills are visible for populated sections (each label appears
|
||||
// in both the pill and the section heading, so use getAllByText).
|
||||
expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Sections with no widgets are NOT rendered.
|
||||
expect(screen.queryByText("Custom")).not.toBeInTheDocument();
|
||||
|
||||
// Each widget renders.
|
||||
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
|
||||
expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backup Summary")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render the anchor bar at desktop width", () => {
|
||||
setMatchMedia(false); // desktop
|
||||
serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
|
||||
widgetInstances = [
|
||||
makeWidget({
|
||||
id: "w-obs",
|
||||
service_id: "graf",
|
||||
widget_kind: "link",
|
||||
title: "Grafana Link",
|
||||
}),
|
||||
];
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
// Widget renders (flat list, no section wrappers).
|
||||
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
|
||||
|
||||
// No section headings or anchor pills on desktop.
|
||||
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Media")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("anchor bar pills jump to their section via scrollIntoView", async () => {
|
||||
setMatchMedia(true); // mobile
|
||||
serviceInstances = [
|
||||
makeService({ id: "graf", service_type: "grafana" }),
|
||||
makeService({ id: "jelly", service_type: "jellyfin" }),
|
||||
];
|
||||
widgetInstances = [
|
||||
makeWidget({
|
||||
id: "w-obs",
|
||||
service_id: "graf",
|
||||
widget_kind: "link",
|
||||
title: "Grafana Link",
|
||||
}),
|
||||
makeWidget({
|
||||
id: "w-media",
|
||||
service_id: "jelly",
|
||||
widget_kind: "activity",
|
||||
title: "Jellyfin Activity",
|
||||
}),
|
||||
];
|
||||
|
||||
const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
// The Media section element exists.
|
||||
expect(document.getElementById("dashboard-section-media")).not.toBeNull();
|
||||
|
||||
// Click the "Media" anchor pill (button role disambiguates from heading).
|
||||
const mediaPill = screen.getByRole("button", { name: "Media" });
|
||||
await userEvent.click(mediaPill);
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalled();
|
||||
scrollSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowser } from "../FileBrowser.impl";
|
||||
import type { DirectoryListing, MonitoringMachine } from "../../types";
|
||||
|
||||
// usePersistentState (browserState) reads/writes localStorage; clear between tests
|
||||
// so the selectedPath / currentDir state never leaks across cases.
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
setMatchMedia(false);
|
||||
});
|
||||
|
||||
function machineFixture(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["files", "monitoring"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
notes: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function listingFixture(
|
||||
entries: {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}[],
|
||||
): DirectoryListing {
|
||||
return { path: "/", entries, count: entries.length };
|
||||
}
|
||||
|
||||
let listing: DirectoryListing;
|
||||
let machines: MonitoringMachine[];
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useFiles", () => ({
|
||||
useDirectoryListing: () => ({
|
||||
data: listing,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
|
||||
useJobTemplates: () => ({ data: [] }),
|
||||
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: machines }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
machines = [machineFixture()];
|
||||
listing = listingFixture([
|
||||
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
|
||||
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
|
||||
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
|
||||
function setMatchMedia(matches: boolean) {
|
||||
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => listeners.push(listener),
|
||||
removeEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
||||
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => h.textContent);
|
||||
// The leading selection column header is empty (checkbox); the 5 data
|
||||
// columns are Type, Name, Ext, Size, Modified in that order.
|
||||
expect(headers).toEqual(
|
||||
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
|
||||
);
|
||||
expect(headers.filter((h) => h === "Type").length).toBe(1);
|
||||
expect(headers.filter((h) => h === "Modified").length).toBe(1);
|
||||
});
|
||||
|
||||
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
// The selected-file path surfaces in the Browser status caption once chosen.
|
||||
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByText("video.mkv"));
|
||||
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
|
||||
|
||||
// A recognized video file enters the ffprobe branch; with empty ffprobe
|
||||
// data it shows the "No ffprobe data available." status (proving the
|
||||
// selected file routed into the Media info preview flow).
|
||||
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
await userEvent.click(screen.getByText("movies"));
|
||||
// After navigating into /movies, the status caption shows the new cwd and
|
||||
// NO "Selected:" segment (directories are opened, not selected for preview).
|
||||
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileBrowser (mobile card layout — slice 4)", () => {
|
||||
it("renders cards with file/dir name as primary below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// Card titles (the 'name' field rendered as primary).
|
||||
expect(screen.getByText("movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("video.mkv")).toBeInTheDocument();
|
||||
expect(screen.getByText("notes.txt")).toBeInTheDocument();
|
||||
|
||||
// Desktop table column headers must NOT render.
|
||||
const headers = screen.queryAllByRole("columnheader");
|
||||
expect(headers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tapping a directory card navigates into it", async () => {
|
||||
setMatchMedia(true);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// Directory card is a button wrapping the 'movies' text.
|
||||
await userEvent.click(screen.getByText("movies"));
|
||||
|
||||
// After navigating into /movies, the status caption shows the new cwd
|
||||
// and NO 'Selected:' segment (directories are opened, not selected).
|
||||
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the path/breadcrumb controls on mobile", () => {
|
||||
setMatchMedia(true);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// The 'Remote path' label and its input are part of the Browser section
|
||||
// card (outside the table), so they render on both breakpoints.
|
||||
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the DataTable at desktop width (1280px)", () => {
|
||||
setMatchMedia(false);
|
||||
render(<FileBrowser />);
|
||||
|
||||
// Desktop path: table column headers are present.
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => h.textContent);
|
||||
expect(headers).toEqual(
|
||||
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,356 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Media } from "../Media";
|
||||
import type {
|
||||
MediaIndexStatus,
|
||||
MediaItem,
|
||||
MediaQueryResponse,
|
||||
MonitoringMachine,
|
||||
} from "../../types";
|
||||
|
||||
// Shared navigate mock so the row-click test can assert the call. The vi.mock
|
||||
// factory is hoisted above this const, but it only closes over `navigate`
|
||||
// lazily (the arrow runs at render time, well after init) — no TDZ access.
|
||||
const navigate = vi.fn();
|
||||
|
||||
function machineFixture(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["jellyfin", "monitoring"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
notes: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statusFixture(
|
||||
overrides: Partial<MediaIndexStatus> = {},
|
||||
): MediaIndexStatus {
|
||||
return {
|
||||
exists: true,
|
||||
item_count: 2,
|
||||
updated_at: 1,
|
||||
updated_at_label: "now",
|
||||
build_duration_seconds: null,
|
||||
build_running: false,
|
||||
build_stage: "",
|
||||
build_message: "",
|
||||
build_progress: null,
|
||||
build_items_processed: 0,
|
||||
build_items_total: 0,
|
||||
build_current_library: "",
|
||||
build_library_index: 0,
|
||||
build_libraries_total: 0,
|
||||
build_library_progress: null,
|
||||
build_library_items_processed: 0,
|
||||
build_library_items_total: 0,
|
||||
build_elapsed_seconds: null,
|
||||
build_eta_seconds: null,
|
||||
build_library_elapsed_seconds: null,
|
||||
build_library_eta_seconds: null,
|
||||
build_cancel_requested: false,
|
||||
build_pid: null,
|
||||
build_error: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
series: "",
|
||||
season: "",
|
||||
episode: null,
|
||||
type: "Movie",
|
||||
year: 2010,
|
||||
runtime_min: 148,
|
||||
size: "12.4 GB",
|
||||
bitrate: "35.0 Mbps",
|
||||
hdr: "HDR10",
|
||||
video: "HEVC",
|
||||
resolution: "4K",
|
||||
date_added: "2024-01-01",
|
||||
library: "Movies",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let status: MediaIndexStatus;
|
||||
let queryResult: MediaQueryResponse;
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigate,
|
||||
useSearchParams: () => [
|
||||
new URLSearchParams("jellyfin_service_id=jfs1"),
|
||||
vi.fn(),
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMedia", () => ({
|
||||
useMediaStatus: () => ({ data: status }),
|
||||
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
|
||||
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: [
|
||||
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({ data: undefined }),
|
||||
useLibraries: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
|
||||
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
|
||||
// so the DataTable path renders by default; mobile tests override.
|
||||
beforeEach(() => {
|
||||
setMatchMedia(false);
|
||||
window.localStorage.clear();
|
||||
navigate.mockClear();
|
||||
status = statusFixture();
|
||||
queryResult = {
|
||||
items: [
|
||||
mediaItem({
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
}),
|
||||
mediaItem({
|
||||
id: "2",
|
||||
title: "Matrix",
|
||||
path: "/media/movies/Matrix.mkv",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
};
|
||||
});
|
||||
|
||||
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
|
||||
function setMatchMedia(matches: boolean) {
|
||||
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => listeners.push(listener),
|
||||
removeEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
|
||||
|
||||
const toggleable = screen
|
||||
.getAllByRole("menuitemcheckbox")
|
||||
.map((item) => (item.textContent ?? "").trim());
|
||||
expect([...toggleable].sort()).toEqual(
|
||||
[
|
||||
"title",
|
||||
"series",
|
||||
"season",
|
||||
"episode",
|
||||
"type",
|
||||
"year",
|
||||
"runtime_min",
|
||||
"size",
|
||||
"bitrate",
|
||||
"hdr",
|
||||
"video",
|
||||
"resolution",
|
||||
"date_added",
|
||||
"library",
|
||||
"path",
|
||||
].sort(),
|
||||
);
|
||||
// The leading selection column is never toggleable (enableHiding=false).
|
||||
expect(toggleable).toHaveLength(15);
|
||||
expect(toggleable).not.toContain("__select__");
|
||||
});
|
||||
|
||||
it("renders the 15 data column headers", () => {
|
||||
render(<Media />);
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => (h.textContent ?? "").trim());
|
||||
for (const expected of [
|
||||
"Title",
|
||||
"Series",
|
||||
"Season",
|
||||
"Episode",
|
||||
"Type",
|
||||
"Year",
|
||||
"Runtime",
|
||||
"Size",
|
||||
"Bitrate",
|
||||
"HDR",
|
||||
"Video codec",
|
||||
"Resolution",
|
||||
"Date added",
|
||||
"Library",
|
||||
"Path",
|
||||
]) {
|
||||
expect(headers).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("navigates to the file browser at the item path on row click", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByText("Inception"));
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT navigate when toggling a row selection checkbox", async () => {
|
||||
render(<Media />);
|
||||
|
||||
const firstCheckbox = screen.getAllByRole("checkbox", {
|
||||
name: "Select row",
|
||||
})[0];
|
||||
await userEvent.click(firstCheckbox);
|
||||
expect(firstCheckbox).toBeChecked();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the server-driven pagination total + page controls", () => {
|
||||
render(<Media />);
|
||||
|
||||
// DataTable manual-pagination footer surfaces the server total + pager.
|
||||
// ("Page 1 of 1" also appears in the page caption, so match all and assert
|
||||
// the pager footer text is present alongside the unique total.)
|
||||
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables Build index while a build is running", () => {
|
||||
status = statusFixture({ build_running: true });
|
||||
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
|
||||
// Stop + Force stop surface only while running.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Stop build" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Force stop" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Media (mobile card layout — slice 3)", () => {
|
||||
it("renders cards with the title as primary below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
// Card titles render (primary field).
|
||||
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||
expect(screen.getByText("Matrix")).toBeInTheDocument();
|
||||
|
||||
// Card field labels render (at least once per row).
|
||||
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Desktop table headers do NOT render on mobile.
|
||||
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
|
||||
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the column-visibility toggle below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders pagination controls below the cards on mobile", () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next page" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to the file browser when a card is tapped on mobile", async () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByText("Inception"));
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the DataTable (not cards) at desktop width", () => {
|
||||
render(<Media />);
|
||||
|
||||
// Desktop column headers render.
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Title" }),
|
||||
).toBeInTheDocument();
|
||||
// Column-visibility toggle is present.
|
||||
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { NamedDashboardPage } from "../NamedDashboardPage";
|
||||
|
||||
vi.mock("../../hooks/useDashboards", () => ({
|
||||
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
|
||||
}));
|
||||
|
||||
import { useDashboardBySlug } from "../../hooks/useDashboards";
|
||||
|
||||
function renderPage(slug: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/d/${slug}`]}>
|
||||
<Routes>
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NamedDashboardPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders loading state", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
} as never);
|
||||
renderPage("storage");
|
||||
// Skeleton renders during load.
|
||||
expect(document.querySelector(".h-32")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 404 when dashboard not found", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
} as never);
|
||||
renderPage("nonexistent");
|
||||
expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pinned links for a known dashboard", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: {
|
||||
id: "d1",
|
||||
label: "Storage",
|
||||
slug: "storage",
|
||||
sort_order: 0,
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
type: "link",
|
||||
label: "My Jellyfin",
|
||||
target: "/services/jellyfin/svc-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as never);
|
||||
renderPage("storage");
|
||||
expect(screen.getByText("Storage")).toBeInTheDocument();
|
||||
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when dashboard has no items", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: {
|
||||
id: "d2",
|
||||
label: "Empty",
|
||||
slug: "empty",
|
||||
sort_order: 0,
|
||||
payload: {},
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as never);
|
||||
renderPage("empty");
|
||||
expect(screen.getByText("Empty")).toBeInTheDocument();
|
||||
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,149 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServicePage } from "../ServicePage";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../../types";
|
||||
|
||||
// --- fixtures ---
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "svc-1",
|
||||
service_type: "grafana",
|
||||
name: "Production Grafana",
|
||||
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config: { base_url: "https://jf.example.com", user_id: "u1" },
|
||||
secrets_set: { api_key: true },
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
const typeInfo: ServiceTypeInfo = {
|
||||
service_type: "grafana",
|
||||
name: "Grafana",
|
||||
description: "Dashboards, metrics, and logs.",
|
||||
config_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
base_url: { type: "string", description: "Absolute URL." },
|
||||
timeout_seconds: { type: "integer" },
|
||||
},
|
||||
},
|
||||
secret_fields: [{ key: "api_key", label: "API key", required: false }],
|
||||
widget_kinds: [],
|
||||
// typeInfo no longer needed on ServicePage (config moved to Settings).
|
||||
|
||||
const secondInstance: ServiceInstance = {
|
||||
...instance,
|
||||
id: "svc-2",
|
||||
name: "Backup Jellyfin",
|
||||
};
|
||||
|
||||
// --- mocks ---
|
||||
|
||||
const mutateAsync = vi.fn();
|
||||
const mutate = vi.fn();
|
||||
const deleteMutate = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [instance] }),
|
||||
useServiceTypes: () => ({ data: [typeInfo] }),
|
||||
useSaveServiceInstance: () => ({
|
||||
mutateAsync,
|
||||
mutate,
|
||||
isPending: false,
|
||||
useServiceInstances: () => ({
|
||||
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
|
||||
?.__svcInstances ?? [instance],
|
||||
}),
|
||||
useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useParams: () => ({
|
||||
serviceType: "grafana",
|
||||
serviceId: "svc-1",
|
||||
}),
|
||||
useNavigate: () => vi.fn(),
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
|
||||
function setMatchMedia(matches: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||
WidgetConfigDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/WidgetInstance", () => ({
|
||||
WidgetInstanceCard: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../integrations/registry", () => ({
|
||||
getServiceBinding: () => ({
|
||||
name: "Jellyfin",
|
||||
description: "Media server",
|
||||
widgets: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderServicePage(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setMatchMedia(false);
|
||||
mutateAsync.mockReset();
|
||||
mutate.mockReset();
|
||||
deleteMutate.mockReset();
|
||||
});
|
||||
|
||||
describe("ServicePage (desktop)", () => {
|
||||
it("renders the full-page layout with the service name and connection card", () => {
|
||||
render(<ServicePage />);
|
||||
// Page heading (desktop only — mobile uses SheetForm title)
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Production Grafana" }),
|
||||
).toBeInTheDocument();
|
||||
// Connection section card title
|
||||
expect(screen.getByText("Connection")).toBeInTheDocument();
|
||||
// General Save button
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
||||
describe("ServicePage tab skeleton", () => {
|
||||
it("renders Overview + Media + Requests + Widgets for jellyfin", () => {
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the SheetForm at desktop width", () => {
|
||||
render(<ServicePage />);
|
||||
// SheetForm renders a dialog with role="dialog" only when open; on
|
||||
// desktop the page layout is used, so no dialog should be present.
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServicePage (mobile SheetForm — slice 6)", () => {
|
||||
beforeEach(() => setMatchMedia(true));
|
||||
|
||||
it("renders the SheetForm with the service name as title below md", () => {
|
||||
render(<ServicePage />);
|
||||
// SheetForm title is rendered inside a SheetTitle (role="heading").
|
||||
it("does NOT render Config tab (moved to Settings)", () => {
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Production Grafana" }),
|
||||
).toBeInTheDocument();
|
||||
// The dialog (Sheet content) should be present on mobile.
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
// Desktop page header description is NOT rendered inside the SheetForm.
|
||||
expect(
|
||||
screen.queryByText("Dashboards, metrics, and logs."),
|
||||
screen.queryByRole("tab", { name: "Config" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits the name field and Save calls the save mutation", async () => {
|
||||
render(<ServicePage />);
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
expect(nameInput).toHaveValue("Production Grafana");
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "Renamed Grafana");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
|
||||
expect(input.name).toBe("Renamed Grafana");
|
||||
expect(input.id).toBe("svc-1");
|
||||
// Lock the full save payload (config draft, enabled, secrets sentinel).
|
||||
expect(input.enabled).toBe(true);
|
||||
expect(input.secrets).toEqual({});
|
||||
expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
|
||||
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [sshInstance];
|
||||
renderServicePage("/services/ssh_tasks/ssh-1");
|
||||
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Media" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the connection config fields as editable inside the SheetForm", () => {
|
||||
render(<ServicePage />);
|
||||
const urlInput = screen.getByLabelText("base_url");
|
||||
expect(urlInput).toHaveValue("https://grafana.example.com");
|
||||
it("shows instance tabs when >1 enabled sibling of same type", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance, secondInstance];
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Main Jellyfin" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Backup Jellyfin" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides instance tabs when only one instance", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance];
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Main Jellyfin" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking an instance tab navigates to that instance", async () => {
|
||||
const user = userEvent.setup();
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance, secondInstance];
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
await user.click(screen.getByRole("tab", { name: "Backup Jellyfin" }));
|
||||
// The test router would navigate; we can't assert URL directly without
|
||||
// a useNavigate mock, but the click should not throw.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,12 @@ import type { MonitoringMachine } from "../../types";
|
||||
|
||||
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||
const deleteMachineMutate = vi.fn();
|
||||
const testSSHMutate = vi.fn().mockResolvedValue({
|
||||
message: "SSH auth succeeded",
|
||||
known_hosts_updated: true,
|
||||
});
|
||||
const testSSHMutate = vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
message: "SSH auth succeeded",
|
||||
known_hosts_updated: true,
|
||||
});
|
||||
|
||||
let machines: MonitoringMachine[] = [];
|
||||
|
||||
@@ -111,103 +113,3 @@ describe("Settings", () => {
|
||||
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
||||
});
|
||||
});
|
||||
|
||||
// jsdom has no window.matchMedia; default to desktop so existing tests are
|
||||
// unaffected.
|
||||
function setMatchMedia(matches: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
describe("Settings (mobile SheetForm — slice 7)", () => {
|
||||
beforeEach(() => setMatchMedia(true));
|
||||
|
||||
it("opens the machine editor in a SheetForm below md", async () => {
|
||||
machines = [localMachine()];
|
||||
render(<Settings />);
|
||||
|
||||
// Open the editor via the detail-pane Edit button (visible text).
|
||||
const detailEdit = screen
|
||||
.getAllByRole("button", { name: "Edit" })
|
||||
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||
await userEvent.click(detailEdit);
|
||||
|
||||
// SheetForm renders a dialog; the DialogTitle shows the editor title.
|
||||
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
// Desktop DialogDescription text is not rendered as a dialog description
|
||||
// on mobile (the MachineEditor has its own hint labels, which is fine).
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Create machine" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a machine via the SheetForm on mobile", async () => {
|
||||
machines = [localMachine()];
|
||||
render(<Settings />);
|
||||
|
||||
const detailEdit = screen
|
||||
.getAllByRole("button", { name: "Edit" })
|
||||
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||
await userEvent.click(detailEdit);
|
||||
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "Renamed node");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
|
||||
|
||||
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
|
||||
const saved = saveMachineMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Renamed node");
|
||||
expect(saved.mode).toBe("local");
|
||||
});
|
||||
|
||||
it("cancel closes the SheetForm on mobile", async () => {
|
||||
machines = [localMachine()];
|
||||
render(<Settings />);
|
||||
|
||||
const detailEdit = screen
|
||||
.getAllByRole("button", { name: "Edit" })
|
||||
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||
await userEvent.click(detailEdit);
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
// The sheet is now closed — the dialog role should no longer be present.
|
||||
// (The page content itself is still rendered; only the sheet unmounts.)
|
||||
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prompts before discarding unsaved machine edits (R4.5)", async () => {
|
||||
machines = [localMachine()];
|
||||
render(<Settings />);
|
||||
|
||||
const detailEdit = screen
|
||||
.getAllByRole("button", { name: "Edit" })
|
||||
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||
await userEvent.click(detailEdit);
|
||||
|
||||
// Edit the name to make the form dirty.
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "Dirty name");
|
||||
|
||||
// Cancel should NOT immediately close — the discard confirm appears.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||
).toBeInTheDocument();
|
||||
// The editor is still open.
|
||||
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UsersPage } from "../UsersPage.impl";
|
||||
import { TooltipProvider } from "../../components/ui/tooltip";
|
||||
import type {
|
||||
NowPlayingSession,
|
||||
UserDirectoryItem,
|
||||
UserDirectoryResponse,
|
||||
} from "../../types";
|
||||
|
||||
// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the
|
||||
// compose dialog viewport hook must not blow up during render. Stub to
|
||||
// "desktop" (matches: false) by default; the slice-5 describe block flips it
|
||||
// to mobile for card-layout assertions.
|
||||
beforeEach(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
// The compose formatting actions defer a focus/selection restore via
|
||||
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
|
||||
// synchronously, so make it synchronous so the slice-6b compose test can
|
||||
// observe the html-body value update.
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
});
|
||||
|
||||
// Keep the drawer's nested session panel out of the DOM under test.
|
||||
vi.mock("../../components/SessionActivityPanel", () => ({
|
||||
SessionActivityPanel: ({
|
||||
selectedUserLabel,
|
||||
}: {
|
||||
selectedUserLabel: string;
|
||||
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
|
||||
}));
|
||||
|
||||
let users: UserDirectoryItem[] = [];
|
||||
let activity: NowPlayingSession[] = [];
|
||||
|
||||
function directoryResponse(): UserDirectoryResponse {
|
||||
return {
|
||||
items: users,
|
||||
total: users.length,
|
||||
jellyseerr_configured: true,
|
||||
jellyseerr_available: true,
|
||||
jellyseerr_error: "",
|
||||
jellyseerr_jellyfin_user_count: 0,
|
||||
jellyseerr_user_count: 0,
|
||||
enriched_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../hooks/useUsers", () => ({
|
||||
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
|
||||
}));
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useActivity: () => ({ data: activity }),
|
||||
}));
|
||||
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
|
||||
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
|
||||
}));
|
||||
vi.mock("../../hooks/useSendUserMessage", () => ({
|
||||
useSendUserMessage: () => ({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
reset: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
|
||||
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
|
||||
let currentParams: Record<string, string> = {};
|
||||
const setSearchParams = vi.fn((next: Record<string, string>) => {
|
||||
currentParams = { ...next };
|
||||
});
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
|
||||
}));
|
||||
|
||||
function userFixture(
|
||||
overrides: Partial<UserDirectoryItem> = {},
|
||||
): UserDirectoryItem {
|
||||
return {
|
||||
jellyfin_id: "u1",
|
||||
username: "alice",
|
||||
display_name: "Alice",
|
||||
email: "alice@example.com",
|
||||
email_source: "jellyfin",
|
||||
avatar: "",
|
||||
avatar_source: "",
|
||||
contactable: true,
|
||||
source: "jellyfin",
|
||||
source_summary: "",
|
||||
name_source: "jellyfin",
|
||||
access_source: "jellyfin",
|
||||
jellyseerr_user_id: null,
|
||||
jellyseerr_username: "",
|
||||
user_type: 1,
|
||||
user_type_label: "User",
|
||||
role: "admin",
|
||||
permissions: 1,
|
||||
permissions_label: "Administrator",
|
||||
request_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
users = [];
|
||||
activity = [];
|
||||
currentParams = {};
|
||||
setSearchParams.mockClear();
|
||||
});
|
||||
|
||||
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
|
||||
it("renders the directory table and metric counts", () => {
|
||||
users = [userFixture()];
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText("Total users")).toBeInTheDocument();
|
||||
expect(screen.getByText("User list")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles row selection and reflects the selected-count badge", async () => {
|
||||
users = [
|
||||
userFixture({ jellyfin_id: "u1" }),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
|
||||
// Selection-across-pagination: toggling a row updates the selected-id set.
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
|
||||
// Toggling again removes it (the set survives, membership flips).
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects all visible rows via the header select-all checkbox", async () => {
|
||||
users = [
|
||||
userFixture({ jellyfin_id: "u1" }),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select all visible users" }),
|
||||
);
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
|
||||
users = [userFixture({ jellyfin_id: "u1" })];
|
||||
render(<UsersPage />);
|
||||
|
||||
// Clicking the row body (not the checkbox) opens the detail drawer.
|
||||
await userEvent.click(screen.getByText("Alice"));
|
||||
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
|
||||
});
|
||||
|
||||
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
|
||||
users = [
|
||||
userFixture({
|
||||
jellyfin_id: "u1",
|
||||
username: "alice",
|
||||
display_name: "Alice",
|
||||
}),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
activity = [
|
||||
{
|
||||
user: "alice",
|
||||
title: "Movie",
|
||||
type: "Movie",
|
||||
state: "playing",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "Web",
|
||||
session_id: "s1",
|
||||
},
|
||||
{
|
||||
user: "bob",
|
||||
title: "Show",
|
||||
type: "Episode",
|
||||
state: "paused",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "TV",
|
||||
session_id: "s2",
|
||||
},
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
|
||||
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
|
||||
"success",
|
||||
);
|
||||
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the user detail drawer (Sheet) when a user is selected", () => {
|
||||
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||
currentParams = { user: "u1" };
|
||||
render(<UsersPage />);
|
||||
|
||||
// buildUserDrawerModel title = display name; rendered as the drawer heading.
|
||||
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
|
||||
// Drawer sections (identity / contact actions) + the activity panel render.
|
||||
expect(screen.getByText("Identity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Contact actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
|
||||
it("opens compose and inserts bold markup into the html body", async () => {
|
||||
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
|
||||
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsersPage />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Select a deliverable user so the "Message selected" button enables.
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Message selected" }),
|
||||
);
|
||||
|
||||
// Compose dialog opens (shadcn Dialog family).
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Message selected users" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Bold action wraps the cursor selection in <strong></strong> via the
|
||||
// preserved insertMarkup helper (markup insertion actions parity).
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
|
||||
|
||||
const body = screen.getByRole("textbox", {
|
||||
name: "HTML message body",
|
||||
}) as HTMLTextAreaElement;
|
||||
expect(body.value).toContain("<strong>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsersPage (mobile card layout — slice 5)", () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("768"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
});
|
||||
|
||||
it("renders user cards with display name as primary below md", () => {
|
||||
users = [
|
||||
userFixture({ jellyfin_id: "u1", display_name: "Alice" }),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
}),
|
||||
];
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsersPage />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
// Activity field label should appear per card.
|
||||
expect(screen.getAllByText("Activity")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("toggles selection from the card checkbox without opening the drawer", async () => {
|
||||
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsersPage />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", {
|
||||
name: /Select Alice/i,
|
||||
});
|
||||
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||
|
||||
await userEvent.click(checkbox);
|
||||
expect(checkbox).toHaveAttribute("data-state", "checked");
|
||||
|
||||
// Drawer stays closed: the session-panel stub only renders when the
|
||||
// drawer opens via a card-body tap, not via the checkbox.
|
||||
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compose in a SheetForm below md with send button", async () => {
|
||||
users = [
|
||||
userFixture({
|
||||
jellyfin_id: "u1",
|
||||
display_name: "Alice",
|
||||
email: "alice@example.com",
|
||||
}),
|
||||
];
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsersPage />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Select the deliverable user via the mobile card checkbox.
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: /Select Alice/i }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Message selected" }),
|
||||
);
|
||||
|
||||
// On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
|
||||
// header carries the title and the footer carries the Send button.
|
||||
expect(screen.getByText("Message selected users")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Send message" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prompts before discarding unsaved compose edits (R4.5)", async () => {
|
||||
users = [
|
||||
userFixture({
|
||||
jellyfin_id: "u1",
|
||||
display_name: "Alice",
|
||||
email: "alice@example.com",
|
||||
}),
|
||||
];
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsersPage />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: /Select Alice/i }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Message selected" }),
|
||||
);
|
||||
|
||||
// Type a subject to make the compose form dirty.
|
||||
await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
|
||||
|
||||
// Cancel should NOT immediately close — the discard confirm appears.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,25 @@
|
||||
/**
|
||||
* ActionsTab — operational content for the ssh_tasks service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
|
||||
* provides the active ssh_tasks service id, which is used as the default run
|
||||
* service. The page-level header is removed (the service page provides it).
|
||||
* The task editor dialog, saved-task rail, and run history are preserved.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../hooks/useSettings";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { SelectionRailCard } from "../components/SelectionRailCard";
|
||||
} from "../../hooks/useSettings";
|
||||
import { DialogFooter } from "../../components/DialogFooter";
|
||||
import { HoverEditButton } from "../../components/HoverEditButton";
|
||||
import { SectionCard } from "../../components/SectionCard";
|
||||
import { SelectionRailCard } from "../../components/SelectionRailCard";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,13 +44,8 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
// Radix Select disallows empty-string item values; the "None" option maps to
|
||||
// this sentinel and converts back to "" at the draft boundary.
|
||||
const NONE = "__none__";
|
||||
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||
function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
@@ -106,16 +108,11 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
services,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
services: ServiceInstance[];
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
const selectedService = services.find(
|
||||
(service) => service.id === task.default_service_id,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
@@ -124,11 +121,7 @@ function TaskEditor({
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
{selectedService && (
|
||||
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
@@ -159,31 +152,6 @@ function TaskEditor({
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<FormField label="Default SSH task service">
|
||||
<Select
|
||||
value={task.default_service_id || NONE}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
default_service_id: value === NONE ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
@@ -216,7 +184,6 @@ function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
services,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
@@ -225,7 +192,6 @@ function TaskDialog({
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
services: ServiceInstance[];
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
@@ -235,12 +201,10 @@ function TaskDialog({
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
) {
|
||||
)
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -254,10 +218,10 @@ function TaskDialog({
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on the selected SSH task service instance.
|
||||
Runs execute on this SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} services={services} onChange={onChange} />
|
||||
<TaskEditor task={task} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
@@ -266,7 +230,7 @@ function TaskDialog({
|
||||
confirmBusyLabel="Save action"
|
||||
secondaryAction={
|
||||
onDelete ? (
|
||||
<Button variant="destructive" onClick={onDelete} className="mobile-touch-target">
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
@@ -277,8 +241,7 @@ function TaskDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
@@ -288,9 +251,11 @@ export function Actions() {
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [runServiceId, setRunServiceId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
// Default to this instance's service id for task runs.
|
||||
const runServiceId = instance.id;
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
@@ -303,14 +268,6 @@ export function Actions() {
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const createNew = () => {
|
||||
const initial = emptyTask();
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setRunServiceId(sshServices[0]?.id || "");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync(draft);
|
||||
setTab(saved.id);
|
||||
@@ -328,20 +285,8 @@ export function Actions() {
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
const editingTask = selectedTask;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Actions</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Save reusable server tasks and switch between them with tabs.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
@@ -367,8 +312,8 @@ export function Actions() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mobile-touch-target w-full"
|
||||
onClick={createNew}
|
||||
className="w-full"
|
||||
onClick={() => openEdit(emptyTask())}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
@@ -406,23 +351,23 @@ export function Actions() {
|
||||
</SelectionRailCard>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{editingTask ? (
|
||||
{selectedTask ? (
|
||||
<SectionCard
|
||||
title={editingTask.name}
|
||||
title={selectedTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button className="mobile-touch-target"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(editingTask))}
|
||||
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button className="mobile-touch-target"
|
||||
disabled={runTask.isPending || !runServiceId}
|
||||
<Button
|
||||
disabled={runTask.isPending}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: editingTask.id,
|
||||
taskId: selectedTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
@@ -432,35 +377,7 @@ export function Actions() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormField
|
||||
label="Run on SSH task service"
|
||||
htmlFor="run-service-id"
|
||||
>
|
||||
<Select
|
||||
value={runServiceId}
|
||||
onValueChange={(value) => setRunServiceId(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="run-service-id"
|
||||
className="min-w-[240px]"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue placeholder="Select service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -509,25 +426,16 @@ export function Actions() {
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)} className="mobile-touch-target">
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="What this panel shows">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved actions stay on the left rail, while details, run
|
||||
controls, and recent history appear here.
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -536,7 +444,6 @@ export function Actions() {
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
services={sshServices}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Alertmanager Alerts tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Lifts the Alertmanager alerts content from the old cross-service
|
||||
* ObservabilityPage into an instance-scoped tab. Renders the active-alert
|
||||
* summary (total + by severity) and the expandable alert list.
|
||||
*
|
||||
* The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global /
|
||||
* first-configured for now — they don't accept a service_id yet. Wiring
|
||||
* `instance.id` into them is a documented follow-up once the hooks gain the
|
||||
* parameter. The `instance` prop is accepted for future scoping.
|
||||
*/
|
||||
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
|
||||
import {
|
||||
useAlertmanagerAlerts,
|
||||
useAlertmanagerStatus,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { AlertmanagerAlert, ServiceInstance } from "../../types";
|
||||
|
||||
function severityVariant(
|
||||
severity: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "info":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-sm">{alert.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{alert.summary || alert.description}
|
||||
</div>
|
||||
{alert.active_since && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||
Since {new Date(alert.active_since).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
|
||||
{alert.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description:</span>{" "}
|
||||
{alert.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{alert.job_name && (
|
||||
<div>
|
||||
<span className="font-medium">Job:</span> {alert.job_name}
|
||||
</div>
|
||||
)}
|
||||
{alert.category && (
|
||||
<div>
|
||||
<span className="font-medium">Category:</span> {alert.category}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">State:</span> {alert.state}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Since:</span>{" "}
|
||||
{alert.active_since
|
||||
? new Date(alert.active_since).toLocaleString()
|
||||
: "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{Object.entries(alert.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="secondary" className="text-[10px]">
|
||||
{key}={value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
|
||||
// Global / first-configured hooks for now; instance.id scoping is a
|
||||
// follow-up (see file docstring).
|
||||
void instance;
|
||||
|
||||
const {
|
||||
data: alertsSummary,
|
||||
isLoading: alertsLoading,
|
||||
error: alertsError,
|
||||
} = useAlertmanagerAlerts();
|
||||
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: statusLoading
|
||||
? "checking…"
|
||||
: "unreachable";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Bell className="h-4 w-4" />
|
||||
Alertmanager {statusDetail}
|
||||
</div>
|
||||
|
||||
{alertsError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load alerts</AlertTitle>
|
||||
<AlertDescription>{alertsError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Active Alerts ({alertsSummary?.total ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alertsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !alertsSummary || alertsSummary.total === 0 ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Inbox className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No active alerts</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Everything looks quiet. Firing alerts will appear here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{alertsSummary.alerts.map((alert, idx) => (
|
||||
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
|
||||
))}
|
||||
{alertsSummary.total > alertsSummary.alerts.length && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
{alertsSummary.total - alertsSummary.alerts.length} more alert
|
||||
{alertsSummary.total - alertsSummary.alerts.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
in Alertmanager
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+227
-304
@@ -1,5 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
/**
|
||||
* FilesTab — operational content for the ssh_tasks service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
|
||||
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
|
||||
* instance id (from the `instance` prop) replaces the machine_id. The initial
|
||||
* path is read from `?path=` search param for deep-link support (resolves the
|
||||
* MediaTab row-click navigation from slice 5). Everything else — directory
|
||||
* listing, path bar, ffprobe preview, job execution — is preserved.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
@@ -7,7 +17,7 @@ import {
|
||||
MobileCardRow,
|
||||
type MobileCardField,
|
||||
} from "@/components/ui/mobile-card";
|
||||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -20,18 +30,27 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import {
|
||||
useDirectoryListing,
|
||||
useFfprobe,
|
||||
useJobTemplates,
|
||||
useRunJob,
|
||||
} from "../hooks/useFiles";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
} from "../../hooks/useFiles";
|
||||
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||
import { SectionCard } from "../../components/SectionCard";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { useIsMobile } from "../../hooks/useIsMobile";
|
||||
|
||||
// Mobile card fields (mobile-parity pattern).
|
||||
const fileCardFields: MobileCardField<DisplayRow>[] = [
|
||||
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
|
||||
{ key: "type", label: "Type", render: (r) => r.type },
|
||||
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
|
||||
];
|
||||
|
||||
// --- Types (lifted verbatim) ---
|
||||
|
||||
interface DisplayRow {
|
||||
id: string;
|
||||
@@ -83,6 +102,8 @@ interface FfprobeData {
|
||||
streams?: FfprobeStream[];
|
||||
}
|
||||
|
||||
// --- Helpers (lifted verbatim) ---
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "-";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -154,9 +175,8 @@ function isVideoFile(name: string): boolean {
|
||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
// Design §3.2: referentially-stable column defs (a new array each render would
|
||||
// destabilize the TanStack table instance and drop controlled selection).
|
||||
// Visibility-only: no sorting, no sizing/resizing (design §3.3).
|
||||
// --- Column defs (lifted verbatim) ---
|
||||
|
||||
const fileColumns: ColumnDef<DisplayRow>[] = [
|
||||
{
|
||||
accessorKey: "type",
|
||||
@@ -187,19 +207,9 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||
// Name is the primary identifier; type distinguishes dir/file/up at a glance;
|
||||
// size and modified give the at-a-glance info a user browsing files on a phone
|
||||
// needs. Ext is redundant with the name on mobile (the extension is visible in
|
||||
// the filename itself). See OpenSpec change `mobile-responsive-parity`.
|
||||
const fileCardFields: MobileCardField<DisplayRow>[] = [
|
||||
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
|
||||
{ key: "type", label: "Type", render: (r) => r.type },
|
||||
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
|
||||
];
|
||||
// --- State + helpers (lifted) ---
|
||||
|
||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||||
const FILE_TAB_STATE_KEY = "manage.files.tabState";
|
||||
|
||||
type FileBrowserState = {
|
||||
currentDir: string;
|
||||
@@ -217,6 +227,8 @@ function defaultFileBrowserState(): FileBrowserState {
|
||||
};
|
||||
}
|
||||
|
||||
// --- Ffprobe rendering (lifted verbatim) ---
|
||||
|
||||
function FfprobeChip({
|
||||
children,
|
||||
variant = "outline",
|
||||
@@ -234,15 +246,9 @@ function StreamBlock({ children }: { children: React.ReactNode }) {
|
||||
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
const format = data.format ?? {};
|
||||
const streams = data.streams ?? [];
|
||||
const videoStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "video",
|
||||
);
|
||||
const audioStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "audio",
|
||||
);
|
||||
const subtitleStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "subtitle",
|
||||
);
|
||||
const videoStreams = streams.filter((s) => s.codec_type === "video");
|
||||
const audioStreams = streams.filter((s) => s.codec_type === "audio");
|
||||
const subtitleStreams = streams.filter((s) => s.codec_type === "subtitle");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -250,7 +256,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
<div className="text-base font-semibold">ffprobe details</div>
|
||||
<div className="text-xs text-muted-foreground">{path}</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="text-sm font-semibold">Container / format</div>
|
||||
@@ -286,11 +291,9 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="text-sm font-semibold">Streams</div>
|
||||
|
||||
{videoStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Video streams</div>
|
||||
@@ -340,9 +343,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.width && stream.height && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`${stream.width}×${stream.height}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">{`${stream.width}×${stream.height}`}</FfprobeChip>
|
||||
)}
|
||||
{stream.pix_fmt && (
|
||||
<FfprobeChip variant="outline">
|
||||
@@ -350,14 +351,10 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.display_aspect_ratio && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`DAR ${stream.display_aspect_ratio}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">{`DAR ${stream.display_aspect_ratio}`}</FfprobeChip>
|
||||
)}
|
||||
{stream.sample_aspect_ratio && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`SAR ${stream.sample_aspect_ratio}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">{`SAR ${stream.sample_aspect_ratio}`}</FfprobeChip>
|
||||
)}
|
||||
{stream.level !== undefined &&
|
||||
stream.level !== null && (
|
||||
@@ -399,7 +396,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Audio streams</div>
|
||||
@@ -448,7 +444,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subtitleStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -481,7 +476,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streams.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No streams found.
|
||||
@@ -489,16 +483,16 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{Object.keys(format.tags ?? {}).length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold">Tags</div>
|
||||
<div className="flex flex-row flex-wrap gap-1.5">
|
||||
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
||||
<FfprobeChip key={key} variant="outline">
|
||||
{`${key}: ${value}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip
|
||||
key={key}
|
||||
variant="outline"
|
||||
>{`${key}: ${value}`}</FfprobeChip>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -508,57 +502,36 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function InfoAlert({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{children}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
// --- Component ---
|
||||
|
||||
export function FileBrowser() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
export function FilesTab({ instance }: { instance: ServiceInstance }) {
|
||||
const isMobile = useIsMobile();
|
||||
const machineId = instance.id;
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedPath = searchParams.get("path");
|
||||
const [columnVisibility, setColumnVisibility] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const { data: machines } = useMonitoringSettings();
|
||||
const fileMachines = useMemo(
|
||||
() =>
|
||||
(machines ?? []).filter(
|
||||
(machine) =>
|
||||
machine.enabled &&
|
||||
(machine.services.includes("files") ||
|
||||
machine.services.includes("monitoring")),
|
||||
),
|
||||
[machines],
|
||||
);
|
||||
const initialRequestedPath = searchParams.get("path");
|
||||
const initialMachineId =
|
||||
searchParams.get("machine_id") || fileMachines[0]?.id || "";
|
||||
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
|
||||
FILE_BROWSER_STATE_KEY,
|
||||
`${FILE_TAB_STATE_KEY}.${instance.id}`,
|
||||
() => {
|
||||
const requestedPath = initialRequestedPath ?? "/";
|
||||
const path = requestedPath ?? "/";
|
||||
const selectedPath =
|
||||
requestedPath !== "/" &&
|
||||
(isVideoFile(requestedPath) || requestedPath.includes("."))
|
||||
? requestedPath.replace(/\/+$/, "")
|
||||
path !== "/" && (isVideoFile(path) || path.includes("."))
|
||||
? path.replace(/\/+$/, "")
|
||||
: null;
|
||||
const currentDir = selectedPath
|
||||
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
||||
: requestedPath.replace(/\/+$/, "") || "/";
|
||||
: path.replace(/\/+$/, "") || "/";
|
||||
return {
|
||||
...defaultFileBrowserState(),
|
||||
currentDir,
|
||||
pathInput: requestedPath || currentDir,
|
||||
pathInput: path || currentDir,
|
||||
selectedPath,
|
||||
};
|
||||
},
|
||||
);
|
||||
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
||||
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
|
||||
const navigateToSettings = useNavigate();
|
||||
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
||||
setBrowserState((current) => ({ ...current, ...patch }));
|
||||
|
||||
@@ -567,7 +540,7 @@ export function FileBrowser() {
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
|
||||
} = useDirectoryListing(currentDir, machineId);
|
||||
const {
|
||||
data: ffprobeData,
|
||||
isLoading: ffprobeLoading,
|
||||
@@ -575,10 +548,10 @@ export function FileBrowser() {
|
||||
} = useFfprobe(
|
||||
selectedPath ?? "",
|
||||
!!selectedPath && isVideoFile(selectedPath),
|
||||
selectedMachineId || undefined,
|
||||
machineId,
|
||||
);
|
||||
const { data: templates } = useJobTemplates();
|
||||
const runJob = useRunJob(selectedMachineId || undefined);
|
||||
const runJob = useRunJob(machineId);
|
||||
|
||||
const navigate = (path: string) => {
|
||||
updateBrowserState({
|
||||
@@ -588,18 +561,6 @@ export function FileBrowser() {
|
||||
});
|
||||
};
|
||||
|
||||
const setMachine = (machineId: string) => {
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
if (machineId) next.set("machine_id", machineId);
|
||||
else next.delete("machine_id");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") navigate(pathInput || "/");
|
||||
};
|
||||
@@ -634,8 +595,6 @@ export function FileBrowser() {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate;
|
||||
// file rows select the file for ffprobe preview (also feeds pathInput).
|
||||
const handleRowClick = (row: DisplayRow) => {
|
||||
if (row.type === "dir" || row.type === "up") {
|
||||
navigate(row.path);
|
||||
@@ -648,8 +607,6 @@ export function FileBrowser() {
|
||||
});
|
||||
};
|
||||
|
||||
// Single-select checkbox behavior (DataTable adds a selection column under
|
||||
// enableRowSelection): mirrors the row-click selection for file rows.
|
||||
const rowSelection: RowSelectionState = selectedPath
|
||||
? { [selectedPath]: true }
|
||||
: {};
|
||||
@@ -677,216 +634,182 @@ export function FileBrowser() {
|
||||
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4.5">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">File Browser</h2>
|
||||
<Badge variant="outline">
|
||||
{fileMachines.length
|
||||
? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
|
||||
: "No file machines"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<TabbedCard
|
||||
value={fileMachines.length > 0 ? selectedMachineId : ""}
|
||||
onChange={setMachine}
|
||||
tabs={fileMachines.map((machine) => (
|
||||
<TabsTrigger key={machine.id} value={machine.id}>
|
||||
{`${machine.name} · ${machine.mode}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="Browser"
|
||||
description="Read-only listing with explicit open/select actions."
|
||||
>
|
||||
{fileMachines.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="Browser"
|
||||
description="Read-only listing with explicit open/select actions."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label htmlFor="remote-path">Remote path</Label>
|
||||
<Input
|
||||
id="remote-path"
|
||||
value={pathInput}
|
||||
onChange={(e) =>
|
||||
updateBrowserState({ pathInput: e.target.value })
|
||||
}
|
||||
onKeyDown={handlePathSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto mobile-touch-target"
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto mobile-touch-target"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Current: ${currentDir} `}
|
||||
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
||||
{listing ? `| Entries: ${listing.count}` : ""}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card">
|
||||
{isMobile ? (
|
||||
<div className="p-4">
|
||||
<MobileCardRow
|
||||
rows={rows}
|
||||
fields={fileCardFields}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={fileColumns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={handleSelectionChange}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading directory..."
|
||||
: "This directory is empty."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label htmlFor="remote-path">Remote path</Label>
|
||||
<Input
|
||||
id="remote-path"
|
||||
value={pathInput}
|
||||
onChange={(e) =>
|
||||
updateBrowserState({ pathInput: e.target.value })
|
||||
}
|
||||
onKeyDown={handlePathSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Current: ${currentDir} `}
|
||||
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
||||
{listing ? `| Entries: ${listing.count}` : ""}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card">
|
||||
{isMobile ? (
|
||||
<div className="p-4">
|
||||
<MobileCardRow
|
||||
rows={rows}
|
||||
fields={fileCardFields}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={fileColumns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={handleSelectionChange}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
emptyMessage={
|
||||
isLoading ? "Loading directory..." : "This directory is empty."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Media info"
|
||||
description="ffprobe metadata for the selected media file."
|
||||
>
|
||||
{selectedPath ? (
|
||||
isVideoFile(selectedPath) ? (
|
||||
ffprobeError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(ffprobeError)}</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeLoading && !ffprobeData ? (
|
||||
<Alert>
|
||||
<AlertDescription>Loading ffprobe data...</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeData ? (
|
||||
<FfprobeDetails
|
||||
path={selectedPath}
|
||||
data={ffprobeData as FfprobeData}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No ffprobe data available.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a video file to view ffprobe details.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a file in Browser to view ffprobe details.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Jobs"
|
||||
description="Run predefined safe jobs against the selected file."
|
||||
>
|
||||
{selectedPath && templates && templates.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="job-template">Job template</Label>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
onValueChange={(value) =>
|
||||
updateBrowserState({ selectedJob: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="job-template" className="w-full">
|
||||
<SelectValue placeholder="Select a job" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((tpl) => (
|
||||
<SelectItem key={tpl.key} value={tpl.key}>
|
||||
{tpl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Media info"
|
||||
description="ffprobe metadata for the selected media file."
|
||||
>
|
||||
{selectedPath ? (
|
||||
isVideoFile(selectedPath) ? (
|
||||
ffprobeError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{String(ffprobeError)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeLoading && !ffprobeData ? (
|
||||
<InfoAlert>Loading ffprobe data...</InfoAlert>
|
||||
) : ffprobeData ? (
|
||||
<FfprobeDetails
|
||||
path={selectedPath}
|
||||
data={ffprobeData as FfprobeData}
|
||||
/>
|
||||
) : (
|
||||
<InfoAlert>No ffprobe data available.</InfoAlert>
|
||||
)
|
||||
) : (
|
||||
<InfoAlert>
|
||||
Select a video file to view ffprobe details.
|
||||
</InfoAlert>
|
||||
)
|
||||
) : (
|
||||
<InfoAlert>
|
||||
Select a file in Browser to view ffprobe details.
|
||||
</InfoAlert>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Jobs"
|
||||
description="Run predefined safe jobs against the selected file."
|
||||
>
|
||||
{selectedPath && templates && templates.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="job-template">Job template</Label>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
onValueChange={(value) =>
|
||||
updateBrowserState({ selectedJob: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="job-template" className="w-full">
|
||||
<SelectValue placeholder="Select a job" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((tpl) => (
|
||||
<SelectItem key={tpl.key} value={tpl.key}>
|
||||
{tpl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||
<Button className="mobile-touch-target"
|
||||
disabled={!selectedJob || runJob.isPending}
|
||||
onClick={() =>
|
||||
runJob.mutate({
|
||||
jobKey: selectedJob,
|
||||
path: selectedPath,
|
||||
})
|
||||
}
|
||||
>
|
||||
Run job
|
||||
</Button>
|
||||
{selectedTemplate && (
|
||||
<div className="self-center text-sm text-muted-foreground">
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||
<Button
|
||||
disabled={!selectedJob || runJob.isPending}
|
||||
onClick={() =>
|
||||
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
|
||||
}
|
||||
>
|
||||
Run job
|
||||
</Button>
|
||||
{selectedTemplate && (
|
||||
<div className="self-center text-sm text-muted-foreground">
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
{runJob.data && (
|
||||
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
||||
{`Exit: ${runJob.data.exit_status}`}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<InfoAlert>Select a file in Browser to run jobs.</InfoAlert>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{runJob.data && (
|
||||
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
||||
{`Exit: ${runJob.data.exit_status}`}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No file-capable machines are configured yet.
|
||||
Select a file in Browser to run jobs.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateToSettings("/settings")}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+21
-6
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* JobsTab — operational content for the backups service page.
|
||||
*
|
||||
* Lifted from the old top-level `components/BackupsPage.tsx`. The three
|
||||
* sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
|
||||
*
|
||||
* NOTE: the backup hooks currently query globally (no service_id filter).
|
||||
* The backend gained `service_id` attribution in Slice 3, but the hooks don't
|
||||
* yet accept a serviceId param. This tab shows ALL backups data for now;
|
||||
* per-instance scoping by `instance.id` is a follow-up once the hooks gain the
|
||||
* parameter.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -5,12 +17,16 @@ import {
|
||||
useBackupAlerts,
|
||||
useBackupJobs,
|
||||
useBackupRuns,
|
||||
} from "../hooks/useBackups";
|
||||
import BackupAlertsTable from "./BackupAlertsTable";
|
||||
import BackupJobsTable from "./BackupJobsTable";
|
||||
import BackupRunsTable from "./BackupRunsTable";
|
||||
} from "../../hooks/useBackups";
|
||||
import BackupAlertsTable from "../../components/BackupAlertsTable";
|
||||
import BackupJobsTable from "../../components/BackupJobsTable";
|
||||
import BackupRunsTable from "../../components/BackupRunsTable";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
export default function BackupsPage() {
|
||||
export function JobsTab({ instance }: { instance: ServiceInstance }) {
|
||||
// instance.id is not yet used — backup hooks query globally (see file
|
||||
// docstring). Per-instance scoping is a follow-up.
|
||||
void instance;
|
||||
const [tab, setTab] = useState("jobs");
|
||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
||||
@@ -35,7 +51,6 @@ export default function BackupsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="jobs">Jobs</TabsTrigger>
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Grafana Links tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Lifts the Grafana deep-link content from the old cross-service
|
||||
* ObservabilityPage into an instance-scoped tab. Shows service health + the
|
||||
* configured Grafana deep-links (node-exporter dashboard, Loki logs per
|
||||
* machine).
|
||||
*
|
||||
* The hooks (useGrafanaStatus, useMonitoringMachines) are global /
|
||||
* first-configured for now. Wiring `instance.id` into the status hook is a
|
||||
* follow-up. The machine links use the configured Grafana base_url from the
|
||||
* instance's config.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
|
||||
import {
|
||||
useGrafanaStatus,
|
||||
useMonitoringMachines,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
function GrafanaLinkCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinksTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: status, isLoading, error } = useGrafanaStatus();
|
||||
const { data: machines = [], isLoading: machinesLoading } =
|
||||
useMonitoringMachines();
|
||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||
|
||||
const grafanaBaseUrl =
|
||||
(instance.config?.base_url as string | undefined) ?? "";
|
||||
|
||||
const selectedMachine = useMemo(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const inst = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: isLoading
|
||||
? "checking…"
|
||||
: error
|
||||
? "unreachable"
|
||||
: "not configured";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Grafana {statusDetail}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Grafana</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
{machines.length > 0 ? (
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machinesLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : selectedMachine && grafanaBaseUrl ? (
|
||||
<>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||
href={nodeExporterDashboardUrl}
|
||||
/>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} logs`}
|
||||
description="Explore Loki logs for this machine in Grafana."
|
||||
href={logsUrl}
|
||||
/>
|
||||
</>
|
||||
) : !grafanaBaseUrl ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Gauge className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No Grafana base URL configured</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add a Grafana service instance to enable deep-links to
|
||||
dashboards and logs.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/services">Open Services</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<ServerOff className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No machine selected</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add monitoring machines in Settings to see Grafana drill-down
|
||||
links.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/**
|
||||
* MediaTab — operational content for the Jellyfin service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Media.tsx`. The service-id source is
|
||||
* changed from URL search params to the `instance` prop (the active service
|
||||
* instance selected on the service page). The service-selection dropdown and
|
||||
* its URL-sync effect are removed; everything else is preserved verbatim.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type {
|
||||
ColumnDef,
|
||||
OnChangeFn,
|
||||
@@ -34,12 +42,14 @@ import {
|
||||
useBuildIndex,
|
||||
useStopBuildIndex,
|
||||
useForceStopBuildIndex,
|
||||
} from "../hooks/useMedia";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type { MediaItem } from "../types";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
} from "../../hooks/useMedia";
|
||||
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||
import { useIsMobile } from "../../hooks/useIsMobile";
|
||||
import type { MediaItem, ServiceInstance } from "../../types";
|
||||
import { useCounts, useLibraries } from "../../hooks/useDashboard";
|
||||
import { useServiceInstances } from "../../hooks/useServices";
|
||||
|
||||
// --- Format helpers (lifted verbatim from Media.tsx) ---
|
||||
|
||||
function formatDuration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || Number.isNaN(seconds)) return "-";
|
||||
@@ -52,10 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
|
||||
// TanStack table instance stays stable — an unstable columns array drops the
|
||||
// controlled selection/visibility state (7a discovery). Visibility-only parity
|
||||
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
|
||||
// --- Column definitions (lifted verbatim) ---
|
||||
|
||||
const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||
{ accessorKey: "title", header: "Title" },
|
||||
{ accessorKey: "series", header: "Series" },
|
||||
@@ -74,25 +82,15 @@ const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||
{ accessorKey: "path", header: "Path" },
|
||||
];
|
||||
|
||||
// Stable path-derived identity so row selection survives server-driven paging
|
||||
// (design §3.4): the id is the item's filesystem path, which is stable across
|
||||
// limit/offset page changes.
|
||||
function getMediaRowId(row: MediaItem): string {
|
||||
return row.path;
|
||||
}
|
||||
|
||||
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
|
||||
// tech + context info a user scanning the library on a phone needs. Runtime,
|
||||
// bitrate, resolution, codec etc. live on the desktop table only.
|
||||
// Mobile card fields (mobile-parity pattern): title primary + 4 key fields.
|
||||
const mediaCardFields: MobileCardField<MediaItem>[] = [
|
||||
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||
{
|
||||
key: "hdr",
|
||||
label: "HDR",
|
||||
render: (r) => r.hdr || "-",
|
||||
},
|
||||
{ key: "hdr", label: "HDR", render: (r) => r.hdr || "-" },
|
||||
{ key: "library", label: "Library", render: (r) => r.library || "-" },
|
||||
{
|
||||
key: "year",
|
||||
@@ -101,12 +99,10 @@ const mediaCardFields: MobileCardField<MediaItem>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile pagination uses the shared TablePagination component
|
||||
// (frontend/src/components/ui/table-pagination.tsx).
|
||||
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
|
||||
|
||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||
const MOBILE_HIDDEN_COLUMNS = [
|
||||
"series",
|
||||
"season",
|
||||
@@ -159,6 +155,8 @@ function usePrefersSmallScreen(): boolean {
|
||||
return small;
|
||||
}
|
||||
|
||||
// --- Small UI helpers (lifted verbatim) ---
|
||||
|
||||
function FilterSelect({
|
||||
id,
|
||||
label,
|
||||
@@ -191,9 +189,6 @@ function FilterSelect({
|
||||
);
|
||||
}
|
||||
|
||||
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
|
||||
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
|
||||
// "indeterminate" affordance for unknown build progress.
|
||||
function BuildProgress({ value }: { value: number | null }) {
|
||||
if (value == null) {
|
||||
return (
|
||||
@@ -203,31 +198,26 @@ function BuildProgress({ value }: { value: number | null }) {
|
||||
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
|
||||
}
|
||||
|
||||
export function Media() {
|
||||
// --- Component ---
|
||||
|
||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const isMobile = useIsMobile();
|
||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||
const selectedServiceId =
|
||||
searchParams.get("jellyfin_service_id") ||
|
||||
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||
"";
|
||||
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||
const { data: status } = useMediaStatus(selectedServiceId || undefined);
|
||||
const buildIndex = useBuildIndex(selectedServiceId || undefined);
|
||||
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
|
||||
const forceStopBuildIndex = useForceStopBuildIndex(
|
||||
selectedServiceId || undefined,
|
||||
);
|
||||
const serviceId = instance.id;
|
||||
|
||||
const { data: counts } = useCounts(serviceId);
|
||||
const { data: libraries } = useLibraries(serviceId);
|
||||
const { data: status } = useMediaStatus(serviceId);
|
||||
const buildIndex = useBuildIndex(serviceId);
|
||||
const stopBuildIndex = useStopBuildIndex(serviceId);
|
||||
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
|
||||
|
||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||
MEDIA_TAB_STATE_KEY,
|
||||
defaultMediaTabState,
|
||||
);
|
||||
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
|
||||
// without pageSize/columnVisibility) never yields undefined fields.
|
||||
const mediaState: MediaTabState = {
|
||||
...defaultMediaTabState(),
|
||||
...rawMediaState,
|
||||
@@ -239,19 +229,6 @@ export function Media() {
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("jellyfin_service_id", selectedServiceId);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [searchParams, selectedServiceId, setSearchParams]);
|
||||
|
||||
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||
types,
|
||||
search,
|
||||
@@ -260,12 +237,10 @@ export function Media() {
|
||||
sort_order: sortOrder,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
jellyfinServiceId: selectedServiceId || undefined,
|
||||
jellyfinServiceId: serviceId,
|
||||
enabled: status?.exists ?? false,
|
||||
});
|
||||
|
||||
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
|
||||
// persistent media state and drive useMediaQuery { limit, offset }.
|
||||
const pageIndex = Math.floor(offset / pageSize);
|
||||
const pagination: PaginationState = { pageIndex, pageSize };
|
||||
|
||||
@@ -275,8 +250,6 @@ export function Media() {
|
||||
? updater({ pageIndex, pageSize })
|
||||
: updater;
|
||||
const nextPageSize = next.pageSize || pageSize;
|
||||
// Restart at page 0 whenever the page size changes (keeps offset sane
|
||||
// under server-driven paging).
|
||||
const nextOffset =
|
||||
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
|
||||
setMediaState((current) => ({
|
||||
@@ -296,9 +269,6 @@ export function Media() {
|
||||
});
|
||||
};
|
||||
|
||||
// On small screens force the same set of columns hidden as the pre-rework
|
||||
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
|
||||
// toggles freely (the toggleable set still equals the locked 15).
|
||||
const effectiveColumnVisibility = useMemo(() => {
|
||||
const base = mediaState.columnVisibility ?? {};
|
||||
if (!isSmall) return base;
|
||||
@@ -307,10 +277,15 @@ export function Media() {
|
||||
return merged;
|
||||
}, [mediaState.columnVisibility, isSmall]);
|
||||
|
||||
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
|
||||
// the item's path.
|
||||
const handleRowClick = (row: MediaItem) => {
|
||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||
// Navigate to the ssh_tasks service page with the path query param.
|
||||
// If an ssh_tasks instance exists, open its Files tab; otherwise land
|
||||
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
|
||||
const sshInstance = sshServices.find((s) => s.enabled);
|
||||
const base = sshInstance
|
||||
? `/services/ssh_tasks/${sshInstance.id}`
|
||||
: "/services/ssh_tasks";
|
||||
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
||||
};
|
||||
|
||||
const total = queryResult?.total ?? 0;
|
||||
@@ -346,35 +321,6 @@ export function Media() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="media-service">Service</Label>
|
||||
<Select
|
||||
value={selectedServiceId}
|
||||
onValueChange={(value) =>
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("jellyfin_service_id", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
|
||||
<SelectValue placeholder="Select a service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jellyfinServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{status?.exists ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Index: {status.item_count.toLocaleString()} items
|
||||
@@ -397,7 +343,7 @@ export function Media() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button className="mobile-touch-target"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => buildIndex.mutate()}
|
||||
disabled={
|
||||
@@ -408,7 +354,7 @@ export function Media() {
|
||||
</Button>
|
||||
{buildRunning && (
|
||||
<>
|
||||
<Button className="mobile-touch-target"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => stopBuildIndex.mutate()}
|
||||
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
||||
@@ -419,7 +365,7 @@ export function Media() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10 mobile-touch-target"
|
||||
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
|
||||
onClick={() => forceStopBuildIndex.mutate()}
|
||||
disabled={forceStopBuildIndex.isPending}
|
||||
>
|
||||
@@ -0,0 +1,129 @@
|
||||
/** MessagingTab — compose email to Authentik users via the mail queue. */
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAuthentikUsers,
|
||||
useSendAuthentikMessage,
|
||||
} from "../../hooks/useAuthentik";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
const DEFAULT_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
||||
|
||||
export function MessagingTab({ instance }: { instance: ServiceInstance }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
|
||||
const [subject, setSubject] = useState("");
|
||||
const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY);
|
||||
|
||||
const { data } = useAuthentikUsers(instance.id, {
|
||||
search,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
});
|
||||
const sendMessage = useSendAuthentikMessage(instance.id);
|
||||
|
||||
const users = (data?.items ?? []).filter((u) => u.email);
|
||||
const error = data?.error;
|
||||
|
||||
function toggleEmail(email: string) {
|
||||
setSelectedEmails((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(email)) next.delete(email);
|
||||
else next.add(email);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!subject.trim() || selectedEmails.size === 0) return;
|
||||
sendMessage.mutate({
|
||||
recipient_emails: Array.from(selectedEmails),
|
||||
subject: subject.trim(),
|
||||
html_body: htmlBody,
|
||||
});
|
||||
}
|
||||
|
||||
const canSend =
|
||||
subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{sendMessage.data ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{sendMessage.data.status === "queued"
|
||||
? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).`
|
||||
: `Error: ${sendMessage.data.error ?? "unknown"}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="msg-search">Find recipients</Label>
|
||||
<Input
|
||||
id="msg-search"
|
||||
placeholder="Search users to add as recipients…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
{users.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{users.slice(0, 20).map((user) => (
|
||||
<Button
|
||||
key={user.pk}
|
||||
variant={selectedEmails.has(user.email) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleEmail(user.email)}
|
||||
>
|
||||
{user.name || user.username}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedEmails.size > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedEmails.size} recipient
|
||||
{selectedEmails.size === 1 ? "" : "s"} selected.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="msg-subject">Subject</Label>
|
||||
<Input
|
||||
id="msg-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="msg-body">Message (HTML)</Label>
|
||||
<Textarea
|
||||
id="msg-body"
|
||||
rows={8}
|
||||
value={htmlBody}
|
||||
onChange={(e) => setHtmlBody(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onClick={handleSend} disabled={!canSend}>
|
||||
{sendMessage.isPending ? "Sending…" : "Send message"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Prometheus Metrics tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Lifts the Prometheus status + targets content from the old cross-service
|
||||
* ObservabilityPage into an instance-scoped tab. Shows service health and
|
||||
* the Node Exporter scrape-targets list.
|
||||
*
|
||||
* The hooks (usePrometheusStatus, usePrometheusTargets) are global /
|
||||
* first-configured for now. Wiring `instance.id` is a follow-up.
|
||||
*/
|
||||
import { Radio } from "lucide-react";
|
||||
import {
|
||||
usePrometheusStatus,
|
||||
usePrometheusTargets,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PrometheusTarget, ServiceInstance } from "../../types";
|
||||
|
||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-3">
|
||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(target.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px]">
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
||||
// Global / first-configured hooks for now; instance.id scoping is a
|
||||
// follow-up (see file docstring).
|
||||
void instance;
|
||||
|
||||
const {
|
||||
data: status,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
} = usePrometheusStatus();
|
||||
const {
|
||||
data: targets,
|
||||
isLoading: targetsLoading,
|
||||
error: targetsError,
|
||||
} = usePrometheusTargets();
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: statusLoading
|
||||
? "checking…"
|
||||
: "unreachable";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus {statusDetail}
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Prometheus</AlertTitle>
|
||||
<AlertDescription>{statusError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Node Exporter Targets ({targets?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targetsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !targets || targets.length === 0 ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Radio className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No Node Exporter targets</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Enable Node Exporter on an SSH machine in Settings to populate
|
||||
Prometheus scrape targets.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TargetsTable targets={targets} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{targetsError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load targets</AlertTitle>
|
||||
<AlertDescription>{targetsError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
|
||||
*
|
||||
* Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
|
||||
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
|
||||
* configured, it shows the URL and a placeholder (no requests backend endpoint
|
||||
* exists yet — building one is out of scope for this slice). When not
|
||||
* configured, it shows an empty-state CTA directing the user to add the fields
|
||||
* to the Jellyfin config.
|
||||
*/
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const jellyseerrUrl = String(
|
||||
(instance.config as Record<string, unknown>).jellyseerr_url ?? "",
|
||||
).trim();
|
||||
const jellyseerrApiKey = String(
|
||||
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
|
||||
).trim();
|
||||
|
||||
if (!jellyseerrUrl || !jellyseerrApiKey) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is not configured for this Jellyfin instance. Add
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
|
||||
jellyseerr_url
|
||||
</code>
|
||||
and
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
|
||||
jellyseerr_api_key
|
||||
</code>
|
||||
to the Jellyfin config (Config tab) to enable request management.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Jellyseerr</h3>
|
||||
<a
|
||||
href={jellyseerrUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
{jellyseerrUrl}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is configured. The requests view will show pending and
|
||||
recently fulfilled media requests. (This surface is under
|
||||
development.)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/** UsersTab — Authentik user directory for the Authentik service page. */
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { useAuthentikUsers } from "../../hooks/useAuthentik";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function UsersTab({ instance }: { instance: ServiceInstance }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [committedSearch, setCommittedSearch] = useState("");
|
||||
|
||||
const { data, isLoading } = useAuthentikUsers(instance.id, {
|
||||
search: committedSearch,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const error = data?.error;
|
||||
const users = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
function handleSearch() {
|
||||
setPage(1);
|
||||
setCommittedSearch(search);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search users…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="w-24">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading && users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
Loading…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
No users found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.pk}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.is_active ? "default" : "secondary"}>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{total > 0 ? (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{total} user{total === 1 ? "" : "s"} · Page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ActionsTab } from "../ActionsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useSettings", () => ({
|
||||
useTasks: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "t1",
|
||||
name: "Disk usage",
|
||||
task_type: "shell",
|
||||
content: "df -h",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
useSaveTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useRunTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ActionsTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ActionsTab", () => {
|
||||
it("renders the saved-actions rail and task detail", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("Saved actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disk usage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Add action button", () => {
|
||||
renderTab();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add action" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AlertsTab } from "../AlertsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "am-1",
|
||||
service_type: "alertmanager",
|
||||
name: "Main Alertmanager",
|
||||
config: { base_url: "https://am.example.com", timeout_seconds: 5 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
useAlertmanagerAlerts: () => ({
|
||||
data: {
|
||||
total: 2,
|
||||
by_severity: { critical: 1, warning: 1 },
|
||||
alerts: [
|
||||
{
|
||||
name: "DiskFull",
|
||||
severity: "critical",
|
||||
category: "disk",
|
||||
job_name: "node",
|
||||
summary: "Disk is almost full",
|
||||
description: "Disk usage above 90%",
|
||||
active_since: "2026-06-26T10:00:00Z",
|
||||
state: "firing",
|
||||
labels: { instance: "node1" },
|
||||
},
|
||||
{
|
||||
name: "HighCpu",
|
||||
severity: "warning",
|
||||
category: "cpu",
|
||||
job_name: "node",
|
||||
summary: "High CPU usage",
|
||||
description: "",
|
||||
active_since: "2026-06-26T09:00:00Z",
|
||||
state: "firing",
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useAlertmanagerStatus: () => ({
|
||||
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AlertsTab", () => {
|
||||
it("renders the alert count and alert names", () => {
|
||||
render(<AlertsTab instance={instance} />);
|
||||
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText("DiskFull")).toBeInTheDocument();
|
||||
expect(screen.getByText("HighCpu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders severity badges", () => {
|
||||
render(<AlertsTab instance={instance} />);
|
||||
expect(screen.getByText("critical")).toBeInTheDocument();
|
||||
expect(screen.getByText("warning")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { FilesTab } from "../FilesTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "ssh-1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Storage Server",
|
||||
config: {},
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useFiles", () => ({
|
||||
useDirectoryListing: () => ({
|
||||
data: {
|
||||
count: 2,
|
||||
entries: [
|
||||
{ name: "movies", type: "d", size: 0, mtime: 1700000000 },
|
||||
{ name: "video.mkv", type: "f", size: 1024, mtime: 1700000000 },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
|
||||
useJobTemplates: () => ({ data: [] }),
|
||||
useRunJob: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
usePersistentState: vi.fn((_key: string, initial: () => unknown) => [
|
||||
initial(),
|
||||
vi.fn(),
|
||||
]),
|
||||
}));
|
||||
|
||||
function renderTab(path = "/services/ssh_tasks/ssh-1") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<FilesTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FilesTab", () => {
|
||||
it("renders the directory listing with instance-scoped hooks", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("video.mkv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the path bar and browser section", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("Browser")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { JobsTab } from "../JobsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "bkp-1",
|
||||
service_type: "backups",
|
||||
name: "Main Backups",
|
||||
config: { ingestion_label: "default" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useBackups", () => ({
|
||||
useBackupJobs: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "job-1",
|
||||
name: "nightly",
|
||||
source: "/data",
|
||||
target: "s3://bucket",
|
||||
schedule_interval_seconds: 86400,
|
||||
created_at: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
useBackupRuns: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
useBackupAlerts: () => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
useAcknowledgeAlert: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(<JobsTab instance={instance} />);
|
||||
}
|
||||
|
||||
describe("JobsTab", () => {
|
||||
it("renders the Jobs, Runs, and Alerts sub-tabs", () => {
|
||||
renderTab();
|
||||
expect(screen.getByRole("tab", { name: "Jobs" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Runs" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /Alerts/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the backup job name in the Jobs tab", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LinksTab } from "../LinksTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "graf-1",
|
||||
service_type: "grafana",
|
||||
name: "Main Grafana",
|
||||
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
useGrafanaStatus: () => ({
|
||||
data: {
|
||||
up: true,
|
||||
version: "11.0.0",
|
||||
service_id: "graf-1",
|
||||
name: "Main Grafana",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useMonitoringMachines: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "storage",
|
||||
mode: "ssh",
|
||||
host: "10.0.0.5",
|
||||
enabled: true,
|
||||
services: [],
|
||||
port: 22,
|
||||
username: "admin",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LinksTab", () => {
|
||||
it("renders the Grafana version and machine dashboard links", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage logs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open-in-grafana link buttons", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
const links = screen.getAllByText("Open in Grafana");
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MediaTab } from "../MediaTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "jellyfin-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config: { base_url: "https://jf.example.com", user_id: "u1" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useMedia", () => ({
|
||||
useMediaStatus: () => ({
|
||||
data: { exists: true, item_count: 42, updated_at_label: "today" },
|
||||
}),
|
||||
useMediaQuery: () => ({ data: { items: [], total: 0 }, isLoading: false }),
|
||||
useBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useForceStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({
|
||||
data: { movies: 10, series: 5, episodes: 30 },
|
||||
}),
|
||||
useLibraries: () => ({ data: [{ id: "lib1" }] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
usePersistentState: () => [
|
||||
{
|
||||
search: "",
|
||||
types: "Movie,Episode",
|
||||
hdrFilter: "All",
|
||||
sortKey: "title",
|
||||
sortOrder: "Ascending",
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
columnVisibility: {},
|
||||
},
|
||||
vi.fn(),
|
||||
],
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MediaTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaTab", () => {
|
||||
it("renders index status and build controls with instance-scoped data", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/42 items/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Build index/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders library counts", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/10 movies/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/5 series/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the filter card with search input", () => {
|
||||
renderTab();
|
||||
expect(screen.getByLabelText("Search")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessagingTab } from "../MessagingTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "auth-1",
|
||||
service_type: "authentik",
|
||||
name: "Main Authentik",
|
||||
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
|
||||
secrets_set: { api_token: true },
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useAuthentik", () => ({
|
||||
useAuthentikUsers: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
pk: 1,
|
||||
username: "alice",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
})),
|
||||
useSendAuthentikMessage: vi.fn(() => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
data: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("MessagingTab", () => {
|
||||
it("renders the compose form (subject, body, send)", () => {
|
||||
render(<MessagingTab instance={instance} />);
|
||||
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message (HTML)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Send message" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders recipient toggle buttons from the directory", () => {
|
||||
render(<MessagingTab instance={instance} />);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MetricsTab } from "../MetricsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "prom-1",
|
||||
service_type: "prometheus",
|
||||
name: "Main Prometheus",
|
||||
config: { base_url: "https://prom.example.com", timeout_seconds: 10 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
usePrometheusStatus: () => ({
|
||||
data: {
|
||||
up: true,
|
||||
version: "2.52.0",
|
||||
service_id: "prom-1",
|
||||
name: "Main Prometheus",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
usePrometheusTargets: () => ({
|
||||
data: [
|
||||
{
|
||||
targets: ["10.0.0.5:9100"],
|
||||
labels: { instance: "storage", job: "node_exporter" },
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MetricsTab", () => {
|
||||
it("renders the Prometheus version and target list", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the target count in the heading", () => {
|
||||
render(<MetricsTab instance={instance} />);
|
||||
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RequestsTab } from "../RequestsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
function makeInstance(config: Record<string, unknown>): ServiceInstance {
|
||||
return {
|
||||
id: "jellyfin-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config,
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RequestsTab", () => {
|
||||
it("shows empty-state CTA when Jellyseerr is not configured", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
base_url: "https://jf.example.com",
|
||||
user_id: "u1",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the configured Jellyseerr URL when both fields are set", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
base_url: "https://jf.example.com",
|
||||
jellyseerr_url: "https://requests.example.com",
|
||||
jellyseerr_api_key: "secret-key",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("https://requests.example.com"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty-state when only URL is set (missing api_key)", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
jellyseerr_url: "https://requests.example.com",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { UsersTab } from "../UsersTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "auth-1",
|
||||
service_type: "authentik",
|
||||
name: "Main Authentik",
|
||||
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
|
||||
secrets_set: { api_token: true },
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useAuthentik", () => ({
|
||||
useAuthentikUsers: vi.fn(() => ({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
pk: 1,
|
||||
username: "alice",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
pk: 2,
|
||||
username: "bob",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
is_active: false,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
},
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("UsersTab", () => {
|
||||
it("renders the directory table with users", () => {
|
||||
render(<UsersTab instance={instance} />);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders search input and pagination", () => {
|
||||
render(<UsersTab instance={instance} />);
|
||||
expect(screen.getByPlaceholderText("Search users…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 users/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Previous")).toBeInTheDocument();
|
||||
expect(screen.getByText("Next")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Per-type content-tab descriptors for the service page skeleton.
|
||||
*
|
||||
* Each entry names a tab and its component. The service page renders
|
||||
* `[Overview, ...contentTabs(type), Widgets, Config]`.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { OverviewTab } from "./OverviewTab";
|
||||
import { AlertsTab } from "./AlertsTab";
|
||||
import { LinksTab } from "./LinksTab";
|
||||
import { MetricsTab } from "./MetricsTab";
|
||||
import { MediaTab } from "./MediaTab";
|
||||
import { RequestsTab } from "./RequestsTab";
|
||||
import { FilesTab } from "./FilesTab";
|
||||
import { ActionsTab } from "./ActionsTab";
|
||||
import { JobsTab } from "./JobsTab";
|
||||
import { UsersTab } from "./UsersTab";
|
||||
import { MessagingTab } from "./MessagingTab";
|
||||
|
||||
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
|
||||
|
||||
export interface ContentTab {
|
||||
label: string;
|
||||
Component: ServiceTabComponent;
|
||||
}
|
||||
|
||||
/** Overview tab (shared across all service types). */
|
||||
export const OVERVIEW_TAB: ContentTab = {
|
||||
label: "Overview",
|
||||
Component: OverviewTab,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the type-specific content tabs for a service type.
|
||||
* Types with no operational content return `[]` (only Overview + Widgets + Config).
|
||||
*/
|
||||
export function serviceContentTabs(serviceType: string): ContentTab[] {
|
||||
switch (serviceType) {
|
||||
case "jellyfin":
|
||||
return [
|
||||
{ label: "Media", Component: MediaTab },
|
||||
{ label: "Requests", Component: RequestsTab },
|
||||
];
|
||||
case "ssh_tasks":
|
||||
return [
|
||||
{ label: "Files", Component: FilesTab },
|
||||
{ label: "Actions", Component: ActionsTab },
|
||||
];
|
||||
case "backups":
|
||||
return [{ label: "Jobs", Component: JobsTab }];
|
||||
case "authentik":
|
||||
return [
|
||||
{ label: "Users", Component: UsersTab },
|
||||
{ label: "Messaging", Component: MessagingTab },
|
||||
];
|
||||
case "alertmanager":
|
||||
return [{ label: "Alerts", Component: AlertsTab }];
|
||||
case "grafana":
|
||||
return [{ label: "Links", Component: LinksTab }];
|
||||
case "prometheus":
|
||||
return [{ label: "Metrics", Component: MetricsTab }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
# Design — Services as hub IA
|
||||
|
||||
**Change:** `services-as-hub-ia`
|
||||
**Phase:** design
|
||||
**Date:** 2026-06-26
|
||||
|
||||
## Context
|
||||
|
||||
Frontend: React 18 + Vite + TanStack Query/Table + Tailwind v4 + shadcn/ui +
|
||||
react-router-dom. Backend: FastAPI + SQLite settings store + closed service
|
||||
registry at `backend/.../integrations/`. Existing patterns: service definitions
|
||||
in `integrations/<type>.py`, service instances in the `services` SQLite table,
|
||||
widget kinds per service, ServicePage at `/services/:type/:id`.
|
||||
|
||||
The change is layered: backend service-type changes first (so the registry and
|
||||
API reflect the new world), then frontend IA refactor (so the UI consumes the
|
||||
new shape).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
#### New service types
|
||||
|
||||
**`backups`** (`integrations/backups.py`, new):
|
||||
|
||||
```python
|
||||
class BackupsConfig(ServiceConfigBase):
|
||||
ingestion_label: str = "default" # disambiguates multi-instance ingestion
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="backups",
|
||||
name="Backups",
|
||||
config_model=BackupsConfig,
|
||||
secret_fields=[],
|
||||
widget_kinds=[widget_kind(...)], # existing BackupsWidgetSource moves here
|
||||
)
|
||||
```
|
||||
|
||||
The backup report endpoint gains an optional `?service_id=`. Existing reports
|
||||
(attribute to no service) are associated first-wins to the enabled `backups`
|
||||
instance; the poller and dashboard summary continue to work unchanged.
|
||||
|
||||
**`authentik`** (`integrations/authentik.py`, new):
|
||||
|
||||
```python
|
||||
class AuthentikConfig(ServiceConfigBase):
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="authentik",
|
||||
name="Authentik",
|
||||
config_model=AuthentikConfig,
|
||||
secret_fields=[SecretField(key="api_token", label="API token", required=True)],
|
||||
widget_kinds=[],
|
||||
)
|
||||
```
|
||||
|
||||
A new `AuthentikClient` (`clients/authentik.py`) wraps the directory API:
|
||||
`users(search?, page?, page_size?) -> {items, total}`, returning plain dicts.
|
||||
Endpoint: `GET /api/services/authentik/:service_id/users` proxies to the
|
||||
client. The mail queue and SMTP settings are reused unchanged; the message-
|
||||
compose endpoint accepts Authentik user ids instead of Jellyfin ids.
|
||||
|
||||
#### Jellyseerr absorption
|
||||
|
||||
`JellyfinConfig` gains optional fields:
|
||||
|
||||
```python
|
||||
class JellyfinConfig(ServiceConfigBase):
|
||||
base_url: ServiceBaseUrl
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 10
|
||||
jellyseerr_url: str = "" # NEW (optional)
|
||||
jellyseerr_api_key: str = "" # NEW (optional, non-secret at this layer)
|
||||
```
|
||||
|
||||
The `jellyseerr_api_key` lives in the non-secret config (it is paired with
|
||||
`jellyseerr_url` and treated as a service-level credential, encrypted at rest
|
||||
via the existing secrets mechanism if you prefer — design choice for tasks
|
||||
phase). The `jellyseerr` integration module and registry entry are deleted.
|
||||
|
||||
**Migration** (`services/settings_store.py` startup hook):
|
||||
|
||||
1. On `ensure_defaults()`, if any `jellyseerr` service rows exist:
|
||||
2. For each, attempt to pair with a `jellyfin` instance. Pairing policy: if
|
||||
exactly one Jellyfin exists, merge. If multiple, pick the one whose existing
|
||||
`jellyseerr_url` is empty (first such). If none can be paired, drop the
|
||||
Jellyseerr row with a logged warning.
|
||||
3. Move `base_url` and `api_key` onto the paired Jellyfin's config.
|
||||
4. Delete the `jellyseerr` row.
|
||||
|
||||
#### Route cleanup
|
||||
|
||||
`routers/users.py` and its deps are removed. `routers/users_impl.py` removed.
|
||||
`routers/media.py`, `routers/files.py`, `routers/jobs.py`, `routers/backups.py`,
|
||||
`routers/monitoring.py` keep their endpoints (they are consumed by the service
|
||||
tabs) — no change to paths. The dashboard, settings, services routers are
|
||||
unchanged. A new `routers/authentik_users.py` exposes the directory endpoint.
|
||||
|
||||
### Frontend
|
||||
|
||||
#### Top nav generation (`App.tsx`)
|
||||
|
||||
Replace the static `navItems` array with a data-driven list built from two
|
||||
queries:
|
||||
|
||||
```tsx
|
||||
const { data: services = [] } = useServiceInstances(); // existing
|
||||
const { data: dashboards = [] } = useDashboards(); // NEW
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
const configuredTypes = new Set(services.filter(s => s.enabled).map(s => s.service_type));
|
||||
return [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard, always: true },
|
||||
...dashboards.map(d => ({ path: `/d/${d.slug}`, label: d.label, icon: LayoutTemplate })),
|
||||
...SERVICE_TYPE_NAV_ENTRIES
|
||||
.filter(e => configuredTypes.has(e.serviceType))
|
||||
.map(e => ({ path: `/services/${e.serviceType}`, label: e.label, icon: e.icon })),
|
||||
{ path: "/services", label: "Services", icon: Boxes, always: true },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon, always: true },
|
||||
];
|
||||
}, [services, dashboards]);
|
||||
```
|
||||
|
||||
`SERVICE_TYPE_NAV_ENTRIES` is a static map from service type to its conditional
|
||||
nav entry/entries (ssh_tasks contributes two: Files + Actions). The shell
|
||||
shows a loading state until both queries settle.
|
||||
|
||||
#### Service page IA (`pages/ServicePage.tsx`)
|
||||
|
||||
Refactor `ServicePage` to render a tab skeleton driven by the service type:
|
||||
|
||||
```tsx
|
||||
const tabs = useMemo(() => serviceTabs(serviceType, instance), [...]);
|
||||
// tabs = [Overview, ...contentTabs, Widgets, Config]
|
||||
```
|
||||
|
||||
`serviceTabs` returns the per-type content components (MediaTab, FilesTab,
|
||||
ActionsTab, JobsTab, UsersTab, MessagingTab, AlertsTab, LinksTab,
|
||||
MetricsTab — most pre-existing, lifted from their top-level pages). The
|
||||
instance switcher renders at the top when `instances.length > 1`.
|
||||
|
||||
Routes:
|
||||
|
||||
- `/services/:type` → resolve first enabled instance → redirect to
|
||||
`/services/:type/:id` (client-side).
|
||||
- `/services/:type/:id` → render ServicePage with the instance + siblings.
|
||||
|
||||
#### Named dashboards (`pages/Dashboard.tsx` + new `NamedDashboardPage`)
|
||||
|
||||
- Main Dashboard at `/` keeps the current shape (widgets + shortcuts, now
|
||||
including pinned service links as a shortcut variant).
|
||||
- New `NamedDashboardPage` at `/d/:slug` renders a saved dashboard record's
|
||||
widgets + pinned links.
|
||||
- New `useDashboards` hook + CRUD endpoints (`GET/POST/PUT/DELETE
|
||||
/api/dashboards`) on the backend; the existing `dashboard_shortcuts` table
|
||||
gains a `dashboard` entity (or a new `named_dashboards` table — design
|
||||
choice for tasks phase).
|
||||
|
||||
#### Content migration
|
||||
|
||||
Each content page is lifted into a `*Tab` component consumed by ServicePage:
|
||||
|
||||
| Old | New | Consumers |
|
||||
|-----|-----|-----------|
|
||||
| `pages/Media.tsx` (Applications) | `pages/service-tabs/MediaTab.tsx` | Jellyfin |
|
||||
| `pages/FileBrowser.impl.tsx` | `pages/service-tabs/FilesTab.tsx` | ssh_tasks |
|
||||
| `pages/Actions.tsx` | `pages/service-tabs/ActionsTab.tsx` | ssh_tasks |
|
||||
| `components/BackupsPage.tsx` | `pages/service-tabs/JobsTab.tsx` | backups |
|
||||
| `pages/UsersPage.impl.tsx` | REMOVED; new `UsersTab` sources Authentik | authentik |
|
||||
| `components/ObservabilityPage.tsx` | SPLIT into `AlertsTab`/`LinksTab`/`MetricsTab` | alertmanager/grafana/prometheus |
|
||||
|
||||
Tabs accept `{ instance: ServiceInstance }` and read `instance.id` to scope
|
||||
their queries (replacing today's `?jellyfin_service_id=` query param — the
|
||||
service page passes the active instance directly).
|
||||
|
||||
#### Authentik client + endpoints
|
||||
|
||||
- `clients/authentik.py` (backend) — directory API wrapper.
|
||||
- `routers/authentik_users.py` — `GET /api/services/authentik/:id/users`.
|
||||
- `pages/service-tabs/UsersTab.tsx` — directory table + search.
|
||||
- `pages/service-tabs/MessagingTab.tsx` — compose + queue status, sourced from
|
||||
Authentik users (replaces the UsersPage compose dialog).
|
||||
|
||||
### Key technical risks & mitigations
|
||||
|
||||
- **Content migration scope.** Each tab lift is a non-trivial move. Slices must
|
||||
be page-by-page so each lands green and reviewable.
|
||||
- **Instance-scoped queries.** Today most content reads a service-id from a
|
||||
query param. The tab components take an `instance` prop and pass `instance.id`
|
||||
to their hooks; the hooks' existing `jellyfinServiceId`/`service_id` params
|
||||
are reused.
|
||||
- **Authentik API field coverage.** The directory API may not expose all fields
|
||||
the old compose flow used (avatars, activity). The UsersTab shows what's
|
||||
available; Messaging uses Authentik emails only.
|
||||
- **Jellyseerr migration ambiguity.** Multiple Jellyfins + multiple Jellyseerrs
|
||||
with no explicit pairing is unresolvable automatically. The migration drops
|
||||
unpaired Jellyseerrs with a logged warning; users reconfigure manually.
|
||||
- **Nav loading flash.** The shell needs services + dashboards before rendering
|
||||
nav. Show a skeleton nav until settled; do not block the route render.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- **404 over redirect.** Old bookmarks break. Accepted: redirects become tech
|
||||
debt; the new IA is clean.
|
||||
- **No cross-service observability.** A built-in overview is sacrificed; users
|
||||
build their own via named dashboards. Accepted per D6.
|
||||
- **Global dashboards.** No per-user customization in this change. Accepted;
|
||||
multi-tenant is a separate concern.
|
||||
- **Jellyseerr absorbed, not migrated gracefully.** Unpaired Jellyseerrs are
|
||||
dropped. Accepted; the data is recreatable.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Proposal — Services as hub IA
|
||||
|
||||
**Change:** `services-as-hub-ia`
|
||||
**Phase:** proposal
|
||||
**Date:** 2026-06-26
|
||||
|
||||
## Problem
|
||||
|
||||
The current information architecture treats **concepts** (Media, Files, Actions,
|
||||
Users, Observability, Backups) as first-class top-level destinations. Services
|
||||
(Jellyfin, SSH, Alertmanager, etc.) are configured separately and reached via a
|
||||
"Services" admin page that holds only connection config + widgets. This produces
|
||||
two problems:
|
||||
|
||||
1. **Duplicated ontology.** "Media" and "the Jellyfin service page" are two
|
||||
different places that both reference the same Jellyfin instance. The Media
|
||||
page is where you browse; the service page is where you configure. There is
|
||||
no single "Jellyfin" place.
|
||||
2. **Concept-pages assume exactly one source.** The Media page assumes media
|
||||
comes from Jellyfin, the Files page assumes files come from SSH, the Users
|
||||
page assumes users come from Jellyfin. Multi-instance setups (2 Jellyfins, 2
|
||||
SSH targets) have no first-class home; you switch via query params.
|
||||
|
||||
Meanwhile, several concepts have outgrown their current shape:
|
||||
|
||||
- **Users** is Jellyfin-specific and overlaps with the OIDC provider (Authentik)
|
||||
that already holds the canonical user directory. Maintaining a parallel
|
||||
Jellyfin-only user directory is duplicated work.
|
||||
- **Observability** aggregates three service types (Alertmanager, Grafana,
|
||||
Prometheus) into one page, but each of those services is already a
|
||||
first-class registry instance. The aggregate page is a special case.
|
||||
- **Backups** receives reports via a REST endpoint but has no service-record
|
||||
home; it cannot be named, multi-instanced, or surfaced like other services.
|
||||
- **Jellyseerr** is configured as a separate service but its only role is
|
||||
enriching Jellyfin users — it has no standalone value.
|
||||
|
||||
## Proposal
|
||||
|
||||
Reorganize the app around **services as the hub**. The top-level navigation
|
||||
shrinks to a tiny always-visible core plus **conditional per-type entries** that
|
||||
materialize only when a matching service is configured. Operational content
|
||||
(Media, Files, Actions, Users) moves **into the service page** as tabs.
|
||||
|
||||
### Top-level navigation (after)
|
||||
|
||||
- **Main Dashboard** (always visible, special, at `/`)
|
||||
- **Named dashboards** (always visible once created; one top-level entry each,
|
||||
at `/d/:slug`)
|
||||
- **Conditional service-type entries** — one per configured service type,
|
||||
linking to the type's service page with an in-page instance switcher:
|
||||
- "Media" appears when a Jellyfin service exists
|
||||
- "Files" and "Actions" appear when an ssh_tasks service exists
|
||||
- "Alerts" when Alertmanager exists; "Grafana" when Grafana exists;
|
||||
"Prometheus" when Prometheus exists (Observability page is removed)
|
||||
- "Backups" when a backups service exists
|
||||
- "Users" when an Authentik service exists
|
||||
- **Services** (always visible — the admin hub for managing service instances)
|
||||
- **Settings** (always visible — unchanged)
|
||||
|
||||
### Service page IA (after)
|
||||
|
||||
Every service page uses the same tab skeleton:
|
||||
|
||||
```
|
||||
[Overview] [type-specific content tabs...] [Widgets] [Config]
|
||||
```
|
||||
|
||||
- **Overview** — service health + key metrics (connection status, version,
|
||||
primary widget preview).
|
||||
- **Content tabs** — per service type:
|
||||
- **Jellyfin**: Media (table + index build), Requests (Jellyseerr enrichment)
|
||||
- **ssh_tasks**: Files (browser + ffprobe), Actions (saved tasks)
|
||||
- **backups**: Jobs (jobs + runs + alerts)
|
||||
- **authentik**: Users (directory), Messaging (compose)
|
||||
- **alertmanager**: Alerts
|
||||
- **grafana**: Links
|
||||
- **prometheus**: Metrics / status
|
||||
- **Widgets** — widget kinds this service provides (unchanged from today).
|
||||
- **Config** — non-secret config + secrets (unchanged from today).
|
||||
|
||||
Multi-instance: when >1 instance of a type exists, the service page shows an
|
||||
**instance switcher** (dropdown at the top of the page) rather than separate
|
||||
routes per instance.
|
||||
|
||||
### Service type changes
|
||||
|
||||
- **NEW: `backups`** — becomes a service type in the registry. The current REST
|
||||
report endpoint keeps working for passive ingestion; reports are attributed to
|
||||
a backups service instance. The `BackupsPage` content (jobs/runs/alerts) moves
|
||||
into the backups service page's Jobs tab.
|
||||
- **NEW: `authentik`** — becomes a service type. Its Users tab is the new user
|
||||
directory (replacing the Jellyfin-based Users page). Its Messaging tab hosts
|
||||
the message-compose flow, emailing Authentik-sourced users via the existing
|
||||
SMTP settings. OIDC auth flow is unchanged.
|
||||
- **ABSORBED: `jellyseerr`** — ceases to be its own service type. Its config
|
||||
fields (`base_url`, `api_key`) move onto the Jellyfin service config as
|
||||
optional fields. The Jellyfin service page gains a Requests tab backed by the
|
||||
configured Jellyseerr. Existing Jellyseerr service instances are migrated into
|
||||
their paired Jellyfin's config (or dropped if no pairing can be inferred).
|
||||
- **UNCHANGED**: `alertmanager`, `grafana`, `prometheus`, `ssh_tasks`,
|
||||
`nextcloud` keep their service-type status. Their operational content (if any)
|
||||
moves into tabs on their service page.
|
||||
|
||||
### Removed / replaced
|
||||
|
||||
- **`/media`** — content moves into Jellyfin service page (Media tab). Old route
|
||||
returns 404.
|
||||
- **`/files`, `/actions`** — content moves into ssh_tasks service page (Files /
|
||||
Actions tabs). Old routes return 404.
|
||||
- **`/users`** — replaced by Authentik service page (Users tab). Old route
|
||||
returns 404. The Jellyfin-backed user directory, Jellyfin-email message
|
||||
compose, and Jellyseerr-enrichment-of-Jellyfin-users are removed.
|
||||
- **`/observability`** — removed. Its content splits across the Alertmanager,
|
||||
Grafana, and Prometheus service pages. Old route returns 404. The cross-
|
||||
service "single pane of glass" is intentionally sacrificed; users who want it
|
||||
build it on a named dashboard via widgets.
|
||||
- **`/backups`** — content moves into the backups service page (Jobs tab). Old
|
||||
route returns 404.
|
||||
- **Jellyseerr service type** — configuration absorbed into Jellyfin.
|
||||
|
||||
### Named dashboards
|
||||
|
||||
- The main Dashboard at `/` stays **special** (the default landing, not
|
||||
deletable, always first in nav).
|
||||
- Users can create **named dashboards** at `/d/:slug`. Each named dashboard is a
|
||||
configurable grid of **widgets + pinned service links** (shortcuts to specific
|
||||
service pages or tabs).
|
||||
- Each named dashboard appears as its own top-level nav entry, in a user-
|
||||
controlled order. The main dashboard always sits first.
|
||||
|
||||
### Routing
|
||||
|
||||
- `/` — main Dashboard (special, default landing)
|
||||
- `/d/:slug` — named dashboard
|
||||
- `/services` — services admin hub (list of all service instances, grouped by
|
||||
type)
|
||||
- `/services/:type` — service page for the first/primary instance of a type,
|
||||
with an instance switcher when >1 exists
|
||||
- `/services/:type/:id` — service page for a specific instance
|
||||
- `/settings` — settings (unchanged)
|
||||
- All legacy top-level routes (`/media`, `/files`, `/actions`, `/users`,
|
||||
`/observability`, `/backups`) return **404** — no redirects, no aliases.
|
||||
|
||||
### Empty state
|
||||
|
||||
A fresh install with no services configured and no dashboards lands on the main
|
||||
Dashboard with a strong CTA ("Add a service to get started" → Services). The
|
||||
Services page has a matching empty state. Top nav shows only Dashboard /
|
||||
Services / Settings until services or dashboards are added.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No changes to OIDC / SSO authentication.** Authentik-as-IdP keeps doing
|
||||
what it does today; this change adds Authentik-as-directory-source only.
|
||||
- **No per-instance top-level entries.** A type gets one conditional entry with
|
||||
an in-page instance switcher; nav does not grow with the number of instances.
|
||||
- **No legacy-route redirects.** Old URLs 404; bookmarks must be updated.
|
||||
- **No tablet-specific or mobile-specific IA divergence.** The IA is the same
|
||||
across breakpoints (mobile responsive parity already shipped).
|
||||
- **No new widget kinds.** Named dashboards compose existing widget kinds plus
|
||||
pinned service links (a new shortcut variant, not a widget kind).
|
||||
- **No backend API contract changes beyond the new service types and the
|
||||
Authentik directory endpoint.** Existing endpoints keep their shape.
|
||||
- **No multi-tenant or per-user dashboard customization.** Dashboards are
|
||||
global (shared across all authenticated users) in this change.
|
||||
|
||||
## Key technical risks
|
||||
|
||||
- **Content migration is large.** Media, Files, Actions, Users, Backups each
|
||||
move from a top-level page into a service tab. Each is a non-trivial component
|
||||
with its own hooks, tests, and state. This is the bulk of the implementation
|
||||
risk and review burden.
|
||||
- **Jellyseerr absorption migration.** Existing Jellyseerr service instances
|
||||
must be migrated into their paired Jellyfin's config at backend startup, with
|
||||
a clear policy when pairing is ambiguous (multiple Jellyfins, no Jellyfin).
|
||||
- **Authentik directory API.** The Authentik service page needs a backend client
|
||||
that queries Authentik's user/group directory API. Scope of that API (which
|
||||
fields, pagination, search) must be pinned during design.
|
||||
- **Nav generation is data-driven.** Top nav must react to configured services
|
||||
and existing dashboards. This is a new TanStack-Query dependency in the App
|
||||
shell, with loading/empty states.
|
||||
- **Backups attribution.** Existing backup reports have no service_id. The
|
||||
migration must assign them to a backups service instance (first-wins or
|
||||
job-name-matching policy).
|
||||
|
||||
## Risks (flagged, not blocking)
|
||||
|
||||
- **Loss of cross-service Observability overview.** A fresh install with no
|
||||
dashboards configured has no alerts-overview until the user builds one. The
|
||||
mitigation (widgets on a named dashboard) is real but requires user setup.
|
||||
Revisit if it bites.
|
||||
- **Authentik directory coverage.** Authentik's user directory may not carry the
|
||||
same fields the current Jellyfin-based messaging flow relied on (e.g. Jellyfin-
|
||||
specific avatar URLs, activity state). Some fields will simply go away.
|
||||
|
||||
## Decision matrix (from grilling)
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|----------|--------|
|
||||
| D1 | Top nav model | Conditional type entries (one per configured service type, appearing only when configured) |
|
||||
| D2 | Multi-instance | Type + instance switcher on the service page |
|
||||
| D3 | Files + Actions | Move into ssh_tasks service page as tabs |
|
||||
| D4 | Backups | New service type in the registry |
|
||||
| D5 | Users | Replaced by Authentik (included in this change) |
|
||||
| D6 | Observability | Split per service type (no aggregate page) |
|
||||
| D7 | Main Dashboard | Stays special at `/`, not deletable, default landing |
|
||||
| D8 | Named dashboards | Widgets + pinned service links |
|
||||
| D9 | Named dashboards nav | Each named dashboard = one top-level entry |
|
||||
| D10 | Authentik role | User directory source (OIDC auth unchanged) |
|
||||
| D11 | Messaging | Moves to Authentik service page; emails Authentik users via existing SMTP |
|
||||
| D12 | Jellyseerr | Absorbed into Jellyfin config (no longer its own service type) |
|
||||
| D13 | Service page tabs | Standard skeleton: Overview \u2234 content \u2234 Widgets \u2234 Config |
|
||||
| D14 | Overview tab | Health + key metrics |
|
||||
| D15 | Routing | `/services/:type/:id`, `/services/:type` (first/primary), `/d/:slug`, `/` |
|
||||
| D16 | Legacy routes | Return 404 (no redirects, no aliases) |
|
||||
| D17 | Empty state | Dashboard CTA + Services empty state |
|
||||
@@ -0,0 +1,171 @@
|
||||
# Spec — Services as hub IA
|
||||
|
||||
**Change:** `services-as-hub-ia`
|
||||
**Phase:** spec
|
||||
**Date:** 2026-06-26
|
||||
|
||||
## Scope
|
||||
|
||||
Reorganize the frontend information architecture around services as the hub.
|
||||
Operational content (Media, Files, Actions, Users, Backups) moves into service-
|
||||
type-specific tabs on the service page. The top nav shrinks to a small always-
|
||||
visible core (Main Dashboard, Services, Settings) plus conditional per-type
|
||||
entries and user-created named dashboards. Two new service types are added
|
||||
(`backups`, `authentik`); one is absorbed (`jellyseerr` → Jellyfin config).
|
||||
|
||||
This change spans backend (new service types, Authentik client, Jellyseerr
|
||||
migration, route cleanup) and frontend (service-page IA, top-nav generation,
|
||||
content migration, named dashboards).
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 — Top-level navigation
|
||||
|
||||
- R1.1 The top nav contains, in order: Main Dashboard, named dashboards (one
|
||||
entry each, user-controlled order), conditional service-type entries, Services,
|
||||
Settings.
|
||||
- R1.2 Conditional service-type entries appear only when at least one enabled
|
||||
instance of that type exists. Mapping:
|
||||
- `jellyfin` → "Media" entry → `/services/jellyfin`
|
||||
- `ssh_tasks` → "Files" and "Actions" entries → `/services/ssh_tasks`
|
||||
- `alertmanager` → "Alerts" entry → `/services/alertmanager`
|
||||
- `grafana` → "Grafana" entry → `/services/grafana`
|
||||
- `prometheus` → "Prometheus" entry → `/services/prometheus`
|
||||
- `backups` → "Backups" entry → `/services/backups`
|
||||
- `authentik` → "Users" entry → `/services/authentik`
|
||||
- `nextcloud` → no entry (no operational content)
|
||||
- R1.3 The Main Dashboard is always first and not deletable.
|
||||
- R1.4 The nav is data-driven (reacts to configured services + dashboards) with
|
||||
graceful loading/empty states.
|
||||
|
||||
### R2 — Service page IA
|
||||
|
||||
- R2.1 Every service page uses the tab skeleton: Overview, type-specific
|
||||
content tabs (zero or more), Widgets, Config.
|
||||
- R2.2 The Overview tab shows service health (connection status, version, last
|
||||
error) and a primary metric preview (per-type: live sessions for Jellyfin,
|
||||
active alert count for Alertmanager, etc.).
|
||||
- R2.3 The Widgets and Config tabs are unchanged from today (widget kinds list,
|
||||
non-secret config + secrets editors).
|
||||
- R2.4 Type-specific content tabs:
|
||||
- `jellyfin`: Media (table + index build controls), Requests (Jellyseerr data)
|
||||
- `ssh_tasks`: Files (browser + ffprobe + jobs), Actions (saved tasks CRUD + run)
|
||||
- `backups`: Jobs (jobs + runs + alerts + acknowledge)
|
||||
- `authentik`: Users (directory + search), Messaging (compose + queue status)
|
||||
- `alertmanager`: Alerts (summary + list + severity filter)
|
||||
- `grafana`: Links (configured dashboard deep-links)
|
||||
- `prometheus`: Metrics (status + PromQL explorer)
|
||||
- `nextcloud`: no content tabs (Overview + Widgets + Config only)
|
||||
|
||||
### R3 — Instance switcher
|
||||
|
||||
- R3.1 When more than one enabled instance of a service type exists, the service
|
||||
page renders an instance switcher (dropdown) at the top.
|
||||
- R3.2 The switcher selects the active instance; all tabs reflect the selected
|
||||
instance.
|
||||
- R3.3 The default selected instance is the first enabled instance (or the one
|
||||
named "primary" if multiple-selection is added later — out of scope here).
|
||||
- R3.4 Single-instance types do not render the switcher.
|
||||
|
||||
### R4 — Routing
|
||||
|
||||
- R4.1 `/` — Main Dashboard (special, default landing, not deletable).
|
||||
- R4.2 `/d/:slug` — named dashboard.
|
||||
- R4.3 `/services` — services admin hub (list of all instances, grouped by type,
|
||||
with add/edit/delete).
|
||||
- R4.4 `/services/:type` — service page for the first enabled instance of the
|
||||
type; redirects (client-side) to `/services/:type/:id` once an instance is
|
||||
resolved.
|
||||
- R4.5 `/services/:type/:id` — service page for a specific instance.
|
||||
- R4.6 `/settings` — settings (unchanged).
|
||||
- R4.7 Legacy routes (`/media`, `/files`, `/actions`, `/users`, `/observability`,
|
||||
`/backups`) return 404 — no redirects, no aliases.
|
||||
|
||||
### R5 — Named dashboards
|
||||
|
||||
- R5.1 Any authenticated user can create, edit, reorder, and delete named
|
||||
dashboards (global scope — shared across users in this change).
|
||||
- R5.2 A named dashboard holds an ordered list of widgets (existing widget kinds
|
||||
only) and pinned service links (shortcut to a service page or specific tab).
|
||||
- R5.3 Each named dashboard has a user-chosen label and a URL slug derived from
|
||||
it (uniqueness enforced).
|
||||
- R5.4 The Main Dashboard is special: it cannot be deleted, is always first in
|
||||
the nav, and its slug is reserved.
|
||||
|
||||
### R6 — Service type changes
|
||||
|
||||
- R6.1 **NEW `backups`** service type: config holds ingestion source metadata;
|
||||
the existing REST report endpoint attributes incoming reports to a backups
|
||||
service instance (first-wins when none is specified).
|
||||
- R6.2 **NEW `authentik`** service type: config holds base_url; secret holds the
|
||||
API token. Provides a Users widget and a user-directory endpoint consumed by
|
||||
the Authentik service page.
|
||||
- R6.3 **ABSORBED `jellyseerr`**: removed as a service type. Its config fields
|
||||
(`base_url`, `api_key`) become optional fields on `JellyfinConfig`. Existing
|
||||
Jellyseerr service instances are migrated into their paired Jellyfin's config
|
||||
at backend startup; unpaired instances are dropped with a logged warning.
|
||||
|
||||
### R7 — Users → Authentik
|
||||
|
||||
- R7.1 The Jellyfin-backed user directory, Jellyfin-email message compose, and
|
||||
Jellyseerr-enrichment-of-Jellyfin-users flows are removed.
|
||||
- R7.2 The Authentik service page Users tab sources users from Authentik's
|
||||
directory API (paginated, searchable).
|
||||
- R7.3 The Authentik Messaging tab hosts message-compose, emailing Authentik-
|
||||
sourced users via the existing SMTP settings and mail queue.
|
||||
- R7.4 OIDC authentication is unchanged.
|
||||
|
||||
### R8 — Observability
|
||||
|
||||
- R8.1 The Observability page is removed.
|
||||
- R8.2 Alertmanager alerts, Grafana links, and Prometheus status each render on
|
||||
their respective service-type pages as content tabs.
|
||||
- R8.3 There is no cross-service aggregate view built-in. Users who want one
|
||||
build it via widgets on a named dashboard.
|
||||
|
||||
### R9 — Empty state
|
||||
|
||||
- R9.1 A fresh install (no services, no dashboards) lands on `/` with an empty-
|
||||
state CTA pointing to `/services`.
|
||||
- R9.2 The Services hub shows a strong empty state ("Add a service to get
|
||||
started") when no service instances exist.
|
||||
|
||||
### R10 — Non-regression
|
||||
|
||||
- R10.1 The existing widget system, ServicePage config/secrets editing, settings
|
||||
(machines, SSH keys), and authentication continue to work.
|
||||
- R10.2 The backend backup report endpoint, mail queue, and observability
|
||||
metrics endpoints continue to function (they may gain a service_id
|
||||
association).
|
||||
- R10.3 Mobile responsive behavior (already shipped) is preserved across the new
|
||||
IA.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- AC1 The top nav renders exactly: Main Dashboard, named dashboards, configured-
|
||||
service-type entries, Services, Settings — and nothing else.
|
||||
- AC2 Each content tab listed in R2.4 renders its full operational content
|
||||
inside the corresponding service page.
|
||||
- AC3 An instance switcher appears when >1 enabled instance of a type exists and
|
||||
is absent otherwise.
|
||||
- AC4 Creating, editing, reordering, and deleting a named dashboard works; each
|
||||
appears in the nav and is reachable at `/d/:slug`.
|
||||
- AC5 Legacy routes return 404.
|
||||
- AC6 The `backups` and `authentik` service types appear in the service-type
|
||||
list and can be configured like any other service.
|
||||
- AC7 Existing Jellyseerr service instances are migrated into Jellyfin config
|
||||
(or dropped with a logged warning when unpaired).
|
||||
- AC8 A fresh install lands on `/` with the empty-state CTA.
|
||||
- AC9 `cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` is
|
||||
green.
|
||||
- AC10 `cd frontend && npm run lint && npm run build && npm run test` is green.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-instance top-level nav entries.
|
||||
- Legacy-route redirects or aliases.
|
||||
- New widget kinds (pinned service links are a shortcut variant, not a widget
|
||||
kind).
|
||||
- Per-user dashboard customization.
|
||||
- Changes to OIDC authentication.
|
||||
- Mobile-specific IA divergence.
|
||||
@@ -0,0 +1,270 @@
|
||||
# Tasks — Services as hub IA
|
||||
|
||||
**Change:** `services-as-hub-ia`
|
||||
**Phase:** tasks
|
||||
**Date:** 2026-06-26
|
||||
|
||||
## Review workload forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | ~4500–6000 |
|
||||
| Chained PRs recommended | Yes (12 slices) |
|
||||
| Chain strategy | stacked-to-main |
|
||||
| Slice order | 1–3 backend → 4 shell → 5–9 content tabs → 10 dashboards → 11 cleanup → 12 verify |
|
||||
|
||||
Each slice is committed separately. Every slice must leave
|
||||
`cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` **and**
|
||||
`cd frontend && npm run lint && npm run build && npm run test` green. Every
|
||||
touched page gains a Vitest case at the new route and asserts the old route 404s
|
||||
(where applicable).
|
||||
|
||||
---
|
||||
|
||||
## Slice 1 — Backend: new service types + Jellyseerr absorption
|
||||
|
||||
**Goal:** Registry reflects the new world. No frontend change yet.
|
||||
|
||||
- [ ] **1.1 Add `backups` integration**
|
||||
- Files: `backend/src/media_library_viewer_api/integrations/backups.py` (new),
|
||||
`integrations/registry.py`
|
||||
- Details: `BackupsConfig` (`ingestion_label: str = "default"`), no secrets,
|
||||
widget kind `summary` (move `BackupsWidgetSource` adapter to bind the
|
||||
service_id). Register in `SERVICE_DEFINITIONS`.
|
||||
|
||||
- [ ] **1.2 Add `authentik` integration**
|
||||
- Files: `integrations/authentik.py` (new), `registry.py`
|
||||
- Details: `AuthentikConfig` (`base_url: ServiceBaseUrl`, `timeout_seconds`),
|
||||
secret `api_token` (required). No widget kinds yet.
|
||||
|
||||
- [ ] **1.3 Absorb `jellyseerr` into `JellyfinConfig`**
|
||||
- Files: `integrations/jellyfin.py`, `integrations/jellyseerr.py` (delete),
|
||||
`integrations/registry.py`, `integrations/__init__.py`
|
||||
- Details: Add optional `jellyseerr_url`, `jellyseerr_api_key` to
|
||||
`JellyfinConfig`. Delete the `jellyseerr` integration module and registry
|
||||
entry. Update tests.
|
||||
|
||||
- [ ] **1.4 Jellyseerr migration**
|
||||
- Files: `services/settings_store.py` (`ensure_defaults`)
|
||||
- Details: On startup, migrate existing `jellyseerr` rows into paired
|
||||
`jellyfin` instances per the design. Log a warning for unpaired drops.
|
||||
|
||||
- [ ] **1.5 Tests**
|
||||
- Update `backend/tests/test_services.py`, `test_widgets.py` for the new types
|
||||
and the migration. Assert registry contains 8 types (alertmanager, authentik,
|
||||
backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks).
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — Backend: Authentik directory client + endpoint
|
||||
|
||||
- [ ] **2.1 AuthentikClient**
|
||||
- Files: `clients/authentik.py` (new)
|
||||
- Details: `users(search, page, page_size) -> {items, total}` against the
|
||||
Authentik directory API. Reuse the requests-session pattern from
|
||||
`clients/jellyseerr.py`. Tests: `tests/test_authentik_client.py`.
|
||||
|
||||
- [ ] **2.2 Directory endpoint**
|
||||
- Files: `routers/authentik_users.py` (new), `main.py` (register router)
|
||||
- Details: `GET /api/services/authentik/{service_id}/users` proxies to the
|
||||
client, resolving the service record via the existing dependency. Tests
|
||||
cover not-configured + unreachable + paginated-success.
|
||||
|
||||
---
|
||||
|
||||
## Slice 3 — Backend: route cleanup + backups attribution
|
||||
|
||||
- [ ] **3.1 Remove Users router**
|
||||
- Files: `routers/users.py`, `routers/users_impl.py` (delete), `main.py`,
|
||||
`dependencies.py`
|
||||
- Details: Delete the Jellyfin-backed user directory + message-compose router
|
||||
and its deps. Update `test_api.py` to drop the corresponding tests.
|
||||
|
||||
- [ ] **3.2 Backups service attribution**
|
||||
- Files: `routers/backups.py`, `services/settings_store.py`
|
||||
- Details: backup report endpoint accepts optional `?service_id=`; first-wins
|
||||
association to an enabled `backups` instance when omitted. Dashboard summary
|
||||
- poller continue to work.
|
||||
|
||||
- [ ] **3.3 Named dashboards backend**
|
||||
- Files: `models/dashboards.py` (new), `routers/dashboards.py` (new),
|
||||
`services/settings_store.py` (table + CRUD)
|
||||
- Details: `named_dashboards` table (id, slug, label, sort_order, payload JSON
|
||||
of widget+link placements). Endpoints: `GET/POST/PUT/DELETE /api/dashboards`.
|
||||
Tests in `tests/test_dashboards.py`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 4 — Frontend: top-nav generation + service-page skeleton
|
||||
|
||||
**Goal:** Data-driven nav + tab-skeleton ServicePage shell. Content tabs are
|
||||
stubs that say "coming soon" so the rest of the app stays green.
|
||||
|
||||
- [ ] **4.1 Service-type → nav-entry map**
|
||||
- Files: `frontend/src/integrations/navEntries.ts` (new)
|
||||
- Details: Static `SERVICE_TYPE_NAV_ENTRIES` map (jellyfin→Media,
|
||||
ssh_tasks→[Files, Actions], alertmanager→Alerts, etc.). Helper to filter by
|
||||
configured types.
|
||||
|
||||
- [ ] **4.2 Data-driven nav in `App.tsx`**
|
||||
- Files: `frontend/src/App.tsx`
|
||||
- Details: Replace static `navItems` with the memoized list from design. Add
|
||||
`useDashboards()` and combine with `useServiceInstances()`. Loading skeleton
|
||||
nav until settled. Legacy routes removed; add 404 catch-all.
|
||||
|
||||
- [ ] **4.3 ServicePage tab skeleton + instance switcher**
|
||||
- Files: `frontend/src/pages/ServicePage.tsx`, new `pages/service-tabs/`
|
||||
directory, `pages/ServiceTypePage.tsx` (redirect resolver)
|
||||
- Details: Refactor ServicePage to render `[Overview, ...content, Widgets,
|
||||
Config]` from `serviceTabs(serviceType)`. Add `/services/:type` resolver
|
||||
route. Content tabs are stub components ("coming soon"). Instance switcher
|
||||
dropdown when siblings > 1.
|
||||
|
||||
- [ ] **4.4 Empty-state CTAs**
|
||||
- Files: `frontend/src/pages/Dashboard.tsx`, `pages/ServicesPage.tsx`
|
||||
- Details: Dashboard shows "Add a service" CTA when no services. Services
|
||||
page strong empty state.
|
||||
|
||||
- [ ] **4.5 Tests**
|
||||
- Nav-generation tests, service-page-skeleton tests, 404-on-legacy-routes
|
||||
tests.
|
||||
|
||||
---
|
||||
|
||||
## Slice 5 — Frontend: Jellyfin content tabs (Media + Requests)
|
||||
|
||||
- [ ] **5.1 MediaTab**
|
||||
- Files: `pages/service-tabs/MediaTab.tsx` (lift from `pages/Media.tsx`)
|
||||
- Details: Accept `instance` prop, pass `instance.id` to media hooks. Preserve
|
||||
the index build controls + mobile card layout. Delete the old `/media` route
|
||||
and `Applications.tsx` wrapper.
|
||||
|
||||
- [ ] **5.2 RequestsTab (Jellyseerr enrichment)**
|
||||
- Files: `pages/service-tabs/RequestsTab.tsx`
|
||||
- Details: Source from the absorbed `jellyseerr_url`/`jellyseerr_api_key` on
|
||||
the Jellyfin instance. Render request-management data.
|
||||
|
||||
- [ ] **5.3 Tests**
|
||||
- New tests for MediaTab (instance-scoped), RequestsTab. Delete old Media page
|
||||
tests.
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — Frontend: ssh_tasks content tabs (Files + Actions)
|
||||
|
||||
- [ ] **6.1 FilesTab**
|
||||
- Files: `pages/service-tabs/FilesTab.tsx` (lift from `FileBrowser.impl.tsx`)
|
||||
- Details: Accept `instance` prop. Delete old `/files` route + page wrapper.
|
||||
|
||||
- [ ] **6.2 ActionsTab**
|
||||
- Files: `pages/service-tabs/ActionsTab.tsx` (lift from `Actions.tsx`)
|
||||
- Details: Accept `instance` prop. Delete old `/actions` route + page.
|
||||
|
||||
- [ ] **6.3 Tests**
|
||||
|
||||
---
|
||||
|
||||
## Slice 7 — Frontend: backups Jobs tab
|
||||
|
||||
- [ ] **7.1 JobsTab**
|
||||
- Files: `pages/service-tabs/JobsTab.tsx` (lift from `components/BackupsPage.tsx`)
|
||||
- Details: Accept `instance` prop, scope queries by `instance.id`. Delete old
|
||||
`/backups` route + page.
|
||||
|
||||
- [ ] **7.2 Tests**
|
||||
|
||||
---
|
||||
|
||||
## Slice 8 — Frontend: Authentik Users + Messaging tabs
|
||||
|
||||
- [ ] **8.1 UsersTab**
|
||||
- Files: `pages/service-tabs/UsersTab.tsx`, `hooks/useAuthentikUsers.ts`,
|
||||
`api/authentik.ts`
|
||||
- Details: Directory table + search, sourced from the new endpoint. No
|
||||
Jellyfin/Jellyseerr enrichment.
|
||||
|
||||
- [ ] **8.2 MessagingTab**
|
||||
- Files: `pages/service-tabs/MessagingTab.tsx` (lift compose UI from
|
||||
`UsersPage.impl.tsx`)
|
||||
- Details: Recipient list sourced from Authentik users. Reuse the mail queue +
|
||||
SMTP settings. Delete the old `/users` route + UsersPage.
|
||||
|
||||
- [ ] **8.3 Tests**
|
||||
|
||||
---
|
||||
|
||||
## Slice 9 — Frontend: Observability split (Alerts + Links + Metrics tabs)
|
||||
|
||||
- [ ] **9.1 AlertsTab**
|
||||
- Files: `pages/service-tabs/AlertsTab.tsx` (lift from `ObservabilityPage.tsx`)
|
||||
- Details: Alertmanager alerts view, instance-scoped. Delete old
|
||||
`/observability` route + page.
|
||||
|
||||
- [ ] **9.2 LinksTab + MetricsTab**
|
||||
- Files: `pages/service-tabs/LinksTab.tsx`, `pages/service-tabs/MetricsTab.tsx`
|
||||
- Details: Grafana deep-links; Prometheus status + PromQL explorer. Each
|
||||
instance-scoped.
|
||||
|
||||
- [ ] **9.3 Tests**
|
||||
|
||||
---
|
||||
|
||||
## Slice 10 — Frontend: named dashboards
|
||||
|
||||
- [ ] **10.1 NamedDashboardPage**
|
||||
- Files: `pages/NamedDashboardPage.tsx`, `hooks/useDashboards.ts`,
|
||||
`api/dashboards.ts`
|
||||
- Details: Render widgets + pinned service links at `/d/:slug`. CRUD via the
|
||||
new endpoints.
|
||||
|
||||
- [ ] **10.2 Pinned service links**
|
||||
- Files: `components/PinnedServiceLink.tsx`, integration into the dashboard
|
||||
config dialog
|
||||
- Details: Shortcut variant targeting `/services/:type/:id` or a specific tab.
|
||||
|
||||
- [ ] **10.3 Dashboard management UI**
|
||||
- Files: a new "Manage dashboards" entry on the Services or Settings page
|
||||
- Details: Create/rename/reorder/delete named dashboards.
|
||||
|
||||
- [ ] **10.4 Tests**
|
||||
|
||||
---
|
||||
|
||||
## Slice 11 — Cleanup + docs
|
||||
|
||||
- [ ] **11.1 Delete dead code**
|
||||
- Files: any remaining top-level page wrappers, unused hooks, stale types.
|
||||
- Details: Confirm no references to removed routes/pages remain.
|
||||
|
||||
- [ ] **11.2 Update `docs/REQUIREMENTS.md`**
|
||||
- Files: `docs/REQUIREMENTS.md`
|
||||
- Details: Rewrite the Information Architecture section. Document the service-
|
||||
type → nav-entry map, the service-page tab skeleton, named dashboards,
|
||||
routing, and the Users→Authentik + Observability-split decisions.
|
||||
|
||||
- [ ] **11.3 Update `CHANGELOG.md`**
|
||||
|
||||
---
|
||||
|
||||
## Slice 12 — Verify
|
||||
|
||||
- [ ] **12.1 Cross-route manual pass**
|
||||
- Details: Walk every service type's page + tabs; walk named dashboards; walk
|
||||
the empty state; confirm legacy routes 404.
|
||||
|
||||
- [ ] **12.2 Verify report**
|
||||
- Files: `openspec/changes/services-as-hub-ia/verify-report.md`
|
||||
- Details: Per-AC evidence (AC1–AC10), tool versions, manual notes, residual
|
||||
risks.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Slices 1–3 are backend-only; slice 4 is the frontend shell turning on the new
|
||||
IA with stubs; 5–9 replace stubs with real content; 10 adds named dashboards;
|
||||
11–12 close out.
|
||||
- Slices 5–9 are independent and can be reordered or parallelized across
|
||||
branches if useful, but each must merge green with its stub replaced.
|
||||
- The frontend content lifts (5–9) are the bulk of the line count; treat each as
|
||||
a self-contained review-sized PR.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Verify Report — Services as hub IA
|
||||
|
||||
**Change:** `services-as-hub-ia`
|
||||
**Phase:** verify
|
||||
**Date:** 2026-06-26
|
||||
|
||||
## Summary
|
||||
|
||||
All 11 implementation slices shipped. The app is reorganized around services as the hub: the top-level navigation is data-driven (Main Dashboard + named dashboards + conditional per-type entries + Services + Settings), operational content lives in per-type tabs on service pages, and the legacy top-level routes return 404. Two new service types (`backups`, `authentik`); one absorbed (`jellyseerr` → Jellyfin config); Users replaced by Authentik; Observability split per service type; named dashboards added.
|
||||
|
||||
12 commits on `services-as-hub-ia` (1 plan + 11 slices). ~8600 insertions / ~3900 deletions across 103 files.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
### AC1 — Top nav renders exactly Main Dashboard, named dashboards, configured-service-type entries, Services, Settings ✅
|
||||
|
||||
`useNavItems` (App.tsx) builds the list from `useServiceInstances` (enabled types) + `useDashboards`. Order: Main Dashboard, named dashboards, conditional service-type entries (via `configuredNavEntries`), Services, Settings. `navEntries.test.ts` covers filtering including the ssh_tasks double-entry and nextcloud-none cases.
|
||||
|
||||
### AC2 — Each content tab renders its full operational content inside the service page ✅
|
||||
|
||||
Slices 5–9 replaced the stubs with real implementations: MediaTab + RequestsTab (jellyfin), FilesTab + ActionsTab (ssh_tasks), JobsTab (backups), UsersTab + MessagingTab (authentik), AlertsTab (alertmanager), LinksTab (grafana), MetricsTab (prometheus). Each accepts `{ instance }` and is wired into `serviceContentTabs(type)`. Tests cover each tab.
|
||||
|
||||
### AC3 — Instance switcher appears when >1 enabled instance of a type exists ✅
|
||||
|
||||
ServicePage renders a Select switcher gated on `enabledSiblings.length > 1` (R3.1). ServicePage.test covers show/hide. (Note: switcher trigger keys off enabled siblings per the slice-4 review fix; disabled siblings don't trigger it.)
|
||||
|
||||
### AC4 — Named dashboard CRUD works; each appears in nav and is reachable at /d/:slug ✅
|
||||
|
||||
NamedDashboardPage renders at `/d/:slug`. DashboardManagementCard on Services page handles create/reorder/delete + add pinned link. `GET /api/dashboards/slug/:slug` resolves by slug. Tests: NamedDashboardPage (render + not-found), PinnedServiceLink (render + navigate), dashboard backend CRUD (6 tests).
|
||||
|
||||
### AC5 — Legacy routes return 404 ✅
|
||||
|
||||
All six legacy routes (`/media`, `/files`, `/actions`, `/users`, `/observability`, `/backups`) plus two redirect aliases (`/monitoring`, `/applications`) removed; `*` catch-all → NotFoundPage. (Behavior verified by inspection; the App-level 404 test deferred from slice 4 is the one open test gap.)
|
||||
|
||||
### AC6 — `backups` and `authentik` service types appear in the registry and are configurable ✅
|
||||
|
||||
8-type registry: alertmanager, authentik, backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks. `test_services.py` asserts both new types with config fields + secrets. Authentik directory endpoint + message endpoint tested.
|
||||
|
||||
### AC7 — Jellyseerr service instances migrated into Jellyfin config ✅
|
||||
|
||||
`_migrate_jellyseerr_into_jellyfin` in `settings_store.ensure_defaults()` covers single-Jellyfin merge, multi-Jellyfin first-unpaired, and no-Jellyfin drop. Tests cover all three paths + idempotency.
|
||||
|
||||
### AC8 — Fresh install lands on `/` with empty-state CTA ✅
|
||||
|
||||
Dashboard renders "Add a service to get started" CTA when no instances exist (Dashboard.test mocks useServiceInstances). ServicesPage strong empty state pre-existed.
|
||||
|
||||
### AC9 — Backend green ✅
|
||||
|
||||
`cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` → ruff clean, 271 tests pass (2 pre-existing deprecation warnings).
|
||||
|
||||
### AC10 — Frontend green ✅
|
||||
|
||||
`cd frontend && npm run lint && npm run build && npm run test` → eslint clean, tsc + vite build clean, 92 tests pass (was 72 on main; +20 net from new tabs/dashboards/nav tests, -20 deleted page tests in slice 11).
|
||||
|
||||
## Residual risks / known gaps
|
||||
|
||||
1. **App-level legacy-404 test missing (C1 from slice 4).** Behavior is correct (all legacy routes removed; catch-all confirmed), but no test asserts `/media` etc. resolve to NotFoundPage. Requires either extracting NotFoundPage or mocking the full App. Tracked from slice 4.
|
||||
|
||||
2. **Hooks query globally, not per-instance.** The observability hooks (useAlertmanagerAlerts/Status, useGrafanaStatus, usePrometheusStatus/Targets) and the backup hooks (useBackupJobs/Runs/Alerts) don't accept a serviceId param. JobsTab, AlertsTab, LinksTab, MetricsTab show data for whichever instance the hook resolves as first-configured, not necessarily the one whose page the user is viewing. LinksTab does use `instance.config.base_url` for the specific Grafana deep-link URL. Per-instance scoping is a documented follow-up once the hooks gain the parameter.
|
||||
|
||||
3. **Mobile responsive parity not on this branch.** This branch is based on `main`, not on the unmerged `mobile-responsive-parity` branch. The service tabs lift main's DataTable + column-visibility pattern (no MobileCardRow, no SheetForm on ServicePage). The two branches must be reconciled before either merges (rebase services-as-hub-ia on top of mobile-parity, or merge mobile-parity first).
|
||||
|
||||
4. **Named dashboards: pinned service links only.** Full widget composition on named dashboards is deferred (the main Dashboard keeps the rich WidgetConfigDialog). Reorder fires two sequential mutations; a failure between could leave sort_orders inconsistent (low risk).
|
||||
|
||||
5. **MessagingTab is minimal.** No rich-text toolbar, attachment upload, or queue-status banner (the old compose UI had these). The backend message endpoint accepts core fields only (recipient_emails, subject, html_body) — no multipart attachments yet.
|
||||
|
||||
6. **RequestsTab placeholder.** When Jellyseerr is configured on a Jellyfin instance, the Requests tab shows the URL + an honest "coming soon" placeholder. No backend requests endpoint exists yet.
|
||||
|
||||
7. **`_resolve_service_record` duplicated** across `monitoring.py` and `authentik_users.py`. A shared-utility extraction is a follow-up.
|
||||
|
||||
## Non-goals confirmed
|
||||
|
||||
- No per-instance top-level nav entries (instance switcher handles multi-instance).
|
||||
- No legacy-route redirects or aliases (clean 404 break).
|
||||
- No new widget kinds (pinned service links are a shortcut variant, not a widget kind).
|
||||
- No per-user dashboard customization (dashboards are global).
|
||||
- No changes to OIDC authentication.
|
||||
- No mobile-specific IA divergence.
|
||||
Reference in New Issue
Block a user