Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01527ae4f0 | |||
| b583d5a365 | |||
| 32fa01cc12 | |||
| ac703eecd2 | |||
| d05de0aacd | |||
| 09b9c45665 | |||
| 32516f6e3b | |||
| 30f1b6e6db | |||
| 7808822a55 | |||
| e805c624b2 | |||
| f7b63fead5 | |||
| 2eb649eceb | |||
| 2076ab76fa | |||
| 2e3e7b3850 | |||
| c447dfe68d | |||
| 688a18af22 | |||
| 18ee77a4e4 | |||
| 3d331e4c72 | |||
| eebc86a52b |
+2
-1
@@ -16,7 +16,7 @@ dir: .
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Root configuration and documentation hub for a containerized media library management application with FastAPI backend, React frontend, and full observability stack.
|
||||
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||
## parent
|
||||
-
|
||||
## children
|
||||
@@ -66,6 +66,7 @@ Root configuration and documentation hub for a containerized media library manag
|
||||
- docker-compose.dev.yml
|
||||
- docker-compose.observability.yml
|
||||
- docker-compose.yml
|
||||
- swap-pane
|
||||
- token-usage-output.txt
|
||||
## links
|
||||
index: ./.pi-map.index.md
|
||||
|
||||
+3
-2
@@ -18,7 +18,7 @@ index: ./.pi-map.index.md
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Root configuration and documentation hub for a containerized media library management application with FastAPI backend, React frontend, and full observability stack.
|
||||
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||
## files
|
||||
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
|
||||
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
|
||||
@@ -32,9 +32,10 @@ Root configuration and documentation hub for a containerized media library manag
|
||||
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
|
||||
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
|
||||
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
|
||||
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
|
||||
## arch
|
||||
Docker Compose-based multi-service architecture with Traefik reverse proxy, OIDC authentication, and optional Prometheus/Grafana/Loki/Alertmanager observability stack; supports dev (hot-reload) and prod (TLS) deployments.
|
||||
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager).
|
||||
## tags
|
||||
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend
|
||||
|
||||
## role
|
||||
FastAPI backend service providing JWT-protected REST APIs for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
||||
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ dir: backend
|
||||
index: backend/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI backend service providing JWT-protected REST APIs for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
||||
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||
## files
|
||||
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
|
||||
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
|
||||
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
|
||||
## arch
|
||||
Layered Python 3.11 API using FastAPI with uvicorn ASGI server, containerized via Docker for deployment.
|
||||
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support.
|
||||
## tags
|
||||
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src
|
||||
|
||||
## role
|
||||
Backend application source directory containing the core business logic, API endpoints, data models, and server configuration for the project.
|
||||
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||
## parent
|
||||
index: backend/.pi-map.index.md
|
||||
map: backend/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: backend/src
|
||||
index: backend/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Backend application source directory containing the core business logic, API endpoints, data models, and server configuration for the project.
|
||||
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||
## files
|
||||
## arch
|
||||
Likely follows a layered/n-tier architecture (controllers/services/models) with possible modular or hexagonal patterns; structure depends on framework conventions (e.g., Express, NestJS, Django, Spring Boot).
|
||||
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture).
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api
|
||||
|
||||
## role
|
||||
FastAPI backend service that provides authenticated APIs for viewing, analyzing, and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
||||
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||
## parent
|
||||
index: backend/src/.pi-map.index.md
|
||||
map: backend/src/.pi-map.md
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: backend/src/media_library_viewer_api
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI backend service that provides authenticated APIs for viewing, analyzing, and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
||||
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
||||
@@ -18,7 +18,7 @@ FastAPI backend service that provides authenticated APIs for viewing, analyzing,
|
||||
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
||||
## arch
|
||||
Layered FastAPI architecture using dependency injection, pydantic-settings configuration, middleware-based OIDC/JWT/API-key authentication, template-based remote job execution, and Prometheus-based observability.
|
||||
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution.
|
||||
## tags
|
||||
call:, settings, call:get, request, get, client, call:str, id
|
||||
## symbols
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Authentik directory API client.
|
||||
|
||||
Authentik is the user-directory source (replacing the Jellyfin-backed Users
|
||||
page). This client wraps the Authentik REST API for browsing the user directory
|
||||
with pagination and search. OIDC authentication is unchanged — this client is
|
||||
for the directory, not SSO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthentikClient:
|
||||
"""Small wrapper around the Authentik core directory API."""
|
||||
|
||||
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
|
||||
if not base_url:
|
||||
raise ValueError("Authentik base_url is required")
|
||||
if not api_token:
|
||||
raise ValueError("Authentik API token is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/api/v3"):
|
||||
self.base_url = self.base_url[:-7]
|
||||
self.api_token = api_token
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def get(self, path: str, **params: Any) -> Any:
|
||||
"""GET an Authentik endpoint and include useful response text on errors."""
|
||||
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/api/v3{path}",
|
||||
params=clean_params,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
logger.warning(
|
||||
"Authentik GET %s failed status=%s url=%s",
|
||||
path,
|
||||
response.status_code,
|
||||
response.url,
|
||||
)
|
||||
raise requests.HTTPError(
|
||||
f"{response.status_code} for {response.url}: {detail}",
|
||||
response=response,
|
||||
) from exc
|
||||
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
|
||||
return response.json()
|
||||
|
||||
def users(
|
||||
self,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a normalized page of Authentik users.
|
||||
|
||||
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
|
||||
Authentik response into ``{items, total, page, page_size}``. Each item
|
||||
is the raw Authentik user dict (pk, username, name, email, avatar, …)
|
||||
so the frontend can pick the fields it needs.
|
||||
"""
|
||||
payload = self.get(
|
||||
"/core/users/",
|
||||
search=search,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
results = payload.get("results")
|
||||
items: list[dict[str, Any]] = (
|
||||
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
||||
)
|
||||
|
||||
pagination = payload.get("pagination") or {}
|
||||
total = 0
|
||||
if isinstance(pagination, dict):
|
||||
try:
|
||||
total = int(pagination.get("count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
|
||||
logger.info(
|
||||
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
|
||||
page,
|
||||
page_size,
|
||||
len(items),
|
||||
total,
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
@@ -18,7 +18,6 @@ from typing import Any
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
@@ -178,22 +177,6 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||
return _jellyfin_client_for(cache_key)
|
||||
|
||||
|
||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
||||
store = get_settings_store()
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyseerr", service_id)
|
||||
if service is None:
|
||||
logger.info("Jellyseerr client not configured (no jellyseerr service)")
|
||||
return None
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
logger.info("Jellyseerr service is missing base_url or api_key")
|
||||
return None
|
||||
return JellyseerrClient(base_url, api_key)
|
||||
|
||||
|
||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||
"""Build a RemoteSSHClient from a machine config dict."""
|
||||
store = store or get_settings_store()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/integrations
|
||||
|
||||
## role
|
||||
Defines and registers third-party service integrations (e.g., Alertmanager, Grafana, Jellyfin, Prometheus) with typed configs, secrets, and widget definitions for the media library viewer API.
|
||||
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
|
||||
@@ -4,22 +4,22 @@ dir: backend/src/media_library_viewer_api/integrations
|
||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines and registers third-party service integrations (e.g., Alertmanager, Grafana, Jellyfin, Prometheus) with typed configs, secrets, and widget definitions for the media library viewer API.
|
||||
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||
## files
|
||||
- __init__.py | Defines a closed registry module for service integrations.
|
||||
- alertmanager.py | Defines the Alertmanager service integration, including connection config, widget definitions, and alert summarization logic. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
||||
- base.py | Defines base classes and utilities for creating compile-time service integration definitions with Pydantic-based config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
||||
- grafana.py | Defines the Grafana service configuration, secret fields, and widget types for integration with the media library viewer API. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyfin.py | Defines the Jellyfin media server service configuration and activity widget definition for a media library viewer API. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyseerr.py | Defines a service configuration and metadata for Jellyseerr, a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
|
||||
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
||||
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
||||
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
|
||||
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
|
||||
- prometheus.py | Defines the service configuration, widget types, and service definition for integrating Prometheus as a metrics data source. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
|
||||
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
## arch
|
||||
Plugin/registry pattern using Pydantic-based config schemas with a closed registry for compile-time service definition lookups, base class extension for per-service widgets and secrets, and a modular file-per-integration structure.
|
||||
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions.
|
||||
## tags
|
||||
config, service, widget, integrations, media_library_viewer_api, base, definition, kind
|
||||
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
|
||||
## symbols
|
||||
- AlertmanagerConfig
|
||||
- AlertmanagerAlertsWidgetConfig
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class AlertmanagerConfig(ServiceConfigBase):
|
||||
"""Non-secret Alertmanager connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -16,9 +16,37 @@ map. There is no runtime plugin loading.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
|
||||
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("base_url must be a string starting with http:// or https://")
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("base_url must not be empty")
|
||||
lowered = text.lower()
|
||||
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
||||
raise ValueError("base_url must start with http:// or https:// (include the schema)")
|
||||
return text
|
||||
|
||||
|
||||
#: Shared annotated type for service ``base_url`` fields. applying the validator
|
||||
#: uniformly across every integration so missing schemas are rejected at the
|
||||
#: config boundary with a helpful message.
|
||||
ServiceBaseUrl = Annotated[
|
||||
str,
|
||||
Field(description="Absolute URL including the http:// or https:// schema."),
|
||||
BeforeValidator(_validate_service_base_url),
|
||||
]
|
||||
|
||||
|
||||
class ServiceConfigBase(BaseModel):
|
||||
@@ -26,6 +54,9 @@ class ServiceConfigBase(BaseModel):
|
||||
|
||||
Subclass this in each integration module and declare the connection fields.
|
||||
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
||||
|
||||
Connection URLs should use the :data:`ServiceBaseUrl` type so the
|
||||
``http(s)://`` schema is enforced consistently across integrations.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -12,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
|
||||
|
||||
|
||||
class JellyfinConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyfin connection config."""
|
||||
"""Non-secret Jellyfin connection config.
|
||||
|
||||
base_url: str
|
||||
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
|
||||
paired Jellyseerr companion config, absorbed from the former standalone
|
||||
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
|
||||
When both are set, the Jellyfin service page renders a Requests tab backed
|
||||
by Jellyseerr.
|
||||
"""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 10
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
|
||||
|
||||
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
|
||||
@@ -1,32 +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,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
|
||||
|
||||
class JellyseerrConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyseerr connection config."""
|
||||
|
||||
base_url: str
|
||||
|
||||
|
||||
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=[],
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class NextcloudConfig(ServiceConfigBase):
|
||||
"""Non-secret Nextcloud connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
username: str = ""
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
|
||||
class PrometheusConfig(ServiceConfigBase):
|
||||
"""Non-secret Prometheus connection config."""
|
||||
|
||||
base_url: str
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ There is no runtime plugin loading.
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
@@ -20,9 +21,10 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
JELLYSEERR.service_type: JELLYSEERR,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
SSH_TASKS.service_type: SSH_TASKS,
|
||||
BACKUPS.service_type: BACKUPS,
|
||||
AUTHENTIK.service_type: AUTHENTIK,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,8 +21,12 @@ from media_library_viewer_api.observability import (
|
||||
record_request,
|
||||
set_current_request_id,
|
||||
)
|
||||
from media_library_viewer_api.routers import (
|
||||
authentik_users as authentik_users_router,
|
||||
)
|
||||
from media_library_viewer_api.routers import backups as backups_router
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
|
||||
from media_library_viewer_api.routers import dashboards as dashboards_router
|
||||
from media_library_viewer_api.routers import services as services_router
|
||||
from media_library_viewer_api.routers import widgets as widgets_router
|
||||
from media_library_viewer_api.routers.settings import router as settings_router
|
||||
@@ -136,12 +140,13 @@ app.include_router(monitoring.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(backups_router.router)
|
||||
app.include_router(widgets_router.router)
|
||||
app.include_router(dashboards_router.router)
|
||||
app.include_router(services_router.router)
|
||||
app.include_router(authentik_users_router.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Pydantic models for the named-dashboards API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NamedDashboardInput(BaseModel):
|
||||
"""Input for create/update of a named dashboard."""
|
||||
|
||||
id: str | None = None
|
||||
label: str = Field(default="Dashboard")
|
||||
slug: str | None = None
|
||||
sort_order: int = 0
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NamedDashboard(BaseModel):
|
||||
"""A named dashboard record."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
slug: str
|
||||
sort_order: int
|
||||
payload: dict[str, Any]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Authentik directory + messaging router.
|
||||
|
||||
Resolves an ``authentik`` service instance from the registry, builds an
|
||||
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
|
||||
proxies paginated directory queries plus message-compose (email enqueue).
|
||||
Graceful "not configured" / "unreachable" payloads (matching the monitoring
|
||||
router's pattern) so the UI always renders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
"""Compose-request body for the Authentik messaging endpoint."""
|
||||
|
||||
recipient_emails: list[str]
|
||||
subject: str
|
||||
html_body: str
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested authentik instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled ``authentik`` instance is configured.
|
||||
"""
|
||||
service_type = "authentik"
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_token = str(service.secrets.get("api_token") or "")
|
||||
try:
|
||||
timeout = float(service.config.get("timeout_seconds") or 10)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 10.0
|
||||
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
|
||||
|
||||
|
||||
def _empty(error: str) -> dict[str, Any]:
|
||||
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
|
||||
|
||||
|
||||
@router.get("/{service_id}/users")
|
||||
def get_authentik_users(
|
||||
service_id: str,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Paginated Authentik user directory for a specific service instance."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
||||
return _empty("Authentik service not configured")
|
||||
|
||||
try:
|
||||
client = _build_client(service)
|
||||
return client.users(search=search, page=page, page_size=page_size)
|
||||
except Exception:
|
||||
logger.exception("Authentik users query failed for service %s", service_id)
|
||||
return _empty("Authentik is unreachable")
|
||||
|
||||
|
||||
@router.get("/{service_id}/message/status")
|
||||
def get_authentik_message_status(
|
||||
service_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||
return mail_queue.status()
|
||||
|
||||
|
||||
@router.post("/{service_id}/message")
|
||||
def post_authentik_message(
|
||||
service_id: str,
|
||||
body: MessageRequest,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
return {"status": "error", "error": "Authentik service not configured"}
|
||||
|
||||
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
|
||||
if not recipients:
|
||||
return {"status": "error", "error": "No recipients with valid email addresses."}
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
validate_smtp_settings(settings)
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
|
||||
|
||||
request_id = mail_queue.enqueue(
|
||||
settings=settings,
|
||||
recipients=recipients,
|
||||
subject=body.subject,
|
||||
html_body=body.html_body,
|
||||
)
|
||||
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
|
||||
return {
|
||||
"status": "queued",
|
||||
"request_id": request_id,
|
||||
"recipient_count": len(recipients),
|
||||
}
|
||||
@@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store
|
||||
router = APIRouter(prefix="/api/backups", tags=["backups"])
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
|
||||
def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
|
||||
"""Return the service_id for backup attribution.
|
||||
|
||||
First-wins: if no explicit service_id is given, pick the first enabled
|
||||
``backups`` service instance (spec R6.1). Returns an empty string when
|
||||
none is configured (backward-compatible with pre-service reports).
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
candidates = store.list_services("backups")
|
||||
for svc in candidates:
|
||||
if svc.get("enabled"):
|
||||
return svc["id"]
|
||||
return ""
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]:
|
||||
job = store.get_backup_job_by_name(report.name)
|
||||
if not job:
|
||||
job = store.upsert_backup_job(
|
||||
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
elif report.schedule_interval_seconds:
|
||||
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
job = store.get_backup_job(job["id"])
|
||||
@@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
@router.post("/report")
|
||||
def post_backup_report(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
# Check for duplicate (same job + started_at within 1s)
|
||||
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
|
||||
@@ -88,10 +108,12 @@ def post_backup_report(
|
||||
@router.post("/report/start")
|
||||
def post_backup_start(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
run_data = {
|
||||
"job_id": job["id"],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Named dashboards CRUD router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/dashboards", tags=["dashboards"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]:
|
||||
rows = store.list_dashboards()
|
||||
return [NamedDashboard(**row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/slug/{slug}")
|
||||
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.get_dashboard_by_slug(slug)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.upsert_dashboard(body.model_dump())
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.put("/{dashboard_id}")
|
||||
def update_dashboard(
|
||||
dashboard_id: str,
|
||||
body: NamedDashboardInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> NamedDashboard:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
if body.id and body.id != dashboard_id:
|
||||
raise HTTPException(status_code=400, detail="ID mismatch")
|
||||
row = store.upsert_dashboard(body.model_dump(), dashboard_id)
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.delete("/{dashboard_id}")
|
||||
def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
store.delete_dashboard(dashboard_id)
|
||||
return {"status": "deleted"}
|
||||
@@ -1 +0,0 @@
|
||||
from .users_impl import * # noqa: F401,F403
|
||||
@@ -1,389 +0,0 @@
|
||||
"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
)
|
||||
from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
_PERMISSION_FLAGS = [
|
||||
(2, "admin"),
|
||||
(4, "manage_settings"),
|
||||
(8, "manage_users"),
|
||||
(16, "manage_requests"),
|
||||
(32, "request"),
|
||||
(64, "vote"),
|
||||
(128, "auto_approve"),
|
||||
(256, "auto_approve_movie"),
|
||||
(512, "auto_approve_tv"),
|
||||
(1024, "request_4k"),
|
||||
(2048, "request_4k_movie"),
|
||||
(4096, "request_4k_tv"),
|
||||
(8192, "request_advanced"),
|
||||
(16384, "request_view"),
|
||||
(32768, "auto_approve_4k"),
|
||||
(65536, "auto_approve_4k_movie"),
|
||||
(131072, "auto_approve_4k_tv"),
|
||||
(262144, "request_movie"),
|
||||
(524288, "request_tv"),
|
||||
(1048576, "manage_issues"),
|
||||
(2097152, "view_issues"),
|
||||
]
|
||||
|
||||
_USER_TYPES = {
|
||||
1: "plex",
|
||||
2: "local",
|
||||
3: "jellyfin",
|
||||
4: "emby",
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _permission_labels(permissions: int) -> list[str]:
|
||||
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
|
||||
return labels or ["none"]
|
||||
|
||||
|
||||
def _role_label(permissions: int) -> str:
|
||||
if permissions & 2:
|
||||
return "admin"
|
||||
if permissions & (4 | 8 | 16):
|
||||
return "manager"
|
||||
if permissions & (32 | 64 | 128):
|
||||
return "requester"
|
||||
return "user"
|
||||
|
||||
|
||||
def _account_type(user_type: Any) -> str:
|
||||
return _USER_TYPES.get(_safe_int(user_type), "unknown")
|
||||
|
||||
|
||||
def _merge_users(
|
||||
jellyfin_users: list[dict[str, Any]],
|
||||
jellyseerr_jellyfin_users: list[dict[str, Any]] | None,
|
||||
jellyseerr_users: list[dict[str, Any]] | None,
|
||||
jellyseerr_client: JellyseerrClient | None,
|
||||
) -> dict[str, Any]:
|
||||
def _normalize(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
def _looks_like_email(value: Any) -> bool:
|
||||
text = str(value or "").strip()
|
||||
return bool(text and "@" in text and " " not in text)
|
||||
|
||||
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
|
||||
for source, value in candidates:
|
||||
if _looks_like_email(value):
|
||||
return source, str(value).strip()
|
||||
return "", ""
|
||||
|
||||
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
|
||||
for source, value in candidates:
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return source, text
|
||||
return "", ""
|
||||
|
||||
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
|
||||
return ", ".join(
|
||||
[
|
||||
f"name={name_source or 'none'}",
|
||||
f"email={email_source or 'none'}",
|
||||
f"avatar={avatar_source or 'none'}",
|
||||
f"access={access_source or 'none'}",
|
||||
]
|
||||
)
|
||||
|
||||
def _lookup_keys(item: dict[str, Any]) -> list[str]:
|
||||
return [
|
||||
_normalize(item.get("id")),
|
||||
_normalize(item.get("Id")),
|
||||
_normalize(item.get("userId")),
|
||||
_normalize(item.get("user_id")),
|
||||
_normalize(item.get("jellyfinUserId")),
|
||||
_normalize(item.get("jellyfin_user_id")),
|
||||
_normalize(item.get("jellyfinUsername")),
|
||||
_normalize(item.get("jellyfin_username")),
|
||||
_normalize(item.get("username")),
|
||||
_normalize(item.get("displayName")),
|
||||
_normalize(item.get("display_name")),
|
||||
]
|
||||
|
||||
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
|
||||
for item in jellyseerr_jellyfin_users or []:
|
||||
for key in (
|
||||
item.get("id"),
|
||||
item.get("Id"),
|
||||
item.get("userId"),
|
||||
item.get("user_id"),
|
||||
item.get("jellyfinUserId"),
|
||||
item.get("jellyfin_user_id"),
|
||||
):
|
||||
normalized = _normalize(key)
|
||||
if normalized:
|
||||
linked_by_jellyfin_id[normalized] = item
|
||||
|
||||
seerr_by_key: dict[str, dict[str, Any]] = {}
|
||||
for item in jellyseerr_users or []:
|
||||
for key in _lookup_keys(item):
|
||||
if key:
|
||||
seerr_by_key[key] = item
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
enriched_count = 0
|
||||
for user in jellyfin_users:
|
||||
jellyfin_id = str(user.get("Id") or user.get("id") or "")
|
||||
jellyfin_name = str(user.get("Name") or user.get("name") or "")
|
||||
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
|
||||
|
||||
seerr_user = None
|
||||
for candidate in [
|
||||
jellyfin_name,
|
||||
(jf_link or {}).get("jellyfinUsername"),
|
||||
(jf_link or {}).get("jellyfin_username"),
|
||||
(jf_link or {}).get("username"),
|
||||
(jf_link or {}).get("displayName"),
|
||||
(jf_link or {}).get("display_name"),
|
||||
]:
|
||||
seerr_user = seerr_by_key.get(_normalize(candidate))
|
||||
if seerr_user:
|
||||
break
|
||||
|
||||
email_source, email = _pick_source_and_value(
|
||||
[
|
||||
("jellyseerr:user", (seerr_user or {}).get("email")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
|
||||
]
|
||||
)
|
||||
avatar_source, avatar = _first_value(
|
||||
[
|
||||
("jellyseerr:user", (seerr_user or {}).get("avatar")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
|
||||
]
|
||||
)
|
||||
if avatar and jellyseerr_client:
|
||||
avatar = jellyseerr_client.absolute_url(avatar)
|
||||
|
||||
permissions = _safe_int((seerr_user or {}).get("permissions"))
|
||||
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
|
||||
role = _role_label(permissions)
|
||||
access_source = "jellyseerr:user" if seerr_user else ""
|
||||
name_source = "jellyfin"
|
||||
summary = _source_summary(name_source, email_source, avatar_source, access_source)
|
||||
|
||||
if seerr_user or jf_link:
|
||||
enriched_count += 1
|
||||
|
||||
items.append(
|
||||
{
|
||||
"jellyfin_id": jellyfin_id,
|
||||
"username": jellyfin_name,
|
||||
"display_name": jellyfin_name,
|
||||
"email": email,
|
||||
"email_source": email_source,
|
||||
"avatar": avatar,
|
||||
"avatar_source": avatar_source,
|
||||
"contactable": bool(email),
|
||||
"source": summary,
|
||||
"source_summary": summary,
|
||||
"name_source": name_source,
|
||||
"access_source": access_source,
|
||||
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId"))
|
||||
or None,
|
||||
"jellyseerr_username": str(
|
||||
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
|
||||
),
|
||||
"user_type": user_type or None,
|
||||
"user_type_label": _account_type(user_type),
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
"permissions_label": ", ".join(_permission_labels(permissions)),
|
||||
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
|
||||
len(jellyfin_users),
|
||||
len(jellyseerr_jellyfin_users or []),
|
||||
len(jellyseerr_users or []),
|
||||
enriched_count,
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"jellyseerr_configured": jellyseerr_client is not None,
|
||||
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
|
||||
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
|
||||
"jellyseerr_user_count": len(jellyseerr_users or []),
|
||||
"enriched_count": enriched_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_users(
|
||||
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
|
||||
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
|
||||
) -> dict[str, Any]:
|
||||
"""Return the known users, enriched with Jellyseerr data when available."""
|
||||
jellyfin_users = jellyfin.users()
|
||||
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
|
||||
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
|
||||
jellyseerr_users: list[dict[str, Any]] | None = None
|
||||
jellyseerr_error = ""
|
||||
if jellyseerr:
|
||||
try:
|
||||
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
|
||||
except Exception as exc: # pragma: no cover - network fallback
|
||||
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
|
||||
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
|
||||
try:
|
||||
jellyseerr_users = jellyseerr.users()
|
||||
except Exception as exc: # pragma: no cover - network fallback
|
||||
logger.exception("Jellyseerr user list fetch failed")
|
||||
jellyseerr_error = (
|
||||
f"{jellyseerr_error}; " if jellyseerr_error else ""
|
||||
) + f"Jellyseerr user list fetch failed: {exc}"
|
||||
|
||||
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
|
||||
result["jellyseerr_error"] = jellyseerr_error
|
||||
logger.info(
|
||||
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
|
||||
result["total"],
|
||||
result["jellyseerr_configured"],
|
||||
result["jellyseerr_available"],
|
||||
result["enriched_count"],
|
||||
bool(jellyseerr_error),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/message/status")
|
||||
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
|
||||
"""Return the current background email queue status."""
|
||||
return mail_queue.status()
|
||||
|
||||
|
||||
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def post_user_message(
|
||||
recipient_ids: str = Form(...),
|
||||
subject: str = Form(...),
|
||||
html_body: str = Form(""),
|
||||
text_body: str = Form(""),
|
||||
attachments: list[UploadFile] | None = File(default=None),
|
||||
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
|
||||
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
|
||||
mail_queue=Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a single email to the selected users without blocking the API."""
|
||||
try:
|
||||
requested_ids = json.loads(recipient_ids)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(requested_ids, list):
|
||||
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
|
||||
|
||||
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
|
||||
if not cleaned_ids:
|
||||
raise HTTPException(status_code=400, detail="At least one recipient is required")
|
||||
|
||||
subject = subject.strip()
|
||||
if not subject:
|
||||
raise HTTPException(status_code=400, detail="Subject is required")
|
||||
|
||||
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
|
||||
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
|
||||
|
||||
recipients: list[str] = []
|
||||
recipient_labels: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for user_id in cleaned_ids:
|
||||
item = users_by_id.get(user_id)
|
||||
if not item:
|
||||
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
|
||||
continue
|
||||
email = str(item.get("email") or "").strip()
|
||||
if not email:
|
||||
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
|
||||
continue
|
||||
recipients.append(email)
|
||||
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
|
||||
|
||||
if not recipients:
|
||||
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
|
||||
|
||||
settings = get_settings()
|
||||
validate_smtp_settings(settings)
|
||||
|
||||
queue_status = mail_queue.status()
|
||||
if not queue_status["worker_running"]:
|
||||
raise HTTPException(status_code=503, detail="Email queue worker is not running")
|
||||
|
||||
attachment_payloads: list[EmailAttachment] = []
|
||||
for upload in attachments or []:
|
||||
data = await upload.read()
|
||||
if not data:
|
||||
continue
|
||||
attachment_payloads.append(
|
||||
EmailAttachment(
|
||||
filename=upload.filename or "attachment",
|
||||
content_type=upload.content_type or "application/octet-stream",
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
request_id = mail_queue.enqueue(
|
||||
settings=settings,
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
text_body=text_body,
|
||||
attachments=attachment_payloads,
|
||||
)
|
||||
from_address = (
|
||||
str(getattr(settings, "smtp_from_address", "") or "").strip()
|
||||
or str(getattr(settings, "smtp_username", "") or "").strip()
|
||||
)
|
||||
logger.info(
|
||||
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
|
||||
request_id,
|
||||
subject,
|
||||
len(recipients),
|
||||
len(attachment_payloads),
|
||||
len(skipped),
|
||||
)
|
||||
return {
|
||||
"status": "queued",
|
||||
"request_id": request_id,
|
||||
"from_address": from_address,
|
||||
"recipient_count": len(recipients),
|
||||
"attachment_count": len(attachment_payloads),
|
||||
"subject": subject,
|
||||
"recipient_labels": recipient_labels,
|
||||
"skipped": skipped,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ in the same UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
@@ -19,6 +20,8 @@ import paramiko
|
||||
|
||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
DEFAULT_SERVICES = ["monitoring", "files"]
|
||||
@@ -172,6 +175,9 @@ class SettingsStore:
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()}
|
||||
if "service_id" not in backup_job_cols:
|
||||
conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -244,6 +250,19 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS named_dashboards (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
@@ -415,6 +434,75 @@ class SettingsStore:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if not row or int(row[0]) == 0:
|
||||
self._seed_local_machine()
|
||||
self._migrate_jellyseerr_into_jellyfin()
|
||||
|
||||
def _migrate_jellyseerr_into_jellyfin(self) -> None:
|
||||
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
|
||||
|
||||
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
|
||||
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
|
||||
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
|
||||
paired -> drop with a logged warning.
|
||||
"""
|
||||
from media_library_viewer_api.services.secrets import decrypt_value
|
||||
|
||||
self.init_schema()
|
||||
jellyseerr_rows: list[sqlite3.Row] = []
|
||||
with self.connect() as conn:
|
||||
jellyseerr_rows = conn.execute(
|
||||
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
|
||||
).fetchall()
|
||||
if not jellyseerr_rows:
|
||||
return
|
||||
|
||||
jellyfin_rows = self.list_services("jellyfin")
|
||||
for js_row in jellyseerr_rows:
|
||||
js_config = json.loads(js_row["config_json"] or "{}")
|
||||
js_secrets = json.loads(js_row["secrets_json"] or "{}")
|
||||
js_url = str(js_config.get("base_url", "")).strip()
|
||||
js_api_key = str(js_secrets.get("api_key", "")).strip()
|
||||
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
|
||||
if js_api_key:
|
||||
try:
|
||||
js_api_key = decrypt_value(js_api_key)
|
||||
except Exception:
|
||||
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
|
||||
js_api_key = ""
|
||||
js_name = js_row["name"]
|
||||
|
||||
target = None
|
||||
if len(jellyfin_rows) == 1:
|
||||
target = jellyfin_rows[0]
|
||||
elif len(jellyfin_rows) > 1:
|
||||
for jf in jellyfin_rows:
|
||||
if not str(jf["config"].get("jellyseerr_url", "")).strip():
|
||||
target = jf
|
||||
break
|
||||
|
||||
if target:
|
||||
merged_config = dict(target["config"])
|
||||
merged_config["jellyseerr_url"] = js_url
|
||||
merged_config["jellyseerr_api_key"] = js_api_key
|
||||
self.upsert_service(
|
||||
{
|
||||
"id": target["id"],
|
||||
"service_type": "jellyfin",
|
||||
"name": target["name"],
|
||||
"config": merged_config,
|
||||
"enabled": target["enabled"],
|
||||
},
|
||||
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
|
||||
)
|
||||
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
|
||||
else:
|
||||
logger.warning(
|
||||
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
|
||||
js_name,
|
||||
)
|
||||
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
|
||||
conn.commit()
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
@@ -892,6 +980,7 @@ class SettingsStore:
|
||||
"source": row["source"],
|
||||
"target": row["target"],
|
||||
"schedule_interval_seconds": row["schedule_interval_seconds"],
|
||||
"service_id": row["service_id"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
@@ -910,12 +999,18 @@ class SettingsStore:
|
||||
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
|
||||
if schedule_interval_seconds is not None:
|
||||
schedule_interval_seconds = int(schedule_interval_seconds)
|
||||
service_id = str(
|
||||
payload.get("service_id")
|
||||
if payload.get("service_id") is not None
|
||||
else (current or {}).get("service_id", "") or ""
|
||||
).strip()
|
||||
return {
|
||||
"id": job_id,
|
||||
"name": name,
|
||||
"source": source,
|
||||
"target": target,
|
||||
"schedule_interval_seconds": schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
|
||||
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
@@ -933,17 +1028,19 @@ class SettingsStore:
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
@@ -951,6 +1048,7 @@ class SettingsStore:
|
||||
job["source"],
|
||||
job["target"],
|
||||
job["schedule_interval_seconds"],
|
||||
job["service_id"],
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
@@ -1566,6 +1664,113 @@ class SettingsStore:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Named dashboards
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _slugify(label: str) -> str:
|
||||
import re
|
||||
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
|
||||
return slug or "dashboard"
|
||||
|
||||
def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str:
|
||||
self.init_schema()
|
||||
base = slug
|
||||
suffix = 1
|
||||
with self.connect() as conn:
|
||||
while True:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM named_dashboards WHERE slug = ? AND id != ?",
|
||||
(slug, exclude_id or ""),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return slug
|
||||
suffix += 1
|
||||
slug = f"{base}-{suffix}"
|
||||
|
||||
def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"label": row["label"],
|
||||
"slug": row["slug"],
|
||||
"sort_order": row["sort_order"],
|
||||
"payload": json.loads(row["payload_json"] or "{}"),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def list_dashboards(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [self._row_to_dashboard(row) for row in rows]
|
||||
|
||||
def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None:
|
||||
if not dashboard_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None:
|
||||
if not slug:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
current = self.get_dashboard(dashboard_id) if dashboard_id else None
|
||||
dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip()
|
||||
label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip()
|
||||
slug = str(payload.get("slug") or "").strip() or self._slugify(label)
|
||||
slug = self._unique_slug(slug, exclude_id=dash_id)
|
||||
sort_order = payload.get("sort_order")
|
||||
if sort_order is None:
|
||||
sort_order = (current or {}).get("sort_order", 0)
|
||||
sort_order = int(sort_order)
|
||||
payload_data = payload.get("payload")
|
||||
if payload_data is None:
|
||||
payload_data = (current or {}).get("payload", {})
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
slug = excluded.slug,
|
||||
sort_order = excluded.sort_order,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
dash_id,
|
||||
label,
|
||||
slug,
|
||||
sort_order,
|
||||
json.dumps(payload_data),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug}
|
||||
|
||||
def delete_dashboard(self, dashboard_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,))
|
||||
|
||||
|
||||
_store: SettingsStore | None = None
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/tests
|
||||
|
||||
## role
|
||||
Test suite providing unit and integration coverage for the backend's API endpoints, clients, configuration, utilities, and services.
|
||||
Test suite providing unit and integration tests that validate API endpoints, configuration, external clients, utilities, and service logic across the backend.
|
||||
## parent
|
||||
index: backend/.pi-map.index.md
|
||||
map: backend/.pi-map.md
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-152
@@ -14,8 +14,6 @@ from fastapi.testclient import TestClient
|
||||
from media_library_viewer_api.clients.ssh import CommandResult
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
get_settings_store,
|
||||
get_ssh_client,
|
||||
get_user_id,
|
||||
@@ -70,38 +68,6 @@ def mock_jellyfin():
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jellyseerr():
|
||||
"""Mock Jellyseerr client."""
|
||||
client = MagicMock()
|
||||
client.jellyfin_users.return_value = [
|
||||
{"id": "jf1", "username": "alex", "thumb": "/avatarproxy/alex", "email": "alex@example.com"},
|
||||
{"id": "jf2", "username": "sam", "thumb": "/avatarproxy/sam", "email": "sam@example.com"},
|
||||
]
|
||||
client.users.return_value = [
|
||||
{
|
||||
"id": 7,
|
||||
"username": "alex",
|
||||
"email": "alex@example.com",
|
||||
"avatar": "/avatarproxy/alex",
|
||||
"userType": 3,
|
||||
"permissions": 10,
|
||||
"requestCount": 3,
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"username": "sam",
|
||||
"email": "sam@example.com",
|
||||
"avatar": "/avatarproxy/sam",
|
||||
"userType": 2,
|
||||
"permissions": 32,
|
||||
"requestCount": 1,
|
||||
},
|
||||
]
|
||||
client.absolute_url.side_effect = lambda path: f"https://requests.example.com{path}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssh():
|
||||
"""Mock SSH client."""
|
||||
@@ -132,10 +98,9 @@ def mock_ssh():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh, tmp_path):
|
||||
def test_client(mock_jellyfin, mock_ssh, tmp_path):
|
||||
"""FastAPI test client with mocked dependencies."""
|
||||
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
|
||||
app.dependency_overrides[get_jellyseerr_client] = lambda: mock_jellyseerr
|
||||
app.dependency_overrides[get_ssh_client] = lambda: mock_ssh
|
||||
app.dependency_overrides[get_user_id] = lambda: "user123"
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
@@ -293,122 +258,6 @@ class TestSettingsReset:
|
||||
assert len(store.list_machines()) == 0
|
||||
|
||||
|
||||
# --- Users ---
|
||||
|
||||
|
||||
class TestUsers:
|
||||
def test_users_list_enriched(self, test_client):
|
||||
response = test_client.get("/api/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 2
|
||||
assert data["jellyseerr_configured"] is True
|
||||
assert data["jellyseerr_available"] is True
|
||||
assert data["jellyseerr_error"] == ""
|
||||
|
||||
alex = next(item for item in data["items"] if item["username"] == "alex")
|
||||
assert alex["email"] == "alex@example.com"
|
||||
assert alex["email_source"] == "jellyseerr:user"
|
||||
assert alex["contactable"] is True
|
||||
assert alex["avatar"].startswith("https://requests.example.com/")
|
||||
assert alex["avatar_source"] == "jellyseerr:user"
|
||||
assert alex["permissions"] == 10
|
||||
assert alex["permissions_label"] == "admin, manage_users"
|
||||
assert alex["role"] == "admin"
|
||||
assert alex["user_type_label"] == "jellyfin"
|
||||
assert alex["request_count"] == 3
|
||||
assert "name=jellyfin" in alex["source_summary"]
|
||||
assert "email=jellyseerr:user" in alex["source_summary"]
|
||||
|
||||
sam = next(item for item in data["items"] if item["username"] == "sam")
|
||||
assert sam["role"] == "requester"
|
||||
assert sam["user_type_label"] == "local"
|
||||
assert sam["email"] == "sam@example.com"
|
||||
|
||||
def test_users_message_status(self, test_client):
|
||||
mail_queue = MagicMock()
|
||||
mail_queue.status.return_value = {
|
||||
"state": "idle",
|
||||
"worker_running": True,
|
||||
"stop_requested": False,
|
||||
"pending_count": 0,
|
||||
"active_request_id": None,
|
||||
"last_request_id": None,
|
||||
"last_result": None,
|
||||
"last_error": "",
|
||||
"last_error_at": None,
|
||||
"last_success_at": None,
|
||||
"last_activity_at": None,
|
||||
"sent_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
|
||||
try:
|
||||
response = test_client.get("/api/users/message/status")
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["state"] == "idle"
|
||||
assert response.json()["pending_count"] == 0
|
||||
|
||||
def test_users_message_is_queued(self, test_client):
|
||||
mail_queue = MagicMock()
|
||||
mail_queue.status.return_value = {
|
||||
"state": "idle",
|
||||
"worker_running": True,
|
||||
"stop_requested": False,
|
||||
"pending_count": 0,
|
||||
"active_request_id": None,
|
||||
"last_request_id": None,
|
||||
"last_result": None,
|
||||
"last_error": "",
|
||||
"last_error_at": None,
|
||||
"last_success_at": None,
|
||||
"last_activity_at": None,
|
||||
"sent_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
mail_queue.enqueue.return_value = "mail-123456"
|
||||
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
|
||||
settings = SimpleNamespace(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
smtp_username="mailer@example.com",
|
||||
smtp_password="secret",
|
||||
smtp_from_address="mailer@example.com",
|
||||
smtp_from_name="Manage",
|
||||
smtp_use_tls=True,
|
||||
smtp_use_ssl=False,
|
||||
smtp_timeout=15,
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("media_library_viewer_api.routers.users_impl.get_settings", return_value=settings):
|
||||
response = test_client.post(
|
||||
"/api/users/message",
|
||||
data={
|
||||
"recipient_ids": json.dumps(["jf1", "jf2"]),
|
||||
"subject": "Hello team",
|
||||
"html_body": "<p>Hi there</p>",
|
||||
"text_body": "Hi there",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_mail_queue, None)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["status"] == "queued"
|
||||
assert data["request_id"] == "mail-123456"
|
||||
assert data["recipient_count"] == 2
|
||||
assert data["attachment_count"] == 0
|
||||
mail_queue.enqueue.assert_called_once()
|
||||
kwargs = mail_queue.enqueue.call_args.kwargs
|
||||
assert kwargs["recipients"] == ["alex@example.com", "sam@example.com"]
|
||||
assert kwargs["subject"] == "Hello team"
|
||||
assert kwargs["settings"] is settings
|
||||
|
||||
|
||||
# --- Files ---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for AuthentikClient and the directory endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
TEST_KEY = Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
||||
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||
reset_encryption_key_cache()
|
||||
yield
|
||||
reset_encryption_key_cache()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path: Path) -> SettingsStore:
|
||||
s = SettingsStore(tmp_path / "settings.sqlite")
|
||||
s.ensure_defaults()
|
||||
app.dependency_overrides[get_settings_store] = lambda: s
|
||||
yield s
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthentikClient:
|
||||
def test_base_url_normalizes_trailing_slash(self) -> None:
|
||||
c = AuthentikClient(base_url="https://auth.example.com/", api_token="t")
|
||||
assert c.base_url == "https://auth.example.com"
|
||||
|
||||
def test_base_url_strips_api_v3_suffix(self) -> None:
|
||||
c = AuthentikClient(base_url="https://auth.example.com/api/v3", api_token="t")
|
||||
assert c.base_url == "https://auth.example.com"
|
||||
|
||||
def test_bearer_header_is_set(self) -> None:
|
||||
c = AuthentikClient(base_url="https://auth.example.com", api_token="tok")
|
||||
assert c.session.headers["Authorization"] == "Bearer tok"
|
||||
|
||||
def test_empty_base_url_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AuthentikClient(base_url="", api_token="t")
|
||||
|
||||
def test_empty_api_token_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AuthentikClient(base_url="https://auth.example.com", api_token="")
|
||||
|
||||
@patch.object(AuthentikClient, "get")
|
||||
def test_users_normalizes_pagination(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = {
|
||||
"pagination": {"count": 42, "next": 2, "previous": 0, "current": 1},
|
||||
"results": [
|
||||
{"pk": 1, "username": "alice", "email": "alice@example.com"},
|
||||
{"pk": 2, "username": "bob", "email": "bob@example.com"},
|
||||
],
|
||||
}
|
||||
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
result = client.users(search="ali", page=1, page_size=2)
|
||||
assert result["total"] == 42
|
||||
assert result["page"] == 1
|
||||
assert result["page_size"] == 2
|
||||
assert len(result["items"]) == 2
|
||||
assert result["items"][0]["username"] == "alice"
|
||||
|
||||
@patch.object(AuthentikClient, "get")
|
||||
def test_users_handles_empty_results(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = {"pagination": {"count": 0}, "results": []}
|
||||
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
result = client.users()
|
||||
assert result["items"] == []
|
||||
assert result["total"] == 0
|
||||
|
||||
@patch.object(AuthentikClient, "get")
|
||||
def test_users_handles_non_dict_payload(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = []
|
||||
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
result = client.users()
|
||||
assert result["items"] == []
|
||||
assert result["total"] == 0
|
||||
|
||||
@patch("media_library_viewer_api.clients.authentik.requests.Session")
|
||||
def test_get_sends_correct_url_and_params(self, mock_session_cls: MagicMock) -> None:
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
c = AuthentikClient(base_url="https://auth.example.com", api_token="t")
|
||||
c.get("/core/users/", search="x", page=2)
|
||||
|
||||
call_args = mock_session.get.call_args
|
||||
assert call_args.kwargs["params"] == {"search": "x", "page": 2}
|
||||
assert call_args.args[0] == "https://auth.example.com/api/v3/core/users/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthentikUsersEndpoint:
|
||||
def test_not_configured_returns_empty_with_error(self, store: SettingsStore) -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/services/authentik/nonexistent/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
assert "error" in data
|
||||
|
||||
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
|
||||
def test_success_returns_users(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.users.return_value = {
|
||||
"items": [{"pk": 1, "username": "alice"}],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 50,
|
||||
}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
created = store.upsert_service(
|
||||
{
|
||||
"service_type": "authentik",
|
||||
"name": "Main",
|
||||
"config": {"base_url": "https://auth.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_token": "secret-token"},
|
||||
)
|
||||
service_id = created["id"]
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/api/services/authentik/{service_id}/users?search=ali")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["username"] == "alice"
|
||||
assert data["total"] == 1
|
||||
assert "error" not in data
|
||||
|
||||
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
|
||||
def test_unreachable_returns_error(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.users.side_effect = ConnectionError("refused")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
created = store.upsert_service(
|
||||
{
|
||||
"service_type": "authentik",
|
||||
"name": "Main",
|
||||
"config": {"base_url": "https://auth.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_token": "secret-token"},
|
||||
)
|
||||
service_id = created["id"]
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/api/services/authentik/{service_id}/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert "error" in data
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for named-dashboards CRUD + slug uniqueness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def _client(tmp_path: Path) -> TestClient:
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
app.dependency_overrides[get_settings_store] = lambda: store
|
||||
client = TestClient(app)
|
||||
client.store = store # type: ignore[attr-defined]
|
||||
return client
|
||||
|
||||
|
||||
def test_create_and_list_dashboards(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/dashboards",
|
||||
json={"label": "Storage Overview", "payload": {"widgets": []}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
created = resp.json()
|
||||
assert created["label"] == "Storage Overview"
|
||||
assert created["slug"] == "storage-overview"
|
||||
assert created["payload"] == {"widgets": []}
|
||||
|
||||
listed = client.get("/api/dashboards").json()
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["id"] == created["id"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_update_dashboard(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
created = client.post("/api/dashboards", json={"label": "First"}).json()
|
||||
updated = client.put(
|
||||
f"/api/dashboards/{created['id']}",
|
||||
json={"label": "Renamed", "payload": {"widgets": ["w1"]}},
|
||||
).json()
|
||||
assert updated["label"] == "Renamed"
|
||||
assert updated["payload"] == {"widgets": ["w1"]}
|
||||
assert updated["slug"] == "renamed"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_delete_dashboard(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
created = client.post("/api/dashboards", json={"label": "Temp"}).json()
|
||||
resp = client.delete(f"/api/dashboards/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
assert client.get("/api/dashboards").json() == []
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_slug_collision_appends_suffix(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
first = client.post("/api/dashboards", json={"label": "Overview"}).json()
|
||||
second = client.post("/api/dashboards", json={"label": "Overview"}).json()
|
||||
assert first["slug"] == "overview"
|
||||
assert second["slug"] == "overview-2"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_explicit_slug_respected(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
created = client.post(
|
||||
"/api/dashboards",
|
||||
json={"label": "My Dashboard", "slug": "custom-slug"},
|
||||
).json()
|
||||
assert created["slug"] == "custom-slug"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_update_nonexistent_returns_404(tmp_path: Path):
|
||||
client = _client(tmp_path)
|
||||
try:
|
||||
resp = client.put("/api/dashboards/nope", json={"label": "X"})
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.registry import (
|
||||
@@ -56,24 +57,55 @@ def client(tmp_path):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_contains_seven_service_types():
|
||||
def test_registry_contains_eight_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
"jellyseerr",
|
||||
"nextcloud",
|
||||
"ssh_tasks",
|
||||
"backups",
|
||||
"authentik",
|
||||
}
|
||||
|
||||
|
||||
def test_jellyseerr_absorbed_into_jellyfin():
|
||||
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
|
||||
assert "jellyseerr" not in SERVICE_DEFINITIONS
|
||||
jellyfin_config = get_service_definition("jellyfin").config_schema["properties"]
|
||||
assert "jellyseerr_url" in jellyfin_config
|
||||
assert "jellyseerr_api_key" in jellyfin_config
|
||||
|
||||
|
||||
def test_backups_service_definition():
|
||||
definition = get_service_definition("backups")
|
||||
assert definition is not None
|
||||
assert definition.secret_fields == []
|
||||
assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
|
||||
schema = definition.config_schema
|
||||
assert "ingestion_label" in schema["properties"]
|
||||
|
||||
|
||||
def test_authentik_service_definition():
|
||||
definition = get_service_definition("authentik")
|
||||
assert definition is not None
|
||||
assert {sf.key for sf in definition.secret_fields} == {"api_token"}
|
||||
assert definition.secret_fields[0].required is True
|
||||
assert definition.widget_kinds == []
|
||||
schema = definition.config_schema
|
||||
assert "base_url" in schema["properties"]
|
||||
assert "timeout_seconds" in schema["properties"]
|
||||
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
assert get_service_definition("authentik").widget_kinds == []
|
||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
||||
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
||||
|
||||
|
||||
@@ -138,9 +170,10 @@ def test_list_service_types(client):
|
||||
types = {item["service_type"] for item in response.json()}
|
||||
assert types == {
|
||||
"alertmanager",
|
||||
"authentik",
|
||||
"backups",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"jellyseerr",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"ssh_tasks",
|
||||
@@ -244,7 +277,8 @@ def test_invalid_config_rejected(client):
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||
)
|
||||
# Pydantic accepts empty string; force a real validation error via bad type.
|
||||
assert response.status_code == 422
|
||||
# Force a real validation error via bad type.
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
@@ -252,6 +286,25 @@ def test_invalid_config_rejected(client):
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
|
||||
)
|
||||
def test_service_base_url_requires_http_schema(bad_url):
|
||||
"""Every service base_url must include an http:// or https:// schema."""
|
||||
model = get_service_definition("grafana").config_model
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
|
||||
)
|
||||
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||
model = get_service_definition(service_type).config_model
|
||||
instance = model.model_validate({"base_url": "https://example.com"})
|
||||
assert instance.base_url == "https://example.com"
|
||||
|
||||
|
||||
def test_unknown_secret_field_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
@@ -369,3 +422,99 @@ def test_record_and_list_service_task_runs(client):
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["status"] == "success"
|
||||
assert runs[0]["stdout_tail"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jellyseerr → Jellyfin migration (Slice 1.4 / 1.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
|
||||
"""A standalone jellyseerr service merges into the only jellyfin instance."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
jellyfin = store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyfin",
|
||||
"name": "Main Jellyfin",
|
||||
"config": {"base_url": "https://jellyfin.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "jf-key"},
|
||||
)
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyseerr",
|
||||
"name": "Main Jellyseerr",
|
||||
"config": {"base_url": "https://jellyseerr.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "js-key"},
|
||||
)
|
||||
|
||||
# Run migration via ensure_defaults (idempotent entry point).
|
||||
store.ensure_defaults()
|
||||
|
||||
# Jellyseerr row is gone.
|
||||
assert store.list_services("jellyseerr") == []
|
||||
|
||||
# Jellyfin config gained the absorbed fields.
|
||||
migrated = store.get_service(jellyfin["id"])
|
||||
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
|
||||
assert migrated["config"]["jellyseerr_api_key"] == "js-key"
|
||||
|
||||
|
||||
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):
|
||||
"""An unpaired jellyseerr (no jellyfin) is dropped with a warning, no crash."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyseerr",
|
||||
"name": "Orphan Jellyseerr",
|
||||
"config": {"base_url": "https://jellyseerr.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "js-key"},
|
||||
)
|
||||
|
||||
store.ensure_defaults()
|
||||
|
||||
assert store.list_services("jellyseerr") == []
|
||||
assert store.list_services("jellyfin") == []
|
||||
|
||||
|
||||
def test_jellyseerr_migration_is_idempotent(tmp_path):
|
||||
"""Running ensure_defaults twice does nothing the second time."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyfin",
|
||||
"name": "JF",
|
||||
"config": {"base_url": "https://jellyfin.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "k"},
|
||||
)
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyseerr",
|
||||
"name": "JS",
|
||||
"config": {"base_url": "https://jellyseerr.example.com"},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "k"},
|
||||
)
|
||||
|
||||
store.ensure_defaults()
|
||||
first_jellyfin = store.list_services("jellyfin")[0]
|
||||
first_url = first_jellyfin["config"]["jellyseerr_url"]
|
||||
|
||||
store.ensure_defaults() # second run
|
||||
second_jellyfin = store.list_services("jellyfin")[0]
|
||||
assert second_jellyfin["config"]["jellyseerr_url"] == first_url
|
||||
assert store.list_services("jellyseerr") == []
|
||||
|
||||
@@ -271,6 +271,10 @@ Service definitions live as Pydantic modules in the backend
|
||||
(`integrations/`); they declare the service config schema, secret fields, and
|
||||
the widget kinds the service provides. There is no runtime plugin loading.
|
||||
|
||||
Every service `base_url` uses the shared `ServiceBaseUrl` type, which rejects
|
||||
values missing an `http://` or `https://` schema with a clear validation error
|
||||
(relative hosts break downstream HTTP clients).
|
||||
|
||||
### Services
|
||||
|
||||
- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
|
||||
@@ -464,3 +468,57 @@ The system receives backup execution reports from an external backup tool via HT
|
||||
|
||||
- Backup tool uses auto-generated Bearer API key
|
||||
- Frontend uses existing OIDC/JWT auth
|
||||
|
||||
## Mobile Responsive Design
|
||||
|
||||
The frontend is fully operable in phone portrait (≥360px) at a single `md:`
|
||||
(768px) breakpoint. Tablets and wider viewports use the desktop layout
|
||||
unchanged.
|
||||
|
||||
### Breakpoint policy
|
||||
|
||||
- Single responsive cut: `md:` (768px). Below is "mobile"; at-or-above is
|
||||
"desktop" (existing layout, unchanged).
|
||||
- `useIsMobile()` hook (`frontend/src/hooks/useIsMobile.ts`) is the single
|
||||
source of truth; it wraps `matchMedia("(max-width: 768px)")` and is SSR-safe.
|
||||
- No `sm:` intermediate cut. No PWA, manifest, or service worker.
|
||||
|
||||
### Data tables (hybrid)
|
||||
|
||||
- The four wide tables (Media, FileBrowser, Users, Backups) render stacked
|
||||
**cards per row** below `md` via `MobileCardRow`, each showing a primary
|
||||
title plus 3–5 key fields. Narrow tables (SessionActivity) keep horizontal
|
||||
scroll. The TanStack column-visibility toggle is hidden below `md`.
|
||||
- At `md:` and above, all tables render as the existing `<DataTable>` unchanged.
|
||||
|
||||
### Edit forms (Sheet)
|
||||
|
||||
- Below `md`, ServicePage, Settings (machine editor), message compose, and
|
||||
WidgetConfigDialog open inside a full-height `SheetForm` (side=bottom,
|
||||
`h-[100dvh]`) with sticky header + sticky save bar instead of a centered
|
||||
Dialog.
|
||||
- At `md:` and above, the existing Dialog-based forms are unchanged.
|
||||
|
||||
### Touch targets
|
||||
|
||||
- All interactive elements below `md` have a minimum 44×44px hit area via the
|
||||
`.mobile-touch-target` CSS utility (applied only below 768px). This covers
|
||||
icon buttons, checkboxes, switches, and small text buttons. The class is a
|
||||
no-op at `md:` and above.
|
||||
|
||||
### Dashboard
|
||||
|
||||
- Below `md`, the widget grid collapses to a single column with a section
|
||||
anchor bar (Observability / Media / Backups / Custom) for quick navigation.
|
||||
- At `md:` and above, the existing multi-widget grid is unchanged.
|
||||
|
||||
### Polling
|
||||
|
||||
- Widget refresh intervals and the message-queue poll interval are identical
|
||||
on mobile and desktop. A follow-up to pause refetch when the tab is hidden
|
||||
(`document.visibilityState`) is tracked as a future battery optimization.
|
||||
|
||||
### `HoverEditButton`
|
||||
|
||||
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
|
||||
and above, the desktop hover-reveal aesthetic is preserved.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: frontend
|
||||
|
||||
## role
|
||||
React-based single-page application frontend for the "Manage" project, built with Vite, TypeScript, Tailwind CSS, and shadcn/ui.
|
||||
Frontend SPA for the "Manage" application, built with React, Vite, and TypeScript, providing the user interface with OIDC authentication and API integration.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+3
-3
@@ -4,11 +4,11 @@ dir: frontend
|
||||
index: frontend/.pi-map.index.md
|
||||
|
||||
## role
|
||||
React-based single-page application frontend for the "Manage" project, built with Vite, TypeScript, Tailwind CSS, and shadcn/ui.
|
||||
Frontend SPA for the "Manage" application, built with React, Vite, and TypeScript, providing the user interface with OIDC authentication and API integration.
|
||||
## files
|
||||
- .gitignore | Specifies files and directories for Git to ignore in a Node.js/frontend project
|
||||
- Dockerfile | Multi-stage Dockerfile for building and serving a Vite-based frontend application with separate production (nginx) and development (node dev server) targets | dep: node:22-alpine, nginx:1.27-alpine, npm, vite
|
||||
- README.md | Documentation for the Manage Frontend React SPA, covering setup, development, build, pages, environment variables, and configuration workflows.
|
||||
- README.md | Documentation for the Manage frontend SPA, describing its tech stack, setup, pages, configuration, and deployment workflow. | dep: React, TypeScript, Vite, @tanstack/react-query, @tanstack/react-table, react-router-dom, Tailwind CSS, shadcn/ui
|
||||
- components.json | Configuration file for shadcn/ui component library setup with Tailwind CSS and path aliases | dep: shadcn/ui, tailwindcss, lucide-react, radix-ui
|
||||
- eslint.config.js | Configures ESLint for a TypeScript React project using Vite with recommended rules for JS, TS, React Hooks, and React Refresh. | dep: @eslint/js, globals, eslint-plugin-react-hooks, eslint-plugin-react-refresh, typescript-eslint, eslint/config
|
||||
- index.html | Standard HTML entry point for a React/Vite single-page application named "Manage" | dep: React (implied by root div and TSX entry), Vite (implied by module script and /src path)
|
||||
@@ -23,7 +23,7 @@ React-based single-page application frontend for the "Manage" project, built wit
|
||||
- vite.config.ts | Configures Vite build tool with React, Tailwind CSS, environment-based API proxying, and path aliasing for a frontend application. | dep: path, vite, @vitejs/plugin-react, @tailwindcss/vite
|
||||
- vitest.config.ts | Configures Vitest test runner for a React project with jsdom environment, path aliasing, and scoped test file inclusion. | dep: path, vitest/config, @vitejs/plugin-react, vitest, jsdom
|
||||
## arch
|
||||
Component-driven SPA architecture using Vite for build tooling, PostCSS/Tailwind for styling, OIDC for authentication, Vitest for testing, with multi-stage Docker deployment supporting both nginx (production) and Node dev server (development) targets.
|
||||
Component-based React architecture styled with Tailwind CSS/shadcn/ui, bundled via Vite, containerized through multi-stage Docker builds (Nginx for production, Node dev server for development), with Vitest for testing and ESLint for code quality.
|
||||
## tags
|
||||
react, @babel, vite, helper, typescript, project, css, eslint
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: frontend/src
|
||||
|
||||
## role
|
||||
Frontend application entry point and core infrastructure providing routing, OIDC authentication, global state, and styling for a React-based admin dashboard.
|
||||
Frontend React application providing an admin dashboard UI with OIDC authentication, dark mode, and user activity management for a Jellyfin media server.
|
||||
## parent
|
||||
index: frontend/.pi-map.index.md
|
||||
map: frontend/.pi-map.md
|
||||
|
||||
@@ -4,21 +4,21 @@ dir: frontend/src
|
||||
index: frontend/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Frontend application entry point and core infrastructure providing routing, OIDC authentication, global state, and styling for a React-based admin dashboard.
|
||||
Frontend React application providing an admin dashboard UI with OIDC authentication, dark mode, and user activity management for a Jellyfin media server.
|
||||
## files
|
||||
- App.tsx | Main application component that renders a responsive admin dashboard with OIDC authentication, dark mode, collapsible sidebar navigation, and route-based page rendering. | exp: func:App() | dep: react-router-dom, @tanstack/react-query, react, react-oidc-context, ./pages/Dashboard, ./pages/Applications, ./pages/Settings, ./pages/Users, ./pages/FileBrowser, ./pages/Actions, ./components/BackupsPage, ./components/ObservabilityPage, ./pages/ServicePage, ./pages/ServicesPage, ./auth, ./api/client, ./version, ./hooks/usePersistentState, @/components/ui/button, @/components/ui/tooltip, @/components/ui/sheet, lucide-react, ./pages/*, ./components/*
|
||||
- auth.ts | Manages OIDC authentication configuration and access token retrieval for a browser-based application. | exp: func:isOidcConfigured() → boolean, call:(import.meta.env.VITE_OIDC_ENABLED ?? "true").toLowerCase, call:Boolean, func:getOidcConfig(), call:window.history.replaceState, func:setAccessToken(token: string | null | undefined), func:getAccessToken() → string | null, call:getStoredAccessToken | dep: oidc-client-ts
|
||||
- index.css | Defines a comprehensive Tailwind CSS v4 theme with custom design tokens, dark mode support, and base styles for a React application. | dep: tailwindcss, Google Fonts (Inter)
|
||||
- main.tsx | Entry point that renders the React application into the DOM root element with StrictMode enabled. | dep: react, react-dom/client, ./App, ./index.css
|
||||
- userState.d.ts | Defines TypeScript type declarations for user activity state management, including interfaces for user activity summaries and state items, plus function declarations for merging users with activity data and resolving user selections. | exp: UserActivitySummary, UserStateItem, mergeUsersWithActivity, resolveUserSelection | dep: ./types, types (NowPlayingSession, UserDirectoryItem)
|
||||
- userState.js | Merges Jellyfin users with their session activity data and provides user lookup by identifier. | exp: func:mergeUsersWithActivity(users, sessions), call:users.map, call:sessions.filter, call:sessionMatchesUser, call:buildActivitySummary, func:resolveUserSelection(users, identifier), call:normalize, call:users.find, call:userKeys(user).some
|
||||
- userState.js | Merges Jellyfin users with their session activity data and provides user lookup by identifier.
|
||||
- users.d.ts | Defines TypeScript interfaces and a function declaration for building a user drawer model from directory data. | exp: UserDetailField, UserContactAction, UserContactState, UserDrawerModel, buildUserDrawerModel | dep: ./types, types
|
||||
- users.js | Transforms raw user data into a structured UI model for a user details drawer component. | exp: func:buildUserDrawerModel(user), call:displayName, call:splitValues, call:String, call:user.role.charAt(0).toUpperCase, call:user.role.slice, call:permissions.join, call:syncStatus
|
||||
- version.ts | Exports frontend version and build info constants with a formatter for semantic version labels | exp: FRONTEND_VERSION, FRONTEND_BUILD_INFO, FRONTEND_VERSION_LABEL, func:formatVersionLabel(version: string, buildInfo: string) → string, call:version.trim, call:buildInfo.trim
|
||||
## arch
|
||||
React SPA architecture using component-based UI with StrictMode bootstrapping, TypeScript type definitions paired with JavaScript logic modules, and Tailwind CSS v4 theming with dark mode support.
|
||||
Component-based React SPA architecture using Tailwind CSS v4 theming, TypeScript type definitions, and utility modules for authentication and user state transformation.
|
||||
## tags
|
||||
user, activity, version, state, react, pages, users, oidc
|
||||
user, version, state, react, pages, oidc, activity, drawer
|
||||
## symbols
|
||||
- App
|
||||
- isOidcConfigured
|
||||
|
||||
+77
-64
@@ -5,7 +5,6 @@ import {
|
||||
NavLink,
|
||||
useLocation,
|
||||
Outlet,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
QueryClient,
|
||||
@@ -13,21 +12,22 @@ import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { AuthProvider, useAuth } from "react-oidc-context";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Applications } from "./pages/Applications";
|
||||
import { NamedDashboardPage } from "./pages/NamedDashboardPage";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { UsersPage } from "./pages/Users";
|
||||
import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { ServicePage } from "./pages/ServicePage";
|
||||
import { ServiceTypePage } from "./pages/ServiceTypePage";
|
||||
import { ServicesPage } from "./pages/ServicesPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
import { usePersistentState } from "./hooks/usePersistentState";
|
||||
import { useIsMobile } from "./hooks/useIsMobile";
|
||||
import { useServiceInstances } from "./hooks/useServices";
|
||||
import { useDashboards } from "./hooks/useDashboards";
|
||||
import { configuredNavEntries } from "./integrations/navEntries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -44,12 +44,6 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
Monitor,
|
||||
Users,
|
||||
Zap,
|
||||
FolderOpen,
|
||||
Settings as SettingsIcon,
|
||||
Menu,
|
||||
Sun,
|
||||
@@ -58,10 +52,17 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Boxes,
|
||||
LayoutTemplate,
|
||||
} from "lucide-react";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchIntervalInBackground: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function useDarkMode() {
|
||||
@@ -82,18 +83,40 @@ function useDarkMode() {
|
||||
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
|
||||
}
|
||||
|
||||
// Navigation items for sidebar
|
||||
const navItems = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/observability", label: "Observability", icon: Activity },
|
||||
{ path: "/media", label: "Media", icon: Monitor },
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ path: "/users", label: "Users", icon: Users },
|
||||
{ path: "/actions", label: "Actions", icon: Zap },
|
||||
{ path: "/services", label: "Services", icon: Boxes },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
// Navigation items are data-driven (spec R1). Built from configured services + dashboards.
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
function useNavItems() {
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: dashboards = [] } = useDashboards();
|
||||
|
||||
return useMemo<NavItem[]>(() => {
|
||||
const configuredTypes = new Set(
|
||||
services.filter((s) => s.enabled).map((s) => s.service_type),
|
||||
);
|
||||
const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({
|
||||
path: e.path,
|
||||
label: e.label,
|
||||
icon: e.icon,
|
||||
}));
|
||||
const dashboardEntries = dashboards.map((d) => ({
|
||||
path: `/d/${d.slug}`,
|
||||
label: d.label,
|
||||
icon: LayoutTemplate,
|
||||
}));
|
||||
return [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
...dashboardEntries,
|
||||
...serviceEntries,
|
||||
{ path: "/services", label: "Services", icon: Boxes },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
}, [services, dashboards]);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
collapsed,
|
||||
@@ -105,6 +128,7 @@ function Sidebar({
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const navItems = useNavItems();
|
||||
|
||||
if (isMobile) return null;
|
||||
|
||||
@@ -189,6 +213,7 @@ function Sidebar({
|
||||
function MobileDrawer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navItems = useNavItems();
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
@@ -253,6 +278,7 @@ function TopBar({
|
||||
});
|
||||
const backendLabel = appVersion?.backend_label || "…";
|
||||
|
||||
const navItems = useNavItems();
|
||||
const pageTitle =
|
||||
navItems.find((item) => item.path === location.pathname)?.label ||
|
||||
"Dashboard";
|
||||
@@ -316,16 +342,7 @@ function ShellLayout({
|
||||
onToggleDarkMode: () => void;
|
||||
}) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
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);
|
||||
}, []);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -427,6 +444,18 @@ function AuthenticatedApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<h2 className="text-xl font-semibold">Not found</h2>
|
||||
<p className="text-sm text-muted-foreground">This page doesn't exist.</p>
|
||||
<Button asChild>
|
||||
<NavLink to="/">Back to dashboard</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const [darkMode, toggleDarkMode] = useDarkMode();
|
||||
|
||||
@@ -438,26 +467,18 @@ function AppInner() {
|
||||
<Routes>
|
||||
<Route element={<AuthenticatedApp />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route
|
||||
path="/monitoring"
|
||||
element={<Navigate to="/observability" replace />}
|
||||
/>
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route
|
||||
path="/applications"
|
||||
element={<Navigate to="/media" replace />}
|
||||
/>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType"
|
||||
element={<ServiceTypePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -474,26 +495,18 @@ function AppInner() {
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route
|
||||
path="/monitoring"
|
||||
element={<Navigate to="/observability" replace />}
|
||||
/>
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route
|
||||
path="/applications"
|
||||
element={<Navigate to="/media" replace />}
|
||||
/>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType"
|
||||
element={<ServiceTypePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** API client for the Authentik service (directory + messaging). */
|
||||
import { get, post } from "./shared";
|
||||
|
||||
export interface AuthentikUser {
|
||||
pk: number;
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
avatar: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuthentikUsersResponse {
|
||||
items: AuthentikUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchAuthentikUsers(
|
||||
serviceId: string,
|
||||
params: { search?: string; page?: number; page_size?: number },
|
||||
): Promise<AuthentikUsersResponse> {
|
||||
return get<AuthentikUsersResponse>(
|
||||
`/api/services/authentik/${serviceId}/users`,
|
||||
{
|
||||
search: params.search ?? "",
|
||||
page: String(params.page ?? 1),
|
||||
page_size: String(params.page_size ?? 50),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface AuthentikMessageInput {
|
||||
recipient_emails: string[];
|
||||
subject: string;
|
||||
html_body: string;
|
||||
}
|
||||
|
||||
export interface AuthentikMessageResponse {
|
||||
status: string;
|
||||
request_id?: string;
|
||||
recipient_count?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function sendAuthentikMessage(
|
||||
serviceId: string,
|
||||
input: AuthentikMessageInput,
|
||||
): Promise<AuthentikMessageResponse> {
|
||||
return post<AuthentikMessageResponse>(
|
||||
`/api/services/authentik/${serviceId}/message`,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAuthentikMessageStatus(
|
||||
serviceId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return get<Record<string, unknown>>(
|
||||
`/api/services/authentik/${serviceId}/message/status`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* API client for the named-dashboards backend (Slice 3).
|
||||
*/
|
||||
import { del, get, post, put } from "./shared";
|
||||
|
||||
export interface NamedDashboard {
|
||||
id: string;
|
||||
label: string;
|
||||
slug: string;
|
||||
sort_order: number;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NamedDashboardInput {
|
||||
id?: string | null;
|
||||
label: string;
|
||||
slug?: string;
|
||||
sort_order: number;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchDashboards(): Promise<NamedDashboard[]> {
|
||||
return get<NamedDashboard[]>("/api/dashboards");
|
||||
}
|
||||
|
||||
export async function fetchDashboardBySlug(
|
||||
slug: string,
|
||||
): Promise<NamedDashboard> {
|
||||
return get<NamedDashboard>(
|
||||
`/api/dashboards/slug/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createDashboard(
|
||||
input: NamedDashboardInput,
|
||||
): Promise<NamedDashboard> {
|
||||
return post<NamedDashboard>("/api/dashboards", input);
|
||||
}
|
||||
|
||||
export async function updateDashboard(
|
||||
input: NamedDashboardInput,
|
||||
): Promise<NamedDashboard> {
|
||||
return put<NamedDashboard>(`/api/dashboards`, input);
|
||||
}
|
||||
|
||||
export async function deleteDashboard(id: string): Promise<{ status: string }> {
|
||||
return del<{ status: string }>(`/api/dashboards/${id}`);
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
MobileCardRow,
|
||||
type MobileCardField,
|
||||
} from "@/components/ui/mobile-card";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type { BackupAlert } from "../types/backups";
|
||||
|
||||
interface Props {
|
||||
@@ -29,7 +34,51 @@ function severityVariant(severity: string): SeverityVariant {
|
||||
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) {
|
||||
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 (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table aria-label="Backup alerts">
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} 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";
|
||||
|
||||
interface Props {
|
||||
@@ -41,7 +46,54 @@ function statusVariant(status: string): StatusVariant {
|
||||
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) {
|
||||
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 (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table aria-label="Backup jobs">
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
MobileCardRow,
|
||||
type MobileCardField,
|
||||
} from "@/components/ui/mobile-card";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type { BackupRun } from "../types/backups";
|
||||
|
||||
interface Props {
|
||||
@@ -55,8 +60,35 @@ function statusVariant(status: string): StatusVariant {
|
||||
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) {
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const filteredRuns =
|
||||
statusFilter === "all"
|
||||
@@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table aria-label="Backup runs">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-card hover:bg-card">
|
||||
<TableHead>Job</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRuns.map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell>{run.job_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(run.status)}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||
{isMobile ? (
|
||||
<MobileCardRow
|
||||
rows={filteredRuns}
|
||||
fields={runCardFields}
|
||||
getRowId={(r) => r.id}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table aria-label="Backup runs">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-card hover:bg-card">
|
||||
<TableHead>Job</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRuns.map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell>{run.job_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(run.status)}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ export function DialogFooter({
|
||||
}: DialogFooterProps) {
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mobile-touch-target"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
{secondaryAction ? (
|
||||
@@ -59,6 +63,7 @@ export function DialogFooter({
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
||||
disabled={confirmDisabled}
|
||||
onClick={onConfirm}
|
||||
|
||||
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
|
||||
interface HoverEditButtonProps {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
/** Controls visibility below the `md:` (768px) breakpoint.
|
||||
*
|
||||
* - `always` (default): the button is always visible on mobile/touch.
|
||||
* - `hover`: keep the legacy opacity-0-everywhere behavior.
|
||||
*
|
||||
* At `md:` and above the hover-reveal aesthetic is always preserved
|
||||
* (`md:opacity-0 md:group-hover:opacity-100`), so desktop is not regressed.
|
||||
* See OpenSpec change `mobile-responsive-parity`, spec R5. */
|
||||
mobile?: "always" | "hover";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover-to-reveal edit affordance.
|
||||
* Hover-to-reveal edit affordance (desktop) / always-visible (mobile).
|
||||
*
|
||||
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
|
||||
* Keeps the `rail-edit` class plus the opacity base + transition so the
|
||||
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
||||
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
||||
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
|
||||
* + lucide `Pencil`. Same exported props/display name.
|
||||
*
|
||||
* Mobile behavior (`mobile="always"`, the default): the button is visible by
|
||||
* default below `md` because hover does not fire on touch. The hover-reveal
|
||||
* aesthetic is layered back on at `md:` and above via `md:opacity-0
|
||||
* md:group-hover:opacity-100`. MUI IconButton + EditOutlined → shadcn `Button
|
||||
* variant="ghost" size="icon-sm"` + lucide `Pencil`. Same exported props/display
|
||||
* name. See OpenSpec change `mobile-responsive-parity`, spec R5.
|
||||
*/
|
||||
export function HoverEditButton({
|
||||
onClick,
|
||||
label = "Edit",
|
||||
mobile = "always",
|
||||
}: 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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
||||
className={`rail-edit text-muted-foreground mobile-touch-target ${hoverClasses}`}
|
||||
aria-label={label}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,667 +0,0 @@
|
||||
import { useMemo, useState, type ElementType, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Gauge,
|
||||
Inbox,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
ServerOff,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useAlertmanagerAlerts,
|
||||
useAlertmanagerStatus,
|
||||
useGrafanaStatus,
|
||||
usePrometheusStatus,
|
||||
usePrometheusTargets,
|
||||
useMonitoringMachines,
|
||||
} from "../hooks/useObservability";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type {
|
||||
AlertmanagerAlert,
|
||||
MonitoringMachine,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
|
||||
function severityVariant(
|
||||
severity: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "info":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function HealthCard({
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
icon: Icon,
|
||||
isLoading,
|
||||
}: {
|
||||
title: string;
|
||||
status: "ok" | "warning" | "error" | "unknown";
|
||||
detail: string;
|
||||
icon: ElementType;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const statusIcon =
|
||||
status === "ok" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : status === "warning" ? (
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
) : status === "error" ? (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? <Skeleton className="h-5 w-5" /> : statusIcon}
|
||||
<span className="text-2xl font-bold capitalize">{status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryError({
|
||||
label,
|
||||
error,
|
||||
refetch,
|
||||
}: {
|
||||
label: string;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{label} failed</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="break-words">{error.message}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-sm">{alert.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{alert.summary || alert.description}
|
||||
</div>
|
||||
{alert.active_since && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||
Since {new Date(alert.active_since).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
|
||||
{alert.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description:</span>{" "}
|
||||
{alert.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{alert.job_name && (
|
||||
<div>
|
||||
<span className="font-medium">Job:</span> {alert.job_name}
|
||||
</div>
|
||||
)}
|
||||
{alert.category && (
|
||||
<div>
|
||||
<span className="font-medium">Category:</span> {alert.category}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">State:</span> {alert.state}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Since:</span>{" "}
|
||||
{alert.active_since
|
||||
? new Date(alert.active_since).toLocaleString()
|
||||
: "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{Object.entries(alert.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="secondary" className="text-[10px]">
|
||||
{key}={value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-3">
|
||||
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
|
||||
{target.labels && Object.keys(target.labels).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{Object.entries(target.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px]">
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrafanaLinkCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservabilityPage() {
|
||||
const {
|
||||
data: alertsSummary,
|
||||
isLoading: alertsLoading,
|
||||
error: alertsError,
|
||||
refetch: refetchAlerts,
|
||||
} = useAlertmanagerAlerts();
|
||||
const {
|
||||
data: alertmanagerStatus,
|
||||
isLoading: statusLoading,
|
||||
error: statusError,
|
||||
refetch: refetchStatus,
|
||||
} = useAlertmanagerStatus();
|
||||
const {
|
||||
data: grafanaStatus,
|
||||
isLoading: grafanaLoading,
|
||||
error: grafanaError,
|
||||
refetch: refetchGrafana,
|
||||
} = useGrafanaStatus();
|
||||
const {
|
||||
data: prometheusStatus,
|
||||
isLoading: prometheusLoading,
|
||||
error: prometheusError,
|
||||
refetch: refetchPrometheus,
|
||||
} = usePrometheusStatus();
|
||||
const {
|
||||
data: prometheusTargets,
|
||||
isLoading: targetsLoading,
|
||||
error: targetsError,
|
||||
refetch: refetchTargets,
|
||||
} = usePrometheusTargets();
|
||||
const {
|
||||
data: machines = [],
|
||||
isLoading: machinesLoading,
|
||||
error: machinesError,
|
||||
refetch: refetchMachines,
|
||||
} = useMonitoringMachines();
|
||||
const { data: grafanaServices = [] } = useServiceInstances("grafana");
|
||||
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
|
||||
|
||||
const grafanaService =
|
||||
grafanaServices.find((s) => s.enabled) ?? grafanaServices[0];
|
||||
const GRAFANA_BASE_URL =
|
||||
(grafanaService?.config?.base_url as string | undefined) ?? "";
|
||||
|
||||
const selectedMachine = useMemo<MonitoringMachine | null>(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||
const instance = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
|
||||
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine, GRAFANA_BASE_URL]);
|
||||
|
||||
const alertmanagerStatusDetail = alertmanagerStatus?.up
|
||||
? alertmanagerStatus.version
|
||||
? `version ${alertmanagerStatus.version}`
|
||||
: "reachable"
|
||||
: "unreachable";
|
||||
|
||||
const targetsCount = prometheusTargets?.length ?? 0;
|
||||
const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading
|
||||
? "unknown"
|
||||
: targetsError
|
||||
? "error"
|
||||
: targetsCount > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading
|
||||
? "unknown"
|
||||
: alertsError
|
||||
? "error"
|
||||
: (alertsSummary?.total ?? 0) > 0
|
||||
? alertsSummary?.alerts.some((a) => a.severity === "critical")
|
||||
? "error"
|
||||
: "warning"
|
||||
: "ok";
|
||||
|
||||
const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading
|
||||
? "unknown"
|
||||
: machinesError
|
||||
? "error"
|
||||
: machines.length > 0
|
||||
? "ok"
|
||||
: "warning";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Observability</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unified view of metrics, logs, and alerts from Prometheus, Loki, and
|
||||
Alertmanager. Deep dashboards live in Grafana.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<HealthCard
|
||||
title="Alertmanager"
|
||||
status={
|
||||
statusError
|
||||
? "error"
|
||||
: alertmanagerStatus?.up
|
||||
? "ok"
|
||||
: statusLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={alertmanagerStatusDetail}
|
||||
icon={Bell}
|
||||
isLoading={statusLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Active Alerts"
|
||||
status={alertStatus}
|
||||
detail={`${alertsSummary?.total ?? 0} firing alert${(alertsSummary?.total ?? 0) === 1 ? "" : "s"}`}
|
||||
icon={AlertTriangle}
|
||||
isLoading={alertsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Prometheus Targets"
|
||||
status={targetsStatus}
|
||||
detail={`${targetsCount} remote Node Exporter target${targetsCount === 1 ? "" : "s"}`}
|
||||
icon={Radio}
|
||||
isLoading={targetsLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Machines"
|
||||
status={machinesStatus}
|
||||
detail={`${machines.length} monitoring machine${machines.length === 1 ? "" : "s"}`}
|
||||
icon={Server}
|
||||
isLoading={machinesLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Grafana"
|
||||
status={
|
||||
grafanaError
|
||||
? "error"
|
||||
: grafanaStatus?.up
|
||||
? "ok"
|
||||
: grafanaLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={
|
||||
grafanaStatus?.up
|
||||
? grafanaStatus.version
|
||||
? `version ${grafanaStatus.version}`
|
||||
: "reachable"
|
||||
: grafanaStatus?.error === "no_service_configured"
|
||||
? "not configured"
|
||||
: "unreachable"
|
||||
}
|
||||
icon={Gauge}
|
||||
isLoading={grafanaLoading}
|
||||
/>
|
||||
<HealthCard
|
||||
title="Prometheus"
|
||||
status={
|
||||
prometheusError
|
||||
? "error"
|
||||
: prometheusStatus?.up
|
||||
? "ok"
|
||||
: prometheusLoading
|
||||
? "unknown"
|
||||
: "error"
|
||||
}
|
||||
detail={
|
||||
prometheusStatus?.up
|
||||
? prometheusStatus.version
|
||||
? `version ${prometheusStatus.version}`
|
||||
: "reachable"
|
||||
: prometheusStatus?.error === "no_service_configured"
|
||||
? "not configured"
|
||||
: "unreachable"
|
||||
}
|
||||
icon={Radio}
|
||||
isLoading={prometheusLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{statusError && (
|
||||
<QueryError
|
||||
label="Alertmanager status"
|
||||
error={statusError}
|
||||
refetch={refetchStatus}
|
||||
/>
|
||||
)}
|
||||
{alertsError && (
|
||||
<QueryError
|
||||
label="Active alerts"
|
||||
error={alertsError}
|
||||
refetch={refetchAlerts}
|
||||
/>
|
||||
)}
|
||||
{targetsError && (
|
||||
<QueryError
|
||||
label="Prometheus targets"
|
||||
error={targetsError}
|
||||
refetch={refetchTargets}
|
||||
/>
|
||||
)}
|
||||
{machinesError && (
|
||||
<QueryError
|
||||
label="Monitoring machines"
|
||||
error={machinesError}
|
||||
refetch={refetchMachines}
|
||||
/>
|
||||
)}
|
||||
{grafanaError && (
|
||||
<QueryError
|
||||
label="Grafana status"
|
||||
error={grafanaError}
|
||||
refetch={refetchGrafana}
|
||||
/>
|
||||
)}
|
||||
{prometheusError && (
|
||||
<QueryError
|
||||
label="Prometheus status"
|
||||
error={prometheusError}
|
||||
refetch={refetchPrometheus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{alertsSummary?.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Alertmanager unreachable</AlertTitle>
|
||||
<AlertDescription>
|
||||
The UI cannot reach Alertmanager right now. Alerts shown here may be
|
||||
stale.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Recent Alerts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alertsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !alertsSummary || alertsSummary.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No active alerts"
|
||||
description="Everything looks quiet. Alertmanager will list firing alerts here when they occur."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{alertsSummary.alerts.map((alert, idx) => (
|
||||
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
|
||||
))}
|
||||
{alertsSummary.total > alertsSummary.alerts.length && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
{alertsSummary.total - alertsSummary.alerts.length} more
|
||||
alert
|
||||
{alertsSummary.total - alertsSummary.alerts.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
in Alertmanager
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Prometheus Targets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{targetsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !prometheusTargets || prometheusTargets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Radio}
|
||||
title="No Node Exporter targets"
|
||||
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TargetsTable targets={prometheusTargets} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machines.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedMachine ? (
|
||||
GRAFANA_BASE_URL ? (
|
||||
<>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||
href={nodeExporterDashboardUrl}
|
||||
/>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} logs`}
|
||||
description="Explore Loki logs for this machine in Grafana."
|
||||
href={logsUrl}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Gauge}
|
||||
title="No Grafana service configured"
|
||||
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/services">Open Services</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ServerOff}
|
||||
title="No machine selected"
|
||||
description="Add monitoring machines in Settings to see Grafana drill-down links."
|
||||
action={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Boxes, ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Pinned service link rendered on named dashboards. A card-shaped shortcut
|
||||
* that navigates to a service page (or a specific tab via query param).
|
||||
*
|
||||
* The `target` is a route path like `/services/jellyfin/svc-1` or
|
||||
* `/services/ssh_tasks/svc-2?tab=Files`.
|
||||
*/
|
||||
export interface PinnedServiceLinkProps {
|
||||
label: string;
|
||||
target: string;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PinnedServiceLink({
|
||||
label,
|
||||
target,
|
||||
icon: Icon = Boxes,
|
||||
className,
|
||||
}: PinnedServiceLinkProps) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(target)}
|
||||
className={cn(
|
||||
"mobile-touch-target group flex min-h-16 w-full items-center justify-between rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper: build a target path for a pinned service link.
|
||||
* Returns `/services/:type/:id` or with a `?tab=` suffix when provided.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function serviceLinkTarget(
|
||||
serviceType: string,
|
||||
serviceId: string,
|
||||
tab?: string,
|
||||
): string {
|
||||
const base = `/services/${serviceType}/${serviceId}`;
|
||||
return tab ? `${base}?tab=${tab}` : base;
|
||||
}
|
||||
@@ -168,6 +168,7 @@ export function SessionActivityPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mobile-touch-target"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectSession(session);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from "../hooks/useWidgets";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useTasks } from "../hooks/useSettings";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||
import {
|
||||
BUILTIN_WIDGETS,
|
||||
@@ -277,201 +279,220 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||
: undefined;
|
||||
const isMobile = useIsMobile();
|
||||
const isTaskOutput =
|
||||
draft?.serviceId !== null &&
|
||||
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||
"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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{draft
|
||||
? draft.id
|
||||
? "Edit widget"
|
||||
: "Add widget"
|
||||
: "Dashboard widgets"}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{draftBody}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import BackupAlertsTable from "../BackupAlertsTable";
|
||||
@@ -61,3 +61,46 @@ describe("BackupAlertsTable", () => {
|
||||
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
|
||||
function setMatchMedia(matches: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
beforeEach(() => setMatchMedia(false));
|
||||
|
||||
describe("BackupAlertsTable (mobile card layout — slice 5)", () => {
|
||||
it("renders cards with message as primary below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(
|
||||
<BackupAlertsTable
|
||||
alerts={[alert({ id: "m1", message: "Disk full" })]}
|
||||
onAcknowledge={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Disk full")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Severity")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders acknowledge action on card below md", async () => {
|
||||
setMatchMedia(true);
|
||||
const onAck = vi.fn();
|
||||
render(
|
||||
<BackupAlertsTable
|
||||
alerts={[alert({ id: "a1", acknowledged: false })]}
|
||||
onAcknowledge={onAck}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ack" }));
|
||||
expect(onAck).toHaveBeenCalledWith("a1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import BackupJobsTable from "../BackupJobsTable";
|
||||
import type { BackupJob, BackupRun } from "../../types/backups";
|
||||
|
||||
function job(overrides: Partial<BackupJob> = {}): BackupJob {
|
||||
return {
|
||||
id: "j1",
|
||||
name: "nightly",
|
||||
source: "/data",
|
||||
target: "s3://bucket",
|
||||
schedule_interval_seconds: 86400,
|
||||
created_at: 1_700_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function run(overrides: Partial<BackupRun> = {}): BackupRun {
|
||||
return {
|
||||
id: "r1",
|
||||
job_id: "j1",
|
||||
started_at: 1_700_000_000,
|
||||
ended_at: null,
|
||||
status: "success",
|
||||
bytes_transferred: 2048,
|
||||
duration_ms: 1500,
|
||||
error_message: null,
|
||||
details_json: null,
|
||||
created_at: 1_700_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// jsdom lacks matchMedia; default to desktop so the table renders.
|
||||
function setMatchMedia(matches: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
beforeEach(() => setMatchMedia(false));
|
||||
|
||||
describe("BackupJobsTable (desktop)", () => {
|
||||
it("renders job name and schedule interval", () => {
|
||||
render(
|
||||
<BackupJobsTable
|
||||
jobs={[job({ name: "nightly", schedule_interval_seconds: 86400 })]}
|
||||
latestRuns={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||
expect(screen.getByText("1d")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupJobsTable (mobile card layout — slice 5)", () => {
|
||||
it("renders cards with job name as primary below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(
|
||||
<BackupJobsTable
|
||||
jobs={[job({ id: "j1", name: "nightly", source: "/data" })]}
|
||||
latestRuns={
|
||||
new Map([["j1", run({ status: "success" })]]) as Map<string, BackupRun>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Source")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Schedule")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Last status")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import BackupRunsTable from "../BackupRunsTable";
|
||||
import type { BackupRun } from "../../types/backups";
|
||||
@@ -57,3 +57,29 @@ describe("BackupRunsTable", () => {
|
||||
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
|
||||
function setMatchMedia(matches: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
beforeEach(() => setMatchMedia(false));
|
||||
|
||||
describe("BackupRunsTable (mobile card layout — slice 5)", () => {
|
||||
it("renders cards with job_id as primary below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(<BackupRunsTable runs={[run({ id: "r1", job_id: "nightly" })]} />);
|
||||
expect(screen.getByText("nightly")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Status")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Duration")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,4 +18,23 @@ describe("HoverEditButton", () => {
|
||||
screen.getByRole("button", { name: "Rename machine" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to always-visible below md (mobile="always")', () => {
|
||||
render(<HoverEditButton onClick={() => {}} />);
|
||||
const button = screen.getByRole("button", { name: "Edit" });
|
||||
const tokens = button.className.split(/\s+/);
|
||||
// The default mobile mode layers hover-reveal only at md+ via
|
||||
// md:opacity-0/md:group-hover:opacity-100, so the button is visible by
|
||||
// default below md (no base opacity-0 token).
|
||||
expect(tokens).toContain("md:opacity-0");
|
||||
expect(tokens).toContain("md:group-hover:opacity-100");
|
||||
expect(tokens).not.toContain("opacity-0");
|
||||
});
|
||||
|
||||
it('preserves the legacy opacity-0 behavior when mobile="hover"', () => {
|
||||
render(<HoverEditButton onClick={() => {}} mobile="hover" />);
|
||||
const button = screen.getByRole("button", { name: "Edit" });
|
||||
expect(button.className).toContain("opacity-0");
|
||||
expect(button.className).toContain("group-hover:opacity-100");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PinnedServiceLink } from "../PinnedServiceLink";
|
||||
|
||||
function renderLink() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PinnedServiceLink
|
||||
label="My Jellyfin"
|
||||
target="/services/jellyfin/svc-1"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/services/jellyfin/svc-1"
|
||||
element={<div>target page</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PinnedServiceLink", () => {
|
||||
it("renders the label", () => {
|
||||
renderLink();
|
||||
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to the target on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLink();
|
||||
await user.click(screen.getByText("My Jellyfin"));
|
||||
expect(screen.getByText("target page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { WidgetConfigDialog } from "../WidgetConfigDialog";
|
||||
|
||||
// jsdom has no window.matchMedia; default to desktop (matches: false).
|
||||
function setMatchMedia(matches: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useTasks: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
beforeEach(() => setMatchMedia(false));
|
||||
|
||||
describe("WidgetConfigDialog (desktop)", () => {
|
||||
it("renders a Dialog with the dashboard widgets title at md+", () => {
|
||||
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Dashboard widgets" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WidgetConfigDialog (mobile SheetForm — slice 8)", () => {
|
||||
beforeEach(() => setMatchMedia(true));
|
||||
|
||||
it("renders a SheetForm with the dashboard widgets title below md", () => {
|
||||
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||
expect(screen.getByText("Dashboard widgets")).toBeInTheDocument();
|
||||
// List mode footer: "Done" button closes.
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prompts before discarding a widget draft (R4.5)", async () => {
|
||||
const { userEvent } = await import("@testing-library/user-event");
|
||||
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
|
||||
|
||||
// Enter draft mode by clicking an "Add widget" button.
|
||||
await userEvent.click(screen.getByRole("button", { name: /Backups/i }));
|
||||
|
||||
// Now in draft mode — Cancel should prompt before resetting.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MobileCardRow, type MobileCardField } from "../mobile-card";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
title: string;
|
||||
size: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
const rows: Row[] = [
|
||||
{ id: "a", title: "Movie A", size: "4.2GB", year: 2026 },
|
||||
{ id: "b", title: "Movie B", size: "2.1GB", year: 2025 },
|
||||
];
|
||||
|
||||
const fields: MobileCardField<Row>[] = [
|
||||
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||
{ key: "size", label: "Size", render: (r) => r.size },
|
||||
{ key: "year", label: "Year", render: (r) => r.year },
|
||||
];
|
||||
|
||||
describe("MobileCardRow", () => {
|
||||
it("renders the primary field as a title and the rest as key/value pairs", () => {
|
||||
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||
|
||||
// Primary title
|
||||
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Movie B")).toBeInTheDocument();
|
||||
|
||||
// Field labels and values (appear once per row)
|
||||
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||
expect(screen.getAllByText("4.2GB")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Year")).toHaveLength(2);
|
||||
expect(screen.getAllByText("2026")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fires onRowClick when the card is tapped", async () => {
|
||||
const onRowClick = vi.fn();
|
||||
render(
|
||||
<MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} />,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Movie A"));
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
|
||||
});
|
||||
|
||||
it("renders the actions slot per row", () => {
|
||||
render(
|
||||
<MobileCardRow
|
||||
rows={rows}
|
||||
fields={fields}
|
||||
actions={(r) => (
|
||||
<button type="button" onClick={() => undefined}>
|
||||
edit-{r.id}
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("edit-a")).toBeInTheDocument();
|
||||
expect(screen.getByText("edit-b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a non-interactive card when onRowClick is absent", () => {
|
||||
render(<MobileCardRow rows={rows} fields={fields} />);
|
||||
// No buttons wrapping the cards.
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||
expect(screen.getByText("Movie A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when rows is empty", () => {
|
||||
const { container } = render(<MobileCardRow rows={[]} fields={fields} />);
|
||||
const cards = container.querySelector(".flex.flex-col.gap-2");
|
||||
expect(cards?.children).toHaveLength(0);
|
||||
expect(screen.queryByText("Size")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a card without a title when no primary field is set", () => {
|
||||
const noPrimary: MobileCardField<Row>[] = fields.filter(
|
||||
(f) => f.key !== "title",
|
||||
);
|
||||
render(<MobileCardRow rows={rows} fields={noPrimary} />);
|
||||
// No title text rendered, but the key/value stack still is.
|
||||
expect(screen.queryByText("Movie A")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Size")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses getRowId for stable keys and emits no duplicate-key warning", () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
render(<MobileCardRow rows={rows} fields={fields} getRowId={(r) => r.id} />);
|
||||
// No React duplicate-key warning should fire.
|
||||
const duplicateKeyCalls = errorSpy.mock.calls.filter((args) =>
|
||||
String(args[0] ?? "").includes("same key"),
|
||||
);
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SheetForm } from "../sheet-form";
|
||||
|
||||
describe("SheetForm", () => {
|
||||
it("renders the title and children", () => {
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit service"
|
||||
onSave={() => {}}
|
||||
onCancel={() => {}}
|
||||
>
|
||||
<input aria-label="Name" />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Edit service")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSave when Save is clicked", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit"
|
||||
onSave={onSave}
|
||||
onCancel={() => {}}
|
||||
>
|
||||
<div />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onCancel when Cancel is clicked", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit"
|
||||
onSave={() => {}}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<div />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables Save and shows a pending label when isPending", () => {
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit"
|
||||
onSave={() => {}}
|
||||
onCancel={() => {}}
|
||||
isPending
|
||||
>
|
||||
<div />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /Saving/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
expect(screen.getByText("Saving…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCancel when the close (X) button is clicked", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit"
|
||||
onSave={() => {}}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<div />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("dirty-state confirm (R4.5)", () => {
|
||||
it("prompts before discarding via Cancel when isDirty", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit"
|
||||
onSave={() => {}}
|
||||
onCancel={onCancel}
|
||||
isDirty
|
||||
>
|
||||
<div />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
// Cancel does not immediately close; a confirm opens.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Discard changes?" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Confirm discard -> actually closes.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closing the confirm without discarding keeps the form open", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit"
|
||||
onSave={() => {}}
|
||||
onCancel={onCancel}
|
||||
isDirty
|
||||
>
|
||||
<div />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
// Two Cancel buttons now exist: the SheetForm footer and the confirm dialog.
|
||||
const cancelButtons = screen.getAllByRole("button", { name: "Cancel" });
|
||||
await userEvent.click(cancelButtons[cancelButtons.length - 1]);
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes immediately when not dirty", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<SheetForm
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Edit"
|
||||
onSave={() => {}}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<div />
|
||||
</SheetForm>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Discard changes?" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type Table as TableInstance,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -18,6 +17,7 @@ import { Columns3 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { TablePagination } from "@/components/ui/table-pagination";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -34,13 +34,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export interface DataTableProps<TData, TValue = unknown> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -253,90 +246,19 @@ export function DataTable<TData, TValue = unknown>({
|
||||
</div>
|
||||
|
||||
{enablePagination && (
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
<TablePagination
|
||||
pageIndex={table.getState().pagination.pageIndex}
|
||||
pageSize={table.getState().pagination.pageSize}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
|
||||
pageCount={pageCount}
|
||||
manual={manualPagination}
|
||||
rowCount={rowCount}
|
||||
onPaginationChange={table.setPagination}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaginationProps<TData> {
|
||||
table: TableInstance<TData>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
// DataTablePagination was extracted into the shared TablePagination component
|
||||
// (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable
|
||||
// and the Media mobile card list consume it.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Field descriptor for a {@link MobileCardRow}.
|
||||
*
|
||||
* The consuming page decides which fields to show and in what order; this
|
||||
* primitive does not pick them. Exactly one field should set `primary: true` —
|
||||
* it renders as the card title (bold, larger). The rest render as a key/value
|
||||
* stack below the title.
|
||||
*/
|
||||
export interface MobileCardField<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
render: (row: T) => React.ReactNode;
|
||||
/** When true, render as the card title (bold, larger). One per card. */
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface MobileCardRowProps<T> {
|
||||
rows: T[];
|
||||
fields: MobileCardField<T>[];
|
||||
/** Stable per-row identity; falls back to the row index when omitted. */
|
||||
getRowId?: (row: T) => string;
|
||||
/** When set, the whole card becomes a button (44px min height). */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Optional right-aligned action slot (edit/delete icon buttons). */
|
||||
actions?: (row: T) => React.ReactNode;
|
||||
/** Optional className for the outer list container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacked card list for wide tables below the `md:` breakpoint.
|
||||
*
|
||||
* Each row renders as a card: the `primary` field as the title and the
|
||||
* remaining fields as a key/value stack. When `onRowClick` is provided the
|
||||
* whole card is a button with a 44px minimum touch target (spec R6.1). An
|
||||
* optional `actions` slot renders right-aligned controls.
|
||||
*
|
||||
* This is the mobile counterpart to {@link DataTable}; pages branch on
|
||||
* `useIsMobile()`. See OpenSpec change `mobile-responsive-parity`, design
|
||||
* §`MobileCardRow`.
|
||||
*/
|
||||
export function MobileCardRow<T>({
|
||||
rows,
|
||||
fields,
|
||||
getRowId,
|
||||
onRowClick,
|
||||
actions,
|
||||
className,
|
||||
}: MobileCardRowProps<T>) {
|
||||
const primary = fields.find((f) => f.primary);
|
||||
const rest = fields.filter((f) => !f.primary);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{rows.map((row, index) => {
|
||||
const rowKey = getRowId?.(row) ?? String(index);
|
||||
const body = (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{primary ? (
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{primary.render(row)}
|
||||
</div>
|
||||
) : null}
|
||||
{rest.length > 0 ? (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
{rest.map((field) => (
|
||||
<React.Fragment key={field.key}>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
</dt>
|
||||
<dd className="truncate text-foreground">
|
||||
{field.render(row)}
|
||||
</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{actions(row)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (onRowClick) {
|
||||
return (
|
||||
<div
|
||||
key={rowKey}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onRowClick(row)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onRowClick(row);
|
||||
}
|
||||
}}
|
||||
className="mobile-touch-target min-h-11 w-full cursor-pointer rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rowKey}
|
||||
className="min-h-11 rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as React from "react";
|
||||
import { Loader2, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
export interface SheetFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
/** Disable Save and show a pending spinner. */
|
||||
isPending?: boolean;
|
||||
/** Override the Save button label (default "Save"). */
|
||||
saveLabel?: string;
|
||||
/** Disable the Save button (e.g. when required fields are empty). */
|
||||
saveDisabled?: boolean;
|
||||
/**
|
||||
* When true, any close attempt (Cancel button, header X, overlay click,
|
||||
* Escape) prompts a discard-confirmation instead of immediately closing.
|
||||
* Spec R4.5.
|
||||
*/
|
||||
isDirty?: boolean;
|
||||
children: React.ReactNode;
|
||||
/** Optional className applied to the scrolling body. */
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-height form host for the mobile (`< md`) breakpoint.
|
||||
*
|
||||
* Wraps the shadcn `Sheet` primitive with a fixed header (title + close) and a
|
||||
* fixed footer (Cancel + Save). The body scrolls between them. Laid out as a
|
||||
* flex column (NOT `position: sticky`) because Radix `Sheet` uses transforms,
|
||||
* which break sticky positioning — see OpenSpec change
|
||||
* `mobile-responsive-parity`, design §`SheetForm` / risks.
|
||||
*
|
||||
* Uses `h-[100dvh]` (not `h-screen`) to avoid the iOS Safari URL-bar resize
|
||||
* jump. Consumers choose this host vs the desktop `Dialog` via `useIsMobile()`.
|
||||
*/
|
||||
export function SheetForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
onSave,
|
||||
onCancel,
|
||||
isPending = false,
|
||||
saveDisabled = false,
|
||||
saveLabel = "Save",
|
||||
isDirty = false,
|
||||
children,
|
||||
bodyClassName,
|
||||
}: SheetFormProps) {
|
||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||
|
||||
// Route every close path (Cancel, header X, Radix overlay/Escape) through one
|
||||
// guard so the dirty-confirm is applied uniformly (spec R4.5).
|
||||
const attemptClose = React.useCallback(() => {
|
||||
if (isDirty) {
|
||||
setConfirmDiscardOpen(true);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}, [isDirty, onCancel]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
attemptClose();
|
||||
} else {
|
||||
onOpenChange(next);
|
||||
}
|
||||
},
|
||||
[attemptClose, onOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Prevent Radix's default Escape close so our guard runs instead.
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
attemptClose();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
// Prevent overlay-click close so our guard runs instead.
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
attemptClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header — fixed at top */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
|
||||
<SheetTitle className="font-heading text-base font-medium">
|
||||
{title}
|
||||
</SheetTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close"
|
||||
onClick={attemptClose}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body — scrolls */}
|
||||
<div className={cn("flex-1 overflow-y-auto p-4", bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer — fixed at bottom */}
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/50 p-4">
|
||||
<Button variant="outline" onClick={attemptClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSave} disabled={isPending || saveDisabled}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
saveLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDiscardOpen}
|
||||
title="Discard changes?"
|
||||
message="You have unsaved changes. Discard them and close?"
|
||||
confirmLabel="Discard"
|
||||
onCancel={() => setConfirmDiscardOpen(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmDiscardOpen(false);
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { OnChangeFn } from "@tanstack/react-table";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
/**
|
||||
* Shared pagination footer for table-style views.
|
||||
*
|
||||
* Renders the rows count, page-size select, page indicator, and prev/next
|
||||
* buttons. Works off the raw {@link PaginationState} primitives so it can back
|
||||
* both a TanStack `Table` instance (via a thin adapter) and standalone card
|
||||
* layouts that drive pagination directly (e.g. MediaMobilePagination).
|
||||
*
|
||||
* The Desktop DataTable and the Media mobile card list both consume this to
|
||||
* avoid the duplication flagged in
|
||||
* `openspec/changes/mobile-responsive-parity/verify-report.md` residual risk #5.
|
||||
*/
|
||||
export interface TablePaginationProps {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions: number[];
|
||||
totalRows: number;
|
||||
pageCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
/** Optional extra className on the outer container (e.g. "p-4"). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
totalRows,
|
||||
pageCount,
|
||||
onPaginationChange,
|
||||
className,
|
||||
}: TablePaginationProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-3 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Rows per page</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) =>
|
||||
onPaginationChange(() => ({
|
||||
pageIndex: 0,
|
||||
pageSize: Number(value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[70px]"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mobile-touch-target"
|
||||
onClick={() =>
|
||||
onPaginationChange((prev) => ({
|
||||
...prev,
|
||||
pageIndex: Math.max(0, prev.pageIndex - 1),
|
||||
}))
|
||||
}
|
||||
disabled={pageIndex <= 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mobile-touch-target"
|
||||
onClick={() =>
|
||||
onPaginationChange((prev) => ({
|
||||
...prev,
|
||||
pageIndex: prev.pageIndex + 1,
|
||||
}))
|
||||
}
|
||||
disabled={pageIndex >= pageCount - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Hooks for the Authentik directory + messaging tabs. */
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchAuthentikMessageStatus,
|
||||
fetchAuthentikUsers,
|
||||
sendAuthentikMessage,
|
||||
} from "../api/authentik";
|
||||
|
||||
export function useAuthentikUsers(
|
||||
serviceId: string,
|
||||
params: { search?: string; page?: number; page_size?: number },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["authentik", "users", serviceId, params],
|
||||
queryFn: () => fetchAuthentikUsers(serviceId, params),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendAuthentikMessage(serviceId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: {
|
||||
recipient_emails: string[];
|
||||
subject: string;
|
||||
html_body: string;
|
||||
}) => sendAuthentikMessage(serviceId, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["authentik", "message-status", serviceId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthentikMessageStatus(serviceId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["authentik", "message-status", serviceId],
|
||||
queryFn: () => fetchAuthentikMessageStatus(serviceId),
|
||||
refetchInterval: 5_000,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createDashboard,
|
||||
deleteDashboard,
|
||||
fetchDashboardBySlug,
|
||||
fetchDashboards,
|
||||
updateDashboard,
|
||||
type NamedDashboardInput,
|
||||
} from "../api/dashboards";
|
||||
|
||||
export function useDashboards() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboards"],
|
||||
queryFn: fetchDashboards,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDashboardBySlug(slug: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboards", "slug", slug],
|
||||
queryFn: () => fetchDashboardBySlug(slug!),
|
||||
enabled: !!slug,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: NamedDashboardInput) =>
|
||||
input.id ? updateDashboard(input) : createDashboard(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteDashboard(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Mobile breakpoint (must match Tailwind `md:` and the OpenSpec spec R1.2). */
|
||||
const MOBILE_QUERY = "(max-width: 768px)";
|
||||
|
||||
/**
|
||||
* Single source of truth for the mobile/desktop responsive cut.
|
||||
*
|
||||
* Returns `true` when the viewport matches `max-width: 768px` (phone portrait),
|
||||
* `false` at `md:` and above. SSR-safe: returns `false` when `window` is
|
||||
* undefined so server-rendered markup stays on the desktop path.
|
||||
*
|
||||
* Replaces the ad-hoc `window.matchMedia("(max-width: 768px)")` reads scattered
|
||||
* across pages (App.tsx, Media.tsx) — see OpenSpec change
|
||||
* `mobile-responsive-parity`, design §`useIsMobile`.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia(MOBILE_QUERY).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
)
|
||||
return;
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -14,7 +14,10 @@ export function useMediaStatus(jellyfinServiceId?: string) {
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.build_running ? 1000 : false,
|
||||
refetchIntervalInBackground: true,
|
||||
// Inherit the default refetchIntervalInBackground: false — pause the
|
||||
// 1s build-progress poll when the tab is hidden. The build keeps
|
||||
// running server-side; the poll resumes and catches up on return.
|
||||
// Battery-friendly (D8 follow-up).
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUsers } from "../api/client";
|
||||
import type { UserDirectoryResponse } from "../types";
|
||||
|
||||
export function useUsers(jellyfinServiceId?: string) {
|
||||
return useQuery<UserDirectoryResponse>({
|
||||
queryKey: ["users", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchUsers(jellyfinServiceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -100,3 +100,19 @@ body,
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mobile touch-target utility (spec R6.1).
|
||||
*
|
||||
* Applies a 44x44px minimum hit area to interactive elements ONLY below the
|
||||
* `md:` (768px) breakpoint, satisfying WCAG 2.5.5 / Apple HIG on touch devices.
|
||||
* At md+ the class is inert so desktop sizing is not regressed. Pages sprinkle
|
||||
* this on icon buttons, checkboxes, switches, and row taps. See OpenSpec
|
||||
* change `mobile-responsive-parity`, design §`mobile-touch-target`.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.mobile-touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
|
||||
|
||||
describe("navEntries", () => {
|
||||
it("returns no entries when no types are configured", () => {
|
||||
expect(configuredNavEntries(new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns Media when jellyfin is configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["jellyfin"]));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe("Media");
|
||||
expect(entries[0].path).toBe("/services/jellyfin");
|
||||
});
|
||||
|
||||
it("returns Files + Actions when ssh_tasks is configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries.map((e) => e.label)).toEqual(["Files", "Actions"]);
|
||||
});
|
||||
|
||||
it("returns all observability entries", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["alertmanager", "grafana", "prometheus"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Alerts",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns Backups + Users when configured", () => {
|
||||
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
|
||||
expect(entries.map((e) => e.label)).toEqual(["Backups", "Users"]);
|
||||
});
|
||||
|
||||
it("nextcloud has no nav entries in the static map", () => {
|
||||
expect(
|
||||
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves declaration order across mixed types", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["authentik", "ssh_tasks", "jellyfin"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Media",
|
||||
"Files",
|
||||
"Actions",
|
||||
"Users",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Service-type → conditional nav-entry map.
|
||||
*
|
||||
* Each configured service type contributes one or more top-level nav entries
|
||||
* that appear only when at least one enabled instance of that type exists.
|
||||
* See OpenSpec change `services-as-hub-ia`, spec R1.2.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
FolderOpen,
|
||||
GanttChartSquare,
|
||||
Link2,
|
||||
Monitor,
|
||||
Users,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavEntry {
|
||||
serviceType: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** Route path for this entry. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static mapping from service type to its conditional nav entries.
|
||||
* `nextcloud` has no entries (no operational content).
|
||||
*/
|
||||
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||
{
|
||||
serviceType: "jellyfin",
|
||||
label: "Media",
|
||||
icon: Monitor,
|
||||
path: "/services/jellyfin",
|
||||
},
|
||||
{
|
||||
serviceType: "ssh_tasks",
|
||||
label: "Files",
|
||||
icon: FolderOpen,
|
||||
path: "/services/ssh_tasks",
|
||||
},
|
||||
{
|
||||
serviceType: "ssh_tasks",
|
||||
label: "Actions",
|
||||
icon: Zap,
|
||||
path: "/services/ssh_tasks",
|
||||
},
|
||||
{
|
||||
serviceType: "alertmanager",
|
||||
label: "Alerts",
|
||||
icon: Activity,
|
||||
path: "/services/alertmanager",
|
||||
},
|
||||
{
|
||||
serviceType: "grafana",
|
||||
label: "Grafana",
|
||||
icon: Link2,
|
||||
path: "/services/grafana",
|
||||
},
|
||||
{
|
||||
serviceType: "prometheus",
|
||||
label: "Prometheus",
|
||||
icon: GanttChartSquare,
|
||||
path: "/services/prometheus",
|
||||
},
|
||||
{
|
||||
serviceType: "backups",
|
||||
label: "Backups",
|
||||
icon: DatabaseBackup,
|
||||
path: "/services/backups",
|
||||
},
|
||||
{
|
||||
serviceType: "authentik",
|
||||
label: "Users",
|
||||
icon: Users,
|
||||
path: "/services/authentik",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter the static entries to those whose service type is configured (present
|
||||
* in the `configuredTypes` set). Returns a flat list in declaration order.
|
||||
*/
|
||||
export function configuredNavEntries(configuredTypes: Set<string>): NavEntry[] {
|
||||
return SERVICE_TYPE_NAV_ENTRIES.filter((e) =>
|
||||
configuredTypes.has(e.serviceType),
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: frontend/src/pages
|
||||
|
||||
## role
|
||||
Top-level page components for the application's main navigation routes, each encapsulating a full-feature UI for managing services, media, files, users, settings, and system actions.
|
||||
Top-level page components constituting the primary UI screens/routes of the frontend application, covering dashboards, media management, file browsing, services, settings, users, and task automation.
|
||||
## parent
|
||||
index: frontend/src/.pi-map.index.md
|
||||
map: frontend/src/.pi-map.md
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: frontend/src/pages
|
||||
index: frontend/src/pages/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Top-level page components for the application's main navigation routes, each encapsulating a full-feature UI for managing services, media, files, users, settings, and system actions.
|
||||
Top-level page components constituting the primary UI screens/routes of the frontend application, covering dashboards, media management, file browsing, services, settings, users, and task automation.
|
||||
## files
|
||||
- Actions.tsx | Provides a React component for managing reusable server tasks (shell/python actions) with CRUD operations, service selection, and execution history display. | exp: func:Actions(), call:useServiceInstances, call:useTasks, call:useSaveTask, call:useDeleteTask, call:useRunTask, call:useState, call:emptyTask, call:useMemo, call:tasks.find, call:useTaskRuns, call:setDraft, call:setDraftBaseline, call:setEditOpen, call:setRunServiceId, call:saveTask.mutateAsync, call:setTab, call:String, call:tasks.map, call:openEdit, call:initialFromTask, call:runTask.mutateAsync, call:sshServices.map, call:selectedRuns.data.items.map, call:new Date(run.created_at * 1000).toLocaleString, call:deleteTask.mutate | dep: react, ../types, ../hooks/useSettings, ../hooks/useServices, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/separator, @/components/ui/tabs, @/components/ui/textarea
|
||||
- Applications.tsx | Renders a tabbed Applications dashboard with Jellyfin library statistics and media management, plus a placeholder for future Nextcloud support. | exp: func:Applications(), call:useState | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/tabs, ./Media, ../hooks/useDashboard, ../hooks/useServices, ../components/SectionCard, ../components/TabbedCard
|
||||
@@ -12,15 +12,15 @@ Top-level page components for the application's main navigation routes, each enc
|
||||
- FileBrowser.impl.tsx | A React component that implements a file browser with directory listing, file selection, ffprobe media metadata inspection, and job execution capabilities. | exp: func:FileBrowser(), call:useSearchParams, call:useState, call:useMonitoringSettings, call:useMemo, call:(machines ?? []).filter, call:machine.services.includes, call:searchParams.get, call:usePersistentState, call:isVideoFile, call:requestedPath.includes, call:requestedPath.replace, call:selectedPath.replace, call:defaultFileBrowserState, call:useNavigate, call:setBrowserState, call:useDirectoryListing, call:useFfprobe, call:useJobTemplates, call:useRunJob, call:updateBrowserState, call:setSearchParams, call:next.set, call:next.delete, call:navigate, call:currentDir.replace, call:rows.push, call:entry.name.split(".").pop, call:formatSize, call:formatTime, call:updater, call:Object.keys(next).filter, call:rows.find, call:templates?.find, call:fileMachines.map, call:refetch, call:String, call:templates.map, call:runJob.mutate, call:navigateToSettings | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/tabs, ../hooks/useFiles, ../hooks/usePersistentState, ../hooks/useSettings, ../components/SectionCard, ../components/TabbedCard
|
||||
- FileBrowser.tsx | Re-exports the FileBrowser component from its implementation file | dep: ./FileBrowser.impl
|
||||
- Media.tsx | A React component for managing and browsing Jellyfin media libraries with server-driven pagination, index building controls, and responsive data table display. | exp: func:Media(), call:useNavigate, call:useSearchParams, call:usePrefersSmallScreen, call:useServiceInstances, call:searchParams.get, call:jellyfinServices.find, call:useCounts, call:useLibraries, call:useMediaStatus, call:useBuildIndex, call:useStopBuildIndex, call:useForceStopBuildIndex, call:usePersistentState, call:defaultMediaTabState, call:setMediaState, call:useState, call:useEffect, call:setSearchParams, call:next.set, call:useMediaDataQuery, call:Math.floor, call:updater, call:useMemo, call:navigate, call:encodeURIComponent, call:Math.max, call:Math.ceil, call:formatDuration, call:jellyfinServices.map, call:status.item_count.toLocaleString, call:counts.movies.toLocaleString, call:counts.series.toLocaleString, call:counts.episodes.toLocaleString, call:(libraries?.length ?? 0).toLocaleString, call:buildIndex.mutate, call:stopBuildIndex.mutate, call:forceStopBuildIndex.mutate, call:Math.round, call:status?.build_items_processed?.toLocaleString, call:status?.build_items_total?.toLocaleString, call:status?.build_library_items_processed?.toLocaleString, call:status?.build_library_items_total?.toLocaleString, call:updateMediaState, call:total.toLocaleString | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/progress, @/components/ui/select, ../hooks/useMedia, ../hooks/usePersistentState, ../types, ../hooks/useServices, ../hooks/useDashboard, @/components/ui (data-table, alert, button, card, input, label, progress, select)
|
||||
- ServicePage.tsx | Provides a UI for viewing and editing a service instance's configuration, secrets, and widget bindings, with save and delete functionality. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useSaveServiceInstance, call:useDeleteServiceInstance, call:useMemo, call:services.find, call:getServiceBinding, call:useState, call:setName, call:setEnabled, call:setHydrated, call:saveService.mutateAsync, call:buildInput, call:setDeleteOpen, call:binding.widgets.map, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../integrations/registry, @/components/ui/*
|
||||
- ServicePage.tsx | Provides a settings form to view, edit (config and secrets), save, and delete a specific service instance, and lists its available widgets. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useServiceTypes, call:useSaveServiceInstance, call:useDeleteServiceInstance, call:useMemo, call:services.find, call:getServiceBinding, call:types.find, call:useState, call:setName, call:setEnabled, call:setDraftConfig, call:setHydrated, call:saveService.mutateAsync, call:buildInput, call:setDeleteOpen, call:binding.widgets.map, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../integrations/registry, @/components/ui (alert, badge, button, input, label, switch)
|
||||
- ServicesPage.tsx | A React page component for managing external service instances, allowing users to view, create, and delete service configurations with dynamic form fields based on service type schemas. | exp: func:ServicesPage(), call:useNavigate, call:useServiceInstances, call:useServiceTypes, call:useDeleteServiceInstance, call:useState, call:useMemo, call:map.get, call:list.push, call:map.set, call:[...map.entries()].sort, call:map.entries, call:a[0].localeCompare, call:types.find, call:getServiceBinding, call:setCreateOpen, call:grouped.map, call:typeName, call:instances.map, call:Object.entries(s.secrets_set).some, call:navigate, call:setDeleteId, call:Boolean, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, @/components/ui/dialog, lucide-react, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../components/DialogFooter, ../integrations/registry, @/components/ui (alert, badge, button, input, label, switch, dialog)
|
||||
- Settings.tsx | This file provides the UI components and form logic for managing application settings, including configuring monitoring machines via SSH and managing SSH keys. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useState, call:emptyMachine, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:Boolean, call:deleteMachine.mutate | dep: react, ../types, ../hooks/useSettings, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea
|
||||
- Settings.tsx | Provides a React settings UI for managing monitoring machines, SSH keys, and local database operations with form editing and validation. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useState, call:emptyMachine, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:Boolean, call:deleteMachine.mutate | dep: react, ../types, ../hooks/useSettings, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea, @/components/ui/*
|
||||
- Users.tsx | Re-exports the UsersPage component from its implementation file to provide a cleaner import interface. | dep: ./UsersPage.impl
|
||||
- UsersPage.impl.tsx | A React page component for managing and messaging Jellyfin users with Jellyseerr enrichment, featuring a searchable directory table, user selection, email composition dialog, and queue status monitoring. | exp: func:UsersPage(), call:useUsers, call:useActivity, call:useUserMessageQueueStatus, call:useSendUserMessage, call:useIsMobile, call:useState, call:useSearchParams, call:useRef, call:useMemo, call:mergeUsersWithActivity, call:search.trim().toLowerCase, call:rows.filter, call:[ row.username, row.display_name, row.email, row.email_source, row.avatar_source, row.name_source, row.access_source, row.user_type_label, row.role, row.permissions_label, row.jellyseerr_username, row.activity_label, row.activity_summary, row.activity.primary_session?.title || "", String(row.jellyseerr_user_id ?? ""), ].some, call:String, call:value.toLowerCase().includes, call:queueStatus.active_request_id.slice, call:selectedIdSet.has, call:selectedRows.filter, call:filteredRows.filter, call:setSelectedUserIds, call:current.includes, call:current.filter, call:filteredRows.forEach, call:next.add, call:next.delete, call:Array.from, call:searchParams.get, call:resolveUserSelection, call:buildUserDrawerModel, call:sendUserMessage.reset, call:subject.trim, call:setSubject, call:htmlBody.trim, call:setHtmlBody, call:setComposeOpen, call:htmlBody.slice, call:requestAnimationFrame, call:textarea.focus, call:textarea.setSelectionRange, call:window.prompt, call:insertMarkup, call:setAttachments, call:formData.append, call:JSON.stringify, call:allSelectedRows.map, call:attachments.forEach, call:sendUserMessage.mutateAsync, call:setSearch, call:cn, call:toggleVisibleSelection, call:filteredRows.map, call:setSearchParams, call:event.stopPropagation, call:toggleUserSelected, call:userLabel(row).charAt(0).toUpperCase, call:activityBadgeVariant, call:Boolean, call:drawerModel.title.charAt(0).toUpperCase, call:drawerModel.identity.map, call:drawerModel.contactActions.map, call:drawerModel.contactActions .map((action) => action.hint) .join, call:drawerModel.permissions.map, call:closeCompose, call:sendUserMessage.data.request_id.slice, call:selectedDeliverableRows.map, call:attachments.map, call:removeAttachment | dep: react, react-router-dom, lucide-react, @/components/ui/dialog, @/components/ui/input, @/components/ui/textarea, @/components/ui/separator, @/components/ui/label, @/components/ui/avatar, @/components/ui/badge, @/components/ui/button, @/components/ui/checkbox, @/components/ui/alert, @/components/ui/progress, @/components/ui/tooltip, @/components/ui/sheet, @/components/ui/table, @/lib/utils, ../components/MetricCard, ../components/SessionActivityPanel, ../hooks/useUsers, ../hooks/useDashboard, ../hooks/useSendUserMessage, ../hooks/useUserMessageQueueStatus, ../types, ../users, ../userState, @/components/ui (dialog, input, textarea, separator, label, avatar, badge, button, checkbox, alert, progress, tooltip, sheet, table)
|
||||
## arch
|
||||
React functional component pattern with lazy-loaded implementations (`.impl.tsx`) behind clean re-export facades (e.g., `FileBrowser.tsx`, `Users.tsx`), using dynamic service-type schemas and tabbed dashboard composition.
|
||||
React functional component pattern with implementation/re-export splitting; each page encapsulates its own state management, CRUD operations, and UI rendering, typically integrating backend APIs, dynamic forms, and tabular/dialog-based interfaces.
|
||||
## tags
|
||||
call:use, components, ui, call:set, state, service, machine, react
|
||||
call:use, components, ui, call:set, state, service, react, machine
|
||||
## symbols
|
||||
- Actions
|
||||
- Applications
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Media } from "./Media";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
|
||||
function JellyfinLibraryStats() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||
const selectedServiceId =
|
||||
searchParams.get("jellyfin_service_id") ||
|
||||
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||
"";
|
||||
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Library stats"
|
||||
description="Compact Jellyfin summary for the selected machine."
|
||||
action={
|
||||
<Badge variant="outline">
|
||||
{selectedServiceId ? "Selected service" : "Default service"}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{counts ? (
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Total</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{(
|
||||
counts.movies +
|
||||
counts.series +
|
||||
counts.episodes
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Movies</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.movies.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Series</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.series.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<span className="text-xs text-muted-foreground">Episodes</span>
|
||||
<div className="text-base leading-tight font-extrabold">
|
||||
{counts.episodes.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{libraries?.length ? (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{libraries.map((library) => (
|
||||
<div
|
||||
key={library.library}
|
||||
className="rounded-lg border bg-card px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{library.library}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total {library.total.toLocaleString()} · Movies{" "}
|
||||
{library.movies.toLocaleString()} · Series{" "}
|
||||
{library.series.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function Applications() {
|
||||
const [tab, setTab] = useState("jellyfin");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Applications</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse application-specific tools from a compact tabbed workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TabbedCard
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||
Jellyfin
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||
Nextcloud
|
||||
</TabsTrigger>,
|
||||
]}
|
||||
>
|
||||
{tab === "jellyfin" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<JellyfinLibraryStats />
|
||||
<Media />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Nextcloud support will be added in a future update.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,13 +32,121 @@ import {
|
||||
useSaveDashboardShortcut,
|
||||
} from "../hooks/useDashboard";
|
||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type {
|
||||
DashboardShortcut,
|
||||
DashboardShortcutInput,
|
||||
ServiceInstance,
|
||||
WidgetInstance,
|
||||
} from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||
|
||||
// --- Mobile section grouping (mobile-parity) ---
|
||||
|
||||
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
|
||||
type SectionId = (typeof SECTION_ORDER)[number];
|
||||
|
||||
const SECTION_META: Record<
|
||||
SectionId,
|
||||
{ label: string; icon: typeof Activity }
|
||||
> = {
|
||||
observability: { label: "Observability", icon: Activity },
|
||||
media: { label: "Media", icon: Monitor },
|
||||
backups: { label: "Backups", icon: DatabaseBackup },
|
||||
custom: { label: "Custom", icon: LayoutDashboard },
|
||||
};
|
||||
|
||||
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
|
||||
|
||||
function widgetSection(
|
||||
widget: WidgetInstance,
|
||||
services: ServiceInstance[],
|
||||
): SectionId {
|
||||
if (!widget.service_id) {
|
||||
return widget.widget_kind === "backups" ? "backups" : "custom";
|
||||
}
|
||||
const service = services.find((s) => s.id === widget.service_id);
|
||||
const serviceType = service?.service_type ?? "";
|
||||
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability";
|
||||
if (serviceType === "jellyfin") return "media";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function groupWidgetsBySection(
|
||||
widgets: WidgetInstance[],
|
||||
services: ServiceInstance[],
|
||||
): { id: SectionId; widgets: WidgetInstance[] }[] {
|
||||
const groups: Record<SectionId, WidgetInstance[]> = {
|
||||
observability: [],
|
||||
media: [],
|
||||
backups: [],
|
||||
custom: [],
|
||||
};
|
||||
for (const w of widgets) {
|
||||
groups[widgetSection(w, services)].push(w);
|
||||
}
|
||||
return SECTION_ORDER.map((id) => ({ id, widgets: groups[id] })).filter(
|
||||
(s) => s.widgets.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function MobileWidgetSections({
|
||||
sections,
|
||||
}: {
|
||||
sections: { id: SectionId; widgets: WidgetInstance[] }[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||
{sections.map((section) => {
|
||||
const meta = SECTION_META[section.id];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
className="mobile-touch-target inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById(`dashboard-section-${section.id}`)
|
||||
?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={`dashboard-section-${section.id}`}
|
||||
className="scroll-mt-16 flex flex-col gap-2"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{SECTION_META[section.id].label}
|
||||
</h3>
|
||||
{section.widgets.map((widget) => (
|
||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function emptyShortcut(): DashboardShortcutInput {
|
||||
return {
|
||||
id: null,
|
||||
@@ -336,6 +450,8 @@ export function Dashboard() {
|
||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const visibleWidgets = useMemo(
|
||||
() =>
|
||||
@@ -345,6 +461,11 @@ export function Dashboard() {
|
||||
[widgetInstances],
|
||||
);
|
||||
|
||||
const mobileSections = useMemo(
|
||||
() => groupWidgetsBySection(visibleWidgets, services),
|
||||
[visibleWidgets, services],
|
||||
);
|
||||
|
||||
const openCreateShortcut = () => {
|
||||
setShortcutDraft(emptyShortcut());
|
||||
setShortcutDialogOpen(true);
|
||||
@@ -374,6 +495,27 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{services.length === 0 ? (
|
||||
<SectionCard
|
||||
title="Welcome to Manage"
|
||||
description="Add a service to get started."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No services configured yet. Add a Jellyfin, SSH target, Authentik,
|
||||
or observability service to populate the navigation and
|
||||
dashboards.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/services")}
|
||||
className="w-fit"
|
||||
>
|
||||
Add a service
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
<SectionCard
|
||||
title="Shortcuts"
|
||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||
@@ -417,9 +559,13 @@ export function Dashboard() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{visibleWidgets.map((widget) => (
|
||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||
))}
|
||||
{isMobile && mobileSections.length > 0 ? (
|
||||
<MobileWidgetSections sections={mobileSections} />
|
||||
) : (
|
||||
visibleWidgets.map((widget) => (
|
||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||
))
|
||||
)}
|
||||
|
||||
<ShortcutDialog
|
||||
open={shortcutDialogOpen}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { FileBrowser } from "./FileBrowser.impl";
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDashboardBySlug } from "../hooks/useDashboards";
|
||||
import { PinnedServiceLink } from "../components/PinnedServiceLink";
|
||||
|
||||
/**
|
||||
* Payload model for named dashboards (design choice: inline items, not widget
|
||||
* instance ids). The payload stores an ordered list of items:
|
||||
*
|
||||
* ```
|
||||
* { items: DashboardItem[] }
|
||||
* ```
|
||||
*
|
||||
* Where `DashboardItem` is either a pinned service link (this slice) or a
|
||||
* future widget reference (follow-up). Widget composition on named dashboards
|
||||
* is deferred — the main Dashboard already has the rich widget config dialog.
|
||||
*/
|
||||
interface LinkItem {
|
||||
type: "link";
|
||||
label: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
type DashboardItem = LinkItem;
|
||||
|
||||
function parseItems(payload: Record<string, unknown>): DashboardItem[] {
|
||||
const items = payload.items;
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.filter(
|
||||
(item): item is LinkItem =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
item.type === "link" &&
|
||||
typeof item.label === "string" &&
|
||||
typeof item.target === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function NamedDashboardPage() {
|
||||
const { slug = "" } = useParams<{ slug: string }>();
|
||||
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
|
||||
|
||||
const items = useMemo(
|
||||
() => parseItems(dashboard?.payload ?? {}),
|
||||
[dashboard?.payload],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-32 w-full" />;
|
||||
}
|
||||
|
||||
if (isError || !dashboard) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Dashboard not found. It may have been deleted or the link is invalid.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This dashboard has no shortcuts yet. Add pinned service links from
|
||||
the dashboard management panel on the Services page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item, index) => (
|
||||
<PinnedServiceLink
|
||||
key={`${item.target}-${index}`}
|
||||
label={item.label}
|
||||
target={item.target}
|
||||
icon={Boxes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+322
-101
@@ -1,20 +1,40 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
useServiceTypes,
|
||||
} from "../hooks/useServices";
|
||||
import type { ServiceInstance, ServiceInstanceInput } from "../types";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import type {
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
} from "../types";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { getServiceBinding } from "../integrations/registry";
|
||||
import {
|
||||
OVERVIEW_TAB,
|
||||
serviceContentTabs,
|
||||
type ContentTab,
|
||||
} from "./service-tabs";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
@@ -44,6 +64,8 @@ export function ServicePage() {
|
||||
serviceId: string;
|
||||
}>();
|
||||
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const navigate = useNavigate();
|
||||
const saveService = useSaveServiceInstance();
|
||||
const deleteService = useDeleteServiceInstance();
|
||||
|
||||
@@ -52,16 +74,39 @@ export function ServicePage() {
|
||||
[services, serviceId],
|
||||
);
|
||||
const binding = getServiceBinding(serviceType);
|
||||
const typeInfo = useMemo(
|
||||
() => types.find((t) => t.service_type === serviceType),
|
||||
[types, serviceType],
|
||||
);
|
||||
const contentTabs = useMemo(
|
||||
() => serviceContentTabs(serviceType),
|
||||
[serviceType],
|
||||
);
|
||||
const siblings = useMemo(
|
||||
() => services.filter((s) => s.service_type === serviceType),
|
||||
[services, serviceType],
|
||||
);
|
||||
// R3.1: switcher trigger keys off ENABLED siblings (not total).
|
||||
const enabledSiblings = useMemo(
|
||||
() => siblings.filter((s) => s.enabled),
|
||||
[siblings],
|
||||
);
|
||||
const showSwitcher = enabledSiblings.length > 1;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
|
||||
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [sheetOpen, setSheetOpen] = useState(true);
|
||||
|
||||
// Hydrate local form state once the instance loads.
|
||||
if (instance && !hydrated) {
|
||||
setName(instance.name);
|
||||
setEnabled(instance.enabled);
|
||||
setDraftConfig({ ...instance.config });
|
||||
setDraftSecrets({});
|
||||
setHydrated(true);
|
||||
}
|
||||
|
||||
@@ -82,86 +127,191 @@ export function ServicePage() {
|
||||
}
|
||||
|
||||
function buildInput(): ServiceInstanceInput {
|
||||
// R2.3/R10.1: collect typed secret drafts. Empty values mean "keep the
|
||||
// existing value" so they are filtered out before sending.
|
||||
const onlyChangedSecrets = Object.fromEntries(
|
||||
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||
);
|
||||
return {
|
||||
id: instance!.id,
|
||||
service_type: instance!.service_type,
|
||||
name,
|
||||
config: instance!.config,
|
||||
secrets: {}, // secrets are managed via the dedicated inputs below
|
||||
config: draftConfig,
|
||||
secrets: onlyChangedSecrets,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await saveService.mutateAsync(buildInput());
|
||||
// Clear secret drafts after a successful save so the inputs reset to
|
||||
// "leave blank to keep" state.
|
||||
setDraftSecrets({});
|
||||
}
|
||||
|
||||
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
|
||||
|
||||
// The config + widgets body, shared between desktop tabs and mobile SheetForm.
|
||||
const widgetsContent =
|
||||
binding.widgets.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{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>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No widget kinds for this service type.
|
||||
</p>
|
||||
);
|
||||
|
||||
const configBody = (
|
||||
<ConfigBody
|
||||
instance={instance}
|
||||
typeInfo={typeInfo}
|
||||
draftConfig={draftConfig}
|
||||
onConfigChange={setDraftConfig}
|
||||
draftSecrets={draftSecrets}
|
||||
onSecretsChange={setDraftSecrets}
|
||||
name={name}
|
||||
enabled={enabled}
|
||||
onNameChange={setName}
|
||||
onEnabledChange={setEnabled}
|
||||
onSave={save}
|
||||
savePending={saveService.isPending}
|
||||
onDelete={() => setDeleteOpen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Mobile: render inside a SheetForm (open on mount; cancel navigates back).
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SheetForm
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={name || instance.name}
|
||||
onSave={save}
|
||||
onCancel={() => {
|
||||
setSheetOpen(false);
|
||||
navigate("/services");
|
||||
}}
|
||||
isPending={saveService.isPending}
|
||||
isDirty={
|
||||
name !== instance.name ||
|
||||
enabled !== instance.enabled ||
|
||||
JSON.stringify(draftConfig) !== JSON.stringify(instance.config)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{allTabs.map((tab) => {
|
||||
const TabComponent = tab.Component;
|
||||
return (
|
||||
<div key={tab.label}>
|
||||
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
|
||||
{tab.label}
|
||||
</h3>
|
||||
<TabComponent instance={instance} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{widgetsContent}
|
||||
{configBody}
|
||||
</div>
|
||||
</SheetForm>
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
title="Delete service?"
|
||||
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={() => {
|
||||
deleteService.mutate(instance.id);
|
||||
setDeleteOpen(false);
|
||||
navigate("/services");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{/* Header + instance switcher */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{binding.name}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
{showSwitcher ? (
|
||||
<Select
|
||||
value={instance.id}
|
||||
onValueChange={(id) => navigate(`/services/${serviceType}/${id}`)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siblings.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Badge variant="outline">{binding.name}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionCard title="General">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="Name" htmlFor="service-name">
|
||||
<Input
|
||||
id="service-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor="service-enabled">Enabled</Label>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button onClick={save} disabled={saveService.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
{/* Tab skeleton */}
|
||||
<Tabs defaultValue="Overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Overview">Overview</TabsTrigger>
|
||||
{contentTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.label} value={tab.label}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
|
||||
<TabsTrigger value="Config">Config</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ServiceSecretsCard instance={instance} />
|
||||
{allTabs.map((tab) => {
|
||||
const TabComponent = tab.Component;
|
||||
return (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<TabComponent instance={instance} />
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
|
||||
{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}
|
||||
<TabsContent value="Widgets">
|
||||
<SectionCard
|
||||
title="Widgets"
|
||||
description="Widget kinds this service provides."
|
||||
>
|
||||
{widgetsContent}
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="Config">{configBody}</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
@@ -172,39 +322,119 @@ export function ServicePage() {
|
||||
onConfirm={() => {
|
||||
deleteService.mutate(instance.id);
|
||||
setDeleteOpen(false);
|
||||
navigate("/services");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
|
||||
const saveService = useSaveServiceInstance();
|
||||
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
||||
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||
function ConfigBody({
|
||||
instance,
|
||||
typeInfo,
|
||||
draftConfig,
|
||||
onConfigChange,
|
||||
draftSecrets,
|
||||
onSecretsChange,
|
||||
name,
|
||||
enabled,
|
||||
onNameChange,
|
||||
onEnabledChange,
|
||||
onSave,
|
||||
savePending,
|
||||
onDelete,
|
||||
}: {
|
||||
instance: ServiceInstance;
|
||||
typeInfo: ServiceTypeInfo | undefined;
|
||||
draftConfig: Record<string, unknown>;
|
||||
onConfigChange: (config: Record<string, unknown>) => void;
|
||||
draftSecrets: Record<string, string>;
|
||||
onSecretsChange: (secrets: Record<string, string>) => void;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
onNameChange: (name: string) => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onSave: () => void;
|
||||
savePending: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const properties =
|
||||
(
|
||||
(typeInfo?.config_schema ?? {}) as {
|
||||
properties?: Record<
|
||||
string,
|
||||
{ type?: string; description?: string; default?: unknown }
|
||||
>;
|
||||
}
|
||||
).properties ?? {};
|
||||
const configEntries: Array<
|
||||
[string, { type?: string; description?: string }]
|
||||
> =
|
||||
Object.keys(properties).length > 0
|
||||
? Object.entries(properties).map(([key, schema]) => [
|
||||
key,
|
||||
{ type: schema?.type, description: schema?.description },
|
||||
])
|
||||
: Object.entries(instance.config).map(([key, value]) => [
|
||||
key,
|
||||
{ type: typeof value === "number" ? "integer" : "string" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Connection"
|
||||
description="Non-secret config is read-only here for now; edit secret values below."
|
||||
>
|
||||
<SectionCard title="Config">
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(instance.config).length === 0 ? (
|
||||
<Field label="Name" htmlFor="service-name">
|
||||
<Input
|
||||
id="service-name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="service-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
/>
|
||||
<Label htmlFor="service-enabled">Enabled</Label>
|
||||
</div>
|
||||
|
||||
{configEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No connection config.</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
{Object.entries(instance.config).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
<dt className="text-xs text-muted-foreground">{key}</dt>
|
||||
<dd className="truncate font-mono text-xs">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<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>
|
||||
) : (
|
||||
{Object.keys(instance.secrets_set).length === 0 ? null : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
@@ -219,7 +449,7 @@ function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
|
||||
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||
value={draftSecrets[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setDraftSecrets({
|
||||
onSecretsChange({
|
||||
...draftSecrets,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
@@ -229,26 +459,17 @@ function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
|
||||
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||
</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: instance.config,
|
||||
secrets: onlyChanged,
|
||||
enabled: instance.enabled,
|
||||
});
|
||||
setDraftSecrets({});
|
||||
}}
|
||||
>
|
||||
Update secrets
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button onClick={onSave} disabled={savePending}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Handles `/services/:type` (no instance id). Resolves the first enabled
|
||||
* instance and redirects. Shows an empty state if none are configured.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams, Navigate } from "react-router-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
|
||||
export function ServiceTypePage() {
|
||||
const { serviceType = "" } = useParams<{ serviceType: string }>();
|
||||
const { data: instances = [], isLoading } = useServiceInstances(
|
||||
serviceType || undefined,
|
||||
);
|
||||
|
||||
const firstEnabled = useMemo(
|
||||
() => instances.find((s) => s.enabled) ?? instances[0],
|
||||
[instances],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (firstEnabled) {
|
||||
return (
|
||||
<Navigate to={`/services/${serviceType}/${firstEnabled.id}`} replace />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
<span>No {serviceType} service configured.</span>
|
||||
<Button asChild className="w-fit">
|
||||
<Link to="/services">Add a service</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,31 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteServiceInstance,
|
||||
useSaveServiceInstance,
|
||||
useServiceInstances,
|
||||
} from "../hooks/useServices";
|
||||
import { useServiceTypes } from "../hooks/useServices";
|
||||
import {
|
||||
useDashboards,
|
||||
useDeleteDashboard,
|
||||
useSaveDashboard,
|
||||
} from "../hooks/useDashboards";
|
||||
import type {
|
||||
SecretFieldInfo,
|
||||
ServiceInstance,
|
||||
@@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { getServiceBinding } from "../integrations/registry";
|
||||
import { serviceLinkTarget } from "../components/PinnedServiceLink";
|
||||
import type { NamedDashboardInput } from "../api/dashboards";
|
||||
|
||||
interface CreateDraft {
|
||||
serviceType: string;
|
||||
@@ -257,6 +277,239 @@ function CreateServiceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Named dashboards management (Slice 10.3) ---
|
||||
|
||||
function DashboardManagementCard() {
|
||||
const { data: dashboards = [] } = useDashboards();
|
||||
const saveDashboard = useSaveDashboard();
|
||||
const deleteDashboard = useDeleteDashboard();
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [linkDashId, setLinkDashId] = useState<string | null>(null);
|
||||
const [linkLabel, setLinkLabel] = useState("");
|
||||
const [linkTarget, setLinkTarget] = useState("");
|
||||
|
||||
const enabledServices = useMemo(
|
||||
() => services.filter((s) => s.enabled),
|
||||
[services],
|
||||
);
|
||||
|
||||
function createDashboard() {
|
||||
if (!newLabel.trim()) return;
|
||||
const input: NamedDashboardInput = {
|
||||
label: newLabel.trim(),
|
||||
sort_order: dashboards.length,
|
||||
payload: { items: [] },
|
||||
};
|
||||
saveDashboard.mutate(input);
|
||||
setNewLabel("");
|
||||
setCreateOpen(false);
|
||||
}
|
||||
|
||||
function reorder(dashId: string, direction: -1 | 1) {
|
||||
const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const idx = sorted.findIndex((d) => d.id === dashId);
|
||||
const swapIdx = idx + direction;
|
||||
if (swapIdx < 0 || swapIdx >= sorted.length) return;
|
||||
const a = sorted[idx];
|
||||
const b = sorted[swapIdx];
|
||||
saveDashboard.mutate({
|
||||
...a,
|
||||
sort_order: b.sort_order,
|
||||
payload: a.payload,
|
||||
});
|
||||
saveDashboard.mutate({
|
||||
...b,
|
||||
sort_order: a.sort_order,
|
||||
payload: b.payload,
|
||||
});
|
||||
}
|
||||
|
||||
function addPinnedLink() {
|
||||
if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return;
|
||||
const dash = dashboards.find((d) => d.id === linkDashId);
|
||||
if (!dash) return;
|
||||
const items = Array.isArray(dash.payload.items)
|
||||
? (dash.payload.items as unknown[])
|
||||
: [];
|
||||
items.push({ type: "link", label: linkLabel.trim(), target: linkTarget });
|
||||
saveDashboard.mutate({
|
||||
id: dash.id,
|
||||
label: dash.label,
|
||||
sort_order: dash.sort_order,
|
||||
payload: { items },
|
||||
});
|
||||
setLinkLabel("");
|
||||
setLinkTarget("");
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Dashboards"
|
||||
description="Named dashboards appear in the top nav. Compose them from pinned service links."
|
||||
action={
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
New dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No named dashboards yet. Create one to add pinned service links.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...dashboards]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((d, idx, arr) => (
|
||||
<div key={d.id} className="rounded border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{d.label}</span>
|
||||
<Badge variant="outline">/{d.slug}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={idx === 0}
|
||||
onClick={() => reorder(d.id, -1)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={idx === arr.length - 1}
|
||||
onClick={() => reorder(d.id, 1)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => setDeleteId(d.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{Array.isArray(d.payload.items) &&
|
||||
(d.payload.items as unknown[]).length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(d.payload.items as unknown[]).length} pinned link(s)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
No links yet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<Field label="Link label" htmlFor={`link-label-${d.id}`}>
|
||||
<Input
|
||||
id={`link-label-${d.id}`}
|
||||
className="w-40"
|
||||
placeholder="My Jellyfin"
|
||||
value={linkDashId === d.id ? linkLabel : ""}
|
||||
onChange={(e) => {
|
||||
setLinkDashId(d.id);
|
||||
setLinkLabel(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`link-target-${d.id}`}>Service</Label>
|
||||
<Select
|
||||
value={linkDashId === d.id ? linkTarget : ""}
|
||||
onValueChange={(v) => {
|
||||
setLinkDashId(d.id);
|
||||
setLinkTarget(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`link-target-${d.id}`}
|
||||
className="w-56"
|
||||
>
|
||||
<SelectValue placeholder="Pick a service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledServices.map((s) => (
|
||||
<SelectItem
|
||||
key={s.id}
|
||||
value={serviceLinkTarget(s.service_type, s.id)}
|
||||
>
|
||||
{s.name} ({s.service_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
linkDashId !== d.id ||
|
||||
!linkLabel.trim() ||
|
||||
!linkTarget.trim()
|
||||
}
|
||||
onClick={addPinnedLink}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New dashboard</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Field label="Label" htmlFor="dash-label">
|
||||
<Input
|
||||
id="dash-label"
|
||||
placeholder="Storage overview"
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") createDashboard();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<DialogFooter
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onConfirm={createDashboard}
|
||||
confirmLabel="Create"
|
||||
confirmDisabled={!newLabel.trim() || saveDashboard.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteId)}
|
||||
title="Delete dashboard?"
|
||||
message="This removes the named dashboard and its pinned links."
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setDeleteId(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteId) deleteDashboard.mutate(deleteId);
|
||||
setDeleteId(null);
|
||||
}}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
@@ -352,6 +605,8 @@ export function ServicesPage() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<DashboardManagementCard />
|
||||
|
||||
<CreateServiceDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
|
||||
+144
-45
@@ -17,6 +17,8 @@ import {
|
||||
useSaveSSHKey,
|
||||
useTestMonitoringMachineSSH,
|
||||
} from "../hooks/useSettings";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import { SheetForm } from "@/components/ui/sheet-form";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
@@ -126,6 +128,30 @@ function emptyMachine(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty check for the machine editor SheetForm guard (spec R4.5).
|
||||
* Pragmatic field-by-field comparison of the user-editable fields. In create
|
||||
* mode (editingMachine is null) the form is always dirty.
|
||||
*/
|
||||
function isMachineDraftDirty(
|
||||
draft: MonitoringMachineInput,
|
||||
editingMachine: MonitoringMachine | null,
|
||||
): boolean {
|
||||
if (!editingMachine) return true;
|
||||
return (
|
||||
draft.name !== editingMachine.name ||
|
||||
draft.host !== editingMachine.host ||
|
||||
draft.mode !== editingMachine.mode ||
|
||||
draft.port !== editingMachine.port ||
|
||||
draft.username !== editingMachine.username ||
|
||||
draft.ssh_key_id !== editingMachine.ssh_key_id ||
|
||||
draft.enabled !== editingMachine.enabled ||
|
||||
draft.notes !== editingMachine.notes ||
|
||||
JSON.stringify([...draft.services].sort()) !==
|
||||
JSON.stringify([...editingMachine.services].sort())
|
||||
);
|
||||
}
|
||||
|
||||
function MachineEditor({
|
||||
title,
|
||||
hint,
|
||||
@@ -230,6 +256,7 @@ function MachineEditor({
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="machine-enabled"
|
||||
className="mobile-touch-target"
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft((current) => ({ ...current, enabled: checked }))
|
||||
@@ -439,6 +466,7 @@ function MachineEditor({
|
||||
</Alert>
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="outline"
|
||||
onClick={onValidateSSH}
|
||||
disabled={
|
||||
@@ -532,7 +560,7 @@ function SSHKeyManager({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
className="mobile-touch-target w-full"
|
||||
onClick={() => {
|
||||
clear();
|
||||
}}
|
||||
@@ -651,6 +679,7 @@ function SSHKeyManager({
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
disabled={saveKey.isPending}
|
||||
onClick={async () => {
|
||||
await saveKey.mutateAsync(draft);
|
||||
@@ -660,6 +689,7 @@ function SSHKeyManager({
|
||||
{editing ? "Update key" : "Save key"}
|
||||
</Button>
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="outline"
|
||||
disabled={generateKey.isPending}
|
||||
onClick={async () => {
|
||||
@@ -683,11 +713,16 @@ function SSHKeyManager({
|
||||
>
|
||||
{generateKey.isPending ? "Generating..." : "Generate key"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={clear}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={clear}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
{selectedKey && (
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="destructive"
|
||||
onClick={() => deleteKey.mutate(selectedKey.id)}
|
||||
>
|
||||
@@ -753,7 +788,11 @@ function ResetLocalDatabaseCard() {
|
||||
Reset the local SQLite settings/media index databases after
|
||||
acknowledging the data loss.
|
||||
</p>
|
||||
<Button variant="destructive" onClick={() => setOpen(true)}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setOpen(true)}
|
||||
className="mobile-touch-target"
|
||||
>
|
||||
Reset local database
|
||||
</Button>
|
||||
{resetDatabase.error && (
|
||||
@@ -780,6 +819,7 @@ function ResetLocalDatabaseCard() {
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
className="mobile-touch-target"
|
||||
checked={ackSettings}
|
||||
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
|
||||
/>
|
||||
@@ -787,6 +827,7 @@ function ResetLocalDatabaseCard() {
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
className="mobile-touch-target"
|
||||
checked={ackIndex}
|
||||
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
|
||||
/>
|
||||
@@ -794,6 +835,7 @@ function ResetLocalDatabaseCard() {
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
className="mobile-touch-target"
|
||||
checked={ackIrreversible}
|
||||
onCheckedChange={(checked) =>
|
||||
setAckIrreversible(Boolean(checked))
|
||||
@@ -850,6 +892,7 @@ export function Settings() {
|
||||
const [editingMachine, setEditingMachine] =
|
||||
useState<MonitoringMachine | null>(null);
|
||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||
const isMobile = useIsMobile();
|
||||
const orderedMachines = useMemo(() => machines ?? [], [machines]);
|
||||
const selectedMachine = useMemo(
|
||||
() =>
|
||||
@@ -970,7 +1013,7 @@ export function Settings() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
className="mobile-touch-target w-full"
|
||||
onClick={() => {
|
||||
clearSSHValidation();
|
||||
setMachineDraft(emptyMachine("local"));
|
||||
@@ -1086,6 +1129,7 @@ export function Settings() {
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
openEditMachine(
|
||||
@@ -1113,6 +1157,7 @@ export function Settings() {
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteMachineId(selectedMachine.id)}
|
||||
>
|
||||
@@ -1135,21 +1180,25 @@ export function Settings() {
|
||||
)}
|
||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||
</TabbedCard>
|
||||
<Dialog
|
||||
open={machineDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeMachineDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{machineDraft.id ? "Edit machine" : "Create machine"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{machineDraft.mode === "local" ? "Local API host" : "SSH target"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isMobile ? (
|
||||
<SheetForm
|
||||
open={machineDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeMachineDialog();
|
||||
}}
|
||||
title={machineDraft.id ? "Edit machine" : "Create machine"}
|
||||
onSave={() => {
|
||||
void saveMachineDraft(machineDraft);
|
||||
}}
|
||||
onCancel={closeMachineDialog}
|
||||
isPending={saveMachine.isPending}
|
||||
saveDisabled={
|
||||
!machineDraft.name ||
|
||||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||
}
|
||||
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
|
||||
>
|
||||
<MachineEditor
|
||||
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||
title={
|
||||
@@ -1170,32 +1219,82 @@ export function Settings() {
|
||||
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
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setDeleteMachineId(machineDraft.id as string);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{machineDraft.id ? (
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteMachineId(machineDraft.id as string)}
|
||||
>
|
||||
Delete machine
|
||||
</Button>
|
||||
) : null}
|
||||
</SheetForm>
|
||||
) : (
|
||||
<Dialog
|
||||
open={machineDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeMachineDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{machineDraft.id ? "Edit machine" : "Create machine"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{machineDraft.mode === "local"
|
||||
? "Local API host"
|
||||
: "SSH target"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<MachineEditor
|
||||
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
|
||||
title={
|
||||
machineDraft.id
|
||||
? machineDraft.name || "Edit machine"
|
||||
: "New machine"
|
||||
}
|
||||
hint={
|
||||
machineDraft.mode === "local" ? "Local API host" : "SSH target"
|
||||
}
|
||||
machine={machineDraft}
|
||||
sshKeys={sshKeys}
|
||||
editingMachine={editingMachine}
|
||||
onChange={updateMachineDraft}
|
||||
onValidateSSH={validateMachineSSH}
|
||||
isValidatingSSH={testMachineSSH.isPending}
|
||||
sshValidationMessage={sshValidationMessage}
|
||||
sshValidationError={sshValidationError}
|
||||
sshValidationStatus={sshValidationStatus}
|
||||
/>
|
||||
<DialogFooter
|
||||
onCancel={closeMachineDialog}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={() => {
|
||||
void saveMachineDraft(machineDraft);
|
||||
}}
|
||||
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
|
||||
confirmDisabled={
|
||||
!machineDraft.name ||
|
||||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
|
||||
}
|
||||
secondaryAction={
|
||||
machineDraft.id ? (
|
||||
<Button
|
||||
className="mobile-touch-target"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setDeleteMachineId(machineDraft.id as string);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteMachineId)}
|
||||
title="Delete machine?"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { UsersPage } from "./UsersPage.impl";
|
||||
@@ -1,983 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ChangeEvent } from "react";
|
||||
// Slice 6b: compose dialog (shadcn Dialog family) + lucide icons. The file is
|
||||
// now fully @mui-free (6a migrated the directory surface, drawer, and the
|
||||
// compose content's shared leaf components).
|
||||
import {
|
||||
X,
|
||||
Paperclip,
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
Mail,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Label } from "@/components/ui/label";
|
||||
// Slice 6a directory surface + drawer primitives.
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button as UiButton } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Alert as UIAlert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||
import { useUsers } from "../hooks/useUsers";
|
||||
import { useActivity } from "../hooks/useDashboard";
|
||||
import { useSendUserMessage } from "../hooks/useSendUserMessage";
|
||||
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
|
||||
import type { UserDirectoryItem } from "../types";
|
||||
import { buildUserDrawerModel } from "../users";
|
||||
import {
|
||||
mergeUsersWithActivity,
|
||||
resolveUserSelection,
|
||||
type UserStateItem,
|
||||
} from "../userState";
|
||||
|
||||
// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free
|
||||
// matchMedia hook for the compose dialog's mobile fullScreen behavior.
|
||||
function useIsMobile(query = "(max-width: 900px)") {
|
||||
const [mobile, setMobile] = useState(() =>
|
||||
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
? window.matchMedia(query).matches
|
||||
: false,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const mql = window.matchMedia(query);
|
||||
const onChange = (event: MediaQueryListEvent) => setMobile(event.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return mobile;
|
||||
}
|
||||
|
||||
function userLabel(user: UserDirectoryItem) {
|
||||
return user.display_name || user.username || user.jellyfin_id;
|
||||
}
|
||||
|
||||
// Activity → Badge status variant (design §2.3: healthy/active = success chart-2,
|
||||
// paused = warning chart-3, neutral = secondary).
|
||||
function activityBadgeVariant(
|
||||
label: string,
|
||||
): "success" | "warning" | "secondary" {
|
||||
if (label === "Playing") return "success";
|
||||
if (label === "Paused") return "warning";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
||||
|
||||
export function UsersPage() {
|
||||
const { data, isError, error } = useUsers();
|
||||
const { data: activity } = useActivity();
|
||||
const queueStatusQuery = useUserMessageQueueStatus();
|
||||
const sendUserMessage = useSendUserMessage();
|
||||
const isMobile = useIsMobile();
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const htmlBodyRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const baseRows = data?.items ?? [];
|
||||
const rows = useMemo(
|
||||
() => mergeUsersWithActivity(baseRows, activity ?? []),
|
||||
[baseRows, activity],
|
||||
);
|
||||
const filteredRows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => {
|
||||
return [
|
||||
row.username,
|
||||
row.display_name,
|
||||
row.email,
|
||||
row.email_source,
|
||||
row.avatar_source,
|
||||
row.name_source,
|
||||
row.access_source,
|
||||
row.user_type_label,
|
||||
row.role,
|
||||
row.permissions_label,
|
||||
row.jellyseerr_username,
|
||||
row.activity_label,
|
||||
row.activity_summary,
|
||||
row.activity.primary_session?.title || "",
|
||||
String(row.jellyseerr_user_id ?? ""),
|
||||
].some((value) => value.toLowerCase().includes(term));
|
||||
});
|
||||
}, [rows, search]);
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const total = baseRows.length;
|
||||
const contactable = rows.filter((row) => row.contactable).length;
|
||||
const enriched = rows.filter(
|
||||
(row) => row.jellyseerr_user_id !== null,
|
||||
).length;
|
||||
const admins = rows.filter((row) => row.role === "admin").length;
|
||||
return { total, contactable, enriched, admins };
|
||||
}, [baseRows]);
|
||||
|
||||
const queueStatus = queueStatusQuery.data;
|
||||
const queueBanner = useMemo(() => {
|
||||
if (!queueStatus) {
|
||||
return null;
|
||||
}
|
||||
const activeCount = queueStatus.active_request_id ? 1 : 0;
|
||||
const totalCount = queueStatus.pending_count + activeCount;
|
||||
const countLabel =
|
||||
totalCount > 0
|
||||
? `${totalCount} item${totalCount === 1 ? "" : "s"} in queue (${queueStatus.pending_count} waiting${activeCount ? ", 1 processing" : ""})`
|
||||
: "0 items in queue";
|
||||
if (!queueStatus.worker_running) {
|
||||
return {
|
||||
severity: "warning" as const,
|
||||
message:
|
||||
queueStatus.last_error ||
|
||||
"Email queue worker is not running. New messages cannot be delivered until it restarts.",
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}
|
||||
if (queueStatus.state === "error") {
|
||||
return {
|
||||
severity: "error" as const,
|
||||
message: queueStatus.last_error || "The last email delivery failed.",
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}
|
||||
if (queueStatus.state === "busy") {
|
||||
const active = queueStatus.active_request_id
|
||||
? `processing ${queueStatus.active_request_id.slice(0, 8)}`
|
||||
: "processing a message";
|
||||
const waiting = queueStatus.pending_count
|
||||
? `${queueStatus.pending_count} waiting`
|
||||
: "no backlog";
|
||||
return {
|
||||
severity: "info" as const,
|
||||
message: `Email queue is busy: ${active}, ${waiting}.`,
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
severity: "success" as const,
|
||||
message: "Email queue is idle and empty.",
|
||||
countLabel,
|
||||
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
|
||||
};
|
||||
}, [queueStatus]);
|
||||
|
||||
const selectedIdSet = useMemo(
|
||||
() => new Set(selectedUserIds),
|
||||
[selectedUserIds],
|
||||
);
|
||||
const selectedRows = useMemo(
|
||||
() => rows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
|
||||
[rows, selectedIdSet],
|
||||
);
|
||||
const selectedDeliverableRows = useMemo(
|
||||
() => selectedRows.filter((row) => row.contactable && row.email),
|
||||
[selectedRows],
|
||||
);
|
||||
const skippedRows = useMemo(
|
||||
() => selectedRows.filter((row) => !row.contactable || !row.email),
|
||||
[selectedRows],
|
||||
);
|
||||
const visibleSelectedRows = useMemo(
|
||||
() => filteredRows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
|
||||
[filteredRows, selectedIdSet],
|
||||
);
|
||||
const allVisibleSelected =
|
||||
filteredRows.length > 0 &&
|
||||
visibleSelectedRows.length === filteredRows.length;
|
||||
|
||||
const toggleUserSelected = (userId: string) => {
|
||||
setSelectedUserIds((current) =>
|
||||
current.includes(userId)
|
||||
? current.filter((id) => id !== userId)
|
||||
: [...current, userId],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleVisibleSelection = (checked: boolean) => {
|
||||
setSelectedUserIds((current) => {
|
||||
const next = new Set(current);
|
||||
filteredRows.forEach((row) => {
|
||||
if (checked) {
|
||||
next.add(row.jellyfin_id);
|
||||
} else {
|
||||
next.delete(row.jellyfin_id);
|
||||
}
|
||||
});
|
||||
return Array.from(next);
|
||||
});
|
||||
};
|
||||
|
||||
const selectedUserParam = searchParams.get("user") || "";
|
||||
const selectedUser = useMemo(
|
||||
() =>
|
||||
selectedUserParam
|
||||
? (resolveUserSelection(
|
||||
rows,
|
||||
selectedUserParam,
|
||||
) as UserStateItem | null)
|
||||
: null,
|
||||
[rows, selectedUserParam],
|
||||
);
|
||||
const drawerModel = selectedUser ? buildUserDrawerModel(selectedUser) : null;
|
||||
|
||||
const openCompose = () => {
|
||||
if (!selectedRows.length) {
|
||||
return;
|
||||
}
|
||||
sendUserMessage.reset();
|
||||
if (!subject.trim()) {
|
||||
setSubject(
|
||||
`Manage update for ${selectedDeliverableRows.length} user${selectedDeliverableRows.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
if (!htmlBody.trim()) {
|
||||
setHtmlBody(DEFAULT_HTML_BODY);
|
||||
}
|
||||
setComposeOpen(true);
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
setComposeOpen(false);
|
||||
sendUserMessage.reset();
|
||||
};
|
||||
|
||||
const insertMarkup = (before: string, after = before) => {
|
||||
const textarea = htmlBodyRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart ?? htmlBody.length;
|
||||
const end = textarea.selectionEnd ?? htmlBody.length;
|
||||
const selected = htmlBody.slice(start, end) || "text";
|
||||
const next =
|
||||
htmlBody.slice(0, start) +
|
||||
before +
|
||||
selected +
|
||||
after +
|
||||
htmlBody.slice(end);
|
||||
setHtmlBody(next);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
const cursorStart = start + before.length;
|
||||
const cursorEnd = cursorStart + selected.length;
|
||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||
});
|
||||
};
|
||||
|
||||
const addLink = () => {
|
||||
const url = window.prompt("Link URL", "https://");
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
insertMarkup(`<a href="${url}">`, "</a>");
|
||||
};
|
||||
|
||||
const handleAttachments = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length) {
|
||||
setAttachments((current) => [...current, ...files]);
|
||||
}
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments((current) => current.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const allSelectedRows = selectedRows;
|
||||
if (!allSelectedRows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"recipient_ids",
|
||||
JSON.stringify(allSelectedRows.map((row) => row.jellyfin_id)),
|
||||
);
|
||||
formData.append("subject", subject);
|
||||
formData.append("html_body", htmlBody);
|
||||
attachments.forEach((file) => {
|
||||
formData.append("attachments", file, file.name);
|
||||
});
|
||||
|
||||
try {
|
||||
await sendUserMessage.mutateAsync(formData);
|
||||
setComposeOpen(false);
|
||||
setAttachments([]);
|
||||
setSubject("");
|
||||
setHtmlBody(DEFAULT_HTML_BODY);
|
||||
} catch {
|
||||
// Mutation state is shown inline.
|
||||
}
|
||||
};
|
||||
|
||||
// Sticky table-header base (opaque so rows don't bleed through on scroll).
|
||||
const thBase = "font-semibold sticky top-0 z-10 bg-card";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Users</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Read-only Jellyfin users with optional Jellyseerr enrichment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<UIAlert variant="destructive">
|
||||
<AlertDescription>
|
||||
Unable to load users: {(error as Error)?.message || "Unknown error"}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{data && !data.jellyseerr_configured ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is not configured in the backend yet. Check
|
||||
JELLYSEERR_URL and JELLYSEERR_API_KEY, then restart the API.
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{data?.jellyseerr_error ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Jellyseerr enrichment is unavailable: {data.jellyseerr_error}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{data?.jellyseerr_configured &&
|
||||
!data.jellyseerr_error &&
|
||||
data.enriched_count === 0 ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is connected, but no Jellyfin users were matched yet. The
|
||||
backend found {data.jellyseerr_jellyfin_user_count} Jellyfin-linked
|
||||
entries and {data.jellyseerr_user_count} Jellyseerr users.
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{queueStatusQuery.isError ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Unable to load email queue status:{" "}
|
||||
{String(
|
||||
(queueStatusQuery.error as Error)?.message || "Unknown error",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : queueBanner ? (
|
||||
<UIAlert
|
||||
variant={queueBanner.severity === "error" ? "destructive" : undefined}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{queueBanner.message}</span>
|
||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
||||
</div>
|
||||
<AlertDescription>{queueBanner.subtext}</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<MetricCard label="Total users" value={String(metrics.total)} />
|
||||
<MetricCard label="Contactable" value={String(metrics.contactable)} />
|
||||
<MetricCard label="Enriched" value={String(metrics.enriched)} />
|
||||
<MetricCard label="Admins" value={String(metrics.admins)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">User list</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredRows.length} visible of {rows.length} total
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Badge variant="outline">{selectedRows.length} selected</Badge>
|
||||
<Badge
|
||||
variant={selectedDeliverableRows.length ? "success" : "outline"}
|
||||
>
|
||||
{selectedDeliverableRows.length} deliverable
|
||||
</Badge>
|
||||
<UiButton
|
||||
variant="default"
|
||||
disabled={!selectedDeliverableRows.length}
|
||||
onClick={openCompose}
|
||||
>
|
||||
<Mail />
|
||||
Message selected
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
disabled={!selectedRows.length}
|
||||
onClick={() => setSelectedUserIds([])}
|
||||
>
|
||||
Clear selection
|
||||
</UiButton>
|
||||
<Input
|
||||
aria-label="Search"
|
||||
placeholder="Name, email, role, permission..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-full sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[660px] overflow-auto rounded-lg border">
|
||||
<Table aria-label="Users table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className={cn(thBase, "w-14 p-2")}>
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
aria-label="Select all visible users"
|
||||
onCheckedChange={(checked) =>
|
||||
toggleVisibleSelection(checked === true)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className={thBase}>User</TableHead>
|
||||
<TableHead className={thBase}>Email</TableHead>
|
||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||
Activity
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-[140px] text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead className={cn(thBase, "w-[132px] text-center")}>
|
||||
Jellyseerr
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-[120px] text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Role
|
||||
</TableHead>
|
||||
<TableHead className={thBase}>Permissions</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-24 text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Reqs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={cn(
|
||||
thBase,
|
||||
"hidden w-[120px] text-center md:table-cell",
|
||||
)}
|
||||
>
|
||||
Contact
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.map((row) => {
|
||||
const linked =
|
||||
row.jellyseerr_user_id !== null &&
|
||||
row.jellyseerr_user_id !== undefined;
|
||||
const checked = selectedIdSet.has(row.jellyfin_id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.jellyfin_id}
|
||||
data-state={
|
||||
checked || selectedUser?.jellyfin_id === row.jellyfin_id
|
||||
? "selected"
|
||||
: undefined
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSearchParams({ user: row.jellyfin_id })}
|
||||
>
|
||||
<TableCell className="w-14 p-2">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
aria-label={`Select ${userLabel(row)}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={() =>
|
||||
toggleUserSelected(row.jellyfin_id)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage
|
||||
src={row.avatar || undefined}
|
||||
alt={userLabel(row)}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{userLabel(row).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold leading-tight">
|
||||
{userLabel(row)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{row.username && row.username !== row.display_name
|
||||
? row.username
|
||||
: row.jellyfin_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="truncate font-medium">
|
||||
{row.email || "—"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={activityBadgeVariant(row.activity_label)}
|
||||
>
|
||||
{row.activity_label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center md:table-cell">
|
||||
<Badge variant="outline">{row.user_type_label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={linked ? "success" : "secondary"}>
|
||||
{linked
|
||||
? `Linked #${row.jellyseerr_user_id}`
|
||||
: "Base only"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center md:table-cell">
|
||||
<Badge variant="outline">{row.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-normal">
|
||||
{row.permissions_label}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center font-semibold md:table-cell">
|
||||
{row.request_count ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-center md:table-cell">
|
||||
<Badge
|
||||
variant={row.contactable ? "success" : "secondary"}
|
||||
>
|
||||
{row.contactable ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={Boolean(drawerModel)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSearchParams({});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="w-full gap-6 overflow-y-auto p-6 sm:max-w-[440px]"
|
||||
>
|
||||
{selectedUser && drawerModel ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="size-14">
|
||||
<AvatarImage
|
||||
src={selectedUser.avatar || undefined}
|
||||
alt={drawerModel.title}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{drawerModel.title.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-lg font-bold">
|
||||
{drawerModel.title}
|
||||
</h2>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{drawerModel.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{drawerModel.contactState.label}
|
||||
</Badge>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
aria-label="Close user details"
|
||||
onClick={() => setSearchParams({})}
|
||||
>
|
||||
<X />
|
||||
Close
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{selectedUser.user_type_label}</Badge>
|
||||
<Badge variant="default">{selectedUser.role}</Badge>
|
||||
<Badge variant="secondary">{drawerModel.syncStatus}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Identity</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{drawerModel.identity.map((field) => (
|
||||
<div key={field.label} className="flex gap-4">
|
||||
<span className="min-w-[120px] text-xs uppercase text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="break-words text-sm">{field.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Activity</h3>
|
||||
<SessionActivityPanel
|
||||
sessions={selectedUser.activity.sessions}
|
||||
selectedUserLabel={
|
||||
selectedUser.display_name ||
|
||||
selectedUser.username ||
|
||||
selectedUser.jellyfin_id
|
||||
}
|
||||
emptyMessage="No live sessions matched to this user."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Contact actions</h3>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
{drawerModel.contactState.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{drawerModel.contactActions.map((action) => (
|
||||
<UiButton
|
||||
key={action.label}
|
||||
variant="outline"
|
||||
disabled={!action.enabled}
|
||||
>
|
||||
{action.label}
|
||||
</UiButton>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{drawerModel.contactActions
|
||||
.map((action) => action.hint)
|
||||
.join(" ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">Permissions</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{drawerModel.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="secondary">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This panel is read-only for now. Communication actions will be
|
||||
added later without redesigning the list.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Dialog
|
||||
open={composeOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeCompose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
|
||||
isMobile &&
|
||||
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
|
||||
)}
|
||||
>
|
||||
<DialogHeader className="gap-1 px-4 pt-4">
|
||||
<DialogTitle className="pr-8">Message selected users</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Compose a message to the selected deliverable users.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{sendUserMessage.isPending ? (
|
||||
<Progress value={100} className="animate-pulse" />
|
||||
) : null}
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||
{sendUserMessage.isError ? (
|
||||
<UIAlert variant="destructive">
|
||||
<AlertDescription>
|
||||
Unable to send message:{" "}
|
||||
{(sendUserMessage.error as Error)?.message || "Unknown error"}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
{sendUserMessage.isSuccess ? (
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
Queued for {sendUserMessage.data.recipient_count} recipients
|
||||
{sendUserMessage.data.attachment_count
|
||||
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
|
||||
: ""}
|
||||
{sendUserMessage.data.request_id
|
||||
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
|
||||
: ""}
|
||||
.
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
{queueBanner ? (
|
||||
<UIAlert
|
||||
variant={
|
||||
queueBanner.severity === "error" ? "destructive" : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{queueBanner.message}
|
||||
</span>
|
||||
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
|
||||
</div>
|
||||
</UIAlert>
|
||||
) : null}
|
||||
|
||||
<UIAlert>
|
||||
<AlertDescription>
|
||||
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
|
||||
deliverable.
|
||||
{skippedRows.length
|
||||
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
|
||||
: ""}
|
||||
</AlertDescription>
|
||||
</UIAlert>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedDeliverableRows.map((row) => (
|
||||
<Badge key={row.jellyfin_id} variant="secondary">
|
||||
{`${userLabel(row)} <${row.email}>`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="compose-subject">Subject</Label>
|
||||
<Input
|
||||
id="compose-subject"
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => insertMarkup("<strong>", "</strong>")}
|
||||
aria-label="Bold"
|
||||
>
|
||||
<Bold />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bold</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => insertMarkup("<em>", "</em>")}
|
||||
aria-label="Italic"
|
||||
>
|
||||
<Italic />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Italic</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={addLink}
|
||||
aria-label="Link"
|
||||
>
|
||||
<Link />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Link</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List />
|
||||
</UiButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bullet list</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="compose-body">HTML message body</Label>
|
||||
<Textarea
|
||||
id="compose-body"
|
||||
ref={htmlBodyRef}
|
||||
value={htmlBody}
|
||||
onChange={(event) => setHtmlBody(event.target.value)}
|
||||
className="min-h-[260px] font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Formatting is sent as HTML; a plain-text fallback is generated
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/40 p-4">
|
||||
<p className="mb-2 text-sm font-semibold">Preview</p>
|
||||
<div className="overflow-hidden rounded-md border bg-card">
|
||||
<iframe
|
||||
title="Email preview"
|
||||
sandbox=""
|
||||
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
|
||||
style={{ width: "100%", minHeight: 220, border: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<UiButton asChild variant="outline">
|
||||
<label className="cursor-pointer">
|
||||
<Paperclip />
|
||||
Add attachment
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleAttachments}
|
||||
/>
|
||||
</label>
|
||||
</UiButton>
|
||||
{attachments.map((file, index) => (
|
||||
<Badge
|
||||
key={`${file.name}-${index}`}
|
||||
variant="secondary"
|
||||
className="gap-1 pr-1"
|
||||
>
|
||||
{file.name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
onClick={() => removeAttachment(index)}
|
||||
className="inline-flex items-center text-current [&>svg]:size-3"
|
||||
>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="m-0 border-t p-4">
|
||||
<UiButton variant="ghost" onClick={closeCompose}>
|
||||
Cancel
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="default"
|
||||
disabled={
|
||||
sendUserMessage.isPending ||
|
||||
!selectedDeliverableRows.length ||
|
||||
!subject.trim()
|
||||
}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send />
|
||||
Send message
|
||||
</UiButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Actions } from "../Actions";
|
||||
import type { SavedTask, ServiceInstance } from "../../types";
|
||||
|
||||
const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||
id: "t1",
|
||||
name: "Restart svc",
|
||||
task_type: "shell",
|
||||
content: "",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
});
|
||||
const deleteTaskMutate = vi.fn();
|
||||
const runTaskMutate = vi.fn().mockResolvedValue({});
|
||||
|
||||
let sshServices: ServiceInstance[] = [];
|
||||
let tasks: SavedTask[] = [];
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useTasks: () => ({ data: tasks }),
|
||||
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
|
||||
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
|
||||
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
|
||||
useTaskRuns: () => ({ data: { items: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: sshServices }),
|
||||
}));
|
||||
|
||||
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
|
||||
return {
|
||||
id: "s1",
|
||||
service_type: "ssh_tasks",
|
||||
name: "Box",
|
||||
config: { host: "box", username: "u" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as ServiceInstance;
|
||||
}
|
||||
|
||||
function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||
return {
|
||||
id: "t1",
|
||||
name: "Restart svc",
|
||||
task_type: "shell",
|
||||
content: "systemctl restart foo",
|
||||
enabled: true,
|
||||
default_service_id: "",
|
||||
notes: "",
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
} as SavedTask;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveTaskMutate.mockClear();
|
||||
deleteTaskMutate.mockClear();
|
||||
runTaskMutate.mockClear();
|
||||
sshServices = [];
|
||||
tasks = [];
|
||||
});
|
||||
|
||||
describe("Actions", () => {
|
||||
it("shows the empty state and creates a task via the editor dialog", async () => {
|
||||
render(<Actions />);
|
||||
|
||||
expect(screen.getByText("No action selected")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add action" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
|
||||
// Editor dialog opened (Name field is unique to the editor).
|
||||
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
|
||||
// Controlled input parity: name + default shell type flow through.
|
||||
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
|
||||
|
||||
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
|
||||
const saved = saveTaskMutate.mock.calls[0][0];
|
||||
expect(saved.name).toBe("Restart svc");
|
||||
expect(saved.task_type).toBe("shell");
|
||||
expect(saved.default_service_id).toBe("");
|
||||
});
|
||||
|
||||
it("disables the Run button until a run service is selected", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
// Selecting a saved task tab exposes the detail + Run control.
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
|
||||
const runButton = screen.getByRole("button", { name: "Run action" });
|
||||
expect(runButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("runs a task on the selected SSH task service", async () => {
|
||||
sshServices = [sshService()];
|
||||
tasks = [task()];
|
||||
render(<Actions />);
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", { name: "Run on SSH task service" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("option", { name: "Box" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(runTaskMutate).toHaveBeenCalledTimes(1);
|
||||
expect(runTaskMutate).toHaveBeenCalledWith({
|
||||
taskId: "t1",
|
||||
serviceId: "s1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Applications } from "../Applications";
|
||||
|
||||
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
|
||||
// so this slice-4 test stays focused on the migrated Applications shell and
|
||||
// does not pull the still-MUI DataGrid into the jsdom render.
|
||||
vi.mock("../Media", () => ({
|
||||
Media: () => <div data-testid="media-child">Media</div>,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "Main",
|
||||
enabled: true,
|
||||
services: ["jellyfin"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: [
|
||||
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({
|
||||
data: { movies: 10, series: 5, episodes: 100 },
|
||||
}),
|
||||
useLibraries: () => ({
|
||||
data: [
|
||||
{ library: "Movies", total: 10, movies: 10, series: 0 },
|
||||
{ library: "Shows", total: 5, movies: 0, series: 5 },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Applications", () => {
|
||||
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
|
||||
render(<Applications />);
|
||||
|
||||
// Library stats header.
|
||||
expect(screen.getByText("Library stats")).toBeInTheDocument();
|
||||
|
||||
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
|
||||
expect(screen.getByText("115")).toBeInTheDocument();
|
||||
expect(screen.getByText("Episodes")).toBeInTheDocument();
|
||||
|
||||
// Library rows render their per-library totals (unique strings).
|
||||
expect(
|
||||
screen.getByText(/Total 10 · Movies 10 · Series 0/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Total 5 · Movies 0 · Series 5/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Tabs present.
|
||||
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
|
||||
|
||||
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
|
||||
expect(screen.getByTestId("media-child")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,9 @@ vi.mock("../../hooks/useSettings", () => ({
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||
const deleteShortcutMutate = vi.fn();
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileBrowser } from "../FileBrowser.impl";
|
||||
import type { DirectoryListing, MonitoringMachine } from "../../types";
|
||||
|
||||
// usePersistentState (browserState) reads/writes localStorage; clear between tests
|
||||
// so the selectedPath / currentDir state never leaks across cases.
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
function machineFixture(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["files", "monitoring"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
notes: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function listingFixture(
|
||||
entries: {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}[],
|
||||
): DirectoryListing {
|
||||
return { path: "/", entries, count: entries.length };
|
||||
}
|
||||
|
||||
let listing: DirectoryListing;
|
||||
let machines: MonitoringMachine[];
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useFiles", () => ({
|
||||
useDirectoryListing: () => ({
|
||||
data: listing,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
|
||||
useJobTemplates: () => ({ data: [] }),
|
||||
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: machines }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
machines = [machineFixture()];
|
||||
listing = listingFixture([
|
||||
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
|
||||
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
|
||||
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
|
||||
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => h.textContent);
|
||||
// The leading selection column header is empty (checkbox); the 5 data
|
||||
// columns are Type, Name, Ext, Size, Modified in that order.
|
||||
expect(headers).toEqual(
|
||||
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
|
||||
);
|
||||
expect(headers.filter((h) => h === "Type").length).toBe(1);
|
||||
expect(headers.filter((h) => h === "Modified").length).toBe(1);
|
||||
});
|
||||
|
||||
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
// The selected-file path surfaces in the Browser status caption once chosen.
|
||||
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByText("video.mkv"));
|
||||
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
|
||||
|
||||
// A recognized video file enters the ffprobe branch; with empty ffprobe
|
||||
// data it shows the "No ffprobe data available." status (proving the
|
||||
// selected file routed into the Media info preview flow).
|
||||
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
|
||||
render(<FileBrowser />);
|
||||
|
||||
await userEvent.click(screen.getByText("movies"));
|
||||
// After navigating into /movies, the status caption shows the new cwd and
|
||||
// NO "Selected:" segment (directories are opened, not selected for preview).
|
||||
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Selected:/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Media } from "../Media";
|
||||
import type {
|
||||
MediaIndexStatus,
|
||||
MediaItem,
|
||||
MediaQueryResponse,
|
||||
MonitoringMachine,
|
||||
} from "../../types";
|
||||
|
||||
// Shared navigate mock so the row-click test can assert the call. The vi.mock
|
||||
// factory is hoisted above this const, but it only closes over `navigate`
|
||||
// lazily (the arrow runs at render time, well after init) — no TDZ access.
|
||||
const navigate = vi.fn();
|
||||
|
||||
function machineFixture(
|
||||
overrides: Partial<MonitoringMachine> = {},
|
||||
): MonitoringMachine {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
mode: "local",
|
||||
enabled: true,
|
||||
services: ["jellyfin", "monitoring"],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
key_directory: "",
|
||||
key_name: "",
|
||||
ssh_key_id: "",
|
||||
ssh_private_key_set: false,
|
||||
ssh_private_key_passphrase_set: false,
|
||||
password_set: false,
|
||||
notes: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statusFixture(
|
||||
overrides: Partial<MediaIndexStatus> = {},
|
||||
): MediaIndexStatus {
|
||||
return {
|
||||
exists: true,
|
||||
item_count: 2,
|
||||
updated_at: 1,
|
||||
updated_at_label: "now",
|
||||
build_duration_seconds: null,
|
||||
build_running: false,
|
||||
build_stage: "",
|
||||
build_message: "",
|
||||
build_progress: null,
|
||||
build_items_processed: 0,
|
||||
build_items_total: 0,
|
||||
build_current_library: "",
|
||||
build_library_index: 0,
|
||||
build_libraries_total: 0,
|
||||
build_library_progress: null,
|
||||
build_library_items_processed: 0,
|
||||
build_library_items_total: 0,
|
||||
build_elapsed_seconds: null,
|
||||
build_eta_seconds: null,
|
||||
build_library_elapsed_seconds: null,
|
||||
build_library_eta_seconds: null,
|
||||
build_cancel_requested: false,
|
||||
build_pid: null,
|
||||
build_error: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
series: "",
|
||||
season: "",
|
||||
episode: null,
|
||||
type: "Movie",
|
||||
year: 2010,
|
||||
runtime_min: 148,
|
||||
size: "12.4 GB",
|
||||
bitrate: "35.0 Mbps",
|
||||
hdr: "HDR10",
|
||||
video: "HEVC",
|
||||
resolution: "4K",
|
||||
date_added: "2024-01-01",
|
||||
library: "Movies",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let status: MediaIndexStatus;
|
||||
let queryResult: MediaQueryResponse;
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigate,
|
||||
useSearchParams: () => [
|
||||
new URLSearchParams("jellyfin_service_id=jfs1"),
|
||||
vi.fn(),
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMedia", () => ({
|
||||
useMediaStatus: () => ({ data: status }),
|
||||
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
|
||||
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSettings", () => ({
|
||||
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: [
|
||||
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({ data: undefined }),
|
||||
useLibraries: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
navigate.mockClear();
|
||||
status = statusFixture();
|
||||
queryResult = {
|
||||
items: [
|
||||
mediaItem({
|
||||
id: "1",
|
||||
title: "Inception",
|
||||
path: "/media/movies/Inception.mkv",
|
||||
}),
|
||||
mediaItem({
|
||||
id: "2",
|
||||
title: "Matrix",
|
||||
path: "/media/movies/Matrix.mkv",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
|
||||
|
||||
const toggleable = screen
|
||||
.getAllByRole("menuitemcheckbox")
|
||||
.map((item) => (item.textContent ?? "").trim());
|
||||
expect([...toggleable].sort()).toEqual(
|
||||
[
|
||||
"title",
|
||||
"series",
|
||||
"season",
|
||||
"episode",
|
||||
"type",
|
||||
"year",
|
||||
"runtime_min",
|
||||
"size",
|
||||
"bitrate",
|
||||
"hdr",
|
||||
"video",
|
||||
"resolution",
|
||||
"date_added",
|
||||
"library",
|
||||
"path",
|
||||
].sort(),
|
||||
);
|
||||
// The leading selection column is never toggleable (enableHiding=false).
|
||||
expect(toggleable).toHaveLength(15);
|
||||
expect(toggleable).not.toContain("__select__");
|
||||
});
|
||||
|
||||
it("renders the 15 data column headers", () => {
|
||||
render(<Media />);
|
||||
const headers = screen
|
||||
.getAllByRole("columnheader")
|
||||
.map((h) => (h.textContent ?? "").trim());
|
||||
for (const expected of [
|
||||
"Title",
|
||||
"Series",
|
||||
"Season",
|
||||
"Episode",
|
||||
"Type",
|
||||
"Year",
|
||||
"Runtime",
|
||||
"Size",
|
||||
"Bitrate",
|
||||
"HDR",
|
||||
"Video codec",
|
||||
"Resolution",
|
||||
"Date added",
|
||||
"Library",
|
||||
"Path",
|
||||
]) {
|
||||
expect(headers).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("navigates to the file browser at the item path on row click", async () => {
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByText("Inception"));
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT navigate when toggling a row selection checkbox", async () => {
|
||||
render(<Media />);
|
||||
|
||||
const firstCheckbox = screen.getAllByRole("checkbox", {
|
||||
name: "Select row",
|
||||
})[0];
|
||||
await userEvent.click(firstCheckbox);
|
||||
expect(firstCheckbox).toBeChecked();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the server-driven pagination total + page controls", () => {
|
||||
render(<Media />);
|
||||
|
||||
// DataTable manual-pagination footer surfaces the server total + pager.
|
||||
// ("Page 1 of 1" also appears in the page caption, so match all and assert
|
||||
// the pager footer text is present alongside the unique total.)
|
||||
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables Build index while a build is running", () => {
|
||||
status = statusFixture({ build_running: true });
|
||||
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
|
||||
// Stop + Force stop surface only while running.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Stop build" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Force stop" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { NamedDashboardPage } from "../NamedDashboardPage";
|
||||
|
||||
vi.mock("../../hooks/useDashboards", () => ({
|
||||
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
|
||||
}));
|
||||
|
||||
import { useDashboardBySlug } from "../../hooks/useDashboards";
|
||||
|
||||
function renderPage(slug: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/d/${slug}`]}>
|
||||
<Routes>
|
||||
<Route path="/d/:slug" element={<NamedDashboardPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NamedDashboardPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders loading state", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
} as never);
|
||||
renderPage("storage");
|
||||
// Skeleton renders during load.
|
||||
expect(document.querySelector(".h-32")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 404 when dashboard not found", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
} as never);
|
||||
renderPage("nonexistent");
|
||||
expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pinned links for a known dashboard", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: {
|
||||
id: "d1",
|
||||
label: "Storage",
|
||||
slug: "storage",
|
||||
sort_order: 0,
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
type: "link",
|
||||
label: "My Jellyfin",
|
||||
target: "/services/jellyfin/svc-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as never);
|
||||
renderPage("storage");
|
||||
expect(screen.getByText("Storage")).toBeInTheDocument();
|
||||
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when dashboard has no items", () => {
|
||||
vi.mocked(useDashboardBySlug).mockReturnValue({
|
||||
data: {
|
||||
id: "d2",
|
||||
label: "Empty",
|
||||
slug: "empty",
|
||||
sort_order: 0,
|
||||
payload: {},
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as never);
|
||||
renderPage("empty");
|
||||
expect(screen.getByText("Empty")).toBeInTheDocument();
|
||||
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServicePage } from "../ServicePage";
|
||||
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "svc-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config: { base_url: "https://jf.example.com", user_id: "u1" },
|
||||
secrets_set: { api_key: true },
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
const typeInfo: ServiceTypeInfo = {
|
||||
service_type: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
description: "Media server",
|
||||
config_schema: {
|
||||
type: "object",
|
||||
properties: { base_url: { type: "string" } },
|
||||
},
|
||||
secret_fields: [{ key: "api_key", label: "API key", required: false }],
|
||||
widget_kinds: [],
|
||||
};
|
||||
|
||||
const secondInstance: ServiceInstance = {
|
||||
...instance,
|
||||
id: "svc-2",
|
||||
name: "Backup Jellyfin",
|
||||
};
|
||||
|
||||
const saveMutateAsync = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
|
||||
?.__svcInstances ?? [instance],
|
||||
}),
|
||||
useServiceTypes: () => ({ data: [typeInfo] }),
|
||||
useSaveServiceInstance: () => ({
|
||||
mutateAsync: saveMutateAsync,
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../integrations/registry", () => ({
|
||||
getServiceBinding: () => ({
|
||||
name: "Jellyfin",
|
||||
description: "Media server",
|
||||
widgets: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderServicePage(path: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ServicePage tab skeleton", () => {
|
||||
it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [sshInstance];
|
||||
renderServicePage("/services/ssh_tasks/ssh-1");
|
||||
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Media" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows instance switcher when >1 sibling of same type", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance, secondInstance];
|
||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
||||
// The switcher renders as a Select trigger (combobox).
|
||||
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides instance switcher when only one instance", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance];
|
||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
||||
// No select trigger rendered (only one instance).
|
||||
expect(
|
||||
container.querySelector("[role='combobox']"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
|
||||
const { userEvent } = await import("@testing-library/user-event");
|
||||
const user = userEvent.setup();
|
||||
saveMutateAsync.mockReset();
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
|
||||
// Open the Config tab and type a new api_key.
|
||||
await user.click(screen.getByRole("tab", { name: "Config" }));
|
||||
const secretInput = screen.getByLabelText("api_key");
|
||||
await user.type(secretInput, "new-secret-value");
|
||||
|
||||
// Save and assert the typed secret is in the payload (not secrets: {}).
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(saveMutateAsync).toHaveBeenCalledTimes(1);
|
||||
const input = saveMutateAsync.mock.calls[0][0] as {
|
||||
secrets: Record<string, string>;
|
||||
};
|
||||
expect(input.secrets).toEqual({ api_key: "new-secret-value" });
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UsersPage } from "../UsersPage.impl";
|
||||
import { TooltipProvider } from "../../components/ui/tooltip";
|
||||
import type {
|
||||
NowPlayingSession,
|
||||
UserDirectoryItem,
|
||||
UserDirectoryResponse,
|
||||
} from "../../types";
|
||||
|
||||
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
|
||||
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
|
||||
beforeEach(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
// The compose formatting actions defer a focus/selection restore via
|
||||
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
|
||||
// synchronously, so make it synchronous so the slice-6b compose test can
|
||||
// observe the html-body value update.
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
});
|
||||
|
||||
// Keep the drawer's nested session panel out of the DOM under test.
|
||||
vi.mock("../../components/SessionActivityPanel", () => ({
|
||||
SessionActivityPanel: ({
|
||||
selectedUserLabel,
|
||||
}: {
|
||||
selectedUserLabel: string;
|
||||
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
|
||||
}));
|
||||
|
||||
let users: UserDirectoryItem[] = [];
|
||||
let activity: NowPlayingSession[] = [];
|
||||
|
||||
function directoryResponse(): UserDirectoryResponse {
|
||||
return {
|
||||
items: users,
|
||||
total: users.length,
|
||||
jellyseerr_configured: true,
|
||||
jellyseerr_available: true,
|
||||
jellyseerr_error: "",
|
||||
jellyseerr_jellyfin_user_count: 0,
|
||||
jellyseerr_user_count: 0,
|
||||
enriched_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../hooks/useUsers", () => ({
|
||||
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
|
||||
}));
|
||||
vi.mock("../../hooks/useDashboard", () => ({
|
||||
useActivity: () => ({ data: activity }),
|
||||
}));
|
||||
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
|
||||
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
|
||||
}));
|
||||
vi.mock("../../hooks/useSendUserMessage", () => ({
|
||||
useSendUserMessage: () => ({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
reset: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
|
||||
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
|
||||
let currentParams: Record<string, string> = {};
|
||||
const setSearchParams = vi.fn((next: Record<string, string>) => {
|
||||
currentParams = { ...next };
|
||||
});
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
|
||||
}));
|
||||
|
||||
function userFixture(
|
||||
overrides: Partial<UserDirectoryItem> = {},
|
||||
): UserDirectoryItem {
|
||||
return {
|
||||
jellyfin_id: "u1",
|
||||
username: "alice",
|
||||
display_name: "Alice",
|
||||
email: "alice@example.com",
|
||||
email_source: "jellyfin",
|
||||
avatar: "",
|
||||
avatar_source: "",
|
||||
contactable: true,
|
||||
source: "jellyfin",
|
||||
source_summary: "",
|
||||
name_source: "jellyfin",
|
||||
access_source: "jellyfin",
|
||||
jellyseerr_user_id: null,
|
||||
jellyseerr_username: "",
|
||||
user_type: 1,
|
||||
user_type_label: "User",
|
||||
role: "admin",
|
||||
permissions: 1,
|
||||
permissions_label: "Administrator",
|
||||
request_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
users = [];
|
||||
activity = [];
|
||||
currentParams = {};
|
||||
setSearchParams.mockClear();
|
||||
});
|
||||
|
||||
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
|
||||
it("renders the directory table and metric counts", () => {
|
||||
users = [userFixture()];
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText("Total users")).toBeInTheDocument();
|
||||
expect(screen.getByText("User list")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles row selection and reflects the selected-count badge", async () => {
|
||||
users = [
|
||||
userFixture({ jellyfin_id: "u1" }),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
|
||||
// Selection-across-pagination: toggling a row updates the selected-id set.
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
|
||||
// Toggling again removes it (the set survives, membership flips).
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
expect(screen.getByText("0 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects all visible rows via the header select-all checkbox", async () => {
|
||||
users = [
|
||||
userFixture({ jellyfin_id: "u1" }),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select all visible users" }),
|
||||
);
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
|
||||
users = [userFixture({ jellyfin_id: "u1" })];
|
||||
render(<UsersPage />);
|
||||
|
||||
// Clicking the row body (not the checkbox) opens the detail drawer.
|
||||
await userEvent.click(screen.getByText("Alice"));
|
||||
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
|
||||
});
|
||||
|
||||
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
|
||||
users = [
|
||||
userFixture({
|
||||
jellyfin_id: "u1",
|
||||
username: "alice",
|
||||
display_name: "Alice",
|
||||
}),
|
||||
userFixture({
|
||||
jellyfin_id: "u2",
|
||||
username: "bob",
|
||||
display_name: "Bob",
|
||||
email: "bob@example.com",
|
||||
}),
|
||||
];
|
||||
activity = [
|
||||
{
|
||||
user: "alice",
|
||||
title: "Movie",
|
||||
type: "Movie",
|
||||
state: "playing",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "Web",
|
||||
session_id: "s1",
|
||||
},
|
||||
{
|
||||
user: "bob",
|
||||
title: "Show",
|
||||
type: "Episode",
|
||||
state: "paused",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "TV",
|
||||
session_id: "s2",
|
||||
},
|
||||
];
|
||||
render(<UsersPage />);
|
||||
|
||||
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
|
||||
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
|
||||
"success",
|
||||
);
|
||||
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the user detail drawer (Sheet) when a user is selected", () => {
|
||||
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||
currentParams = { user: "u1" };
|
||||
render(<UsersPage />);
|
||||
|
||||
// buildUserDrawerModel title = display name; rendered as the drawer heading.
|
||||
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
|
||||
// Drawer sections (identity / contact actions) + the activity panel render.
|
||||
expect(screen.getByText("Identity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Contact actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
|
||||
it("opens compose and inserts bold markup into the html body", async () => {
|
||||
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
|
||||
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
|
||||
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsersPage />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Select a deliverable user so the "Message selected" button enables.
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Select Alice" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Message selected" }),
|
||||
);
|
||||
|
||||
// Compose dialog opens (shadcn Dialog family).
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Message selected users" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Bold action wraps the cursor selection in <strong></strong> via the
|
||||
// preserved insertMarkup helper (markup insertion actions parity).
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
|
||||
|
||||
const body = screen.getByRole("textbox", {
|
||||
name: "HTML message body",
|
||||
}) as HTMLTextAreaElement;
|
||||
expect(body.value).toContain("<strong>");
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,25 @@
|
||||
/**
|
||||
* ActionsTab — operational content for the ssh_tasks service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
|
||||
* provides the active ssh_tasks service id, which is used as the default run
|
||||
* service. The page-level header is removed (the service page provides it).
|
||||
* The task editor dialog, saved-task rail, and run history are preserved.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
|
||||
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
|
||||
import {
|
||||
useDeleteTask,
|
||||
useRunTask,
|
||||
useSaveTask,
|
||||
useTaskRuns,
|
||||
useTasks,
|
||||
} from "../hooks/useSettings";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { DialogFooter } from "../components/DialogFooter";
|
||||
import { HoverEditButton } from "../components/HoverEditButton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { SelectionRailCard } from "../components/SelectionRailCard";
|
||||
} from "../../hooks/useSettings";
|
||||
import { DialogFooter } from "../../components/DialogFooter";
|
||||
import { HoverEditButton } from "../../components/HoverEditButton";
|
||||
import { SectionCard } from "../../components/SectionCard";
|
||||
import { SelectionRailCard } from "../../components/SelectionRailCard";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,13 +44,8 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
// Radix Select disallows empty-string item values; the "None" option maps to
|
||||
// this sentinel and converts back to "" at the draft boundary.
|
||||
const NONE = "__none__";
|
||||
|
||||
type ActionTab = "new" | string;
|
||||
|
||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||
function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
@@ -106,16 +108,11 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
services,
|
||||
onChange,
|
||||
}: {
|
||||
task: SavedTaskInput;
|
||||
services: ServiceInstance[];
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
}) {
|
||||
const selectedService = services.find(
|
||||
(service) => service.id === task.default_service_id,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
@@ -124,11 +121,7 @@ function TaskEditor({
|
||||
</p>
|
||||
<Badge variant="outline">{task.task_type}</Badge>
|
||||
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||
{selectedService && (
|
||||
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField label="Name" htmlFor="task-name">
|
||||
<Input
|
||||
@@ -159,31 +152,6 @@ function TaskEditor({
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<FormField label="Default SSH task service">
|
||||
<Select
|
||||
value={task.default_service_id || NONE}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
...task,
|
||||
default_service_id: value === NONE ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Notes">
|
||||
<Input
|
||||
@@ -216,7 +184,6 @@ function TaskDialog({
|
||||
open,
|
||||
task,
|
||||
baseline,
|
||||
services,
|
||||
onClose,
|
||||
onChange,
|
||||
onSave,
|
||||
@@ -225,7 +192,6 @@ function TaskDialog({
|
||||
open: boolean;
|
||||
task: SavedTaskInput;
|
||||
baseline: SavedTaskInput;
|
||||
services: ServiceInstance[];
|
||||
onClose: () => void;
|
||||
onChange: (task: SavedTaskInput) => void;
|
||||
onSave: () => void;
|
||||
@@ -235,12 +201,10 @@ function TaskDialog({
|
||||
if (
|
||||
!sameTask(task, baseline) &&
|
||||
!window.confirm("Discard unsaved changes?")
|
||||
) {
|
||||
)
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -254,10 +218,10 @@ function TaskDialog({
|
||||
<DialogDescription>
|
||||
Save a reusable server task. Shell commands run via{" "}
|
||||
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||
Runs execute on the selected SSH task service instance.
|
||||
Runs execute on this SSH task service instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskEditor task={task} services={services} onChange={onChange} />
|
||||
<TaskEditor task={task} onChange={onChange} />
|
||||
<DialogFooter
|
||||
onCancel={requestClose}
|
||||
cancelLabel="Cancel"
|
||||
@@ -277,8 +241,7 @@ function TaskDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: tasks = [] } = useTasks();
|
||||
const saveTask = useSaveTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
@@ -288,9 +251,11 @@ export function Actions() {
|
||||
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
||||
emptyTask(),
|
||||
);
|
||||
const [runServiceId, setRunServiceId] = useState("");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
// Default to this instance's service id for task runs.
|
||||
const runServiceId = instance.id;
|
||||
|
||||
const selectedTask = useMemo(
|
||||
() => tasks.find((task) => task.id === tab) ?? null,
|
||||
[tasks, tab],
|
||||
@@ -303,14 +268,6 @@ export function Actions() {
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const createNew = () => {
|
||||
const initial = emptyTask();
|
||||
setDraft(initial);
|
||||
setDraftBaseline(initial);
|
||||
setRunServiceId(sshServices[0]?.id || "");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
const saved = await saveTask.mutateAsync(draft);
|
||||
setTab(saved.id);
|
||||
@@ -328,20 +285,8 @@ export function Actions() {
|
||||
setDraftBaseline(nextDraft);
|
||||
};
|
||||
|
||||
const editingTask = selectedTask;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Actions</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Save reusable server tasks and switch between them with tabs.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{saveTask.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||
@@ -368,7 +313,7 @@ export function Actions() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={createNew}
|
||||
onClick={() => openEdit(emptyTask())}
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
@@ -406,23 +351,23 @@ export function Actions() {
|
||||
</SelectionRailCard>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{editingTask ? (
|
||||
{selectedTask ? (
|
||||
<SectionCard
|
||||
title={editingTask.name}
|
||||
title={selectedTask.name}
|
||||
description="Open the editor popup to modify this action."
|
||||
action={
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openEdit(initialFromTask(editingTask))}
|
||||
onClick={() => openEdit(initialFromTask(selectedTask))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
disabled={runTask.isPending || !runServiceId}
|
||||
disabled={runTask.isPending}
|
||||
onClick={async () => {
|
||||
await runTask.mutateAsync({
|
||||
taskId: editingTask.id,
|
||||
taskId: selectedTask.id,
|
||||
serviceId: runServiceId,
|
||||
});
|
||||
}}
|
||||
@@ -432,35 +377,7 @@ export function Actions() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormField
|
||||
label="Run on SSH task service"
|
||||
htmlFor="run-service-id"
|
||||
>
|
||||
<Select
|
||||
value={runServiceId}
|
||||
onValueChange={(value) => setRunServiceId(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="run-service-id"
|
||||
className="min-w-[240px]"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue placeholder="Select service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-sm font-semibold">Recent runs</p>
|
||||
{selectedRuns.data?.items?.length ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -509,25 +426,16 @@ export function Actions() {
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="What this panel shows">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved actions stay on the left rail, while details, run
|
||||
controls, and recent history appear here.
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<SectionCard
|
||||
title="No action selected"
|
||||
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
|
||||
>
|
||||
{tasks[0] && (
|
||||
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||
Select first action
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -536,7 +444,6 @@ export function Actions() {
|
||||
open={editOpen}
|
||||
task={draft}
|
||||
baseline={draftBaseline}
|
||||
services={sshServices}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onChange={setDraft}
|
||||
onSave={saveDraft}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Alertmanager Alerts tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Lifts the Alertmanager alerts content from the old cross-service
|
||||
* ObservabilityPage into an instance-scoped tab. Renders the active-alert
|
||||
* summary (total + by severity) and the expandable alert list.
|
||||
*
|
||||
* The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global /
|
||||
* first-configured for now — they don't accept a service_id yet. Wiring
|
||||
* `instance.id` into them is a documented follow-up once the hooks gain the
|
||||
* parameter. The `instance` prop is accepted for future scoping.
|
||||
*/
|
||||
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
|
||||
import {
|
||||
useAlertmanagerAlerts,
|
||||
useAlertmanagerStatus,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { AlertmanagerAlert, ServiceInstance } from "../../types";
|
||||
|
||||
function severityVariant(
|
||||
severity: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "default";
|
||||
case "info":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-medium text-sm">{alert.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={severityVariant(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{alert.summary || alert.description}
|
||||
</div>
|
||||
{alert.active_since && (
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">
|
||||
Since {new Date(alert.active_since).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
|
||||
{alert.description && (
|
||||
<div>
|
||||
<span className="font-medium">Description:</span>{" "}
|
||||
{alert.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{alert.job_name && (
|
||||
<div>
|
||||
<span className="font-medium">Job:</span> {alert.job_name}
|
||||
</div>
|
||||
)}
|
||||
{alert.category && (
|
||||
<div>
|
||||
<span className="font-medium">Category:</span> {alert.category}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">State:</span> {alert.state}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Since:</span>{" "}
|
||||
{alert.active_since
|
||||
? new Date(alert.active_since).toLocaleString()
|
||||
: "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{Object.entries(alert.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="secondary" className="text-[10px]">
|
||||
{key}={value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
|
||||
// Global / first-configured hooks for now; instance.id scoping is a
|
||||
// follow-up (see file docstring).
|
||||
void instance;
|
||||
|
||||
const {
|
||||
data: alertsSummary,
|
||||
isLoading: alertsLoading,
|
||||
error: alertsError,
|
||||
} = useAlertmanagerAlerts();
|
||||
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: statusLoading
|
||||
? "checking…"
|
||||
: "unreachable";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Bell className="h-4 w-4" />
|
||||
Alertmanager {statusDetail}
|
||||
</div>
|
||||
|
||||
{alertsError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load alerts</AlertTitle>
|
||||
<AlertDescription>{alertsError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Active Alerts ({alertsSummary?.total ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{alertsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : !alertsSummary || alertsSummary.total === 0 ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Inbox className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No active alerts</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Everything looks quiet. Firing alerts will appear here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{alertsSummary.alerts.map((alert, idx) => (
|
||||
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
|
||||
))}
|
||||
{alertsSummary.total > alertsSummary.alerts.length && (
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
{alertsSummary.total - alertsSummary.alerts.length} more alert
|
||||
{alertsSummary.total - alertsSummary.alerts.length === 1
|
||||
? ""
|
||||
: "s"}{" "}
|
||||
in Alertmanager
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+233
-280
@@ -1,9 +1,23 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
/**
|
||||
* FilesTab — operational content for the ssh_tasks service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
|
||||
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
|
||||
* instance id (from the `instance` prop) replaces the machine_id. The initial
|
||||
* path is read from `?path=` search param for deep-link support (resolves the
|
||||
* MediaTab row-click navigation from slice 5). Everything else — directory
|
||||
* listing, path bar, ffprobe preview, job execution — is preserved.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
MobileCardRow,
|
||||
type MobileCardField,
|
||||
} from "@/components/ui/mobile-card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -16,17 +30,27 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import {
|
||||
useDirectoryListing,
|
||||
useFfprobe,
|
||||
useJobTemplates,
|
||||
useRunJob,
|
||||
} from "../hooks/useFiles";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { TabbedCard } from "../components/TabbedCard";
|
||||
} from "../../hooks/useFiles";
|
||||
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||
import { SectionCard } from "../../components/SectionCard";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { useIsMobile } from "../../hooks/useIsMobile";
|
||||
|
||||
// Mobile card fields (mobile-parity pattern).
|
||||
const fileCardFields: MobileCardField<DisplayRow>[] = [
|
||||
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
|
||||
{ key: "type", label: "Type", render: (r) => r.type },
|
||||
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
|
||||
];
|
||||
|
||||
// --- Types (lifted verbatim) ---
|
||||
|
||||
interface DisplayRow {
|
||||
id: string;
|
||||
@@ -78,6 +102,8 @@ interface FfprobeData {
|
||||
streams?: FfprobeStream[];
|
||||
}
|
||||
|
||||
// --- Helpers (lifted verbatim) ---
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "-";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -149,9 +175,8 @@ function isVideoFile(name: string): boolean {
|
||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
// Design §3.2: referentially-stable column defs (a new array each render would
|
||||
// destabilize the TanStack table instance and drop controlled selection).
|
||||
// Visibility-only: no sorting, no sizing/resizing (design §3.3).
|
||||
// --- Column defs (lifted verbatim) ---
|
||||
|
||||
const fileColumns: ColumnDef<DisplayRow>[] = [
|
||||
{
|
||||
accessorKey: "type",
|
||||
@@ -182,7 +207,9 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
|
||||
// --- State + helpers (lifted) ---
|
||||
|
||||
const FILE_TAB_STATE_KEY = "manage.files.tabState";
|
||||
|
||||
type FileBrowserState = {
|
||||
currentDir: string;
|
||||
@@ -200,6 +227,8 @@ function defaultFileBrowserState(): FileBrowserState {
|
||||
};
|
||||
}
|
||||
|
||||
// --- Ffprobe rendering (lifted verbatim) ---
|
||||
|
||||
function FfprobeChip({
|
||||
children,
|
||||
variant = "outline",
|
||||
@@ -217,15 +246,9 @@ function StreamBlock({ children }: { children: React.ReactNode }) {
|
||||
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
const format = data.format ?? {};
|
||||
const streams = data.streams ?? [];
|
||||
const videoStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "video",
|
||||
);
|
||||
const audioStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "audio",
|
||||
);
|
||||
const subtitleStreams = streams.filter(
|
||||
(stream) => stream.codec_type === "subtitle",
|
||||
);
|
||||
const videoStreams = streams.filter((s) => s.codec_type === "video");
|
||||
const audioStreams = streams.filter((s) => s.codec_type === "audio");
|
||||
const subtitleStreams = streams.filter((s) => s.codec_type === "subtitle");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -233,7 +256,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
<div className="text-base font-semibold">ffprobe details</div>
|
||||
<div className="text-xs text-muted-foreground">{path}</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="text-sm font-semibold">Container / format</div>
|
||||
@@ -269,11 +291,9 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="text-sm font-semibold">Streams</div>
|
||||
|
||||
{videoStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Video streams</div>
|
||||
@@ -323,9 +343,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.width && stream.height && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`${stream.width}×${stream.height}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">{`${stream.width}×${stream.height}`}</FfprobeChip>
|
||||
)}
|
||||
{stream.pix_fmt && (
|
||||
<FfprobeChip variant="outline">
|
||||
@@ -333,14 +351,10 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</FfprobeChip>
|
||||
)}
|
||||
{stream.display_aspect_ratio && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`DAR ${stream.display_aspect_ratio}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">{`DAR ${stream.display_aspect_ratio}`}</FfprobeChip>
|
||||
)}
|
||||
{stream.sample_aspect_ratio && (
|
||||
<FfprobeChip variant="outline">
|
||||
{`SAR ${stream.sample_aspect_ratio}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip variant="outline">{`SAR ${stream.sample_aspect_ratio}`}</FfprobeChip>
|
||||
)}
|
||||
{stream.level !== undefined &&
|
||||
stream.level !== null && (
|
||||
@@ -382,7 +396,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Audio streams</div>
|
||||
@@ -431,7 +444,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subtitleStreams.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -464,7 +476,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streams.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No streams found.
|
||||
@@ -472,16 +483,16 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{Object.keys(format.tags ?? {}).length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold">Tags</div>
|
||||
<div className="flex flex-row flex-wrap gap-1.5">
|
||||
{Object.entries(format.tags ?? {}).map(([key, value]) => (
|
||||
<FfprobeChip key={key} variant="outline">
|
||||
{`${key}: ${value}`}
|
||||
</FfprobeChip>
|
||||
<FfprobeChip
|
||||
key={key}
|
||||
variant="outline"
|
||||
>{`${key}: ${value}`}</FfprobeChip>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -491,56 +502,36 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function InfoAlert({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{children}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
// --- Component ---
|
||||
|
||||
export function FileBrowser() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
export function FilesTab({ instance }: { instance: ServiceInstance }) {
|
||||
const isMobile = useIsMobile();
|
||||
const machineId = instance.id;
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedPath = searchParams.get("path");
|
||||
const [columnVisibility, setColumnVisibility] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const { data: machines } = useMonitoringSettings();
|
||||
const fileMachines = useMemo(
|
||||
() =>
|
||||
(machines ?? []).filter(
|
||||
(machine) =>
|
||||
machine.enabled &&
|
||||
(machine.services.includes("files") ||
|
||||
machine.services.includes("monitoring")),
|
||||
),
|
||||
[machines],
|
||||
);
|
||||
const initialRequestedPath = searchParams.get("path");
|
||||
const initialMachineId =
|
||||
searchParams.get("machine_id") || fileMachines[0]?.id || "";
|
||||
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
|
||||
FILE_BROWSER_STATE_KEY,
|
||||
`${FILE_TAB_STATE_KEY}.${instance.id}`,
|
||||
() => {
|
||||
const requestedPath = initialRequestedPath ?? "/";
|
||||
const path = requestedPath ?? "/";
|
||||
const selectedPath =
|
||||
requestedPath !== "/" &&
|
||||
(isVideoFile(requestedPath) || requestedPath.includes("."))
|
||||
? requestedPath.replace(/\/+$/, "")
|
||||
path !== "/" && (isVideoFile(path) || path.includes("."))
|
||||
? path.replace(/\/+$/, "")
|
||||
: null;
|
||||
const currentDir = selectedPath
|
||||
? selectedPath.replace(/\/[^/]+$/, "") || "/"
|
||||
: requestedPath.replace(/\/+$/, "") || "/";
|
||||
: path.replace(/\/+$/, "") || "/";
|
||||
return {
|
||||
...defaultFileBrowserState(),
|
||||
currentDir,
|
||||
pathInput: requestedPath || currentDir,
|
||||
pathInput: path || currentDir,
|
||||
selectedPath,
|
||||
};
|
||||
},
|
||||
);
|
||||
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
|
||||
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
|
||||
const navigateToSettings = useNavigate();
|
||||
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
|
||||
setBrowserState((current) => ({ ...current, ...patch }));
|
||||
|
||||
@@ -549,7 +540,7 @@ export function FileBrowser() {
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
|
||||
} = useDirectoryListing(currentDir, machineId);
|
||||
const {
|
||||
data: ffprobeData,
|
||||
isLoading: ffprobeLoading,
|
||||
@@ -557,10 +548,10 @@ export function FileBrowser() {
|
||||
} = useFfprobe(
|
||||
selectedPath ?? "",
|
||||
!!selectedPath && isVideoFile(selectedPath),
|
||||
selectedMachineId || undefined,
|
||||
machineId,
|
||||
);
|
||||
const { data: templates } = useJobTemplates();
|
||||
const runJob = useRunJob(selectedMachineId || undefined);
|
||||
const runJob = useRunJob(machineId);
|
||||
|
||||
const navigate = (path: string) => {
|
||||
updateBrowserState({
|
||||
@@ -570,18 +561,6 @@ export function FileBrowser() {
|
||||
});
|
||||
};
|
||||
|
||||
const setMachine = (machineId: string) => {
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
if (machineId) next.set("machine_id", machineId);
|
||||
else next.delete("machine_id");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") navigate(pathInput || "/");
|
||||
};
|
||||
@@ -616,8 +595,6 @@ export function FileBrowser() {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate;
|
||||
// file rows select the file for ffprobe preview (also feeds pathInput).
|
||||
const handleRowClick = (row: DisplayRow) => {
|
||||
if (row.type === "dir" || row.type === "up") {
|
||||
navigate(row.path);
|
||||
@@ -630,8 +607,6 @@ export function FileBrowser() {
|
||||
});
|
||||
};
|
||||
|
||||
// Single-select checkbox behavior (DataTable adds a selection column under
|
||||
// enableRowSelection): mirrors the row-click selection for file rows.
|
||||
const rowSelection: RowSelectionState = selectedPath
|
||||
? { [selectedPath]: true }
|
||||
: {};
|
||||
@@ -659,204 +634,182 @@ export function FileBrowser() {
|
||||
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4.5">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">File Browser</h2>
|
||||
<Badge variant="outline">
|
||||
{fileMachines.length
|
||||
? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
|
||||
: "No file machines"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<TabbedCard
|
||||
value={fileMachines.length > 0 ? selectedMachineId : ""}
|
||||
onChange={setMachine}
|
||||
tabs={fileMachines.map((machine) => (
|
||||
<TabsTrigger key={machine.id} value={machine.id}>
|
||||
{`${machine.name} · ${machine.mode}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="Browser"
|
||||
description="Read-only listing with explicit open/select actions."
|
||||
>
|
||||
{fileMachines.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCard
|
||||
title="Browser"
|
||||
description="Read-only listing with explicit open/select actions."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label htmlFor="remote-path">Remote path</Label>
|
||||
<Input
|
||||
id="remote-path"
|
||||
value={pathInput}
|
||||
onChange={(e) =>
|
||||
updateBrowserState({ pathInput: e.target.value })
|
||||
}
|
||||
onKeyDown={handlePathSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Current: ${currentDir} `}
|
||||
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
||||
{listing ? `| Entries: ${listing.count}` : ""}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card">
|
||||
<DataTable
|
||||
columns={fileColumns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={handleSelectionChange}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading directory..."
|
||||
: "This directory is empty."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label htmlFor="remote-path">Remote path</Label>
|
||||
<Input
|
||||
id="remote-path"
|
||||
value={pathInput}
|
||||
onChange={(e) =>
|
||||
updateBrowserState({ pathInput: e.target.value })
|
||||
}
|
||||
onKeyDown={handlePathSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Current: ${currentDir} `}
|
||||
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
|
||||
{listing ? `| Entries: ${listing.count}` : ""}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card">
|
||||
{isMobile ? (
|
||||
<div className="p-4">
|
||||
<MobileCardRow
|
||||
rows={rows}
|
||||
fields={fileCardFields}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={fileColumns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={handleSelectionChange}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
emptyMessage={
|
||||
isLoading ? "Loading directory..." : "This directory is empty."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Media info"
|
||||
description="ffprobe metadata for the selected media file."
|
||||
>
|
||||
{selectedPath ? (
|
||||
isVideoFile(selectedPath) ? (
|
||||
ffprobeError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{String(ffprobeError)}</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeLoading && !ffprobeData ? (
|
||||
<Alert>
|
||||
<AlertDescription>Loading ffprobe data...</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeData ? (
|
||||
<FfprobeDetails
|
||||
path={selectedPath}
|
||||
data={ffprobeData as FfprobeData}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No ffprobe data available.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a video file to view ffprobe details.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a file in Browser to view ffprobe details.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Jobs"
|
||||
description="Run predefined safe jobs against the selected file."
|
||||
>
|
||||
{selectedPath && templates && templates.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="job-template">Job template</Label>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
onValueChange={(value) =>
|
||||
updateBrowserState({ selectedJob: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="job-template" className="w-full">
|
||||
<SelectValue placeholder="Select a job" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((tpl) => (
|
||||
<SelectItem key={tpl.key} value={tpl.key}>
|
||||
{tpl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Media info"
|
||||
description="ffprobe metadata for the selected media file."
|
||||
>
|
||||
{selectedPath ? (
|
||||
isVideoFile(selectedPath) ? (
|
||||
ffprobeError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{String(ffprobeError)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : ffprobeLoading && !ffprobeData ? (
|
||||
<InfoAlert>Loading ffprobe data...</InfoAlert>
|
||||
) : ffprobeData ? (
|
||||
<FfprobeDetails
|
||||
path={selectedPath}
|
||||
data={ffprobeData as FfprobeData}
|
||||
/>
|
||||
) : (
|
||||
<InfoAlert>No ffprobe data available.</InfoAlert>
|
||||
)
|
||||
) : (
|
||||
<InfoAlert>
|
||||
Select a video file to view ffprobe details.
|
||||
</InfoAlert>
|
||||
)
|
||||
) : (
|
||||
<InfoAlert>
|
||||
Select a file in Browser to view ffprobe details.
|
||||
</InfoAlert>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Jobs"
|
||||
description="Run predefined safe jobs against the selected file."
|
||||
>
|
||||
{selectedPath && templates && templates.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="job-template">Job template</Label>
|
||||
<Select
|
||||
value={selectedJob}
|
||||
onValueChange={(value) =>
|
||||
updateBrowserState({ selectedJob: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="job-template" className="w-full">
|
||||
<SelectValue placeholder="Select a job" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((tpl) => (
|
||||
<SelectItem key={tpl.key} value={tpl.key}>
|
||||
{tpl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||
<Button
|
||||
disabled={!selectedJob || runJob.isPending}
|
||||
onClick={() =>
|
||||
runJob.mutate({
|
||||
jobKey: selectedJob,
|
||||
path: selectedPath,
|
||||
})
|
||||
}
|
||||
>
|
||||
Run job
|
||||
</Button>
|
||||
{selectedTemplate && (
|
||||
<div className="self-center text-sm text-muted-foreground">
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
|
||||
<Button
|
||||
disabled={!selectedJob || runJob.isPending}
|
||||
onClick={() =>
|
||||
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
|
||||
}
|
||||
>
|
||||
Run job
|
||||
</Button>
|
||||
{selectedTemplate && (
|
||||
<div className="self-center text-sm text-muted-foreground">
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
{runJob.data && (
|
||||
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
||||
{`Exit: ${runJob.data.exit_status}`}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<InfoAlert>Select a file in Browser to run jobs.</InfoAlert>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{runJob.data && (
|
||||
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
|
||||
{`Exit: ${runJob.data.exit_status}`}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No file-capable machines are configured yet.
|
||||
Select a file in Browser to run jobs.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateToSettings("/settings")}
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)}
|
||||
</TabbedCard>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+21
-6
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* JobsTab — operational content for the backups service page.
|
||||
*
|
||||
* Lifted from the old top-level `components/BackupsPage.tsx`. The three
|
||||
* sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
|
||||
*
|
||||
* NOTE: the backup hooks currently query globally (no service_id filter).
|
||||
* The backend gained `service_id` attribution in Slice 3, but the hooks don't
|
||||
* yet accept a serviceId param. This tab shows ALL backups data for now;
|
||||
* per-instance scoping by `instance.id` is a follow-up once the hooks gain the
|
||||
* parameter.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
@@ -5,12 +17,16 @@ import {
|
||||
useBackupAlerts,
|
||||
useBackupJobs,
|
||||
useBackupRuns,
|
||||
} from "../hooks/useBackups";
|
||||
import BackupAlertsTable from "./BackupAlertsTable";
|
||||
import BackupJobsTable from "./BackupJobsTable";
|
||||
import BackupRunsTable from "./BackupRunsTable";
|
||||
} from "../../hooks/useBackups";
|
||||
import BackupAlertsTable from "../../components/BackupAlertsTable";
|
||||
import BackupJobsTable from "../../components/BackupJobsTable";
|
||||
import BackupRunsTable from "../../components/BackupRunsTable";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
export default function BackupsPage() {
|
||||
export function JobsTab({ instance }: { instance: ServiceInstance }) {
|
||||
// instance.id is not yet used — backup hooks query globally (see file
|
||||
// docstring). Per-instance scoping is a follow-up.
|
||||
void instance;
|
||||
const [tab, setTab] = useState("jobs");
|
||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
||||
@@ -35,7 +51,6 @@ export default function BackupsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="jobs">Jobs</TabsTrigger>
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Grafana Links tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Lifts the Grafana deep-link content from the old cross-service
|
||||
* ObservabilityPage into an instance-scoped tab. Shows service health + the
|
||||
* configured Grafana deep-links (node-exporter dashboard, Loki logs per
|
||||
* machine).
|
||||
*
|
||||
* The hooks (useGrafanaStatus, useMonitoringMachines) are global /
|
||||
* first-configured for now. Wiring `instance.id` into the status hook is a
|
||||
* follow-up. The machine links use the configured Grafana base_url from the
|
||||
* instance's config.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
|
||||
import {
|
||||
useGrafanaStatus,
|
||||
useMonitoringMachines,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
function GrafanaLinkCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinksTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: status, isLoading, error } = useGrafanaStatus();
|
||||
const { data: machines = [], isLoading: machinesLoading } =
|
||||
useMonitoringMachines();
|
||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||
|
||||
const grafanaBaseUrl =
|
||||
(instance.config?.base_url as string | undefined) ?? "";
|
||||
|
||||
const selectedMachine = useMemo(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const inst = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: isLoading
|
||||
? "checking…"
|
||||
: error
|
||||
? "unreachable"
|
||||
: "not configured";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Grafana {statusDetail}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Grafana</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
{machines.length > 0 ? (
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machinesLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : selectedMachine && grafanaBaseUrl ? (
|
||||
<>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||
href={nodeExporterDashboardUrl}
|
||||
/>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} logs`}
|
||||
description="Explore Loki logs for this machine in Grafana."
|
||||
href={logsUrl}
|
||||
/>
|
||||
</>
|
||||
) : !grafanaBaseUrl ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Gauge className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No Grafana base URL configured</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add a Grafana service instance to enable deep-links to
|
||||
dashboards and logs.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/services">Open Services</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<ServerOff className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No machine selected</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add monitoring machines in Settings to see Grafana drill-down
|
||||
links.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/**
|
||||
* MediaTab — operational content for the Jellyfin service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Media.tsx`. The service-id source is
|
||||
* changed from URL search params to the `instance` prop (the active service
|
||||
* instance selected on the service page). The service-selection dropdown and
|
||||
* its URL-sync effect are removed; everything else is preserved verbatim.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type {
|
||||
ColumnDef,
|
||||
OnChangeFn,
|
||||
@@ -9,6 +17,11 @@ import type {
|
||||
} from "@tanstack/react-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 { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -29,11 +42,14 @@ import {
|
||||
useBuildIndex,
|
||||
useStopBuildIndex,
|
||||
useForceStopBuildIndex,
|
||||
} from "../hooks/useMedia";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import type { MediaItem } from "../types";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
} from "../../hooks/useMedia";
|
||||
import { usePersistentState } from "../../hooks/usePersistentState";
|
||||
import { useIsMobile } from "../../hooks/useIsMobile";
|
||||
import type { MediaItem, ServiceInstance } from "../../types";
|
||||
import { useCounts, useLibraries } from "../../hooks/useDashboard";
|
||||
import { useServiceInstances } from "../../hooks/useServices";
|
||||
|
||||
// --- Format helpers (lifted verbatim from Media.tsx) ---
|
||||
|
||||
function formatDuration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || Number.isNaN(seconds)) return "-";
|
||||
@@ -46,10 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
|
||||
// TanStack table instance stays stable — an unstable columns array drops the
|
||||
// controlled selection/visibility state (7a discovery). Visibility-only parity
|
||||
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
|
||||
// --- Column definitions (lifted verbatim) ---
|
||||
|
||||
const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||
{ accessorKey: "title", header: "Title" },
|
||||
{ accessorKey: "series", header: "Series" },
|
||||
@@ -68,16 +82,27 @@ const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||
{ accessorKey: "path", header: "Path" },
|
||||
];
|
||||
|
||||
// Stable path-derived identity so row selection survives server-driven paging
|
||||
// (design §3.4): the id is the item's filesystem path, which is stable across
|
||||
// limit/offset page changes.
|
||||
function getMediaRowId(row: MediaItem): string {
|
||||
return row.path;
|
||||
}
|
||||
|
||||
// Mobile card fields (mobile-parity pattern): title primary + 4 key fields.
|
||||
const mediaCardFields: MobileCardField<MediaItem>[] = [
|
||||
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||
{ key: "hdr", label: "HDR", render: (r) => r.hdr || "-" },
|
||||
{ key: "library", label: "Library", render: (r) => r.library || "-" },
|
||||
{
|
||||
key: "year",
|
||||
label: "Year",
|
||||
render: (r) => (r.year != null ? String(r.year) : "-"),
|
||||
},
|
||||
];
|
||||
|
||||
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
|
||||
|
||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||
const MOBILE_HIDDEN_COLUMNS = [
|
||||
"series",
|
||||
"season",
|
||||
@@ -130,6 +155,8 @@ function usePrefersSmallScreen(): boolean {
|
||||
return small;
|
||||
}
|
||||
|
||||
// --- Small UI helpers (lifted verbatim) ---
|
||||
|
||||
function FilterSelect({
|
||||
id,
|
||||
label,
|
||||
@@ -162,9 +189,6 @@ function FilterSelect({
|
||||
);
|
||||
}
|
||||
|
||||
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
|
||||
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
|
||||
// "indeterminate" affordance for unknown build progress.
|
||||
function BuildProgress({ value }: { value: number | null }) {
|
||||
if (value == null) {
|
||||
return (
|
||||
@@ -174,30 +198,26 @@ function BuildProgress({ value }: { value: number | null }) {
|
||||
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
|
||||
}
|
||||
|
||||
export function Media() {
|
||||
// --- Component ---
|
||||
|
||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||
const selectedServiceId =
|
||||
searchParams.get("jellyfin_service_id") ||
|
||||
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||
"";
|
||||
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||
const { data: status } = useMediaStatus(selectedServiceId || undefined);
|
||||
const buildIndex = useBuildIndex(selectedServiceId || undefined);
|
||||
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
|
||||
const forceStopBuildIndex = useForceStopBuildIndex(
|
||||
selectedServiceId || undefined,
|
||||
);
|
||||
const isMobile = useIsMobile();
|
||||
const serviceId = instance.id;
|
||||
|
||||
const { data: counts } = useCounts(serviceId);
|
||||
const { data: libraries } = useLibraries(serviceId);
|
||||
const { data: status } = useMediaStatus(serviceId);
|
||||
const buildIndex = useBuildIndex(serviceId);
|
||||
const stopBuildIndex = useStopBuildIndex(serviceId);
|
||||
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
|
||||
|
||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||
MEDIA_TAB_STATE_KEY,
|
||||
defaultMediaTabState,
|
||||
);
|
||||
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
|
||||
// without pageSize/columnVisibility) never yields undefined fields.
|
||||
const mediaState: MediaTabState = {
|
||||
...defaultMediaTabState(),
|
||||
...rawMediaState,
|
||||
@@ -209,19 +229,6 @@ export function Media() {
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("jellyfin_service_id", selectedServiceId);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [searchParams, selectedServiceId, setSearchParams]);
|
||||
|
||||
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||
types,
|
||||
search,
|
||||
@@ -230,12 +237,10 @@ export function Media() {
|
||||
sort_order: sortOrder,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
jellyfinServiceId: selectedServiceId || undefined,
|
||||
jellyfinServiceId: serviceId,
|
||||
enabled: status?.exists ?? false,
|
||||
});
|
||||
|
||||
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
|
||||
// persistent media state and drive useMediaQuery { limit, offset }.
|
||||
const pageIndex = Math.floor(offset / pageSize);
|
||||
const pagination: PaginationState = { pageIndex, pageSize };
|
||||
|
||||
@@ -245,8 +250,6 @@ export function Media() {
|
||||
? updater({ pageIndex, pageSize })
|
||||
: updater;
|
||||
const nextPageSize = next.pageSize || pageSize;
|
||||
// Restart at page 0 whenever the page size changes (keeps offset sane
|
||||
// under server-driven paging).
|
||||
const nextOffset =
|
||||
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
|
||||
setMediaState((current) => ({
|
||||
@@ -266,9 +269,6 @@ export function Media() {
|
||||
});
|
||||
};
|
||||
|
||||
// On small screens force the same set of columns hidden as the pre-rework
|
||||
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
|
||||
// toggles freely (the toggleable set still equals the locked 15).
|
||||
const effectiveColumnVisibility = useMemo(() => {
|
||||
const base = mediaState.columnVisibility ?? {};
|
||||
if (!isSmall) return base;
|
||||
@@ -277,10 +277,15 @@ export function Media() {
|
||||
return merged;
|
||||
}, [mediaState.columnVisibility, isSmall]);
|
||||
|
||||
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
|
||||
// the item's path.
|
||||
const handleRowClick = (row: MediaItem) => {
|
||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
||||
// Navigate to the ssh_tasks service page with the path query param.
|
||||
// If an ssh_tasks instance exists, open its Files tab; otherwise land
|
||||
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
|
||||
const sshInstance = sshServices.find((s) => s.enabled);
|
||||
const base = sshInstance
|
||||
? `/services/ssh_tasks/${sshInstance.id}`
|
||||
: "/services/ssh_tasks";
|
||||
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
|
||||
};
|
||||
|
||||
const total = queryResult?.total ?? 0;
|
||||
@@ -316,35 +321,6 @@ export function Media() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="media-service">Service</Label>
|
||||
<Select
|
||||
value={selectedServiceId}
|
||||
onValueChange={(value) =>
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("jellyfin_service_id", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
|
||||
<SelectValue placeholder="Select a service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{jellyfinServices.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{status?.exists ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Index: {status.item_count.toLocaleString()} items
|
||||
@@ -532,33 +508,56 @@ export function Media() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.exists && (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<DataTable
|
||||
columns={mediaColumns}
|
||||
data={queryResult?.items ?? []}
|
||||
getRowId={getMediaRowId}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={effectiveColumnVisibility}
|
||||
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||
enablePagination
|
||||
manualPagination
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
pageSizeOptions={[50, 100, 200]}
|
||||
rowCount={total}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading media..."
|
||||
: "No media items match these filters."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{status?.exists &&
|
||||
(isMobile ? (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4">
|
||||
<MobileCardRow
|
||||
rows={queryResult?.items ?? []}
|
||||
fields={mediaCardFields}
|
||||
getRowId={getMediaRowId}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
{queryResult && (
|
||||
<TablePagination
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[50, 100, 200]}
|
||||
totalRows={total}
|
||||
pageCount={totalPages}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
className="p-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<DataTable
|
||||
columns={mediaColumns}
|
||||
data={queryResult?.items ?? []}
|
||||
getRowId={getMediaRowId}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
onRowClick={handleRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={effectiveColumnVisibility}
|
||||
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||
enablePagination
|
||||
manualPagination
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
pageSizeOptions={[50, 100, 200]}
|
||||
rowCount={total}
|
||||
emptyMessage={
|
||||
isLoading
|
||||
? "Loading media..."
|
||||
: "No media items match these filters."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/** MessagingTab — compose email to Authentik users via the mail queue. */
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAuthentikUsers,
|
||||
useSendAuthentikMessage,
|
||||
} from "../../hooks/useAuthentik";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
const DEFAULT_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
|
||||
|
||||
export function MessagingTab({ instance }: { instance: ServiceInstance }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
|
||||
const [subject, setSubject] = useState("");
|
||||
const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY);
|
||||
|
||||
const { data } = useAuthentikUsers(instance.id, {
|
||||
search,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
});
|
||||
const sendMessage = useSendAuthentikMessage(instance.id);
|
||||
|
||||
const users = (data?.items ?? []).filter((u) => u.email);
|
||||
const error = data?.error;
|
||||
|
||||
function toggleEmail(email: string) {
|
||||
setSelectedEmails((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(email)) next.delete(email);
|
||||
else next.add(email);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!subject.trim() || selectedEmails.size === 0) return;
|
||||
sendMessage.mutate({
|
||||
recipient_emails: Array.from(selectedEmails),
|
||||
subject: subject.trim(),
|
||||
html_body: htmlBody,
|
||||
});
|
||||
}
|
||||
|
||||
const canSend =
|
||||
subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{sendMessage.data ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{sendMessage.data.status === "queued"
|
||||
? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).`
|
||||
: `Error: ${sendMessage.data.error ?? "unknown"}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="msg-search">Find recipients</Label>
|
||||
<Input
|
||||
id="msg-search"
|
||||
placeholder="Search users to add as recipients…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
{users.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{users.slice(0, 20).map((user) => (
|
||||
<Button
|
||||
key={user.pk}
|
||||
variant={selectedEmails.has(user.email) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleEmail(user.email)}
|
||||
>
|
||||
{user.name || user.username}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedEmails.size > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedEmails.size} recipient
|
||||
{selectedEmails.size === 1 ? "" : "s"} selected.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="msg-subject">Subject</Label>
|
||||
<Input
|
||||
id="msg-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="msg-body">Message (HTML)</Label>
|
||||
<Textarea
|
||||
id="msg-body"
|
||||
rows={8}
|
||||
value={htmlBody}
|
||||
onChange={(e) => setHtmlBody(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onClick={handleSend} disabled={!canSend}>
|
||||
{sendMessage.isPending ? "Sending…" : "Send message"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user