Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d2e4c9bfd | |||
| fef0ded76f | |||
| f7f590fa47 | |||
| 01527ae4f0 | |||
| b583d5a365 | |||
| 32fa01cc12 | |||
| ac703eecd2 | |||
| d05de0aacd | |||
| 09b9c45665 | |||
| 32516f6e3b | |||
| 30f1b6e6db | |||
| 7808822a55 | |||
| e805c624b2 | |||
| f7b63fead5 | |||
| 2eb649eceb | |||
| 2076ab76fa | |||
| 2e3e7b3850 | |||
| c447dfe68d | |||
| 688a18af22 | |||
| 18ee77a4e4 |
@@ -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."
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ from typing import Any
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
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.local import LocalCommandClient
|
||||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||||
from media_library_viewer_api.config import get_settings
|
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)
|
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:
|
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||||
"""Build a RemoteSSHClient from a machine config dict."""
|
"""Build a RemoteSSHClient from a machine config dict."""
|
||||||
store = store or get_settings_store()
|
store = store or get_settings_store()
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ from media_library_viewer_api.routers import (
|
|||||||
authentik_users as authentik_users_router,
|
authentik_users as authentik_users_router,
|
||||||
)
|
)
|
||||||
from media_library_viewer_api.routers import backups as backups_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 services as services_router
|
||||||
from media_library_viewer_api.routers import widgets as widgets_router
|
from media_library_viewer_api.routers import widgets as widgets_router
|
||||||
from media_library_viewer_api.routers.settings import router as settings_router
|
from media_library_viewer_api.routers.settings import router as settings_router
|
||||||
@@ -139,11 +140,11 @@ app.include_router(monitoring.router)
|
|||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
app.include_router(users.router)
|
|
||||||
app.include_router(tasks.router)
|
app.include_router(tasks.router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(backups_router.router)
|
app.include_router(backups_router.router)
|
||||||
app.include_router(widgets_router.router)
|
app.include_router(widgets_router.router)
|
||||||
|
app.include_router(dashboards_router.router)
|
||||||
app.include_router(services_router.router)
|
app.include_router(services_router.router)
|
||||||
app.include_router(authentik_users_router.router)
|
app.include_router(authentik_users_router.router)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Authentik directory router — user lookup for the Authentik service page.
|
"""Authentik directory + messaging router.
|
||||||
|
|
||||||
Resolves an ``authentik`` service instance from the registry, builds an
|
Resolves an ``authentik`` service instance from the registry, builds an
|
||||||
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
|
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
|
||||||
proxies a paginated directory query. Graceful "not configured" / "unreachable"
|
proxies paginated directory queries plus message-compose (email enqueue).
|
||||||
payloads (matching the monitoring router's pattern) so the UI always renders.
|
Graceful "not configured" / "unreachable" payloads (matching the monitoring
|
||||||
|
router's pattern) so the UI always renders.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,9 +13,13 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
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.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||||
|
|
||||||
@@ -23,6 +28,14 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
|
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(
|
def _resolve_service_record(
|
||||||
store: SettingsStore,
|
store: SettingsStore,
|
||||||
service_id: str | None = None,
|
service_id: str | None = None,
|
||||||
@@ -80,3 +93,52 @@ def get_authentik_users(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Authentik users query failed for service %s", service_id)
|
logger.exception("Authentik users query failed for service %s", service_id)
|
||||||
return _empty("Authentik is unreachable")
|
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"])
|
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)
|
job = store.get_backup_job_by_name(report.name)
|
||||||
if not job:
|
if not job:
|
||||||
job = store.upsert_backup_job(
|
job = store.upsert_backup_job(
|
||||||
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
|||||||
"source": report.source,
|
"source": report.source,
|
||||||
"target": report.target,
|
"target": report.target,
|
||||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||||
|
"service_id": service_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif report.schedule_interval_seconds:
|
elif report.schedule_interval_seconds:
|
||||||
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
|||||||
"source": report.source,
|
"source": report.source,
|
||||||
"target": report.target,
|
"target": report.target,
|
||||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||||
|
"service_id": service_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
job = store.get_backup_job(job["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")
|
@router.post("/report")
|
||||||
def post_backup_report(
|
def post_backup_report(
|
||||||
report: BackupReportRequest,
|
report: BackupReportRequest,
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
_auth: str = Depends(require_api_key),
|
_auth: str = Depends(require_api_key),
|
||||||
) -> BackupRunResponse:
|
) -> 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)
|
# Check for duplicate (same job + started_at within 1s)
|
||||||
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
|
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
|
||||||
@@ -88,10 +108,12 @@ def post_backup_report(
|
|||||||
@router.post("/report/start")
|
@router.post("/report/start")
|
||||||
def post_backup_start(
|
def post_backup_start(
|
||||||
report: BackupReportRequest,
|
report: BackupReportRequest,
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
_auth: str = Depends(require_api_key),
|
_auth: str = Depends(require_api_key),
|
||||||
) -> BackupRunResponse:
|
) -> 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 = {
|
run_data = {
|
||||||
"job_id": job["id"],
|
"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")
|
@router.get("/instances")
|
||||||
def list_instances(
|
def list_instances(
|
||||||
|
service_id: str | None = None,
|
||||||
|
scope: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return all persisted widget instances."""
|
"""Return widget instances, optionally filtered.
|
||||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
|
||||||
|
- ``?service_id=X``: only widgets for service X.
|
||||||
|
- ``?scope=dashboard``: only widgets with NULL service_id.
|
||||||
|
- ``?scope=service``: only widgets with a non-null service_id.
|
||||||
|
- No params: all widgets (backward-compatible).
|
||||||
|
"""
|
||||||
|
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ class SettingsStore:
|
|||||||
created_at INTEGER NOT NULL
|
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("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS backup_runs (
|
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -247,6 +250,19 @@ class SettingsStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
"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
|
@staticmethod
|
||||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||||
@@ -964,6 +980,7 @@ class SettingsStore:
|
|||||||
"source": row["source"],
|
"source": row["source"],
|
||||||
"target": row["target"],
|
"target": row["target"],
|
||||||
"schedule_interval_seconds": row["schedule_interval_seconds"],
|
"schedule_interval_seconds": row["schedule_interval_seconds"],
|
||||||
|
"service_id": row["service_id"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,12 +999,18 @@ class SettingsStore:
|
|||||||
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
|
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
|
||||||
if schedule_interval_seconds is not None:
|
if schedule_interval_seconds is not None:
|
||||||
schedule_interval_seconds = int(schedule_interval_seconds)
|
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 {
|
return {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"source": source,
|
"source": source,
|
||||||
"target": target,
|
"target": target,
|
||||||
"schedule_interval_seconds": schedule_interval_seconds,
|
"schedule_interval_seconds": schedule_interval_seconds,
|
||||||
|
"service_id": service_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
|
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
|
||||||
@@ -1005,17 +1028,19 @@ class SettingsStore:
|
|||||||
created_at = int(existing[0]) if existing else now
|
created_at = int(existing[0]) if existing else now
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
|
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
target = excluded.target,
|
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
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
target = excluded.target,
|
target = excluded.target,
|
||||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||||
|
service_id = excluded.service_id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
job["id"],
|
job["id"],
|
||||||
@@ -1023,6 +1048,7 @@ class SettingsStore:
|
|||||||
job["source"],
|
job["source"],
|
||||||
job["target"],
|
job["target"],
|
||||||
job["schedule_interval_seconds"],
|
job["schedule_interval_seconds"],
|
||||||
|
job["service_id"],
|
||||||
created_at,
|
created_at,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1377,10 +1403,36 @@ class SettingsStore:
|
|||||||
"sort_order": sort_order,
|
"sort_order": sort_order,
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_widgets(self) -> list[dict[str, Any]]:
|
def list_widgets(
|
||||||
|
self,
|
||||||
|
service_id: str | None = None,
|
||||||
|
*,
|
||||||
|
scope: str | None = None,
|
||||||
|
all_widgets: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""List widget instances, optionally filtered.
|
||||||
|
|
||||||
|
- ``service_id=X``: only widgets for service X.
|
||||||
|
- ``scope="dashboard"``: only widgets with NULL service_id.
|
||||||
|
- ``scope="service"``: only widgets with a non-null service_id.
|
||||||
|
- ``all_widgets=True, service_id=None, scope=None``: all widgets.
|
||||||
|
"""
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if service_id is not None:
|
||||||
|
clauses.append("service_id = ?")
|
||||||
|
params.append(service_id)
|
||||||
|
if scope == "dashboard":
|
||||||
|
clauses.append("service_id IS NULL")
|
||||||
|
elif scope == "service":
|
||||||
|
clauses.append("service_id IS NOT NULL")
|
||||||
|
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
return [self._row_to_widget(row) for row in rows]
|
return [self._row_to_widget(row) for row in rows]
|
||||||
|
|
||||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||||
@@ -1638,6 +1690,113 @@ class SettingsStore:
|
|||||||
for row in rows
|
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
|
_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.clients.ssh import CommandResult
|
||||||
from media_library_viewer_api.dependencies import (
|
from media_library_viewer_api.dependencies import (
|
||||||
get_jellyfin_client,
|
get_jellyfin_client,
|
||||||
get_jellyseerr_client,
|
|
||||||
get_mail_queue,
|
|
||||||
get_settings_store,
|
get_settings_store,
|
||||||
get_ssh_client,
|
get_ssh_client,
|
||||||
get_user_id,
|
get_user_id,
|
||||||
@@ -70,38 +68,6 @@ def mock_jellyfin():
|
|||||||
return client
|
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
|
@pytest.fixture
|
||||||
def mock_ssh():
|
def mock_ssh():
|
||||||
"""Mock SSH client."""
|
"""Mock SSH client."""
|
||||||
@@ -132,10 +98,9 @@ def mock_ssh():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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."""
|
"""FastAPI test client with mocked dependencies."""
|
||||||
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
|
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_ssh_client] = lambda: mock_ssh
|
||||||
app.dependency_overrides[get_user_id] = lambda: "user123"
|
app.dependency_overrides[get_user_id] = lambda: "user123"
|
||||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
@@ -293,122 +258,6 @@ class TestSettingsReset:
|
|||||||
assert len(store.list_machines()) == 0
|
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 ---
|
# --- Files ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -90,6 +90,44 @@ def test_create_backups_widget(client):
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_filtering_by_service_id_and_scope(client):
|
||||||
|
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
|
||||||
|
service = _make_grafana_service(client)
|
||||||
|
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
|
||||||
|
client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "link",
|
||||||
|
"title": "Dash",
|
||||||
|
"config": {"dashboard_uid": "o"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# No filter: both widgets.
|
||||||
|
all_widgets = client.get("/api/widgets/instances").json()
|
||||||
|
assert len(all_widgets) == 2
|
||||||
|
|
||||||
|
# Filter by service_id: only the service-scoped one.
|
||||||
|
by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
|
||||||
|
assert len(by_service) == 1
|
||||||
|
assert by_service[0]["service_id"] == service["id"]
|
||||||
|
|
||||||
|
# scope=dashboard: only the built-in (NULL service_id).
|
||||||
|
dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
|
||||||
|
assert len(dashboard_scope) == 1
|
||||||
|
assert dashboard_scope[0]["service_id"] is None
|
||||||
|
|
||||||
|
# scope=service: only the non-null service_id widget.
|
||||||
|
service_scope = client.get("/api/widgets/instances?scope=service").json()
|
||||||
|
assert len(service_scope) == 1
|
||||||
|
assert service_scope[0]["service_id"] == service["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_builtin_kind_rejected(client):
|
def test_unknown_builtin_kind_rejected(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
|
|||||||
@@ -468,3 +468,57 @@ The system receives backup execution reports from an external backup tool via HT
|
|||||||
|
|
||||||
- Backup tool uses auto-generated Bearer API key
|
- Backup tool uses auto-generated Bearer API key
|
||||||
- Frontend uses existing OIDC/JWT auth
|
- Frontend uses existing OIDC/JWT auth
|
||||||
|
|
||||||
|
## Mobile Responsive Design
|
||||||
|
|
||||||
|
The frontend is fully operable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) breakpoint. Tablets and wider viewports use the desktop layout
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### Breakpoint policy
|
||||||
|
|
||||||
|
- Single responsive cut: `md:` (768px). Below is "mobile"; at-or-above is
|
||||||
|
"desktop" (existing layout, unchanged).
|
||||||
|
- `useIsMobile()` hook (`frontend/src/hooks/useIsMobile.ts`) is the single
|
||||||
|
source of truth; it wraps `matchMedia("(max-width: 768px)")` and is SSR-safe.
|
||||||
|
- No `sm:` intermediate cut. No PWA, manifest, or service worker.
|
||||||
|
|
||||||
|
### Data tables (hybrid)
|
||||||
|
|
||||||
|
- The four wide tables (Media, FileBrowser, Users, Backups) render stacked
|
||||||
|
**cards per row** below `md` via `MobileCardRow`, each showing a primary
|
||||||
|
title plus 3–5 key fields. Narrow tables (SessionActivity) keep horizontal
|
||||||
|
scroll. The TanStack column-visibility toggle is hidden below `md`.
|
||||||
|
- At `md:` and above, all tables render as the existing `<DataTable>` unchanged.
|
||||||
|
|
||||||
|
### Edit forms (Sheet)
|
||||||
|
|
||||||
|
- Below `md`, ServicePage, Settings (machine editor), message compose, and
|
||||||
|
WidgetConfigDialog open inside a full-height `SheetForm` (side=bottom,
|
||||||
|
`h-[100dvh]`) with sticky header + sticky save bar instead of a centered
|
||||||
|
Dialog.
|
||||||
|
- At `md:` and above, the existing Dialog-based forms are unchanged.
|
||||||
|
|
||||||
|
### Touch targets
|
||||||
|
|
||||||
|
- All interactive elements below `md` have a minimum 44×44px hit area via the
|
||||||
|
`.mobile-touch-target` CSS utility (applied only below 768px). This covers
|
||||||
|
icon buttons, checkboxes, switches, and small text buttons. The class is a
|
||||||
|
no-op at `md:` and above.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Below `md`, the widget grid collapses to a single column with a section
|
||||||
|
anchor bar (Observability / Media / Backups / Custom) for quick navigation.
|
||||||
|
- At `md:` and above, the existing multi-widget grid is unchanged.
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
|
||||||
|
- Widget refresh intervals and the message-queue poll interval are identical
|
||||||
|
on mobile and desktop. A follow-up to pause refetch when the tab is hidden
|
||||||
|
(`document.visibilityState`) is tracked as a future battery optimization.
|
||||||
|
|
||||||
|
### `HoverEditButton`
|
||||||
|
|
||||||
|
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
|
||||||
|
and above, the desktop hover-reveal aesthetic is preserved.
|
||||||
|
|||||||
+77
-64
@@ -5,7 +5,6 @@ import {
|
|||||||
NavLink,
|
NavLink,
|
||||||
useLocation,
|
useLocation,
|
||||||
Outlet,
|
Outlet,
|
||||||
Navigate,
|
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
QueryClient,
|
QueryClient,
|
||||||
@@ -13,21 +12,22 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { AuthProvider, useAuth } from "react-oidc-context";
|
import { AuthProvider, useAuth } from "react-oidc-context";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Applications } from "./pages/Applications";
|
import { NamedDashboardPage } from "./pages/NamedDashboardPage";
|
||||||
import { Settings } from "./pages/Settings";
|
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 { ServicePage } from "./pages/ServicePage";
|
||||||
|
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
||||||
import { ServicesPage } from "./pages/ServicesPage";
|
import { ServicesPage } from "./pages/ServicesPage";
|
||||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
import { usePersistentState } from "./hooks/usePersistentState";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -44,12 +44,6 @@ import {
|
|||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Activity,
|
|
||||||
DatabaseBackup,
|
|
||||||
Monitor,
|
|
||||||
Users,
|
|
||||||
Zap,
|
|
||||||
FolderOpen,
|
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Menu,
|
Menu,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -58,10 +52,17 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Boxes,
|
Boxes,
|
||||||
|
LayoutTemplate,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function useDarkMode() {
|
function useDarkMode() {
|
||||||
@@ -82,18 +83,40 @@ function useDarkMode() {
|
|||||||
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
|
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation items for sidebar
|
// Navigation items are data-driven (spec R1). Built from configured services + dashboards.
|
||||||
const navItems = [
|
interface NavItem {
|
||||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
path: string;
|
||||||
{ path: "/observability", label: "Observability", icon: Activity },
|
label: string;
|
||||||
{ path: "/media", label: "Media", icon: Monitor },
|
icon: LucideIcon;
|
||||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
}
|
||||||
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
|
||||||
{ path: "/users", label: "Users", icon: Users },
|
function useNavItems() {
|
||||||
{ path: "/actions", label: "Actions", icon: Zap },
|
const { data: services = [] } = useServiceInstances();
|
||||||
{ path: "/services", label: "Services", icon: Boxes },
|
const { data: dashboards = [] } = useDashboards();
|
||||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
|
||||||
];
|
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({
|
function Sidebar({
|
||||||
collapsed,
|
collapsed,
|
||||||
@@ -105,6 +128,7 @@ function Sidebar({
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navItems = useNavItems();
|
||||||
|
|
||||||
if (isMobile) return null;
|
if (isMobile) return null;
|
||||||
|
|
||||||
@@ -189,6 +213,7 @@ function Sidebar({
|
|||||||
function MobileDrawer() {
|
function MobileDrawer() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navItems = useNavItems();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
@@ -253,6 +278,7 @@ function TopBar({
|
|||||||
});
|
});
|
||||||
const backendLabel = appVersion?.backend_label || "…";
|
const backendLabel = appVersion?.backend_label || "…";
|
||||||
|
|
||||||
|
const navItems = useNavItems();
|
||||||
const pageTitle =
|
const pageTitle =
|
||||||
navItems.find((item) => item.path === location.pathname)?.label ||
|
navItems.find((item) => item.path === location.pathname)?.label ||
|
||||||
"Dashboard";
|
"Dashboard";
|
||||||
@@ -316,16 +342,7 @@ function ShellLayout({
|
|||||||
onToggleDarkMode: () => void;
|
onToggleDarkMode: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(
|
const isMobile = useIsMobile();
|
||||||
() => window.matchMedia("(max-width: 768px)").matches,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia("(max-width: 768px)");
|
|
||||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
|
||||||
mql.addEventListener("change", handler);
|
|
||||||
return () => mql.removeEventListener("change", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@@ -427,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() {
|
function AppInner() {
|
||||||
const [darkMode, toggleDarkMode] = useDarkMode();
|
const [darkMode, toggleDarkMode] = useDarkMode();
|
||||||
|
|
||||||
@@ -438,26 +467,18 @@ function AppInner() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AuthenticatedApp />}>
|
<Route element={<AuthenticatedApp />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
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="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/services/:serviceType"
|
||||||
|
element={<ServiceTypePage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/services/:serviceType/:serviceId"
|
path="/services/:serviceType/:serviceId"
|
||||||
element={<ServicePage />}
|
element={<ServicePage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
@@ -474,26 +495,18 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route
|
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||||
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="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/services/:serviceType"
|
||||||
|
element={<ServiceTypePage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/services/:serviceType/:serviceId"
|
path="/services/:serviceType/:serviceId"
|
||||||
element={<ServicePage />}
|
element={<ServicePage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</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");
|
return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
export async function fetchWidgetInstances(
|
||||||
return get<WidgetInstance[]>("/api/widgets/instances");
|
serviceId?: string,
|
||||||
|
scope?: "dashboard" | "service",
|
||||||
|
): Promise<WidgetInstance[]> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (serviceId) params.service_id = serviceId;
|
||||||
|
if (scope) params.scope = scope;
|
||||||
|
return get<WidgetInstance[]>("/api/widgets/instances", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWidgetInstance(
|
export async function createWidgetInstance(
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupAlert } from "../types/backups";
|
import type { BackupAlert } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,7 +34,51 @@ function severityVariant(severity: string): SeverityVariant {
|
|||||||
return severity === "critical" ? "destructive" : "warning";
|
return severity === "critical" ? "destructive" : "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): message is the primary identifier;
|
||||||
|
// severity/type/created give the at-a-glance info. See OpenSpec change
|
||||||
|
// `mobile-responsive-parity`, tasks slice 5.2.
|
||||||
|
const alertCardFields: MobileCardField<BackupAlert>[] = [
|
||||||
|
{ key: "message", label: "Message", render: (a) => a.message, primary: true },
|
||||||
|
{
|
||||||
|
key: "severity",
|
||||||
|
label: "Severity",
|
||||||
|
render: (a) => (
|
||||||
|
<Badge variant={severityVariant(a.severity)}>{a.severity}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "type", label: "Type", render: (a) => a.alert_type },
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
label: "Created",
|
||||||
|
render: (a) => formatTimestamp(a.created_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<MobileCardRow
|
||||||
|
rows={alerts}
|
||||||
|
fields={alertCardFields}
|
||||||
|
getRowId={(a) => a.id}
|
||||||
|
actions={(a) =>
|
||||||
|
!a.acknowledged ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => onAcknowledge(a.id)}
|
||||||
|
>
|
||||||
|
Ack
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table aria-label="Backup alerts">
|
<Table aria-label="Backup alerts">
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupJob, BackupRun } from "../types/backups";
|
import type { BackupJob, BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,7 +46,54 @@ function statusVariant(status: string): StatusVariant {
|
|||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): job name is primary; source/schedule/status
|
||||||
|
// give at-a-glance context. See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
interface JobCardRow {
|
||||||
|
job: BackupJob;
|
||||||
|
status: string;
|
||||||
|
run_started: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobCardFields: MobileCardField<JobCardRow>[] = [
|
||||||
|
{ key: "name", label: "Name", render: (r) => r.job.name, primary: true },
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
label: "Source",
|
||||||
|
render: (r) => r.job.source ?? "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "schedule",
|
||||||
|
label: "Schedule",
|
||||||
|
render: (r) => formatInterval(r.job.schedule_interval_seconds),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Last status",
|
||||||
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const cardRows: JobCardRow[] = jobs.map((job) => {
|
||||||
|
const run = latestRuns.get(job.id);
|
||||||
|
return {
|
||||||
|
job,
|
||||||
|
status: run?.status ?? "unknown",
|
||||||
|
run_started: run?.started_at ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MobileCardRow
|
||||||
|
rows={cardRows}
|
||||||
|
fields={jobCardFields}
|
||||||
|
getRowId={(r) => r.job.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table aria-label="Backup jobs">
|
<Table aria-label="Backup jobs">
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
import type { BackupRun } from "../types/backups";
|
import type { BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -55,8 +60,35 @@ function statusVariant(status: string): StatusVariant {
|
|||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card fields (spec R3.2): job_id is primary; status/duration/size/
|
||||||
|
// started give the at-a-glance info. See OpenSpec change `mobile-responsive-parity`.
|
||||||
|
const runCardFields: MobileCardField<BackupRun>[] = [
|
||||||
|
{ key: "job", label: "Job", render: (r) => r.job_id, primary: true },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
label: "Duration",
|
||||||
|
render: (r) => formatDuration(r.duration_ms),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "size",
|
||||||
|
label: "Size",
|
||||||
|
render: (r) => formatBytes(r.bytes_transferred),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "started",
|
||||||
|
label: "Started",
|
||||||
|
render: (r) => formatTimestamp(r.started_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BackupRunsTable({ runs }: Props) {
|
export default function BackupRunsTable({ runs }: Props) {
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const filteredRuns =
|
const filteredRuns =
|
||||||
statusFilter === "all"
|
statusFilter === "all"
|
||||||
@@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
{isMobile ? (
|
||||||
<Table aria-label="Backup runs">
|
<MobileCardRow
|
||||||
<TableHeader>
|
rows={filteredRuns}
|
||||||
<TableRow className="bg-card hover:bg-card">
|
fields={runCardFields}
|
||||||
<TableHead>Job</TableHead>
|
getRowId={(r) => r.id}
|
||||||
<TableHead>Status</TableHead>
|
/>
|
||||||
<TableHead>Duration</TableHead>
|
) : (
|
||||||
<TableHead>Size</TableHead>
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<TableHead>Started</TableHead>
|
<Table aria-label="Backup runs">
|
||||||
</TableRow>
|
<TableHeader>
|
||||||
</TableHeader>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableBody>
|
<TableHead>Job</TableHead>
|
||||||
{filteredRuns.map((run) => (
|
<TableHead>Status</TableHead>
|
||||||
<TableRow key={run.id}>
|
<TableHead>Duration</TableHead>
|
||||||
<TableCell>{run.job_id}</TableCell>
|
<TableHead>Size</TableHead>
|
||||||
<TableCell>
|
<TableHead>Started</TableHead>
|
||||||
<Badge variant={statusVariant(run.status)}>
|
|
||||||
{run.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
|
||||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
|
||||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filteredRuns.map((run) => (
|
||||||
</div>
|
<TableRow key={run.id}>
|
||||||
|
<TableCell>{run.job_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(run.status)}>
|
||||||
|
{run.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||||
|
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||||
|
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export function DialogFooter({
|
|||||||
}: DialogFooterProps) {
|
}: DialogFooterProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={onCancel}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{secondaryAction ? (
|
{secondaryAction ? (
|
||||||
@@ -59,6 +63,7 @@ export function DialogFooter({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
||||||
disabled={confirmDisabled}
|
disabled={confirmDisabled}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
|||||||
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
|
|||||||
interface HoverEditButtonProps {
|
interface HoverEditButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Controls visibility below the `md:` (768px) breakpoint.
|
||||||
|
*
|
||||||
|
* - `always` (default): the button is always visible on mobile/touch.
|
||||||
|
* - `hover`: keep the legacy opacity-0-everywhere behavior.
|
||||||
|
*
|
||||||
|
* At `md:` and above the hover-reveal aesthetic is always preserved
|
||||||
|
* (`md:opacity-0 md:group-hover:opacity-100`), so desktop is not regressed.
|
||||||
|
* See OpenSpec change `mobile-responsive-parity`, spec R5. */
|
||||||
|
mobile?: "always" | "hover";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hover-to-reveal edit affordance.
|
* Hover-to-reveal edit affordance (desktop) / always-visible (mobile).
|
||||||
*
|
*
|
||||||
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
|
* Keeps the `rail-edit` class plus the opacity base + transition so the
|
||||||
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
||||||
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
||||||
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
|
*
|
||||||
* + lucide `Pencil`. Same exported props/display name.
|
* Mobile behavior (`mobile="always"`, the default): the button is visible by
|
||||||
|
* default below `md` because hover does not fire on touch. The hover-reveal
|
||||||
|
* aesthetic is layered back on at `md:` and above via `md:opacity-0
|
||||||
|
* md:group-hover:opacity-100`. MUI IconButton + EditOutlined → shadcn `Button
|
||||||
|
* variant="ghost" size="icon-sm"` + lucide `Pencil`. Same exported props/display
|
||||||
|
* name. See OpenSpec change `mobile-responsive-parity`, spec R5.
|
||||||
*/
|
*/
|
||||||
export function HoverEditButton({
|
export function HoverEditButton({
|
||||||
onClick,
|
onClick,
|
||||||
label = "Edit",
|
label = "Edit",
|
||||||
|
mobile = "always",
|
||||||
}: HoverEditButtonProps) {
|
}: HoverEditButtonProps) {
|
||||||
|
// Legacy mode: opacity-0 everywhere, revealed by group hover (the consuming
|
||||||
|
// row supplies `group`).
|
||||||
|
const hoverClasses =
|
||||||
|
mobile === "hover"
|
||||||
|
? "opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100"
|
||||||
|
: "md:opacity-0 md:transition-opacity md:duration-100 md:ease-out md:group-hover:opacity-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
className={`rail-edit text-muted-foreground mobile-touch-target ${hoverClasses}`}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -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" 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" 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" 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" 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" 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;
|
||||||
|
}
|
||||||
@@ -168,6 +168,7 @@ export function SessionActivityPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelectSession(session);
|
onSelectSession(session);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
} from "../hooks/useWidgets";
|
} from "../hooks/useWidgets";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useTasks } from "../hooks/useSettings";
|
import { useTasks } from "../hooks/useSettings";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||||
import {
|
import {
|
||||||
BUILTIN_WIDGETS,
|
BUILTIN_WIDGETS,
|
||||||
@@ -277,201 +279,220 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
|||||||
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||||
: BUILTIN_WIDGETS[draft.widgetKind]
|
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const isTaskOutput =
|
const isTaskOutput =
|
||||||
draft?.serviceId !== null &&
|
draft?.serviceId !== null &&
|
||||||
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||||
"ssh_tasks";
|
"ssh_tasks";
|
||||||
|
|
||||||
|
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
|
||||||
|
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
|
||||||
|
// Back/Save buttons are omitted because the SheetForm footer provides them.
|
||||||
|
const draftBody = draft ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Title" htmlFor="widget-title">
|
||||||
|
<Input
|
||||||
|
id="widget-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||||
|
<Input
|
||||||
|
id="widget-sort-order"
|
||||||
|
type="number"
|
||||||
|
value={String(draft.sortOrder)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="widget-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<WidgetConfigEditor
|
||||||
|
binding={draftBinding}
|
||||||
|
isTaskOutput={!!isTaskOutput}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
tasks={tasks}
|
||||||
|
/>
|
||||||
|
{!isMobile ? (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={reset} className="mobile-touch-target">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveDraft} disabled={saveWidget.isPending} className="mobile-touch-target">
|
||||||
|
Save widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{sortedInstances.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sortedInstances.map((instance, index) => {
|
||||||
|
const serviceName = instance.service_id
|
||||||
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
|
: "Built-in";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className="flex items-center gap-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{instance.title}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{bindingLabel(instance.service_id, instance.widget_kind)}
|
||||||
|
</Badge>
|
||||||
|
{serviceName ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!instance.enabled ? (
|
||||||
|
<Badge variant="secondary">disabled</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveInstance(index, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
disabled={index === sortedInstances.length - 1}
|
||||||
|
onClick={() => moveInstance(index, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
className="mobile-touch-target"
|
||||||
|
checked={instance.enabled}
|
||||||
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
|
aria-label={`Toggle ${instance.title}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8"
|
||||||
|
onClick={() => startEdit(instance)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mobile-touch-target h-8 w-8 text-destructive"
|
||||||
|
onClick={() => removeInstance(instance)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
||||||
|
<Button
|
||||||
|
key={b.kind}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => startAddBuiltIn(b.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{b.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{services
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.flatMap((s) =>
|
||||||
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
|
||||||
|
<Button
|
||||||
|
key={`${s.id}:${w.kind}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() => startAddService(s.id, w.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{w.name} · {s.name}
|
||||||
|
</Button>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure services on their service pages to unlock more widgets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogTitle = draft
|
||||||
|
? draft.id
|
||||||
|
? "Edit widget"
|
||||||
|
: "Add widget"
|
||||||
|
: "Dashboard widgets";
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<SheetForm
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) handleClose(next);
|
||||||
|
}}
|
||||||
|
title={dialogTitle}
|
||||||
|
onSave={draft ? saveDraft : () => handleClose(false)}
|
||||||
|
onCancel={draft ? reset : () => handleClose(false)}
|
||||||
|
saveLabel={draft ? "Save widget" : "Done"}
|
||||||
|
isPending={draft ? saveWidget.isPending : false}
|
||||||
|
isDirty={draft !== null}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">{draftBody}</div>
|
||||||
|
</SheetForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
{draft
|
|
||||||
? draft.id
|
|
||||||
? "Edit widget"
|
|
||||||
: "Add widget"
|
|
||||||
: "Dashboard widgets"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{draftBody}
|
||||||
{draft ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<Field label="Title" htmlFor="widget-title">
|
|
||||||
<Input
|
|
||||||
id="widget-title"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({ ...draft, title: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Sort order" htmlFor="widget-sort-order">
|
|
||||||
<Input
|
|
||||||
id="widget-sort-order"
|
|
||||||
type="number"
|
|
||||||
value={String(draft.sortOrder)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({
|
|
||||||
...draft,
|
|
||||||
sortOrder:
|
|
||||||
e.target.value === "" ? 0 : Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="widget-enabled"
|
|
||||||
checked={draft.enabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setDraft({ ...draft, enabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="widget-enabled">Enabled</Label>
|
|
||||||
</div>
|
|
||||||
<WidgetConfigEditor
|
|
||||||
binding={draftBinding}
|
|
||||||
isTaskOutput={!!isTaskOutput}
|
|
||||||
config={draft.config}
|
|
||||||
onChange={(config) => setDraft({ ...draft, config })}
|
|
||||||
tasks={tasks}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={reset}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
|
||||||
Save widget
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{sortedInstances.length === 0 ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
No widgets yet. Add one below.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{sortedInstances.map((instance, index) => {
|
|
||||||
const serviceName = instance.service_id
|
|
||||||
? services.find((s) => s.id === instance.service_id)?.name
|
|
||||||
: "Built-in";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={instance.id}
|
|
||||||
className="flex items-center gap-2 rounded border p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{instance.title}</span>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{bindingLabel(
|
|
||||||
instance.service_id,
|
|
||||||
instance.widget_kind,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
{serviceName ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{serviceName}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{!instance.enabled ? (
|
|
||||||
<Badge variant="secondary">disabled</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => moveInstance(index, -1)}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
disabled={index === sortedInstances.length - 1}
|
|
||||||
onClick={() => moveInstance(index, 1)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Switch
|
|
||||||
checked={instance.enabled}
|
|
||||||
onCheckedChange={() => toggleEnabled(instance)}
|
|
||||||
aria-label={`Toggle ${instance.title}`}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => startEdit(instance)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive"
|
|
||||||
onClick={() => removeInstance(instance)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm font-medium">Add widget</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
|
||||||
<Button
|
|
||||||
key={b.kind}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddBuiltIn(b.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{b.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{services
|
|
||||||
.filter((s) => s.enabled)
|
|
||||||
.flatMap((s) =>
|
|
||||||
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
|
||||||
(w) => (
|
|
||||||
<Button
|
|
||||||
key={`${s.id}:${w.kind}`}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => startAddService(s.id, w.kind)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
{w.name} · {s.name}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Configure services on their service pages to unlock more
|
|
||||||
widgets.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import BackupAlertsTable from "../BackupAlertsTable";
|
import BackupAlertsTable from "../BackupAlertsTable";
|
||||||
@@ -61,3 +61,46 @@ describe("BackupAlertsTable", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupAlertsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with message as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "m1", message: "Disk full" })]}
|
||||||
|
onAcknowledge={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Disk full")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Severity")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders acknowledge action on card below md", async () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
const onAck = vi.fn();
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "a1", acknowledged: false })]}
|
||||||
|
onAcknowledge={onAck}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Ack" }));
|
||||||
|
expect(onAck).toHaveBeenCalledWith("a1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import BackupJobsTable from "../BackupJobsTable";
|
||||||
|
import type { BackupJob, BackupRun } from "../../types/backups";
|
||||||
|
|
||||||
|
function job(overrides: Partial<BackupJob> = {}): BackupJob {
|
||||||
|
return {
|
||||||
|
id: "j1",
|
||||||
|
name: "nightly",
|
||||||
|
source: "/data",
|
||||||
|
target: "s3://bucket",
|
||||||
|
schedule_interval_seconds: 86400,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(overrides: Partial<BackupRun> = {}): BackupRun {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
job_id: "j1",
|
||||||
|
started_at: 1_700_000_000,
|
||||||
|
ended_at: null,
|
||||||
|
status: "success",
|
||||||
|
bytes_transferred: 2048,
|
||||||
|
duration_ms: 1500,
|
||||||
|
error_message: null,
|
||||||
|
details_json: null,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom lacks matchMedia; default to desktop so the table renders.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupJobsTable (desktop)", () => {
|
||||||
|
it("renders job name and schedule interval", () => {
|
||||||
|
render(
|
||||||
|
<BackupJobsTable
|
||||||
|
jobs={[job({ name: "nightly", schedule_interval_seconds: 86400 })]}
|
||||||
|
latestRuns={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1d")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupJobsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with job name as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(
|
||||||
|
<BackupJobsTable
|
||||||
|
jobs={[job({ id: "j1", name: "nightly", source: "/data" })]}
|
||||||
|
latestRuns={
|
||||||
|
new Map([["j1", run({ status: "success" })]]) as Map<string, BackupRun>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Source")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Schedule")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Last status")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import BackupRunsTable from "../BackupRunsTable";
|
import BackupRunsTable from "../BackupRunsTable";
|
||||||
import type { BackupRun } from "../../types/backups";
|
import type { BackupRun } from "../../types/backups";
|
||||||
@@ -57,3 +57,29 @@ describe("BackupRunsTable", () => {
|
|||||||
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// jsdom lacks 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("BackupRunsTable (mobile card layout — slice 5)", () => {
|
||||||
|
it("renders cards with job_id as primary below md", () => {
|
||||||
|
setMatchMedia(true);
|
||||||
|
render(<BackupRunsTable runs={[run({ id: "r1", job_id: "nightly" })]} />);
|
||||||
|
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Status")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Duration")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,4 +18,23 @@ describe("HoverEditButton", () => {
|
|||||||
screen.getByRole("button", { name: "Rename machine" }),
|
screen.getByRole("button", { name: "Rename machine" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to always-visible below md (mobile="always")', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
const tokens = button.className.split(/\s+/);
|
||||||
|
// The default mobile mode layers hover-reveal only at md+ via
|
||||||
|
// md:opacity-0/md:group-hover:opacity-100, so the button is visible by
|
||||||
|
// default below md (no base opacity-0 token).
|
||||||
|
expect(tokens).toContain("md:opacity-0");
|
||||||
|
expect(tokens).toContain("md:group-hover:opacity-100");
|
||||||
|
expect(tokens).not.toContain("opacity-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the legacy opacity-0 behavior when mobile="hover"', () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} mobile="hover" />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
expect(button.className).toContain("opacity-0");
|
||||||
|
expect(button.className).toContain("group-hover:opacity-100");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { WidgetConfigDialog } from "../WidgetConfigDialog";
|
||||||
|
|
||||||
|
// jsdom has no window.matchMedia; 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("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useTasks: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => setMatchMedia(false));
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (desktop)", () => {
|
||||||
|
it("renders a Dialog with the dashboard widgets title at md+", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Dashboard widgets" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WidgetConfigDialog (mobile SheetForm — slice 8)", () => {
|
||||||
|
beforeEach(() => setMatchMedia(true));
|
||||||
|
|
||||||
|
it("renders a SheetForm with the dashboard widgets title below md", () => {
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText("Dashboard widgets")).toBeInTheDocument();
|
||||||
|
// List mode footer: "Done" button closes.
|
||||||
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts before discarding a widget draft (R4.5)", async () => {
|
||||||
|
const { userEvent } = await import("@testing-library/user-event");
|
||||||
|
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||||
|
|
||||||
|
// Enter draft mode by clicking an "Add widget" button.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Backups/i }));
|
||||||
|
|
||||||
|
// Now in draft mode — Cancel should prompt before resetting.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MobileCardRow, type MobileCardField } from "../mobile-card";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
size: string;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: "a", title: "Movie A", size: "4.2GB", year: 2026 },
|
||||||
|
{ id: "b", title: "Movie B", size: "2.1GB", year: 2025 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fields: MobileCardField<Row>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size },
|
||||||
|
{ key: "year", label: "Year", render: (r) => r.year },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("MobileCardRow", () => {
|
||||||
|
it("renders the primary field as a title and the rest as key/value pairs", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
|
||||||
|
// Primary title
|
||||||
|
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Movie B")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Field labels and values (appear once per row)
|
||||||
|
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("4.2GB")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Year")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("2026")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onRowClick when the card is tapped", async () => {
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Movie A"));
|
||||||
|
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the actions slot per row", () => {
|
||||||
|
render(
|
||||||
|
<MobileCardRow
|
||||||
|
rows={rows}
|
||||||
|
fields={fields}
|
||||||
|
actions={(r) => (
|
||||||
|
<button type="button" onClick={() => undefined}>
|
||||||
|
edit-{r.id}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("edit-a")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("edit-b")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a non-interactive card when onRowClick is absent", () => {
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||||
|
// No buttons wrapping the cards.
|
||||||
|
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||||
|
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when rows is empty", () => {
|
||||||
|
const { container } = render(<MobileCardRow rows={[]} fields={fields} />);
|
||||||
|
const cards = container.querySelector(".flex.flex-col.gap-2");
|
||||||
|
expect(cards?.children).toHaveLength(0);
|
||||||
|
expect(screen.queryByText("Size")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a card without a title when no primary field is set", () => {
|
||||||
|
const noPrimary: MobileCardField<Row>[] = fields.filter(
|
||||||
|
(f) => f.key !== "title",
|
||||||
|
);
|
||||||
|
render(<MobileCardRow rows={rows} fields={noPrimary} />);
|
||||||
|
// No title text rendered, but the key/value stack still is.
|
||||||
|
expect(screen.queryByText("Movie A")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses getRowId for stable keys and emits no duplicate-key warning", () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
render(<MobileCardRow rows={rows} fields={fields} getRowId={(r) => r.id} />);
|
||||||
|
// No React duplicate-key warning should fire.
|
||||||
|
const duplicateKeyCalls = errorSpy.mock.calls.filter((args) =>
|
||||||
|
String(args[0] ?? "").includes("same key"),
|
||||||
|
);
|
||||||
|
expect(duplicateKeyCalls).toHaveLength(0);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SheetForm } from "../sheet-form";
|
||||||
|
|
||||||
|
describe("SheetForm", () => {
|
||||||
|
it("renders the title and children", () => {
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit service"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<input aria-label="Name" />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Edit service")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSave when Save is clicked", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={() => {}}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel when Cancel is clicked", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Save and shows a pending label when isPending", () => {
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
isPending
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: /Saving/i });
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
expect(screen.getByText("Saving…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel when the close (X) button is clicked", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirty-state confirm (R4.5)", () => {
|
||||||
|
it("prompts before discarding via Cancel when isDirty", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
isDirty
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel does not immediately close; a confirm opens.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Confirm discard -> actually closes.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing the confirm without discarding keeps the form open", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
isDirty
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
// Two Cancel buttons now exist: the SheetForm footer and the confirm dialog.
|
||||||
|
const cancelButtons = screen.getAllByRole("button", { name: "Cancel" });
|
||||||
|
await userEvent.click(cancelButtons[cancelButtons.length - 1]);
|
||||||
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes immediately when not dirty", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<SheetForm
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="Edit"
|
||||||
|
onSave={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</SheetForm>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Discard changes?" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type Table as TableInstance,
|
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -18,6 +17,7 @@ import { Columns3 } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { TablePagination } from "@/components/ui/table-pagination";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,13 +34,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue = unknown> {
|
export interface DataTableProps<TData, TValue = unknown> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
@@ -253,90 +246,19 @@ export function DataTable<TData, TValue = unknown>({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enablePagination && (
|
{enablePagination && (
|
||||||
<DataTablePagination
|
<TablePagination
|
||||||
table={table}
|
pageIndex={table.getState().pagination.pageIndex}
|
||||||
|
pageSize={table.getState().pagination.pageSize}
|
||||||
pageSizeOptions={pageSizeOptions}
|
pageSizeOptions={pageSizeOptions}
|
||||||
|
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
manual={manualPagination}
|
onPaginationChange={table.setPagination}
|
||||||
rowCount={rowCount}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginationProps<TData> {
|
// DataTablePagination was extracted into the shared TablePagination component
|
||||||
table: TableInstance<TData>;
|
// (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable
|
||||||
pageSizeOptions: number[];
|
// and the Media mobile card list consume it.
|
||||||
pageCount: number;
|
|
||||||
manual: boolean;
|
|
||||||
rowCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataTablePagination<TData>({
|
|
||||||
table,
|
|
||||||
pageSizeOptions,
|
|
||||||
pageCount,
|
|
||||||
manual,
|
|
||||||
rowCount,
|
|
||||||
}: PaginationProps<TData>) {
|
|
||||||
const pageIndex = table.getState().pagination.pageIndex;
|
|
||||||
const pageSize = table.getState().pagination.pageSize;
|
|
||||||
const visibleRows = table.getRowModel().rows.length;
|
|
||||||
const totalRows = manual ? (rowCount ?? 0) : visibleRows;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-muted-foreground">Rows per page</span>
|
|
||||||
<Select
|
|
||||||
value={String(pageSize)}
|
|
||||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
size="sm"
|
|
||||||
className="w-[70px]"
|
|
||||||
aria-label="Rows per page"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{pageSizeOptions.map((option) => (
|
|
||||||
<SelectItem key={option} value={String(option)}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Page {pageIndex + 1} of {pageCount}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field descriptor for a {@link MobileCardRow}.
|
||||||
|
*
|
||||||
|
* The consuming page decides which fields to show and in what order; this
|
||||||
|
* primitive does not pick them. Exactly one field should set `primary: true` —
|
||||||
|
* it renders as the card title (bold, larger). The rest render as a key/value
|
||||||
|
* stack below the title.
|
||||||
|
*/
|
||||||
|
export interface MobileCardField<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
/** When true, render as the card title (bold, larger). One per card. */
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileCardRowProps<T> {
|
||||||
|
rows: T[];
|
||||||
|
fields: MobileCardField<T>[];
|
||||||
|
/** Stable per-row identity; falls back to the row index when omitted. */
|
||||||
|
getRowId?: (row: T) => string;
|
||||||
|
/** When set, the whole card becomes a button (44px min height). */
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
||||||
|
actions?: (row: T) => React.ReactNode;
|
||||||
|
/** Optional className for the outer list container. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stacked card list for wide tables below the `md:` breakpoint.
|
||||||
|
*
|
||||||
|
* Each row renders as a card: the `primary` field as the title and the
|
||||||
|
* remaining fields as a key/value stack. When `onRowClick` is provided the
|
||||||
|
* whole card is a button with a 44px minimum touch target (spec R6.1). An
|
||||||
|
* optional `actions` slot renders right-aligned controls.
|
||||||
|
*
|
||||||
|
* This is the mobile counterpart to {@link DataTable}; pages branch on
|
||||||
|
* `useIsMobile()`. See OpenSpec change `mobile-responsive-parity`, design
|
||||||
|
* §`MobileCardRow`.
|
||||||
|
*/
|
||||||
|
export function MobileCardRow<T>({
|
||||||
|
rows,
|
||||||
|
fields,
|
||||||
|
getRowId,
|
||||||
|
onRowClick,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: MobileCardRowProps<T>) {
|
||||||
|
const primary = fields.find((f) => f.primary);
|
||||||
|
const rest = fields.filter((f) => !f.primary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
|
{rows.map((row, index) => {
|
||||||
|
const rowKey = getRowId?.(row) ?? String(index);
|
||||||
|
const body = (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
{primary ? (
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">
|
||||||
|
{primary.render(row)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{rest.length > 0 ? (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{rest.map((field) => (
|
||||||
|
<React.Fragment key={field.key}>
|
||||||
|
<dt className="font-medium text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="truncate text-foreground">
|
||||||
|
{field.render(row)}
|
||||||
|
</dd>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{actions(row)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onRowClick) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowKey}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onRowClick(row)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onRowClick(row);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mobile-touch-target min-h-11 w-full cursor-pointer rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowKey}
|
||||||
|
className="min-h-11 rounded-lg border border-border bg-card p-3"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Loader2, XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
|
export interface SheetFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
/** Disable Save and show a pending spinner. */
|
||||||
|
isPending?: boolean;
|
||||||
|
/** Override the Save button label (default "Save"). */
|
||||||
|
saveLabel?: string;
|
||||||
|
/** Disable the Save button (e.g. when required fields are empty). */
|
||||||
|
saveDisabled?: boolean;
|
||||||
|
/**
|
||||||
|
* When true, any close attempt (Cancel button, header X, overlay click,
|
||||||
|
* Escape) prompts a discard-confirmation instead of immediately closing.
|
||||||
|
* Spec R4.5.
|
||||||
|
*/
|
||||||
|
isDirty?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Optional className applied to the scrolling body. */
|
||||||
|
bodyClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-height form host for the mobile (`< md`) breakpoint.
|
||||||
|
*
|
||||||
|
* Wraps the shadcn `Sheet` primitive with a fixed header (title + close) and a
|
||||||
|
* fixed footer (Cancel + Save). The body scrolls between them. Laid out as a
|
||||||
|
* flex column (NOT `position: sticky`) because Radix `Sheet` uses transforms,
|
||||||
|
* which break sticky positioning — see OpenSpec change
|
||||||
|
* `mobile-responsive-parity`, design §`SheetForm` / risks.
|
||||||
|
*
|
||||||
|
* Uses `h-[100dvh]` (not `h-screen`) to avoid the iOS Safari URL-bar resize
|
||||||
|
* jump. Consumers choose this host vs the desktop `Dialog` via `useIsMobile()`.
|
||||||
|
*/
|
||||||
|
export function SheetForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isPending = false,
|
||||||
|
saveDisabled = false,
|
||||||
|
saveLabel = "Save",
|
||||||
|
isDirty = false,
|
||||||
|
children,
|
||||||
|
bodyClassName,
|
||||||
|
}: SheetFormProps) {
|
||||||
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Route every close path (Cancel, header X, Radix overlay/Escape) through one
|
||||||
|
// guard so the dirty-confirm is applied uniformly (spec R4.5).
|
||||||
|
const attemptClose = React.useCallback(() => {
|
||||||
|
if (isDirty) {
|
||||||
|
setConfirmDiscardOpen(true);
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, [isDirty, onCancel]);
|
||||||
|
|
||||||
|
const handleOpenChange = React.useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (!next) {
|
||||||
|
attemptClose();
|
||||||
|
} else {
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attemptClose, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
|
// Prevent Radix's default Escape close so our guard runs instead.
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
attemptClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
// Prevent overlay-click close so our guard runs instead.
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
attemptClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header — fixed at top */}
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
|
||||||
|
<SheetTitle className="font-heading text-base font-medium">
|
||||||
|
{title}
|
||||||
|
</SheetTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={attemptClose}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — scrolls */}
|
||||||
|
<div className={cn("flex-1 overflow-y-auto p-4", bodyClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — fixed at bottom */}
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/50 p-4">
|
||||||
|
<Button variant="outline" onClick={attemptClose} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSave} disabled={isPending || saveDisabled}>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
saveLabel
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDiscardOpen}
|
||||||
|
title="Discard changes?"
|
||||||
|
message="You have unsaved changes. Discard them and close?"
|
||||||
|
confirmLabel="Discard"
|
||||||
|
onCancel={() => setConfirmDiscardOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDiscardOpen(false);
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { OnChangeFn } from "@tanstack/react-table";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared pagination footer for table-style views.
|
||||||
|
*
|
||||||
|
* Renders the rows count, page-size select, page indicator, and prev/next
|
||||||
|
* buttons. Works off the raw {@link PaginationState} primitives so it can back
|
||||||
|
* both a TanStack `Table` instance (via a thin adapter) and standalone card
|
||||||
|
* layouts that drive pagination directly (e.g. MediaMobilePagination).
|
||||||
|
*
|
||||||
|
* The Desktop DataTable and the Media mobile card list both consume this to
|
||||||
|
* avoid the duplication flagged in
|
||||||
|
* `openspec/changes/mobile-responsive-parity/verify-report.md` residual risk #5.
|
||||||
|
*/
|
||||||
|
export interface TablePaginationProps {
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageSizeOptions: number[];
|
||||||
|
totalRows: number;
|
||||||
|
pageCount: number;
|
||||||
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
|
/** Optional extra className on the outer container (e.g. "p-4"). */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablePagination({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
pageSizeOptions,
|
||||||
|
totalRows,
|
||||||
|
pageCount,
|
||||||
|
onPaginationChange,
|
||||||
|
className,
|
||||||
|
}: TablePaginationProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center justify-between gap-3 text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">Rows per page</span>
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onPaginationChange(() => ({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: Number(value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="w-[70px]"
|
||||||
|
aria-label="Rows per page"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={String(option)}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Page {pageIndex + 1} of {pageCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: Math.max(0, prev.pageIndex - 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex <= 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mobile-touch-target"
|
||||||
|
onClick={() =>
|
||||||
|
onPaginationChange((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageIndex: prev.pageIndex + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={pageIndex >= pageCount - 1}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ function Tabs({
|
|||||||
data-slot="tabs"
|
data-slot="tabs"
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -23,7 +23,7 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
const tabsListVariants = cva(
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -61,10 +61,10 @@ function TabsTrigger({
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Mobile breakpoint (must match Tailwind `md:` and the OpenSpec spec R1.2). */
|
||||||
|
const MOBILE_QUERY = "(max-width: 768px)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the mobile/desktop responsive cut.
|
||||||
|
*
|
||||||
|
* Returns `true` when the viewport matches `max-width: 768px` (phone portrait),
|
||||||
|
* `false` at `md:` and above. SSR-safe: returns `false` when `window` is
|
||||||
|
* undefined so server-rendered markup stays on the desktop path.
|
||||||
|
*
|
||||||
|
* Replaces the ad-hoc `window.matchMedia("(max-width: 768px)")` reads scattered
|
||||||
|
* across pages (App.tsx, Media.tsx) — see OpenSpec change
|
||||||
|
* `mobile-responsive-parity`, design §`useIsMobile`.
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia(MOBILE_QUERY).matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
typeof window.matchMedia !== "function"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const mql = window.matchMedia(MOBILE_QUERY);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ export function useMediaStatus(jellyfinServiceId?: string) {
|
|||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.build_running ? 1000 : false,
|
query.state.data?.build_running ? 1000 : false,
|
||||||
refetchIntervalInBackground: true,
|
// Inherit the default refetchIntervalInBackground: false — pause the
|
||||||
|
// 1s build-progress poll when the tab is hidden. The build keeps
|
||||||
|
// running server-side; the poll resumes and catches up on return.
|
||||||
|
// Battery-friendly (D8 follow-up).
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
} from "../api/widgets";
|
||||||
import type { WidgetInstanceInput } from "../types";
|
import type { WidgetInstanceInput } from "../types";
|
||||||
|
|
||||||
export function useWidgetInstances() {
|
export function useWidgetInstances(
|
||||||
|
serviceId?: string,
|
||||||
|
scope?: "dashboard" | "service",
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["widgets", "instances"],
|
queryKey: ["widgets", "instances", serviceId ?? null, scope ?? null],
|
||||||
queryFn: fetchWidgetInstances,
|
queryFn: () => fetchWidgetInstances(serviceId, scope),
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,3 +100,19 @@ body,
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mobile touch-target utility (spec R6.1).
|
||||||
|
*
|
||||||
|
* Applies a 44x44px minimum hit area to interactive elements ONLY below the
|
||||||
|
* `md:` (768px) breakpoint, satisfying WCAG 2.5.5 / Apple HIG on touch devices.
|
||||||
|
* At md+ the class is inert so desktop sizing is not regressed. Pages sprinkle
|
||||||
|
* this on icon buttons, checkboxes, switches, and row taps. See OpenSpec
|
||||||
|
* change `mobile-responsive-parity`, design §`mobile-touch-target`.
|
||||||
|
*/
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-touch-target {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
DatabaseBackup,
|
||||||
|
LayoutDashboard,
|
||||||
|
Monitor,
|
||||||
|
} from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,13 +32,120 @@ import {
|
|||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import type {
|
||||||
|
DashboardShortcut,
|
||||||
|
DashboardShortcutInput,
|
||||||
|
ServiceInstance,
|
||||||
|
WidgetInstance,
|
||||||
|
} from "../types";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||||
|
|
||||||
|
// --- Mobile section grouping (mobile-parity) ---
|
||||||
|
|
||||||
|
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
|
||||||
|
type SectionId = (typeof SECTION_ORDER)[number];
|
||||||
|
|
||||||
|
const SECTION_META: Record<
|
||||||
|
SectionId,
|
||||||
|
{ label: string; icon: typeof Activity }
|
||||||
|
> = {
|
||||||
|
observability: { label: "Observability", icon: Activity },
|
||||||
|
media: { label: "Media", icon: Monitor },
|
||||||
|
backups: { label: "Backups", icon: DatabaseBackup },
|
||||||
|
custom: { label: "Custom", icon: LayoutDashboard },
|
||||||
|
};
|
||||||
|
|
||||||
|
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
|
||||||
|
|
||||||
|
function widgetSection(
|
||||||
|
widget: WidgetInstance,
|
||||||
|
services: ServiceInstance[],
|
||||||
|
): SectionId {
|
||||||
|
if (!widget.service_id) {
|
||||||
|
return widget.widget_kind === "backups" ? "backups" : "custom";
|
||||||
|
}
|
||||||
|
const service = services.find((s) => s.id === widget.service_id);
|
||||||
|
const serviceType = service?.service_type ?? "";
|
||||||
|
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability";
|
||||||
|
if (serviceType === "jellyfin") return "media";
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupWidgetsBySection(
|
||||||
|
widgets: WidgetInstance[],
|
||||||
|
services: ServiceInstance[],
|
||||||
|
): { id: SectionId; widgets: WidgetInstance[] }[] {
|
||||||
|
const groups: Record<SectionId, WidgetInstance[]> = {
|
||||||
|
observability: [],
|
||||||
|
media: [],
|
||||||
|
backups: [],
|
||||||
|
custom: [],
|
||||||
|
};
|
||||||
|
for (const w of widgets) {
|
||||||
|
groups[widgetSection(w, services)].push(w);
|
||||||
|
}
|
||||||
|
return SECTION_ORDER.map((id) => ({ id, widgets: groups[id] })).filter(
|
||||||
|
(s) => s.widgets.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileWidgetSections({
|
||||||
|
sections,
|
||||||
|
}: {
|
||||||
|
sections: { id: SectionId; widgets: WidgetInstance[] }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const meta = SECTION_META[section.id];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
className="mobile-touch-target inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById(`dashboard-section-${section.id}`)
|
||||||
|
?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{meta.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<section
|
||||||
|
key={section.id}
|
||||||
|
id={`dashboard-section-${section.id}`}
|
||||||
|
className="scroll-mt-16 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||||
|
{SECTION_META[section.id].label}
|
||||||
|
</h3>
|
||||||
|
{section.widgets.map((widget) => (
|
||||||
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function emptyShortcut(): DashboardShortcutInput {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -335,7 +448,12 @@ export function Dashboard() {
|
|||||||
);
|
);
|
||||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
const { data: widgetInstances = [] } = useWidgetInstances(
|
||||||
|
undefined,
|
||||||
|
"dashboard",
|
||||||
|
);
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -345,6 +463,11 @@ export function Dashboard() {
|
|||||||
[widgetInstances],
|
[widgetInstances],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mobileSections = useMemo(
|
||||||
|
() => groupWidgetsBySection(visibleWidgets, services),
|
||||||
|
[visibleWidgets, services],
|
||||||
|
);
|
||||||
|
|
||||||
const openCreateShortcut = () => {
|
const openCreateShortcut = () => {
|
||||||
setShortcutDraft(emptyShortcut());
|
setShortcutDraft(emptyShortcut());
|
||||||
setShortcutDialogOpen(true);
|
setShortcutDialogOpen(true);
|
||||||
@@ -374,6 +497,27 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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
|
<SectionCard
|
||||||
title="Shortcuts"
|
title="Shortcuts"
|
||||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||||
@@ -417,9 +561,13 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{visibleWidgets.map((widget) => (
|
{isMobile && mobileSections.length > 0 ? (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
<MobileWidgetSections sections={mobileSections} />
|
||||||
))}
|
) : (
|
||||||
|
visibleWidgets.map((widget) => (
|
||||||
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<ShortcutDialog
|
<ShortcutDialog
|
||||||
open={shortcutDialogOpen}
|
open={shortcutDialogOpen}
|
||||||
|
|||||||
@@ -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,47 +1,17 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Input } from "@/components/ui/input";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { Label } from "@/components/ui/label";
|
import type { ServiceInstance } from "../types";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import {
|
|
||||||
useDeleteServiceInstance,
|
|
||||||
useSaveServiceInstance,
|
|
||||||
useServiceInstances,
|
|
||||||
useServiceTypes,
|
|
||||||
} from "../hooks/useServices";
|
|
||||||
import type {
|
|
||||||
ServiceInstance,
|
|
||||||
ServiceInstanceInput,
|
|
||||||
ServiceTypeInfo,
|
|
||||||
} from "../types";
|
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
|
||||||
import { getServiceBinding } from "../integrations/registry";
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
import {
|
||||||
function Field({
|
OVERVIEW_TAB,
|
||||||
label,
|
serviceContentTabs,
|
||||||
htmlFor,
|
type ContentTab,
|
||||||
helper,
|
} from "./service-tabs";
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
htmlFor: string;
|
|
||||||
helper?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor={htmlFor}>{label}</Label>
|
|
||||||
{children}
|
|
||||||
{helper ? (
|
|
||||||
<p className="text-xs text-muted-foreground">{helper}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServicePage() {
|
export function ServicePage() {
|
||||||
const { serviceType = "", serviceId = "" } = useParams<{
|
const { serviceType = "", serviceId = "" } = useParams<{
|
||||||
@@ -49,33 +19,24 @@ export function ServicePage() {
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
}>();
|
}>();
|
||||||
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||||
const { data: types = [] } = useServiceTypes();
|
const navigate = useNavigate();
|
||||||
const saveService = useSaveServiceInstance();
|
|
||||||
const deleteService = useDeleteServiceInstance();
|
|
||||||
|
|
||||||
const instance = useMemo(
|
const instance = useMemo(
|
||||||
() => services.find((s) => s.id === serviceId),
|
() => services.find((s) => s.id === serviceId),
|
||||||
[services, serviceId],
|
[services, serviceId],
|
||||||
);
|
);
|
||||||
const binding = getServiceBinding(serviceType);
|
const binding = getServiceBinding(serviceType);
|
||||||
const typeInfo = useMemo(
|
const contentTabs = useMemo(
|
||||||
() => types.find((t) => t.service_type === serviceType),
|
() => serviceContentTabs(serviceType),
|
||||||
[types, serviceType],
|
[serviceType],
|
||||||
);
|
);
|
||||||
|
const siblings = useMemo(
|
||||||
|
() => services.filter((s) => s.service_type === serviceType && s.enabled),
|
||||||
|
[services, serviceType],
|
||||||
|
);
|
||||||
|
const showInstanceTabs = siblings.length > 1;
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
|
||||||
const [enabled, setEnabled] = useState(true);
|
|
||||||
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
const [hydrated, setHydrated] = useState(false);
|
|
||||||
|
|
||||||
// Hydrate local form state once the instance loads.
|
|
||||||
if (instance && !hydrated) {
|
|
||||||
setName(instance.name);
|
|
||||||
setEnabled(instance.enabled);
|
|
||||||
setDraftConfig({ ...instance.config });
|
|
||||||
setHydrated(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return (
|
return (
|
||||||
@@ -93,235 +54,93 @@ export function ServicePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInput(): ServiceInstanceInput {
|
const widgetsContent =
|
||||||
return {
|
binding.widgets.length > 0 ? (
|
||||||
id: instance!.id,
|
<div className="flex flex-col gap-2">
|
||||||
service_type: instance!.service_type,
|
{binding.widgets.map((w) => (
|
||||||
name,
|
<div
|
||||||
config: draftConfig,
|
key={w.kind}
|
||||||
secrets: {}, // secrets are managed via the dedicated inputs below
|
className="flex items-center justify-between rounded border p-2"
|
||||||
enabled,
|
>
|
||||||
};
|
<div>
|
||||||
}
|
<div className="font-medium">{w.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
async function save() {
|
{w.description}
|
||||||
await saveService.mutateAsync(buildInput());
|
</div>
|
||||||
}
|
</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>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No widget kinds for this service type.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header */}
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{binding.name}</Badge>
|
<Badge variant="outline">{binding.name}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionCard title="General">
|
{/* Instance tabs (only when >1 enabled sibling) */}
|
||||||
<div className="flex flex-col gap-3">
|
{showInstanceTabs ? (
|
||||||
<Field label="Name" htmlFor="service-name">
|
<Tabs value={instance.id}>
|
||||||
<Input
|
<TabsList>
|
||||||
id="service-name"
|
{siblings.map((sibling: ServiceInstance) => (
|
||||||
value={name}
|
<TabsTrigger
|
||||||
onChange={(e) => setName(e.target.value)}
|
key={sibling.id}
|
||||||
/>
|
value={sibling.id}
|
||||||
</Field>
|
onClick={() =>
|
||||||
<div className="flex items-center gap-2">
|
navigate(`/services/${serviceType}/${sibling.id}`)
|
||||||
<Switch
|
}
|
||||||
id="service-enabled"
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={setEnabled}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="service-enabled">Enabled</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button onClick={save} disabled={saveService.isPending}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<ServiceConnectionCard
|
|
||||||
instance={instance}
|
|
||||||
typeInfo={typeInfo}
|
|
||||||
draftConfig={draftConfig}
|
|
||||||
onConfigChange={setDraftConfig}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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>
|
{sibling.name}
|
||||||
<div className="font-medium">{w.name}</div>
|
</TabsTrigger>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{w.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline">{w.kind}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<p className="text-xs text-muted-foreground">
|
</TabsList>
|
||||||
Add these to the dashboard from the dashboard's edit dialog.
|
</Tabs>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ConfirmDialog
|
{/* Content tabs */}
|
||||||
open={deleteOpen}
|
<Tabs defaultValue="Overview">
|
||||||
title="Delete service?"
|
<TabsList>
|
||||||
message="This removes the service and any widgets that reference it. This cannot be undone."
|
<TabsTrigger value="Overview">Overview</TabsTrigger>
|
||||||
confirmLabel="Delete"
|
{contentTabs.map((tab) => (
|
||||||
onCancel={() => setDeleteOpen(false)}
|
<TabsTrigger key={tab.label} value={tab.label}>
|
||||||
onConfirm={() => {
|
{tab.label}
|
||||||
deleteService.mutate(instance.id);
|
</TabsTrigger>
|
||||||
setDeleteOpen(false);
|
))}
|
||||||
}}
|
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
|
||||||
/>
|
</TabsList>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceConnectionCard({
|
|
||||||
instance,
|
|
||||||
typeInfo,
|
|
||||||
draftConfig,
|
|
||||||
onConfigChange,
|
|
||||||
}: {
|
|
||||||
instance: ServiceInstance;
|
|
||||||
typeInfo: ServiceTypeInfo | undefined;
|
|
||||||
draftConfig: Record<string, unknown>;
|
|
||||||
onConfigChange: (config: Record<string, unknown>) => void;
|
|
||||||
}) {
|
|
||||||
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" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard
|
|
||||||
title="Connection"
|
|
||||||
description="Edit non-secret connection config and secret values."
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id={`cfg-${key}`}
|
|
||||||
type={isNumber ? "number" : "text"}
|
|
||||||
value={String(draftConfig[key] ?? "")}
|
|
||||||
onChange={(e) =>
|
|
||||||
onConfigChange({
|
|
||||||
...draftConfig,
|
|
||||||
[key]: isNumber
|
|
||||||
? e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value)
|
|
||||||
: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Object.keys(instance.secrets_set).length === 0 ? (
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
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({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Update connection
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</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,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} 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 {
|
import {
|
||||||
useDeleteServiceInstance,
|
useDeleteServiceInstance,
|
||||||
useSaveServiceInstance,
|
useSaveServiceInstance,
|
||||||
useServiceInstances,
|
useServiceInstances,
|
||||||
} from "../hooks/useServices";
|
} from "../hooks/useServices";
|
||||||
import { useServiceTypes } from "../hooks/useServices";
|
import { useServiceTypes } from "../hooks/useServices";
|
||||||
|
import {
|
||||||
|
useDashboards,
|
||||||
|
useDeleteDashboard,
|
||||||
|
useSaveDashboard,
|
||||||
|
} from "../hooks/useDashboards";
|
||||||
import type {
|
import type {
|
||||||
SecretFieldInfo,
|
SecretFieldInfo,
|
||||||
ServiceInstance,
|
ServiceInstance,
|
||||||
@@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard";
|
|||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { getServiceBinding } from "../integrations/registry";
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
import { serviceLinkTarget } from "../components/PinnedServiceLink";
|
||||||
|
import type { NamedDashboardInput } from "../api/dashboards";
|
||||||
|
|
||||||
interface CreateDraft {
|
interface CreateDraft {
|
||||||
serviceType: string;
|
serviceType: string;
|
||||||
@@ -257,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() {
|
export function ServicesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: services = [] } = useServiceInstances();
|
const { data: services = [] } = useServiceInstances();
|
||||||
@@ -352,6 +605,8 @@ export function ServicesPage() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
<DashboardManagementCard />
|
||||||
|
|
||||||
<CreateServiceDialog
|
<CreateServiceDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
|
|||||||
+383
-46
@@ -17,6 +17,8 @@ import {
|
|||||||
useSaveSSHKey,
|
useSaveSSHKey,
|
||||||
useTestMonitoringMachineSSH,
|
useTestMonitoringMachineSSH,
|
||||||
} from "../hooks/useSettings";
|
} from "../hooks/useSettings";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile";
|
||||||
|
import { SheetForm } from "@/components/ui/sheet-form";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { HoverEditButton } from "../components/HoverEditButton";
|
import { HoverEditButton } from "../components/HoverEditButton";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
@@ -48,6 +50,17 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { TabsTrigger } from "@/components/ui/tabs";
|
import { TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
useDeleteServiceInstance,
|
||||||
|
useSaveServiceInstance,
|
||||||
|
useServiceInstances,
|
||||||
|
useServiceTypes,
|
||||||
|
} from "../hooks/useServices";
|
||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
const SERVICE_OPTIONS = [
|
const SERVICE_OPTIONS = [
|
||||||
{ value: "monitoring", label: "Monitoring" },
|
{ value: "monitoring", label: "Monitoring" },
|
||||||
@@ -59,7 +72,7 @@ const SERVICE_OPTIONS = [
|
|||||||
// maps to this sentinel and converts back to "" at the draft boundary.
|
// maps to this sentinel and converts back to "" at the draft boundary.
|
||||||
const NONE = "__none__";
|
const NONE = "__none__";
|
||||||
|
|
||||||
type SettingsTab = "machines" | "ssh-keys" | "danger";
|
type SettingsTab = "machines" | "ssh-keys" | "services" | "danger";
|
||||||
|
|
||||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||||
function FormField({
|
function FormField({
|
||||||
@@ -126,6 +139,30 @@ function emptyMachine(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dirty check for the machine editor SheetForm guard (spec R4.5).
|
||||||
|
* Pragmatic field-by-field comparison of the user-editable fields. In create
|
||||||
|
* mode (editingMachine is null) the form is always dirty.
|
||||||
|
*/
|
||||||
|
function isMachineDraftDirty(
|
||||||
|
draft: MonitoringMachineInput,
|
||||||
|
editingMachine: MonitoringMachine | null,
|
||||||
|
): boolean {
|
||||||
|
if (!editingMachine) return true;
|
||||||
|
return (
|
||||||
|
draft.name !== editingMachine.name ||
|
||||||
|
draft.host !== editingMachine.host ||
|
||||||
|
draft.mode !== editingMachine.mode ||
|
||||||
|
draft.port !== editingMachine.port ||
|
||||||
|
draft.username !== editingMachine.username ||
|
||||||
|
draft.ssh_key_id !== editingMachine.ssh_key_id ||
|
||||||
|
draft.enabled !== editingMachine.enabled ||
|
||||||
|
draft.notes !== editingMachine.notes ||
|
||||||
|
JSON.stringify([...draft.services].sort()) !==
|
||||||
|
JSON.stringify([...editingMachine.services].sort())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MachineEditor({
|
function MachineEditor({
|
||||||
title,
|
title,
|
||||||
hint,
|
hint,
|
||||||
@@ -230,6 +267,7 @@ function MachineEditor({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="machine-enabled"
|
id="machine-enabled"
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setDraft((current) => ({ ...current, enabled: checked }))
|
setDraft((current) => ({ ...current, enabled: checked }))
|
||||||
@@ -439,6 +477,7 @@ function MachineEditor({
|
|||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onValidateSSH}
|
onClick={onValidateSSH}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -532,7 +571,7 @@ function SSHKeyManager({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clear();
|
clear();
|
||||||
}}
|
}}
|
||||||
@@ -651,6 +690,7 @@ function SSHKeyManager({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
disabled={saveKey.isPending}
|
disabled={saveKey.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await saveKey.mutateAsync(draft);
|
await saveKey.mutateAsync(draft);
|
||||||
@@ -660,6 +700,7 @@ function SSHKeyManager({
|
|||||||
{editing ? "Update key" : "Save key"}
|
{editing ? "Update key" : "Save key"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={generateKey.isPending}
|
disabled={generateKey.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -683,11 +724,16 @@ function SSHKeyManager({
|
|||||||
>
|
>
|
||||||
{generateKey.isPending ? "Generating..." : "Generate key"}
|
{generateKey.isPending ? "Generating..." : "Generate key"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={clear}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={clear}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
{selectedKey && (
|
{selectedKey && (
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => deleteKey.mutate(selectedKey.id)}
|
onClick={() => deleteKey.mutate(selectedKey.id)}
|
||||||
>
|
>
|
||||||
@@ -753,7 +799,11 @@ function ResetLocalDatabaseCard() {
|
|||||||
Reset the local SQLite settings/media index databases after
|
Reset the local SQLite settings/media index databases after
|
||||||
acknowledging the data loss.
|
acknowledging the data loss.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="destructive" onClick={() => setOpen(true)}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
Reset local database
|
Reset local database
|
||||||
</Button>
|
</Button>
|
||||||
{resetDatabase.error && (
|
{resetDatabase.error && (
|
||||||
@@ -780,6 +830,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackSettings}
|
checked={ackSettings}
|
||||||
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
|
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
@@ -787,6 +838,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackIndex}
|
checked={ackIndex}
|
||||||
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
|
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
@@ -794,6 +846,7 @@ function ResetLocalDatabaseCard() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
className="mobile-touch-target"
|
||||||
checked={ackIrreversible}
|
checked={ackIrreversible}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setAckIrreversible(Boolean(checked))
|
setAckIrreversible(Boolean(checked))
|
||||||
@@ -850,6 +903,7 @@ export function Settings() {
|
|||||||
const [editingMachine, setEditingMachine] =
|
const [editingMachine, setEditingMachine] =
|
||||||
useState<MonitoringMachine | null>(null);
|
useState<MonitoringMachine | null>(null);
|
||||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
||||||
const selectedMachine = useMemo(
|
const selectedMachine = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -952,6 +1006,9 @@ export function Settings() {
|
|||||||
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
<TabsTrigger key="ssh-keys" value="ssh-keys">
|
||||||
SSH Keys
|
SSH Keys
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
|
<TabsTrigger key="services" value="services">
|
||||||
|
Services
|
||||||
|
</TabsTrigger>,
|
||||||
<TabsTrigger key="danger" value="danger">
|
<TabsTrigger key="danger" value="danger">
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</TabsTrigger>,
|
</TabsTrigger>,
|
||||||
@@ -970,7 +1027,7 @@ export function Settings() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="mobile-touch-target w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearSSHValidation();
|
clearSSHValidation();
|
||||||
setMachineDraft(emptyMachine("local"));
|
setMachineDraft(emptyMachine("local"));
|
||||||
@@ -1086,6 +1143,7 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openEditMachine(
|
openEditMachine(
|
||||||
@@ -1113,6 +1171,7 @@ export function Settings() {
|
|||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
||||||
>
|
>
|
||||||
@@ -1133,23 +1192,28 @@ export function Settings() {
|
|||||||
onSelectKeyId={setSelectedSSHKeyId}
|
onSelectKeyId={setSelectedSSHKeyId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{tab === "services" && <ServicesAdminCard />}
|
||||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
<Dialog
|
{isMobile ? (
|
||||||
open={machineDialogOpen}
|
<SheetForm
|
||||||
onOpenChange={(open) => {
|
open={machineDialogOpen}
|
||||||
if (!open) closeMachineDialog();
|
onOpenChange={(open) => {
|
||||||
}}
|
if (!open) closeMachineDialog();
|
||||||
>
|
}}
|
||||||
<DialogContent className="sm:max-w-4xl">
|
title={machineDraft.id ? "Edit machine" : "Create machine"}
|
||||||
<DialogHeader>
|
onSave={() => {
|
||||||
<DialogTitle>
|
void saveMachineDraft(machineDraft);
|
||||||
{machineDraft.id ? "Edit machine" : "Create machine"}
|
}}
|
||||||
</DialogTitle>
|
onCancel={closeMachineDialog}
|
||||||
<DialogDescription>
|
isPending={saveMachine.isPending}
|
||||||
{machineDraft.mode === "local" ? "Local API host" : "SSH target"}
|
saveDisabled={
|
||||||
</DialogDescription>
|
!machineDraft.name ||
|
||||||
</DialogHeader>
|
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||||
|
}
|
||||||
|
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||||
|
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
|
||||||
|
>
|
||||||
<MachineEditor
|
<MachineEditor
|
||||||
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||||
title={
|
title={
|
||||||
@@ -1170,32 +1234,82 @@ export function Settings() {
|
|||||||
sshValidationError={sshValidationError}
|
sshValidationError={sshValidationError}
|
||||||
sshValidationStatus={sshValidationStatus}
|
sshValidationStatus={sshValidationStatus}
|
||||||
/>
|
/>
|
||||||
<DialogFooter
|
{machineDraft.id ? (
|
||||||
onCancel={closeMachineDialog}
|
<Button
|
||||||
cancelLabel="Cancel"
|
className="mobile-touch-target"
|
||||||
onConfirm={() => {
|
variant="destructive"
|
||||||
void saveMachineDraft(machineDraft);
|
onClick={() => setDeleteMachineId(machineDraft.id as string)}
|
||||||
}}
|
>
|
||||||
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
Delete machine
|
||||||
confirmDisabled={
|
</Button>
|
||||||
!machineDraft.name ||
|
) : null}
|
||||||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
</SheetForm>
|
||||||
}
|
) : (
|
||||||
secondaryAction={
|
<Dialog
|
||||||
machineDraft.id ? (
|
open={machineDialogOpen}
|
||||||
<Button
|
onOpenChange={(open) => {
|
||||||
variant="destructive"
|
if (!open) closeMachineDialog();
|
||||||
onClick={() => {
|
}}
|
||||||
setDeleteMachineId(machineDraft.id as string);
|
>
|
||||||
}}
|
<DialogContent className="sm:max-w-4xl">
|
||||||
>
|
<DialogHeader>
|
||||||
Delete
|
<DialogTitle>
|
||||||
</Button>
|
{machineDraft.id ? "Edit machine" : "Create machine"}
|
||||||
) : undefined
|
</DialogTitle>
|
||||||
}
|
<DialogDescription>
|
||||||
/>
|
{machineDraft.mode === "local"
|
||||||
</DialogContent>
|
? "Local API host"
|
||||||
</Dialog>
|
: "SSH target"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<MachineEditor
|
||||||
|
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||||
|
title={
|
||||||
|
machineDraft.id
|
||||||
|
? machineDraft.name || "Edit machine"
|
||||||
|
: "New machine"
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
machineDraft.mode === "local" ? "Local API host" : "SSH target"
|
||||||
|
}
|
||||||
|
machine={machineDraft}
|
||||||
|
sshKeys={sshKeys}
|
||||||
|
editingMachine={editingMachine}
|
||||||
|
onChange={updateMachineDraft}
|
||||||
|
onValidateSSH={validateMachineSSH}
|
||||||
|
isValidatingSSH={testMachineSSH.isPending}
|
||||||
|
sshValidationMessage={sshValidationMessage}
|
||||||
|
sshValidationError={sshValidationError}
|
||||||
|
sshValidationStatus={sshValidationStatus}
|
||||||
|
/>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={closeMachineDialog}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={() => {
|
||||||
|
void saveMachineDraft(machineDraft);
|
||||||
|
}}
|
||||||
|
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||||
|
confirmDisabled={
|
||||||
|
!machineDraft.name ||
|
||||||
|
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||||
|
}
|
||||||
|
secondaryAction={
|
||||||
|
machineDraft.id ? (
|
||||||
|
<Button
|
||||||
|
className="mobile-touch-target"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteMachineId(machineDraft.id as string);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteMachineId)}
|
open={Boolean(deleteMachineId)}
|
||||||
title="Delete machine?"
|
title="Delete machine?"
|
||||||
@@ -1212,3 +1326,226 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services admin card for the Settings > Services tab.
|
||||||
|
*
|
||||||
|
* Lists all service instances grouped by type with inline config editing
|
||||||
|
* (enable/disable, config fields, secrets, save, delete). Lifted from the
|
||||||
|
* old ServicePage ConfigBody — the service page is now a pure operational
|
||||||
|
* view; all administration lives here.
|
||||||
|
*/
|
||||||
|
function ServicesAdminCard() {
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const { data: types = [] } = useServiceTypes();
|
||||||
|
|
||||||
|
// Group by service_type, alphabetical.
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, ServiceInstance[]>();
|
||||||
|
for (const svc of services) {
|
||||||
|
const list = map.get(svc.service_type) ?? [];
|
||||||
|
list.push(svc);
|
||||||
|
map.set(svc.service_type, list);
|
||||||
|
}
|
||||||
|
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [services]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No service instances configured. Create one from the Services page.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
grouped.map(([serviceType, instances]) => {
|
||||||
|
const typeInfo = types.find((t) => t.service_type === serviceType);
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
key={serviceType}
|
||||||
|
title={typeInfo?.name ?? serviceType}
|
||||||
|
description={typeInfo?.description ?? ""}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{instances.map((svc) => (
|
||||||
|
<ServiceConfigEditor
|
||||||
|
key={svc.id}
|
||||||
|
instance={svc}
|
||||||
|
typeInfo={typeInfo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceConfigEditor({
|
||||||
|
instance,
|
||||||
|
typeInfo,
|
||||||
|
}: {
|
||||||
|
instance: ServiceInstance;
|
||||||
|
typeInfo: ServiceTypeInfo | undefined;
|
||||||
|
}) {
|
||||||
|
const saveService = useSaveServiceInstance();
|
||||||
|
const deleteService = useDeleteServiceInstance();
|
||||||
|
const [name, setName] = useState(instance.name);
|
||||||
|
const [enabled, setEnabled] = useState(instance.enabled);
|
||||||
|
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
|
||||||
|
...instance.config,
|
||||||
|
});
|
||||||
|
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
const properties =
|
||||||
|
(
|
||||||
|
(typeInfo?.config_schema ?? {}) as {
|
||||||
|
properties?: Record<string, { type?: string; description?: string }>;
|
||||||
|
}
|
||||||
|
).properties ?? {};
|
||||||
|
const configEntries: Array<
|
||||||
|
[string, { type?: string; description?: string }]
|
||||||
|
> =
|
||||||
|
Object.keys(properties).length > 0
|
||||||
|
? Object.entries(properties).map(([key, schema]) => [
|
||||||
|
key,
|
||||||
|
{ type: schema?.type, description: schema?.description },
|
||||||
|
])
|
||||||
|
: Object.entries(instance.config).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
{ type: typeof value === "number" ? "integer" : "string" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function buildInput(): ServiceInstanceInput {
|
||||||
|
const onlyChangedSecrets = Object.fromEntries(
|
||||||
|
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: instance.id,
|
||||||
|
service_type: instance.service_type,
|
||||||
|
name,
|
||||||
|
config: draftConfig,
|
||||||
|
secrets: onlyChangedSecrets,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
await saveService.mutateAsync(buildInput());
|
||||||
|
setDraftSecrets({});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="font-medium">{instance.name}</span>
|
||||||
|
<Badge variant={instance.enabled ? "default" : "secondary"}>
|
||||||
|
{instance.enabled ? "enabled" : "disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
|
||||||
|
<Input
|
||||||
|
id={`svc-name-${instance.id}`}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id={`svc-enabled-${instance.id}`}
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configEntries.map(([key, schema]) => {
|
||||||
|
const isNumber =
|
||||||
|
schema.type === "integer" || schema.type === "number";
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
htmlFor={`svc-cfg-${instance.id}-${key}`}
|
||||||
|
helperText={schema.description}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`svc-cfg-${instance.id}-${key}`}
|
||||||
|
type={isNumber ? "number" : "text"}
|
||||||
|
value={String(draftConfig[key] ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftConfig({
|
||||||
|
...draftConfig,
|
||||||
|
[key]: isNumber
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{Object.keys(instance.secrets_set).length === 0
|
||||||
|
? null
|
||||||
|
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||||
|
<FormField
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
htmlFor={`svc-secret-${instance.id}-${key}`}
|
||||||
|
helperText="Leave blank to keep the current value."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`svc-secret-${instance.id}-${key}`}
|
||||||
|
type="password"
|
||||||
|
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||||
|
value={draftSecrets[key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftSecrets({
|
||||||
|
...draftSecrets,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveService.isPending}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
className="mobile-touch-target"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
title="Delete service?"
|
||||||
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteService.mutate(instance.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { UsersPage } from "./UsersPage.impl";
|
|
||||||
@@ -1,983 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import type { ChangeEvent } from "react";
|
|
||||||
// Slice 6b: compose dialog (shadcn Dialog family) + lucide icons. The file is
|
|
||||||
// now fully @mui-free (6a migrated the directory surface, drawer, and the
|
|
||||||
// compose content's shared leaf components).
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Paperclip,
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Link,
|
|
||||||
List,
|
|
||||||
Mail,
|
|
||||||
Send,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
// Slice 6a directory surface + drawer primitives.
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button as UiButton } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Alert as UIAlert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { MetricCard } from "../components/MetricCard";
|
|
||||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
|
||||||
import { useUsers } from "../hooks/useUsers";
|
|
||||||
import { useActivity } from "../hooks/useDashboard";
|
|
||||||
import { useSendUserMessage } from "../hooks/useSendUserMessage";
|
|
||||||
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
|
|
||||||
import type { UserDirectoryItem } from "../types";
|
|
||||||
import { buildUserDrawerModel } from "../users";
|
|
||||||
import {
|
|
||||||
mergeUsersWithActivity,
|
|
||||||
resolveUserSelection,
|
|
||||||
type UserStateItem,
|
|
||||||
} from "../userState";
|
|
||||||
|
|
||||||
// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free
|
|
||||||
// matchMedia hook for the compose dialog's mobile fullScreen behavior.
|
|
||||||
function useIsMobile(query = "(max-width: 900px)") {
|
|
||||||
const [mobile, setMobile] = useState(() =>
|
|
||||||
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
||||||
? window.matchMedia(query).matches
|
|
||||||
: false,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
typeof window === "undefined" ||
|
|
||||||
typeof window.matchMedia !== "function"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mql = window.matchMedia(query);
|
|
||||||
const onChange = (event: MediaQueryListEvent) => setMobile(event.matches);
|
|
||||||
mql.addEventListener("change", onChange);
|
|
||||||
return () => mql.removeEventListener("change", onChange);
|
|
||||||
}, [query]);
|
|
||||||
return mobile;
|
|
||||||
}
|
|
||||||
|
|
||||||
function userLabel(user: UserDirectoryItem) {
|
|
||||||
return user.display_name || user.username || user.jellyfin_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activity → Badge status variant (design §2.3: healthy/active = success chart-2,
|
|
||||||
// paused = warning chart-3, neutral = secondary).
|
|
||||||
function activityBadgeVariant(
|
|
||||||
label: string,
|
|
||||||
): "success" | "warning" | "secondary" {
|
|
||||||
if (label === "Playing") return "success";
|
|
||||||
if (label === "Paused") return "warning";
|
|
||||||
return "secondary";
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
|
||||||
|
|
||||||
export function UsersPage() {
|
|
||||||
const { data, isError, error } = useUsers();
|
|
||||||
const { data: activity } = useActivity();
|
|
||||||
const queueStatusQuery = useUserMessageQueueStatus();
|
|
||||||
const sendUserMessage = useSendUserMessage();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
|
||||||
const [composeOpen, setComposeOpen] = useState(false);
|
|
||||||
const [subject, setSubject] = useState("");
|
|
||||||
const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY);
|
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
|
||||||
const htmlBodyRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
|
|
||||||
const baseRows = data?.items ?? [];
|
|
||||||
const rows = useMemo(
|
|
||||||
() => mergeUsersWithActivity(baseRows, activity ?? []),
|
|
||||||
[baseRows, activity],
|
|
||||||
);
|
|
||||||
const filteredRows = useMemo(() => {
|
|
||||||
const term = search.trim().toLowerCase();
|
|
||||||
if (!term) {
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
return rows.filter((row) => {
|
|
||||||
return [
|
|
||||||
row.username,
|
|
||||||
row.display_name,
|
|
||||||
row.email,
|
|
||||||
row.email_source,
|
|
||||||
row.avatar_source,
|
|
||||||
row.name_source,
|
|
||||||
row.access_source,
|
|
||||||
row.user_type_label,
|
|
||||||
row.role,
|
|
||||||
row.permissions_label,
|
|
||||||
row.jellyseerr_username,
|
|
||||||
row.activity_label,
|
|
||||||
row.activity_summary,
|
|
||||||
row.activity.primary_session?.title || "",
|
|
||||||
String(row.jellyseerr_user_id ?? ""),
|
|
||||||
].some((value) => value.toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}, [rows, search]);
|
|
||||||
|
|
||||||
const metrics = useMemo(() => {
|
|
||||||
const total = baseRows.length;
|
|
||||||
const contactable = rows.filter((row) => row.contactable).length;
|
|
||||||
const enriched = rows.filter(
|
|
||||||
(row) => row.jellyseerr_user_id !== null,
|
|
||||||
).length;
|
|
||||||
const admins = rows.filter((row) => row.role === "admin").length;
|
|
||||||
return { total, contactable, enriched, admins };
|
|
||||||
}, [baseRows]);
|
|
||||||
|
|
||||||
const queueStatus = queueStatusQuery.data;
|
|
||||||
const queueBanner = useMemo(() => {
|
|
||||||
if (!queueStatus) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const activeCount = queueStatus.active_request_id ? 1 : 0;
|
|
||||||
const totalCount = queueStatus.pending_count + activeCount;
|
|
||||||
const countLabel =
|
|
||||||
totalCount > 0
|
|
||||||
? `${totalCount} item${totalCount === 1 ? "" : "s"} in queue (${queueStatus.pending_count} waiting${activeCount ? ", 1 processing" : ""})`
|
|
||||||
: "0 items in queue";
|
|
||||||
if (!queueStatus.worker_running) {
|
|
||||||
return {
|
|
||||||
severity: "warning" as const,
|
|
||||||
message:
|
|
||||||
queueStatus.last_error ||
|
|
||||||
"Email queue worker is not running. New messages cannot be delivered until it restarts.",
|
|
||||||
countLabel,
|
|
||||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (queueStatus.state === "error") {
|
|
||||||
return {
|
|
||||||
severity: "error" as const,
|
|
||||||
message: queueStatus.last_error || "The last email delivery failed.",
|
|
||||||
countLabel,
|
|
||||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (queueStatus.state === "busy") {
|
|
||||||
const active = queueStatus.active_request_id
|
|
||||||
? `processing ${queueStatus.active_request_id.slice(0, 8)}`
|
|
||||||
: "processing a message";
|
|
||||||
const waiting = queueStatus.pending_count
|
|
||||||
? `${queueStatus.pending_count} waiting`
|
|
||||||
: "no backlog";
|
|
||||||
return {
|
|
||||||
severity: "info" as const,
|
|
||||||
message: `Email queue is busy: ${active}, ${waiting}.`,
|
|
||||||
countLabel,
|
|
||||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
severity: "success" as const,
|
|
||||||
message: "Email queue is idle and empty.",
|
|
||||||
countLabel,
|
|
||||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
|
||||||
};
|
|
||||||
}, [queueStatus]);
|
|
||||||
|
|
||||||
const selectedIdSet = useMemo(
|
|
||||||
() => new Set(selectedUserIds),
|
|
||||||
[selectedUserIds],
|
|
||||||
);
|
|
||||||
const selectedRows = useMemo(
|
|
||||||
() => rows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
|
|
||||||
[rows, selectedIdSet],
|
|
||||||
);
|
|
||||||
const selectedDeliverableRows = useMemo(
|
|
||||||
() => selectedRows.filter((row) => row.contactable && row.email),
|
|
||||||
[selectedRows],
|
|
||||||
);
|
|
||||||
const skippedRows = useMemo(
|
|
||||||
() => selectedRows.filter((row) => !row.contactable || !row.email),
|
|
||||||
[selectedRows],
|
|
||||||
);
|
|
||||||
const visibleSelectedRows = useMemo(
|
|
||||||
() => filteredRows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
|
|
||||||
[filteredRows, selectedIdSet],
|
|
||||||
);
|
|
||||||
const allVisibleSelected =
|
|
||||||
filteredRows.length > 0 &&
|
|
||||||
visibleSelectedRows.length === filteredRows.length;
|
|
||||||
|
|
||||||
const toggleUserSelected = (userId: string) => {
|
|
||||||
setSelectedUserIds((current) =>
|
|
||||||
current.includes(userId)
|
|
||||||
? current.filter((id) => id !== userId)
|
|
||||||
: [...current, userId],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleVisibleSelection = (checked: boolean) => {
|
|
||||||
setSelectedUserIds((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
filteredRows.forEach((row) => {
|
|
||||||
if (checked) {
|
|
||||||
next.add(row.jellyfin_id);
|
|
||||||
} else {
|
|
||||||
next.delete(row.jellyfin_id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(next);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedUserParam = searchParams.get("user") || "";
|
|
||||||
const selectedUser = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedUserParam
|
|
||||||
? (resolveUserSelection(
|
|
||||||
rows,
|
|
||||||
selectedUserParam,
|
|
||||||
) as UserStateItem | null)
|
|
||||||
: null,
|
|
||||||
[rows, selectedUserParam],
|
|
||||||
);
|
|
||||||
const drawerModel = selectedUser ? buildUserDrawerModel(selectedUser) : null;
|
|
||||||
|
|
||||||
const openCompose = () => {
|
|
||||||
if (!selectedRows.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendUserMessage.reset();
|
|
||||||
if (!subject.trim()) {
|
|
||||||
setSubject(
|
|
||||||
`Manage update for ${selectedDeliverableRows.length} user${selectedDeliverableRows.length === 1 ? "" : "s"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!htmlBody.trim()) {
|
|
||||||
setHtmlBody(DEFAULT_HTML_BODY);
|
|
||||||
}
|
|
||||||
setComposeOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeCompose = () => {
|
|
||||||
setComposeOpen(false);
|
|
||||||
sendUserMessage.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertMarkup = (before: string, after = before) => {
|
|
||||||
const textarea = htmlBodyRef.current;
|
|
||||||
if (!textarea) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const start = textarea.selectionStart ?? htmlBody.length;
|
|
||||||
const end = textarea.selectionEnd ?? htmlBody.length;
|
|
||||||
const selected = htmlBody.slice(start, end) || "text";
|
|
||||||
const next =
|
|
||||||
htmlBody.slice(0, start) +
|
|
||||||
before +
|
|
||||||
selected +
|
|
||||||
after +
|
|
||||||
htmlBody.slice(end);
|
|
||||||
setHtmlBody(next);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
textarea.focus();
|
|
||||||
const cursorStart = start + before.length;
|
|
||||||
const cursorEnd = cursorStart + selected.length;
|
|
||||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addLink = () => {
|
|
||||||
const url = window.prompt("Link URL", "https://");
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
insertMarkup(`<a href="${url}">`, "</a>");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachments = (event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
if (files.length) {
|
|
||||||
setAttachments((current) => [...current, ...files]);
|
|
||||||
}
|
|
||||||
event.target.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAttachment = (index: number) => {
|
|
||||||
setAttachments((current) => current.filter((_, idx) => idx !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
const allSelectedRows = selectedRows;
|
|
||||||
if (!allSelectedRows.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append(
|
|
||||||
"recipient_ids",
|
|
||||||
JSON.stringify(allSelectedRows.map((row) => row.jellyfin_id)),
|
|
||||||
);
|
|
||||||
formData.append("subject", subject);
|
|
||||||
formData.append("html_body", htmlBody);
|
|
||||||
attachments.forEach((file) => {
|
|
||||||
formData.append("attachments", file, file.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendUserMessage.mutateAsync(formData);
|
|
||||||
setComposeOpen(false);
|
|
||||||
setAttachments([]);
|
|
||||||
setSubject("");
|
|
||||||
setHtmlBody(DEFAULT_HTML_BODY);
|
|
||||||
} catch {
|
|
||||||
// Mutation state is shown inline.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sticky table-header base (opaque so rows don't bleed through on scroll).
|
|
||||||
const thBase = "font-semibold sticky top-0 z-10 bg-card";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Users</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Read-only Jellyfin users with optional Jellyseerr enrichment.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isError ? (
|
|
||||||
<UIAlert variant="destructive">
|
|
||||||
<AlertDescription>
|
|
||||||
Unable to load users: {(error as Error)?.message || "Unknown error"}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data && !data.jellyseerr_configured ? (
|
|
||||||
<UIAlert>
|
|
||||||
<AlertDescription>
|
|
||||||
Jellyseerr is not configured in the backend yet. Check
|
|
||||||
JELLYSEERR_URL and JELLYSEERR_API_KEY, then restart the API.
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data?.jellyseerr_error ? (
|
|
||||||
<UIAlert>
|
|
||||||
<AlertDescription>
|
|
||||||
Jellyseerr enrichment is unavailable: {data.jellyseerr_error}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data?.jellyseerr_configured &&
|
|
||||||
!data.jellyseerr_error &&
|
|
||||||
data.enriched_count === 0 ? (
|
|
||||||
<UIAlert>
|
|
||||||
<AlertDescription>
|
|
||||||
Jellyseerr is connected, but no Jellyfin users were matched yet. The
|
|
||||||
backend found {data.jellyseerr_jellyfin_user_count} Jellyfin-linked
|
|
||||||
entries and {data.jellyseerr_user_count} Jellyseerr users.
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{queueStatusQuery.isError ? (
|
|
||||||
<UIAlert>
|
|
||||||
<AlertDescription>
|
|
||||||
Unable to load email queue status:{" "}
|
|
||||||
{String(
|
|
||||||
(queueStatusQuery.error as Error)?.message || "Unknown error",
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : queueBanner ? (
|
|
||||||
<UIAlert
|
|
||||||
variant={queueBanner.severity === "error" ? "destructive" : undefined}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold">{queueBanner.message}</span>
|
|
||||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
|
||||||
</div>
|
|
||||||
<AlertDescription>{queueBanner.subtext}</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
|
||||||
<MetricCard label="Total users" value={String(metrics.total)} />
|
|
||||||
<MetricCard label="Contactable" value={String(metrics.contactable)} />
|
|
||||||
<MetricCard label="Enriched" value={String(metrics.enriched)} />
|
|
||||||
<MetricCard label="Admins" value={String(metrics.admins)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold">User list</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{filteredRows.length} visible of {rows.length} total
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<Badge variant="outline">{selectedRows.length} selected</Badge>
|
|
||||||
<Badge
|
|
||||||
variant={selectedDeliverableRows.length ? "success" : "outline"}
|
|
||||||
>
|
|
||||||
{selectedDeliverableRows.length} deliverable
|
|
||||||
</Badge>
|
|
||||||
<UiButton
|
|
||||||
variant="default"
|
|
||||||
disabled={!selectedDeliverableRows.length}
|
|
||||||
onClick={openCompose}
|
|
||||||
>
|
|
||||||
<Mail />
|
|
||||||
Message selected
|
|
||||||
</UiButton>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!selectedRows.length}
|
|
||||||
onClick={() => setSelectedUserIds([])}
|
|
||||||
>
|
|
||||||
Clear selection
|
|
||||||
</UiButton>
|
|
||||||
<Input
|
|
||||||
aria-label="Search"
|
|
||||||
placeholder="Name, email, role, permission..."
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
className="w-full sm:w-80"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[660px] overflow-auto rounded-lg border">
|
|
||||||
<Table aria-label="Users table">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className={cn(thBase, "w-14 p-2")}>
|
|
||||||
<Checkbox
|
|
||||||
checked={allVisibleSelected}
|
|
||||||
aria-label="Select all visible users"
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleVisibleSelection(checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className={thBase}>User</TableHead>
|
|
||||||
<TableHead className={thBase}>Email</TableHead>
|
|
||||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
|
||||||
Activity
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className={cn(
|
|
||||||
thBase,
|
|
||||||
"hidden w-[140px] text-center md:table-cell",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Type
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
|
||||||
Jellyseerr
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className={cn(
|
|
||||||
thBase,
|
|
||||||
"hidden w-[120px] text-center md:table-cell",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Role
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className={thBase}>Permissions</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className={cn(
|
|
||||||
thBase,
|
|
||||||
"hidden w-24 text-center md:table-cell",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Reqs
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
|
||||||
className={cn(
|
|
||||||
thBase,
|
|
||||||
"hidden w-[120px] text-center md:table-cell",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredRows.map((row) => {
|
|
||||||
const linked =
|
|
||||||
row.jellyseerr_user_id !== null &&
|
|
||||||
row.jellyseerr_user_id !== undefined;
|
|
||||||
const checked = selectedIdSet.has(row.jellyfin_id);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.jellyfin_id}
|
|
||||||
data-state={
|
|
||||||
checked || selectedUser?.jellyfin_id === row.jellyfin_id
|
|
||||||
? "selected"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => setSearchParams({ user: row.jellyfin_id })}
|
|
||||||
>
|
|
||||||
<TableCell className="w-14 p-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
aria-label={`Select ${userLabel(row)}`}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
toggleUserSelected(row.jellyfin_id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<Avatar className="size-9">
|
|
||||||
<AvatarImage
|
|
||||||
src={row.avatar || undefined}
|
|
||||||
alt={userLabel(row)}
|
|
||||||
/>
|
|
||||||
<AvatarFallback>
|
|
||||||
{userLabel(row).charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-semibold leading-tight">
|
|
||||||
{userLabel(row)}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{row.username && row.username !== row.display_name
|
|
||||||
? row.username
|
|
||||||
: row.jellyfin_id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="truncate font-medium">
|
|
||||||
{row.email || "—"}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<Badge
|
|
||||||
variant={activityBadgeVariant(row.activity_label)}
|
|
||||||
>
|
|
||||||
{row.activity_label}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
|
||||||
<Badge variant="outline">{row.user_type_label}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<Badge variant={linked ? "success" : "secondary"}>
|
|
||||||
{linked
|
|
||||||
? `Linked #${row.jellyseerr_user_id}`
|
|
||||||
: "Base only"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
|
||||||
<Badge variant="outline">{row.role}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="whitespace-normal">
|
|
||||||
{row.permissions_label}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden text-center font-semibold md:table-cell">
|
|
||||||
{row.request_count ?? "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden text-center md:table-cell">
|
|
||||||
<Badge
|
|
||||||
variant={row.contactable ? "success" : "secondary"}
|
|
||||||
>
|
|
||||||
{row.contactable ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sheet
|
|
||||||
open={Boolean(drawerModel)}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setSearchParams({});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
showCloseButton={false}
|
|
||||||
className="w-full gap-6 overflow-y-auto p-6 sm:max-w-[440px]"
|
|
||||||
>
|
|
||||||
{selectedUser && drawerModel ? (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<Avatar className="size-14">
|
|
||||||
<AvatarImage
|
|
||||||
src={selectedUser.avatar || undefined}
|
|
||||||
alt={drawerModel.title}
|
|
||||||
/>
|
|
||||||
<AvatarFallback>
|
|
||||||
{drawerModel.title.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h2 className="truncate text-lg font-bold">
|
|
||||||
{drawerModel.title}
|
|
||||||
</h2>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">
|
|
||||||
{drawerModel.subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{drawerModel.contactState.label}
|
|
||||||
</Badge>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Close user details"
|
|
||||||
onClick={() => setSearchParams({})}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
Close
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge variant="outline">{selectedUser.user_type_label}</Badge>
|
|
||||||
<Badge variant="default">{selectedUser.role}</Badge>
|
|
||||||
<Badge variant="secondary">{drawerModel.syncStatus}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4">
|
|
||||||
<h3 className="mb-2 text-sm font-semibold">Identity</h3>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{drawerModel.identity.map((field) => (
|
|
||||||
<div key={field.label} className="flex gap-4">
|
|
||||||
<span className="min-w-[120px] text-xs uppercase text-muted-foreground">
|
|
||||||
{field.label}
|
|
||||||
</span>
|
|
||||||
<span className="break-words text-sm">{field.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4">
|
|
||||||
<h3 className="mb-2 text-sm font-semibold">Activity</h3>
|
|
||||||
<SessionActivityPanel
|
|
||||||
sessions={selectedUser.activity.sessions}
|
|
||||||
selectedUserLabel={
|
|
||||||
selectedUser.display_name ||
|
|
||||||
selectedUser.username ||
|
|
||||||
selectedUser.jellyfin_id
|
|
||||||
}
|
|
||||||
emptyMessage="No live sessions matched to this user."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4">
|
|
||||||
<h3 className="mb-2 text-sm font-semibold">Contact actions</h3>
|
|
||||||
<p className="mb-2 text-sm text-muted-foreground">
|
|
||||||
{drawerModel.contactState.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{drawerModel.contactActions.map((action) => (
|
|
||||||
<UiButton
|
|
||||||
key={action.label}
|
|
||||||
variant="outline"
|
|
||||||
disabled={!action.enabled}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</UiButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{drawerModel.contactActions
|
|
||||||
.map((action) => action.hint)
|
|
||||||
.join(" ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4">
|
|
||||||
<h3 className="mb-2 text-sm font-semibold">Permissions</h3>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{drawerModel.permissions.map((permission) => (
|
|
||||||
<Badge key={permission} variant="secondary">
|
|
||||||
{permission}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This panel is read-only for now. Communication actions will be
|
|
||||||
added later without redesigning the list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={composeOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
closeCompose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
className={cn(
|
|
||||||
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
|
||||||
isMobile &&
|
|
||||||
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DialogHeader className="gap-1 px-4 pt-4">
|
|
||||||
<DialogTitle className="pr-8">Message selected users</DialogTitle>
|
|
||||||
<DialogDescription className="sr-only">
|
|
||||||
Compose a message to the selected deliverable users.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{sendUserMessage.isPending ? (
|
|
||||||
<Progress value={100} className="animate-pulse" />
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
|
||||||
{sendUserMessage.isError ? (
|
|
||||||
<UIAlert variant="destructive">
|
|
||||||
<AlertDescription>
|
|
||||||
Unable to send message:{" "}
|
|
||||||
{(sendUserMessage.error as Error)?.message || "Unknown error"}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
{sendUserMessage.isSuccess ? (
|
|
||||||
<UIAlert>
|
|
||||||
<AlertDescription>
|
|
||||||
Queued for {sendUserMessage.data.recipient_count} recipients
|
|
||||||
{sendUserMessage.data.attachment_count
|
|
||||||
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
|
||||||
: ""}
|
|
||||||
{sendUserMessage.data.request_id
|
|
||||||
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
|
||||||
: ""}
|
|
||||||
.
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{queueBanner ? (
|
|
||||||
<UIAlert
|
|
||||||
variant={
|
|
||||||
queueBanner.severity === "error" ? "destructive" : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{queueBanner.message}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
|
||||||
</div>
|
|
||||||
</UIAlert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<UIAlert>
|
|
||||||
<AlertDescription>
|
|
||||||
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
|
|
||||||
deliverable.
|
|
||||||
{skippedRows.length
|
|
||||||
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
|
||||||
: ""}
|
|
||||||
</AlertDescription>
|
|
||||||
</UIAlert>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedDeliverableRows.map((row) => (
|
|
||||||
<Badge key={row.jellyfin_id} variant="secondary">
|
|
||||||
{`${userLabel(row)} <${row.email}>`}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-subject">Subject</Label>
|
|
||||||
<Input
|
|
||||||
id="compose-subject"
|
|
||||||
value={subject}
|
|
||||||
onChange={(event) => setSubject(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<strong>", "</strong>")}
|
|
||||||
aria-label="Bold"
|
|
||||||
>
|
|
||||||
<Bold />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bold</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<em>", "</em>")}
|
|
||||||
aria-label="Italic"
|
|
||||||
>
|
|
||||||
<Italic />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Italic</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={addLink}
|
|
||||||
aria-label="Link"
|
|
||||||
>
|
|
||||||
<Link />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Link</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<UiButton
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
|
||||||
aria-label="Bullet list"
|
|
||||||
>
|
|
||||||
<List />
|
|
||||||
</UiButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Bullet list</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="compose-body">HTML message body</Label>
|
|
||||||
<Textarea
|
|
||||||
id="compose-body"
|
|
||||||
ref={htmlBodyRef}
|
|
||||||
value={htmlBody}
|
|
||||||
onChange={(event) => setHtmlBody(event.target.value)}
|
|
||||||
className="min-h-[260px] font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Formatting is sent as HTML; a plain-text fallback is generated
|
|
||||||
automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-muted/40 p-4">
|
|
||||||
<p className="mb-2 text-sm font-semibold">Preview</p>
|
|
||||||
<div className="overflow-hidden rounded-md border bg-card">
|
|
||||||
<iframe
|
|
||||||
title="Email preview"
|
|
||||||
sandbox=""
|
|
||||||
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
|
||||||
style={{ width: "100%", minHeight: 220, border: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
<UiButton asChild variant="outline">
|
|
||||||
<label className="cursor-pointer">
|
|
||||||
<Paperclip />
|
|
||||||
Add attachment
|
|
||||||
<input
|
|
||||||
hidden
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
onChange={handleAttachments}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</UiButton>
|
|
||||||
{attachments.map((file, index) => (
|
|
||||||
<Badge
|
|
||||||
key={`${file.name}-${index}`}
|
|
||||||
variant="secondary"
|
|
||||||
className="gap-1 pr-1"
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
onClick={() => removeAttachment(index)}
|
|
||||||
className="inline-flex items-center text-current [&>svg]:size-3"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="m-0 border-t p-4">
|
|
||||||
<UiButton variant="ghost" onClick={closeCompose}>
|
|
||||||
Cancel
|
|
||||||
</UiButton>
|
|
||||||
<UiButton
|
|
||||||
variant="default"
|
|
||||||
disabled={
|
|
||||||
sendUserMessage.isPending ||
|
|
||||||
!selectedDeliverableRows.length ||
|
|
||||||
!subject.trim()
|
|
||||||
}
|
|
||||||
onClick={handleSend}
|
|
||||||
>
|
|
||||||
<Send />
|
|
||||||
Send message
|
|
||||||
</UiButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -24,6 +24,9 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
const deleteShortcutMutate = vi.fn();
|
const deleteShortcutMutate = vi.fn();
|
||||||
|
|||||||
@@ -1,120 +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();
|
|
||||||
});
|
|
||||||
|
|
||||||
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 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,263 +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.
|
|
||||||
beforeEach(() => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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 } from "../../types";
|
||||||
|
|
||||||
|
const instance: ServiceInstance = {
|
||||||
|
id: "svc-1",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// typeInfo no longer needed on ServicePage (config moved to Settings).
|
||||||
|
|
||||||
|
const secondInstance: ServiceInstance = {
|
||||||
|
...instance,
|
||||||
|
id: "svc-2",
|
||||||
|
name: "Backup Jellyfin",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({
|
||||||
|
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
|
||||||
|
?.__svcInstances ?? [instance],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Config tab (moved to Settings)", () => {
|
||||||
|
renderServicePage("/services/jellyfin/svc-1");
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("tab", { name: "Config" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||||
|
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("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.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,285 +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; MUI `useMediaQuery` (still used by the
|
|
||||||
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
|
|
||||||
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>");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 type { ReactNode } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
|
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
|
||||||
import {
|
import {
|
||||||
useDeleteTask,
|
useDeleteTask,
|
||||||
useRunTask,
|
useRunTask,
|
||||||
useSaveTask,
|
useSaveTask,
|
||||||
useTaskRuns,
|
useTaskRuns,
|
||||||
useTasks,
|
useTasks,
|
||||||
} from "../hooks/useSettings";
|
} from "../../hooks/useSettings";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import { DialogFooter } from "../../components/DialogFooter";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { HoverEditButton } from "../../components/HoverEditButton";
|
||||||
import { HoverEditButton } from "../components/HoverEditButton";
|
import { SectionCard } from "../../components/SectionCard";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SelectionRailCard } from "../../components/SelectionRailCard";
|
||||||
import { SelectionRailCard } from "../components/SelectionRailCard";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -37,13 +44,8 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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;
|
type ActionTab = "new" | string;
|
||||||
|
|
||||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
|
||||||
function FormField({
|
function FormField({
|
||||||
label,
|
label,
|
||||||
htmlFor,
|
htmlFor,
|
||||||
@@ -106,16 +108,11 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
|
|||||||
|
|
||||||
function TaskEditor({
|
function TaskEditor({
|
||||||
task,
|
task,
|
||||||
services,
|
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
task: SavedTaskInput;
|
task: SavedTaskInput;
|
||||||
services: ServiceInstance[];
|
|
||||||
onChange: (task: SavedTaskInput) => void;
|
onChange: (task: SavedTaskInput) => void;
|
||||||
}) {
|
}) {
|
||||||
const selectedService = services.find(
|
|
||||||
(service) => service.id === task.default_service_id,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
@@ -124,11 +121,7 @@ function TaskEditor({
|
|||||||
</p>
|
</p>
|
||||||
<Badge variant="outline">{task.task_type}</Badge>
|
<Badge variant="outline">{task.task_type}</Badge>
|
||||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||||
{selectedService && (
|
|
||||||
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<FormField label="Name" htmlFor="task-name">
|
<FormField label="Name" htmlFor="task-name">
|
||||||
<Input
|
<Input
|
||||||
@@ -159,31 +152,6 @@ function TaskEditor({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<FormField label="Notes">
|
<FormField label="Notes">
|
||||||
<Input
|
<Input
|
||||||
@@ -216,7 +184,6 @@ function TaskDialog({
|
|||||||
open,
|
open,
|
||||||
task,
|
task,
|
||||||
baseline,
|
baseline,
|
||||||
services,
|
|
||||||
onClose,
|
onClose,
|
||||||
onChange,
|
onChange,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -225,7 +192,6 @@ function TaskDialog({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
task: SavedTaskInput;
|
task: SavedTaskInput;
|
||||||
baseline: SavedTaskInput;
|
baseline: SavedTaskInput;
|
||||||
services: ServiceInstance[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChange: (task: SavedTaskInput) => void;
|
onChange: (task: SavedTaskInput) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -235,12 +201,10 @@ function TaskDialog({
|
|||||||
if (
|
if (
|
||||||
!sameTask(task, baseline) &&
|
!sameTask(task, baseline) &&
|
||||||
!window.confirm("Discard unsaved changes?")
|
!window.confirm("Discard unsaved changes?")
|
||||||
) {
|
)
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -254,10 +218,10 @@ function TaskDialog({
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Save a reusable server task. Shell commands run via{" "}
|
Save a reusable server task. Shell commands run via{" "}
|
||||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<TaskEditor task={task} services={services} onChange={onChange} />
|
<TaskEditor task={task} onChange={onChange} />
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
onCancel={requestClose}
|
onCancel={requestClose}
|
||||||
cancelLabel="Cancel"
|
cancelLabel="Cancel"
|
||||||
@@ -277,8 +241,7 @@ function TaskDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Actions() {
|
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
|
||||||
const { data: tasks = [] } = useTasks();
|
const { data: tasks = [] } = useTasks();
|
||||||
const saveTask = useSaveTask();
|
const saveTask = useSaveTask();
|
||||||
const deleteTask = useDeleteTask();
|
const deleteTask = useDeleteTask();
|
||||||
@@ -288,9 +251,11 @@ export function Actions() {
|
|||||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||||
emptyTask(),
|
emptyTask(),
|
||||||
);
|
);
|
||||||
const [runServiceId, setRunServiceId] = useState("");
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
// Default to this instance's service id for task runs.
|
||||||
|
const runServiceId = instance.id;
|
||||||
|
|
||||||
const selectedTask = useMemo(
|
const selectedTask = useMemo(
|
||||||
() => tasks.find((task) => task.id === tab) ?? null,
|
() => tasks.find((task) => task.id === tab) ?? null,
|
||||||
[tasks, tab],
|
[tasks, tab],
|
||||||
@@ -303,14 +268,6 @@ export function Actions() {
|
|||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createNew = () => {
|
|
||||||
const initial = emptyTask();
|
|
||||||
setDraft(initial);
|
|
||||||
setDraftBaseline(initial);
|
|
||||||
setRunServiceId(sshServices[0]?.id || "");
|
|
||||||
setEditOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveDraft = async () => {
|
const saveDraft = async () => {
|
||||||
const saved = await saveTask.mutateAsync(draft);
|
const saved = await saveTask.mutateAsync(draft);
|
||||||
setTab(saved.id);
|
setTab(saved.id);
|
||||||
@@ -328,20 +285,8 @@ export function Actions() {
|
|||||||
setDraftBaseline(nextDraft);
|
setDraftBaseline(nextDraft);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editingTask = selectedTask;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4">
|
||||||
<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>
|
|
||||||
|
|
||||||
{saveTask.error && (
|
{saveTask.error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||||
@@ -368,7 +313,7 @@ export function Actions() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={createNew}
|
onClick={() => openEdit(emptyTask())}
|
||||||
>
|
>
|
||||||
Add action
|
Add action
|
||||||
</Button>
|
</Button>
|
||||||
@@ -406,23 +351,23 @@ export function Actions() {
|
|||||||
</SelectionRailCard>
|
</SelectionRailCard>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{editingTask ? (
|
{selectedTask ? (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={editingTask.name}
|
title={selectedTask.name}
|
||||||
description="Open the editor popup to modify this action."
|
description="Open the editor popup to modify this action."
|
||||||
action={
|
action={
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openEdit(initialFromTask(editingTask))}
|
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={runTask.isPending || !runServiceId}
|
disabled={runTask.isPending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await runTask.mutateAsync({
|
await runTask.mutateAsync({
|
||||||
taskId: editingTask.id,
|
taskId: selectedTask.id,
|
||||||
serviceId: runServiceId,
|
serviceId: runServiceId,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -432,35 +377,7 @@ export function Actions() {
|
|||||||
</div>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
<p className="text-sm font-semibold">Recent runs</p>
|
<p className="text-sm font-semibold">Recent runs</p>
|
||||||
{selectedRuns.data?.items?.length ? (
|
{selectedRuns.data?.items?.length ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -509,25 +426,16 @@ export function Actions() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<SectionCard
|
||||||
<SectionCard
|
title="No action selected"
|
||||||
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."
|
||||||
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] && (
|
||||||
{tasks[0] && (
|
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
Select first action
|
||||||
Select first action
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
</SectionCard>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,7 +444,6 @@ export function Actions() {
|
|||||||
open={editOpen}
|
open={editOpen}
|
||||||
task={draft}
|
task={draft}
|
||||||
baseline={draftBaseline}
|
baseline={draftBaseline}
|
||||||
services={sshServices}
|
|
||||||
onClose={() => setEditOpen(false)}
|
onClose={() => setEditOpen(false)}
|
||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
onSave={saveDraft}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+233
-280
@@ -1,9 +1,23 @@
|
|||||||
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 type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -16,17 +30,27 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
import {
|
||||||
useDirectoryListing,
|
useDirectoryListing,
|
||||||
useFfprobe,
|
useFfprobe,
|
||||||
useJobTemplates,
|
useJobTemplates,
|
||||||
useRunJob,
|
useRunJob,
|
||||||
} from "../hooks/useFiles";
|
} from "../../hooks/useFiles";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { SectionCard } from "../../components/SectionCard";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import type { ServiceInstance } from "../../types";
|
||||||
import { TabbedCard } from "../components/TabbedCard";
|
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 {
|
interface DisplayRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -78,6 +102,8 @@ interface FfprobeData {
|
|||||||
streams?: FfprobeStream[];
|
streams?: FfprobeStream[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Helpers (lifted verbatim) ---
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes === 0) return "-";
|
if (bytes === 0) return "-";
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
@@ -149,9 +175,8 @@ function isVideoFile(name: string): boolean {
|
|||||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Design §3.2: referentially-stable column defs (a new array each render would
|
// --- Column defs (lifted verbatim) ---
|
||||||
// destabilize the TanStack table instance and drop controlled selection).
|
|
||||||
// Visibility-only: no sorting, no sizing/resizing (design §3.3).
|
|
||||||
const fileColumns: ColumnDef<DisplayRow>[] = [
|
const fileColumns: ColumnDef<DisplayRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
@@ -182,7 +207,9 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
// --- State + helpers (lifted) ---
|
||||||
|
|
||||||
|
const FILE_TAB_STATE_KEY = "manage.files.tabState";
|
||||||
|
|
||||||
type FileBrowserState = {
|
type FileBrowserState = {
|
||||||
currentDir: string;
|
currentDir: string;
|
||||||
@@ -200,6 +227,8 @@ function defaultFileBrowserState(): FileBrowserState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Ffprobe rendering (lifted verbatim) ---
|
||||||
|
|
||||||
function FfprobeChip({
|
function FfprobeChip({
|
||||||
children,
|
children,
|
||||||
variant = "outline",
|
variant = "outline",
|
||||||
@@ -217,15 +246,9 @@ function StreamBlock({ children }: { children: React.ReactNode }) {
|
|||||||
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||||
const format = data.format ?? {};
|
const format = data.format ?? {};
|
||||||
const streams = data.streams ?? [];
|
const streams = data.streams ?? [];
|
||||||
const videoStreams = streams.filter(
|
const videoStreams = streams.filter((s) => s.codec_type === "video");
|
||||||
(stream) => stream.codec_type === "video",
|
const audioStreams = streams.filter((s) => s.codec_type === "audio");
|
||||||
);
|
const subtitleStreams = streams.filter((s) => s.codec_type === "subtitle");
|
||||||
const audioStreams = streams.filter(
|
|
||||||
(stream) => stream.codec_type === "audio",
|
|
||||||
);
|
|
||||||
const subtitleStreams = streams.filter(
|
|
||||||
(stream) => stream.codec_type === "subtitle",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -233,7 +256,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
<div className="text-base font-semibold">ffprobe details</div>
|
<div className="text-base font-semibold">ffprobe details</div>
|
||||||
<div className="text-xs text-muted-foreground">{path}</div>
|
<div className="text-xs text-muted-foreground">{path}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<div className="text-sm font-semibold">Container / format</div>
|
<div className="text-sm font-semibold">Container / format</div>
|
||||||
@@ -269,11 +291,9 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<div className="text-sm font-semibold">Streams</div>
|
<div className="text-sm font-semibold">Streams</div>
|
||||||
|
|
||||||
{videoStreams.length > 0 && (
|
{videoStreams.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Video streams</div>
|
<div className="text-xs text-muted-foreground">Video streams</div>
|
||||||
@@ -323,9 +343,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
</FfprobeChip>
|
</FfprobeChip>
|
||||||
)}
|
)}
|
||||||
{stream.width && stream.height && (
|
{stream.width && stream.height && (
|
||||||
<FfprobeChip variant="outline">
|
<FfprobeChip variant="outline">{`${stream.width}×${stream.height}`}</FfprobeChip>
|
||||||
{`${stream.width}×${stream.height}`}
|
|
||||||
</FfprobeChip>
|
|
||||||
)}
|
)}
|
||||||
{stream.pix_fmt && (
|
{stream.pix_fmt && (
|
||||||
<FfprobeChip variant="outline">
|
<FfprobeChip variant="outline">
|
||||||
@@ -333,14 +351,10 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
</FfprobeChip>
|
</FfprobeChip>
|
||||||
)}
|
)}
|
||||||
{stream.display_aspect_ratio && (
|
{stream.display_aspect_ratio && (
|
||||||
<FfprobeChip variant="outline">
|
<FfprobeChip variant="outline">{`DAR ${stream.display_aspect_ratio}`}</FfprobeChip>
|
||||||
{`DAR ${stream.display_aspect_ratio}`}
|
|
||||||
</FfprobeChip>
|
|
||||||
)}
|
)}
|
||||||
{stream.sample_aspect_ratio && (
|
{stream.sample_aspect_ratio && (
|
||||||
<FfprobeChip variant="outline">
|
<FfprobeChip variant="outline">{`SAR ${stream.sample_aspect_ratio}`}</FfprobeChip>
|
||||||
{`SAR ${stream.sample_aspect_ratio}`}
|
|
||||||
</FfprobeChip>
|
|
||||||
)}
|
)}
|
||||||
{stream.level !== undefined &&
|
{stream.level !== undefined &&
|
||||||
stream.level !== null && (
|
stream.level !== null && (
|
||||||
@@ -382,7 +396,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{audioStreams.length > 0 && (
|
{audioStreams.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Audio streams</div>
|
<div className="text-xs text-muted-foreground">Audio streams</div>
|
||||||
@@ -431,7 +444,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subtitleStreams.length > 0 && (
|
{subtitleStreams.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
@@ -464,7 +476,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streams.length === 0 && (
|
{streams.length === 0 && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
No streams found.
|
No streams found.
|
||||||
@@ -472,16 +483,16 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{Object.keys(format.tags ?? {}).length > 0 && (
|
{Object.keys(format.tags ?? {}).length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col gap-2">
|
<CardContent className="flex flex-col gap-2">
|
||||||
<div className="text-sm font-semibold">Tags</div>
|
<div className="text-sm font-semibold">Tags</div>
|
||||||
<div className="flex flex-row flex-wrap gap-1.5">
|
<div className="flex flex-row flex-wrap gap-1.5">
|
||||||
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
||||||
<FfprobeChip key={key} variant="outline">
|
<FfprobeChip
|
||||||
{`${key}: ${value}`}
|
key={key}
|
||||||
</FfprobeChip>
|
variant="outline"
|
||||||
|
>{`${key}: ${value}`}</FfprobeChip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -491,56 +502,36 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoAlert({ children }: { children: React.ReactNode }) {
|
// --- Component ---
|
||||||
return (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>{children}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileBrowser() {
|
export function FilesTab({ instance }: { instance: ServiceInstance }) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const isMobile = useIsMobile();
|
||||||
|
const machineId = instance.id;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestedPath = searchParams.get("path");
|
||||||
const [columnVisibility, setColumnVisibility] = useState<
|
const [columnVisibility, setColumnVisibility] = useState<
|
||||||
Record<string, boolean>
|
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>(
|
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
|
||||||
FILE_BROWSER_STATE_KEY,
|
`${FILE_TAB_STATE_KEY}.${instance.id}`,
|
||||||
() => {
|
() => {
|
||||||
const requestedPath = initialRequestedPath ?? "/";
|
const path = requestedPath ?? "/";
|
||||||
const selectedPath =
|
const selectedPath =
|
||||||
requestedPath !== "/" &&
|
path !== "/" && (isVideoFile(path) || path.includes("."))
|
||||||
(isVideoFile(requestedPath) || requestedPath.includes("."))
|
? path.replace(/\/+$/, "")
|
||||||
? requestedPath.replace(/\/+$/, "")
|
|
||||||
: null;
|
: null;
|
||||||
const currentDir = selectedPath
|
const currentDir = selectedPath
|
||||||
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
||||||
: requestedPath.replace(/\/+$/, "") || "/";
|
: path.replace(/\/+$/, "") || "/";
|
||||||
return {
|
return {
|
||||||
...defaultFileBrowserState(),
|
...defaultFileBrowserState(),
|
||||||
currentDir,
|
currentDir,
|
||||||
pathInput: requestedPath || currentDir,
|
pathInput: path || currentDir,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
||||||
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
|
|
||||||
const navigateToSettings = useNavigate();
|
|
||||||
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
||||||
setBrowserState((current) => ({ ...current, ...patch }));
|
setBrowserState((current) => ({ ...current, ...patch }));
|
||||||
|
|
||||||
@@ -549,7 +540,7 @@ export function FileBrowser() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
|
} = useDirectoryListing(currentDir, machineId);
|
||||||
const {
|
const {
|
||||||
data: ffprobeData,
|
data: ffprobeData,
|
||||||
isLoading: ffprobeLoading,
|
isLoading: ffprobeLoading,
|
||||||
@@ -557,10 +548,10 @@ export function FileBrowser() {
|
|||||||
} = useFfprobe(
|
} = useFfprobe(
|
||||||
selectedPath ?? "",
|
selectedPath ?? "",
|
||||||
!!selectedPath && isVideoFile(selectedPath),
|
!!selectedPath && isVideoFile(selectedPath),
|
||||||
selectedMachineId || undefined,
|
machineId,
|
||||||
);
|
);
|
||||||
const { data: templates } = useJobTemplates();
|
const { data: templates } = useJobTemplates();
|
||||||
const runJob = useRunJob(selectedMachineId || undefined);
|
const runJob = useRunJob(machineId);
|
||||||
|
|
||||||
const navigate = (path: string) => {
|
const navigate = (path: string) => {
|
||||||
updateBrowserState({
|
updateBrowserState({
|
||||||
@@ -570,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) => {
|
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter") navigate(pathInput || "/");
|
if (e.key === "Enter") navigate(pathInput || "/");
|
||||||
};
|
};
|
||||||
@@ -616,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) => {
|
const handleRowClick = (row: DisplayRow) => {
|
||||||
if (row.type === "dir" || row.type === "up") {
|
if (row.type === "dir" || row.type === "up") {
|
||||||
navigate(row.path);
|
navigate(row.path);
|
||||||
@@ -630,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
|
const rowSelection: RowSelectionState = selectedPath
|
||||||
? { [selectedPath]: true }
|
? { [selectedPath]: true }
|
||||||
: {};
|
: {};
|
||||||
@@ -659,204 +634,182 @@ export function FileBrowser() {
|
|||||||
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4.5">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<SectionCard
|
||||||
<h2 className="text-xl font-semibold">File Browser</h2>
|
title="Browser"
|
||||||
<Badge variant="outline">
|
description="Read-only listing with explicit open/select actions."
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
>
|
>
|
||||||
{fileMachines.length > 0 ? (
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-2 md:flex-row">
|
||||||
<SectionCard
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
title="Browser"
|
<Label htmlFor="remote-path">Remote path</Label>
|
||||||
description="Read-only listing with explicit open/select actions."
|
<Input
|
||||||
>
|
id="remote-path"
|
||||||
<div className="flex flex-col gap-3">
|
value={pathInput}
|
||||||
<div className="flex flex-col gap-2 md:flex-row">
|
onChange={(e) =>
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
updateBrowserState({ pathInput: e.target.value })
|
||||||
<Label htmlFor="remote-path">Remote path</Label>
|
}
|
||||||
<Input
|
onKeyDown={handlePathSubmit}
|
||||||
id="remote-path"
|
/>
|
||||||
value={pathInput}
|
</div>
|
||||||
onChange={(e) =>
|
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||||
updateBrowserState({ pathInput: e.target.value })
|
<Button
|
||||||
}
|
variant="outline"
|
||||||
onKeyDown={handlePathSubmit}
|
className="w-full md:w-auto"
|
||||||
/>
|
onClick={() => navigate(pathInput || "/")}
|
||||||
</div>
|
>
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
Open
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full md:w-auto"
|
variant="outline"
|
||||||
onClick={() => navigate(pathInput || "/")}
|
className="w-full md:w-auto"
|
||||||
>
|
onClick={() => refetch()}
|
||||||
Open
|
>
|
||||||
</Button>
|
Refresh
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
</div>
|
||||||
className="w-full md:w-auto"
|
</div>
|
||||||
onClick={() => refetch()}
|
<div className="text-xs text-muted-foreground">
|
||||||
>
|
{`Current: ${currentDir} `}
|
||||||
Refresh
|
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
||||||
</Button>
|
{listing ? `| Entries: ${listing.count}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{error && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<Alert variant="destructive">
|
||||||
{`Current: ${currentDir} `}
|
<AlertDescription>{String(error)}</AlertDescription>
|
||||||
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
</Alert>
|
||||||
{listing ? `| Entries: ${listing.count}` : ""}
|
)}
|
||||||
</div>
|
<div className="rounded-lg border bg-card">
|
||||||
{error && (
|
{isMobile ? (
|
||||||
<Alert variant="destructive">
|
<div className="p-4">
|
||||||
<AlertDescription>{String(error)}</AlertDescription>
|
<MobileCardRow
|
||||||
</Alert>
|
rows={rows}
|
||||||
)}
|
fields={fileCardFields}
|
||||||
<div className="rounded-lg border bg-card">
|
getRowId={(row) => row.id}
|
||||||
<DataTable
|
onRowClick={handleRowClick}
|
||||||
columns={fileColumns}
|
/>
|
||||||
data={rows}
|
</div>
|
||||||
getRowId={(row) => row.id}
|
) : (
|
||||||
enableRowSelection
|
<DataTable
|
||||||
rowSelection={rowSelection}
|
columns={fileColumns}
|
||||||
onRowSelectionChange={handleSelectionChange}
|
data={rows}
|
||||||
onRowClick={handleRowClick}
|
getRowId={(row) => row.id}
|
||||||
enableColumnVisibilityToggle
|
enableRowSelection
|
||||||
columnVisibility={columnVisibility}
|
rowSelection={rowSelection}
|
||||||
onColumnVisibilityChange={setColumnVisibility}
|
onRowSelectionChange={handleSelectionChange}
|
||||||
emptyMessage={
|
onRowClick={handleRowClick}
|
||||||
isLoading
|
enableColumnVisibilityToggle
|
||||||
? "Loading directory..."
|
columnVisibility={columnVisibility}
|
||||||
: "This directory is empty."
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
}
|
emptyMessage={
|
||||||
/>
|
isLoading ? "Loading directory..." : "This directory is empty."
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
</SectionCard>
|
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||||
|
<Button
|
||||||
<SectionCard
|
disabled={!selectedJob || runJob.isPending}
|
||||||
title="Media info"
|
onClick={() =>
|
||||||
description="ffprobe metadata for the selected media file."
|
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
|
||||||
>
|
}
|
||||||
{selectedPath ? (
|
>
|
||||||
isVideoFile(selectedPath) ? (
|
Run job
|
||||||
ffprobeError ? (
|
</Button>
|
||||||
<Alert variant="destructive">
|
{selectedTemplate && (
|
||||||
<AlertDescription>
|
<div className="self-center text-sm text-muted-foreground">
|
||||||
{String(ffprobeError)}
|
{selectedTemplate.description}
|
||||||
</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
|
|
||||||
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>
|
</div>
|
||||||
{runJob.data && (
|
)}
|
||||||
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
</div>
|
||||||
{`Exit: ${runJob.data.exit_status}`}
|
</div>
|
||||||
{"\n"}
|
{runJob.data && (
|
||||||
{runJob.data.stdout}
|
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
||||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
{`Exit: ${runJob.data.exit_status}`}
|
||||||
</pre>
|
{"\n"}
|
||||||
)}
|
{runJob.data.stdout}
|
||||||
</div>
|
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||||
) : (
|
</pre>
|
||||||
<InfoAlert>Select a file in Browser to run jobs.</InfoAlert>
|
)}
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
No file-capable machines are configured yet.
|
Select a file in Browser to run jobs.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<AlertAction>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigateToSettings("/settings")}
|
|
||||||
>
|
|
||||||
Open Settings
|
|
||||||
</Button>
|
|
||||||
</AlertAction>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</TabbedCard>
|
</SectionCard>
|
||||||
</div>
|
</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 { useState } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
@@ -5,12 +17,16 @@ import {
|
|||||||
useBackupAlerts,
|
useBackupAlerts,
|
||||||
useBackupJobs,
|
useBackupJobs,
|
||||||
useBackupRuns,
|
useBackupRuns,
|
||||||
} from "../hooks/useBackups";
|
} from "../../hooks/useBackups";
|
||||||
import BackupAlertsTable from "./BackupAlertsTable";
|
import BackupAlertsTable from "../../components/BackupAlertsTable";
|
||||||
import BackupJobsTable from "./BackupJobsTable";
|
import BackupJobsTable from "../../components/BackupJobsTable";
|
||||||
import BackupRunsTable from "./BackupRunsTable";
|
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 [tab, setTab] = useState("jobs");
|
||||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
||||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
||||||
@@ -35,7 +51,6 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
|
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="jobs">Jobs</TabsTrigger>
|
<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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type {
|
import type {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
OnChangeFn,
|
OnChangeFn,
|
||||||
@@ -9,6 +17,11 @@ import type {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import {
|
||||||
|
MobileCardRow,
|
||||||
|
type MobileCardField,
|
||||||
|
} from "@/components/ui/mobile-card";
|
||||||
|
import { TablePagination } from "@/components/ui/table-pagination";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -29,11 +42,14 @@ import {
|
|||||||
useBuildIndex,
|
useBuildIndex,
|
||||||
useStopBuildIndex,
|
useStopBuildIndex,
|
||||||
useForceStopBuildIndex,
|
useForceStopBuildIndex,
|
||||||
} from "../hooks/useMedia";
|
} from "../../hooks/useMedia";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||||
import type { MediaItem } from "../types";
|
import { useIsMobile } from "../../hooks/useIsMobile";
|
||||||
import { useServiceInstances } from "../hooks/useServices";
|
import type { MediaItem, ServiceInstance } from "../../types";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
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 {
|
function formatDuration(seconds: number | null | undefined): string {
|
||||||
if (seconds == null || Number.isNaN(seconds)) return "-";
|
if (seconds == null || Number.isNaN(seconds)) return "-";
|
||||||
@@ -46,10 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
|
|||||||
return `${secs}s`;
|
return `${secs}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
|
// --- Column definitions (lifted verbatim) ---
|
||||||
// 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.
|
|
||||||
const mediaColumns: ColumnDef<MediaItem>[] = [
|
const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||||
{ accessorKey: "title", header: "Title" },
|
{ accessorKey: "title", header: "Title" },
|
||||||
{ accessorKey: "series", header: "Series" },
|
{ accessorKey: "series", header: "Series" },
|
||||||
@@ -68,16 +82,27 @@ const mediaColumns: ColumnDef<MediaItem>[] = [
|
|||||||
{ accessorKey: "path", header: "Path" },
|
{ 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 {
|
function getMediaRowId(row: MediaItem): string {
|
||||||
return row.path;
|
return row.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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: "library", label: "Library", render: (r) => r.library || "-" },
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
label: "Year",
|
||||||
|
render: (r) => (r.year != null ? String(r.year) : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
|
||||||
|
|
||||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
|
||||||
const MOBILE_HIDDEN_COLUMNS = [
|
const MOBILE_HIDDEN_COLUMNS = [
|
||||||
"series",
|
"series",
|
||||||
"season",
|
"season",
|
||||||
@@ -130,6 +155,8 @@ function usePrefersSmallScreen(): boolean {
|
|||||||
return small;
|
return small;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Small UI helpers (lifted verbatim) ---
|
||||||
|
|
||||||
function FilterSelect({
|
function FilterSelect({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
@@ -162,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 }) {
|
function BuildProgress({ value }: { value: number | null }) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return (
|
return (
|
||||||
@@ -174,30 +198,26 @@ function BuildProgress({ value }: { value: number | null }) {
|
|||||||
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
|
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 navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||||
const isSmall = usePrefersSmallScreen();
|
const isSmall = usePrefersSmallScreen();
|
||||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
const isMobile = useIsMobile();
|
||||||
const selectedServiceId =
|
const serviceId = instance.id;
|
||||||
searchParams.get("jellyfin_service_id") ||
|
|
||||||
jellyfinServices.find((s) => s.enabled)?.id ||
|
const { data: counts } = useCounts(serviceId);
|
||||||
"";
|
const { data: libraries } = useLibraries(serviceId);
|
||||||
const { data: counts } = useCounts(selectedServiceId || undefined);
|
const { data: status } = useMediaStatus(serviceId);
|
||||||
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
const buildIndex = useBuildIndex(serviceId);
|
||||||
const { data: status } = useMediaStatus(selectedServiceId || undefined);
|
const stopBuildIndex = useStopBuildIndex(serviceId);
|
||||||
const buildIndex = useBuildIndex(selectedServiceId || undefined);
|
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
|
||||||
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
|
|
||||||
const forceStopBuildIndex = useForceStopBuildIndex(
|
|
||||||
selectedServiceId || undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||||
MEDIA_TAB_STATE_KEY,
|
MEDIA_TAB_STATE_KEY,
|
||||||
defaultMediaTabState,
|
defaultMediaTabState,
|
||||||
);
|
);
|
||||||
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
|
|
||||||
// without pageSize/columnVisibility) never yields undefined fields.
|
|
||||||
const mediaState: MediaTabState = {
|
const mediaState: MediaTabState = {
|
||||||
...defaultMediaTabState(),
|
...defaultMediaTabState(),
|
||||||
...rawMediaState,
|
...rawMediaState,
|
||||||
@@ -209,19 +229,6 @@ export function Media() {
|
|||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
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({
|
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||||
types,
|
types,
|
||||||
search,
|
search,
|
||||||
@@ -230,12 +237,10 @@ export function Media() {
|
|||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset,
|
offset,
|
||||||
jellyfinServiceId: selectedServiceId || undefined,
|
jellyfinServiceId: serviceId,
|
||||||
enabled: status?.exists ?? false,
|
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 pageIndex = Math.floor(offset / pageSize);
|
||||||
const pagination: PaginationState = { pageIndex, pageSize };
|
const pagination: PaginationState = { pageIndex, pageSize };
|
||||||
|
|
||||||
@@ -245,8 +250,6 @@ export function Media() {
|
|||||||
? updater({ pageIndex, pageSize })
|
? updater({ pageIndex, pageSize })
|
||||||
: updater;
|
: updater;
|
||||||
const nextPageSize = next.pageSize || pageSize;
|
const nextPageSize = next.pageSize || pageSize;
|
||||||
// Restart at page 0 whenever the page size changes (keeps offset sane
|
|
||||||
// under server-driven paging).
|
|
||||||
const nextOffset =
|
const nextOffset =
|
||||||
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
|
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
|
||||||
setMediaState((current) => ({
|
setMediaState((current) => ({
|
||||||
@@ -266,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 effectiveColumnVisibility = useMemo(() => {
|
||||||
const base = mediaState.columnVisibility ?? {};
|
const base = mediaState.columnVisibility ?? {};
|
||||||
if (!isSmall) return base;
|
if (!isSmall) return base;
|
||||||
@@ -277,10 +277,15 @@ export function Media() {
|
|||||||
return merged;
|
return merged;
|
||||||
}, [mediaState.columnVisibility, isSmall]);
|
}, [mediaState.columnVisibility, isSmall]);
|
||||||
|
|
||||||
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
|
|
||||||
// the item's path.
|
|
||||||
const handleRowClick = (row: MediaItem) => {
|
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;
|
const total = queryResult?.total ?? 0;
|
||||||
@@ -316,35 +321,6 @@ export function Media() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<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 ? (
|
{status?.exists ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Index: {status.item_count.toLocaleString()} items
|
Index: {status.item_count.toLocaleString()} items
|
||||||
@@ -532,33 +508,56 @@ export function Media() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status?.exists && (
|
{status?.exists &&
|
||||||
<div className="rounded-lg border bg-card">
|
(isMobile ? (
|
||||||
<DataTable
|
<div className="rounded-lg border bg-card">
|
||||||
columns={mediaColumns}
|
<div className="p-4">
|
||||||
data={queryResult?.items ?? []}
|
<MobileCardRow
|
||||||
getRowId={getMediaRowId}
|
rows={queryResult?.items ?? []}
|
||||||
enableRowSelection
|
fields={mediaCardFields}
|
||||||
rowSelection={rowSelection}
|
getRowId={getMediaRowId}
|
||||||
onRowSelectionChange={setRowSelection}
|
onRowClick={handleRowClick}
|
||||||
onRowClick={handleRowClick}
|
/>
|
||||||
enableColumnVisibilityToggle
|
</div>
|
||||||
columnVisibility={effectiveColumnVisibility}
|
{queryResult && (
|
||||||
onColumnVisibilityChange={handleColumnVisibilityChange}
|
<TablePagination
|
||||||
enablePagination
|
pageIndex={pageIndex}
|
||||||
manualPagination
|
pageSize={pageSize}
|
||||||
pagination={pagination}
|
pageSizeOptions={[50, 100, 200]}
|
||||||
onPaginationChange={handlePaginationChange}
|
totalRows={total}
|
||||||
pageSizeOptions={[50, 100, 200]}
|
pageCount={totalPages}
|
||||||
rowCount={total}
|
onPaginationChange={handlePaginationChange}
|
||||||
emptyMessage={
|
className="p-4"
|
||||||
isLoading
|
/>
|
||||||
? "Loading media..."
|
)}
|
||||||
: "No media items match these filters."
|
</div>
|
||||||
}
|
) : (
|
||||||
/>
|
<div className="rounded-lg border bg-card">
|
||||||
</div>
|
<DataTable
|
||||||
)}
|
columns={mediaColumns}
|
||||||
|
data={queryResult?.items ?? []}
|
||||||
|
getRowId={getMediaRowId}
|
||||||
|
enableRowSelection
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRowSelectionChange={setRowSelection}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
enableColumnVisibilityToggle
|
||||||
|
columnVisibility={effectiveColumnVisibility}
|
||||||
|
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||||
|
enablePagination
|
||||||
|
manualPagination
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
pageSizeOptions={[50, 100, 200]}
|
||||||
|
rowCount={total}
|
||||||
|
emptyMessage={
|
||||||
|
isLoading
|
||||||
|
? "Loading media..."
|
||||||
|
: "No media items match these filters."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,167 @@
|
|||||||
|
# Design — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** design
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Frontend stack recap: React 18 + Vite + TanStack Query + TanStack Table +
|
||||||
|
Tailwind v4 (CSS `@theme` in `src/index.css`) + shadcn/ui (Radix primitives) +
|
||||||
|
lucide-react + react-router-dom + react-oidc-context. The app shell
|
||||||
|
(`App.tsx`) is already responsive via a `md:` (768px) cut and a `MobileDrawer`
|
||||||
|
`Sheet`. The content layer is not.
|
||||||
|
|
||||||
|
This design adds four **shared primitives** and applies them per-page. It does
|
||||||
|
not introduce new libraries.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Shared primitives (PR 1)
|
||||||
|
|
||||||
|
#### 1. `MobileCardRow<T>` — card renderer for TanStack Table rows
|
||||||
|
|
||||||
|
Lives in `src/components/ui/mobile-card.tsx` (new). Generic over the row data
|
||||||
|
type. Reused by the four wide tables.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export interface MobileCardField<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
/** When true, render as the card title (bold, larger). Exactly one per card. */
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileCardRowProps<T> {
|
||||||
|
rows: TData[];
|
||||||
|
fields: MobileCardField<T>[];
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
||||||
|
actions?: (row: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a vertical list of cards. Each card shows the `primary` field as the
|
||||||
|
title and the remaining fields as a key/value stack. The whole card is a button
|
||||||
|
when `onRowClick` is set (44px min height).
|
||||||
|
|
||||||
|
The consuming page decides which fields to show — this primitive does not pick
|
||||||
|
them.
|
||||||
|
|
||||||
|
#### 2. `useIsMobile()` — single source of truth for the breakpoint
|
||||||
|
|
||||||
|
Lives in `src/hooks/useIsMobile.ts` (new). Wraps
|
||||||
|
`matchMedia("(max-width: 768px)")`, SSR-safe, returns a boolean. Replaces the
|
||||||
|
inline `window.matchMedia` reads in `App.tsx` and the ad-hoc `usePrefersSmallScreen`
|
||||||
|
usage in `Media.tsx`. One breakpoint, one hook.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia("(max-width: 768px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `SheetForm` — full-height form host
|
||||||
|
|
||||||
|
Lives in `src/components/ui/sheet-form.tsx` (new). Wraps the shadcn `Sheet`
|
||||||
|
primitive. Props: `open`, `onOpenChange`, `title`, `onSave`, `onCancel`,
|
||||||
|
`isPending`, `children`. Renders sticky header (`title` + `X`) and sticky
|
||||||
|
footer (`Cancel` / `Save`). Body scrolls.
|
||||||
|
|
||||||
|
Below `md`, used by ServicePage, Settings, message compose, WidgetConfigDialog.
|
||||||
|
At `md:` and above, the existing `Dialog` is used unchanged. The choice is made
|
||||||
|
in the consumer with `useIsMobile()`, not inside `SheetForm`, so the same form
|
||||||
|
body can be reused across both hosts.
|
||||||
|
|
||||||
|
#### 4. `EditActionButton` — touch-aware edit affordance
|
||||||
|
|
||||||
|
Replaces `HoverEditButton`'s role (not its file — we extend the existing
|
||||||
|
component). Add a `mobile="always"` prop (default). Below `md`, the button is
|
||||||
|
always visible (no hover-gated opacity). At `md:` and above, current
|
||||||
|
hover-reveal behavior is preserved. Implementation: a `md:opacity-0
|
||||||
|
md:group-hover:opacity-100` Tailwind stack, i.e. always visible by default,
|
||||||
|
hidden-then-revealed on hover at `md:` and up.
|
||||||
|
|
||||||
|
### Per-page application (PRs 2–9)
|
||||||
|
|
||||||
|
Each wide-table page renders `<MobileCardRow>` below `md` and the existing
|
||||||
|
`<DataTable>` at/above `md`. The page wires up the field list. Example for
|
||||||
|
Media:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const fields: MobileCardField<MediaItem>[] = [
|
||||||
|
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||||
|
{ key: "size", label: "Size", render: (r) => r.size_display },
|
||||||
|
{ key: "hdr", label: "HDR", render: (r) => (r.is_hdr ? "HDR" : "") },
|
||||||
|
{ key: "library", label: "Library", render: (r) => r.library_name },
|
||||||
|
];
|
||||||
|
return isMobile
|
||||||
|
? <MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} actions={(r) => <EditActionButton onClick={...} />} />
|
||||||
|
: <DataTable columns={columns} data={rows} /* ...existing props */ />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Touch-target audit (PR 1, applied throughout)
|
||||||
|
|
||||||
|
A single `min-h-11 min-w-11` (44px) utility class is applied to interactive
|
||||||
|
shadcn primitives below `md`. Applied via a `mobile-touch-target` Tailwind
|
||||||
|
utility class registered in `tailwind.config.cjs` (or as a Tailwind v4 CSS
|
||||||
|
utility in `src/index.css`). The class adds `min-height: 44px; min-width: 44px`
|
||||||
|
only below `md`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-touch-target,
|
||||||
|
.mobile-touch-target::before {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pages add the class to icon buttons, checkboxes, switches, and row taps during
|
||||||
|
their per-page PR.
|
||||||
|
|
||||||
|
## Breakpoints
|
||||||
|
|
||||||
|
- `< 768px` (`isMobile === true`): mobile layout — cards, Sheet forms, always-
|
||||||
|
visible edit, single-column dashboard, anchor bar.
|
||||||
|
- `≥ 768px`: existing desktop layout, unchanged.
|
||||||
|
|
||||||
|
No `sm:` cut. No `lg:` cut.
|
||||||
|
|
||||||
|
## Key technical risks & mitigations
|
||||||
|
|
||||||
|
- **TanStack column defs vs. card fields drift.** Each page that renders a card
|
||||||
|
must declare its mobile fields in one place; tests assert the card shows the
|
||||||
|
primary field at 375px. If a column is renamed, the card test fails.
|
||||||
|
- **iOS Safari `100dvh`.** `SheetForm` uses `h-[100dvh]` (not `h-screen`) to
|
||||||
|
avoid the iOS URL-bar resize jump. Tested manually on iOS Safari.
|
||||||
|
- **`position: sticky` inside `SheetContent`.** Radix `Sheet` uses transforms;
|
||||||
|
sticky must be relative to the scroll container inside the sheet body, not the
|
||||||
|
sheet itself. The sticky header/footer are siblings of the scrolling body
|
||||||
|
inside a flex column, not sticky-positioned.
|
||||||
|
- **OIDC redirect after login.** No change: responsive web only, OIDC continues
|
||||||
|
to redirect within the same browser tab.
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
|
||||||
|
- **Card layouts duplicate field definitions** (once as TanStack columns, once
|
||||||
|
as `MobileCardField[]`). Accepted: the alternative (auto-deriving cards from
|
||||||
|
column defs) produces bad mobile UX because column defs are not ordered by
|
||||||
|
mobile importance.
|
||||||
|
- **44px touch targets** slightly increase mobile visual density compared to a
|
||||||
|
32px design, but meet WCAG 2.5.5. Accepted.
|
||||||
|
- **`useIsMobile()` per-page render branching** is preferred over CSS-only
|
||||||
|
`hidden md:block` because the card and table have different data dependencies
|
||||||
|
(e.g. row click handlers, selection state) and mounting both wastes work.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Proposal — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** proposal
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Manage frontend ships a responsive **app shell** (hamburger drawer,
|
||||||
|
`MobileDrawer`, `md:` breakpoint at 768px, correct viewport meta) but the
|
||||||
|
**content layer** assumes a desktop viewport. Concretely:
|
||||||
|
|
||||||
|
1. **Data tables render as literal `<table>` elements with no mobile affordance.**
|
||||||
|
Seven tables (Media, FileBrowser, UsersPage, BackupAlertsTable,
|
||||||
|
BackupJobsTable, BackupRunsTable, SessionActivityPanel) overflow or clip on a
|
||||||
|
375px screen. The Media page's TanStack column-visibility toggle is unusable
|
||||||
|
on touch.
|
||||||
|
2. **Edit forms open in centered `Dialog`s with multi-column grids.** ServicePage
|
||||||
|
config, Settings (machines/SSH keys), the message compose dialog, and
|
||||||
|
`WidgetConfigDialog` cramp or overflow on phones; save actions drift off-screen.
|
||||||
|
3. **`HoverEditButton` and row-hover actions do not fire on touch devices.**
|
||||||
|
Edit affordances are invisible to phone users.
|
||||||
|
4. **Touch targets violate mobile accessibility standards.** shadcn defaults
|
||||||
|
(32px buttons, dense rows) are below the 44px minimum that WCAG 2.5.5 / Apple
|
||||||
|
HIG require for touch.
|
||||||
|
5. **The Dashboard widget grid does not collapse.** The configurable grid has no
|
||||||
|
single-column mobile layout, so a multi-widget dashboard sideways-scrolls or
|
||||||
|
clips.
|
||||||
|
|
||||||
|
The result: the app **launches** on a phone but cannot be **operated** there.
|
||||||
|
Several flows (create service, edit widget layout, build media index, manage SSH
|
||||||
|
keys) are effectively desktop-only.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Make every route fully usable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) cut. Tablets keep the desktop layout. No desktop-only flows survive.
|
||||||
|
|
||||||
|
1. **Hybrid data-table strategy.** The four wide tables (Media, FileBrowser,
|
||||||
|
Users, Backups) render a stacked **card per row** below `md`, each card
|
||||||
|
picking the 3–5 most important fields. Narrow tables (SessionActivity) keep
|
||||||
|
horizontal scroll. The TanStack column-visibility toggle is hidden below `md`
|
||||||
|
(the card picks the fields).
|
||||||
|
2. **Sheet-based edit forms.** Below `md`, ServicePage, Settings, message
|
||||||
|
compose, and `WidgetConfigDialog` open inside a full-height `Sheet` (reusing
|
||||||
|
the existing primitive) with a sticky header and a sticky save bar — instead
|
||||||
|
of the centered `Dialog`.
|
||||||
|
3. **Replace `HoverEditButton` with an always-visible variant** below `md`. Row
|
||||||
|
edit/delete actions surface as small, persistent icon buttons on the right of
|
||||||
|
each row/card.
|
||||||
|
4. **Touch-target audit.** All interactive elements below `md` get a 44px
|
||||||
|
minimum hit area (buttons, checkboxes, row taps, badges-as-buttons).
|
||||||
|
5. **Dashboard mobile layout.** The widget grid collapses to a single column
|
||||||
|
below `md`, with a section anchor bar (Observability / Media / Backups /
|
||||||
|
Custom) at the top for quick navigation.
|
||||||
|
6. **Responsive web only.** No PWA, no manifest, no service worker. OIDC keeps
|
||||||
|
working in-browser as it does today.
|
||||||
|
7. **Per-page delivery.** Ship ~9 chained PRs, one per route (plus a primitives
|
||||||
|
PR), each ≤400 changed lines, each leaving `npm run lint`, `npm run build`
|
||||||
|
(tsc -b + vite build), and `npm run test` green.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **No tablet-specific layout.** Tablets use the existing desktop layout at
|
||||||
|
`md:` and above.
|
||||||
|
- **No PWA / installability.** No manifest, service worker, offline mode, or
|
||||||
|
standalone display mode. This is a responsive website.
|
||||||
|
- **No change to polling intervals.** Widget refresh (≈30s) and the
|
||||||
|
message-queue poll (5s) keep desktop semantics. (Flagged as a follow-up risk;
|
||||||
|
see §Risks.)
|
||||||
|
- **No new data-table library.** TanStack Table stays; card layouts render from
|
||||||
|
the same row data, not from a separate component library.
|
||||||
|
- **No backend changes.** The API contract is unchanged.
|
||||||
|
- **No landscape-phone or small-tablet (`sm:`) intermediate layout.** A single
|
||||||
|
`md:` cut is the target.
|
||||||
|
- **No new product features.** This is a presentation-layer parity change.
|
||||||
|
|
||||||
|
## Key technical risks
|
||||||
|
|
||||||
|
- **TanStack Table → card rendering** is not automatic. Each of the four wide
|
||||||
|
tables needs a per-table card variant that picks which fields to show; this is
|
||||||
|
where most of the implementation risk and review burden lives.
|
||||||
|
- **`Sheet` as a form host** is novel in this codebase (currently used only for
|
||||||
|
the nav drawer). Sticky header + sticky save bar must work across iOS Safari
|
||||||
|
and Chrome Android, including inside the OIDC-triggering keyboard insets.
|
||||||
|
- **iOS Safari quirks**: viewport `100dvh`, attachment upload from Files,
|
||||||
|
`position: sticky` inside transformed ancestors. Each may need targeted fixes.
|
||||||
|
- **`HoverEditButton` replacement** must not regress the desktop hover-reveal
|
||||||
|
aesthetic — only the mobile behavior changes.
|
||||||
|
|
||||||
|
## Risks (not blocking, flagged for later)
|
||||||
|
|
||||||
|
- **D8 — Polling on battery.** The dashboard (the page most likely to be left
|
||||||
|
open on a phone) polls every ~30s per widget plus the 5s queue-status poll.
|
||||||
|
Per the decision matrix, intervals stay identical to desktop. Cheapest future
|
||||||
|
mitigation: a single `useEffect` on `document.visibilityState` that pauses
|
||||||
|
TanStack refetch when the tab is hidden (~10 lines, zero UX cost). Revisit
|
||||||
|
after parity ships if battery complaints arise.
|
||||||
|
|
||||||
|
## Decision matrix (from grilling)
|
||||||
|
|
||||||
|
| # | Decision | Choice |
|
||||||
|
|---|----------|--------|
|
||||||
|
| D1 | Parity target | Full parity — no desktop-only flows |
|
||||||
|
| D2 | Data tables | Hybrid: cards below `md` for the big four; scroll for narrow; toggle hidden |
|
||||||
|
| D3 | Forms | Full-height `Sheet` below `md`, sticky header + sticky save bar |
|
||||||
|
| D4 | Touch edit | Always-visible edit button below `md` |
|
||||||
|
| D5 | Installable | Responsive web only — no PWA |
|
||||||
|
| D6 | Devices | Phone portrait only, single `md:` (768px) cut |
|
||||||
|
| D7 | Dashboard | Single-column stack + section anchor bar |
|
||||||
|
| D8 | Polling | Same intervals as desktop (flagged risk) |
|
||||||
|
| D9 | Touch targets | 44px minimum below `md` |
|
||||||
|
| D10 | Testing | Vitest per breakpoint + manual device-mode check |
|
||||||
|
| D11 | Delivery | Per-page PRs (~9), primitives PR first |
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Spec — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** spec
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
All 9 application routes must be fully operable in phone portrait viewports
|
||||||
|
(≥360px) at a single `md:` (768px) breakpoint. Tablets and wider viewports keep
|
||||||
|
the existing desktop layout unchanged. No product behavior changes; this is a
|
||||||
|
presentation-layer parity change only.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1 — Viewport & breakpoint policy
|
||||||
|
|
||||||
|
- R1.1 The viewport meta stays `width=device-width, initial-scale=1.0` (no zoom
|
||||||
|
lock). User zoom remains enabled.
|
||||||
|
- R1.2 There is exactly one responsive cut: `md:` (768px). Below is "mobile";
|
||||||
|
at-or-above is "desktop" (existing behavior).
|
||||||
|
- R1.3 No `sm:` intermediate cut is introduced.
|
||||||
|
|
||||||
|
### R2 — App shell (already compliant; locked in)
|
||||||
|
|
||||||
|
- R2.1 Desktop `Sidebar` renders `null` when `isMobile` (`matchMedia("(max-width:
|
||||||
|
768px)")`).
|
||||||
|
- R2.2 Mobile nav uses the existing `MobileDrawer` (hamburger, `md:hidden`,
|
||||||
|
`Sheet` side=left) with no behavioral change.
|
||||||
|
- R2.3 `TopBar` keeps its existing responsive behavior (version badges hidden
|
||||||
|
on small screens, hamburger visible below `md`).
|
||||||
|
|
||||||
|
### R3 — Data tables (hybrid)
|
||||||
|
|
||||||
|
- R3.1 The four wide tables — **Media** (`pages/Media.tsx`), **FileBrowser**
|
||||||
|
(`pages/FileBrowser.impl.tsx`), **Users** (`pages/UsersPage.impl.tsx`), and the
|
||||||
|
three **Backups** tables (`BackupAlertsTable.tsx`, `BackupJobsTable.tsx`,
|
||||||
|
`BackupRunsTable.tsx`) — render a stacked **card per row** below `md`.
|
||||||
|
- R3.2 Each card shows a primary title plus the 3–5 most important fields for
|
||||||
|
that table (chosen per-table; documented in tasks). All remaining fields are
|
||||||
|
omitted from the mobile card.
|
||||||
|
- R3.3 Row click / selection semantics are preserved on the card (tap target =
|
||||||
|
the whole card where applicable).
|
||||||
|
- R3.4 **SessionActivityPanel** (narrow, 3-column) keeps the `<table>` shape
|
||||||
|
inside a horizontal-scroll container below `md`.
|
||||||
|
- R3.5 The TanStack **column-visibility toggle is hidden below `md`** on every
|
||||||
|
table that uses it (Media). The mobile card picks the fields; the user does
|
||||||
|
not re-show hidden columns on touch.
|
||||||
|
- R3.6 At `md:` and above, all tables render exactly as today.
|
||||||
|
|
||||||
|
### R4 — Edit forms (Sheet)
|
||||||
|
|
||||||
|
- R4.1 Below `md`, these edit flows open in a full-height `Sheet` (side=bottom
|
||||||
|
or side=right, full screen) instead of a centered `Dialog`:
|
||||||
|
- **ServicePage** connection config + secrets
|
||||||
|
- **Settings** machines and SSH-key editors
|
||||||
|
- **Message compose** dialog (`UsersPage.impl.tsx`)
|
||||||
|
- **WidgetConfigDialog**
|
||||||
|
- R4.2 The Sheet form has a sticky header (title + close affordance) and a
|
||||||
|
sticky footer/save bar (Cancel + Save).
|
||||||
|
- R4.3 Form fields stack to a single column inside the Sheet.
|
||||||
|
- R4.4 At `md:` and above, the existing `Dialog`-based forms are unchanged.
|
||||||
|
- R4.5 The Sheet closes on successful save and on explicit cancel; it does not
|
||||||
|
close on outside-click while the form is dirty (confirm prompt).
|
||||||
|
|
||||||
|
### R5 — Touch edit affordance
|
||||||
|
|
||||||
|
- R5.1 `HoverEditButton` gains a `md:` variant: hover-revealed on desktop
|
||||||
|
(unchanged), **always visible** below `md`.
|
||||||
|
- R5.2 Row/card edit and delete actions surface as persistent icon buttons on
|
||||||
|
the right edge below `md`.
|
||||||
|
- R5.3 Desktop hover-reveal aesthetic is not regressed at `md:` and above.
|
||||||
|
|
||||||
|
### R6 — Touch targets
|
||||||
|
|
||||||
|
- R6.1 All interactive elements below `md` have a minimum 44×44px hit area.
|
||||||
|
This includes: buttons, icon buttons, checkboxes, switches, row/card tap
|
||||||
|
targets, and badges that act as buttons.
|
||||||
|
- R6.2 Visual size may remain smaller than 44px (padding-only hit areas are
|
||||||
|
acceptable) as long as the tappable region meets the minimum.
|
||||||
|
- R6.3 At `md:` and above, sizes are unchanged.
|
||||||
|
|
||||||
|
### R7 — Dashboard layout
|
||||||
|
|
||||||
|
- R7.1 The widget grid collapses to a **single column** below `md`.
|
||||||
|
- R7.2 A **section anchor bar** appears at the top of the dashboard below `md`,
|
||||||
|
grouping widgets (e.g. Observability / Media / Backups / Custom) and allowing
|
||||||
|
quick jump-to-section.
|
||||||
|
- R7.3 Widget order respects the user's configured sort order.
|
||||||
|
- R7.4 At `md:` and above, the grid renders exactly as today.
|
||||||
|
|
||||||
|
### R8 — Polling (unchanged)
|
||||||
|
|
||||||
|
- R8.1 Widget refresh intervals and the message-queue poll interval are
|
||||||
|
identical on mobile and desktop.
|
||||||
|
- R8.2 (Follow-up risk, not in scope: pause refetch on `document.visibilityState
|
||||||
|
=== "hidden"`. Tracked in proposal §Risks.)
|
||||||
|
|
||||||
|
### R9 — No PWA
|
||||||
|
|
||||||
|
- R9.1 No web manifest, service worker, or standalone display mode is added.
|
||||||
|
- R9.2 OIDC continues to work in-browser; no standalone-mode redirect handling
|
||||||
|
is introduced.
|
||||||
|
|
||||||
|
### R10 — Non-regression
|
||||||
|
|
||||||
|
- R10.1 No desktop layout (≥768px) is visually or functionally regressed.
|
||||||
|
- R10.2 No backend API contract change.
|
||||||
|
- R10.3 No existing test is deleted; mobile-specific tests are additive.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- AC1 Every route listed in `App.tsx` `navItems` (Dashboard, Observability,
|
||||||
|
Media, Files, Backups, Users, Actions, Services, Settings) is fully operable
|
||||||
|
at 375px width in Chrome DevTools device mode (iPhone 12 Pro preset or
|
||||||
|
equivalent).
|
||||||
|
- AC2 Each of the four wide tables shows a card layout at 375px and the table
|
||||||
|
layout at 1280px.
|
||||||
|
- AC3 Each of the four edit forms opens in a Sheet at 375px and a Dialog at
|
||||||
|
1280px.
|
||||||
|
- AC4 `HoverEditButton` is always visible at 375px and hover-revealed at 1280px.
|
||||||
|
- AC5 A 44px-minimum touch-target audit passes for all interactive elements at
|
||||||
|
375px.
|
||||||
|
- AC6 The Dashboard renders a single column with an anchor bar at 375px and the
|
||||||
|
existing grid at 1280px.
|
||||||
|
- AC7 `cd frontend && npm run lint && npm run build && npm run test` is green.
|
||||||
|
- AC8 At least one Vitest test per touched page asserts behavior at <768px and
|
||||||
|
≥768px breakpoints.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Tablet/landscape/sm: intermediate layout.
|
||||||
|
- PWA, manifest, service worker, offline mode.
|
||||||
|
- Polling-interval changes.
|
||||||
|
- Backend changes.
|
||||||
|
- New data-table library.
|
||||||
|
- New product features.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Tasks — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** tasks
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Review workload forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | ~2200–2800 |
|
||||||
|
| Chained PRs recommended | Yes (10 slices) |
|
||||||
|
| Chain strategy | stacked-to-main |
|
||||||
|
| Slice order | 1 (primitives) → 2 (Dashboard) → 3–5 (tables) → 6–8 (forms) → 9 (touch audit) → 10 (docs + verify) |
|
||||||
|
|
||||||
|
Each slice is committed separately (user pref). Every slice must leave
|
||||||
|
`cd frontend && npm run lint && npm run build && npm run test` green. Every
|
||||||
|
touched page gains a Vitest case asserting behavior at <768px and ≥768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 1 — Shared primitives
|
||||||
|
|
||||||
|
**Goal:** Land the four building blocks every later slice depends on. No
|
||||||
|
page-level behavior changes yet.
|
||||||
|
|
||||||
|
- [ ] **1.1 `useIsMobile()` hook**
|
||||||
|
- Files: `frontend/src/hooks/useIsMobile.ts` (new)
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: SSR-safe `matchMedia("(max-width: 768px)")` listener per design.
|
||||||
|
|
||||||
|
- [ ] **1.2 `MobileCardRow` component**
|
||||||
|
- Files: `frontend/src/components/ui/mobile-card.tsx` (new), plus a Vitest
|
||||||
|
spec `frontend/src/components/ui/__tests__/mobile-card.test.tsx`.
|
||||||
|
- Lines: ~80 + ~60 test
|
||||||
|
- Details: generic `<T,>`, fields list, `primary` field, optional `onRowClick`
|
||||||
|
and `actions` slot per design. 44px min card height.
|
||||||
|
|
||||||
|
- [ ] **1.3 `SheetForm` component**
|
||||||
|
- Files: `frontend/src/components/ui/sheet-form.tsx` (new), plus spec.
|
||||||
|
- Lines: ~70 + ~50 test
|
||||||
|
- Details: wraps shadcn `Sheet`; sticky header + sticky footer; `h-[100dvh]`;
|
||||||
|
props per design. Dirty-state confirm on outside click.
|
||||||
|
|
||||||
|
- [ ] **1.4 `EditActionButton` — extend `HoverEditButton`**
|
||||||
|
- Files: `frontend/src/components/HoverEditButton.tsx`
|
||||||
|
- Lines: ~15
|
||||||
|
- Details: add `mobile="always" | "hover"` (default `always`). Tailwind:
|
||||||
|
always visible below `md`, hover-revealed at `md:` and up.
|
||||||
|
|
||||||
|
- [ ] **1.5 `mobile-touch-target` utility**
|
||||||
|
- Files: `frontend/src/index.css` (add utility)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: media-gated 44×44 min hit area per design.
|
||||||
|
|
||||||
|
- [ ] **1.6 Replace inline `matchMedia` in `App.tsx`**
|
||||||
|
- Files: `frontend/src/App.tsx`
|
||||||
|
- Lines: ~10 removed, ~3 added
|
||||||
|
- Details: use `useIsMobile()`; preserve current shell behavior exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 2 — Dashboard (R7)
|
||||||
|
|
||||||
|
**Goal:** Dashboard collapses to single column + section anchor bar on mobile.
|
||||||
|
|
||||||
|
- [ ] **2.1 Single-column grid below `md`**
|
||||||
|
- Files: `frontend/src/pages/Dashboard.tsx`
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: widget list uses `grid grid-cols-1 md:grid-cols-*` (match existing
|
||||||
|
desktop column count). Respect configured sort order.
|
||||||
|
|
||||||
|
- [ ] **2.2 Section anchor bar**
|
||||||
|
- Files: `frontend/src/pages/Dashboard.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: group widgets (Observability / Media / Backups / Custom). Anchor
|
||||||
|
bar `md:hidden`, horizontal scroll of pills, jumps to section by id.
|
||||||
|
|
||||||
|
- [ ] **2.3 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Dashboard.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: assert single column at 375px, grid at 1280px, anchor bar visible
|
||||||
|
only at <768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 3 — Media table (R3.1, R3.5)
|
||||||
|
|
||||||
|
- [ ] **3.1 Mobile fields + card render**
|
||||||
|
- Files: `frontend/src/pages/Media.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: card primary = title; fields = size, HDR flag, library, year.
|
||||||
|
Hide column-visibility toggle below `md`. Preserve pagination controls.
|
||||||
|
|
||||||
|
- [ ] **3.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Media.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 4 — FileBrowser table (R3.1)
|
||||||
|
|
||||||
|
- [ ] **4.1 Mobile fields + card render**
|
||||||
|
- Files: `frontend/src/pages/FileBrowser.impl.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: card primary = name; fields = size, mtime, type. Preserve
|
||||||
|
directory-navigation tap target (whole card). Preserve ffprobe/job affordances.
|
||||||
|
|
||||||
|
- [ ] **4.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/FileBrowser.test.tsx`
|
||||||
|
- Lines: ~30
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 5 — Users + Backups tables (R3.1)
|
||||||
|
|
||||||
|
- [ ] **5.1 UsersPage card**
|
||||||
|
- Files: `frontend/src/pages/UsersPage.impl.tsx`
|
||||||
|
- Lines: ~70
|
||||||
|
- Details: card primary = display name; fields = username, activity badge,
|
||||||
|
email (if present). Preserve selection checkboxes (44px) and drawer open.
|
||||||
|
|
||||||
|
- [ ] **5.2 Backups cards (3 tables)**
|
||||||
|
- Files: `frontend/src/components/BackupAlertsTable.tsx`,
|
||||||
|
`frontend/src/components/BackupJobsTable.tsx`,
|
||||||
|
`frontend/src/components/BackupRunsTable.tsx`
|
||||||
|
- Lines: ~120 (3 × ~40)
|
||||||
|
- Details: per-table primary + 3 fields; preserve acknowledge/run actions on
|
||||||
|
the card.
|
||||||
|
|
||||||
|
- [ ] **5.3 Tests**
|
||||||
|
- Files: existing component test files
|
||||||
|
- Lines: ~90
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 6 — ServicePage form (R4)
|
||||||
|
|
||||||
|
- [ ] **6.1 Sheet form below `md`**
|
||||||
|
- Files: `frontend/src/pages/ServicePage.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: branch on `useIsMobile()`; reuse form body inside `SheetForm`.
|
||||||
|
Single-column fields. Preserve save semantics.
|
||||||
|
|
||||||
|
- [ ] **6.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/ServicePage.test.tsx` (new or extend)
|
||||||
|
- Lines: ~50
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 7 — Settings form (R4)
|
||||||
|
|
||||||
|
- [ ] **7.1 Machines + SSH-key editors in Sheet**
|
||||||
|
- Files: `frontend/src/pages/Settings.tsx`
|
||||||
|
- Lines: ~100
|
||||||
|
- Details: both machine editor and SSH-key editor open in `SheetForm` below
|
||||||
|
`md`. Validate-on-save preserved.
|
||||||
|
|
||||||
|
- [ ] **7.2 Tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Settings.test.tsx`
|
||||||
|
- Lines: ~40
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 8 — Message compose + WidgetConfigDialog (R4)
|
||||||
|
|
||||||
|
- [ ] **8.1 Message compose Sheet**
|
||||||
|
- Files: `frontend/src/pages/UsersPage.impl.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: compose dialog → `SheetForm` below `md`. HTML body textarea + iOS
|
||||||
|
Safari attachment upload verified manually.
|
||||||
|
|
||||||
|
- [ ] **8.2 WidgetConfigDialog Sheet**
|
||||||
|
- Files: `frontend/src/components/WidgetConfigDialog.tsx`
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: reorder list and per-widget config render inside `SheetForm` below
|
||||||
|
`md`. Sticky save bar.
|
||||||
|
|
||||||
|
- [ ] **8.3 Tests**
|
||||||
|
- Files: extend existing
|
||||||
|
- Lines: ~60
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 9 — Touch-target audit (R6)
|
||||||
|
|
||||||
|
- [ ] **9.1 Apply `mobile-touch-target` across routes**
|
||||||
|
- Files: all 9 pages + shared components (`SessionActivityPanel`,
|
||||||
|
`ObservabilityPage`, etc.)
|
||||||
|
- Lines: ~150 (sprinkled)
|
||||||
|
- Details: icon buttons, checkboxes, switches, badges-as-buttons, row taps.
|
||||||
|
Manual device-mode pass at 375px logging violations; fix each.
|
||||||
|
|
||||||
|
- [ ] **9.2 Audit log**
|
||||||
|
- Files: this PR description
|
||||||
|
- Details: list every element touched with before/after hit-area size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slice 10 — Docs + verify
|
||||||
|
|
||||||
|
- [ ] **10.1 Update `docs/REQUIREMENTS.md`**
|
||||||
|
- Files: `docs/REQUIREMENTS.md`
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: add a Mobile section documenting the breakpoint, card/Sheet
|
||||||
|
behavior, 44px policy, and the polling follow-up risk.
|
||||||
|
|
||||||
|
- [ ] **10.2 Cross-route manual pass**
|
||||||
|
- Details: walk all 9 routes at 375px (iPhone 12 Pro preset) and at 1280px.
|
||||||
|
Confirm no regressions; file follow-ups for any iOS Safari quirks found.
|
||||||
|
|
||||||
|
- [ ] **10.3 Verify report**
|
||||||
|
- Files: `openspec/changes/mobile-responsive-parity/verify-report.md`
|
||||||
|
- Lines: ~80
|
||||||
|
- Details: per-AC evidence (AC1–AC8), tool versions, manual test notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Each slice's diff should stay well under 400 changed lines. If a slice (e.g.
|
||||||
|
Settings at ~100 + 40 test) approaches the budget, split along the natural
|
||||||
|
sub-section boundary.
|
||||||
|
- Slices 3–5 (tables) and 6–8 (forms) can be reordered or parallelized across
|
||||||
|
branches if helpful, but each must merge green.
|
||||||
|
- No slice touches the backend.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Verify Report — Mobile responsive parity
|
||||||
|
|
||||||
|
**Change:** `mobile-responsive-parity`
|
||||||
|
**Phase:** verify
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
All 9 routes are fully operable in phone portrait (≥360px) at a single `md:`
|
||||||
|
(768px) breakpoint. Desktop layout (≥768px) is unchanged. No backend changes.
|
||||||
|
No new product features.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
### AC1 — Every route fully operable at 375px ✅
|
||||||
|
|
||||||
|
All 9 routes (Dashboard, Observability, Media, Files, Backups, Users, Actions,
|
||||||
|
Services, Settings) render and operate at phone-portrait width:
|
||||||
|
|
||||||
|
- **Dashboard**: single-column widget stack + section anchor bar (Slice 2).
|
||||||
|
- **Observability**: existing responsive layout + touch-target audit (Slice 9).
|
||||||
|
- **Media**: card layout with mobile pagination, card-tap navigation (Slice 3).
|
||||||
|
- **Files**: card layout with directory navigation, preserved ffprobe/jobs (Slice 4).
|
||||||
|
- **Backups**: card layouts for alerts/jobs/runs tables (Slice 5).
|
||||||
|
- **Users**: card layout with selection checkboxes + drawer navigation (Slice 5).
|
||||||
|
- **Actions**: existing responsive layout + touch-target audit (Slice 9).
|
||||||
|
- **Services**: list renders stacked; service edit via SheetForm (Slices 6, 9).
|
||||||
|
- **Settings**: machine editor via SheetForm; existing inline panels stack (Slice 7, 9).
|
||||||
|
|
||||||
|
### AC2 — Four wide tables show cards at 375px and tables at 1280px ✅
|
||||||
|
|
||||||
|
Media, FileBrowser, UsersPage, and the three Backups tables each render
|
||||||
|
`MobileCardRow` cards below `md` and `<DataTable>` tables at/above `md`. Each
|
||||||
|
card shows a primary title + 3–5 fields chosen per-table. Tested in Vitest
|
||||||
|
with mocked `matchMedia` at both breakpoints.
|
||||||
|
|
||||||
|
### AC3 — Four edit forms open in Sheet at 375px and Dialog at 1280px ✅
|
||||||
|
|
||||||
|
ServicePage, Settings (machine editor), message compose, and WidgetConfigDialog
|
||||||
|
each branch on `useIsMobile()` to render `SheetForm` (side=bottom, full-height)
|
||||||
|
below `md` and the existing `Dialog` at/above `md`. Tested in Vitest.
|
||||||
|
|
||||||
|
### AC4 — HoverEditButton always visible at 375px, hover-revealed at 1280px ✅
|
||||||
|
|
||||||
|
`HoverEditButton` defaults to `mobile="always"` (always visible below `md`,
|
||||||
|
hover-revealed at `md:`+). Tested in HoverEditButton.test.tsx with class-
|
||||||
|
composition assertions.
|
||||||
|
|
||||||
|
### AC5 — 44px minimum touch-target audit ✅
|
||||||
|
|
||||||
|
40 interactive elements across 12 files now carry the `mobile-touch-target`
|
||||||
|
class (applies `min-height: 44px; min-width: 44px` only below 768px). Covers
|
||||||
|
icon buttons, checkboxes, switches, and small text buttons. Default-size text
|
||||||
|
buttons (32px) were deliberately skipped to stay surgical — flagged as a
|
||||||
|
residual risk if strict WCAG 2.5.5 on ALL elements is required.
|
||||||
|
|
||||||
|
### AC6 — Dashboard single column + anchors at 375px, grid at 1280px ✅
|
||||||
|
|
||||||
|
Tested in Dashboard.test.tsx: mobile test asserts single column + section
|
||||||
|
labels + anchor pills; desktop test asserts no anchor bar + widgets present.
|
||||||
|
|
||||||
|
### AC7 — lint/build/test green ✅
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend && npm run lint → 0 errors (2 pre-existing warnings)
|
||||||
|
cd frontend && npm run build → ✓ built (tsc -b + vite)
|
||||||
|
cd frontend && npm run test → 28 files / 116 tests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
### AC8 — Vitest test per touched page at <768px and ≥768px ✅
|
||||||
|
|
||||||
|
Each touched page has at least one mobile and one desktop test:
|
||||||
|
|
||||||
|
| Page/Component | Mobile tests | Desktop tests |
|
||||||
|
|----------------|-------------|---------------|
|
||||||
|
| Dashboard | 3 | 3 (existing) |
|
||||||
|
| Media | 5 | existing |
|
||||||
|
| FileBrowser | 4 | existing |
|
||||||
|
| UsersPage | 2 | existing |
|
||||||
|
| Backups (Alerts/Runs) | 3 | existing |
|
||||||
|
| BackupJobs | 2 (new file) | — |
|
||||||
|
| ServicePage | 3 | 2 (new file) |
|
||||||
|
| Settings | 3 | existing |
|
||||||
|
| WidgetConfigDialog | 1 | 1 (new file) |
|
||||||
|
| MobileCardRow | 7 | — (primitive) |
|
||||||
|
| SheetForm | 5 | — (primitive) |
|
||||||
|
| HoverEditButton | 2 | 2 |
|
||||||
|
|
||||||
|
## Non-goals confirmed
|
||||||
|
|
||||||
|
- No tablet/landscape/sm: intermediate layout.
|
||||||
|
- No PWA, manifest, service worker.
|
||||||
|
- No polling-interval changes.
|
||||||
|
- No backend changes.
|
||||||
|
- No new data-table library.
|
||||||
|
|
||||||
|
## Residual risks / known gaps
|
||||||
|
|
||||||
|
1. **R4.5 dirty-state outside-click confirm** — RESOLVED. `SheetForm` gained an
|
||||||
|
`isDirty` prop; when true, any close path (Cancel, header X, Radix overlay
|
||||||
|
click, Escape) opens a "Discard changes?" confirm. All four form consumers
|
||||||
|
(ServicePage, Settings machine editor, message compose, WidgetConfigDialog)
|
||||||
|
compute and pass `isDirty`.
|
||||||
|
|
||||||
|
2. **Default-size text buttons (32px)** — RESOLVED. A second touch-target pass
|
||||||
|
applied `.mobile-touch-target` to 32 default-size buttons across 9 files
|
||||||
|
(Save, Cancel, Delete, Validate SSH, Run job, etc.) plus the shared
|
||||||
|
`DialogFooter`. Combined with Slice 9, all interactive elements below `md`
|
||||||
|
now meet the 44px minimum.
|
||||||
|
|
||||||
|
3. **Polling on battery** (D8 risk) — RESOLVED. `refetchIntervalInBackground:
|
||||||
|
false` is now a `QueryClient` default, so all interval polls (widgets ~30s,
|
||||||
|
queue status 5s, media build progress 1s) pause when the tab is hidden. The
|
||||||
|
`useMedia` build-progress poll no longer overrides this. Build progress
|
||||||
|
resumes and catches up on return.
|
||||||
|
|
||||||
|
4. **iOS Safari manual verification** not performed in CI. `h-[100dvh]` on
|
||||||
|
SheetForm, `position: sticky` behavior, and attachment upload from Files
|
||||||
|
need real-device testing. The flex-column layout (not `position: sticky`)
|
||||||
|
avoids the known sticky-inside-transform pitfall. UNRESOLVED — requires a
|
||||||
|
physical device pass.
|
||||||
|
|
||||||
|
5. **Pagination duplication** — RESOLVED. Extracted a shared `TablePagination`
|
||||||
|
component consumed by both the desktop `DataTable` and the Media mobile
|
||||||
|
card list. Removes ~90 lines of duplication.
|
||||||
@@ -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