Compare commits

..

3 Commits

Author SHA1 Message Date
Developer 9370e52cfc Backend: Authentik directory client + endpoint (Slice 2)
AuthentikClient (clients/authentik.py) wraps Authentik's directory API:
- Bearer-token requests.Session, base_url normalization (rstrip / and
  trailing /api/v3), get() helper mirroring JellyseerrClient.
- users(search, page, page_size) calls GET /api/v3/core/users/ and
  normalizes Authentik's {pagination, results} shape into
  {items, total, page, page_size} for frontend consumption.

Directory endpoint (routers/authentik_users.py):
- GET /api/services/authentik/{service_id}/users resolves the service
  record, builds the client from decrypted api_token, returns the
  normalized user list.
- Graceful error handling matching monitoring.py: not-configured and
  unreachable return {items:[], total:0, error} with 200 (no 500s).
- _resolve_service_record copied in (self-contained; shared-utility
  extraction is a follow-up).

Router registered in main.py.

Tests: 12 new (8 client unit + 4 endpoint integration covering success,
not-configured, unreachable, URL/params). 268 backend tests pass; ruff
clean.

Refs openspec/changes/services-as-hub-ia/ (spec R6.2/R7.2, tasks slice 2).
2026-06-26 18:11:44 +00:00
Developer b3b167c075 Backend: add backups + authentik service types, absorb jellyseerr (Slice 1)
New service types:
- backups: BackupsConfig(ingestion_label), no secrets, summary widget kind.
  Modeled as a service so it can be named/multi-instanced like others.
- authentik: AuthentikConfig(base_url, timeout_seconds), api_token secret
  (required). Directory source for the upcoming Users tab.

Jellyseerr absorption:
- JellyfinConfig gains optional jellyseerr_url + jellyseerr_api_key fields.
- integrations/jellyseerr.py deleted; registry entry removed.
- clients/jellyseerr.py stays (JellyseerrClient still used by enrichment).
- One-time idempotent migration in settings_store.ensure_defaults():
  jellyseerr service rows merge into a paired Jellyfin (exactly-one merges;
  multiple picks first unpaired; none/all-paired drops with a logged
  warning). The api_key is decrypted from secrets before moving to config.

Registry is now 8 types: alertmanager, authentik, backups, grafana,
jellyfin, nextcloud, prometheus, ssh_tasks.

Tests: registry count updated to 8, jellyseerr-absent assertion, new-type
definition assertions, and migration tests (single-jellyfin merge, no-
jellyfin drop, idempotency). 256 backend tests pass; ruff clean.

Refs openspec/changes/services-as-hub-ia/ (spec R6, tasks slice 1).
2026-06-26 18:02:59 +00:00
Developer fe028b0e6f Plan services-as-hub IA rework (OpenSpec change)
Reorganize the app around services as the hub. Operational content
(Media, Files, Actions, Users, Backups) moves into type-specific tabs on
the service page. Top nav shrinks to Main Dashboard + named dashboards +
conditional per-type entries (appear when configured) + Services + Settings.

Decisions (D1-D17): conditional type entries; instance switcher for
multi-instance; Files/Actions into ssh_tasks tabs; Backups = new service
type; Users -> Authentik (in scope); Observability split per type (no
aggregate); main dashboard special at /; named dashboards = widgets +
pinned service links; each named dashboard = top entry; Authentik =
directory source (OIDC unchanged); Messaging -> Authentik users via SMTP;
Jellyseerr absorbed into Jellyfin config; standard tab skeleton
(Overview | content | Widgets | Config); Overview = health + metrics;
routing /services/:type/:id + /d/:slug; legacy routes 404; empty-state
CTAs.

Authentik directory client + endpoint included. 12 chained PRs forecast
(backend types -> frontend shell -> content tabs -> dashboards ->
cleanup -> verify).
2026-06-26 17:16:52 +00:00
58 changed files with 2496 additions and 4116 deletions
@@ -0,0 +1,115 @@
"""Authentik directory API client.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). This client wraps the Authentik REST API for browsing the user directory
with pagination and search. OIDC authentication is unchanged — this client is
for the directory, not SSO.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
logger = logging.getLogger(__name__)
class AuthentikClient:
"""Small wrapper around the Authentik core directory API."""
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
if not base_url:
raise ValueError("Authentik base_url is required")
if not api_token:
raise ValueError("Authentik API token is required")
self.base_url = base_url.rstrip("/")
if self.base_url.endswith("/api/v3"):
self.base_url = self.base_url[:-7]
self.api_token = api_token
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
def get(self, path: str, **params: Any) -> Any:
"""GET an Authentik endpoint and include useful response text on errors."""
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}/api/v3{path}",
params=clean_params,
timeout=self.timeout,
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning(
"Authentik GET %s failed status=%s url=%s",
path,
response.status_code,
response.url,
)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
return response.json()
def users(
self,
search: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
"""Return a normalized page of Authentik users.
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
Authentik response into ``{items, total, page, page_size}``. Each item
is the raw Authentik user dict (pk, username, name, email, avatar, …)
so the frontend can pick the fields it needs.
"""
payload = self.get(
"/core/users/",
search=search,
page=page,
page_size=page_size,
)
if not isinstance(payload, dict):
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
return {"items": [], "total": 0, "page": page, "page_size": page_size}
results = payload.get("results")
items: list[dict[str, Any]] = (
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
)
pagination = payload.get("pagination") or {}
total = 0
if isinstance(pagination, dict):
try:
total = int(pagination.get("count") or 0)
except (TypeError, ValueError):
total = 0
logger.info(
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
page,
page_size,
len(items),
total,
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
@@ -0,0 +1,35 @@
"""Authentik service definition.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
on the Authentik service page (Users + Messaging tabs). OIDC authentication
is unchanged -- this service type is for the directory, not SSO.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 10
DEFINITION = ServiceDefinition(
service_type="authentik",
name="Authentik",
description="User directory and identity provider integration.",
config_model=AuthentikConfig,
secret_fields=[
SecretField(key="api_token", label="API token", required=True),
],
widget_kinds=[],
)
@@ -0,0 +1,48 @@
"""Backups service definition.
Backups is modeled as a service type so it can be configured, named, and
multi-instanced like other services. Reports arrive via the existing REST
report endpoint; the ``ingestion_label`` disambiguates multi-instance
ingestion.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class BackupsConfig(ServiceConfigBase):
"""Non-secret Backups connection config."""
ingestion_label: str = "default"
class BackupsSummaryWidgetConfig(WidgetConfigBase):
"""Backup dashboard summary (jobs, runs, alerts)."""
# No user-overridable fields; the widget reads the internal backup tables.
pass
DEFINITION = ServiceDefinition(
service_type="backups",
name="Backups",
description="Backup job monitoring, run history, and alerting.",
config_model=BackupsConfig,
secret_fields=[],
widget_kinds=[
widget_kind(
kind="summary",
name="Summary",
description="Backup job summary and active alerts.",
model_cls=BackupsSummaryWidgetConfig,
default_config={},
refresh_interval_ms=60_000,
),
],
)
@@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
class JellyfinConfig(ServiceConfigBase): class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config.""" """Non-secret Jellyfin connection config.
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
paired Jellyseerr companion config, absorbed from the former standalone
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
When both are set, the Jellyfin service page renders a Requests tab backed
by Jellyseerr.
"""
base_url: ServiceBaseUrl base_url: ServiceBaseUrl
user_id: str = "" user_id: str = ""
timeout_seconds: int = 10 timeout_seconds: int = 10
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase): class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -1,33 +0,0 @@
"""Jellyseerr service definition.
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
own service type so multiple Jellyseerr instances are supported independently of
Jellyfin. It provides no dashboard widgets today.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
class JellyseerrConfig(ServiceConfigBase):
"""Non-secret Jellyseerr connection config."""
base_url: ServiceBaseUrl
DEFINITION = ServiceDefinition(
service_type="jellyseerr",
name="Jellyseerr",
description="Request management companion to Jellyfin.",
config_model=JellyseerrConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
],
widget_kinds=[],
)
@@ -7,10 +7,11 @@ There is no runtime plugin loading.
from __future__ import annotations from __future__ import annotations
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
@@ -20,9 +21,10 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
PROMETHEUS.service_type: PROMETHEUS, PROMETHEUS.service_type: PROMETHEUS,
ALERTMANAGER.service_type: ALERTMANAGER, ALERTMANAGER.service_type: ALERTMANAGER,
JELLYFIN.service_type: JELLYFIN, JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD, NEXTCLOUD.service_type: NEXTCLOUD,
SSH_TASKS.service_type: SSH_TASKS, SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
} }
@@ -21,6 +21,9 @@ from media_library_viewer_api.observability import (
record_request, record_request,
set_current_request_id, set_current_request_id,
) )
from media_library_viewer_api.routers import (
authentik_users as authentik_users_router,
)
from media_library_viewer_api.routers import backups as backups_router from media_library_viewer_api.routers import 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, users
from media_library_viewer_api.routers import services as services_router from media_library_viewer_api.routers import services as services_router
@@ -142,6 +145,7 @@ 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(services_router.router) app.include_router(services_router.router)
app.include_router(authentik_users_router.router)
@app.get("/api/health") @app.get("/api/health")
@@ -0,0 +1,82 @@
"""Authentik directory router — user lookup for the Authentik service page.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies a paginated directory query. Graceful "not configured" / "unreachable"
payloads (matching the monitoring router's pattern) so the UI always renders.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
def _resolve_service_record(
store: SettingsStore,
service_id: str | None = None,
) -> ServiceRecord | None:
"""Return the requested authentik instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled ``authentik`` instance is configured.
"""
service_type = "authentik"
if service_id:
row = store.get_service(service_id)
if not row or row.get("service_type") != service_type:
return None
if not row.get("enabled", True):
return None
return build_service_record(store, row)
for row in store.list_services(service_type):
if row.get("enabled", True):
return build_service_record(store, row)
return None
def _build_client(service: ServiceRecord) -> AuthentikClient:
base_url = str(service.config.get("base_url") or "").rstrip("/")
api_token = str(service.secrets.get("api_token") or "")
try:
timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError):
timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
def _empty(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
@router.get("/{service_id}/users")
def get_authentik_users(
service_id: str,
search: str | None = None,
page: int = 1,
page_size: int = 50,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
service = _resolve_service_record(store, service_id)
if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
return _empty("Authentik service not configured")
try:
client = _build_client(service)
return client.users(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable")
@@ -8,6 +8,7 @@ in the same UI.
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import sqlite3 import sqlite3
import time import time
import uuid import uuid
@@ -19,6 +20,8 @@ import paramiko
from media_library_viewer_api.models.widgets import _validate_config_keys from media_library_viewer_api.models.widgets import _validate_config_keys
logger = logging.getLogger(__name__)
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite") DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local" LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files"] DEFAULT_SERVICES = ["monitoring", "files"]
@@ -415,6 +418,75 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0: if not row or int(row[0]) == 0:
self._seed_local_machine() self._seed_local_machine()
self._migrate_jellyseerr_into_jellyfin()
def _migrate_jellyseerr_into_jellyfin(self) -> None:
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
paired -> drop with a logged warning.
"""
from media_library_viewer_api.services.secrets import decrypt_value
self.init_schema()
jellyseerr_rows: list[sqlite3.Row] = []
with self.connect() as conn:
jellyseerr_rows = conn.execute(
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
).fetchall()
if not jellyseerr_rows:
return
jellyfin_rows = self.list_services("jellyfin")
for js_row in jellyseerr_rows:
js_config = json.loads(js_row["config_json"] or "{}")
js_secrets = json.loads(js_row["secrets_json"] or "{}")
js_url = str(js_config.get("base_url", "")).strip()
js_api_key = str(js_secrets.get("api_key", "")).strip()
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
if js_api_key:
try:
js_api_key = decrypt_value(js_api_key)
except Exception:
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
js_api_key = ""
js_name = js_row["name"]
target = None
if len(jellyfin_rows) == 1:
target = jellyfin_rows[0]
elif len(jellyfin_rows) > 1:
for jf in jellyfin_rows:
if not str(jf["config"].get("jellyseerr_url", "")).strip():
target = jf
break
if target:
merged_config = dict(target["config"])
merged_config["jellyseerr_url"] = js_url
merged_config["jellyseerr_api_key"] = js_api_key
self.upsert_service(
{
"id": target["id"],
"service_type": "jellyfin",
"name": target["name"],
"config": merged_config,
"enabled": target["enabled"],
},
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
)
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
else:
logger.warning(
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
js_name,
)
with self.connect() as conn:
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
conn.commit()
def list_machines(self) -> list[dict[str, Any]]: def list_machines(self) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
+183
View File
@@ -0,0 +1,183 @@
"""Tests for AuthentikClient and the directory endpoint."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.main import app
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
from media_library_viewer_api.services.settings_store import SettingsStore
TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
reset_encryption_key_cache()
yield
reset_encryption_key_cache()
@pytest.fixture()
def store(tmp_path: Path) -> SettingsStore:
s = SettingsStore(tmp_path / "settings.sqlite")
s.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: s
yield s
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Client unit tests
# ---------------------------------------------------------------------------
class TestAuthentikClient:
def test_base_url_normalizes_trailing_slash(self) -> None:
c = AuthentikClient(base_url="https://auth.example.com/", api_token="t")
assert c.base_url == "https://auth.example.com"
def test_base_url_strips_api_v3_suffix(self) -> None:
c = AuthentikClient(base_url="https://auth.example.com/api/v3", api_token="t")
assert c.base_url == "https://auth.example.com"
def test_bearer_header_is_set(self) -> None:
c = AuthentikClient(base_url="https://auth.example.com", api_token="tok")
assert c.session.headers["Authorization"] == "Bearer tok"
def test_empty_base_url_raises(self) -> None:
with pytest.raises(ValueError):
AuthentikClient(base_url="", api_token="t")
def test_empty_api_token_raises(self) -> None:
with pytest.raises(ValueError):
AuthentikClient(base_url="https://auth.example.com", api_token="")
@patch.object(AuthentikClient, "get")
def test_users_normalizes_pagination(self, mock_get: MagicMock) -> None:
mock_get.return_value = {
"pagination": {"count": 42, "next": 2, "previous": 0, "current": 1},
"results": [
{"pk": 1, "username": "alice", "email": "alice@example.com"},
{"pk": 2, "username": "bob", "email": "bob@example.com"},
],
}
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
result = client.users(search="ali", page=1, page_size=2)
assert result["total"] == 42
assert result["page"] == 1
assert result["page_size"] == 2
assert len(result["items"]) == 2
assert result["items"][0]["username"] == "alice"
@patch.object(AuthentikClient, "get")
def test_users_handles_empty_results(self, mock_get: MagicMock) -> None:
mock_get.return_value = {"pagination": {"count": 0}, "results": []}
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
result = client.users()
assert result["items"] == []
assert result["total"] == 0
@patch.object(AuthentikClient, "get")
def test_users_handles_non_dict_payload(self, mock_get: MagicMock) -> None:
mock_get.return_value = []
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
result = client.users()
assert result["items"] == []
assert result["total"] == 0
@patch("media_library_viewer_api.clients.authentik.requests.Session")
def test_get_sends_correct_url_and_params(self, mock_session_cls: MagicMock) -> None:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_session.get.return_value = mock_response
c = AuthentikClient(base_url="https://auth.example.com", api_token="t")
c.get("/core/users/", search="x", page=2)
call_args = mock_session.get.call_args
assert call_args.kwargs["params"] == {"search": "x", "page": 2}
assert call_args.args[0] == "https://auth.example.com/api/v3/core/users/"
# ---------------------------------------------------------------------------
# Endpoint integration tests
# ---------------------------------------------------------------------------
class TestAuthentikUsersEndpoint:
def test_not_configured_returns_empty_with_error(self, store: SettingsStore) -> None:
client = TestClient(app)
response = client.get("/api/services/authentik/nonexistent/users")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
assert "error" in data
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
def test_success_returns_users(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
mock_client = MagicMock()
mock_client.users.return_value = {
"items": [{"pk": 1, "username": "alice"}],
"total": 1,
"page": 1,
"page_size": 50,
}
mock_client_cls.return_value = mock_client
created = store.upsert_service(
{
"service_type": "authentik",
"name": "Main",
"config": {"base_url": "https://auth.example.com"},
"enabled": True,
},
secret_values={"api_token": "secret-token"},
)
service_id = created["id"]
client = TestClient(app)
response = client.get(f"/api/services/authentik/{service_id}/users?search=ali")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["username"] == "alice"
assert data["total"] == 1
assert "error" not in data
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
def test_unreachable_returns_error(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
mock_client = MagicMock()
mock_client.users.side_effect = ConnectionError("refused")
mock_client_cls.return_value = mock_client
created = store.upsert_service(
{
"service_type": "authentik",
"name": "Main",
"config": {"base_url": "https://auth.example.com"},
"enabled": True,
},
secret_values={"api_token": "secret-token"},
)
service_id = created["id"]
client = TestClient(app)
response = client.get(f"/api/services/authentik/{service_id}/users")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert "error" in data
+132 -4
View File
@@ -57,24 +57,55 @@ def client(tmp_path):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_registry_contains_seven_service_types(): def test_registry_contains_eight_service_types():
assert set(SERVICE_DEFINITIONS) == { assert set(SERVICE_DEFINITIONS) == {
"grafana", "grafana",
"prometheus", "prometheus",
"alertmanager", "alertmanager",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"ssh_tasks", "ssh_tasks",
"backups",
"authentik",
} }
def test_jellyseerr_absorbed_into_jellyfin():
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
assert "jellyseerr" not in SERVICE_DEFINITIONS
jellyfin_config = get_service_definition("jellyfin").config_schema["properties"]
assert "jellyseerr_url" in jellyfin_config
assert "jellyseerr_api_key" in jellyfin_config
def test_backups_service_definition():
definition = get_service_definition("backups")
assert definition is not None
assert definition.secret_fields == []
assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
schema = definition.config_schema
assert "ingestion_label" in schema["properties"]
def test_authentik_service_definition():
definition = get_service_definition("authentik")
assert definition is not None
assert {sf.key for sf in definition.secret_fields} == {"api_token"}
assert definition.secret_fields[0].required is True
assert definition.widget_kinds == []
schema = definition.config_schema
assert "base_url" in schema["properties"]
assert "timeout_seconds" in schema["properties"]
def test_definitions_declare_widget_kinds(): def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"} assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"} assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
assert get_service_definition("nextcloud").widget_kinds == [] assert get_service_definition("nextcloud").widget_kinds == []
assert get_service_definition("authentik").widget_kinds == []
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"} assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
@@ -139,9 +170,10 @@ def test_list_service_types(client):
types = {item["service_type"] for item in response.json()} types = {item["service_type"] for item in response.json()}
assert types == { assert types == {
"alertmanager", "alertmanager",
"authentik",
"backups",
"grafana", "grafana",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"prometheus", "prometheus",
"ssh_tasks", "ssh_tasks",
@@ -265,7 +297,7 @@ def test_service_base_url_requires_http_schema(bad_url):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"] "service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
) )
def test_service_base_url_accepts_absolute_urls(service_type): def test_service_base_url_accepts_absolute_urls(service_type):
model = get_service_definition(service_type).config_model model = get_service_definition(service_type).config_model
@@ -390,3 +422,99 @@ def test_record_and_list_service_task_runs(client):
assert len(runs) == 1 assert len(runs) == 1
assert runs[0]["status"] == "success" assert runs[0]["status"] == "success"
assert runs[0]["stdout_tail"] == "ok" assert runs[0]["stdout_tail"] == "ok"
# ---------------------------------------------------------------------------
# Jellyseerr → Jellyfin migration (Slice 1.4 / 1.5)
# ---------------------------------------------------------------------------
def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
"""A standalone jellyseerr service merges into the only jellyfin instance."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
jellyfin = store.upsert_service(
{
"service_type": "jellyfin",
"name": "Main Jellyfin",
"config": {"base_url": "https://jellyfin.example.com"},
"enabled": True,
},
secret_values={"api_key": "jf-key"},
)
store.upsert_service(
{
"service_type": "jellyseerr",
"name": "Main Jellyseerr",
"config": {"base_url": "https://jellyseerr.example.com"},
"enabled": True,
},
secret_values={"api_key": "js-key"},
)
# Run migration via ensure_defaults (idempotent entry point).
store.ensure_defaults()
# Jellyseerr row is gone.
assert store.list_services("jellyseerr") == []
# Jellyfin config gained the absorbed fields.
migrated = store.get_service(jellyfin["id"])
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
assert migrated["config"]["jellyseerr_api_key"] == "js-key"
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):
"""An unpaired jellyseerr (no jellyfin) is dropped with a warning, no crash."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
store.upsert_service(
{
"service_type": "jellyseerr",
"name": "Orphan Jellyseerr",
"config": {"base_url": "https://jellyseerr.example.com"},
"enabled": True,
},
secret_values={"api_key": "js-key"},
)
store.ensure_defaults()
assert store.list_services("jellyseerr") == []
assert store.list_services("jellyfin") == []
def test_jellyseerr_migration_is_idempotent(tmp_path):
"""Running ensure_defaults twice does nothing the second time."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
store.upsert_service(
{
"service_type": "jellyfin",
"name": "JF",
"config": {"base_url": "https://jellyfin.example.com"},
"enabled": True,
},
secret_values={"api_key": "k"},
)
store.upsert_service(
{
"service_type": "jellyseerr",
"name": "JS",
"config": {"base_url": "https://jellyseerr.example.com"},
"enabled": True,
},
secret_values={"api_key": "k"},
)
store.ensure_defaults()
first_jellyfin = store.list_services("jellyfin")[0]
first_url = first_jellyfin["config"]["jellyseerr_url"]
store.ensure_defaults() # second run
second_jellyfin = store.list_services("jellyfin")[0]
assert second_jellyfin["config"]["jellyseerr_url"] == first_url
assert store.list_services("jellyseerr") == []
-54
View File
@@ -468,57 +468,3 @@ 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 35 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.
+14 -15
View File
@@ -28,7 +28,6 @@ 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 { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
@@ -62,16 +61,7 @@ import {
} from "lucide-react"; } from "lucide-react";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
queries: {
retry: 1,
refetchOnWindowFocus: false,
// Pause interval-based refetches (widgets ~30s, queue status 5s,
// media build progress 1s) when the tab is hidden. Saves battery on
// mobile (D8 follow-up). Build progress polls resume on return.
refetchIntervalInBackground: false,
},
},
}); });
function useDarkMode() { function useDarkMode() {
@@ -203,7 +193,7 @@ function MobileDrawer() {
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="mobile-touch-target md:hidden"> <Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
</SheetTrigger> </SheetTrigger>
@@ -290,7 +280,7 @@ function TopBar({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleDarkMode} onClick={onToggleDarkMode}
className="mobile-touch-target h-8 w-8" className="h-8 w-8"
> >
{darkMode ? ( {darkMode ? (
<Sun className="h-4 w-4" /> <Sun className="h-4 w-4" />
@@ -303,7 +293,7 @@ function TopBar({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onSignOut} onClick={onSignOut}
className="mobile-touch-target gap-2" className="gap-2"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span> <span className="hidden sm:inline">Logout</span>
@@ -326,7 +316,16 @@ function ShellLayout({
onToggleDarkMode: () => void; onToggleDarkMode: () => void;
}) { }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const isMobile = useIsMobile(); const [isMobile, setIsMobile] = useState(
() => 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">
@@ -8,11 +8,6 @@ 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 {
@@ -34,51 +29,7 @@ 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,11 +7,6 @@ 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 {
@@ -46,54 +41,7 @@ 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">
+27 -67
View File
@@ -15,11 +15,6 @@ 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 {
@@ -60,35 +55,8 @@ 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"
@@ -109,42 +77,34 @@ export default function BackupRunsTable({ runs }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
{isMobile ? ( <div className="overflow-hidden rounded-lg border border-border">
<MobileCardRow <Table aria-label="Backup runs">
rows={filteredRuns} <TableHeader>
fields={runCardFields} <TableRow className="bg-card hover:bg-card">
getRowId={(r) => r.id} <TableHead>Job</TableHead>
/> <TableHead>Status</TableHead>
) : ( <TableHead>Duration</TableHead>
<div className="overflow-hidden rounded-lg border border-border"> <TableHead>Size</TableHead>
<Table aria-label="Backup runs"> <TableHead>Started</TableHead>
<TableHeader> </TableRow>
<TableRow className="bg-card hover:bg-card"> </TableHeader>
<TableHead>Job</TableHead> <TableBody>
<TableHead>Status</TableHead> {filteredRuns.map((run) => (
<TableHead>Duration</TableHead> <TableRow key={run.id}>
<TableHead>Size</TableHead> <TableCell>{run.job_id}</TableCell>
<TableHead>Started</TableHead> <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> </TableRow>
</TableHeader> ))}
<TableBody> </TableBody>
{filteredRuns.map((run) => ( </Table>
<TableRow key={run.id}> </div>
<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>
); );
} }
+1 -6
View File
@@ -50,11 +50,7 @@ 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 <Button variant="ghost" onClick={onCancel}>
variant="ghost"
className="mobile-touch-target"
onClick={onCancel}
>
{cancelLabel} {cancelLabel}
</Button> </Button>
{secondaryAction ? ( {secondaryAction ? (
@@ -63,7 +59,6 @@ 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}
+5 -27
View File
@@ -4,48 +4,26 @@ 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 (desktop) / always-visible (mobile). * Hover-to-reveal edit affordance.
* *
* Keeps the `rail-edit` class plus the opacity base + transition so the * Keeps the `rail-edit` class plus the opacity-0 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"`
* Mobile behavior (`mobile="always"`, the default): the button is visible by * + lucide `Pencil`. Same exported props/display name.
* 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 mobile-touch-target ${hoverClasses}`} className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
aria-label={label} aria-label={label}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
@@ -141,7 +141,7 @@ function QueryError({
<AlertTitle>{label} failed</AlertTitle> <AlertTitle>{label} failed</AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{error.message}</span> <span className="break-words">{error.message}</span>
<Button variant="outline" size="sm" className="mobile-touch-target" onClick={() => refetch()}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="mr-1 h-3 w-3" /> <RefreshCw className="mr-1 h-3 w-3" />
Retry Retry
</Button> </Button>
@@ -255,7 +255,7 @@ function GrafanaLinkCard({
<div className="font-medium">{title}</div> <div className="font-medium">{title}</div>
<div className="text-sm text-muted-foreground">{description}</div> <div className="text-sm text-muted-foreground">{description}</div>
</div> </div>
<Button variant="outline" size="sm" className="mobile-touch-target" asChild> <Button variant="outline" size="sm" asChild>
<a <a
href={href} href={href}
target="_blank" target="_blank"
@@ -585,7 +585,7 @@ export function ObservabilityPage() {
title="No Node Exporter targets" title="No Node Exporter targets"
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets." description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
action={ action={
<Button variant="outline" size="sm" className="mobile-touch-target" asChild> <Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link> <Link to="/settings">Open Settings</Link>
</Button> </Button>
} }
@@ -641,7 +641,7 @@ export function ObservabilityPage() {
title="No Grafana service configured" title="No Grafana service configured"
description="Add a Grafana service instance to enable deep-links to dashboards and logs." description="Add a Grafana service instance to enable deep-links to dashboards and logs."
action={ action={
<Button variant="outline" size="sm" className="mobile-touch-target" asChild> <Button variant="outline" size="sm" asChild>
<Link to="/services">Open Services</Link> <Link to="/services">Open Services</Link>
</Button> </Button>
} }
@@ -653,7 +653,7 @@ export function ObservabilityPage() {
title="No machine selected" title="No machine selected"
description="Add monitoring machines in Settings to see Grafana drill-down links." description="Add monitoring machines in Settings to see Grafana drill-down links."
action={ action={
<Button variant="outline" size="sm" className="mobile-touch-target" asChild> <Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link> <Link to="/settings">Open Settings</Link>
</Button> </Button>
} }
@@ -168,7 +168,6 @@ 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);
+185 -206
View File
@@ -26,8 +26,6 @@ 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,
@@ -279,220 +277,201 @@ 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, beforeEach } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import BackupAlertsTable from "../BackupAlertsTable"; import BackupAlertsTable from "../BackupAlertsTable";
@@ -61,46 +61,3 @@ 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");
});
});
@@ -1,79 +0,0 @@
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, beforeEach } from "vitest"; import { describe, it, expect } 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,29 +57,3 @@ 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,23 +18,4 @@ 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");
});
}); });
@@ -1,67 +0,0 @@
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();
});
});
@@ -1,101 +0,0 @@
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();
});
});
@@ -1,168 +0,0 @@
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();
});
});
});
+87 -9
View File
@@ -6,6 +6,7 @@ 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,
@@ -17,7 +18,6 @@ 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,6 +34,13 @@ 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>[];
@@ -246,19 +253,90 @@ export function DataTable<TData, TValue = unknown>({
</div> </div>
{enablePagination && ( {enablePagination && (
<TablePagination <DataTablePagination
pageIndex={table.getState().pagination.pageIndex} table={table}
pageSize={table.getState().pagination.pageSize}
pageSizeOptions={pageSizeOptions} pageSizeOptions={pageSizeOptions}
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
pageCount={pageCount} pageCount={pageCount}
onPaginationChange={table.setPagination} manual={manualPagination}
rowCount={rowCount}
/> />
)} )}
</div> </div>
); );
} }
// DataTablePagination was extracted into the shared TablePagination component interface PaginationProps<TData> {
// (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable table: TableInstance<TData>;
// and the Media mobile card list consume it. pageSizeOptions: number[];
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>
);
}
-123
View File
@@ -1,123 +0,0 @@
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>
);
}
-152
View File
@@ -1,152 +0,0 @@
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>
);
}
@@ -1,122 +0,0 @@
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>
);
}
-38
View File
@@ -1,38 +0,0 @@
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;
}
+1 -4
View File
@@ -14,10 +14,7 @@ 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,
// Inherit the default refetchIntervalInBackground: false — pause the refetchIntervalInBackground: true,
// 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).
}); });
} }
-16
View File
@@ -100,19 +100,3 @@ 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;
}
}
+5 -5
View File
@@ -266,7 +266,7 @@ function TaskDialog({
confirmBusyLabel="Save action" confirmBusyLabel="Save action"
secondaryAction={ secondaryAction={
onDelete ? ( onDelete ? (
<Button variant="destructive" onClick={onDelete} className="mobile-touch-target"> <Button variant="destructive" onClick={onDelete}>
Delete Delete
</Button> </Button>
) : undefined ) : undefined
@@ -367,7 +367,7 @@ export function Actions() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="mobile-touch-target w-full" className="w-full"
onClick={createNew} onClick={createNew}
> >
Add action Add action
@@ -412,13 +412,13 @@ export function Actions() {
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 className="mobile-touch-target" <Button
variant="outline" variant="outline"
onClick={() => openEdit(initialFromTask(editingTask))} onClick={() => openEdit(initialFromTask(editingTask))}
> >
Edit Edit
</Button> </Button>
<Button className="mobile-touch-target" <Button
disabled={runTask.isPending || !runServiceId} disabled={runTask.isPending || !runServiceId}
onClick={async () => { onClick={async () => {
await runTask.mutateAsync({ await runTask.mutateAsync({
@@ -515,7 +515,7 @@ export function Actions() {
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." 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)} className="mobile-touch-target"> <Button variant="outline" onClick={() => setTab(tasks[0].id)}>
Select first action Select first action
</Button> </Button>
)} )}
+8 -154
View File
@@ -1,11 +1,5 @@
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";
@@ -32,122 +26,13 @@ import {
useSaveDashboardShortcut, useSaveDashboardShortcut,
} from "../hooks/useDashboard"; } from "../hooks/useDashboard";
import { useWidgetInstances } from "../hooks/useWidgets"; import { useWidgetInstances } from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices"; import type { DashboardShortcut, DashboardShortcutInput } from "../types";
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 (spec R7.2) ---
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 (
<>
{/* Anchor bar — horizontally scrollable pills (spec R7.2, md:hidden) */}
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{sections.map((section) => {
const meta = SECTION_META[section.id];
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>
{/* Sectioned widgets — single column (spec R7.1) */}
<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,
@@ -354,7 +239,6 @@ function ShortcutDialog({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="shortcut-enabled" id="shortcut-enabled"
className="mobile-touch-target"
checked={draft.enabled} checked={draft.enabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onChange({ ...draft, enabled: checked }) onChange({ ...draft, enabled: checked })
@@ -425,24 +309,13 @@ function ShortcutCard({
size="sm" size="sm"
disabled={!shortcut.enabled || !href} disabled={!shortcut.enabled || !href}
onClick={onOpen} onClick={onOpen}
className="mobile-touch-target"
> >
Open Open
</Button> </Button>
<Button <Button size="sm" variant="outline" onClick={onEdit}>
size="sm"
variant="outline"
onClick={onEdit}
className="mobile-touch-target"
>
Edit Edit
</Button> </Button>
<Button <Button size="sm" variant="destructive" onClick={onDelete}>
size="sm"
variant="destructive"
onClick={onDelete}
className="mobile-touch-target"
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -463,8 +336,6 @@ 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();
const { data: services = [] } = useServiceInstances();
const isMobile = useIsMobile();
const visibleWidgets = useMemo( const visibleWidgets = useMemo(
() => () =>
@@ -474,11 +345,6 @@ 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);
@@ -513,18 +379,10 @@ export function Dashboard() {
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."
action={ action={
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
variant="outline"
className="mobile-touch-target"
onClick={() => setWidgetDialogOpen(true)}
>
Edit dashboard Edit dashboard
</Button> </Button>
<Button <Button variant="outline" onClick={openCreateShortcut}>
variant="outline"
className="mobile-touch-target"
onClick={openCreateShortcut}
>
Add shortcut Add shortcut
</Button> </Button>
</div> </div>
@@ -559,13 +417,9 @@ export function Dashboard() {
)} )}
</SectionCard> </SectionCard>
{isMobile && mobileSections.length > 0 ? ( {visibleWidgets.map((widget) => (
<MobileWidgetSections sections={mobileSections} /> <WidgetInstanceCard key={widget.id} widget={widget} />
) : ( ))}
visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))
)}
<ShortcutDialog <ShortcutDialog
open={shortcutDialogOpen} open={shortcutDialogOpen}
+20 -50
View File
@@ -3,10 +3,6 @@ import { useNavigate, 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 {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertAction, 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";
@@ -28,7 +24,6 @@ import {
useRunJob, useRunJob,
} from "../hooks/useFiles"; } from "../hooks/useFiles";
import { usePersistentState } from "../hooks/usePersistentState"; import { usePersistentState } from "../hooks/usePersistentState";
import { useIsMobile } from "../hooks/useIsMobile";
import { useMonitoringSettings } from "../hooks/useSettings"; import { useMonitoringSettings } from "../hooks/useSettings";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard"; import { TabbedCard } from "../components/TabbedCard";
@@ -187,18 +182,6 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
}, },
]; ];
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
// Name is the primary identifier; type distinguishes dir/file/up at a glance;
// size and modified give the at-a-glance info a user browsing files on a phone
// needs. Ext is redundant with the name on mobile (the extension is visible in
// the filename itself). See OpenSpec change `mobile-responsive-parity`.
const fileCardFields: MobileCardField<DisplayRow>[] = [
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
{ key: "type", label: "Type", render: (r) => r.type },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
];
const FILE_BROWSER_STATE_KEY = "manage.files.browserState"; const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
type FileBrowserState = { type FileBrowserState = {
@@ -518,7 +501,6 @@ function InfoAlert({ children }: { children: React.ReactNode }) {
export function FileBrowser() { export function FileBrowser() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useIsMobile();
const [columnVisibility, setColumnVisibility] = useState< const [columnVisibility, setColumnVisibility] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
@@ -718,14 +700,14 @@ export function FileBrowser() {
<div className="flex flex-col gap-2 md:flex-row md:items-end"> <div className="flex flex-col gap-2 md:flex-row md:items-end">
<Button <Button
variant="outline" variant="outline"
className="w-full md:w-auto mobile-touch-target" className="w-full md:w-auto"
onClick={() => navigate(pathInput || "/")} onClick={() => navigate(pathInput || "/")}
> >
Open Open
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="w-full md:w-auto mobile-touch-target" className="w-full md:w-auto"
onClick={() => refetch()} onClick={() => refetch()}
> >
Refresh Refresh
@@ -743,34 +725,23 @@ export function FileBrowser() {
</Alert> </Alert>
)} )}
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
{isMobile ? ( <DataTable
<div className="p-4"> columns={fileColumns}
<MobileCardRow data={rows}
rows={rows} getRowId={(row) => row.id}
fields={fileCardFields} enableRowSelection
getRowId={(row) => row.id} rowSelection={rowSelection}
onRowClick={handleRowClick} onRowSelectionChange={handleSelectionChange}
/> onRowClick={handleRowClick}
</div> enableColumnVisibilityToggle
) : ( columnVisibility={columnVisibility}
<DataTable onColumnVisibilityChange={setColumnVisibility}
columns={fileColumns} emptyMessage={
data={rows} isLoading
getRowId={(row) => row.id} ? "Loading directory..."
enableRowSelection : "This directory is empty."
rowSelection={rowSelection} }
onRowSelectionChange={handleSelectionChange} />
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
emptyMessage={
isLoading
? "Loading directory..."
: "This directory is empty."
}
/>
)}
</div> </div>
</div> </div>
</SectionCard> </SectionCard>
@@ -837,7 +808,7 @@ export function FileBrowser() {
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end"> <div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
<Button className="mobile-touch-target" <Button
disabled={!selectedJob || runJob.isPending} disabled={!selectedJob || runJob.isPending}
onClick={() => onClick={() =>
runJob.mutate({ runJob.mutate({
@@ -879,7 +850,6 @@ export function FileBrowser() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => navigateToSettings("/settings")} onClick={() => navigateToSettings("/settings")}
className="mobile-touch-target"
> >
Open Settings Open Settings
</Button> </Button>
+30 -83
View File
@@ -9,11 +9,6 @@ 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";
@@ -36,7 +31,6 @@ import {
useForceStopBuildIndex, useForceStopBuildIndex,
} from "../hooks/useMedia"; } from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState"; import { usePersistentState } from "../hooks/usePersistentState";
import { useIsMobile } from "../hooks/useIsMobile";
import type { MediaItem } from "../types"; import type { MediaItem } from "../types";
import { useServiceInstances } from "../hooks/useServices"; import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard"; import { useCounts, useLibraries } from "../hooks/useDashboard";
@@ -81,29 +75,6 @@ function getMediaRowId(row: MediaItem): string {
return row.path; return row.path;
} }
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
// tech + context info a user scanning the library on a phone needs. Runtime,
// bitrate, resolution, codec etc. live on the desktop table only.
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) : "-"),
},
];
// Mobile pagination uses the shared TablePagination component
// (frontend/src/components/ui/table-pagination.tsx).
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. // Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
@@ -207,7 +178,6 @@ export function Media() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isSmall = usePrefersSmallScreen(); const isSmall = usePrefersSmallScreen();
const isMobile = useIsMobile();
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin"); const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId = const selectedServiceId =
searchParams.get("jellyfin_service_id") || searchParams.get("jellyfin_service_id") ||
@@ -397,7 +367,7 @@ export function Media() {
</p> </p>
)} )}
<Button className="mobile-touch-target" <Button
variant="outline" variant="outline"
onClick={() => buildIndex.mutate()} onClick={() => buildIndex.mutate()}
disabled={ disabled={
@@ -408,7 +378,7 @@ export function Media() {
</Button> </Button>
{buildRunning && ( {buildRunning && (
<> <>
<Button className="mobile-touch-target" <Button
variant="destructive" variant="destructive"
onClick={() => stopBuildIndex.mutate()} onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested} disabled={stopBuildIndex.isPending || buildCancelRequested}
@@ -419,7 +389,7 @@ export function Media() {
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10 mobile-touch-target" className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
onClick={() => forceStopBuildIndex.mutate()} onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending} disabled={forceStopBuildIndex.isPending}
> >
@@ -562,56 +532,33 @@ export function Media() {
</p> </p>
)} )}
{status?.exists && {status?.exists && (
(isMobile ? ( <div className="rounded-lg border bg-card">
<div className="rounded-lg border bg-card"> <DataTable
<div className="p-4"> columns={mediaColumns}
<MobileCardRow data={queryResult?.items ?? []}
rows={queryResult?.items ?? []} getRowId={getMediaRowId}
fields={mediaCardFields} enableRowSelection
getRowId={getMediaRowId} rowSelection={rowSelection}
onRowClick={handleRowClick} onRowSelectionChange={setRowSelection}
/> onRowClick={handleRowClick}
</div> enableColumnVisibilityToggle
{queryResult && ( columnVisibility={effectiveColumnVisibility}
<TablePagination onColumnVisibilityChange={handleColumnVisibilityChange}
pageIndex={pageIndex} enablePagination
pageSize={pageSize} manualPagination
pageSizeOptions={[50, 100, 200]} pagination={pagination}
totalRows={total} onPaginationChange={handlePaginationChange}
pageCount={totalPages} pageSizeOptions={[50, 100, 200]}
onPaginationChange={handlePaginationChange} rowCount={total}
className="p-4" emptyMessage={
/> isLoading
)} ? "Loading media..."
</div> : "No media items match these filters."
) : ( }
<div className="rounded-lg border bg-card"> />
<DataTable </div>
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>
); );
} }
+132 -233
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useParams } 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 { Button } from "@/components/ui/button";
@@ -12,7 +12,6 @@ import {
useServiceInstances, useServiceInstances,
useServiceTypes, useServiceTypes,
} from "../hooks/useServices"; } from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile";
import type { import type {
ServiceInstance, ServiceInstance,
ServiceInstanceInput, ServiceInstanceInput,
@@ -20,7 +19,6 @@ import type {
} from "../types"; } from "../types";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { SheetForm } from "@/components/ui/sheet-form";
import { getServiceBinding } from "../integrations/registry"; import { getServiceBinding } from "../integrations/registry";
function Field({ function Field({
@@ -65,17 +63,11 @@ export function ServicePage() {
[types, serviceType], [types, serviceType],
); );
const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({}); const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const isMobile = useIsMobile();
// The mobile SheetForm opens by default when the page loads: this page is
// reached via /services/:serviceType/:serviceId, always editing an existing
// instance, so there is no separate "open edit" trigger on mobile.
const [sheetOpen, setSheetOpen] = useState(true);
// Hydrate local form state once the instance loads. // Hydrate local form state once the instance loads.
if (instance && !hydrated) { if (instance && !hydrated) {
@@ -114,117 +106,6 @@ export function ServicePage() {
async function save() { async function save() {
await saveService.mutateAsync(buildInput()); await saveService.mutateAsync(buildInput());
// R4.5: close the sheet on successful save and return to the services list
// (on mobile the sheet IS the page, so closing it would strand the user).
if (isMobile) {
setSheetOpen(false);
navigate("/services");
}
}
const configFields = (
<ServiceConnectionFields
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
isMobile={isMobile}
/>
);
const widgetsCard =
binding.widgets.length > 0 ? (
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
<div className="flex flex-col gap-2">
{binding.widgets.map((w) => (
<div
key={w.kind}
className="flex items-center justify-between rounded border p-2"
>
<div>
<div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
<Badge variant="outline">{w.kind}</Badge>
</div>
))}
<p className="text-xs text-muted-foreground">
Add these to the dashboard from the dashboard's edit dialog.
</p>
</div>
</SectionCard>
) : null;
const confirmDelete = (
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
}}
/>
);
// Dirty when any editable field diverges from the persisted instance (mobile SheetForm R4.5 guard).
const isDirty =
name !== instance.name ||
enabled !== instance.enabled ||
JSON.stringify(draftConfig) !== JSON.stringify(instance.config);
if (isMobile) {
return (
<div className="flex flex-col gap-4">
<SheetForm
open={sheetOpen}
onOpenChange={setSheetOpen}
title={name || instance.name}
onSave={save}
onCancel={() => {
setSheetOpen(false);
navigate("/services");
}}
isPending={saveService.isPending}
isDirty={isDirty}
>
<div className="flex flex-col gap-6">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
{configFields}
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
Delete service
</Button>
{widgetsCard}
</div>
</SheetForm>
{confirmDelete}
</div>
);
} }
return ( return (
@@ -249,52 +130,81 @@ export function ServicePage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="service-enabled" id="service-enabled"
className="mobile-touch-target"
checked={enabled} checked={enabled}
onCheckedChange={setEnabled} onCheckedChange={setEnabled}
/> />
<Label htmlFor="service-enabled">Enabled</Label> <Label htmlFor="service-enabled">Enabled</Label>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<Button <Button onClick={save} disabled={saveService.isPending}>
className="mobile-touch-target"
onClick={save}
disabled={saveService.isPending}
>
Save Save
</Button> </Button>
<Button <Button variant="destructive" onClick={() => setDeleteOpen(true)}>
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
Delete Delete
</Button> </Button>
</div> </div>
</div> </div>
</SectionCard> </SectionCard>
{configFields} <ServiceConnectionCard
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
/>
{widgetsCard} {binding.widgets.length > 0 ? (
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
<div className="flex flex-col gap-2">
{binding.widgets.map((w) => (
<div
key={w.kind}
className="flex items-center justify-between rounded border p-2"
>
<div>
<div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
<Badge variant="outline">{w.kind}</Badge>
</div>
))}
<p className="text-xs text-muted-foreground">
Add these to the dashboard from the dashboard's edit dialog.
</p>
</div>
</SectionCard>
) : null}
{confirmDelete} <ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
}}
/>
</div> </div>
); );
} }
function ServiceConnectionFields({ function ServiceConnectionCard({
instance, instance,
typeInfo, typeInfo,
draftConfig, draftConfig,
onConfigChange, onConfigChange,
isMobile,
}: { }: {
instance: ServiceInstance; instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined; typeInfo: ServiceTypeInfo | undefined;
draftConfig: Record<string, unknown>; draftConfig: Record<string, unknown>;
onConfigChange: (config: Record<string, unknown>) => void; onConfigChange: (config: Record<string, unknown>) => void;
isMobile: boolean;
}) { }) {
const saveService = useSaveServiceInstance(); const saveService = useSaveServiceInstance();
// Empty-on-edit: local state starts blank; a blank field means "keep existing". // Empty-on-edit: local state starts blank; a blank field means "keep existing".
@@ -322,107 +232,96 @@ function ServiceConnectionFields({
{ type: typeof value === "number" ? "integer" : "string" }, { type: typeof value === "number" ? "integer" : "string" },
]); ]);
function handleUpdateConnection() {
const onlyChanged = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
saveService.mutate({
id: instance.id,
service_type: instance.service_type,
name: instance.name,
config: draftConfig,
secrets: onlyChanged,
enabled: instance.enabled,
});
setDraftSecrets({});
}
const fields = (
<div className="flex flex-col gap-3">
{configEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<div className="flex flex-col gap-3">
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<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 className="mobile-touch-target" onClick={handleUpdateConnection}>
Update connection
</Button>
</div>
);
// On mobile the fields render inside the SheetForm body without a card
// wrapper (the SheetForm already provides the container). On desktop they
// keep their original SectionCard framing.
if (isMobile) {
return <div className="flex flex-col gap-3">{fields}</div>;
}
return ( return (
<SectionCard <SectionCard
title="Connection" title="Connection"
description="Edit non-secret connection config and secret values." description="Edit non-secret connection config and secret values."
> >
{fields} <div className="flex flex-col gap-3">
{configEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<div className="flex flex-col gap-3">
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<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> </SectionCard>
); );
} }
+3 -5
View File
@@ -195,7 +195,7 @@ function CreateServiceDialog({
{!draft ? ( {!draft ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{types.map((t) => ( {types.map((t) => (
<Button className="mobile-touch-target" <Button
key={t.service_type} key={t.service_type}
variant="outline" variant="outline"
onClick={() => setDraft(emptyDraft(t.service_type))} onClick={() => setDraft(emptyDraft(t.service_type))}
@@ -234,7 +234,6 @@ function CreateServiceDialog({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="service-enabled" id="service-enabled"
className="mobile-touch-target"
checked={draft.enabled} checked={draft.enabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked }) setDraft({ ...draft, enabled: checked })
@@ -287,7 +286,7 @@ export function ServicesPage() {
title="Services" title="Services"
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest." description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
action={ action={
<Button variant="outline" onClick={() => setCreateOpen(true)} className="mobile-touch-target"> <Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add service Add service
</Button> </Button>
@@ -329,7 +328,6 @@ export function ServicesPage() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mobile-touch-target"
onClick={() => onClick={() =>
navigate(`/services/${s.service_type}/${s.id}`) navigate(`/services/${s.service_type}/${s.id}`)
} }
@@ -339,7 +337,7 @@ export function ServicesPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="mobile-touch-target h-8 w-8 text-destructive" className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(s.id)} onClick={() => setDeleteId(s.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
+45 -144
View File
@@ -17,8 +17,6 @@ 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";
@@ -128,30 +126,6 @@ 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,
@@ -256,7 +230,6 @@ 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 }))
@@ -466,7 +439,6 @@ 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={
@@ -560,7 +532,7 @@ function SSHKeyManager({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="mobile-touch-target w-full" className="w-full"
onClick={() => { onClick={() => {
clear(); clear();
}} }}
@@ -679,7 +651,6 @@ 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);
@@ -689,7 +660,6 @@ 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 () => {
@@ -713,16 +683,11 @@ function SSHKeyManager({
> >
{generateKey.isPending ? "Generating..." : "Generate key"} {generateKey.isPending ? "Generating..." : "Generate key"}
</Button> </Button>
<Button <Button variant="outline" onClick={clear}>
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)}
> >
@@ -788,11 +753,7 @@ 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 <Button variant="destructive" onClick={() => setOpen(true)}>
variant="destructive"
onClick={() => setOpen(true)}
className="mobile-touch-target"
>
Reset local database Reset local database
</Button> </Button>
{resetDatabase.error && ( {resetDatabase.error && (
@@ -819,7 +780,6 @@ 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))}
/> />
@@ -827,7 +787,6 @@ 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))}
/> />
@@ -835,7 +794,6 @@ 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))
@@ -892,7 +850,6 @@ 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(
() => () =>
@@ -1013,7 +970,7 @@ export function Settings() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="mobile-touch-target w-full" className="w-full"
onClick={() => { onClick={() => {
clearSSHValidation(); clearSSHValidation();
setMachineDraft(emptyMachine("local")); setMachineDraft(emptyMachine("local"));
@@ -1129,7 +1086,6 @@ 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(
@@ -1157,7 +1113,6 @@ 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)}
> >
@@ -1180,25 +1135,21 @@ export function Settings() {
)} )}
{tab === "danger" && <ResetLocalDatabaseCard />} {tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard> </TabbedCard>
{isMobile ? ( <Dialog
<SheetForm open={machineDialogOpen}
open={machineDialogOpen} onOpenChange={(open) => {
onOpenChange={(open) => { if (!open) closeMachineDialog();
if (!open) closeMachineDialog(); }}
}} >
title={machineDraft.id ? "Edit machine" : "Create machine"} <DialogContent className="sm:max-w-4xl">
onSave={() => { <DialogHeader>
void saveMachineDraft(machineDraft); <DialogTitle>
}} {machineDraft.id ? "Edit machine" : "Create machine"}
onCancel={closeMachineDialog} </DialogTitle>
isPending={saveMachine.isPending} <DialogDescription>
saveDisabled={ {machineDraft.mode === "local" ? "Local API host" : "SSH target"}
!machineDraft.name || </DialogDescription>
(machineDraft.mode === "ssh" && !machineDraft.host.trim()) </DialogHeader>
}
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={
@@ -1219,82 +1170,32 @@ export function Settings() {
sshValidationError={sshValidationError} sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus} sshValidationStatus={sshValidationStatus}
/> />
{machineDraft.id ? ( <DialogFooter
<Button onCancel={closeMachineDialog}
className="mobile-touch-target" cancelLabel="Cancel"
variant="destructive" onConfirm={() => {
onClick={() => setDeleteMachineId(machineDraft.id as string)} void saveMachineDraft(machineDraft);
> }}
Delete machine confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
</Button> confirmDisabled={
) : null} !machineDraft.name ||
</SheetForm> (machineDraft.mode === "ssh" && !machineDraft.host.trim())
) : ( }
<Dialog secondaryAction={
open={machineDialogOpen} machineDraft.id ? (
onOpenChange={(open) => { <Button
if (!open) closeMachineDialog(); variant="destructive"
}} onClick={() => {
> setDeleteMachineId(machineDraft.id as string);
<DialogContent className="sm:max-w-4xl"> }}
<DialogHeader> >
<DialogTitle> Delete
{machineDraft.id ? "Edit machine" : "Create machine"} </Button>
</DialogTitle> ) : undefined
<DialogDescription> }
{machineDraft.mode === "local" />
? "Local API host" </DialogContent>
: "SSH target"} </Dialog>
</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?"
+360 -461
View File
@@ -40,7 +40,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Sheet, SheetContent } from "@/components/ui/sheet";
import { SheetForm } from "@/components/ui/sheet-form";
import { import {
Table, Table,
TableBody, TableBody,
@@ -52,13 +51,8 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
import { SessionActivityPanel } from "../components/SessionActivityPanel"; import { SessionActivityPanel } from "../components/SessionActivityPanel";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { useUsers } from "../hooks/useUsers"; import { useUsers } from "../hooks/useUsers";
import { useActivity } from "../hooks/useDashboard"; import { useActivity } from "../hooks/useDashboard";
import { useIsMobile } from "../hooks/useIsMobile";
import { useSendUserMessage } from "../hooks/useSendUserMessage"; import { useSendUserMessage } from "../hooks/useSendUserMessage";
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus"; import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
import type { UserDirectoryItem } from "../types"; import type { UserDirectoryItem } from "../types";
@@ -69,9 +63,9 @@ import {
type UserStateItem, type UserStateItem,
} from "../userState"; } from "../userState";
// Local breakpoint for the compose dialog (slice 6b uses 900px for fullScreen). // Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free
// The shared `useIsMobile` from hooks/ (768px) drives the directory table branch. // matchMedia hook for the compose dialog's mobile fullScreen behavior.
function useComposeViewport(query = "(max-width: 900px)") { function useIsMobile(query = "(max-width: 900px)") {
const [mobile, setMobile] = useState(() => const [mobile, setMobile] = useState(() =>
typeof window !== "undefined" && typeof window.matchMedia === "function" typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(query).matches ? window.matchMedia(query).matches
@@ -108,35 +102,11 @@ function activityBadgeVariant(
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>"; const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
// Mobile card fields (spec R3.2): display name is primary; username, activity
// badge, and email give the at-a-glance info for scanning users on a phone.
// See OpenSpec change `mobile-responsive-parity`, tasks slice 5.1.
const userCardFields: MobileCardField<UserStateItem>[] = [
{ key: "name", label: "Name", render: (r) => userLabel(r), primary: true },
{
key: "username",
label: "Username",
render: (r) =>
r.username && r.username !== r.display_name ? r.username : r.jellyfin_id,
},
{
key: "activity",
label: "Activity",
render: (r) => (
<Badge variant={activityBadgeVariant(r.activity_label)}>
{r.activity_label}
</Badge>
),
},
{ key: "email", label: "Email", render: (r) => r.email || "—" },
];
export function UsersPage() { export function UsersPage() {
const { data, isError, error } = useUsers(); const { data, isError, error } = useUsers();
const { data: activity } = useActivity(); const { data: activity } = useActivity();
const queueStatusQuery = useUserMessageQueueStatus(); const queueStatusQuery = useUserMessageQueueStatus();
const sendUserMessage = useSendUserMessage(); const sendUserMessage = useSendUserMessage();
const isComposeMobile = useComposeViewport();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -509,183 +479,154 @@ export function UsersPage() {
</div> </div>
<div className="max-h-[660px] overflow-auto rounded-lg border"> <div className="max-h-[660px] overflow-auto rounded-lg border">
{isMobile ? ( <Table aria-label="Users table">
<div className="p-3"> <TableHeader>
<MobileCardRow <TableRow>
rows={filteredRows} <TableHead className={cn(thBase, "w-14 p-2")}>
fields={userCardFields} <Checkbox
getRowId={(r) => r.jellyfin_id} checked={allVisibleSelected}
onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })} aria-label="Select all visible users"
actions={(r) => { onCheckedChange={(checked) =>
const checked = selectedIdSet.has(r.jellyfin_id); toggleVisibleSelection(checked === true)
return ( }
<Checkbox />
checked={checked} </TableHead>
aria-label={`Select ${userLabel(r)}`} <TableHead className={thBase}>User</TableHead>
className="mobile-touch-target" <TableHead className={thBase}>Email</TableHead>
onClick={(e) => e.stopPropagation()} <TableHead className={cn(thBase, "w-[132px] text-center")}>
onCheckedChange={() => Activity
toggleUserSelected(r.jellyfin_id) </TableHead>
} <TableHead
/> className={cn(
); thBase,
}} "hidden w-[140px] text-center md:table-cell",
/> )}
</div> >
) : ( Type
<Table aria-label="Users table"> </TableHead>
<TableHeader> <TableHead className={cn(thBase, "w-[132px] text-center")}>
<TableRow> Jellyseerr
<TableHead className={cn(thBase, "w-14 p-2")}> </TableHead>
<Checkbox <TableHead
checked={allVisibleSelected} className={cn(
aria-label="Select all visible users" thBase,
onCheckedChange={(checked) => "hidden w-[120px] text-center md:table-cell",
toggleVisibleSelection(checked === true) )}
} >
/> Role
</TableHead> </TableHead>
<TableHead className={thBase}>User</TableHead> <TableHead className={thBase}>Permissions</TableHead>
<TableHead className={thBase}>Email</TableHead> <TableHead
<TableHead className={cn(thBase, "w-[132px] text-center")}> className={cn(
Activity thBase,
</TableHead> "hidden w-24 text-center md:table-cell",
<TableHead )}
className={cn( >
thBase, Reqs
"hidden w-[140px] text-center md:table-cell", </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 })}
> >
Type <TableCell className="w-14 p-2">
</TableHead> <Checkbox
<TableHead className={cn(thBase, "w-[132px] text-center")}> checked={checked}
Jellyseerr aria-label={`Select ${userLabel(row)}`}
</TableHead> onClick={(event) => event.stopPropagation()}
<TableHead onCheckedChange={() =>
className={cn( toggleUserSelected(row.jellyfin_id)
thBase, }
"hidden w-[120px] text-center md:table-cell", />
)} </TableCell>
> <TableCell>
Role <div className="flex items-center gap-3 min-w-0">
</TableHead> <Avatar className="size-9">
<TableHead className={thBase}>Permissions</TableHead> <AvatarImage
<TableHead src={row.avatar || undefined}
className={cn( alt={userLabel(row)}
thBase, />
"hidden w-24 text-center md:table-cell", <AvatarFallback>
)} {userLabel(row).charAt(0).toUpperCase()}
> </AvatarFallback>
Reqs </Avatar>
</TableHead> <div className="min-w-0">
<TableHead <div className="truncate font-semibold leading-tight">
className={cn( {userLabel(row)}
thBase, </div>
"hidden w-[120px] text-center md:table-cell", <div className="truncate text-xs text-muted-foreground">
)} {row.username && row.username !== row.display_name
> ? row.username
Contact : row.jellyfin_id}
</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>
</div> </div>
</TableCell> </div>
<TableCell> </TableCell>
<div className="truncate font-medium"> <TableCell>
{row.email || "—"} <div className="truncate font-medium">
</div> {row.email || "—"}
</TableCell> </div>
<TableCell className="text-center"> </TableCell>
<Badge <TableCell className="text-center">
variant={activityBadgeVariant(row.activity_label)} <Badge
> variant={activityBadgeVariant(row.activity_label)}
{row.activity_label} >
</Badge> {row.activity_label}
</TableCell> </Badge>
<TableCell className="hidden text-center md:table-cell"> </TableCell>
<Badge variant="outline">{row.user_type_label}</Badge> <TableCell className="hidden text-center md:table-cell">
</TableCell> <Badge variant="outline">{row.user_type_label}</Badge>
<TableCell className="text-center"> </TableCell>
<Badge variant={linked ? "success" : "secondary"}> <TableCell className="text-center">
{linked <Badge variant={linked ? "success" : "secondary"}>
? `Linked #${row.jellyseerr_user_id}` {linked
: "Base only"} ? `Linked #${row.jellyseerr_user_id}`
</Badge> : "Base only"}
</TableCell> </Badge>
<TableCell className="hidden text-center md:table-cell"> </TableCell>
<Badge variant="outline">{row.role}</Badge> <TableCell className="hidden text-center md:table-cell">
</TableCell> <Badge variant="outline">{row.role}</Badge>
<TableCell className="whitespace-normal"> </TableCell>
{row.permissions_label} <TableCell className="whitespace-normal">
</TableCell> {row.permissions_label}
<TableCell className="hidden text-center font-semibold md:table-cell"> </TableCell>
{row.request_count ?? "—"} <TableCell className="hidden text-center font-semibold md:table-cell">
</TableCell> {row.request_count ?? "—"}
<TableCell className="hidden text-center md:table-cell"> </TableCell>
<Badge <TableCell className="hidden text-center md:table-cell">
variant={row.contactable ? "success" : "secondary"} <Badge
> variant={row.contactable ? "success" : "secondary"}
{row.contactable ? "Yes" : "No"} >
</Badge> {row.contactable ? "Yes" : "No"}
</TableCell> </Badge>
</TableRow> </TableCell>
); </TableRow>
})} );
</TableBody> })}
</Table> </TableBody>
)} </Table>
</div> </div>
</div> </div>
</div> </div>
@@ -814,271 +755,229 @@ export function UsersPage() {
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */} <Dialog
{(() => { open={composeOpen}
const composeBody = ( onOpenChange={(open) => {
<> if (!open) {
{sendUserMessage.isPending ? ( closeCompose();
<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 ? ( <DialogContent
<UIAlert variant="destructive"> className={cn(
<AlertDescription> "flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
Unable to send message:{" "} isMobile &&
{(sendUserMessage.error as Error)?.message || "inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
"Unknown error"} )}
</AlertDescription> >
</UIAlert> <DialogHeader className="gap-1 px-4 pt-4">
) : null} <DialogTitle className="pr-8">Message selected users</DialogTitle>
{sendUserMessage.isSuccess ? ( <DialogDescription className="sr-only">
<UIAlert> Compose a message to the selected deliverable users.
<AlertDescription> </DialogDescription>
Queued for {sendUserMessage.data.recipient_count} recipients </DialogHeader>
{sendUserMessage.data.attachment_count {sendUserMessage.isPending ? (
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}` <Progress value={100} className="animate-pulse" />
: ""} ) : null}
{sendUserMessage.data.request_id <div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})` {sendUserMessage.isError ? (
: ""} <UIAlert variant="destructive">
.
</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> <AlertDescription>
{selectedRows.length} selected,{" "} Unable to send message:{" "}
{selectedDeliverableRows.length} deliverable. {(sendUserMessage.error as Error)?.message || "Unknown error"}
{skippedRows.length
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
: ""}
</AlertDescription> </AlertDescription>
</UIAlert> </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}
<div className="flex flex-wrap gap-1"> {queueBanner ? (
{selectedDeliverableRows.map((row) => ( <UIAlert
<Badge key={row.jellyfin_id} variant="secondary"> variant={
{`${userLabel(row)} <${row.email}>`} queueBanner.severity === "error" ? "destructive" : undefined
</Badge> }
))} >
</div> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">
<div className="flex flex-col gap-1.5"> {queueBanner.message}
<Label htmlFor="compose-subject">Subject</Label> </span>
<Input <Badge variant="secondary">{queueBanner.countLabel}</Badge>
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"
className="mobile-touch-target"
onClick={() => insertMarkup("<strong>", "</strong>")}
aria-label="Bold"
>
<Bold />
</UiButton>
</TooltipTrigger>
<TooltipContent>Bold</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
onClick={() => insertMarkup("<em>", "</em>")}
aria-label="Italic"
>
<Italic />
</UiButton>
</TooltipTrigger>
<TooltipContent>Italic</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
onClick={addLink}
aria-label="Link"
>
<Link />
</UiButton>
</TooltipTrigger>
<TooltipContent>Link</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
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> </UIAlert>
) : null}
<div className="flex flex-wrap items-center gap-1"> <UIAlert>
<UiButton asChild variant="outline"> <AlertDescription>
<label className="cursor-pointer"> {selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
<Paperclip /> deliverable.
Add attachment {skippedRows.length
<input ? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
hidden : ""}
type="file" </AlertDescription>
multiple </UIAlert>
onChange={handleAttachments}
/> <div className="flex flex-wrap gap-1">
</label> {selectedDeliverableRows.map((row) => (
</UiButton> <Badge key={row.jellyfin_id} variant="secondary">
{attachments.map((file, index) => ( {`${userLabel(row)} <${row.email}>`}
<Badge </Badge>
key={`${file.name}-${index}`} ))}
variant="secondary" </div>
className="gap-1 pr-1"
<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"
> >
{file.name} <Bold />
<button </UiButton>
type="button" </TooltipTrigger>
aria-label={`Remove ${file.name}`} <TooltipContent>Bold</TooltipContent>
onClick={() => removeAttachment(index)} </Tooltip>
className="mobile-touch-target inline-flex items-center text-current [&>svg]:size-3" <Tooltip>
> <TooltipTrigger asChild>
<Trash2 /> <UiButton
</button> variant="ghost"
</Badge> 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> </div>
</>
);
if (isMobile) { <div className="flex flex-wrap items-center gap-1">
return ( <UiButton asChild variant="outline">
<SheetForm <label className="cursor-pointer">
open={composeOpen} <Paperclip />
onOpenChange={(open) => { Add attachment
if (!open) closeCompose(); <input
}} hidden
title="Message selected users" type="file"
onSave={handleSend} multiple
onCancel={closeCompose} onChange={handleAttachments}
isPending={sendUserMessage.isPending} />
saveDisabled={!selectedDeliverableRows.length || !subject.trim()} </label>
saveLabel="Send message" </UiButton>
isDirty={ {attachments.map((file, index) => (
subject.trim() !== "" || <Badge
htmlBody.trim() !== DEFAULT_HTML_BODY.trim() || key={`${file.name}-${index}`}
attachments.length > 0 variant="secondary"
} className="gap-1 pr-1"
>
<div className="flex flex-col gap-4">{composeBody}</div>
</SheetForm>
);
}
return (
<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",
isComposeMobile &&
"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>
{composeBody}
<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 /> {file.name}
Send message <button
</UiButton> type="button"
</DialogFooter> aria-label={`Remove ${file.name}`}
</DialogContent> onClick={() => removeAttachment(index)}
</Dialog> 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> </div>
); );
} }
+3 -173
View File
@@ -2,18 +2,12 @@ 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 { Dashboard } from "../Dashboard"; import { Dashboard } from "../Dashboard";
import type { import type { DashboardShortcut } from "../../types";
DashboardShortcut,
ServiceInstance,
WidgetInstance,
} from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior // Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering widgets or their data queries. // (shortcut CRUD) without rendering widgets or their data queries.
vi.mock("../../components/WidgetInstance", () => ({ vi.mock("../../components/WidgetInstance", () => ({
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => ( WidgetInstanceCard: () => <div data-testid="widget-stub" />,
<div data-testid="widget-stub">{widget.title}</div>
),
})); }));
vi.mock("../../components/WidgetConfigDialog", () => ({ vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />, WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
@@ -27,15 +21,8 @@ vi.mock("react-router-dom", () => ({
vi.mock("../../hooks/useSettings", () => ({ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }), useMonitoringSettings: () => ({ data: [] }),
})); }));
// --- Dynamic mock state (reset in beforeEach) ---
let widgetInstances: WidgetInstance[] = [];
let serviceInstances: ServiceInstance[] = [];
vi.mock("../../hooks/useWidgets", () => ({ vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: widgetInstances }), useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: serviceInstances }),
})); }));
const saveShortcutMutate = vi.fn().mockResolvedValue({}); const saveShortcutMutate = vi.fn().mockResolvedValue({});
@@ -75,26 +62,8 @@ beforeEach(() => {
saveShortcutMutate.mockClear(); saveShortcutMutate.mockClear();
deleteShortcutMutate.mockClear(); deleteShortcutMutate.mockClear();
shortcuts = []; shortcuts = [];
widgetInstances = [];
serviceInstances = [];
setMatchMedia(false); // desktop by default
}); });
// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query === "(max-width: 768px)" ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Dashboard", () => { describe("Dashboard", () => {
it("shows the empty-state alert when there are no shortcuts", () => { it("shows the empty-state alert when there are no shortcuts", () => {
render(<Dashboard />); render(<Dashboard />);
@@ -142,142 +111,3 @@ describe("Dashboard", () => {
expect(saved.shortcut_type).toBe("website"); expect(saved.shortcut_type).toBe("website");
}); });
}); });
// --- Mobile layout tests (spec R7.1, R7.2) ---
function makeWidget(overrides: Partial<WidgetInstance> = {}): WidgetInstance {
return {
id: "w1",
service_id: null,
widget_kind: "static",
title: "Widget 1",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
...overrides,
};
}
function makeService(
overrides: Partial<ServiceInstance> = {},
): ServiceInstance {
return {
id: "svc1",
service_type: "jellyfin",
name: "Jellyfin",
config: {},
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
...overrides,
};
}
describe("Dashboard mobile layout", () => {
it("renders widgets in a single column with an anchor bar below md", () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
makeWidget({
id: "w-backup",
service_id: null,
widget_kind: "backups",
title: "Backup Summary",
}),
];
render(<Dashboard />);
// Anchor bar pills are visible for populated sections (each label appears
// in both the pill and the section heading, so use getAllByText).
expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
1,
);
expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
// Sections with no widgets are NOT rendered.
expect(screen.queryByText("Custom")).not.toBeInTheDocument();
// Each widget renders.
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
expect(screen.getByText("Backup Summary")).toBeInTheDocument();
});
it("does NOT render the anchor bar at desktop width", () => {
setMatchMedia(false); // desktop
serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
];
render(<Dashboard />);
// Widget renders (flat list, no section wrappers).
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
// No section headings or anchor pills on desktop.
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
expect(screen.queryByText("Media")).not.toBeInTheDocument();
});
it("anchor bar pills jump to their section via scrollIntoView", async () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
];
const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
render(<Dashboard />);
// The Media section element exists.
expect(document.getElementById("dashboard-section-media")).not.toBeNull();
// Click the "Media" anchor pill (button role disambiguates from heading).
const mediaPill = screen.getByRole("button", { name: "Media" });
await userEvent.click(mediaPill);
expect(scrollSpy).toHaveBeenCalled();
scrollSpy.mockRestore();
});
});
@@ -8,7 +8,6 @@ import type { DirectoryListing, MonitoringMachine } from "../../types";
// so the selectedPath / currentDir state never leaks across cases. // so the selectedPath / currentDir state never leaks across cases.
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); window.localStorage.clear();
setMatchMedia(false);
}); });
function machineFixture( function machineFixture(
@@ -78,30 +77,6 @@ beforeEach(() => {
]); ]);
}); });
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
function setMatchMedia(matches: boolean) {
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => listeners.push(listener),
removeEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => { describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
it("renders the 5 locked columns (type/name/ext/size/modified)", () => { it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
render(<FileBrowser />); render(<FileBrowser />);
@@ -143,56 +118,3 @@ describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
expect(screen.queryByText(/Selected:/)).toBeNull(); expect(screen.queryByText(/Selected:/)).toBeNull();
}); });
}); });
describe("FileBrowser (mobile card layout — slice 4)", () => {
it("renders cards with file/dir name as primary below md", () => {
setMatchMedia(true);
render(<FileBrowser />);
// Card titles (the 'name' field rendered as primary).
expect(screen.getByText("movies")).toBeInTheDocument();
expect(screen.getByText("video.mkv")).toBeInTheDocument();
expect(screen.getByText("notes.txt")).toBeInTheDocument();
// Desktop table column headers must NOT render.
const headers = screen.queryAllByRole("columnheader");
expect(headers).toHaveLength(0);
});
it("tapping a directory card navigates into it", async () => {
setMatchMedia(true);
render(<FileBrowser />);
// Directory card is a button wrapping the 'movies' text.
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd
// and NO 'Selected:' segment (directories are opened, not selected).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
it("renders the path/breadcrumb controls on mobile", () => {
setMatchMedia(true);
render(<FileBrowser />);
// The 'Remote path' label and its input are part of the Browser section
// card (outside the table), so they render on both breakpoints.
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
});
it("renders the DataTable at desktop width (1280px)", () => {
setMatchMedia(false);
render(<FileBrowser />);
// Desktop path: table column headers are present.
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
});
});
@@ -129,11 +129,7 @@ vi.mock("../../hooks/useDashboard", () => ({
// usePersistentState reads/writes localStorage; clear between tests so the // usePersistentState reads/writes localStorage; clear between tests so the
// offset/pageSize/columnVisibility state never leaks across cases. // offset/pageSize/columnVisibility state never leaks across cases.
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
// so the DataTable path renders by default; mobile tests override.
beforeEach(() => { beforeEach(() => {
setMatchMedia(false);
window.localStorage.clear(); window.localStorage.clear();
navigate.mockClear(); navigate.mockClear();
status = statusFixture(); status = statusFixture();
@@ -156,30 +152,6 @@ beforeEach(() => {
}; };
}); });
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
function setMatchMedia(matches: boolean) {
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => listeners.push(listener),
removeEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => { describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
it("exposes exactly the 15 locked toggleable columns", async () => { it("exposes exactly the 15 locked toggleable columns", async () => {
render(<Media />); render(<Media />);
@@ -289,68 +261,3 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", (
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
describe("Media (mobile card layout — slice 3)", () => {
it("renders cards with the title as primary below md", () => {
setMatchMedia(true);
render(<Media />);
// Card titles render (primary field).
expect(screen.getByText("Inception")).toBeInTheDocument();
expect(screen.getByText("Matrix")).toBeInTheDocument();
// Card field labels render (at least once per row).
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
// Desktop table headers do NOT render on mobile.
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
});
it("hides the column-visibility toggle below md", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
});
it("renders pagination controls below the cards on mobile", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Next page" }),
).toBeInTheDocument();
});
it("navigates to the file browser when a card is tapped on mobile", async () => {
setMatchMedia(true);
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("renders the DataTable (not cards) at desktop width", () => {
render(<Media />);
// Desktop column headers render.
expect(
screen.getByRole("columnheader", { name: "Title" }),
).toBeInTheDocument();
// Column-visibility toggle is present.
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
});
});
@@ -1,149 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ServicePage } from "../ServicePage";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../../types";
// --- fixtures ---
const instance: ServiceInstance = {
id: "svc-1",
service_type: "grafana",
name: "Production Grafana",
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
secrets_set: { api_key: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
const typeInfo: ServiceTypeInfo = {
service_type: "grafana",
name: "Grafana",
description: "Dashboards, metrics, and logs.",
config_schema: {
type: "object",
properties: {
base_url: { type: "string", description: "Absolute URL." },
timeout_seconds: { type: "integer" },
},
},
secret_fields: [{ key: "api_key", label: "API key", required: false }],
widget_kinds: [],
};
// --- mocks ---
const mutateAsync = vi.fn();
const mutate = vi.fn();
const deleteMutate = vi.fn();
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [instance] }),
useServiceTypes: () => ({ data: [typeInfo] }),
useSaveServiceInstance: () => ({
mutateAsync,
mutate,
isPending: false,
}),
useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
}));
vi.mock("react-router-dom", () => ({
useParams: () => ({
serviceType: "grafana",
serviceId: "svc-1",
}),
useNavigate: () => vi.fn(),
}));
// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
beforeEach(() => {
setMatchMedia(false);
mutateAsync.mockReset();
mutate.mockReset();
deleteMutate.mockReset();
});
describe("ServicePage (desktop)", () => {
it("renders the full-page layout with the service name and connection card", () => {
render(<ServicePage />);
// Page heading (desktop only — mobile uses SheetForm title)
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// Connection section card title
expect(screen.getByText("Connection")).toBeInTheDocument();
// General Save button
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});
it("does not render the SheetForm at desktop width", () => {
render(<ServicePage />);
// SheetForm renders a dialog with role="dialog" only when open; on
// desktop the page layout is used, so no dialog should be present.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("ServicePage (mobile SheetForm — slice 6)", () => {
beforeEach(() => setMatchMedia(true));
it("renders the SheetForm with the service name as title below md", () => {
render(<ServicePage />);
// SheetForm title is rendered inside a SheetTitle (role="heading").
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// The dialog (Sheet content) should be present on mobile.
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop page header description is NOT rendered inside the SheetForm.
expect(
screen.queryByText("Dashboards, metrics, and logs."),
).not.toBeInTheDocument();
});
it("edits the name field and Save calls the save mutation", async () => {
render(<ServicePage />);
const nameInput = screen.getByLabelText("Name");
expect(nameInput).toHaveValue("Production Grafana");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed Grafana");
const saveButton = screen.getByRole("button", { name: "Save" });
await userEvent.click(saveButton);
expect(mutateAsync).toHaveBeenCalledTimes(1);
const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
expect(input.name).toBe("Renamed Grafana");
expect(input.id).toBe("svc-1");
// Lock the full save payload (config draft, enabled, secrets sentinel).
expect(input.enabled).toBe(true);
expect(input.secrets).toEqual({});
expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
});
it("renders the connection config fields as editable inside the SheetForm", () => {
render(<ServicePage />);
const urlInput = screen.getByLabelText("base_url");
expect(urlInput).toHaveValue("https://grafana.example.com");
});
});
+6 -104
View File
@@ -6,10 +6,12 @@ import type { MonitoringMachine } from "../../types";
const saveMachineMutate = vi.fn().mockResolvedValue({}); const saveMachineMutate = vi.fn().mockResolvedValue({});
const deleteMachineMutate = vi.fn(); const deleteMachineMutate = vi.fn();
const testSSHMutate = vi.fn().mockResolvedValue({ const testSSHMutate = vi
message: "SSH auth succeeded", .fn()
known_hosts_updated: true, .mockResolvedValue({
}); message: "SSH auth succeeded",
known_hosts_updated: true,
});
let machines: MonitoringMachine[] = []; let machines: MonitoringMachine[] = [];
@@ -111,103 +113,3 @@ describe("Settings", () => {
expect(deleteMachineMutate).toHaveBeenCalledWith("m1"); expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
}); });
}); });
// jsdom has no window.matchMedia; default to desktop so existing tests are
// unaffected.
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Settings (mobile SheetForm — slice 7)", () => {
beforeEach(() => setMatchMedia(true));
it("opens the machine editor in a SheetForm below md", async () => {
machines = [localMachine()];
render(<Settings />);
// Open the editor via the detail-pane Edit button (visible text).
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
// SheetForm renders a dialog; the DialogTitle shows the editor title.
expect(screen.getByText("Edit machine")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop DialogDescription text is not rendered as a dialog description
// on mobile (the MachineEditor has its own hint labels, which is fine).
expect(
screen.queryByRole("heading", { name: "Create machine" }),
).not.toBeInTheDocument();
});
it("saves a machine via the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed node");
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
const saved = saveMachineMutate.mock.calls[0][0];
expect(saved.name).toBe("Renamed node");
expect(saved.mode).toBe("local");
});
it("cancel closes the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
expect(screen.getByRole("dialog")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
// The sheet is now closed — the dialog role should no longer be present.
// (The page content itself is still rendered; only the sheet unmounts.)
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
});
it("prompts before discarding unsaved machine edits (R4.5)", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
// Edit the name to make the form dirty.
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Dirty name");
// Cancel should NOT immediately close — the discard confirm appears.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
// The editor is still open.
expect(screen.getByText("Edit machine")).toBeInTheDocument();
});
});
+2 -124
View File
@@ -9,10 +9,8 @@ import type {
UserDirectoryResponse, UserDirectoryResponse,
} from "../../types"; } from "../../types";
// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the // jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
// compose dialog viewport hook must not blow up during render. Stub to // compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
// "desktop" (matches: false) by default; the slice-5 describe block flips it
// to mobile for card-layout assertions.
beforeEach(() => { beforeEach(() => {
if (!window.matchMedia) { if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({ window.matchMedia = ((query: string) => ({
@@ -285,123 +283,3 @@ describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
expect(body.value).toContain("<strong>"); expect(body.value).toContain("<strong>");
}); });
}); });
describe("UsersPage (mobile card layout — slice 5)", () => {
beforeEach(() => {
window.matchMedia = ((query: string) => ({
matches: query.includes("768"),
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
});
it("renders user cards with display name as primary below md", () => {
users = [
userFixture({ jellyfin_id: "u1", display_name: "Alice" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
// Activity field label should appear per card.
expect(screen.getAllByText("Activity")).toHaveLength(2);
});
it("toggles selection from the card checkbox without opening the drawer", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
const checkbox = screen.getByRole("checkbox", {
name: /Select Alice/i,
});
expect(checkbox).toHaveAttribute("data-state", "unchecked");
await userEvent.click(checkbox);
expect(checkbox).toHaveAttribute("data-state", "checked");
// Drawer stays closed: the session-panel stub only renders when the
// drawer opens via a card-body tap, not via the checkbox.
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
});
it("renders compose in a SheetForm below md with send button", async () => {
users = [
userFixture({
jellyfin_id: "u1",
display_name: "Alice",
email: "alice@example.com",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select the deliverable user via the mobile card checkbox.
await userEvent.click(
screen.getByRole("checkbox", { name: /Select Alice/i }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
// header carries the title and the footer carries the Send button.
expect(screen.getByText("Message selected users")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Send message" }),
).toBeInTheDocument();
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
});
it("prompts before discarding unsaved compose edits (R4.5)", async () => {
users = [
userFixture({
jellyfin_id: "u1",
display_name: "Alice",
email: "alice@example.com",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
await userEvent.click(
screen.getByRole("checkbox", { name: /Select Alice/i }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Type a subject to make the compose form dirty.
await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
// Cancel should NOT immediately close — the discard confirm appears.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
});
});
@@ -1,167 +0,0 @@
# 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 29)
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.
@@ -1,114 +0,0 @@
# 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 35 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 |
@@ -1,137 +0,0 @@
# 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 35 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.
@@ -1,226 +0,0 @@
# Tasks — Mobile responsive parity
**Change:** `mobile-responsive-parity`
**Phase:** tasks
**Date:** 2026-06-26
## Review workload forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~22002800 |
| Chained PRs recommended | Yes (10 slices) |
| Chain strategy | stacked-to-main |
| Slice order | 1 (primitives) → 2 (Dashboard) → 35 (tables) → 68 (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 (AC1AC8), 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 35 (tables) and 68 (forms) can be reordered or parallelized across
branches if helpful, but each must merge green.
- No slice touches the backend.
@@ -1,125 +0,0 @@
# 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 + 35 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,213 @@
# Design — Services as hub IA
**Change:** `services-as-hub-ia`
**Phase:** design
**Date:** 2026-06-26
## Context
Frontend: React 18 + Vite + TanStack Query/Table + Tailwind v4 + shadcn/ui +
react-router-dom. Backend: FastAPI + SQLite settings store + closed service
registry at `backend/.../integrations/`. Existing patterns: service definitions
in `integrations/<type>.py`, service instances in the `services` SQLite table,
widget kinds per service, ServicePage at `/services/:type/:id`.
The change is layered: backend service-type changes first (so the registry and
API reflect the new world), then frontend IA refactor (so the UI consumes the
new shape).
## Architecture
### Backend
#### New service types
**`backups`** (`integrations/backups.py`, new):
```python
class BackupsConfig(ServiceConfigBase):
ingestion_label: str = "default" # disambiguates multi-instance ingestion
DEFINITION = ServiceDefinition(
service_type="backups",
name="Backups",
config_model=BackupsConfig,
secret_fields=[],
widget_kinds=[widget_kind(...)], # existing BackupsWidgetSource moves here
)
```
The backup report endpoint gains an optional `?service_id=`. Existing reports
(attribute to no service) are associated first-wins to the enabled `backups`
instance; the poller and dashboard summary continue to work unchanged.
**`authentik`** (`integrations/authentik.py`, new):
```python
class AuthentikConfig(ServiceConfigBase):
base_url: ServiceBaseUrl
timeout_seconds: int = 10
DEFINITION = ServiceDefinition(
service_type="authentik",
name="Authentik",
config_model=AuthentikConfig,
secret_fields=[SecretField(key="api_token", label="API token", required=True)],
widget_kinds=[],
)
```
A new `AuthentikClient` (`clients/authentik.py`) wraps the directory API:
`users(search?, page?, page_size?) -> {items, total}`, returning plain dicts.
Endpoint: `GET /api/services/authentik/:service_id/users` proxies to the
client. The mail queue and SMTP settings are reused unchanged; the message-
compose endpoint accepts Authentik user ids instead of Jellyfin ids.
#### Jellyseerr absorption
`JellyfinConfig` gains optional fields:
```python
class JellyfinConfig(ServiceConfigBase):
base_url: ServiceBaseUrl
user_id: str = ""
timeout_seconds: int = 10
jellyseerr_url: str = "" # NEW (optional)
jellyseerr_api_key: str = "" # NEW (optional, non-secret at this layer)
```
The `jellyseerr_api_key` lives in the non-secret config (it is paired with
`jellyseerr_url` and treated as a service-level credential, encrypted at rest
via the existing secrets mechanism if you prefer — design choice for tasks
phase). The `jellyseerr` integration module and registry entry are deleted.
**Migration** (`services/settings_store.py` startup hook):
1. On `ensure_defaults()`, if any `jellyseerr` service rows exist:
2. For each, attempt to pair with a `jellyfin` instance. Pairing policy: if
exactly one Jellyfin exists, merge. If multiple, pick the one whose existing
`jellyseerr_url` is empty (first such). If none can be paired, drop the
Jellyseerr row with a logged warning.
3. Move `base_url` and `api_key` onto the paired Jellyfin's config.
4. Delete the `jellyseerr` row.
#### Route cleanup
`routers/users.py` and its deps are removed. `routers/users_impl.py` removed.
`routers/media.py`, `routers/files.py`, `routers/jobs.py`, `routers/backups.py`,
`routers/monitoring.py` keep their endpoints (they are consumed by the service
tabs) — no change to paths. The dashboard, settings, services routers are
unchanged. A new `routers/authentik_users.py` exposes the directory endpoint.
### Frontend
#### Top nav generation (`App.tsx`)
Replace the static `navItems` array with a data-driven list built from two
queries:
```tsx
const { data: services = [] } = useServiceInstances(); // existing
const { data: dashboards = [] } = useDashboards(); // NEW
const navItems = useMemo(() => {
const configuredTypes = new Set(services.filter(s => s.enabled).map(s => s.service_type));
return [
{ path: "/", label: "Dashboard", icon: LayoutDashboard, always: true },
...dashboards.map(d => ({ path: `/d/${d.slug}`, label: d.label, icon: LayoutTemplate })),
...SERVICE_TYPE_NAV_ENTRIES
.filter(e => configuredTypes.has(e.serviceType))
.map(e => ({ path: `/services/${e.serviceType}`, label: e.label, icon: e.icon })),
{ path: "/services", label: "Services", icon: Boxes, always: true },
{ path: "/settings", label: "Settings", icon: SettingsIcon, always: true },
];
}, [services, dashboards]);
```
`SERVICE_TYPE_NAV_ENTRIES` is a static map from service type to its conditional
nav entry/entries (ssh_tasks contributes two: Files + Actions). The shell
shows a loading state until both queries settle.
#### Service page IA (`pages/ServicePage.tsx`)
Refactor `ServicePage` to render a tab skeleton driven by the service type:
```tsx
const tabs = useMemo(() => serviceTabs(serviceType, instance), [...]);
// tabs = [Overview, ...contentTabs, Widgets, Config]
```
`serviceTabs` returns the per-type content components (MediaTab, FilesTab,
ActionsTab, JobsTab, UsersTab, MessagingTab, AlertsTab, LinksTab,
MetricsTab — most pre-existing, lifted from their top-level pages). The
instance switcher renders at the top when `instances.length > 1`.
Routes:
- `/services/:type` → resolve first enabled instance → redirect to
`/services/:type/:id` (client-side).
- `/services/:type/:id` → render ServicePage with the instance + siblings.
#### Named dashboards (`pages/Dashboard.tsx` + new `NamedDashboardPage`)
- Main Dashboard at `/` keeps the current shape (widgets + shortcuts, now
including pinned service links as a shortcut variant).
- New `NamedDashboardPage` at `/d/:slug` renders a saved dashboard record's
widgets + pinned links.
- New `useDashboards` hook + CRUD endpoints (`GET/POST/PUT/DELETE
/api/dashboards`) on the backend; the existing `dashboard_shortcuts` table
gains a `dashboard` entity (or a new `named_dashboards` table — design
choice for tasks phase).
#### Content migration
Each content page is lifted into a `*Tab` component consumed by ServicePage:
| Old | New | Consumers |
|-----|-----|-----------|
| `pages/Media.tsx` (Applications) | `pages/service-tabs/MediaTab.tsx` | Jellyfin |
| `pages/FileBrowser.impl.tsx` | `pages/service-tabs/FilesTab.tsx` | ssh_tasks |
| `pages/Actions.tsx` | `pages/service-tabs/ActionsTab.tsx` | ssh_tasks |
| `components/BackupsPage.tsx` | `pages/service-tabs/JobsTab.tsx` | backups |
| `pages/UsersPage.impl.tsx` | REMOVED; new `UsersTab` sources Authentik | authentik |
| `components/ObservabilityPage.tsx` | SPLIT into `AlertsTab`/`LinksTab`/`MetricsTab` | alertmanager/grafana/prometheus |
Tabs accept `{ instance: ServiceInstance }` and read `instance.id` to scope
their queries (replacing today's `?jellyfin_service_id=` query param — the
service page passes the active instance directly).
#### Authentik client + endpoints
- `clients/authentik.py` (backend) — directory API wrapper.
- `routers/authentik_users.py``GET /api/services/authentik/:id/users`.
- `pages/service-tabs/UsersTab.tsx` — directory table + search.
- `pages/service-tabs/MessagingTab.tsx` — compose + queue status, sourced from
Authentik users (replaces the UsersPage compose dialog).
### Key technical risks & mitigations
- **Content migration scope.** Each tab lift is a non-trivial move. Slices must
be page-by-page so each lands green and reviewable.
- **Instance-scoped queries.** Today most content reads a service-id from a
query param. The tab components take an `instance` prop and pass `instance.id`
to their hooks; the hooks' existing `jellyfinServiceId`/`service_id` params
are reused.
- **Authentik API field coverage.** The directory API may not expose all fields
the old compose flow used (avatars, activity). The UsersTab shows what's
available; Messaging uses Authentik emails only.
- **Jellyseerr migration ambiguity.** Multiple Jellyfins + multiple Jellyseerrs
with no explicit pairing is unresolvable automatically. The migration drops
unpaired Jellyseerrs with a logged warning; users reconfigure manually.
- **Nav loading flash.** The shell needs services + dashboards before rendering
nav. Show a skeleton nav until settled; do not block the route render.
## Trade-offs
- **404 over redirect.** Old bookmarks break. Accepted: redirects become tech
debt; the new IA is clean.
- **No cross-service observability.** A built-in overview is sacrificed; users
build their own via named dashboards. Accepted per D6.
- **Global dashboards.** No per-user customization in this change. Accepted;
multi-tenant is a separate concern.
- **Jellyseerr absorbed, not migrated gracefully.** Unpaired Jellyseerrs are
dropped. Accepted; the data is recreatable.
@@ -0,0 +1,216 @@
# Proposal — Services as hub IA
**Change:** `services-as-hub-ia`
**Phase:** proposal
**Date:** 2026-06-26
## Problem
The current information architecture treats **concepts** (Media, Files, Actions,
Users, Observability, Backups) as first-class top-level destinations. Services
(Jellyfin, SSH, Alertmanager, etc.) are configured separately and reached via a
"Services" admin page that holds only connection config + widgets. This produces
two problems:
1. **Duplicated ontology.** "Media" and "the Jellyfin service page" are two
different places that both reference the same Jellyfin instance. The Media
page is where you browse; the service page is where you configure. There is
no single "Jellyfin" place.
2. **Concept-pages assume exactly one source.** The Media page assumes media
comes from Jellyfin, the Files page assumes files come from SSH, the Users
page assumes users come from Jellyfin. Multi-instance setups (2 Jellyfins, 2
SSH targets) have no first-class home; you switch via query params.
Meanwhile, several concepts have outgrown their current shape:
- **Users** is Jellyfin-specific and overlaps with the OIDC provider (Authentik)
that already holds the canonical user directory. Maintaining a parallel
Jellyfin-only user directory is duplicated work.
- **Observability** aggregates three service types (Alertmanager, Grafana,
Prometheus) into one page, but each of those services is already a
first-class registry instance. The aggregate page is a special case.
- **Backups** receives reports via a REST endpoint but has no service-record
home; it cannot be named, multi-instanced, or surfaced like other services.
- **Jellyseerr** is configured as a separate service but its only role is
enriching Jellyfin users — it has no standalone value.
## Proposal
Reorganize the app around **services as the hub**. The top-level navigation
shrinks to a tiny always-visible core plus **conditional per-type entries** that
materialize only when a matching service is configured. Operational content
(Media, Files, Actions, Users) moves **into the service page** as tabs.
### Top-level navigation (after)
- **Main Dashboard** (always visible, special, at `/`)
- **Named dashboards** (always visible once created; one top-level entry each,
at `/d/:slug`)
- **Conditional service-type entries** — one per configured service type,
linking to the type's service page with an in-page instance switcher:
- "Media" appears when a Jellyfin service exists
- "Files" and "Actions" appear when an ssh_tasks service exists
- "Alerts" when Alertmanager exists; "Grafana" when Grafana exists;
"Prometheus" when Prometheus exists (Observability page is removed)
- "Backups" when a backups service exists
- "Users" when an Authentik service exists
- **Services** (always visible — the admin hub for managing service instances)
- **Settings** (always visible — unchanged)
### Service page IA (after)
Every service page uses the same tab skeleton:
```
[Overview] [type-specific content tabs...] [Widgets] [Config]
```
- **Overview** — service health + key metrics (connection status, version,
primary widget preview).
- **Content tabs** — per service type:
- **Jellyfin**: Media (table + index build), Requests (Jellyseerr enrichment)
- **ssh_tasks**: Files (browser + ffprobe), Actions (saved tasks)
- **backups**: Jobs (jobs + runs + alerts)
- **authentik**: Users (directory), Messaging (compose)
- **alertmanager**: Alerts
- **grafana**: Links
- **prometheus**: Metrics / status
- **Widgets** — widget kinds this service provides (unchanged from today).
- **Config** — non-secret config + secrets (unchanged from today).
Multi-instance: when >1 instance of a type exists, the service page shows an
**instance switcher** (dropdown at the top of the page) rather than separate
routes per instance.
### Service type changes
- **NEW: `backups`** — becomes a service type in the registry. The current REST
report endpoint keeps working for passive ingestion; reports are attributed to
a backups service instance. The `BackupsPage` content (jobs/runs/alerts) moves
into the backups service page's Jobs tab.
- **NEW: `authentik`** — becomes a service type. Its Users tab is the new user
directory (replacing the Jellyfin-based Users page). Its Messaging tab hosts
the message-compose flow, emailing Authentik-sourced users via the existing
SMTP settings. OIDC auth flow is unchanged.
- **ABSORBED: `jellyseerr`** — ceases to be its own service type. Its config
fields (`base_url`, `api_key`) move onto the Jellyfin service config as
optional fields. The Jellyfin service page gains a Requests tab backed by the
configured Jellyseerr. Existing Jellyseerr service instances are migrated into
their paired Jellyfin's config (or dropped if no pairing can be inferred).
- **UNCHANGED**: `alertmanager`, `grafana`, `prometheus`, `ssh_tasks`,
`nextcloud` keep their service-type status. Their operational content (if any)
moves into tabs on their service page.
### Removed / replaced
- **`/media`** — content moves into Jellyfin service page (Media tab). Old route
returns 404.
- **`/files`, `/actions`** — content moves into ssh_tasks service page (Files /
Actions tabs). Old routes return 404.
- **`/users`** — replaced by Authentik service page (Users tab). Old route
returns 404. The Jellyfin-backed user directory, Jellyfin-email message
compose, and Jellyseerr-enrichment-of-Jellyfin-users are removed.
- **`/observability`** — removed. Its content splits across the Alertmanager,
Grafana, and Prometheus service pages. Old route returns 404. The cross-
service "single pane of glass" is intentionally sacrificed; users who want it
build it on a named dashboard via widgets.
- **`/backups`** — content moves into the backups service page (Jobs tab). Old
route returns 404.
- **Jellyseerr service type** — configuration absorbed into Jellyfin.
### Named dashboards
- The main Dashboard at `/` stays **special** (the default landing, not
deletable, always first in nav).
- Users can create **named dashboards** at `/d/:slug`. Each named dashboard is a
configurable grid of **widgets + pinned service links** (shortcuts to specific
service pages or tabs).
- Each named dashboard appears as its own top-level nav entry, in a user-
controlled order. The main dashboard always sits first.
### Routing
- `/` — main Dashboard (special, default landing)
- `/d/:slug` — named dashboard
- `/services` — services admin hub (list of all service instances, grouped by
type)
- `/services/:type` — service page for the first/primary instance of a type,
with an instance switcher when >1 exists
- `/services/:type/:id` — service page for a specific instance
- `/settings` — settings (unchanged)
- All legacy top-level routes (`/media`, `/files`, `/actions`, `/users`,
`/observability`, `/backups`) return **404** — no redirects, no aliases.
### Empty state
A fresh install with no services configured and no dashboards lands on the main
Dashboard with a strong CTA ("Add a service to get started" → Services). The
Services page has a matching empty state. Top nav shows only Dashboard /
Services / Settings until services or dashboards are added.
## Non-goals
- **No changes to OIDC / SSO authentication.** Authentik-as-IdP keeps doing
what it does today; this change adds Authentik-as-directory-source only.
- **No per-instance top-level entries.** A type gets one conditional entry with
an in-page instance switcher; nav does not grow with the number of instances.
- **No legacy-route redirects.** Old URLs 404; bookmarks must be updated.
- **No tablet-specific or mobile-specific IA divergence.** The IA is the same
across breakpoints (mobile responsive parity already shipped).
- **No new widget kinds.** Named dashboards compose existing widget kinds plus
pinned service links (a new shortcut variant, not a widget kind).
- **No backend API contract changes beyond the new service types and the
Authentik directory endpoint.** Existing endpoints keep their shape.
- **No multi-tenant or per-user dashboard customization.** Dashboards are
global (shared across all authenticated users) in this change.
## Key technical risks
- **Content migration is large.** Media, Files, Actions, Users, Backups each
move from a top-level page into a service tab. Each is a non-trivial component
with its own hooks, tests, and state. This is the bulk of the implementation
risk and review burden.
- **Jellyseerr absorption migration.** Existing Jellyseerr service instances
must be migrated into their paired Jellyfin's config at backend startup, with
a clear policy when pairing is ambiguous (multiple Jellyfins, no Jellyfin).
- **Authentik directory API.** The Authentik service page needs a backend client
that queries Authentik's user/group directory API. Scope of that API (which
fields, pagination, search) must be pinned during design.
- **Nav generation is data-driven.** Top nav must react to configured services
and existing dashboards. This is a new TanStack-Query dependency in the App
shell, with loading/empty states.
- **Backups attribution.** Existing backup reports have no service_id. The
migration must assign them to a backups service instance (first-wins or
job-name-matching policy).
## Risks (flagged, not blocking)
- **Loss of cross-service Observability overview.** A fresh install with no
dashboards configured has no alerts-overview until the user builds one. The
mitigation (widgets on a named dashboard) is real but requires user setup.
Revisit if it bites.
- **Authentik directory coverage.** Authentik's user directory may not carry the
same fields the current Jellyfin-based messaging flow relied on (e.g. Jellyfin-
specific avatar URLs, activity state). Some fields will simply go away.
## Decision matrix (from grilling)
| # | Decision | Choice |
|---|----------|--------|
| D1 | Top nav model | Conditional type entries (one per configured service type, appearing only when configured) |
| D2 | Multi-instance | Type + instance switcher on the service page |
| D3 | Files + Actions | Move into ssh_tasks service page as tabs |
| D4 | Backups | New service type in the registry |
| D5 | Users | Replaced by Authentik (included in this change) |
| D6 | Observability | Split per service type (no aggregate page) |
| D7 | Main Dashboard | Stays special at `/`, not deletable, default landing |
| D8 | Named dashboards | Widgets + pinned service links |
| D9 | Named dashboards nav | Each named dashboard = one top-level entry |
| D10 | Authentik role | User directory source (OIDC auth unchanged) |
| D11 | Messaging | Moves to Authentik service page; emails Authentik users via existing SMTP |
| D12 | Jellyseerr | Absorbed into Jellyfin config (no longer its own service type) |
| D13 | Service page tabs | Standard skeleton: Overview \u2234 content \u2234 Widgets \u2234 Config |
| D14 | Overview tab | Health + key metrics |
| D15 | Routing | `/services/:type/:id`, `/services/:type` (first/primary), `/d/:slug`, `/` |
| D16 | Legacy routes | Return 404 (no redirects, no aliases) |
| D17 | Empty state | Dashboard CTA + Services empty state |
+171
View File
@@ -0,0 +1,171 @@
# Spec — Services as hub IA
**Change:** `services-as-hub-ia`
**Phase:** spec
**Date:** 2026-06-26
## Scope
Reorganize the frontend information architecture around services as the hub.
Operational content (Media, Files, Actions, Users, Backups) moves into service-
type-specific tabs on the service page. The top nav shrinks to a small always-
visible core (Main Dashboard, Services, Settings) plus conditional per-type
entries and user-created named dashboards. Two new service types are added
(`backups`, `authentik`); one is absorbed (`jellyseerr` → Jellyfin config).
This change spans backend (new service types, Authentik client, Jellyseerr
migration, route cleanup) and frontend (service-page IA, top-nav generation,
content migration, named dashboards).
## Requirements
### R1 — Top-level navigation
- R1.1 The top nav contains, in order: Main Dashboard, named dashboards (one
entry each, user-controlled order), conditional service-type entries, Services,
Settings.
- R1.2 Conditional service-type entries appear only when at least one enabled
instance of that type exists. Mapping:
- `jellyfin` → "Media" entry → `/services/jellyfin`
- `ssh_tasks` → "Files" and "Actions" entries → `/services/ssh_tasks`
- `alertmanager` → "Alerts" entry → `/services/alertmanager`
- `grafana` → "Grafana" entry → `/services/grafana`
- `prometheus` → "Prometheus" entry → `/services/prometheus`
- `backups` → "Backups" entry → `/services/backups`
- `authentik` → "Users" entry → `/services/authentik`
- `nextcloud` → no entry (no operational content)
- R1.3 The Main Dashboard is always first and not deletable.
- R1.4 The nav is data-driven (reacts to configured services + dashboards) with
graceful loading/empty states.
### R2 — Service page IA
- R2.1 Every service page uses the tab skeleton: Overview, type-specific
content tabs (zero or more), Widgets, Config.
- R2.2 The Overview tab shows service health (connection status, version, last
error) and a primary metric preview (per-type: live sessions for Jellyfin,
active alert count for Alertmanager, etc.).
- R2.3 The Widgets and Config tabs are unchanged from today (widget kinds list,
non-secret config + secrets editors).
- R2.4 Type-specific content tabs:
- `jellyfin`: Media (table + index build controls), Requests (Jellyseerr data)
- `ssh_tasks`: Files (browser + ffprobe + jobs), Actions (saved tasks CRUD + run)
- `backups`: Jobs (jobs + runs + alerts + acknowledge)
- `authentik`: Users (directory + search), Messaging (compose + queue status)
- `alertmanager`: Alerts (summary + list + severity filter)
- `grafana`: Links (configured dashboard deep-links)
- `prometheus`: Metrics (status + PromQL explorer)
- `nextcloud`: no content tabs (Overview + Widgets + Config only)
### R3 — Instance switcher
- R3.1 When more than one enabled instance of a service type exists, the service
page renders an instance switcher (dropdown) at the top.
- R3.2 The switcher selects the active instance; all tabs reflect the selected
instance.
- R3.3 The default selected instance is the first enabled instance (or the one
named "primary" if multiple-selection is added later — out of scope here).
- R3.4 Single-instance types do not render the switcher.
### R4 — Routing
- R4.1 `/` — Main Dashboard (special, default landing, not deletable).
- R4.2 `/d/:slug` — named dashboard.
- R4.3 `/services` — services admin hub (list of all instances, grouped by type,
with add/edit/delete).
- R4.4 `/services/:type` — service page for the first enabled instance of the
type; redirects (client-side) to `/services/:type/:id` once an instance is
resolved.
- R4.5 `/services/:type/:id` — service page for a specific instance.
- R4.6 `/settings` — settings (unchanged).
- R4.7 Legacy routes (`/media`, `/files`, `/actions`, `/users`, `/observability`,
`/backups`) return 404 — no redirects, no aliases.
### R5 — Named dashboards
- R5.1 Any authenticated user can create, edit, reorder, and delete named
dashboards (global scope — shared across users in this change).
- R5.2 A named dashboard holds an ordered list of widgets (existing widget kinds
only) and pinned service links (shortcut to a service page or specific tab).
- R5.3 Each named dashboard has a user-chosen label and a URL slug derived from
it (uniqueness enforced).
- R5.4 The Main Dashboard is special: it cannot be deleted, is always first in
the nav, and its slug is reserved.
### R6 — Service type changes
- R6.1 **NEW `backups`** service type: config holds ingestion source metadata;
the existing REST report endpoint attributes incoming reports to a backups
service instance (first-wins when none is specified).
- R6.2 **NEW `authentik`** service type: config holds base_url; secret holds the
API token. Provides a Users widget and a user-directory endpoint consumed by
the Authentik service page.
- R6.3 **ABSORBED `jellyseerr`**: removed as a service type. Its config fields
(`base_url`, `api_key`) become optional fields on `JellyfinConfig`. Existing
Jellyseerr service instances are migrated into their paired Jellyfin's config
at backend startup; unpaired instances are dropped with a logged warning.
### R7 — Users → Authentik
- R7.1 The Jellyfin-backed user directory, Jellyfin-email message compose, and
Jellyseerr-enrichment-of-Jellyfin-users flows are removed.
- R7.2 The Authentik service page Users tab sources users from Authentik's
directory API (paginated, searchable).
- R7.3 The Authentik Messaging tab hosts message-compose, emailing Authentik-
sourced users via the existing SMTP settings and mail queue.
- R7.4 OIDC authentication is unchanged.
### R8 — Observability
- R8.1 The Observability page is removed.
- R8.2 Alertmanager alerts, Grafana links, and Prometheus status each render on
their respective service-type pages as content tabs.
- R8.3 There is no cross-service aggregate view built-in. Users who want one
build it via widgets on a named dashboard.
### R9 — Empty state
- R9.1 A fresh install (no services, no dashboards) lands on `/` with an empty-
state CTA pointing to `/services`.
- R9.2 The Services hub shows a strong empty state ("Add a service to get
started") when no service instances exist.
### R10 — Non-regression
- R10.1 The existing widget system, ServicePage config/secrets editing, settings
(machines, SSH keys), and authentication continue to work.
- R10.2 The backend backup report endpoint, mail queue, and observability
metrics endpoints continue to function (they may gain a service_id
association).
- R10.3 Mobile responsive behavior (already shipped) is preserved across the new
IA.
## Acceptance criteria
- AC1 The top nav renders exactly: Main Dashboard, named dashboards, configured-
service-type entries, Services, Settings — and nothing else.
- AC2 Each content tab listed in R2.4 renders its full operational content
inside the corresponding service page.
- AC3 An instance switcher appears when >1 enabled instance of a type exists and
is absent otherwise.
- AC4 Creating, editing, reordering, and deleting a named dashboard works; each
appears in the nav and is reachable at `/d/:slug`.
- AC5 Legacy routes return 404.
- AC6 The `backups` and `authentik` service types appear in the service-type
list and can be configured like any other service.
- AC7 Existing Jellyseerr service instances are migrated into Jellyfin config
(or dropped with a logged warning when unpaired).
- AC8 A fresh install lands on `/` with the empty-state CTA.
- AC9 `cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` is
green.
- AC10 `cd frontend && npm run lint && npm run build && npm run test` is green.
## Non-goals
- Per-instance top-level nav entries.
- Legacy-route redirects or aliases.
- New widget kinds (pinned service links are a shortcut variant, not a widget
kind).
- Per-user dashboard customization.
- Changes to OIDC authentication.
- Mobile-specific IA divergence.
@@ -0,0 +1,270 @@
# Tasks — Services as hub IA
**Change:** `services-as-hub-ia`
**Phase:** tasks
**Date:** 2026-06-26
## Review workload forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~45006000 |
| Chained PRs recommended | Yes (12 slices) |
| Chain strategy | stacked-to-main |
| Slice order | 13 backend → 4 shell → 59 content tabs → 10 dashboards → 11 cleanup → 12 verify |
Each slice is committed separately. Every slice must leave
`cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` **and**
`cd frontend && npm run lint && npm run build && npm run test` green. Every
touched page gains a Vitest case at the new route and asserts the old route 404s
(where applicable).
---
## Slice 1 — Backend: new service types + Jellyseerr absorption
**Goal:** Registry reflects the new world. No frontend change yet.
- [ ] **1.1 Add `backups` integration**
- Files: `backend/src/media_library_viewer_api/integrations/backups.py` (new),
`integrations/registry.py`
- Details: `BackupsConfig` (`ingestion_label: str = "default"`), no secrets,
widget kind `summary` (move `BackupsWidgetSource` adapter to bind the
service_id). Register in `SERVICE_DEFINITIONS`.
- [ ] **1.2 Add `authentik` integration**
- Files: `integrations/authentik.py` (new), `registry.py`
- Details: `AuthentikConfig` (`base_url: ServiceBaseUrl`, `timeout_seconds`),
secret `api_token` (required). No widget kinds yet.
- [ ] **1.3 Absorb `jellyseerr` into `JellyfinConfig`**
- Files: `integrations/jellyfin.py`, `integrations/jellyseerr.py` (delete),
`integrations/registry.py`, `integrations/__init__.py`
- Details: Add optional `jellyseerr_url`, `jellyseerr_api_key` to
`JellyfinConfig`. Delete the `jellyseerr` integration module and registry
entry. Update tests.
- [ ] **1.4 Jellyseerr migration**
- Files: `services/settings_store.py` (`ensure_defaults`)
- Details: On startup, migrate existing `jellyseerr` rows into paired
`jellyfin` instances per the design. Log a warning for unpaired drops.
- [ ] **1.5 Tests**
- Update `backend/tests/test_services.py`, `test_widgets.py` for the new types
and the migration. Assert registry contains 8 types (alertmanager, authentik,
backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks).
---
## Slice 2 — Backend: Authentik directory client + endpoint
- [ ] **2.1 AuthentikClient**
- Files: `clients/authentik.py` (new)
- Details: `users(search, page, page_size) -> {items, total}` against the
Authentik directory API. Reuse the requests-session pattern from
`clients/jellyseerr.py`. Tests: `tests/test_authentik_client.py`.
- [ ] **2.2 Directory endpoint**
- Files: `routers/authentik_users.py` (new), `main.py` (register router)
- Details: `GET /api/services/authentik/{service_id}/users` proxies to the
client, resolving the service record via the existing dependency. Tests
cover not-configured + unreachable + paginated-success.
---
## Slice 3 — Backend: route cleanup + backups attribution
- [ ] **3.1 Remove Users router**
- Files: `routers/users.py`, `routers/users_impl.py` (delete), `main.py`,
`dependencies.py`
- Details: Delete the Jellyfin-backed user directory + message-compose router
and its deps. Update `test_api.py` to drop the corresponding tests.
- [ ] **3.2 Backups service attribution**
- Files: `routers/backups.py`, `services/settings_store.py`
- Details: backup report endpoint accepts optional `?service_id=`; first-wins
association to an enabled `backups` instance when omitted. Dashboard summary
- poller continue to work.
- [ ] **3.3 Named dashboards backend**
- Files: `models/dashboards.py` (new), `routers/dashboards.py` (new),
`services/settings_store.py` (table + CRUD)
- Details: `named_dashboards` table (id, slug, label, sort_order, payload JSON
of widget+link placements). Endpoints: `GET/POST/PUT/DELETE /api/dashboards`.
Tests in `tests/test_dashboards.py`.
---
## Slice 4 — Frontend: top-nav generation + service-page skeleton
**Goal:** Data-driven nav + tab-skeleton ServicePage shell. Content tabs are
stubs that say "coming soon" so the rest of the app stays green.
- [ ] **4.1 Service-type → nav-entry map**
- Files: `frontend/src/integrations/navEntries.ts` (new)
- Details: Static `SERVICE_TYPE_NAV_ENTRIES` map (jellyfin→Media,
ssh_tasks→[Files, Actions], alertmanager→Alerts, etc.). Helper to filter by
configured types.
- [ ] **4.2 Data-driven nav in `App.tsx`**
- Files: `frontend/src/App.tsx`
- Details: Replace static `navItems` with the memoized list from design. Add
`useDashboards()` and combine with `useServiceInstances()`. Loading skeleton
nav until settled. Legacy routes removed; add 404 catch-all.
- [ ] **4.3 ServicePage tab skeleton + instance switcher**
- Files: `frontend/src/pages/ServicePage.tsx`, new `pages/service-tabs/`
directory, `pages/ServiceTypePage.tsx` (redirect resolver)
- Details: Refactor ServicePage to render `[Overview, ...content, Widgets,
Config]` from `serviceTabs(serviceType)`. Add `/services/:type` resolver
route. Content tabs are stub components ("coming soon"). Instance switcher
dropdown when siblings > 1.
- [ ] **4.4 Empty-state CTAs**
- Files: `frontend/src/pages/Dashboard.tsx`, `pages/ServicesPage.tsx`
- Details: Dashboard shows "Add a service" CTA when no services. Services
page strong empty state.
- [ ] **4.5 Tests**
- Nav-generation tests, service-page-skeleton tests, 404-on-legacy-routes
tests.
---
## Slice 5 — Frontend: Jellyfin content tabs (Media + Requests)
- [ ] **5.1 MediaTab**
- Files: `pages/service-tabs/MediaTab.tsx` (lift from `pages/Media.tsx`)
- Details: Accept `instance` prop, pass `instance.id` to media hooks. Preserve
the index build controls + mobile card layout. Delete the old `/media` route
and `Applications.tsx` wrapper.
- [ ] **5.2 RequestsTab (Jellyseerr enrichment)**
- Files: `pages/service-tabs/RequestsTab.tsx`
- Details: Source from the absorbed `jellyseerr_url`/`jellyseerr_api_key` on
the Jellyfin instance. Render request-management data.
- [ ] **5.3 Tests**
- New tests for MediaTab (instance-scoped), RequestsTab. Delete old Media page
tests.
---
## Slice 6 — Frontend: ssh_tasks content tabs (Files + Actions)
- [ ] **6.1 FilesTab**
- Files: `pages/service-tabs/FilesTab.tsx` (lift from `FileBrowser.impl.tsx`)
- Details: Accept `instance` prop. Delete old `/files` route + page wrapper.
- [ ] **6.2 ActionsTab**
- Files: `pages/service-tabs/ActionsTab.tsx` (lift from `Actions.tsx`)
- Details: Accept `instance` prop. Delete old `/actions` route + page.
- [ ] **6.3 Tests**
---
## Slice 7 — Frontend: backups Jobs tab
- [ ] **7.1 JobsTab**
- Files: `pages/service-tabs/JobsTab.tsx` (lift from `components/BackupsPage.tsx`)
- Details: Accept `instance` prop, scope queries by `instance.id`. Delete old
`/backups` route + page.
- [ ] **7.2 Tests**
---
## Slice 8 — Frontend: Authentik Users + Messaging tabs
- [ ] **8.1 UsersTab**
- Files: `pages/service-tabs/UsersTab.tsx`, `hooks/useAuthentikUsers.ts`,
`api/authentik.ts`
- Details: Directory table + search, sourced from the new endpoint. No
Jellyfin/Jellyseerr enrichment.
- [ ] **8.2 MessagingTab**
- Files: `pages/service-tabs/MessagingTab.tsx` (lift compose UI from
`UsersPage.impl.tsx`)
- Details: Recipient list sourced from Authentik users. Reuse the mail queue +
SMTP settings. Delete the old `/users` route + UsersPage.
- [ ] **8.3 Tests**
---
## Slice 9 — Frontend: Observability split (Alerts + Links + Metrics tabs)
- [ ] **9.1 AlertsTab**
- Files: `pages/service-tabs/AlertsTab.tsx` (lift from `ObservabilityPage.tsx`)
- Details: Alertmanager alerts view, instance-scoped. Delete old
`/observability` route + page.
- [ ] **9.2 LinksTab + MetricsTab**
- Files: `pages/service-tabs/LinksTab.tsx`, `pages/service-tabs/MetricsTab.tsx`
- Details: Grafana deep-links; Prometheus status + PromQL explorer. Each
instance-scoped.
- [ ] **9.3 Tests**
---
## Slice 10 — Frontend: named dashboards
- [ ] **10.1 NamedDashboardPage**
- Files: `pages/NamedDashboardPage.tsx`, `hooks/useDashboards.ts`,
`api/dashboards.ts`
- Details: Render widgets + pinned service links at `/d/:slug`. CRUD via the
new endpoints.
- [ ] **10.2 Pinned service links**
- Files: `components/PinnedServiceLink.tsx`, integration into the dashboard
config dialog
- Details: Shortcut variant targeting `/services/:type/:id` or a specific tab.
- [ ] **10.3 Dashboard management UI**
- Files: a new "Manage dashboards" entry on the Services or Settings page
- Details: Create/rename/reorder/delete named dashboards.
- [ ] **10.4 Tests**
---
## Slice 11 — Cleanup + docs
- [ ] **11.1 Delete dead code**
- Files: any remaining top-level page wrappers, unused hooks, stale types.
- Details: Confirm no references to removed routes/pages remain.
- [ ] **11.2 Update `docs/REQUIREMENTS.md`**
- Files: `docs/REQUIREMENTS.md`
- Details: Rewrite the Information Architecture section. Document the service-
type → nav-entry map, the service-page tab skeleton, named dashboards,
routing, and the Users→Authentik + Observability-split decisions.
- [ ] **11.3 Update `CHANGELOG.md`**
---
## Slice 12 — Verify
- [ ] **12.1 Cross-route manual pass**
- Details: Walk every service type's page + tabs; walk named dashboards; walk
the empty state; confirm legacy routes 404.
- [ ] **12.2 Verify report**
- Files: `openspec/changes/services-as-hub-ia/verify-report.md`
- Details: Per-AC evidence (AC1AC10), tool versions, manual notes, residual
risks.
---
## Notes
- Slices 13 are backend-only; slice 4 is the frontend shell turning on the new
IA with stubs; 59 replace stubs with real content; 10 adds named dashboards;
1112 close out.
- Slices 59 are independent and can be reordered or parallelized across
branches if useful, but each must merge green with its stub replaced.
- The frontend content lifts (59) are the bulk of the line count; treat each as
a self-contained review-sized PR.