Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cd8e926de | |||
| 1a52dfb087 | |||
| 9dfe62eb6f | |||
| 200d319fb0 | |||
| 24427b4869 | |||
| bb8b040657 | |||
| a8eb751322 | |||
| 08a3b616f6 | |||
| 1c29299e8c | |||
| 0ec2a8806b | |||
| 9cae5fc98c | |||
| 7646f3236f | |||
| 9de2d5b8d2 | |||
| 3dc1b31fc3 | |||
| e8b0f1144b | |||
| 04f2e59c92 | |||
| 1e23c07a20 | |||
| b6da7df7f9 | |||
| c721f0dece | |||
| 77c6b62ee2 | |||
| 109e74db41 | |||
| dd778d8850 |
@@ -23,6 +23,8 @@ PROMETHEUS_ENABLED=true
|
|||||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||||
ALERTMANAGER_URL=http://alertmanager:9093
|
ALERTMANAGER_URL=http://alertmanager:9093
|
||||||
ALERTMANAGER_WEBHOOK_URL=
|
ALERTMANAGER_WEBHOOK_URL=
|
||||||
|
GRAFANA_URL=http://grafana:3000
|
||||||
|
PROMETHEUS_URL=http://prometheus:9090
|
||||||
BACKEND_CACHE_DIR=./backend-cache
|
BACKEND_CACHE_DIR=./backend-cache
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -41,6 +43,7 @@ VITE_OIDC_SCOPE=openid profile email
|
|||||||
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
|
VITE_GRAFANA_URL=https://grafana.example.com
|
||||||
|
|
||||||
# SMTP
|
# SMTP
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`.
|
- Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`.
|
||||||
- Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`).
|
- Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`).
|
||||||
- Frontend entrypoint: `frontend/src/main.tsx`.
|
- Frontend entrypoint: `frontend/src/main.tsx`.
|
||||||
- Backend uses a `src/` layout; tests live in `backend/tests/`.
|
- Backend uses a `src/` layout; tests live in `backend/tests/`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'`
|
- Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'`
|
||||||
- Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`.
|
- Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`.
|
||||||
- Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k <expr>`; if the package is not installed, use `PYTHONPATH=src pytest`.
|
- Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k <expr>`; if the package is not installed, use `PYTHONPATH=src pytest`.
|
||||||
@@ -17,12 +19,13 @@
|
|||||||
- Production stack: `docker compose up --build`
|
- Production stack: `docker compose up --build`
|
||||||
|
|
||||||
## Repo-Specific Gotchas
|
## Repo-Specific Gotchas
|
||||||
|
|
||||||
- Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them.
|
- Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them.
|
||||||
- Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars).
|
- Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars).
|
||||||
- Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines.
|
- Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines.
|
||||||
- `backend_cache` persists the media index and the managed `known_hosts` file.
|
- `backend_cache` persists the media index and the managed `known_hosts` file.
|
||||||
- SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`.
|
- SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`.
|
||||||
- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and monitoring poller.
|
- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and backup alert poller.
|
||||||
- Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically.
|
- Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically.
|
||||||
- Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact.
|
- Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact.
|
||||||
- Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11.
|
- Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11.
|
||||||
|
|||||||
@@ -52,16 +52,13 @@ class Settings(BaseSettings):
|
|||||||
ssh_password: str = ""
|
ssh_password: str = ""
|
||||||
ssh_known_hosts_path: str = ""
|
ssh_known_hosts_path: str = ""
|
||||||
|
|
||||||
# Monitoring poller
|
|
||||||
monitoring_poll_interval_seconds: int = 300
|
|
||||||
monitoring_poll_initial_delay_seconds: int = 20
|
|
||||||
monitoring_action_retention_days: int = 30
|
|
||||||
|
|
||||||
# Observability
|
# Observability
|
||||||
prometheus_enabled: bool = True
|
prometheus_enabled: bool = True
|
||||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
||||||
alertmanager_url: str = "http://alertmanager:9093"
|
alertmanager_url: str = "http://alertmanager:9093"
|
||||||
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
||||||
|
grafana_url: str = "http://grafana:3000"
|
||||||
|
prometheus_url: str = "http://prometheus:9090"
|
||||||
|
|
||||||
# Remote paths
|
# Remote paths
|
||||||
remote_media_root: str = ""
|
remote_media_root: str = ""
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
|||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.config import get_settings
|
||||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||||
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
|
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
|
||||||
from media_library_viewer_api.services.monitoring_poller import (
|
|
||||||
MonitoringPoller,
|
|
||||||
)
|
|
||||||
from media_library_viewer_api.services.monitoring_poller import (
|
|
||||||
get_monitoring_poller as _get_monitoring_poller,
|
|
||||||
)
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.settings_store import get_settings_store as _get_settings_store
|
from media_library_viewer_api.services.settings_store import get_settings_store as _get_settings_store
|
||||||
|
|
||||||
@@ -251,11 +245,6 @@ def get_mail_queue() -> MailQueue:
|
|||||||
return _get_mail_queue()
|
return _get_mail_queue()
|
||||||
|
|
||||||
|
|
||||||
def get_monitoring_poller() -> MonitoringPoller:
|
|
||||||
"""Return the singleton background monitoring poller."""
|
|
||||||
return _get_monitoring_poller()
|
|
||||||
|
|
||||||
|
|
||||||
def get_settings_store() -> SettingsStore:
|
def get_settings_store() -> SettingsStore:
|
||||||
"""Return the singleton persistent settings store."""
|
"""Return the singleton persistent settings store."""
|
||||||
return _get_settings_store()
|
return _get_settings_store()
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Dashboard domain helpers shared between routers and widget adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for session in sessions:
|
||||||
|
item = session.get("NowPlayingItem") or {}
|
||||||
|
play_state = session.get("PlayState") or {}
|
||||||
|
transcoding = session.get("TranscodingInfo") or {}
|
||||||
|
|
||||||
|
has_item = bool(item)
|
||||||
|
series = item.get("SeriesName") or ""
|
||||||
|
title = (
|
||||||
|
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown"))
|
||||||
|
if has_item
|
||||||
|
else "(idle)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_item:
|
||||||
|
state_label = "idle"
|
||||||
|
else:
|
||||||
|
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||||
|
|
||||||
|
is_transcoding = bool(transcoding)
|
||||||
|
transcode_type: list[str] = []
|
||||||
|
if is_transcoding:
|
||||||
|
if transcoding.get("IsVideoDirect") is False:
|
||||||
|
transcode_type.append("video")
|
||||||
|
if transcoding.get("IsAudioDirect") is False:
|
||||||
|
transcode_type.append("audio")
|
||||||
|
if not transcode_type:
|
||||||
|
transcode_type.append("active")
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"user": session.get("UserName") or "Unknown",
|
||||||
|
"title": title,
|
||||||
|
"type": item.get("Type", "") if has_item else "",
|
||||||
|
"state": state_label,
|
||||||
|
"transcoding": "yes" if is_transcoding else "no",
|
||||||
|
"transcoding_type": ", ".join(transcode_type),
|
||||||
|
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||||
|
"session_id": session.get("Id") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def build_backup_dashboard_summary(store: SettingsStore) -> BackupDashboardSummary:
|
||||||
|
"""Compute the backup summary shown on the dashboard."""
|
||||||
|
jobs = store.list_backup_jobs()
|
||||||
|
total_jobs = len(jobs)
|
||||||
|
|
||||||
|
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||||
|
recent_runs = []
|
||||||
|
for job in jobs:
|
||||||
|
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||||
|
if runs and runs[0]["started_at"] >= cutoff:
|
||||||
|
recent_runs.append(runs[0])
|
||||||
|
|
||||||
|
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||||
|
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||||
|
|
||||||
|
alerts = store.list_backup_alerts(acknowledged=False)
|
||||||
|
active_alerts = len(alerts)
|
||||||
|
|
||||||
|
failed_runs = []
|
||||||
|
for job in jobs:
|
||||||
|
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||||
|
if runs:
|
||||||
|
failed_runs.append(runs[0])
|
||||||
|
|
||||||
|
last_failed_at = None
|
||||||
|
if failed_runs:
|
||||||
|
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||||
|
|
||||||
|
return BackupDashboardSummary(
|
||||||
|
total_jobs=total_jobs,
|
||||||
|
success_rate_24h=round(success_rate, 1),
|
||||||
|
active_alerts=active_alerts,
|
||||||
|
last_failed_at=last_failed_at,
|
||||||
|
)
|
||||||
@@ -13,7 +13,7 @@ from fastapi.responses import Response as FastAPIResponse
|
|||||||
|
|
||||||
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
|
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
|
||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.config import get_settings
|
||||||
from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller, get_settings_store
|
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings, sanitize_log_extra
|
from media_library_viewer_api.logging_utils import configure_logging, describe_settings, sanitize_log_extra
|
||||||
from media_library_viewer_api.observability import (
|
from media_library_viewer_api.observability import (
|
||||||
get_request_id,
|
get_request_id,
|
||||||
@@ -23,6 +23,7 @@ from media_library_viewer_api.observability import (
|
|||||||
)
|
)
|
||||||
from media_library_viewer_api.routers import backups as backups_router
|
from media_library_viewer_api.routers import backups as backups_router
|
||||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||||
|
from media_library_viewer_api.routers import widgets as widgets_router
|
||||||
from media_library_viewer_api.routers.settings import router as settings_router
|
from media_library_viewer_api.routers.settings import router as settings_router
|
||||||
|
|
||||||
from .services.backup_poller import get_backup_poller
|
from .services.backup_poller import get_backup_poller
|
||||||
@@ -45,14 +46,15 @@ async def lifespan(app: FastAPI):
|
|||||||
write_prometheus_targets(get_settings_store())
|
write_prometheus_targets(get_settings_store())
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
||||||
|
try:
|
||||||
|
get_settings_store().ensure_defaults()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to seed default settings during startup")
|
||||||
mail_queue = get_mail_queue()
|
mail_queue = get_mail_queue()
|
||||||
monitoring_poller = get_monitoring_poller()
|
|
||||||
backup_poller = get_backup_poller()
|
backup_poller = get_backup_poller()
|
||||||
mail_queue.start()
|
mail_queue.start()
|
||||||
monitoring_poller.start()
|
|
||||||
backup_poller.start()
|
backup_poller.start()
|
||||||
yield
|
yield
|
||||||
monitoring_poller.stop()
|
|
||||||
backup_poller.stop()
|
backup_poller.stop()
|
||||||
mail_queue.stop()
|
mail_queue.stop()
|
||||||
logger.info("Backend shutdown complete")
|
logger.info("Backend shutdown complete")
|
||||||
@@ -140,6 +142,7 @@ app.include_router(users.router)
|
|||||||
app.include_router(tasks.router)
|
app.include_router(tasks.router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(backups_router.router)
|
app.include_router(backups_router.router)
|
||||||
|
app.include_router(widgets_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Pydantic models for the dashboard widget system."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
FORBIDDEN_CONFIG_KEYS = {
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"private_key",
|
||||||
|
"passphrase",
|
||||||
|
"credential",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_secret(value: Any) -> bool:
|
||||||
|
"""Heuristic to detect values that look like secrets/tokens."""
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return False
|
||||||
|
lowered = value.lower()
|
||||||
|
if value.startswith("sk-") or value.startswith("eyJ"):
|
||||||
|
return True
|
||||||
|
if len(value) > 64 and lowered.isalnum():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Recursively reject credential keys and secret-looking values."""
|
||||||
|
for key, value in config.items():
|
||||||
|
if key.lower() in FORBIDDEN_CONFIG_KEYS:
|
||||||
|
raise ValueError(f"Credential key '{key}' is not allowed in widget config")
|
||||||
|
if _looks_secret(value):
|
||||||
|
raise ValueError(f"Value for '{key}' looks like a secret")
|
||||||
|
if isinstance(value, dict):
|
||||||
|
_validate_config_keys(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
_validate_config_keys(item)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class _WidgetInstanceBase(BaseModel):
|
||||||
|
"""Shared fields between input and output widget models."""
|
||||||
|
|
||||||
|
addon_id: str
|
||||||
|
widget_type: str
|
||||||
|
title: str = Field(..., min_length=1)
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
|
sort_order: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
@field_validator("config")
|
||||||
|
@classmethod
|
||||||
|
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return _validate_config_keys(value or {})
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetInstanceInput(_WidgetInstanceBase):
|
||||||
|
"""Payload for creating or updating a widget instance."""
|
||||||
|
|
||||||
|
id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetInstance(_WidgetInstanceBase):
|
||||||
|
"""Persisted widget instance returned by the API."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetTypeInfo(BaseModel):
|
||||||
|
"""Metadata about a built-in widget type."""
|
||||||
|
|
||||||
|
addon_id: str
|
||||||
|
widget_type: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
source_type: str
|
||||||
|
config_schema: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetDataResponse(BaseModel):
|
||||||
|
"""Response from the per-widget data endpoint."""
|
||||||
|
|
||||||
|
widget_id: str
|
||||||
|
widget_type: str
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
fetched_at: int
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
|
|||||||
get_settings_store,
|
get_settings_store,
|
||||||
get_user_id,
|
get_user_id,
|
||||||
)
|
)
|
||||||
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
|
_map_sessions_to_activity_rows,
|
||||||
|
build_backup_dashboard_summary,
|
||||||
|
)
|
||||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
@@ -85,50 +88,6 @@ def delete_shortcut(
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
for session in sessions:
|
|
||||||
item = session.get("NowPlayingItem") or {}
|
|
||||||
play_state = session.get("PlayState") or {}
|
|
||||||
transcoding = session.get("TranscodingInfo") or {}
|
|
||||||
|
|
||||||
has_item = bool(item)
|
|
||||||
series = item.get("SeriesName") or ""
|
|
||||||
title = (
|
|
||||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not has_item:
|
|
||||||
state_label = "idle"
|
|
||||||
else:
|
|
||||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
|
||||||
|
|
||||||
is_transcoding = bool(transcoding)
|
|
||||||
transcode_type: list[str] = []
|
|
||||||
if is_transcoding:
|
|
||||||
if transcoding.get("IsVideoDirect") is False:
|
|
||||||
transcode_type.append("video")
|
|
||||||
if transcoding.get("IsAudioDirect") is False:
|
|
||||||
transcode_type.append("audio")
|
|
||||||
if not transcode_type:
|
|
||||||
transcode_type.append("active")
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"user": session.get("UserName") or "Unknown",
|
|
||||||
"title": title,
|
|
||||||
"type": item.get("Type", "") if has_item else "",
|
|
||||||
"state": state_label,
|
|
||||||
"transcoding": "yes" if is_transcoding else "no",
|
|
||||||
"transcoding_type": ", ".join(transcode_type),
|
|
||||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
|
||||||
"session_id": session.get("Id") or "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/activity")
|
@router.get("/activity")
|
||||||
def get_activity(
|
def get_activity(
|
||||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
@@ -154,38 +113,4 @@ def get_now_playing(
|
|||||||
def get_backup_dashboard(
|
def get_backup_dashboard(
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> BackupDashboardSummary:
|
) -> BackupDashboardSummary:
|
||||||
jobs = store.list_backup_jobs()
|
return build_backup_dashboard_summary(store)
|
||||||
total_jobs = len(jobs)
|
|
||||||
|
|
||||||
# Calculate 24h success rate
|
|
||||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
|
||||||
recent_runs = []
|
|
||||||
for job in jobs:
|
|
||||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
|
||||||
if runs and runs[0]["started_at"] >= cutoff:
|
|
||||||
recent_runs.append(runs[0])
|
|
||||||
|
|
||||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
|
||||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
|
||||||
|
|
||||||
# Active alerts
|
|
||||||
alerts = store.list_backup_alerts(acknowledged=False)
|
|
||||||
active_alerts = len(alerts)
|
|
||||||
|
|
||||||
# Last failed
|
|
||||||
failed_runs = []
|
|
||||||
for job in jobs:
|
|
||||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
|
||||||
if runs:
|
|
||||||
failed_runs.append(runs[0])
|
|
||||||
|
|
||||||
last_failed_at = None
|
|
||||||
if failed_runs:
|
|
||||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
|
||||||
|
|
||||||
return BackupDashboardSummary(
|
|
||||||
total_jobs=total_jobs,
|
|
||||||
success_rate_24h=round(success_rate, 1),
|
|
||||||
active_alerts=active_alerts,
|
|
||||||
last_failed_at=last_failed_at,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
"""Monitoring router — disk checks, action history, and observability stack status."""
|
"""Monitoring router — observability stack status (Alertmanager + Prometheus)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
from fastapi import APIRouter, Body, Depends
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.config import get_settings
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.services.monitoring_actions import (
|
|
||||||
disk_space,
|
|
||||||
run_machine_operation,
|
|
||||||
)
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||||
|
|
||||||
@@ -68,80 +64,12 @@ def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
|||||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||||
|
|
||||||
|
|
||||||
def _resolve_machine(store: SettingsStore, machine_id: str | None) -> dict[str, Any]:
|
|
||||||
"""Return the requested machine or the first enabled machine.
|
|
||||||
|
|
||||||
Monitoring is treated as a machine-by-machine view. If a machine is
|
|
||||||
explicitly requested but disabled, we surface that as a user-facing error so
|
|
||||||
the Settings tab can be used to re-enable it.
|
|
||||||
"""
|
|
||||||
machines = store.list_machines()
|
|
||||||
if machine_id:
|
|
||||||
machine = next((item for item in machines if item["id"] == machine_id), None)
|
|
||||||
if not machine:
|
|
||||||
raise HTTPException(status_code=404, detail="Monitoring machine not found")
|
|
||||||
if not machine.get("enabled"):
|
|
||||||
raise HTTPException(status_code=409, detail=f"Monitoring machine '{machine['name']}' is disabled")
|
|
||||||
return machine
|
|
||||||
|
|
||||||
for machine in machines:
|
|
||||||
if machine.get("enabled"):
|
|
||||||
return machine
|
|
||||||
raise HTTPException(status_code=404, detail="No enabled monitoring machines configured")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/machines")
|
@router.get("/machines")
|
||||||
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||||
"""Return enabled monitoring machines for the UI."""
|
"""Return enabled monitoring machines for the UI."""
|
||||||
return [m for m in store.list_machines() if m.get("enabled")]
|
return [m for m in store.list_machines() if m.get("enabled")]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/poller")
|
|
||||||
def get_poller_status() -> dict[str, Any]:
|
|
||||||
"""Return the backend poller status and configuration."""
|
|
||||||
from media_library_viewer_api.dependencies import get_monitoring_poller
|
|
||||||
|
|
||||||
poller = get_monitoring_poller().snapshot()
|
|
||||||
logger.info(
|
|
||||||
"Monitoring poller status requested running=%s poll_count=%s",
|
|
||||||
poller.get("worker_running"),
|
|
||||||
poller.get("poll_count"),
|
|
||||||
)
|
|
||||||
return poller
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/machines/{machine_id}/actions")
|
|
||||||
def get_machine_actions(
|
|
||||||
machine_id: str,
|
|
||||||
limit: int = 20,
|
|
||||||
action: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return recent action history for a single machine."""
|
|
||||||
machine = _resolve_machine(store, machine_id)
|
|
||||||
actions = store.list_machine_actions(machine["id"], limit=limit, action=action, status=status)
|
|
||||||
return {"items": actions, "total": len(actions)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/disk")
|
|
||||||
def get_disk_space(
|
|
||||||
machine_id: str | None = Query(default=None),
|
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return disk space for the configured path of a given machine."""
|
|
||||||
machine = _resolve_machine(store, machine_id)
|
|
||||||
app_settings = get_settings()
|
|
||||||
path = str(machine.get("media_root") or app_settings.media_root or "/")
|
|
||||||
logger.info("Monitoring disk requested machine_id=%s path=%s", machine["id"], path)
|
|
||||||
return run_machine_operation(
|
|
||||||
machine,
|
|
||||||
store,
|
|
||||||
f"disk lookup for {path}",
|
|
||||||
lambda client: disk_space(client, path),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/prometheus-targets")
|
@router.get("/prometheus-targets")
|
||||||
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||||
"""Return Prometheus file-SD targets for remote Node Exporters.
|
"""Return Prometheus file-SD targets for remote Node Exporters.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.config import get_settings
|
||||||
from media_library_viewer_api.dependencies import get_monitoring_poller, get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
|
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
|
||||||
from media_library_viewer_api.services.known_hosts import has_known_host
|
from media_library_viewer_api.services.known_hosts import has_known_host
|
||||||
from media_library_viewer_api.services.media_index import MediaIndex
|
from media_library_viewer_api.services.media_index import MediaIndex
|
||||||
@@ -195,13 +195,8 @@ def post_machine(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||||
_write_prometheus_targets(store)
|
_write_prometheus_targets(store)
|
||||||
poller = get_monitoring_poller()
|
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||||
try:
|
_validate_saved_machine_ssh(saved_machine, store)
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
|
||||||
finally:
|
|
||||||
poller.start()
|
|
||||||
poller.kick()
|
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
|
||||||
@@ -215,13 +210,8 @@ def put_machine(
|
|||||||
raise HTTPException(status_code=404, detail="Machine not found")
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
||||||
_write_prometheus_targets(store)
|
_write_prometheus_targets(store)
|
||||||
poller = get_monitoring_poller()
|
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||||
try:
|
_validate_saved_machine_ssh(saved_machine, store)
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
|
||||||
finally:
|
|
||||||
poller.start()
|
|
||||||
poller.kick()
|
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""REST API for dashboard widget instances and registry metadata."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.models.widgets import (
|
||||||
|
WidgetDataResponse,
|
||||||
|
WidgetInstance,
|
||||||
|
WidgetInstanceInput,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.widgets.registry import (
|
||||||
|
get_widget_info,
|
||||||
|
list_source_types,
|
||||||
|
list_widget_types,
|
||||||
|
validate_config,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.widgets.sources import get_source_adapter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
||||||
|
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||||
|
|
||||||
|
info = WIDGET_REGISTRY.get(widget_type)
|
||||||
|
if not info:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unknown widget type: {widget_type}",
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_widget_input(body: WidgetInstanceInput) -> None:
|
||||||
|
"""Validate widget_type/addon_id match and config schema."""
|
||||||
|
info = _registry_for_type(body.widget_type)
|
||||||
|
expected_addon = info["addon_id"]
|
||||||
|
if body.addon_id != expected_addon:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(
|
||||||
|
f"Widget type '{body.widget_type}' belongs to addon "
|
||||||
|
f"'{expected_addon}', not '{body.addon_id}'"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validate_config(body.widget_type, body.config)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources")
|
||||||
|
def list_sources() -> list[str]:
|
||||||
|
"""Return all registered widget source types."""
|
||||||
|
return list_source_types()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/types")
|
||||||
|
def list_types() -> list[dict[str, Any]]:
|
||||||
|
"""Return metadata for all registered widget types."""
|
||||||
|
return [info.model_dump() for info in list_widget_types()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances")
|
||||||
|
def list_instances(
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return all persisted widget instances."""
|
||||||
|
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_instance(
|
||||||
|
body: WidgetInstanceInput,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new widget instance."""
|
||||||
|
_validate_widget_input(body)
|
||||||
|
widget = store.upsert_widget(body.model_dump())
|
||||||
|
return WidgetInstance(**widget).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/instances/{widget_id}")
|
||||||
|
def update_instance(
|
||||||
|
widget_id: str,
|
||||||
|
body: WidgetInstanceInput,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update an existing widget instance."""
|
||||||
|
existing = store.get_widget(widget_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||||
|
if body.id is not None and body.id != widget_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="ID in path does not match ID in body",
|
||||||
|
)
|
||||||
|
_validate_widget_input(body)
|
||||||
|
widget = store.upsert_widget(body.model_dump(), widget_id)
|
||||||
|
return WidgetInstance(**widget).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/instances/{widget_id}")
|
||||||
|
def delete_instance(
|
||||||
|
widget_id: str,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Delete a widget instance."""
|
||||||
|
existing = store.get_widget(widget_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||||
|
store.delete_widget(widget_id)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{widget_id}/data")
|
||||||
|
async def fetch_data(
|
||||||
|
widget_id: str,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch widget data through the registered source adapter."""
|
||||||
|
widget = store.get_widget(widget_id)
|
||||||
|
if not widget:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||||
|
|
||||||
|
widget_type = widget["widget_type"]
|
||||||
|
info = get_widget_info(widget_type)
|
||||||
|
if info is None:
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
widget_type=widget_type,
|
||||||
|
data=None,
|
||||||
|
error=f"Unknown widget type: {widget_type}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
adapter = get_source_adapter(info.source_type)
|
||||||
|
if adapter is None:
|
||||||
|
# Defensive: registry should prevent this, but return a safe error.
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
widget_type=widget_type,
|
||||||
|
data=None,
|
||||||
|
error=f"No adapter registered for source type: {info.source_type}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await adapter.fetch(widget["config"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Widget data fetch failed",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
widget_type=widget_type,
|
||||||
|
data=data if "error" not in data else None,
|
||||||
|
error=data.get("error"),
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"""Shared monitoring action helpers.
|
|
||||||
|
|
||||||
The router and the background poller both use these helpers so machine
|
|
||||||
operations are recorded consistently whether they were triggered by a user
|
|
||||||
request or by the backend's scheduled polling loop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import shlex
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
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
|
|
||||||
from media_library_viewer_api.observability import record_ssh_command
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def build_machine_client(machine: dict[str, Any], store: SettingsStore):
|
|
||||||
"""Build the appropriate command client for a machine definition."""
|
|
||||||
mode = str(machine.get("mode") or "local").strip().lower()
|
|
||||||
if mode == "local":
|
|
||||||
return LocalCommandClient()
|
|
||||||
|
|
||||||
key_directory = str(machine.get("key_directory") or "").strip()
|
|
||||||
key_name = str(machine.get("key_name") or "").strip()
|
|
||||||
key_path = f"{key_directory}/{key_name}" if key_directory and key_name else None
|
|
||||||
private_key = str(machine.get("ssh_private_key") or "")
|
|
||||||
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
|
|
||||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
|
||||||
if ssh_key_id:
|
|
||||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
|
||||||
if ssh_key:
|
|
||||||
private_key = str(ssh_key.get("private_key") or private_key)
|
|
||||||
passphrase = str(ssh_key.get("passphrase") or passphrase)
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
return RemoteSSHClient(
|
|
||||||
host=str(machine.get("host") or ""),
|
|
||||||
username=str(machine.get("username") or ""),
|
|
||||||
port=int(machine.get("port") or 22),
|
|
||||||
key_filename=key_path,
|
|
||||||
private_key=private_key or None,
|
|
||||||
private_key_passphrase=passphrase or None,
|
|
||||||
password=str(machine.get("password") or "") or None,
|
|
||||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def disk_space(client: Any, path: str = "/") -> dict[str, Any]:
|
|
||||||
"""Return df information for the filesystem containing ``path``.
|
|
||||||
|
|
||||||
Works against any client with a ``run`` method (local shell or SSH).
|
|
||||||
"""
|
|
||||||
command = (
|
|
||||||
"df -P -B1 -- " + shlex.quote(path or "/") + " | awk 'NR==2 {printf "
|
|
||||||
'"{\\"filesystem\\":\\"%s\\",\\"size\\":%s,"'
|
|
||||||
'"\\"used\\":%s,\\"available\\":%s,"'
|
|
||||||
'"\\"used_pct\\":\\"%s\\",\\"mount\\":\\"%s\\"}", "'
|
|
||||||
"$1,$2,$3,$4,$5,$6}'"
|
|
||||||
)
|
|
||||||
logger.debug("Reading disk space for path=%s", path)
|
|
||||||
result = client.run(command, timeout=20)
|
|
||||||
if result.exit_status != 0 or not result.stdout.strip():
|
|
||||||
logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout)
|
|
||||||
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
|
||||||
data = json.loads(result.stdout)
|
|
||||||
logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct"))
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_operation_result(action: str, result: Any) -> str:
|
|
||||||
"""Turn an operation result into a compact human-readable summary."""
|
|
||||||
if result is None:
|
|
||||||
return action
|
|
||||||
if isinstance(result, str):
|
|
||||||
text = result.strip().splitlines()[0] if result.strip() else action
|
|
||||||
return text[:200]
|
|
||||||
if isinstance(result, list):
|
|
||||||
return f"{action}: {len(result)} item(s)"
|
|
||||||
if isinstance(result, dict):
|
|
||||||
if action.startswith("disk lookup"):
|
|
||||||
used_pct = result.get("used_pct")
|
|
||||||
mount = result.get("mount") or result.get("filesystem")
|
|
||||||
return f"disk {mount or ''} used {used_pct or '?'}".strip()
|
|
||||||
if "message" in result and isinstance(result["message"], str):
|
|
||||||
return result["message"][:200]
|
|
||||||
return json_compact(result)
|
|
||||||
return action
|
|
||||||
|
|
||||||
|
|
||||||
def json_compact(value: Any) -> str:
|
|
||||||
try:
|
|
||||||
text = json.dumps(value, sort_keys=True, default=str)
|
|
||||||
return text[:200]
|
|
||||||
except Exception:
|
|
||||||
return str(value)[:200]
|
|
||||||
|
|
||||||
|
|
||||||
def run_machine_operation(
|
|
||||||
machine: dict[str, Any],
|
|
||||||
store: SettingsStore,
|
|
||||||
action: str,
|
|
||||||
callback: Callable[[Any], Any],
|
|
||||||
*,
|
|
||||||
summarize: Callable[[Any], str] | None = None,
|
|
||||||
request_id: str = "",
|
|
||||||
raise_http: bool = True,
|
|
||||||
client: Any | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""Run a machine operation, record history, and optionally raise on failure."""
|
|
||||||
started = time.perf_counter()
|
|
||||||
if client is None:
|
|
||||||
client = build_machine_client(machine, store)
|
|
||||||
try:
|
|
||||||
result = callback(client)
|
|
||||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
||||||
record_ssh_command(
|
|
||||||
machine_id=machine.get("id") or "unknown",
|
|
||||||
action=action,
|
|
||||||
status="ok",
|
|
||||||
duration_seconds=duration_ms / 1000.0,
|
|
||||||
)
|
|
||||||
store.record_machine_action(
|
|
||||||
machine,
|
|
||||||
action,
|
|
||||||
"ok",
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
request_id=request_id,
|
|
||||||
message=(summarize(result) if summarize else summarize_operation_result(action, result)),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc: # pragma: no cover - transport/network fallback
|
|
||||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
||||||
record_ssh_command(
|
|
||||||
machine_id=machine.get("id") or "unknown",
|
|
||||||
action=action,
|
|
||||||
status="error",
|
|
||||||
duration_seconds=duration_ms / 1000.0,
|
|
||||||
)
|
|
||||||
logger.exception(
|
|
||||||
"Monitoring %s failed machine_id=%s machine_name=%s",
|
|
||||||
action,
|
|
||||||
machine["id"],
|
|
||||||
machine["name"],
|
|
||||||
)
|
|
||||||
error_text = str(exc)
|
|
||||||
store.record_machine_action(
|
|
||||||
machine,
|
|
||||||
action,
|
|
||||||
"error",
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
request_id=request_id,
|
|
||||||
error=error_text,
|
|
||||||
)
|
|
||||||
if not raise_http:
|
|
||||||
return None
|
|
||||||
status_code = 503 if machine.get("mode") == "local" else 502
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status_code,
|
|
||||||
detail=f"{machine['name']}: {action} failed: {exc}",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def poll_machine_snapshot(
|
|
||||||
machine: dict[str, Any],
|
|
||||||
store: SettingsStore,
|
|
||||||
*,
|
|
||||||
metrics_limit: int = 70_000,
|
|
||||||
request_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Collect a backend-scheduled snapshot for a machine.
|
|
||||||
|
|
||||||
The legacy POSIX collector has been removed; this now records a lightweight
|
|
||||||
disk-space lookup on the same schedule so action history stays useful.
|
|
||||||
"""
|
|
||||||
request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}"
|
|
||||||
results: dict[str, Any] = {"request_id": request_id, "machine_id": machine.get("id"), "actions": []}
|
|
||||||
|
|
||||||
client = build_machine_client(machine, store)
|
|
||||||
settings = get_settings()
|
|
||||||
path = str(machine.get("media_root") or settings.media_root or "/")
|
|
||||||
disk = run_machine_operation(
|
|
||||||
machine,
|
|
||||||
store,
|
|
||||||
f"disk lookup for {path}",
|
|
||||||
lambda client: disk_space(client, path),
|
|
||||||
request_id=request_id,
|
|
||||||
raise_http=False,
|
|
||||||
client=client,
|
|
||||||
)
|
|
||||||
results["disk_mount"] = (disk or {}).get("mount") if isinstance(disk, dict) else None
|
|
||||||
results["actions"].append("disk lookup")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""Background poller for monitoring machine snapshots.
|
|
||||||
|
|
||||||
The poller runs entirely inside the backend. It periodically reads the defined
|
|
||||||
machines, collects a small snapshot from each enabled machine over SSH or local
|
|
||||||
shell execution, and stores the resulting history rows in the settings DB.
|
|
||||||
|
|
||||||
This keeps the Monitoring page populated without any daemon or agent running on
|
|
||||||
the remote machines.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
|
||||||
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PollerConfig:
|
|
||||||
interval_seconds: int = 300
|
|
||||||
initial_delay_seconds: int = 20
|
|
||||||
metrics_limit: int = 70_000
|
|
||||||
retention_days: int = 30
|
|
||||||
|
|
||||||
|
|
||||||
class MonitoringPoller:
|
|
||||||
"""Single-worker background poller for monitoring snapshots."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
self._stop_event = threading.Event()
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._last_run_at: float | None = None
|
|
||||||
self._last_success_at: float | None = None
|
|
||||||
self._last_error: str = ""
|
|
||||||
self._last_cycle_ms: int | None = None
|
|
||||||
self._poll_count = 0
|
|
||||||
self._error_count = 0
|
|
||||||
|
|
||||||
def _config(self) -> PollerConfig:
|
|
||||||
settings = get_settings()
|
|
||||||
return PollerConfig(
|
|
||||||
interval_seconds=max(30, int(getattr(settings, "monitoring_poll_interval_seconds", 300) or 300)),
|
|
||||||
initial_delay_seconds=max(0, int(getattr(settings, "monitoring_poll_initial_delay_seconds", 20) or 20)),
|
|
||||||
metrics_limit=70_000,
|
|
||||||
retention_days=max(1, int(getattr(settings, "monitoring_action_retention_days", 30) or 30)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
"""Start the background worker if it is not already running."""
|
|
||||||
with self._lock:
|
|
||||||
if self._thread and self._thread.is_alive():
|
|
||||||
return
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._thread = threading.Thread(target=self._run, name="monitoring-poller", daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
logger.info("Monitoring poller started")
|
|
||||||
|
|
||||||
def kick(self) -> None:
|
|
||||||
"""Run one immediate snapshot cycle in the background."""
|
|
||||||
store = get_settings_store()
|
|
||||||
config = self._config()
|
|
||||||
threading.Thread(
|
|
||||||
target=self._run_cycle,
|
|
||||||
args=(store, config),
|
|
||||||
name="monitoring-poller-kick",
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
logger.info("Monitoring poller kick requested")
|
|
||||||
|
|
||||||
def stop(self, timeout: float = 5.0) -> None:
|
|
||||||
"""Stop the worker thread and wait briefly for shutdown."""
|
|
||||||
with self._lock:
|
|
||||||
thread = self._thread
|
|
||||||
if not thread:
|
|
||||||
return
|
|
||||||
self._stop_event.set()
|
|
||||||
thread.join(timeout=timeout)
|
|
||||||
if thread.is_alive():
|
|
||||||
logger.warning("Monitoring poller did not stop within %.1fs", timeout)
|
|
||||||
else:
|
|
||||||
logger.info("Monitoring poller stopped")
|
|
||||||
with self._lock:
|
|
||||||
if self._thread is thread:
|
|
||||||
self._thread = None
|
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
|
||||||
"""Return a small status snapshot for diagnostics and tests."""
|
|
||||||
with self._lock:
|
|
||||||
return {
|
|
||||||
"worker_running": bool(self._thread and self._thread.is_alive()),
|
|
||||||
"stop_requested": self._stop_event.is_set(),
|
|
||||||
"last_run_at": self._last_run_at,
|
|
||||||
"last_success_at": self._last_success_at,
|
|
||||||
"last_error": self._last_error,
|
|
||||||
"last_cycle_ms": self._last_cycle_ms,
|
|
||||||
"poll_count": self._poll_count,
|
|
||||||
"error_count": self._error_count,
|
|
||||||
}
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, Any]:
|
|
||||||
"""Return status plus the active polling configuration."""
|
|
||||||
data = self.status()
|
|
||||||
config = self._config()
|
|
||||||
data.update(
|
|
||||||
{
|
|
||||||
"interval_seconds": config.interval_seconds,
|
|
||||||
"initial_delay_seconds": config.initial_delay_seconds,
|
|
||||||
"retention_days": config.retention_days,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _run_cycle(self, store: SettingsStore, config: PollerConfig) -> None:
|
|
||||||
start = time.perf_counter()
|
|
||||||
machines = store.list_machines()
|
|
||||||
enabled = [machine for machine in machines if machine.get("enabled")]
|
|
||||||
logger.info("Monitoring poll cycle starting enabled_machines=%s", len(enabled))
|
|
||||||
cycle_errors = 0
|
|
||||||
for machine in enabled:
|
|
||||||
if self._stop_event.is_set():
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
snapshot = poll_machine_snapshot(
|
|
||||||
machine,
|
|
||||||
store,
|
|
||||||
metrics_limit=config.metrics_limit,
|
|
||||||
request_id=f"poll:{machine['id']}:{int(time.time())}",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Monitoring poll snapshot machine_id=%s request_id=%s disk_mount=%s actions=%s",
|
|
||||||
machine["id"],
|
|
||||||
snapshot.get("request_id"),
|
|
||||||
snapshot.get("disk_mount"),
|
|
||||||
snapshot.get("actions"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
cycle_errors += 1
|
|
||||||
logger.exception(
|
|
||||||
"Monitoring poll snapshot failed machine_id=%s machine_name=%s", machine["id"], machine["name"]
|
|
||||||
)
|
|
||||||
retention_seconds = config.retention_days * 24 * 60 * 60
|
|
||||||
cutoff_ts = int(time.time()) - retention_seconds
|
|
||||||
removed = store.prune_machine_actions(cutoff_ts)
|
|
||||||
if removed:
|
|
||||||
logger.info("Pruned %s old monitoring action rows older than %s", removed, cutoff_ts)
|
|
||||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
||||||
with self._lock:
|
|
||||||
self._last_run_at = time.time()
|
|
||||||
self._last_cycle_ms = duration_ms
|
|
||||||
self._poll_count += 1
|
|
||||||
if cycle_errors:
|
|
||||||
self._error_count += cycle_errors
|
|
||||||
self._last_error = f"{cycle_errors} machine(s) failed"
|
|
||||||
else:
|
|
||||||
self._last_success_at = self._last_run_at
|
|
||||||
self._last_error = ""
|
|
||||||
logger.info(
|
|
||||||
"Monitoring poll cycle complete enabled_machines=%s errors=%s duration_ms=%s removed_rows=%s",
|
|
||||||
len(enabled),
|
|
||||||
cycle_errors,
|
|
||||||
duration_ms,
|
|
||||||
removed,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
config = self._config()
|
|
||||||
if config.initial_delay_seconds:
|
|
||||||
logger.info("Monitoring poller initial delay=%ss", config.initial_delay_seconds)
|
|
||||||
if self._stop_event.wait(config.initial_delay_seconds):
|
|
||||||
return
|
|
||||||
store = get_settings_store()
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
self._run_cycle(store, config)
|
|
||||||
except Exception:
|
|
||||||
with self._lock:
|
|
||||||
self._last_error = "poller cycle failed"
|
|
||||||
self._error_count += 1
|
|
||||||
logger.exception("Monitoring poller cycle failed")
|
|
||||||
if self._stop_event.wait(config.interval_seconds):
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
_MONITORING_POLLER = MonitoringPoller()
|
|
||||||
|
|
||||||
|
|
||||||
def get_monitoring_poller() -> MonitoringPoller:
|
|
||||||
return _MONITORING_POLLER
|
|
||||||
@@ -18,6 +18,7 @@ from typing import Any
|
|||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.config import get_settings
|
||||||
|
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||||
|
|
||||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||||
LOCAL_MACHINE_ID = "local"
|
LOCAL_MACHINE_ID = "local"
|
||||||
@@ -85,25 +86,10 @@ class SettingsStore:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||||
conn.execute(
|
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||||
"""
|
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
|
||||||
CREATE TABLE IF NOT EXISTS monitoring_machine_actions (
|
# table on startup so existing databases get a clean slate.
|
||||||
id TEXT PRIMARY KEY,
|
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||||
machine_id TEXT NOT NULL,
|
|
||||||
machine_name TEXT NOT NULL,
|
|
||||||
mode TEXT NOT NULL,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
duration_ms INTEGER NOT NULL,
|
|
||||||
request_id TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
error TEXT NOT NULL,
|
|
||||||
stdout_tail TEXT NOT NULL,
|
|
||||||
stderr_tail TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS ssh_keys (
|
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||||
@@ -180,15 +166,21 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time
|
CREATE TABLE IF NOT EXISTS dashboard_widgets (
|
||||||
ON monitoring_machine_actions(machine_id, created_at DESC)
|
id TEXT PRIMARY KEY,
|
||||||
|
addon_id TEXT NOT NULL,
|
||||||
|
widget_type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
|
||||||
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_action_status
|
|
||||||
ON monitoring_machine_actions(action, status)
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
@@ -380,12 +372,8 @@ class SettingsStore:
|
|||||||
"notes": notes,
|
"notes": notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
def ensure_defaults(self) -> None:
|
def _seed_local_machine(self) -> None:
|
||||||
self.init_schema()
|
"""Seed the default local machine if none exists."""
|
||||||
with self.connect() as conn:
|
|
||||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
|
||||||
if row and int(row[0]) > 0:
|
|
||||||
return
|
|
||||||
machine = _default_local_machine()
|
machine = _default_local_machine()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
config = {
|
config = {
|
||||||
@@ -428,6 +416,49 @@ class SettingsStore:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _seed_dashboard_widgets(self) -> None:
|
||||||
|
"""Seed default dashboard widgets only when the table is empty."""
|
||||||
|
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||||
|
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
|
||||||
|
if row and int(row[0]) > 0:
|
||||||
|
return
|
||||||
|
defaults = [
|
||||||
|
{
|
||||||
|
"id": "jellyfin-activity-default",
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "jellyfin",
|
||||||
|
"title": "Jellyfin activity",
|
||||||
|
"config": {"machine_id": ""},
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backups-summary-default",
|
||||||
|
"addon_id": "backups",
|
||||||
|
"widget_type": "backups",
|
||||||
|
"title": "Backups",
|
||||||
|
"config": {},
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for widget in defaults:
|
||||||
|
info = WIDGET_REGISTRY.get(widget["widget_type"])
|
||||||
|
if not info or info["addon_id"] != widget["addon_id"]:
|
||||||
|
continue
|
||||||
|
self.upsert_widget(widget)
|
||||||
|
|
||||||
|
def ensure_defaults(self) -> None:
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||||
|
if not row or int(row[0]) == 0:
|
||||||
|
self._seed_local_machine()
|
||||||
|
self._seed_dashboard_widgets()
|
||||||
|
|
||||||
def list_machines(self) -> list[dict[str, Any]]:
|
def list_machines(self) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
@@ -563,88 +594,6 @@ class SettingsStore:
|
|||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
|
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
|
||||||
|
|
||||||
def record_machine_action(
|
|
||||||
self,
|
|
||||||
machine: dict[str, Any],
|
|
||||||
action: str,
|
|
||||||
status: str,
|
|
||||||
*,
|
|
||||||
duration_ms: int,
|
|
||||||
request_id: str = "",
|
|
||||||
message: str = "",
|
|
||||||
error: str = "",
|
|
||||||
stdout_tail: str = "",
|
|
||||||
stderr_tail: str = "",
|
|
||||||
) -> None:
|
|
||||||
"""Store a compact action history row for a machine operation."""
|
|
||||||
self.init_schema()
|
|
||||||
now = int(time.time())
|
|
||||||
with self.connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO monitoring_machine_actions
|
|
||||||
(
|
|
||||||
id, machine_id, machine_name, mode, action, status,
|
|
||||||
created_at, duration_ms, request_id, message, error,
|
|
||||||
stdout_tail, stderr_tail
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
uuid.uuid4().hex,
|
|
||||||
str(machine.get("id") or ""),
|
|
||||||
str(machine.get("name") or ""),
|
|
||||||
str(machine.get("mode") or "local"),
|
|
||||||
action,
|
|
||||||
status,
|
|
||||||
now,
|
|
||||||
duration_ms,
|
|
||||||
request_id,
|
|
||||||
message,
|
|
||||||
error,
|
|
||||||
stdout_tail,
|
|
||||||
stderr_tail,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_machine_actions(
|
|
||||||
self,
|
|
||||||
machine_id: str,
|
|
||||||
*,
|
|
||||||
limit: int = 20,
|
|
||||||
action: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
self.init_schema()
|
|
||||||
clauses = ["machine_id = ?"]
|
|
||||||
params: list[Any] = [machine_id]
|
|
||||||
if action:
|
|
||||||
clauses.append("action = ?")
|
|
||||||
params.append(action)
|
|
||||||
if status:
|
|
||||||
clauses.append("status = ?")
|
|
||||||
params.append(status)
|
|
||||||
sql = (
|
|
||||||
"SELECT machine_id, machine_name, mode, action, status, "
|
|
||||||
"created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail "
|
|
||||||
f"FROM monitoring_machine_actions WHERE {' AND '.join(clauses)} "
|
|
||||||
"ORDER BY created_at DESC LIMIT ?"
|
|
||||||
)
|
|
||||||
params.append(max(1, min(int(limit), 200)))
|
|
||||||
with self.connect() as conn:
|
|
||||||
rows = conn.execute(sql, params).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def prune_machine_actions(self, older_than_ts: int) -> int:
|
|
||||||
"""Delete action history rows older than the given timestamp."""
|
|
||||||
self.init_schema()
|
|
||||||
with self.connect() as conn:
|
|
||||||
cur = conn.execute(
|
|
||||||
"DELETE FROM monitoring_machine_actions WHERE created_at < ?",
|
|
||||||
(int(older_than_ts),),
|
|
||||||
)
|
|
||||||
return int(cur.rowcount or 0)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _private_key_summary(private_key: str) -> dict[str, str]:
|
def _private_key_summary(private_key: str) -> dict[str, str]:
|
||||||
if not private_key:
|
if not private_key:
|
||||||
@@ -1415,6 +1364,119 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Dashboard widgets
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"addon_id": row["addon_id"],
|
||||||
|
"widget_type": row["widget_type"],
|
||||||
|
"title": row["title"],
|
||||||
|
"config": json.loads(row["config_json"] or "{}"),
|
||||||
|
"enabled": bool(row["enabled"]),
|
||||||
|
"sort_order": int(row["sort_order"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize_widget_payload(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
widget_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
current = self.get_widget(widget_id) if widget_id else None
|
||||||
|
widget_id = (
|
||||||
|
str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip()
|
||||||
|
or uuid.uuid4().hex[:12]
|
||||||
|
)
|
||||||
|
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
|
||||||
|
widget_type = str(
|
||||||
|
payload.get("widget_type") or (current or {}).get("widget_type", "")
|
||||||
|
).strip()
|
||||||
|
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
||||||
|
config = payload.get("config", (current or {}).get("config", {}))
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
config = {}
|
||||||
|
# Defense-in-depth: reject credential keys at the store layer too.
|
||||||
|
_validate_config_keys(config)
|
||||||
|
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||||
|
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
|
||||||
|
return {
|
||||||
|
"id": widget_id,
|
||||||
|
"addon_id": addon_id,
|
||||||
|
"widget_type": widget_type,
|
||||||
|
"title": title,
|
||||||
|
"config": config,
|
||||||
|
"enabled": enabled,
|
||||||
|
"sort_order": sort_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_widgets(self) -> list[dict[str, Any]]:
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_widget(row) for row in rows]
|
||||||
|
|
||||||
|
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||||
|
if not widget_id:
|
||||||
|
return None
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)
|
||||||
|
).fetchone()
|
||||||
|
return self._row_to_widget(row) if row else None
|
||||||
|
|
||||||
|
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
|
||||||
|
self.init_schema()
|
||||||
|
widget = self._normalize_widget_payload(payload, widget_id)
|
||||||
|
now = int(time.time())
|
||||||
|
with self.connect() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT created_at FROM dashboard_widgets WHERE id = ?",
|
||||||
|
(widget["id"],),
|
||||||
|
).fetchone()
|
||||||
|
created_at = int(existing[0]) if existing else now
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO dashboard_widgets (
|
||||||
|
id, addon_id, widget_type, title, config_json, enabled,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
addon_id = excluded.addon_id,
|
||||||
|
widget_type = excluded.widget_type,
|
||||||
|
title = excluded.title,
|
||||||
|
config_json = excluded.config_json,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
sort_order = excluded.sort_order,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
widget["id"],
|
||||||
|
widget["addon_id"],
|
||||||
|
widget["widget_type"],
|
||||||
|
widget["title"],
|
||||||
|
json.dumps(widget["config"]),
|
||||||
|
1 if widget["enabled"] else 0,
|
||||||
|
widget["sort_order"],
|
||||||
|
created_at,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.get_widget(widget["id"]) or widget
|
||||||
|
|
||||||
|
def delete_widget(self, widget_id: str) -> None:
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||||
|
|
||||||
|
|
||||||
_store: SettingsStore | None = None
|
_store: SettingsStore | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Widget subsystem package."""
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Closed, compile-time widget registry.
|
||||||
|
|
||||||
|
New widget types and source adapters require a code change in Phase 1.
|
||||||
|
There is no runtime plugin loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.models.widgets import WidgetTypeInfo
|
||||||
|
|
||||||
|
WIDGET_REGISTRY: dict[str, dict[str, Any]] = {
|
||||||
|
"jellyfin": {
|
||||||
|
"addon_id": "core",
|
||||||
|
"name": "Jellyfin activity",
|
||||||
|
"description": "Live sessions and idle users from a Jellyfin server.",
|
||||||
|
"source_type": "jellyfin",
|
||||||
|
"config_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"machine_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Jellyfin machine id (empty = default)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["machine_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"backups": {
|
||||||
|
"addon_id": "backups",
|
||||||
|
"name": "Backups",
|
||||||
|
"description": "Backup job summary and active alerts.",
|
||||||
|
"source_type": "backups",
|
||||||
|
"config_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"grafana-link": {
|
||||||
|
"addon_id": "grafana",
|
||||||
|
"name": "Grafana link",
|
||||||
|
"description": "Deep-link to a Grafana dashboard or panel.",
|
||||||
|
"source_type": "grafana",
|
||||||
|
"config_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dashboard_uid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Grafana dashboard UID",
|
||||||
|
},
|
||||||
|
"panel_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Optional panel id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["dashboard_uid"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"prometheus-metric": {
|
||||||
|
"addon_id": "prometheus",
|
||||||
|
"name": "Prometheus metric",
|
||||||
|
"description": "Instant query result rendered as a metric.",
|
||||||
|
"source_type": "prometheus",
|
||||||
|
"config_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"promql": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "PromQL instant query",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["promql"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ssh-task": {
|
||||||
|
"addon_id": "ssh-tasks",
|
||||||
|
"name": "SSH task output",
|
||||||
|
"description": "Output of a saved task run on a machine.",
|
||||||
|
"source_type": "ssh_task",
|
||||||
|
"config_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Saved task id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["task_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"static": {
|
||||||
|
"addon_id": "core",
|
||||||
|
"name": "Static text",
|
||||||
|
"description": "Plain text or markdown note.",
|
||||||
|
"source_type": "static",
|
||||||
|
"config_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Text or markdown content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_source_types() -> list[str]:
|
||||||
|
"""Return all registered source type names."""
|
||||||
|
return sorted({info["source_type"] for info in WIDGET_REGISTRY.values()})
|
||||||
|
|
||||||
|
|
||||||
|
def list_widget_types() -> list[WidgetTypeInfo]:
|
||||||
|
"""Return metadata for all registered widget types."""
|
||||||
|
return [
|
||||||
|
WidgetTypeInfo(
|
||||||
|
addon_id=info["addon_id"],
|
||||||
|
widget_type=widget_type,
|
||||||
|
name=info["name"],
|
||||||
|
description=info["description"],
|
||||||
|
source_type=info["source_type"],
|
||||||
|
config_schema=info["config_schema"],
|
||||||
|
)
|
||||||
|
for widget_type, info in WIDGET_REGISTRY.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_widget_info(widget_type: str) -> WidgetTypeInfo | None:
|
||||||
|
"""Return metadata for a single widget type, or None if unknown."""
|
||||||
|
info = WIDGET_REGISTRY.get(widget_type)
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
return WidgetTypeInfo(
|
||||||
|
addon_id=info["addon_id"],
|
||||||
|
widget_type=widget_type,
|
||||||
|
name=info["name"],
|
||||||
|
description=info["description"],
|
||||||
|
source_type=info["source_type"],
|
||||||
|
config_schema=info["config_schema"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_type(value: Any, expected: str) -> bool:
|
||||||
|
if expected == "string":
|
||||||
|
return isinstance(value, str)
|
||||||
|
if expected == "integer":
|
||||||
|
return isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
if expected == "boolean":
|
||||||
|
return isinstance(value, bool)
|
||||||
|
if expected == "number":
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
if expected == "object":
|
||||||
|
return isinstance(value, dict)
|
||||||
|
if expected == "array":
|
||||||
|
return isinstance(value, list)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(widget_type: str, config: dict[str, Any]) -> None:
|
||||||
|
"""Validate a widget config against its registered JSON schema.
|
||||||
|
|
||||||
|
Raises ValueError with a descriptive message if validation fails.
|
||||||
|
Phase 1 supports only required-field and primitive-type checks.
|
||||||
|
"""
|
||||||
|
info = WIDGET_REGISTRY.get(widget_type)
|
||||||
|
if not info:
|
||||||
|
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||||
|
|
||||||
|
schema = info["config_schema"]
|
||||||
|
required = schema.get("required", [])
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
|
||||||
|
for key in required:
|
||||||
|
if key not in config:
|
||||||
|
raise ValueError(f"Missing required config field: {key}")
|
||||||
|
|
||||||
|
for key, value in config.items():
|
||||||
|
prop = properties.get(key)
|
||||||
|
if not prop:
|
||||||
|
# Unknown keys are allowed in Phase 1 unless they look like secrets
|
||||||
|
# (handled by the model validator). Skip type checks for unknowns.
|
||||||
|
continue
|
||||||
|
expected_type = prop.get("type")
|
||||||
|
if expected_type and not _validate_type(value, expected_type):
|
||||||
|
raise ValueError(f"Config field '{key}' must be of type {expected_type}")
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Widget source adapters.
|
||||||
|
|
||||||
|
Each adapter implements a uniform async interface and translates widget
|
||||||
|
configuration into data for the dashboard. Adapters reuse existing clients,
|
||||||
|
machine registries, and environment settings; they never accept arbitrary
|
||||||
|
commands or store credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from media_library_viewer_api.config import get_settings
|
||||||
|
from media_library_viewer_api.dependencies import get_jellyfin_client
|
||||||
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
|
_map_sessions_to_activity_rows,
|
||||||
|
build_backup_dashboard_summary,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.routers.tasks import _client_for_machine, _resolve_machine_for_task
|
||||||
|
from media_library_viewer_api.services.settings_store import get_settings_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_with_machine_id(machine_id: str | None = None) -> Request:
|
||||||
|
"""Build a minimal Starlette Request carrying a machine_id query param."""
|
||||||
|
query = f"machine_id={machine_id}".encode() if machine_id else b""
|
||||||
|
return Request({"type": "http", "query_string": query})
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetSource(Protocol):
|
||||||
|
"""Protocol for widget source adapters."""
|
||||||
|
|
||||||
|
source_type: str
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinWidgetSource:
|
||||||
|
"""Fetch Jellyfin sessions and map them to activity rows."""
|
||||||
|
|
||||||
|
source_type = "jellyfin"
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
request = _request_with_machine_id(config.get("machine_id") or None)
|
||||||
|
client = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(get_jellyfin_client, request),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
sessions = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.sessions),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
rows = _map_sessions_to_activity_rows(sessions)
|
||||||
|
return {"sessions": rows}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("jellyfin adapter failed")
|
||||||
|
return {"error": f"Jellyfin data fetch failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class BackupsWidgetSource:
|
||||||
|
"""Compute the backup dashboard summary."""
|
||||||
|
|
||||||
|
source_type = "backups"
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
store = get_settings_store()
|
||||||
|
summary = build_backup_dashboard_summary(store)
|
||||||
|
return summary.model_dump()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("backups adapter failed")
|
||||||
|
return {"error": f"Backup summary failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class GrafanaWidgetSource:
|
||||||
|
"""Build a Grafana deep-link (no embedding)."""
|
||||||
|
|
||||||
|
source_type = "grafana"
|
||||||
|
timeout = 5
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
dashboard_uid = config.get("dashboard_uid")
|
||||||
|
if not dashboard_uid:
|
||||||
|
return {"error": "dashboard_uid is required"}
|
||||||
|
url = f"{settings.grafana_url.rstrip('/')}/d/{dashboard_uid}"
|
||||||
|
panel_id = config.get("panel_id")
|
||||||
|
if panel_id is not None:
|
||||||
|
url = f"{url}?viewPanel={panel_id}"
|
||||||
|
return {"url": url}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("grafana adapter failed")
|
||||||
|
return {"error": f"Grafana link failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusWidgetSource:
|
||||||
|
"""Run a PromQL instant query against Prometheus."""
|
||||||
|
|
||||||
|
source_type = "prometheus"
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
promql = config.get("promql")
|
||||||
|
if not promql:
|
||||||
|
return {"error": "promql is required"}
|
||||||
|
url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query"
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(
|
||||||
|
requests.get,
|
||||||
|
url,
|
||||||
|
params={"query": promql},
|
||||||
|
timeout=self.timeout,
|
||||||
|
),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
return {"result": payload.get("data", {})}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.exception("prometheus adapter failed")
|
||||||
|
return {"error": f"Prometheus query failed: {exc}"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("prometheus adapter failed")
|
||||||
|
return {"error": f"Prometheus query failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class SshTaskWidgetSource:
|
||||||
|
"""Run a saved task from the registry and return its output."""
|
||||||
|
|
||||||
|
source_type = "ssh_task"
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
store = get_settings_store()
|
||||||
|
task_id = config.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return {"error": "task_id is required"}
|
||||||
|
task = store.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return {"error": f"Task {task_id} not found"}
|
||||||
|
if not task.get("enabled", True):
|
||||||
|
return {"error": "Task is disabled"}
|
||||||
|
|
||||||
|
machine = _resolve_machine_for_task(store, task, None)
|
||||||
|
if not machine:
|
||||||
|
return {"error": "No machine available for this task"}
|
||||||
|
|
||||||
|
client = _client_for_machine(store, machine)
|
||||||
|
task_type = str(task.get("task_type") or "shell").lower()
|
||||||
|
command = str(task.get("content") or "")
|
||||||
|
if task_type == "python":
|
||||||
|
command = f"python3 -c {shlex.quote(command)}"
|
||||||
|
elif task_type != "shell":
|
||||||
|
return {"error": f"Unknown task type: {task_type}"}
|
||||||
|
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.run, command, timeout=self.timeout),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"exit_status": result.exit_status,
|
||||||
|
"stdout": result.stdout or "",
|
||||||
|
"stderr": result.stderr or "",
|
||||||
|
}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("ssh_task adapter failed")
|
||||||
|
return {"error": f"SSH task failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class StaticWidgetSource:
|
||||||
|
"""Return static text/markdown unchanged."""
|
||||||
|
|
||||||
|
source_type = "static"
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"text": config.get("text", "")}
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_REGISTRY: dict[str, WidgetSource] = {
|
||||||
|
"jellyfin": JellyfinWidgetSource(),
|
||||||
|
"backups": BackupsWidgetSource(),
|
||||||
|
"grafana": GrafanaWidgetSource(),
|
||||||
|
"prometheus": PrometheusWidgetSource(),
|
||||||
|
"ssh_task": SshTaskWidgetSource(),
|
||||||
|
"static": StaticWidgetSource(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_adapter(source_type: str) -> WidgetSource | None:
|
||||||
|
"""Return the adapter for a source type, or None if unknown."""
|
||||||
|
return SOURCE_REGISTRY.get(source_type)
|
||||||
@@ -598,34 +598,6 @@ class TestJobs:
|
|||||||
|
|
||||||
|
|
||||||
class TestMonitoring:
|
class TestMonitoring:
|
||||||
def _ensure_machine(self):
|
|
||||||
store = app.dependency_overrides[get_settings_store]()
|
|
||||||
if not store.list_machines():
|
|
||||||
store.upsert_machine(
|
|
||||||
{
|
|
||||||
"name": "Test Machine",
|
|
||||||
"mode": "ssh",
|
|
||||||
"enabled": True,
|
|
||||||
"services": ["monitoring", "files", "jellyfin"],
|
|
||||||
"host": "test-host",
|
|
||||||
"username": "test-user",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_disk(self, test_client, mock_ssh):
|
|
||||||
self._ensure_machine()
|
|
||||||
mock_ssh.run.return_value = CommandResult(
|
|
||||||
command="df ...",
|
|
||||||
exit_status=0,
|
|
||||||
stdout='{"filesystem":"/dev/sda1","size":1000000000,"used":500000000,"available":500000000,"used_pct":"50%","mount":"/"}',
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
with patch("media_library_viewer_api.services.monitoring_actions.build_machine_client", return_value=mock_ssh):
|
|
||||||
response = test_client.get("/api/monitoring/disk")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["used_pct"] == "50%"
|
|
||||||
|
|
||||||
def test_prometheus_targets_empty(self, test_client):
|
def test_prometheus_targets_empty(self, test_client):
|
||||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
|
|
||||||
|
|
||||||
|
|
||||||
def test_poll_machine_snapshot_records_disk_lookup():
|
|
||||||
store = MagicMock()
|
|
||||||
machine = {
|
|
||||||
"id": "local",
|
|
||||||
"name": "This machine",
|
|
||||||
"mode": "local",
|
|
||||||
"media_root": "/srv/media",
|
|
||||||
}
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"media_library_viewer_api.services.monitoring_actions.build_machine_client",
|
|
||||||
return_value=object(),
|
|
||||||
) as build_client,
|
|
||||||
patch(
|
|
||||||
"media_library_viewer_api.services.monitoring_actions.disk_space",
|
|
||||||
return_value={"mount": "/srv/media", "used_pct": "12.5%"},
|
|
||||||
) as disk_fn,
|
|
||||||
):
|
|
||||||
result = poll_machine_snapshot(machine, store, metrics_limit=123, request_id="poll:test")
|
|
||||||
|
|
||||||
assert result["request_id"] == "poll:test"
|
|
||||||
assert result["disk_mount"] == "/srv/media"
|
|
||||||
assert result["actions"] == ["disk lookup"]
|
|
||||||
build_client.assert_called_once_with(machine, store)
|
|
||||||
disk_fn.assert_called_once_with(build_client.return_value, "/srv/media")
|
|
||||||
assert store.record_machine_action.call_count == 1
|
|
||||||
recorded_action = store.record_machine_action.call_args
|
|
||||||
assert recorded_action.args[1] == "disk lookup for /srv/media"
|
|
||||||
assert recorded_action.kwargs["request_id"] == "poll:test"
|
|
||||||
assert recorded_action.args[2] == "ok"
|
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
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
|
||||||
|
from media_library_viewer_api.widgets.sources import (
|
||||||
|
SOURCE_REGISTRY,
|
||||||
|
GrafanaWidgetSource,
|
||||||
|
SshTaskWidgetSource,
|
||||||
|
StaticWidgetSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
"""FastAPI test client with a fresh settings store and auth disabled."""
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.ensure_defaults()
|
||||||
|
app.dependency_overrides[get_settings_store] = lambda: store
|
||||||
|
auth_settings = SimpleNamespace(auth_enabled=False)
|
||||||
|
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
|
||||||
|
yield TestClient(app)
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_sources(client):
|
||||||
|
response = client.get("/api/widgets/sources")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert set(response.json()) == {
|
||||||
|
"jellyfin",
|
||||||
|
"backups",
|
||||||
|
"grafana",
|
||||||
|
"prometheus",
|
||||||
|
"ssh_task",
|
||||||
|
"static",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_types(client):
|
||||||
|
response = client.get("/api/widgets/types")
|
||||||
|
assert response.status_code == 200
|
||||||
|
types = {item["widget_type"] for item in response.json()}
|
||||||
|
assert types == {
|
||||||
|
"jellyfin",
|
||||||
|
"backups",
|
||||||
|
"grafana-link",
|
||||||
|
"prometheus-metric",
|
||||||
|
"ssh-task",
|
||||||
|
"static",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_read_widget(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Note",
|
||||||
|
"config": {"text": "hello"},
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
widget = response.json()
|
||||||
|
assert widget["title"] == "Note"
|
||||||
|
assert widget["config"] == {"text": "hello"}
|
||||||
|
assert widget["enabled"] is True
|
||||||
|
assert widget["sort_order"] == 5
|
||||||
|
widget_id = widget["id"]
|
||||||
|
|
||||||
|
response = client.get("/api/widgets/instances")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert any(w["id"] == widget_id for w in response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_widget(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Note",
|
||||||
|
"config": {"text": "hello"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/widgets/instances/{widget_id}",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Updated",
|
||||||
|
"config": {"text": "world"},
|
||||||
|
"enabled": False,
|
||||||
|
"sort_order": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "Updated"
|
||||||
|
assert data["config"] == {"text": "world"}
|
||||||
|
assert data["enabled"] is False
|
||||||
|
assert data["sort_order"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_widget(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "To delete",
|
||||||
|
"config": {"text": "bye"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.delete(f"/api/widgets/instances/{widget_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = client.get("/api/widgets/instances")
|
||||||
|
assert not any(w["id"] == widget_id for w in response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_widget_type_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "unknown",
|
||||||
|
"title": "Bad",
|
||||||
|
"config": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_addon_id_mismatch_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "grafana",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Bad",
|
||||||
|
"config": {"text": "x"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_key_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Bad",
|
||||||
|
"config": {"api_key": "secret123"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nonexistent_widget(client):
|
||||||
|
response = client.put(
|
||||||
|
"/api/widgets/instances/does-not-exist",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Bad",
|
||||||
|
"config": {"text": "x"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_nonexistent_widget(client):
|
||||||
|
response = client.delete("/api/widgets/instances/does-not-exist")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_widgets_seeded(client):
|
||||||
|
response = client.get("/api/widgets/instances")
|
||||||
|
assert response.status_code == 200
|
||||||
|
widgets = response.json()
|
||||||
|
types = [w["widget_type"] for w in widgets]
|
||||||
|
assert "jellyfin" in types
|
||||||
|
assert "backups" in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_reseed_when_widgets_exist(tmp_path):
|
||||||
|
db_path = tmp_path / "settings.sqlite"
|
||||||
|
store = SettingsStore(db_path)
|
||||||
|
store.ensure_defaults()
|
||||||
|
widgets = store.list_widgets()
|
||||||
|
assert len(widgets) == 2
|
||||||
|
|
||||||
|
store.delete_widget(widgets[0]["id"])
|
||||||
|
store.ensure_defaults()
|
||||||
|
|
||||||
|
remaining = store.list_widgets()
|
||||||
|
assert len(remaining) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_id_mismatch_returns_400(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Note",
|
||||||
|
"config": {"text": "hello"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/widgets/instances/{widget_id}",
|
||||||
|
json={
|
||||||
|
"id": "different-id",
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Updated",
|
||||||
|
"config": {"text": "world"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_title_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "",
|
||||||
|
"config": {"text": "hello"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_type_error_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "grafana",
|
||||||
|
"widget_type": "grafana-link",
|
||||||
|
"title": "Grafana",
|
||||||
|
"config": {"panel_id": "not-an-integer"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_instances_respects_sort_order(client):
|
||||||
|
response = client.get("/api/widgets/instances")
|
||||||
|
assert response.status_code == 200
|
||||||
|
widgets = response.json()
|
||||||
|
orders = [w["sort_order"] for w in widgets]
|
||||||
|
assert orders == sorted(orders)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_round_trip(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Toggle",
|
||||||
|
"config": {"text": "x"},
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/widgets/instances/{widget_id}",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Toggle",
|
||||||
|
"config": {"text": "x"},
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_static_widget_data(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Note",
|
||||||
|
"config": {"text": "hello world"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["widget_id"] == widget_id
|
||||||
|
assert data["widget_type"] == "static"
|
||||||
|
assert data["data"] == {"text": "hello world"}
|
||||||
|
assert data["error"] is None
|
||||||
|
assert isinstance(data["fetched_at"], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_grafana_widget_data(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "grafana",
|
||||||
|
"widget_type": "grafana-link",
|
||||||
|
"title": "Grafana",
|
||||||
|
"config": {"dashboard_uid": "overview", "panel_id": 3},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["widget_type"] == "grafana-link"
|
||||||
|
assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_prometheus_widget_data(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "prometheus",
|
||||||
|
"widget_type": "prometheus-metric",
|
||||||
|
"title": "CPU",
|
||||||
|
"config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
fake_payload = {"data": {"resultType": "scalar", "result": [1718900000, "42.5"]}}
|
||||||
|
with patch("media_library_viewer_api.widgets.sources.requests.get") as mock_get:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = fake_payload
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["widget_type"] == "prometheus-metric"
|
||||||
|
assert data["data"]["result"]["resultType"] == "scalar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_jellyfin_widget_data_error(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "jellyfin",
|
||||||
|
"title": "Activity",
|
||||||
|
"config": {"machine_id": ""},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["widget_type"] == "jellyfin"
|
||||||
|
assert data["data"] is None
|
||||||
|
assert data["error"] is not None
|
||||||
|
assert "Jellyfin" in data["error"] or "machine" in data["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_widget_data_not_found(client):
|
||||||
|
response = client.get("/api/widgets/instances/does-not-exist/data")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_widget_data_unhandled_exception_returns_500(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"addon_id": "core",
|
||||||
|
"widget_type": "static",
|
||||||
|
"title": "Note",
|
||||||
|
"config": {"text": "x"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
widget_id = response.json()["id"]
|
||||||
|
|
||||||
|
class _ExplodingAdapter:
|
||||||
|
source_type = "static"
|
||||||
|
|
||||||
|
async def fetch(self, config):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
with patch("media_library_viewer_api.routers.widgets.get_source_adapter", return_value=_ExplodingAdapter()):
|
||||||
|
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_static_adapter():
|
||||||
|
adapter = StaticWidgetSource()
|
||||||
|
result = await adapter.fetch({"text": "hello"})
|
||||||
|
assert result == {"text": "hello"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grafana_adapter():
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2})
|
||||||
|
assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2"
|
||||||
|
|
||||||
|
result = await adapter.fetch({"dashboard_uid": "overview"})
|
||||||
|
assert result["url"] == "http://grafana:3000/d/overview"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ssh_task_adapter_timeout(tmp_path):
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.ensure_defaults()
|
||||||
|
|
||||||
|
# Create a local machine and a simple shell task.
|
||||||
|
machine = store.list_machines()[0]
|
||||||
|
task = store.upsert_task(
|
||||||
|
{
|
||||||
|
"name": "slow-task",
|
||||||
|
"task_type": "shell",
|
||||||
|
"content": "echo hello",
|
||||||
|
"enabled": True,
|
||||||
|
"default_machine_id": machine["id"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = SshTaskWidgetSource()
|
||||||
|
with patch(
|
||||||
|
"media_library_viewer_api.widgets.sources.get_settings_store",
|
||||||
|
return_value=store,
|
||||||
|
), patch(
|
||||||
|
"media_library_viewer_api.widgets.sources.asyncio.wait_for",
|
||||||
|
side_effect=asyncio.TimeoutError,
|
||||||
|
):
|
||||||
|
result = await adapter.fetch({"task_id": task["id"]})
|
||||||
|
|
||||||
|
assert "error" in result
|
||||||
|
assert "timed out" in result["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_registry_closed():
|
||||||
|
assert set(SOURCE_REGISTRY.keys()) == {
|
||||||
|
"jellyfin",
|
||||||
|
"backups",
|
||||||
|
"grafana",
|
||||||
|
"prometheus",
|
||||||
|
"ssh_task",
|
||||||
|
"static",
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ services:
|
|||||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||||
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
||||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||||
|
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||||
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||||
|
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -37,6 +39,7 @@ services:
|
|||||||
VITE_API_URL: "/api"
|
VITE_API_URL: "/api"
|
||||||
VITE_OIDC_ENABLED: "false"
|
VITE_OIDC_ENABLED: "false"
|
||||||
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
||||||
|
VITE_GRAFANA_URL: "http://localhost:3000"
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ services:
|
|||||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||||
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
||||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||||
|
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||||
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||||
|
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
|
||||||
volumes:
|
volumes:
|
||||||
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -69,6 +71,7 @@ services:
|
|||||||
VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI:?set VITE_OIDC_REDIRECT_URI}
|
VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI:?set VITE_OIDC_REDIRECT_URI}
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
||||||
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
||||||
|
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
|
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
|
||||||
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ repo/
|
|||||||
| `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals |
|
| `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals |
|
||||||
| `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown |
|
| `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown |
|
||||||
| `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info |
|
| `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info |
|
||||||
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? |
|
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? *(legacy/removed)* |
|
||||||
| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples |
|
| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples *(legacy/removed)* |
|
||||||
| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root |
|
| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root *(removed 2026-06-17; metrics now in Prometheus/Grafana)* |
|
||||||
| `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector |
|
| `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector |
|
||||||
| `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector |
|
| `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector |
|
||||||
| `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector |
|
| `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector |
|
||||||
|
|||||||
+70
-2
@@ -10,6 +10,67 @@ Build Manage, a compact web application for browsing a remote Jellyfin media lib
|
|||||||
|
|
||||||
Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server monitoring, and safe job templates.
|
Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server monitoring, and safe job templates.
|
||||||
|
|
||||||
|
## Frontend Design System & Architecture
|
||||||
|
|
||||||
|
The Manage frontend is a React + TypeScript SPA built on a **single design system**.
|
||||||
|
The legacy Material UI (MUI v9) / Emotion / recharts / D3 / `theme.ts` stack has been
|
||||||
|
fully removed (web-ui-rework; see decision log 2026-06-17).
|
||||||
|
|
||||||
|
### Design system
|
||||||
|
|
||||||
|
- **shadcn/ui** components + **Tailwind CSS v4** + **lucide-react** icons are the only UI layer.
|
||||||
|
- Design tokens live as CSS `@theme` tokens in `frontend/src/index.css` (light + `.dark`),
|
||||||
|
with the primary brand color `#4f8cff`.
|
||||||
|
- The `chart-1`..`chart-5` color tokens are **repurposed as status / Grafana-link color
|
||||||
|
cues** (not charts): `chart-1`=info/brand, `chart-2`=success/healthy, `chart-3`=warning,
|
||||||
|
`chart-4`=destructive, `chart-5`=neutral accent. No token value changed.
|
||||||
|
- Removed from the frontend dependency tree: `@mui/material`, `@mui/icons-material`,
|
||||||
|
`@mui/x-data-grid`, `@emotion/react`, `@emotion/styled`, `recharts`, `d3`, and the
|
||||||
|
no-op `src/theme.ts` shim.
|
||||||
|
|
||||||
|
### Thin-dashboard observability model
|
||||||
|
|
||||||
|
- The app does **no in-app charting**. Metrics, charts, and logs live in the external,
|
||||||
|
decoupled observability stack (Prometheus / Loki / Grafana / Alertmanager).
|
||||||
|
- In-app observability surfaces (`/observability`) show **Alertmanager alerts, Prometheus
|
||||||
|
target health, machine health, and Grafana deep-links** (per-machine metric/log panels),
|
||||||
|
not rendered graphs.
|
||||||
|
- The legacy in-app D3 monitoring charts and the POSIX remote resource collector are
|
||||||
|
superseded by this Grafana-based model (see decision log 2026-06-13 and 2026-06-17).
|
||||||
|
- **Manage no longer scrapes its own system metrics** (decision 2026-06-17). The backend
|
||||||
|
`MonitoringPoller` (which SSH-ran `df` on every machine every 5 minutes into a local
|
||||||
|
SQLite `monitoring_machine_actions` table), the `/api/monitoring/disk`, `/poller`, and
|
||||||
|
`/machines/{id}/actions` endpoints, and the frontend `DiskSpaceCard` have been removed.
|
||||||
|
Disk/CPU/memory visibility is owned by Prometheus + node_exporter + Grafana. The
|
||||||
|
`disk_usage` **job template** in Actions remains as a manual on-demand SSH check.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
|
||||||
|
wrapper (`components/ui/data-table.tsx`).
|
||||||
|
- Parity is **visibility-only**: pagination, row selection, row click, and column
|
||||||
|
visibility are supported. There is **no client sorting and no column resizing**.
|
||||||
|
- Media uses **server-driven pagination** (`manualPagination` + `rowCount`); the File
|
||||||
|
Browser renders the full listing without pagination.
|
||||||
|
- The Media and File Browser tables previously used `@mui/x-data-grid`; both now use the
|
||||||
|
TanStack `DataTable` (earlier "AG Grid" / `@mui/x-data-grid` references are superseded).
|
||||||
|
|
||||||
|
### Reconciled information architecture
|
||||||
|
|
||||||
|
- **Backups** is a top-level navigation item at `/backups`.
|
||||||
|
- The media/applications surface is named **Media** and lives at `/media`; `/applications`
|
||||||
|
redirects to `/media`, mirroring the existing `/monitoring` → `/observability` redirect.
|
||||||
|
- User deep-links (`/users?user=<id>`), dashboard shortcut deep-links, and the Media →
|
||||||
|
File Browser row-click navigation are preserved under the reconciled routes.
|
||||||
|
|
||||||
|
### Frontend testing
|
||||||
|
|
||||||
|
- Component tests run on **Vitest + @testing-library/react** (`npm test`), with the
|
||||||
|
`@testing-library/jest-dom` matchers.
|
||||||
|
- Legacy plain-Node suites (`frontend/tests/*.test.mjs`) run via
|
||||||
|
`node --test tests/*.test.mjs` (npm script `test:node`).
|
||||||
|
- The build/lint gate is `npm run build` (`tsc -b` + `vite build`) + `npm run lint` (ESLint).
|
||||||
|
|
||||||
## Core Requirements
|
## Core Requirements
|
||||||
|
|
||||||
### Jellyfin Library
|
### Jellyfin Library
|
||||||
@@ -82,7 +143,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
- Support manual path entry and refresh.
|
- Support manual path entry and refresh.
|
||||||
- Remote file listing must be compact, structured, and navigable.
|
- Remote file listing must be compact, structured, and navigable.
|
||||||
- The file table should be read-only.
|
- The file table should be read-only.
|
||||||
- The file table should use row selection (single-select) in an AG Grid format consistent with the Media tab.
|
- The file table should use row selection (single-select) in a TanStack `DataTable` format consistent with the Media tab (both migrated off the legacy `@mui/x-data-grid`/AG Grid).
|
||||||
- The file table should not expose a visible checkbox selection column.
|
- The file table should not expose a visible checkbox selection column.
|
||||||
- The file table should not show a visible `selected` column.
|
- The file table should not show a visible `selected` column.
|
||||||
- Include a top `[UP] ..` row, when not at `/`, to navigate to the parent directory.
|
- Include a top `[UP] ..` row, when not at `/`, to navigate to the parent directory.
|
||||||
@@ -166,7 +227,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
- The dashboard should present disk space as a single combined card with the progress/fill bar embedded inside the card and the size breakdown laid out clearly, with centered sub-card text for the Used/Free/Total breakdown and consistent vertical spacing across the dashboard cards.
|
- The dashboard should present disk space as a single combined card with the progress/fill bar embedded inside the card and the size breakdown laid out clearly, with centered sub-card text for the Used/Free/Total breakdown and consistent vertical spacing across the dashboard cards.
|
||||||
- The disk usage bar should change color as usage increases so high utilization is easy to notice at a glance.
|
- The disk usage bar should change color as usage increases so high utilization is easy to notice at a glance.
|
||||||
- The disk usage card should avoid redundant percentage labels next to the bar if the bar itself already communicates the value.
|
- The disk usage card should avoid redundant percentage labels next to the bar if the bar itself already communicates the value.
|
||||||
- Render Monitoring charts directly with D3 so the UI can support brush-based range selection, hover tooltips with a moving vertical cursor and snapped point markers, summary chips, and moving averages without a separate wrapper library.
|
- (Superseded by the thin-dashboard observability model — 2026-06-17.) The app no longer renders in-app monitoring charts with D3; metrics/charts/logs live in the external Grafana stack, and the in-app Observability page surfaces Alertmanager alerts, Prometheus target health, and Grafana deep-links.
|
||||||
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
|
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
|
||||||
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
|
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
|
||||||
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
|
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
|
||||||
@@ -197,6 +258,8 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
|
||||||
|
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
|
||||||
|
- 2026-06-17: Completed the web UI rework to a single design system. The frontend now uses **shadcn/ui + Tailwind CSS v4 + lucide-react** exclusively, with CSS `@theme` tokens in `src/index.css` (primary `#4f8cff`; `chart-1..5` repurposed as status/Grafana-link cues). Removed `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, `@emotion/styled`, `recharts`, `d3`, and the `src/theme.ts` shim. Tables moved from `@mui/x-data-grid`/AG Grid to a visibility-only TanStack `DataTable` wrapper (pagination, row selection, row click, column visibility — no sorting/resizing). Adopted the thin-dashboard observability model (no in-app charts; Alertmanager alerts + Prometheus target health + Grafana deep-links). Reconciled the information architecture: Backups is a top-level nav item at `/backups`, and the media surface is named Media at `/media` with `/applications` redirecting to `/media` (mirroring `/monitoring` → `/observability`). Frontend tests moved to Vitest + @testing-library/react (`npm test`), with legacy node suites in `frontend/tests`.
|
||||||
- 2026-06-13: Adopted a dedicated, self-hosted observability subsystem based on Prometheus, Grafana Loki, Grafana, and Alertmanager. Metrics will be pulled from Node Exporter on machines and from application exporters in containers; logs will be structured JSON shipped by Promtail/Grafana Alloy. The existing POSIX remote collector will be removed and backup alerts migrated to Alertmanager rules. See `docs/monitoring-logging-design.md`.
|
- 2026-06-13: Adopted a dedicated, self-hosted observability subsystem based on Prometheus, Grafana Loki, Grafana, and Alertmanager. Metrics will be pulled from Node Exporter on machines and from application exporters in containers; logs will be structured JSON shipped by Promtail/Grafana Alloy. The existing POSIX remote collector will be removed and backup alerts migrated to Alertmanager rules. See `docs/monitoring-logging-design.md`.
|
||||||
- 2026-06-13 (Phase 1): Added Prometheus, Loki, Grafana Alloy, Grafana, Alertmanager, and Node Exporter services to `docker-compose.yml` and `docker-compose.dev.yml`. Provisioned Grafana datasources and an initial `Manage Overview` dashboard as code. Configured Alloy to tail Docker logs and ship to Loki. Added Grafana generic OAuth configuration via `monitoring/grafana/grafana.ini` and a dedicated Traefik host rule. Added Alertmanager email routing with env-var interpolation. Added `/grafana` proxy to the Vite dev server for iframe embedding.
|
- 2026-06-13 (Phase 1): Added Prometheus, Loki, Grafana Alloy, Grafana, Alertmanager, and Node Exporter services to `docker-compose.yml` and `docker-compose.dev.yml`. Provisioned Grafana datasources and an initial `Manage Overview` dashboard as code. Configured Alloy to tail Docker logs and ship to Loki. Added Grafana generic OAuth configuration via `monitoring/grafana/grafana.ini` and a dedicated Traefik host rule. Added Alertmanager email routing with env-var interpolation. Added `/grafana` proxy to the Vite dev server for iframe embedding.
|
||||||
- 2026-06-13 (Phase 2): Extended machine settings with `node_exporter_enabled`, `node_exporter_port`, and `node_exporter_scrape_host`. Added Node Exporter install/restart/status job templates to `jobs.py`. Implemented `media_library_viewer_api.services.targets` to generate Prometheus file-SD target files and wired target regeneration into machine create/update/delete. Added `/api/monitoring/prometheus-targets` for live target previews. Configured Prometheus with a `node-exporter-remote` job reading file SD from the backend cache volume. Added a minimal `Node Exporter Overview` Grafana dashboard. Added unit and integration tests for target generation and the new endpoint.
|
- 2026-06-13 (Phase 2): Extended machine settings with `node_exporter_enabled`, `node_exporter_port`, and `node_exporter_scrape_host`. Added Node Exporter install/restart/status job templates to `jobs.py`. Implemented `media_library_viewer_api.services.targets` to generate Prometheus file-SD target files and wired target regeneration into machine create/update/delete. Added `/api/monitoring/prometheus-targets` for live target previews. Configured Prometheus with a `node-exporter-remote` job reading file SD from the backend cache volume. Added a minimal `Node Exporter Overview` Grafana dashboard. Added unit and integration tests for target generation and the new endpoint.
|
||||||
@@ -289,9 +352,11 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
## Backup Monitoring
|
## Backup Monitoring
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
The system receives backup execution reports from an external backup tool via HTTP API, stores job and run history, and provides alerting on failures, missed schedules, and anomalies.
|
The system receives backup execution reports from an external backup tool via HTTP API, stores job and run history, and provides alerting on failures, missed schedules, and anomalies.
|
||||||
|
|
||||||
### API
|
### API
|
||||||
|
|
||||||
- `POST /api/backups/report` — Submit backup run (Bearer token auth)
|
- `POST /api/backups/report` — Submit backup run (Bearer token auth)
|
||||||
- `POST /api/backups/report/start` — Mark backup as in_progress
|
- `POST /api/backups/report/start` — Mark backup as in_progress
|
||||||
- `GET /api/backups/jobs` — List jobs
|
- `GET /api/backups/jobs` — List jobs
|
||||||
@@ -301,16 +366,19 @@ The system receives backup execution reports from an external backup tool via HT
|
|||||||
- `GET /api/dashboard/backups` — Dashboard summary
|
- `GET /api/dashboard/backups` — Dashboard summary
|
||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
- **BackupJob**: id, name, source, target, schedule_interval_seconds, created_at
|
- **BackupJob**: id, name, source, target, schedule_interval_seconds, created_at
|
||||||
- **BackupRun**: id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json
|
- **BackupRun**: id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json
|
||||||
- **BackupAlert**: id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at
|
- **BackupAlert**: id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at
|
||||||
|
|
||||||
### Alert Types
|
### Alert Types
|
||||||
|
|
||||||
- `failed_status` — Backup reported failure (critical)
|
- `failed_status` — Backup reported failure (critical)
|
||||||
- `missed_schedule` — No run within 1.5x expected interval (warning)
|
- `missed_schedule` — No run within 1.5x expected interval (warning)
|
||||||
- `anomaly_size` — Size is 0 or <10% / >300% of 7-day median (warning)
|
- `anomaly_size` — Size is 0 or <10% / >300% of 7-day median (warning)
|
||||||
- `anomaly_duration` — Duration >300% of 7-day median (warning)
|
- `anomaly_duration` — Duration >300% of 7-day median (warning)
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- Backup tool uses auto-generated Bearer API key
|
- Backup tool uses auto-generated Bearer API key
|
||||||
- Frontend uses existing OIDC/JWT auth
|
- Frontend uses existing OIDC/JWT auth
|
||||||
|
|||||||
@@ -60,10 +60,16 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
|||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
|
|
||||||
- `backend/src/media_library_viewer_api/clients/resources.py` deploys a POSIX shell collector to `/tmp` on each remote machine.
|
> **Historical note (2026-06-17):** The legacy Manage-side `MonitoringPoller` that
|
||||||
- The collector samples `/proc/stat`, `/proc/meminfo`, `/proc/net/dev`, and `/sys/block/*/stat` every 10s and writes JSONL to `/tmp/media_library_viewer_metrics.jsonl`.
|
> SSH-scraped `/proc` + `df` into a local SQLite table (`monitoring_machine_actions`)
|
||||||
- `MonitoringPoller` (`monitoring_poller.py`) runs every 5 minutes, reads the remote JSONL, and stores snapshots in SQLite (`monitoring_machine_actions`).
|
> has been **decommissioned**. System metrics now live entirely in the external
|
||||||
- Retention defaults to 30 days with periodic pruning.
|
> observability stack: `node_exporter` on each machine is scraped by **Prometheus**
|
||||||
|
> and visualised in **Grafana** (see the standalone `docker-compose.observability.yml`
|
||||||
|
> stack). Manage is a thin dashboard: it surfaces Alertmanager alerts + Prometheus
|
||||||
|
> target health + Grafana deep-links, and does not collect or store its own metrics.
|
||||||
|
|
||||||
|
- `main.py` has a `log_requests` middleware that emits method, path, client IP, status code, and elapsed time.
|
||||||
|
- Frontend uses standard `console.log` / browser dev tools; no server-side log aggregation.
|
||||||
|
|
||||||
### Alerting
|
### Alerting
|
||||||
|
|
||||||
@@ -210,7 +216,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
|||||||
- Manage API overview (request rate, latency, errors).
|
- Manage API overview (request rate, latency, errors).
|
||||||
- Manage operations (SSH commands, media index builds, mail queue).
|
- Manage operations (SSH commands, media index builds, mail queue).
|
||||||
- Backup runs and alert history.
|
- Backup runs and alert history.
|
||||||
- Manage iframe embeds point to specific dashboard panels using Grafana's `panelId` and ` kiosk` mode.
|
- Manage iframe embeds point to specific dashboard panels using Grafana's `panelId` and `kiosk` mode.
|
||||||
|
|
||||||
### Manage React UI
|
### Manage React UI
|
||||||
|
|
||||||
@@ -309,6 +315,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
|||||||
- [x] Wire Grafana OAuth to Authentik.
|
- [x] Wire Grafana OAuth to Authentik.
|
||||||
|
|
||||||
**Phase 1 files**:
|
**Phase 1 files**:
|
||||||
|
|
||||||
- `monitoring/prometheus/prometheus.yml`
|
- `monitoring/prometheus/prometheus.yml`
|
||||||
- `monitoring/prometheus/rules/backup_alerts.yml`
|
- `monitoring/prometheus/rules/backup_alerts.yml`
|
||||||
- `monitoring/loki/loki.yml`
|
- `monitoring/loki/loki.yml`
|
||||||
@@ -336,6 +343,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
|||||||
- [x] Remove POSIX collector fallback. The legacy collector code in `backend/src/media_library_viewer_api/clients/resources.py` has been deleted, the collector control endpoints were removed from `routers/monitoring.py`, and `disk_space` was relocated to `services/monitoring_actions.py` as a lightweight SSH/local helper. Metrics are now sourced exclusively from Prometheus/Node Exporter.
|
- [x] Remove POSIX collector fallback. The legacy collector code in `backend/src/media_library_viewer_api/clients/resources.py` has been deleted, the collector control endpoints were removed from `routers/monitoring.py`, and `disk_space` was relocated to `services/monitoring_actions.py` as a lightweight SSH/local helper. Metrics are now sourced exclusively from Prometheus/Node Exporter.
|
||||||
|
|
||||||
**Phase 2 files**:
|
**Phase 2 files**:
|
||||||
|
|
||||||
- `backend/src/media_library_viewer_api/jobs.py` (Node Exporter job templates).
|
- `backend/src/media_library_viewer_api/jobs.py` (Node Exporter job templates).
|
||||||
- `backend/src/media_library_viewer_api/routers/settings.py` (machine input fields + target regeneration).
|
- `backend/src/media_library_viewer_api/routers/settings.py` (machine input fields + target regeneration).
|
||||||
- `backend/src/media_library_viewer_api/services/settings_store.py` (machine persistence fields).
|
- `backend/src/media_library_viewer_api/services/settings_store.py` (machine persistence fields).
|
||||||
@@ -364,6 +372,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
|||||||
- [x] Added tests for the Alertmanager endpoints and the backup success gauge.
|
- [x] Added tests for the Alertmanager endpoints and the backup success gauge.
|
||||||
|
|
||||||
**Phase 3 files**:
|
**Phase 3 files**:
|
||||||
|
|
||||||
- `backend/src/media_library_viewer_api/routers/monitoring.py` (`/alerts` and `/alertmanager-status` endpoints).
|
- `backend/src/media_library_viewer_api/routers/monitoring.py` (`/alerts` and `/alertmanager-status` endpoints).
|
||||||
- `backend/src/media_library_viewer_api/observability.py` (`BACKUP_RUNS_LAST_SUCCESS` gauge + updated `record_backup_run`).
|
- `backend/src/media_library_viewer_api/observability.py` (`BACKUP_RUNS_LAST_SUCCESS` gauge + updated `record_backup_run`).
|
||||||
- `backend/src/media_library_viewer_api/routers/backups.py` (pass `success=True` to `record_backup_run` on successful reports).
|
- `backend/src/media_library_viewer_api/routers/backups.py` (pass `success=True` to `record_backup_run` on successful reports).
|
||||||
@@ -386,6 +395,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
|||||||
- [x] Wire the new `/observability` route into `App.tsx` and the sidebar navigation.
|
- [x] Wire the new `/observability` route into `App.tsx` and the sidebar navigation.
|
||||||
|
|
||||||
**Phase 4 files**:
|
**Phase 4 files**:
|
||||||
|
|
||||||
- `frontend/src/components/ObservabilityPage.tsx` (page component).
|
- `frontend/src/components/ObservabilityPage.tsx` (page component).
|
||||||
- `frontend/src/hooks/useObservability.ts` (React Query hooks).
|
- `frontend/src/hooks/useObservability.ts` (React Query hooks).
|
||||||
- `frontend/src/api/client.ts` (API client functions).
|
- `frontend/src/api/client.ts` (API client functions).
|
||||||
@@ -406,6 +416,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
|||||||
- [ ] Optional: add OpenTelemetry Collector as a translation layer for traces later.
|
- [ ] Optional: add OpenTelemetry Collector as a translation layer for traces later.
|
||||||
|
|
||||||
**Phase 5 files**:
|
**Phase 5 files**:
|
||||||
|
|
||||||
- `docker-compose.yml` and `docker-compose.dev.yml` (health checks, resource limits, `depends_on` conditions).
|
- `docker-compose.yml` and `docker-compose.dev.yml` (health checks, resource limits, `depends_on` conditions).
|
||||||
- `monitoring/prometheus/prometheus.yml` (additional scrape jobs for observability services).
|
- `monitoring/prometheus/prometheus.yml` (additional scrape jobs for observability services).
|
||||||
- `monitoring/prometheus/rules/backup_alerts.yml` (renamed scope to include observability health alerts).
|
- `monitoring/prometheus/rules/backup_alerts.yml` (renamed scope to include observability health alerts).
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ARG VITE_OIDC_SCOPE=openid profile email
|
|||||||
ARG VITE_OIDC_REDIRECT_URI=
|
ARG VITE_OIDC_REDIRECT_URI=
|
||||||
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
||||||
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
|
ARG VITE_GRAFANA_URL=https://grafana.example.com
|
||||||
ARG VITE_APP_VERSION=0.1.0
|
ARG VITE_APP_VERSION=0.1.0
|
||||||
ARG VITE_APP_BUILD_INFO=dev
|
ARG VITE_APP_BUILD_INFO=dev
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ ENV VITE_API_URL=${VITE_API_URL} \
|
|||||||
VITE_OIDC_REDIRECT_URI=${VITE_OIDC_REDIRECT_URI} \
|
VITE_OIDC_REDIRECT_URI=${VITE_OIDC_REDIRECT_URI} \
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
|
||||||
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
|
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
|
||||||
|
VITE_GRAFANA_URL=${VITE_GRAFANA_URL} \
|
||||||
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
||||||
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ COPY frontend/ ./
|
|||||||
ENV VITE_API_URL=/api \
|
ENV VITE_API_URL=/api \
|
||||||
VITE_OIDC_ENABLED=false \
|
VITE_OIDC_ENABLED=false \
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
||||||
|
VITE_GRAFANA_URL=http://localhost:3000 \
|
||||||
VITE_APP_VERSION=0.1.0 \
|
VITE_APP_VERSION=0.1.0 \
|
||||||
VITE_APP_BUILD_INFO=dev
|
VITE_APP_BUILD_INFO=dev
|
||||||
|
|
||||||
|
|||||||
Generated
+1126
-1491
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -7,19 +7,17 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:node": "node --test tests/*.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@emotion/styled": "^11.14.1",
|
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@mui/icons-material": "^9.0.0",
|
|
||||||
"@mui/material": "^9.0.0",
|
|
||||||
"@mui/x-data-grid": "^9.0.4",
|
|
||||||
"@tanstack/react-query": "^5.100.6",
|
"@tanstack/react-query": "^5.100.6",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3": "^7.9.0",
|
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -27,7 +25,6 @@
|
|||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-oidc-context": "^3.3.1",
|
"react-oidc-context": "^3.3.1",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"recharts": "^3.8.1",
|
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
@@ -36,7 +33,9 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -46,10 +45,12 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-5
@@ -43,6 +43,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Activity,
|
Activity,
|
||||||
|
DatabaseBackup,
|
||||||
Monitor,
|
Monitor,
|
||||||
Users,
|
Users,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -82,8 +83,9 @@ function useDarkMode() {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ path: "/observability", label: "Observability", icon: Activity },
|
{ path: "/observability", label: "Observability", icon: Activity },
|
||||||
{ path: "/applications", label: "Media", icon: Monitor },
|
{ path: "/media", label: "Media", icon: Monitor },
|
||||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||||
|
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
||||||
{ path: "/users", label: "Users", icon: Users },
|
{ path: "/users", label: "Users", icon: Users },
|
||||||
{ path: "/actions", label: "Actions", icon: Zap },
|
{ path: "/actions", label: "Actions", icon: Zap },
|
||||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||||
@@ -432,9 +434,15 @@ function AppInner() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AuthenticatedApp />}>
|
<Route element={<AuthenticatedApp />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
|
<Route
|
||||||
<Route path="/applications" element={<Applications />} />
|
path="/monitoring"
|
||||||
|
element={<Navigate to="/observability" replace />}
|
||||||
|
/>
|
||||||
<Route path="/media" element={<Applications />} />
|
<Route path="/media" element={<Applications />} />
|
||||||
|
<Route
|
||||||
|
path="/applications"
|
||||||
|
element={<Navigate to="/media" replace />}
|
||||||
|
/>
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/actions" element={<Actions />} />
|
<Route path="/actions" element={<Actions />} />
|
||||||
<Route path="/files" element={<FileBrowser />} />
|
<Route path="/files" element={<FileBrowser />} />
|
||||||
@@ -457,9 +465,15 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
|
<Route
|
||||||
<Route path="/applications" element={<Applications />} />
|
path="/monitoring"
|
||||||
|
element={<Navigate to="/observability" replace />}
|
||||||
|
/>
|
||||||
<Route path="/media" element={<Applications />} />
|
<Route path="/media" element={<Applications />} />
|
||||||
|
<Route
|
||||||
|
path="/applications"
|
||||||
|
element={<Navigate to="/media" replace />}
|
||||||
|
/>
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/actions" element={<Actions />} />
|
<Route path="/actions" element={<Actions />} />
|
||||||
<Route path="/files" element={<FileBrowser />} />
|
<Route path="/files" element={<FileBrowser />} />
|
||||||
|
|||||||
@@ -1,56 +1,73 @@
|
|||||||
import { Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import type { BackupAlert } from "../types/backups";
|
import type { BackupAlert } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
alerts: BackupAlert[];
|
alerts: BackupAlert[];
|
||||||
onAcknowledge: (alertId: string) => void;
|
onAcknowledge: (alertId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleString();
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeverityVariant = "destructive" | "warning";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an alert severity onto a Badge variant per design §2.3.
|
||||||
|
* `critical` → destructive (chart-4); `warning` → warning (chart-3).
|
||||||
|
*/
|
||||||
|
function severityVariant(severity: string): SeverityVariant {
|
||||||
|
return severity === "critical" ? "destructive" : "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper}>
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table>
|
<Table aria-label="Backup alerts">
|
||||||
<TableHead>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableCell>Severity</TableCell>
|
<TableHead>Severity</TableHead>
|
||||||
<TableCell>Type</TableCell>
|
<TableHead>Type</TableHead>
|
||||||
<TableCell>Message</TableCell>
|
<TableHead>Message</TableHead>
|
||||||
<TableCell>Created</TableCell>
|
<TableHead>Created</TableHead>
|
||||||
<TableCell>Actions</TableCell>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<TableRow key={alert.id} hover>
|
<TableRow key={alert.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<Badge variant={severityVariant(alert.severity)}>
|
||||||
label={alert.severity}
|
{alert.severity}
|
||||||
color={alert.severity === "critical" ? "error" : "warning"}
|
</Badge>
|
||||||
size="small"
|
</TableCell>
|
||||||
/>
|
<TableCell>{alert.alert_type}</TableCell>
|
||||||
</TableCell>
|
<TableCell>{alert.message}</TableCell>
|
||||||
<TableCell>{alert.alert_type}</TableCell>
|
<TableCell>{formatTimestamp(alert.created_at)}</TableCell>
|
||||||
<TableCell>{alert.message}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{formatTimestamp(alert.created_at)}</TableCell>
|
{!alert.acknowledged && (
|
||||||
<TableCell>
|
<Button
|
||||||
{!alert.acknowledged && (
|
size="sm"
|
||||||
<Button
|
variant="outline"
|
||||||
size="small"
|
onClick={() => onAcknowledge(alert.id)}
|
||||||
variant="outlined"
|
>
|
||||||
onClick={() => onAcknowledge(alert.id)}
|
Acknowledge
|
||||||
>
|
</Button>
|
||||||
Acknowledge
|
)}
|
||||||
</Button>
|
</TableCell>
|
||||||
)}
|
</TableRow>
|
||||||
</TableCell>
|
))}
|
||||||
</TableRow>
|
</TableBody>
|
||||||
))}
|
</Table>
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
);
|
||||||
</TableContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,49 @@
|
|||||||
import { Card, CardContent, Typography, Box, Chip } from "@mui/material";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useBackupDashboard } from "../hooks/useBackups";
|
import { useBackupDashboard } from "../hooks/useBackups";
|
||||||
|
|
||||||
export default function BackupDashboardWidget() {
|
export default function BackupDashboardWidget() {
|
||||||
const { data, isLoading } = useBackupDashboard();
|
const { data, isLoading } = useBackupDashboard();
|
||||||
|
|
||||||
if (isLoading || !data) {
|
return (
|
||||||
return (
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle>Backups</CardTitle>
|
||||||
<Typography variant="h6">Backups</Typography>
|
</CardHeader>
|
||||||
<Typography color="text.secondary">Loading...</Typography>
|
<CardContent>
|
||||||
</CardContent>
|
{isLoading || !data ? (
|
||||||
</Card>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
);
|
) : (
|
||||||
}
|
<div className="flex flex-row flex-wrap gap-6">
|
||||||
|
<div>
|
||||||
return (
|
<div className="text-2xl font-semibold">{data.total_jobs}</div>
|
||||||
<Card>
|
<div className="text-xs text-muted-foreground">Jobs</div>
|
||||||
<CardContent>
|
</div>
|
||||||
<Typography variant="h6" gutterBottom>Backups</Typography>
|
<div>
|
||||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
<div className="text-2xl font-semibold">
|
||||||
<Box>
|
{data.success_rate_24h}%
|
||||||
<Typography variant="h4">{data.total_jobs}</Typography>
|
</div>
|
||||||
<Typography variant="body2" color="text.secondary">Jobs</Typography>
|
<div className="text-xs text-muted-foreground">24h Success</div>
|
||||||
</Box>
|
</div>
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="h4">{data.success_rate_24h}%</Typography>
|
<div className="text-2xl font-semibold">
|
||||||
<Typography variant="body2" color="text.secondary">24h Success</Typography>
|
{data.active_alerts > 0 ? (
|
||||||
</Box>
|
<Badge variant="destructive">{data.active_alerts}</Badge>
|
||||||
<Box>
|
) : (
|
||||||
<Typography variant="h4">
|
0
|
||||||
{data.active_alerts > 0 ? (
|
)}
|
||||||
<Chip label={data.active_alerts} color="error" size="small" />
|
</div>
|
||||||
) : (
|
<div className="text-xs text-muted-foreground">Alerts</div>
|
||||||
0
|
</div>
|
||||||
)}
|
{data.last_failed_at && (
|
||||||
</Typography>
|
<div className="self-center text-xs text-destructive">
|
||||||
<Typography variant="body2" color="text.secondary">Alerts</Typography>
|
Last failed:{" "}
|
||||||
</Box>
|
{new Date(data.last_failed_at * 1000).toLocaleString()}
|
||||||
{data.last_failed_at && (
|
</div>
|
||||||
<Box>
|
)}
|
||||||
<Typography variant="body2" color="error">
|
</div>
|
||||||
Last failed: {new Date(data.last_failed_at * 1000).toLocaleString()}
|
)}
|
||||||
</Typography>
|
</CardContent>
|
||||||
</Box>
|
</Card>
|
||||||
)}
|
);
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,90 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Chip,
|
Table,
|
||||||
Paper,
|
TableBody,
|
||||||
Table,
|
TableCell,
|
||||||
TableBody,
|
TableHead,
|
||||||
TableCell,
|
TableHeader,
|
||||||
TableContainer,
|
TableRow,
|
||||||
TableHead,
|
} from "@/components/ui/table";
|
||||||
TableRow,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type { BackupJob, BackupRun } from "../types/backups";
|
import type { BackupJob, BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
jobs: BackupJob[];
|
jobs: BackupJob[];
|
||||||
latestRuns: Map<string, BackupRun>;
|
latestRuns: Map<string, BackupRun>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatInterval(seconds: number | null): string {
|
function formatInterval(seconds: number | null): string {
|
||||||
if (!seconds) return "N/A";
|
if (!seconds) return "N/A";
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||||
return `${Math.floor(seconds / 86400)}d`;
|
return `${Math.floor(seconds / 86400)}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number | null): string {
|
function formatTimestamp(ts: number | null): string {
|
||||||
if (!ts) return "Never";
|
if (!ts) return "Never";
|
||||||
return new Date(ts * 1000).toLocaleString();
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusVariant = "success" | "destructive" | "warning" | "secondary";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a job/run status onto a Badge variant per design §2.3:
|
||||||
|
* `success` → success (chart-2); `failure` → destructive (chart-4);
|
||||||
|
* `in_progress` → warning (chart-3); unknown → secondary (neutral accent).
|
||||||
|
*/
|
||||||
|
function statusVariant(status: string): StatusVariant {
|
||||||
|
if (status === "success") return "success";
|
||||||
|
if (status === "failure") return "destructive";
|
||||||
|
if (status === "in_progress") return "warning";
|
||||||
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper}>
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table>
|
<Table aria-label="Backup jobs">
|
||||||
<TableHead>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableCell>Name</TableCell>
|
<TableHead>Name</TableHead>
|
||||||
<TableCell>Source</TableCell>
|
<TableHead>Source</TableHead>
|
||||||
<TableCell>Target</TableCell>
|
<TableHead>Target</TableHead>
|
||||||
<TableCell>Schedule</TableCell>
|
<TableHead>Schedule</TableHead>
|
||||||
<TableCell>Last Status</TableCell>
|
<TableHead>Last Status</TableHead>
|
||||||
<TableCell>Last Run</TableCell>
|
<TableHead>Last Run</TableHead>
|
||||||
<TableCell>Next Expected</TableCell>
|
<TableHead>Next Expected</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{jobs.map((job) => {
|
{jobs.map((job) => {
|
||||||
const run = latestRuns.get(job.id);
|
const run = latestRuns.get(job.id);
|
||||||
const status = run?.status ?? "unknown";
|
const status = run?.status ?? "unknown";
|
||||||
const nextExpected = run && job.schedule_interval_seconds
|
const nextExpected =
|
||||||
? run.started_at + job.schedule_interval_seconds
|
run && job.schedule_interval_seconds
|
||||||
: null;
|
? run.started_at + job.schedule_interval_seconds
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={job.id} hover>
|
<TableRow key={job.id}>
|
||||||
<TableCell>{job.name}</TableCell>
|
<TableCell>{job.name}</TableCell>
|
||||||
<TableCell>{job.source ?? "—"}</TableCell>
|
<TableCell>{job.source ?? "—"}</TableCell>
|
||||||
<TableCell>{job.target ?? "—"}</TableCell>
|
<TableCell>{job.target ?? "—"}</TableCell>
|
||||||
<TableCell>{formatInterval(job.schedule_interval_seconds)}</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
{formatInterval(job.schedule_interval_seconds)}
|
||||||
<Chip
|
</TableCell>
|
||||||
label={status}
|
<TableCell>
|
||||||
color={
|
<Badge variant={statusVariant(status)}>{status}</Badge>
|
||||||
status === "success"
|
</TableCell>
|
||||||
? "success"
|
<TableCell>
|
||||||
: status === "failure"
|
{formatTimestamp(run?.started_at ?? null)}
|
||||||
? "error"
|
</TableCell>
|
||||||
: status === "in_progress"
|
<TableCell>{formatTimestamp(nextExpected)}</TableCell>
|
||||||
? "warning"
|
</TableRow>
|
||||||
: "default"
|
);
|
||||||
}
|
})}
|
||||||
size="small"
|
</TableBody>
|
||||||
/>
|
</Table>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>{formatTimestamp(run?.started_at ?? null)}</TableCell>
|
);
|
||||||
<TableCell>{formatTimestamp(nextExpected)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,110 @@
|
|||||||
import {
|
|
||||||
Chip,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import type { BackupRun } from "../types/backups";
|
import type { BackupRun } from "../types/backups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
runs: BackupRun[];
|
runs: BackupRun[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number | null): string {
|
function formatBytes(bytes: number | null): string {
|
||||||
if (bytes === null || bytes === undefined) return "—";
|
if (bytes === null || bytes === undefined) return "—";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
if (bytes < 1024 * 1024 * 1024)
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number | null): string {
|
function formatDuration(ms: number | null): string {
|
||||||
if (ms === null || ms === undefined) return "—";
|
if (ms === null || ms === undefined) return "—";
|
||||||
if (ms < 1000) return `${ms}ms`;
|
if (ms < 1000) return `${ms}ms`;
|
||||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
|
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
|
||||||
return `${(ms / 3600_000).toFixed(1)}h`;
|
return `${(ms / 3600_000).toFixed(1)}h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleString();
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusVariant = "success" | "destructive" | "warning";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a run status onto a Badge variant per design §2.3:
|
||||||
|
* `success` → success (chart-2); `failure` → destructive (chart-4);
|
||||||
|
* `in_progress` → warning (chart-3).
|
||||||
|
*/
|
||||||
|
function statusVariant(status: string): StatusVariant {
|
||||||
|
if (status === "success") return "success";
|
||||||
|
if (status === "failure") return "destructive";
|
||||||
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BackupRunsTable({ runs }: Props) {
|
export default function BackupRunsTable({ runs }: Props) {
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
|
||||||
const filteredRuns = statusFilter === "all"
|
const filteredRuns =
|
||||||
? runs
|
statusFilter === "all"
|
||||||
: runs.filter((r) => r.status === statusFilter);
|
? runs
|
||||||
|
: runs.filter((r) => r.status === statusFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-3">
|
||||||
<FormControl sx={{ minWidth: 120, mb: 2 }}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<InputLabel>Status</InputLabel>
|
<SelectTrigger className="w-[160px]" aria-label="Status filter">
|
||||||
<Select
|
<SelectValue placeholder="Status" />
|
||||||
value={statusFilter}
|
</SelectTrigger>
|
||||||
label="Status"
|
<SelectContent>
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
<SelectItem value="all">All</SelectItem>
|
||||||
>
|
<SelectItem value="success">Success</SelectItem>
|
||||||
<MenuItem value="all">All</MenuItem>
|
<SelectItem value="failure">Failure</SelectItem>
|
||||||
<MenuItem value="success">Success</MenuItem>
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
<MenuItem value="failure">Failure</MenuItem>
|
</SelectContent>
|
||||||
<MenuItem value="in_progress">In Progress</MenuItem>
|
</Select>
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<TableContainer component={Paper}>
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
<Table>
|
<Table aria-label="Backup runs">
|
||||||
<TableHead>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableCell>Job</TableCell>
|
<TableHead>Job</TableHead>
|
||||||
<TableCell>Status</TableCell>
|
<TableHead>Status</TableHead>
|
||||||
<TableCell>Duration</TableCell>
|
<TableHead>Duration</TableHead>
|
||||||
<TableCell>Size</TableCell>
|
<TableHead>Size</TableHead>
|
||||||
<TableCell>Started</TableCell>
|
<TableHead>Started</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRuns.map((run) => (
|
{filteredRuns.map((run) => (
|
||||||
<TableRow key={run.id} hover>
|
<TableRow key={run.id}>
|
||||||
<TableCell>{run.job_id}</TableCell>
|
<TableCell>{run.job_id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<Badge variant={statusVariant(run.status)}>
|
||||||
label={run.status}
|
{run.status}
|
||||||
color={
|
</Badge>
|
||||||
run.status === "success"
|
</TableCell>
|
||||||
? "success"
|
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||||
: run.status === "failure"
|
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||||
? "error"
|
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||||
: "warning"
|
</TableRow>
|
||||||
}
|
))}
|
||||||
size="small"
|
</TableBody>
|
||||||
/>
|
</Table>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
</div>
|
||||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
);
|
||||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,72 @@
|
|||||||
import { Box, Tab, Tabs, Typography } from "@mui/material";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
useAcknowledgeAlert,
|
useAcknowledgeAlert,
|
||||||
useBackupAlerts,
|
useBackupAlerts,
|
||||||
useBackupJobs,
|
useBackupJobs,
|
||||||
useBackupRuns,
|
useBackupRuns,
|
||||||
} from "../hooks/useBackups";
|
} from "../hooks/useBackups";
|
||||||
import BackupAlertsTable from "./BackupAlertsTable";
|
import BackupAlertsTable from "./BackupAlertsTable";
|
||||||
import BackupJobsTable from "./BackupJobsTable";
|
import BackupJobsTable from "./BackupJobsTable";
|
||||||
import BackupRunsTable from "./BackupRunsTable";
|
import BackupRunsTable from "./BackupRunsTable";
|
||||||
|
|
||||||
export default function BackupsPage() {
|
export default function BackupsPage() {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState("jobs");
|
||||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
||||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
||||||
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(undefined, false);
|
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
|
||||||
const acknowledgeMutation = useAcknowledgeAlert();
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const acknowledgeMutation = useAcknowledgeAlert();
|
||||||
|
|
||||||
// Build a map of latest runs per job
|
// Build a map of latest runs per job
|
||||||
const latestRuns = new Map();
|
const latestRuns = new Map();
|
||||||
if (runsData) {
|
if (runsData) {
|
||||||
for (const run of runsData) {
|
for (const run of runsData) {
|
||||||
const existing = latestRuns.get(run.job_id);
|
const existing = latestRuns.get(run.job_id);
|
||||||
if (!existing || run.started_at > existing.started_at) {
|
if (!existing || run.started_at > existing.started_at) {
|
||||||
latestRuns.set(run.job_id, run);
|
latestRuns.set(run.job_id, run);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const alertsLabel = alertsData ? `Alerts (${alertsData.length})` : "Alerts";
|
||||||
<Box sx={{ p: 3 }}>
|
|
||||||
<Typography variant="h4" gutterBottom>Backups</Typography>
|
|
||||||
|
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
return (
|
||||||
<Tab label="Jobs" />
|
<div className="space-y-4">
|
||||||
<Tab label="Runs" />
|
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
|
||||||
<Tab label={`Alerts ${alertsData ? `(${alertsData.length})` : ""}`} />
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
</Tabs>
|
<TabsList>
|
||||||
|
<TabsTrigger value="jobs">Jobs</TabsTrigger>
|
||||||
{tab === 0 && (
|
<TabsTrigger value="runs">Runs</TabsTrigger>
|
||||||
jobsLoading ? (
|
<TabsTrigger value="alerts">{alertsLabel}</TabsTrigger>
|
||||||
<Typography>Loading jobs...</Typography>
|
</TabsList>
|
||||||
) : (
|
<TabsContent value="jobs">
|
||||||
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
|
{jobsLoading ? (
|
||||||
)
|
<p className="text-sm text-muted-foreground">Loading jobs…</p>
|
||||||
)}
|
) : (
|
||||||
|
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
|
||||||
{tab === 1 && (
|
)}
|
||||||
runsLoading ? (
|
</TabsContent>
|
||||||
<Typography>Loading runs...</Typography>
|
<TabsContent value="runs">
|
||||||
) : (
|
{runsLoading ? (
|
||||||
<BackupRunsTable runs={runsData ?? []} />
|
<p className="text-sm text-muted-foreground">Loading runs…</p>
|
||||||
)
|
) : (
|
||||||
)}
|
<BackupRunsTable runs={runsData ?? []} />
|
||||||
|
)}
|
||||||
{tab === 2 && (
|
</TabsContent>
|
||||||
alertsLoading ? (
|
<TabsContent value="alerts">
|
||||||
<Typography>Loading alerts...</Typography>
|
{alertsLoading ? (
|
||||||
) : (
|
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
||||||
<BackupAlertsTable
|
) : (
|
||||||
alerts={alertsData ?? []}
|
<BackupAlertsTable
|
||||||
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
|
alerts={alertsData ?? []}
|
||||||
/>
|
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
|
||||||
)
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</TabsContent>
|
||||||
);
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Stack,
|
} from "@/components/ui/dialog";
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { DialogFooter } from "./DialogFooter";
|
import { DialogFooter } from "./DialogFooter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable confirmation dialog built on the shadcn Dialog family and the
|
||||||
|
* shared `DialogFooter`. Same exported props as the MUI version; Esc / overlay
|
||||||
|
* click routes to `onCancel` via `onOpenChange`.
|
||||||
|
*/
|
||||||
export function ConfirmDialog({
|
export function ConfirmDialog({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
@@ -25,23 +30,26 @@ export function ConfirmDialog({
|
|||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
|
<Dialog
|
||||||
<DialogTitle>{title}</DialogTitle>
|
open={open}
|
||||||
<DialogContent>
|
onOpenChange={(next) => {
|
||||||
<Stack spacing={1}>
|
if (!next) onCancel();
|
||||||
<Typography variant="body2" color="text.secondary">
|
}}
|
||||||
{message}
|
>
|
||||||
</Typography>
|
<DialogContent showCloseButton={false}>
|
||||||
</Stack>
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{message}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
confirmLabel={confirmLabel}
|
||||||
|
confirmColor="error"
|
||||||
|
confirmBusyLabel={confirmLabel}
|
||||||
|
confirmDisabled={busy}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter
|
|
||||||
onCancel={onCancel}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
confirmLabel={confirmLabel}
|
|
||||||
confirmColor="error"
|
|
||||||
confirmBusyLabel={confirmLabel}
|
|
||||||
confirmDisabled={busy}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Button, DialogActions } from "@mui/material";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface DialogFooterProps {
|
interface DialogFooterProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -14,6 +14,28 @@ interface DialogFooterProps {
|
|||||||
secondaryAction?: ReactNode;
|
secondaryAction?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the legacy MUI color/variant props onto a shadcn Button variant so
|
||||||
|
* the exported API stays unchanged for consuming pages (ConfirmDialog here,
|
||||||
|
* plus Dashboard/Settings/Actions in later slices).
|
||||||
|
*/
|
||||||
|
function resolveConfirmVariant(
|
||||||
|
color: DialogFooterProps["confirmColor"],
|
||||||
|
variant: DialogFooterProps["confirmVariant"],
|
||||||
|
): "default" | "outline" | "ghost" | "destructive" {
|
||||||
|
if (color === "error") return "destructive";
|
||||||
|
if (variant === "outlined") return "outline";
|
||||||
|
if (variant === "text") return "ghost";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog action row: cancel + optional secondary action + confirm.
|
||||||
|
*
|
||||||
|
* Renders a horizontal Button row (`flex flex-row items-center gap-2`).
|
||||||
|
* Preserves cancel/confirm/secondary-action props and the busy/disabled label
|
||||||
|
* contract (renders `confirmBusyLabel` when provided, else `confirmLabel`).
|
||||||
|
*/
|
||||||
export function DialogFooter({
|
export function DialogFooter({
|
||||||
onCancel,
|
onCancel,
|
||||||
cancelLabel = "Cancel",
|
cancelLabel = "Cancel",
|
||||||
@@ -27,20 +49,23 @@ export function DialogFooter({
|
|||||||
secondaryAction,
|
secondaryAction,
|
||||||
}: DialogFooterProps) {
|
}: DialogFooterProps) {
|
||||||
return (
|
return (
|
||||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
||||||
<Button onClick={onCancel}>{cancelLabel}</Button>
|
<Button variant="ghost" onClick={onCancel}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
{cancelLabel}
|
||||||
{secondaryAction}
|
</Button>
|
||||||
<Button
|
{secondaryAction ? (
|
||||||
variant={confirmVariant}
|
<div className="flex flex-row items-center gap-2">
|
||||||
color={confirmColor}
|
{secondaryAction}
|
||||||
disabled={confirmDisabled}
|
</div>
|
||||||
startIcon={confirmStartIcon}
|
) : null}
|
||||||
onClick={onConfirm}
|
<Button
|
||||||
>
|
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
||||||
{confirmBusyLabel ?? confirmLabel}
|
disabled={confirmDisabled}
|
||||||
</Button>
|
onClick={onConfirm}
|
||||||
</Box>
|
>
|
||||||
</DialogActions>
|
{confirmStartIcon}
|
||||||
|
{confirmBusyLabel ?? confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Grid,
|
|
||||||
LinearProgress,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
used: number;
|
|
||||||
available: number;
|
|
||||||
size: number;
|
|
||||||
usedPct: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (!bytes || bytes === 0) return "0 B";
|
|
||||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
||||||
let value = bytes;
|
|
||||||
let unitIdx = 0;
|
|
||||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
|
||||||
value /= 1000;
|
|
||||||
unitIdx++;
|
|
||||||
}
|
|
||||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dashboard card that summarizes the configured media disk.
|
|
||||||
*
|
|
||||||
* It intentionally keeps the progress bar inside the card so the capacity
|
|
||||||
* signal, raw byte values, and free-space breakdown stay visually grouped.
|
|
||||||
*/
|
|
||||||
export function DiskSpaceCard({ used, available, size, usedPct }: Props) {
|
|
||||||
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
|
|
||||||
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
|
||||||
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
|
|
||||||
<Stack spacing={1.5}>
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textTransform: "uppercase" }}
|
|
||||||
>
|
|
||||||
Disk space
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h5"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: { xs: "1.05rem", sm: "1.5rem" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{usedPct} used
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ width: "100%" }}>
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={pct}
|
|
||||||
color={barColor}
|
|
||||||
sx={{
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
"& .MuiLinearProgress-bar": {
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
|
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
gap: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Used
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{formatBytes(used)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
gap: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Free
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{formatBytes(available)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
gap: 0.25,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Total
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{formatBytes(size)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,37 @@
|
|||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import { Pencil } from "lucide-react";
|
||||||
import { IconButton } from "@mui/material";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface HoverEditButtonProps {
|
interface HoverEditButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover-to-reveal edit affordance.
|
||||||
|
*
|
||||||
|
* Keeps the `rail-edit` class plus the opacity-0 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.
|
||||||
|
*/
|
||||||
export function HoverEditButton({
|
export function HoverEditButton({
|
||||||
onClick,
|
onClick,
|
||||||
label = "Edit",
|
label = "Edit",
|
||||||
}: HoverEditButtonProps) {
|
}: HoverEditButtonProps) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<Button
|
||||||
className="rail-edit"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
size="small"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick();
|
onClick();
|
||||||
}}
|
}}
|
||||||
sx={{
|
|
||||||
opacity: 0,
|
|
||||||
transition: "opacity 120ms ease",
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<EditOutlinedIcon fontSize="inherit" />
|
<Pencil />
|
||||||
</IconButton>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,57 @@
|
|||||||
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import type { LibraryCount } from "../types";
|
import type { LibraryCount } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraries: LibraryCount[];
|
libraries: LibraryCount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column overview of movie and TV libraries on a responsive CSS grid
|
||||||
|
* (`grid grid-cols-1 md:grid-cols-2 gap-4`). Same exported props as the MUI
|
||||||
|
* version; the per-library counts render verbatim.
|
||||||
|
*/
|
||||||
export function LibraryOverview({ libraries }: Props) {
|
export function LibraryOverview({ libraries }: Props) {
|
||||||
const movieLibs = libraries.filter((l) => l.type === "movies");
|
const movieLibs = libraries.filter((l) => l.type === "movies");
|
||||||
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2}>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<div className="flex flex-col gap-4">
|
||||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||||
Movie libraries
|
Movie libraries
|
||||||
</Typography>
|
</h4>
|
||||||
<Stack spacing={1.5}>
|
<div className="flex flex-col gap-4">
|
||||||
{movieLibs.map((lib) => (
|
{movieLibs.map((lib) => (
|
||||||
<Card key={lib.library} variant="outlined">
|
<Card key={lib.library}>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-1">
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<span className="text-base font-semibold">{lib.library}</span>
|
||||||
{lib.library}
|
<span className="text-sm text-muted-foreground">
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Total: {lib.total.toLocaleString()} | Movies:{" "}
|
Total: {lib.total.toLocaleString()} | Movies:{" "}
|
||||||
{lib.movies.toLocaleString()}
|
{lib.movies.toLocaleString()}
|
||||||
</Typography>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<div className="flex flex-col gap-4">
|
||||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||||
TV libraries
|
TV libraries
|
||||||
</Typography>
|
</h4>
|
||||||
<Stack spacing={1.5}>
|
<div className="flex flex-col gap-4">
|
||||||
{tvLibs.map((lib) => (
|
{tvLibs.map((lib) => (
|
||||||
<Card key={lib.library} variant="outlined">
|
<Card key={lib.library}>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-1">
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<span className="text-base font-semibold">{lib.library}</span>
|
||||||
{lib.library}
|
<span className="text-sm text-muted-foreground">
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Total: {lib.total.toLocaleString()} | Series:{" "}
|
Total: {lib.total.toLocaleString()} | Series:{" "}
|
||||||
{lib.series.toLocaleString()}
|
{lib.series.toLocaleString()}
|
||||||
</Typography>
|
</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, CardContent, Typography } from "@mui/material";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -6,44 +6,24 @@ interface Props {
|
|||||||
subtext?: string;
|
subtext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact metric tile: label / value / optional subtext on the comfortable
|
||||||
|
* density ramp (label `text-sm`, value `text-lg font-semibold`, subtext
|
||||||
|
* `text-xs text-muted-foreground`). Same exported props as the MUI version.
|
||||||
|
*/
|
||||||
export function MetricCard({ label, value, subtext }: Props) {
|
export function MetricCard({ label, value, subtext }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
<Card className="h-full">
|
||||||
<CardContent
|
<CardContent className="flex h-full flex-col gap-1.5">
|
||||||
sx={{
|
<span className="text-sm uppercase leading-tight tracking-wide text-muted-foreground">
|
||||||
p: { xs: 1.5, sm: 2 },
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 0.5,
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textTransform: "uppercase", lineHeight: 1.2 }}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</span>
|
||||||
<Typography
|
<span className="text-lg font-semibold leading-tight">{value}</span>
|
||||||
variant="h5"
|
{subtext ? (
|
||||||
sx={{
|
<span className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: { xs: "1.05rem", sm: "1.5rem" },
|
|
||||||
lineHeight: 1.15,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
{subtext && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ whiteSpace: "pre-line", display: "block", lineHeight: 1.35 }}
|
|
||||||
>
|
|
||||||
{subtext}
|
{subtext}
|
||||||
</Typography>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface SectionCardProps {
|
interface SectionCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,6 +8,13 @@ interface SectionCardProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Titled section surface built on the shadcn Card family.
|
||||||
|
*
|
||||||
|
* Comfortable density: `gap-4` between the header row and the body. Exports
|
||||||
|
* the same props/display name as the prior MUI implementation so every
|
||||||
|
* consuming page compiles unchanged.
|
||||||
|
*/
|
||||||
export function SectionCard({
|
export function SectionCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -15,32 +22,18 @@ export function SectionCard({
|
|||||||
children,
|
children,
|
||||||
}: SectionCardProps) {
|
}: SectionCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined">
|
<Card className="gap-4">
|
||||||
<CardContent sx={{ p: 1.5 }}>
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Stack spacing={1.25}>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<Box
|
<div className="min-w-0">
|
||||||
sx={{
|
<h3 className="text-base font-semibold">{title}</h3>
|
||||||
display: "flex",
|
{description ? (
|
||||||
alignItems: "center",
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
justifyContent: "space-between",
|
) : null}
|
||||||
gap: 1,
|
</div>
|
||||||
flexWrap: "wrap",
|
{action}
|
||||||
}}
|
</div>
|
||||||
>
|
{children}
|
||||||
<Box sx={{ minWidth: 0 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{description ? (
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
{action}
|
|
||||||
</Box>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Card, CardContent, Typography } from "@mui/material";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
interface SelectionRailCardProps {
|
interface SelectionRailCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,65 +7,38 @@ interface SelectionRailCardProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
contentSx?: object;
|
contentSx?: object;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
bodySx?: object;
|
bodySx?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection-rail surface: titled header, scrollable body, optional footer.
|
||||||
|
*
|
||||||
|
* Preserves the exported props (`minHeight`, `footer`, and the legacy `*Sx`
|
||||||
|
* no-op passthroughs) so consuming pages (Actions, Settings) compile
|
||||||
|
* unchanged. The scrollable body and footer contract are preserved.
|
||||||
|
*/
|
||||||
export function SelectionRailCard({
|
export function SelectionRailCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
minHeight = 420,
|
minHeight = 420,
|
||||||
contentSx,
|
|
||||||
bodySx,
|
|
||||||
}: SelectionRailCardProps) {
|
}: SelectionRailCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
|
<Card className="h-fit self-start py-0" style={{ minHeight }}>
|
||||||
<CardContent
|
<div className="flex flex-col" style={{ minHeight }}>
|
||||||
sx={{
|
<div className="border-b bg-muted/50 px-4 py-3">
|
||||||
p: 0,
|
<h4 className="text-sm font-semibold tracking-wide">{title}</h4>
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
minHeight,
|
|
||||||
...contentSx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 1.5,
|
|
||||||
py: 1.25,
|
|
||||||
borderBottom: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{ fontWeight: 800, letterSpacing: 0.2 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{description ? (
|
{description ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</div>
|
||||||
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
{footer ? (
|
{footer ? <div className="border-t bg-card p-3">{footer}</div> : null}
|
||||||
<Box
|
</div>
|
||||||
sx={{
|
|
||||||
p: 1,
|
|
||||||
borderTop: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
bgcolor: "background.paper",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{footer}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Paper,
|
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
Typography,
|
} from "@/components/ui/table";
|
||||||
} from "@mui/material";
|
|
||||||
import type { NowPlayingSession } from "../types";
|
import type { NowPlayingSession } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +17,23 @@ interface Props {
|
|||||||
onSelectSession?: (session: NowPlayingSession) => void;
|
onSelectSession?: (session: NowPlayingSession) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionStateVariant = "success" | "warning" | "secondary";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a session state onto a Badge variant per design §2.3.
|
||||||
|
*
|
||||||
|
* `playing` (active/healthy) → `success` (chart-2), `paused` → `warning`
|
||||||
|
* (chart-3), anything else (idle/unknown) → `secondary` (neutral accent).
|
||||||
|
*/
|
||||||
|
function sessionStateVariant(state: string): SessionStateVariant {
|
||||||
|
const normalized = String(state || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (normalized === "playing") return "success";
|
||||||
|
if (normalized === "paused") return "warning";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
function formatStateLabel(state: string): string {
|
function formatStateLabel(state: string): string {
|
||||||
const normalized = String(state || "")
|
const normalized = String(state || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -68,177 +83,91 @@ export function SessionActivityPanel({
|
|||||||
const userFallback = selectedUserLabel || "Unknown user";
|
const userFallback = selectedUserLabel || "Unknown user";
|
||||||
|
|
||||||
if (!sessions.length) {
|
if (!sessions.length) {
|
||||||
return (
|
return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{emptyMessage}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer
|
<div className="max-h-[280px] overflow-auto rounded-lg border border-border">
|
||||||
component={Paper}
|
<Table aria-label="Session activity details" className="min-w-[880px]">
|
||||||
variant="outlined"
|
<TableHeader>
|
||||||
sx={{
|
<TableRow className="bg-card hover:bg-card">
|
||||||
maxHeight: 280,
|
<TableHead className="min-w-[160px]">User</TableHead>
|
||||||
borderColor: "divider",
|
<TableHead className="w-[82px]">State</TableHead>
|
||||||
borderRadius: 1,
|
<TableHead className="min-w-[140px]">Title / Type</TableHead>
|
||||||
overflowX: "auto",
|
<TableHead className="min-w-[140px]">Device</TableHead>
|
||||||
}}
|
<TableHead className="w-[118px]">Transcoding</TableHead>
|
||||||
>
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
stickyHeader
|
|
||||||
aria-label="Session activity details"
|
|
||||||
sx={{ minWidth: 880 }}
|
|
||||||
>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
minWidth: 160,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
User
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
|
|
||||||
>
|
|
||||||
State
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
minWidth: 140,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Title / Type
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
minWidth: 140,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Device
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
width: 118,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Transcoding
|
|
||||||
</TableCell>
|
|
||||||
{onSelectSession ? (
|
{onSelectSession ? (
|
||||||
<TableCell
|
<TableHead className="w-[150px]">Action</TableHead>
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: "background.default",
|
|
||||||
width: 150,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</TableCell>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow className="bg-card hover:bg-card">
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={onSelectSession ? 6 : 5}
|
colSpan={onSelectSession ? 6 : 5}
|
||||||
sx={{ py: 0.75, bgcolor: "background.paper" }}
|
className="bg-card py-3"
|
||||||
>
|
>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<span className="text-xs text-muted-foreground">
|
||||||
{buildStatusSummary(sessions)}
|
{buildStatusSummary(sessions)}
|
||||||
</Typography>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const state = String(session.state || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
const sessionLabel = formatStateLabel(session.state);
|
const sessionLabel = formatStateLabel(session.state);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={session.session_id}
|
key={session.session_id}
|
||||||
hover
|
className={onSelectSession ? "cursor-pointer" : undefined}
|
||||||
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
|
|
||||||
onClick={
|
onClick={
|
||||||
onSelectSession ? () => onSelectSession(session) : undefined
|
onSelectSession ? () => onSelectSession(session) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
|
<TableCell className="min-w-[160px]">
|
||||||
<Typography
|
<div
|
||||||
variant="body2"
|
className="truncate text-sm"
|
||||||
noWrap
|
|
||||||
title={session.user || userFallback}
|
title={session.user || userFallback}
|
||||||
>
|
>
|
||||||
{session.user || userFallback}
|
{session.user || userFallback}
|
||||||
</Typography>
|
</div>
|
||||||
<Typography
|
<div
|
||||||
variant="caption"
|
className="truncate text-xs text-muted-foreground"
|
||||||
color="text.secondary"
|
|
||||||
noWrap
|
|
||||||
title={session.session_id}
|
title={session.session_id}
|
||||||
>
|
>
|
||||||
{session.session_id}
|
{session.session_id}
|
||||||
</Typography>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Chip
|
<Badge variant={sessionStateVariant(session.state)}>
|
||||||
size="small"
|
{sessionLabel}
|
||||||
label={sessionLabel}
|
</Badge>
|
||||||
color={
|
|
||||||
state === "playing"
|
|
||||||
? "primary"
|
|
||||||
: state === "paused"
|
|
||||||
? "warning"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
variant={
|
|
||||||
state === "playing" || state === "paused"
|
|
||||||
? "filled"
|
|
||||||
: "outlined"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
<TableCell className="min-w-[140px]">
|
||||||
<Typography
|
<div className="truncate text-sm" title={session.title || ""}>
|
||||||
variant="body2"
|
|
||||||
noWrap
|
|
||||||
title={session.title || ""}
|
|
||||||
>
|
|
||||||
{session.title || "(idle)"}
|
{session.title || "(idle)"}
|
||||||
</Typography>
|
</div>
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{session.type || "—"}
|
{session.type || "—"}
|
||||||
</Typography>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
<TableCell className="min-w-[140px]">
|
||||||
<Typography variant="body2" noWrap>
|
<div className="truncate text-sm">
|
||||||
{session.device || "Unknown device"}
|
{session.device || "Unknown device"}
|
||||||
</Typography>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Typography variant="body2" noWrap>
|
<span className="text-sm">
|
||||||
{session.transcoding === "yes"
|
{session.transcoding === "yes"
|
||||||
? session.transcoding_type
|
? session.transcoding_type
|
||||||
? `yes (${session.transcoding_type})`
|
? `yes (${session.transcoding_type})`
|
||||||
: "yes"
|
: "yes"
|
||||||
: "no"}
|
: "no"}
|
||||||
</Typography>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{onSelectSession ? (
|
{onSelectSession ? (
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
variant="outline"
|
||||||
variant="outlined"
|
size="sm"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onSelectSession(session);
|
onSelectSession(session);
|
||||||
@@ -253,6 +182,6 @@ export function SessionActivityPanel({
|
|||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { Box, Card, CardContent, Tabs } from "@mui/material";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsList } from "@/components/ui/tabs";
|
||||||
|
|
||||||
interface TabbedCardProps {
|
interface TabbedCardProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
tabs: ReactElement[];
|
tabs: ReactElement[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
contentSx?: object;
|
contentSx?: object;
|
||||||
|
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||||
tabsSx?: object;
|
tabsSx?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card surface with a line-style tab bar on top and a content area below.
|
||||||
|
*
|
||||||
|
* `value`/`onChange` stay string-typed (controlled) and the `tabs` prop stays
|
||||||
|
* `ReactElement[]`, so consuming pages compile unchanged. The page owns the
|
||||||
|
* rendered content from `children` keyed off `value`, exactly as before.
|
||||||
|
*/
|
||||||
export function TabbedCard({
|
export function TabbedCard({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
tabs,
|
tabs,
|
||||||
children,
|
children,
|
||||||
contentSx,
|
|
||||||
tabsSx,
|
|
||||||
}: TabbedCardProps) {
|
}: TabbedCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined">
|
<Card className="gap-0 py-0">
|
||||||
<CardContent sx={{ p: 0 }}>
|
<Tabs value={value} onValueChange={(next) => onChange(String(next))}>
|
||||||
<Tabs
|
<div className="border-b px-2">
|
||||||
value={value}
|
<TabsList variant="line">{tabs}</TabsList>
|
||||||
onChange={(_, next) => onChange(String(next))}
|
</div>
|
||||||
variant="scrollable"
|
<div className="p-4">{children}</div>
|
||||||
scrollButtons="auto"
|
</Tabs>
|
||||||
allowScrollButtonsMobile
|
|
||||||
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
|
|
||||||
>
|
|
||||||
{tabs}
|
|
||||||
</Tabs>
|
|
||||||
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import BackupAlertsTable from "../BackupAlertsTable";
|
||||||
|
import type { BackupAlert } from "../../types/backups";
|
||||||
|
|
||||||
|
function alert(overrides: Partial<BackupAlert> = {}): BackupAlert {
|
||||||
|
return {
|
||||||
|
id: "a1",
|
||||||
|
job_id: "job-1",
|
||||||
|
run_id: null,
|
||||||
|
alert_type: "failed_status",
|
||||||
|
severity: "warning",
|
||||||
|
message: "Run failed",
|
||||||
|
acknowledged: false,
|
||||||
|
resolved_at: null,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackupAlertsTable", () => {
|
||||||
|
it("maps alert severity onto Badge variants per design §2.3", () => {
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[
|
||||||
|
alert({ id: "c", severity: "critical" }),
|
||||||
|
alert({ id: "w", severity: "warning" }),
|
||||||
|
]}
|
||||||
|
onAcknowledge={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("critical").getAttribute("data-variant")).toBe(
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("warning").getAttribute("data-variant")).toBe(
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onAcknowledge with the alert id when the button is clicked", async () => {
|
||||||
|
const onAcknowledge = vi.fn();
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "ack-me" })]}
|
||||||
|
onAcknowledge={onAcknowledge}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Acknowledge" }));
|
||||||
|
expect(onAcknowledge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAcknowledge).toHaveBeenCalledWith("ack-me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the acknowledge button for already-acknowledged alerts", () => {
|
||||||
|
render(
|
||||||
|
<BackupAlertsTable
|
||||||
|
alerts={[alert({ id: "done", acknowledged: true })]}
|
||||||
|
onAcknowledge={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import BackupDashboardWidget from "../BackupDashboardWidget";
|
||||||
|
import { useBackupDashboard } from "../../hooks/useBackups";
|
||||||
|
|
||||||
|
// The widget reads from the react-query hook; mocking `useBackupDashboard` lets
|
||||||
|
// us exercise the render paths without a QueryClientProvider or network.
|
||||||
|
vi.mock("../../hooks/useBackups", () => ({
|
||||||
|
useBackupDashboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseBackupDashboard = vi.mocked(useBackupDashboard);
|
||||||
|
type DashboardResult = ReturnType<typeof useBackupDashboard>;
|
||||||
|
|
||||||
|
function mockResult(
|
||||||
|
data: DashboardResult["data"],
|
||||||
|
isLoading = false,
|
||||||
|
): DashboardResult {
|
||||||
|
return { data, isLoading } as DashboardResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseBackupDashboard.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupDashboardWidget", () => {
|
||||||
|
it("renders the loading state while data is pending", () => {
|
||||||
|
mockUseBackupDashboard.mockReturnValue(mockResult(undefined, true));
|
||||||
|
render(<BackupDashboardWidget />);
|
||||||
|
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the backup dashboard stats (jobs / 24h success)", () => {
|
||||||
|
mockUseBackupDashboard.mockReturnValue(
|
||||||
|
mockResult({
|
||||||
|
total_jobs: 4,
|
||||||
|
success_rate_24h: 96,
|
||||||
|
active_alerts: 0,
|
||||||
|
last_failed_at: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<BackupDashboardWidget />);
|
||||||
|
expect(screen.getByText("4")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("96%")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Jobs")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("24h Success")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a destructive Badge for active alerts and shows last-failed time", () => {
|
||||||
|
mockUseBackupDashboard.mockReturnValue(
|
||||||
|
mockResult({
|
||||||
|
total_jobs: 2,
|
||||||
|
success_rate_24h: 50,
|
||||||
|
active_alerts: 3,
|
||||||
|
last_failed_at: 1_700_000_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<BackupDashboardWidget />);
|
||||||
|
const badge = screen.getByText("3");
|
||||||
|
expect(badge.getAttribute("data-variant")).toBe("destructive");
|
||||||
|
expect(screen.getByText(/Last failed:/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import BackupRunsTable from "../BackupRunsTable";
|
||||||
|
import type { BackupRun } from "../../types/backups";
|
||||||
|
|
||||||
|
function run(overrides: Partial<BackupRun> = {}): BackupRun {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
job_id: "job-1",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackupRunsTable", () => {
|
||||||
|
it("maps run status onto Badge variants per design §2.3", () => {
|
||||||
|
render(
|
||||||
|
<BackupRunsTable
|
||||||
|
runs={[
|
||||||
|
run({ id: "a", status: "success" }),
|
||||||
|
run({ id: "b", status: "failure" }),
|
||||||
|
run({ id: "c", status: "in_progress" }),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("success").getAttribute("data-variant")).toBe(
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("failure").getAttribute("data-variant")).toBe(
|
||||||
|
"destructive",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("in_progress").getAttribute("data-variant")).toBe(
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the formatted duration and transferred size", () => {
|
||||||
|
render(
|
||||||
|
<BackupRunsTable
|
||||||
|
runs={[
|
||||||
|
run({
|
||||||
|
id: "fmt",
|
||||||
|
duration_ms: 1500,
|
||||||
|
bytes_transferred: 2048,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("1.5s")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { ConfirmDialog } from "../ConfirmDialog";
|
||||||
|
|
||||||
|
describe("ConfirmDialog", () => {
|
||||||
|
it("renders the title and message and wires confirm/cancel", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Delete machine?"
|
||||||
|
message="This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open={false}
|
||||||
|
title="Hidden"
|
||||||
|
message="nope"
|
||||||
|
onCancel={() => {}}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { DialogFooter } from "../DialogFooter";
|
||||||
|
|
||||||
|
describe("DialogFooter", () => {
|
||||||
|
it("renders cancel/confirm labels and wires both callbacks", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={onCancel}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
confirmLabel="Save"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the busy label and maps confirmColor=error to destructive", () => {
|
||||||
|
render(
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={() => {}}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
confirmBusyLabel="Deleting…"
|
||||||
|
confirmColor="error"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const confirm = screen.getByRole("button", { name: "Deleting…" });
|
||||||
|
expect(confirm).toBeInTheDocument();
|
||||||
|
expect(confirm.getAttribute("data-variant")).toBe("destructive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the secondary action when provided", () => {
|
||||||
|
render(
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={() => {}}
|
||||||
|
onConfirm={() => {}}
|
||||||
|
confirmLabel="OK"
|
||||||
|
secondaryAction={<button type="button">Test SSH</button>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Test SSH" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { HoverEditButton } from "../HoverEditButton";
|
||||||
|
|
||||||
|
describe("HoverEditButton", () => {
|
||||||
|
it("fires onClick and exposes the default aria-label", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<HoverEditButton onClick={onClick} />);
|
||||||
|
const button = screen.getByRole("button", { name: "Edit" });
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a custom label", () => {
|
||||||
|
render(<HoverEditButton onClick={() => {}} label="Rename machine" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Rename machine" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { LibraryOverview } from "../LibraryOverview";
|
||||||
|
import type { LibraryCount } from "../../types";
|
||||||
|
|
||||||
|
const libraries: LibraryCount[] = [
|
||||||
|
{
|
||||||
|
library: "Films",
|
||||||
|
type: "movies",
|
||||||
|
movies: 100,
|
||||||
|
series: 0,
|
||||||
|
episodes: 0,
|
||||||
|
total: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
library: "Shows",
|
||||||
|
type: "tvshows",
|
||||||
|
movies: 0,
|
||||||
|
series: 12,
|
||||||
|
episodes: 240,
|
||||||
|
total: 252,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("LibraryOverview", () => {
|
||||||
|
it("renders movie and TV library cards with their counts", () => {
|
||||||
|
render(<LibraryOverview libraries={libraries} />);
|
||||||
|
expect(screen.getByText("Movie libraries")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("TV libraries")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Films")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Total: 100 \| Movies: 100/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Shows")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Total: 252 \| Series: 12/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MetricCard } from "../MetricCard";
|
||||||
|
|
||||||
|
describe("MetricCard", () => {
|
||||||
|
it("renders the label, value, and subtext on the comfortable ramp", () => {
|
||||||
|
render(
|
||||||
|
<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Movies")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits subtext when not provided", () => {
|
||||||
|
render(<MetricCard label="Series" value="42" />);
|
||||||
|
expect(screen.getByText("Series")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("42")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/subtext/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { NowPlaying } from "../NowPlaying";
|
||||||
|
|
||||||
|
describe("NowPlaying", () => {
|
||||||
|
it("renders the dashboard empty-state message contract when there are no sessions", () => {
|
||||||
|
render(<NowPlaying sessions={[]} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("No recent user activity sessions right now."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { SectionCard } from "../SectionCard";
|
||||||
|
|
||||||
|
describe("SectionCard", () => {
|
||||||
|
it("renders title, description, action, and children", () => {
|
||||||
|
render(
|
||||||
|
<SectionCard
|
||||||
|
title="Shortcuts"
|
||||||
|
description="Quick links"
|
||||||
|
action={<button type="button">Add</button>}
|
||||||
|
>
|
||||||
|
<p>Body content</p>
|
||||||
|
</SectionCard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Shortcuts")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Quick links")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Body content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without a description or action", () => {
|
||||||
|
render(<SectionCard title="Only title">children</SectionCard>);
|
||||||
|
expect(screen.getByText("Only title")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("children")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { SelectionRailCard } from "../SelectionRailCard";
|
||||||
|
|
||||||
|
describe("SelectionRailCard", () => {
|
||||||
|
it("renders the title, body, and footer and honors minHeight", () => {
|
||||||
|
render(
|
||||||
|
<SelectionRailCard
|
||||||
|
title="Saved tasks"
|
||||||
|
description="Pick one"
|
||||||
|
minHeight={200}
|
||||||
|
footer={<button type="button">New task</button>}
|
||||||
|
>
|
||||||
|
<div>Task A</div>
|
||||||
|
</SelectionRailCard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Saved tasks")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Task A")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "New task" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// minHeight is applied to the Card via inline style.
|
||||||
|
const card = screen
|
||||||
|
.getByText("Saved tasks")
|
||||||
|
.closest("[data-slot='card']") as HTMLElement | null;
|
||||||
|
expect(card?.style.minHeight).toBe("200px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without a footer", () => {
|
||||||
|
render(<SelectionRailCard title="No footer">body</SelectionRailCard>);
|
||||||
|
expect(screen.getByText("No footer")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SessionActivityPanel } from "../SessionActivityPanel";
|
||||||
|
import type { NowPlayingSession } from "../../types";
|
||||||
|
|
||||||
|
function session(
|
||||||
|
overrides: Partial<NowPlayingSession> = {},
|
||||||
|
): NowPlayingSession {
|
||||||
|
return {
|
||||||
|
user: "alice",
|
||||||
|
title: "Movie",
|
||||||
|
type: "Movie",
|
||||||
|
state: "playing",
|
||||||
|
transcoding: "no",
|
||||||
|
transcoding_type: "",
|
||||||
|
device: "Web",
|
||||||
|
session_id: "s1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SessionActivityPanel", () => {
|
||||||
|
it("maps a playing (healthy) session to the success Badge variant", () => {
|
||||||
|
render(<SessionActivityPanel sessions={[session({ state: "playing" })]} />);
|
||||||
|
const badge = screen.getByText("Playing");
|
||||||
|
expect(badge.getAttribute("data-variant")).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps paused → warning and idle → secondary", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SessionActivityPanel sessions={[session({ state: "paused" })]} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
rerender(<SessionActivityPanel sessions={[session({ state: "idle" })]} />);
|
||||||
|
expect(screen.getByText("Idle").getAttribute("data-variant")).toBe(
|
||||||
|
"secondary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the empty-state message when there are no sessions", () => {
|
||||||
|
render(
|
||||||
|
<SessionActivityPanel sessions={[]} emptyMessage="Nothing playing." />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Nothing playing.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelectSession on row click and on the action button", async () => {
|
||||||
|
const onSelectSession = vi.fn();
|
||||||
|
render(
|
||||||
|
<SessionActivityPanel
|
||||||
|
sessions={[session({ state: "playing" })]}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText("alice"));
|
||||||
|
expect(onSelectSession).toHaveBeenCalledTimes(1);
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Open in Users" }),
|
||||||
|
);
|
||||||
|
expect(onSelectSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { TabbedCard } from "../TabbedCard";
|
||||||
|
import { TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
describe("TabbedCard", () => {
|
||||||
|
it("renders the provided tab triggers and reports selection changes", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<TabbedCard
|
||||||
|
value="jellyfin"
|
||||||
|
onChange={onChange}
|
||||||
|
tabs={[
|
||||||
|
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||||
|
Jellyfin
|
||||||
|
</TabsTrigger>,
|
||||||
|
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||||
|
Nextcloud
|
||||||
|
</TabsTrigger>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p>Body</p>
|
||||||
|
</TabbedCard>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Jellyfin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Body")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Nextcloud"));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("nextcloud");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Badge } from "../badge";
|
||||||
|
|
||||||
|
// Slice 1 harness smoke test: proves the Vitest + jsdom + Testing Library
|
||||||
|
// harness runs and the new `success` Badge variant renders with the chart-2 cue.
|
||||||
|
describe("Badge", () => {
|
||||||
|
it("renders a success variant tagged with the chart-2 cue", () => {
|
||||||
|
render(<Badge variant="success">Healthy</Badge>);
|
||||||
|
const badge = screen.getByText("Healthy");
|
||||||
|
expect(badge).toBeInTheDocument();
|
||||||
|
expect(badge.getAttribute("data-variant")).toBe("success");
|
||||||
|
expect(badge.className).toContain("bg-chart-2/10");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { DataTable } from "../data-table";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
{ id: "1", name: "Alice", role: "Admin" },
|
||||||
|
{ id: "2", name: "Bob", role: "Editor" },
|
||||||
|
{ id: "3", name: "Carol", role: "Viewer" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: ColumnDef<Row>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: () => "Name",
|
||||||
|
cell: ({ row }) => row.original.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "role",
|
||||||
|
header: () => "Role",
|
||||||
|
cell: ({ row }) => row.original.role,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Wrapper so the DataTable's controlled state can update during interaction. */
|
||||||
|
function Harness({
|
||||||
|
onRowClick,
|
||||||
|
initialSelection = {},
|
||||||
|
}: {
|
||||||
|
onRowClick?: (row: Row) => void;
|
||||||
|
initialSelection?: Record<string, boolean>;
|
||||||
|
}) {
|
||||||
|
const [selection, setSelection] =
|
||||||
|
useState<Record<string, boolean>>(initialSelection);
|
||||||
|
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
enableRowSelection
|
||||||
|
rowSelection={selection}
|
||||||
|
onRowSelectionChange={setSelection}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
enableColumnVisibilityToggle
|
||||||
|
columnVisibility={visibility}
|
||||||
|
onColumnVisibilityChange={setVisibility}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DataTable (slice 7a — TanStack wrapper)", () => {
|
||||||
|
it("renders the column headers and rows", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Carol")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles row selection via the per-row checkbox and reflects state", async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
// Header select-all checkbox + one per-row checkbox exist before rows.
|
||||||
|
expect(screen.getAllByRole("checkbox", { name: "Select row" }).length).toBe(
|
||||||
|
rows.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const aliceCheckbox = screen.getAllByRole("checkbox", {
|
||||||
|
name: "Select row",
|
||||||
|
})[0];
|
||||||
|
await userEvent.click(aliceCheckbox);
|
||||||
|
expect(aliceCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
// Toggling again un-selects (controlled membership flips).
|
||||||
|
await userEvent.click(aliceCheckbox);
|
||||||
|
expect(aliceCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects all page rows via the header select-all checkbox", async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const selectAll = screen.getByRole("checkbox", {
|
||||||
|
name: "Select all rows on this page",
|
||||||
|
});
|
||||||
|
await userEvent.click(selectAll);
|
||||||
|
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
|
||||||
|
expect(cb).toBeChecked();
|
||||||
|
}
|
||||||
|
await userEvent.click(selectAll);
|
||||||
|
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
|
||||||
|
expect(cb).not.toBeChecked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles column visibility via the Columns dropdown (column disappears)", async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
// Role column header present initially.
|
||||||
|
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("menuitemcheckbox", { name: "role" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Role header + all role cells vanish from the table.
|
||||||
|
expect(screen.queryByText("Role")).toBeNull();
|
||||||
|
expect(screen.queryByText("Admin")).toBeNull();
|
||||||
|
expect(screen.queryByText("Viewer")).toBeNull();
|
||||||
|
// Name column is unaffected.
|
||||||
|
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onRowClick with row.original when a row body is clicked", async () => {
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(<Harness onRowClick={onRowClick} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Bob"));
|
||||||
|
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "2", name: "Bob", role: "Editor" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fire onRowClick when the selection checkbox is toggled", async () => {
|
||||||
|
const onRowClick = vi.fn();
|
||||||
|
render(<Harness onRowClick={onRowClick} />);
|
||||||
|
|
||||||
|
const firstCheckbox = screen.getAllByRole("checkbox", {
|
||||||
|
name: "Select row",
|
||||||
|
})[0];
|
||||||
|
await userEvent.click(firstCheckbox);
|
||||||
|
expect(onRowClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the empty message when data is empty", () => {
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={[]}
|
||||||
|
emptyMessage="No files in this directory."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("No files in this directory.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders client pagination controls when enabled", () => {
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
enablePagination
|
||||||
|
pageSizeOptions={[2, 10]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Page 1 of/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous page" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the manual pagination total when rowCount is supplied", () => {
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows.slice(0, 2)}
|
||||||
|
enablePagination
|
||||||
|
manualPagination
|
||||||
|
rowCount={42}
|
||||||
|
pagination={{ pageIndex: 0, pageSize: 2 }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("42 rows")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Page 1 of 21/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ const badgeVariants = cva(
|
|||||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
success:
|
||||||
|
"bg-chart-2/10 text-chart-2 focus-visible:ring-chart-2/20 dark:bg-chart-2/20 dark:focus-visible:ring-chart-2/40 [a]:hover:bg-chart-2/20",
|
||||||
|
warning:
|
||||||
|
"bg-chart-3/10 text-chart-3 focus-visible:ring-chart-3/20 dark:bg-chart-3/20 dark:focus-visible:ring-chart-3/40 [a]:hover:bg-chart-3/20",
|
||||||
outline:
|
outline:
|
||||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type OnChangeFn,
|
||||||
|
type PaginationState,
|
||||||
|
type RowSelectionState,
|
||||||
|
type Table as TableInstance,
|
||||||
|
type VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Columns3 } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
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>[];
|
||||||
|
data: TData[];
|
||||||
|
/** Stable row identity; Media derives it from `path` so selection survives paging. */
|
||||||
|
getRowId?: (row: TData, index: number) => string;
|
||||||
|
/** Visibility-only feature set (no sorting, no resizing — locked, design §3.3). */
|
||||||
|
enableRowSelection?: boolean;
|
||||||
|
rowSelection?: RowSelectionState;
|
||||||
|
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||||
|
onRowClick?: (row: TData) => void;
|
||||||
|
columnVisibility?: VisibilityState;
|
||||||
|
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
|
||||||
|
enableColumnVisibilityToggle?: boolean;
|
||||||
|
/** Pagination (Media only; FileBrowser does not paginate). */
|
||||||
|
enablePagination?: boolean;
|
||||||
|
manualPagination?: boolean;
|
||||||
|
pagination?: PaginationState;
|
||||||
|
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
/** Server total for Media (manual pagination). */
|
||||||
|
rowCount?: number;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable TanStack Table wrapper built on the shadcn `Table` primitive.
|
||||||
|
*
|
||||||
|
* Visibility-only feature scope (locked, design §3): pagination, row selection,
|
||||||
|
* row click, column visibility. A sorting row model is deliberately never
|
||||||
|
* wired and column resizing/sizing is never enabled — both are explicit
|
||||||
|
* non-goals.
|
||||||
|
*/
|
||||||
|
export function DataTable<TData, TValue = unknown>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
getRowId,
|
||||||
|
enableRowSelection = false,
|
||||||
|
rowSelection,
|
||||||
|
onRowSelectionChange,
|
||||||
|
onRowClick,
|
||||||
|
columnVisibility,
|
||||||
|
onColumnVisibilityChange,
|
||||||
|
enableColumnVisibilityToggle = false,
|
||||||
|
enablePagination = false,
|
||||||
|
manualPagination = false,
|
||||||
|
pagination,
|
||||||
|
onPaginationChange,
|
||||||
|
pageSizeOptions = [10, 20, 30, 50],
|
||||||
|
rowCount,
|
||||||
|
emptyMessage = "No results.",
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const pageSize = pagination?.pageSize ?? pageSizeOptions[0] ?? 10;
|
||||||
|
|
||||||
|
// Selection column is a *display* column (no accessor); only rendered when
|
||||||
|
// the consumer opts in. Its checkbox handlers stopPropagation so toggling a
|
||||||
|
// row never also fires onRowClick navigation.
|
||||||
|
const tableColumns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {
|
||||||
|
if (!enableRowSelection) return columns;
|
||||||
|
const selectColumn: ColumnDef<TData, TValue> = {
|
||||||
|
id: "__select__",
|
||||||
|
enableSorting: false,
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select all rows on this page"
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected()
|
||||||
|
? true
|
||||||
|
: table.getIsSomePageRowsSelected()
|
||||||
|
? "indeterminate"
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select row"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableHiding: false,
|
||||||
|
};
|
||||||
|
return [selectColumn as ColumnDef<TData, TValue>, ...columns];
|
||||||
|
}, [columns, enableRowSelection]);
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/incompatible-library -- TanStack's
|
||||||
|
useReactTable intentionally returns non-memoizable updater fns (controlled state). */
|
||||||
|
const table = useReactTable<TData>({
|
||||||
|
data,
|
||||||
|
columns: tableColumns,
|
||||||
|
getRowId,
|
||||||
|
enableRowSelection,
|
||||||
|
onRowSelectionChange,
|
||||||
|
onColumnVisibilityChange,
|
||||||
|
manualPagination: enablePagination ? manualPagination : false,
|
||||||
|
rowCount: enablePagination && manualPagination ? rowCount : undefined,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
// Client pagination model ONLY when paginating locally (FileBrowser does
|
||||||
|
// not paginate; Media drives the page from the server via limit/offset).
|
||||||
|
getPaginationRowModel:
|
||||||
|
enablePagination && !manualPagination
|
||||||
|
? getPaginationRowModel()
|
||||||
|
: undefined,
|
||||||
|
state: {
|
||||||
|
...(rowSelection !== undefined ? { rowSelection } : {}),
|
||||||
|
...(columnVisibility !== undefined ? { columnVisibility } : {}),
|
||||||
|
...(enablePagination
|
||||||
|
? { pagination: pagination ?? { pageIndex: 0, pageSize } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
onPaginationChange,
|
||||||
|
// Visibility-only: deliberately NO sorting model / sorting state.
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageCount =
|
||||||
|
enablePagination && rowCount !== undefined && pageSize > 0
|
||||||
|
? Math.max(1, Math.ceil(rowCount / pageSize))
|
||||||
|
: table.getPageCount();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{enableColumnVisibilityToggle && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Columns3 className="size-4" />
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
column.toggleVisibility(!!value)
|
||||||
|
}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() ? "selected" : undefined}
|
||||||
|
className={cn(onRowClick && "cursor-pointer")}
|
||||||
|
onClick={() => onRowClick?.(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={tableColumns.length}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
{emptyMessage}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enablePagination && (
|
||||||
|
<DataTablePagination
|
||||||
|
table={table}
|
||||||
|
pageSizeOptions={pageSizeOptions}
|
||||||
|
pageCount={pageCount}
|
||||||
|
manual={manualPagination}
|
||||||
|
rowCount={rowCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="size-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
|
VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
--color-border: #e2e8f0;
|
--color-border: #e2e8f0;
|
||||||
--color-input: #e2e8f0;
|
--color-input: #e2e8f0;
|
||||||
--color-ring: #4f8cff;
|
--color-ring: #4f8cff;
|
||||||
|
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||||
|
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||||
|
chart-4=destructive, chart-5=neutral-accent. */
|
||||||
--color-chart-1: #4f8cff;
|
--color-chart-1: #4f8cff;
|
||||||
--color-chart-2: #22c55e;
|
--color-chart-2: #22c55e;
|
||||||
--color-chart-3: #f59e0b;
|
--color-chart-3: #f59e0b;
|
||||||
@@ -59,6 +62,9 @@
|
|||||||
--color-border: #334155;
|
--color-border: #334155;
|
||||||
--color-input: #334155;
|
--color-input: #334155;
|
||||||
--color-ring: #4f8cff;
|
--color-ring: #4f8cff;
|
||||||
|
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||||
|
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||||
|
chart-4=destructive, chart-5=neutral-accent. */
|
||||||
--color-chart-1: #4f8cff;
|
--color-chart-1: #4f8cff;
|
||||||
--color-chart-2: #22c55e;
|
--color-chart-2: #22c55e;
|
||||||
--color-chart-3: #f59e0b;
|
--color-chart-3: #f59e0b;
|
||||||
|
|||||||
+338
-433
@@ -1,26 +1,6 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import type { MonitoringMachine, SavedTask, SavedTaskInput } from "../types";
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Divider,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Tab,
|
|
||||||
Tabs,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type { MonitoringMachine, SavedTaskInput } from "../types";
|
|
||||||
import {
|
import {
|
||||||
useDeleteTask,
|
useDeleteTask,
|
||||||
useMonitoringSettings,
|
useMonitoringSettings,
|
||||||
@@ -31,10 +11,63 @@ import {
|
|||||||
} from "../hooks/useSettings";
|
} from "../hooks/useSettings";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { HoverEditButton } from "../components/HoverEditButton";
|
import { HoverEditButton } from "../components/HoverEditButton";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { SelectionRailCard } from "../components/SelectionRailCard";
|
import { SelectionRailCard } from "../components/SelectionRailCard";
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
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;
|
type ActionTab = "new" | string;
|
||||||
|
|
||||||
|
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||||
|
function FormField({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
helperText,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor?: string;
|
||||||
|
helperText?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label htmlFor={htmlFor} className="mb-1">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
{children}
|
||||||
|
{helperText ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function emptyTask(): SavedTaskInput {
|
function emptyTask(): SavedTaskInput {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -59,6 +92,18 @@ function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialFromTask(task: SavedTask): SavedTaskInput {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
task_type: task.task_type,
|
||||||
|
content: task.content,
|
||||||
|
enabled: task.enabled,
|
||||||
|
default_machine_id: task.default_machine_id,
|
||||||
|
notes: task.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function TaskEditor({
|
function TaskEditor({
|
||||||
task,
|
task,
|
||||||
machines,
|
machines,
|
||||||
@@ -72,101 +117,98 @@ function TaskEditor({
|
|||||||
(machine) => machine.id === task.default_machine_id,
|
(machine) => machine.id === task.default_machine_id,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Stack spacing={1.5}>
|
<div className="flex flex-col gap-4">
|
||||||
<Stack
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
direction="row"
|
<p className="text-sm font-semibold">
|
||||||
spacing={1}
|
|
||||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{task.id ? "Edit action" : "New action"}
|
{task.id ? "Edit action" : "New action"}
|
||||||
</Typography>
|
</p>
|
||||||
<Chip size="small" variant="outlined" label={task.task_type} />
|
<Badge variant="outline">{task.task_type}</Badge>
|
||||||
<Chip
|
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={task.enabled ? "enabled" : "disabled"}
|
|
||||||
/>
|
|
||||||
{selectedMachine && (
|
{selectedMachine && (
|
||||||
<Chip
|
<Badge variant="outline">{`default: ${selectedMachine.name}`}</Badge>
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={`default: ${selectedMachine.name}`}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</div>
|
||||||
|
|
||||||
<Stack spacing={1.25}>
|
<div className="flex flex-col gap-2">
|
||||||
<TextField
|
<FormField label="Name" htmlFor="task-name">
|
||||||
fullWidth
|
<Input
|
||||||
size="small"
|
id="task-name"
|
||||||
label="Name"
|
value={task.name}
|
||||||
value={task.name}
|
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
||||||
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
/>
|
||||||
/>
|
</FormField>
|
||||||
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}>
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}>
|
<div className="min-w-[180px] flex-1">
|
||||||
<InputLabel>Type</InputLabel>
|
<FormField label="Type">
|
||||||
<Select
|
<Select
|
||||||
label="Type"
|
value={task.task_type}
|
||||||
value={task.task_type}
|
onValueChange={(value) =>
|
||||||
onChange={(e) =>
|
onChange({
|
||||||
onChange({
|
...task,
|
||||||
...task,
|
task_type: value as SavedTaskInput["task_type"],
|
||||||
task_type: e.target.value as SavedTaskInput["task_type"],
|
})
|
||||||
})
|
}
|
||||||
}
|
>
|
||||||
>
|
<SelectTrigger className="w-full" size="sm">
|
||||||
<MenuItem value="shell">Shell</MenuItem>
|
<SelectValue />
|
||||||
<MenuItem value="python">Python</MenuItem>
|
</SelectTrigger>
|
||||||
</Select>
|
<SelectContent>
|
||||||
</FormControl>
|
<SelectItem value="shell">Shell</SelectItem>
|
||||||
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}>
|
<SelectItem value="python">Python</SelectItem>
|
||||||
<InputLabel>Default machine</InputLabel>
|
</SelectContent>
|
||||||
<Select
|
</Select>
|
||||||
label="Default machine"
|
</FormField>
|
||||||
value={task.default_machine_id}
|
</div>
|
||||||
onChange={(e) =>
|
<div className="min-w-[220px] flex-1">
|
||||||
onChange({
|
<FormField label="Default machine">
|
||||||
...task,
|
<Select
|
||||||
default_machine_id: String(e.target.value),
|
value={task.default_machine_id || NONE}
|
||||||
})
|
onValueChange={(value) =>
|
||||||
}
|
onChange({
|
||||||
>
|
...task,
|
||||||
<MenuItem value="">None</MenuItem>
|
default_machine_id: value === NONE ? "" : value,
|
||||||
{machines.map((machine) => (
|
})
|
||||||
<MenuItem key={machine.id} value={machine.id}>
|
}
|
||||||
{machine.name}
|
>
|
||||||
</MenuItem>
|
<SelectTrigger className="w-full" size="sm">
|
||||||
))}
|
<SelectValue placeholder="None" />
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
</FormControl>
|
<SelectContent>
|
||||||
</Stack>
|
<SelectItem value={NONE}>None</SelectItem>
|
||||||
<TextField
|
{machines.map((machine) => (
|
||||||
fullWidth
|
<SelectItem key={machine.id} value={machine.id}>
|
||||||
size="small"
|
{machine.name}
|
||||||
label="Notes"
|
</SelectItem>
|
||||||
value={task.notes}
|
))}
|
||||||
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
</SelectContent>
|
||||||
/>
|
</Select>
|
||||||
<TextField
|
</FormField>
|
||||||
fullWidth
|
</div>
|
||||||
multiline
|
</div>
|
||||||
minRows={9}
|
<FormField label="Notes">
|
||||||
size="small"
|
<Input
|
||||||
|
value={task.notes}
|
||||||
|
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
label={
|
label={
|
||||||
task.task_type === "python" ? "Python script" : "Shell command"
|
task.task_type === "python" ? "Python script" : "Shell command"
|
||||||
}
|
}
|
||||||
value={task.content}
|
|
||||||
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
|
||||||
helperText={
|
helperText={
|
||||||
task.task_type === "python"
|
task.task_type === "python"
|
||||||
? "Python is run as `python3 -c`."
|
? "Python is run as `python3 -c`."
|
||||||
: "Shell commands are run through `/bin/sh -c`."
|
: "Shell commands are run through `/bin/sh -c`."
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
</Stack>
|
<Textarea
|
||||||
</Stack>
|
rows={9}
|
||||||
|
value={task.content}
|
||||||
|
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,25 +242,36 @@ function TaskDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="md">
|
<Dialog
|
||||||
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
open={open}
|
||||||
<DialogContent dividers>
|
onOpenChange={(next) => {
|
||||||
|
if (!next) requestClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Save a reusable server task. Shell commands run via{" "}
|
||||||
|
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<TaskEditor task={task} machines={machines} onChange={onChange} />
|
<TaskEditor task={task} machines={machines} onChange={onChange} />
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={requestClose}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={onSave}
|
||||||
|
confirmLabel="Save action"
|
||||||
|
confirmBusyLabel="Save action"
|
||||||
|
secondaryAction={
|
||||||
|
onDelete ? (
|
||||||
|
<Button variant="destructive" onClick={onDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter
|
|
||||||
onCancel={requestClose}
|
|
||||||
cancelLabel="Cancel"
|
|
||||||
onConfirm={onSave}
|
|
||||||
confirmLabel="Save action"
|
|
||||||
confirmBusyLabel="Save action"
|
|
||||||
secondaryAction={
|
|
||||||
onDelete ? (
|
|
||||||
<Button variant="outlined" color="error" onClick={onDelete}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,6 +296,12 @@ export function Actions() {
|
|||||||
);
|
);
|
||||||
const selectedRuns = useTaskRuns(selectedTask?.id);
|
const selectedRuns = useTaskRuns(selectedTask?.id);
|
||||||
|
|
||||||
|
const openEdit = (initial: SavedTaskInput) => {
|
||||||
|
setDraft(initial);
|
||||||
|
setDraftBaseline(initial);
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const createNew = () => {
|
const createNew = () => {
|
||||||
const initial = emptyTask();
|
const initial = emptyTask();
|
||||||
setDraft(initial);
|
setDraft(initial);
|
||||||
@@ -271,51 +330,43 @@ export function Actions() {
|
|||||||
const editingTask = selectedTask;
|
const editingTask = selectedTask;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2.25}>
|
<div className="flex flex-col gap-6">
|
||||||
<Stack
|
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||||
direction="row"
|
<div>
|
||||||
spacing={1}
|
<h1 className="text-lg font-semibold">Actions</h1>
|
||||||
sx={{
|
<p className="text-xs text-muted-foreground">
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
|
||||||
Actions
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Save reusable server tasks and switch between them with tabs.
|
Save reusable server tasks and switch between them with tabs.
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
<Chip label={`${tasks.length} saved`} variant="outlined" />
|
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
|
||||||
</Stack>
|
</div>
|
||||||
|
|
||||||
{saveTask.error && (
|
{saveTask.error && (
|
||||||
<Alert severity="error">{String(saveTask.error)}</Alert>
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{deleteTask.error && (
|
{deleteTask.error && (
|
||||||
<Alert severity="error">{String(deleteTask.error)}</Alert>
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{runTask.error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{runTask.error && <Alert severity="error">{String(runTask.error)}</Alert>}
|
|
||||||
|
|
||||||
<Box
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectionRailCard
|
<SelectionRailCard
|
||||||
title="Saved actions"
|
title="Saved actions"
|
||||||
description="Pick a saved task, then edit or run it from the detail pane."
|
description="Pick a saved task, then edit or run it from the detail pane."
|
||||||
contentSx={{ maxHeight: { xs: 520, md: 620 } }}
|
contentSx={{}}
|
||||||
footer={
|
footer={
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outline"
|
||||||
size="small"
|
size="sm"
|
||||||
fullWidth
|
className="w-full"
|
||||||
onClick={createNew}
|
onClick={createNew}
|
||||||
>
|
>
|
||||||
Add action
|
Add action
|
||||||
@@ -324,301 +375,155 @@ export function Actions() {
|
|||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={(_, value) => setTab(value)}
|
onValueChange={(value) => setTab(value)}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
variant="scrollable"
|
className="w-full"
|
||||||
sx={{ borderRight: 1, borderColor: "divider" }}
|
|
||||||
>
|
>
|
||||||
{tasks.map((task) => (
|
<TabsList variant="line" className="h-fit w-full justify-start">
|
||||||
<Box
|
{tasks.map((task) => (
|
||||||
key={task.id}
|
<div
|
||||||
sx={{
|
key={task.id}
|
||||||
position: "relative",
|
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
||||||
width: "100%",
|
|
||||||
"&:hover .rail-edit": { opacity: 1 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab
|
|
||||||
value={task.id}
|
|
||||||
label={task.name}
|
|
||||||
sx={{
|
|
||||||
alignItems: "flex-start",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
width: 1,
|
|
||||||
pr: 5,
|
|
||||||
}}
|
|
||||||
onClick={() => setTab(task.id)}
|
|
||||||
onDoubleClick={() => {
|
|
||||||
const initial = {
|
|
||||||
id: task.id,
|
|
||||||
name: task.name,
|
|
||||||
task_type: task.task_type,
|
|
||||||
content: task.content,
|
|
||||||
enabled: task.enabled,
|
|
||||||
default_machine_id: task.default_machine_id,
|
|
||||||
notes: task.notes,
|
|
||||||
};
|
|
||||||
setDraft(initial);
|
|
||||||
setDraftBaseline(initial);
|
|
||||||
setEditOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 4,
|
|
||||||
top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<HoverEditButton
|
<TabsTrigger
|
||||||
onClick={() => {
|
value={task.id}
|
||||||
const initial = {
|
className="w-full justify-start pr-9"
|
||||||
id: task.id,
|
onClick={() => setTab(task.id)}
|
||||||
name: task.name,
|
onDoubleClick={() => openEdit(initialFromTask(task))}
|
||||||
task_type: task.task_type,
|
>
|
||||||
content: task.content,
|
{task.name}
|
||||||
enabled: task.enabled,
|
</TabsTrigger>
|
||||||
default_machine_id: task.default_machine_id,
|
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
||||||
notes: task.notes,
|
<HoverEditButton
|
||||||
};
|
onClick={() => openEdit(initialFromTask(task))}
|
||||||
setDraft(initial);
|
/>
|
||||||
setDraftBaseline(initial);
|
</div>
|
||||||
setEditOpen(true);
|
</div>
|
||||||
}}
|
))}
|
||||||
/>
|
</TabsList>
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</SelectionRailCard>
|
</SelectionRailCard>
|
||||||
|
|
||||||
<Stack spacing={2}>
|
<div className="flex flex-col gap-4">
|
||||||
{editingTask ? (
|
{editingTask ? (
|
||||||
<Card variant="outlined">
|
<SectionCard
|
||||||
<CardContent sx={{ p: 1.5 }}>
|
title={editingTask.name}
|
||||||
<Stack spacing={1.5}>
|
description="Open the editor popup to modify this action."
|
||||||
<Stack
|
action={
|
||||||
direction="row"
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
spacing={1}
|
<Button
|
||||||
sx={{
|
variant="outline"
|
||||||
alignItems: "center",
|
onClick={() => openEdit(initialFromTask(editingTask))}
|
||||||
justifyContent: "space-between",
|
>
|
||||||
flexWrap: "wrap",
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={runTask.isPending || !runMachineId}
|
||||||
|
onClick={async () => {
|
||||||
|
await runTask.mutateAsync({
|
||||||
|
taskId: editingTask.id,
|
||||||
|
machineId: runMachineId,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
{runTask.isPending ? "Running..." : "Run action"}
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
</Button>
|
||||||
{editingTask.name}
|
</div>
|
||||||
</Typography>
|
}
|
||||||
<Typography variant="body2" color="text.secondary">
|
>
|
||||||
Open the editor popup to modify this action.
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</Typography>
|
<FormField label="Run on machine">
|
||||||
</Box>
|
<Select
|
||||||
<Stack
|
value={runMachineId}
|
||||||
direction="row"
|
onValueChange={(value) => setRunMachineId(value)}
|
||||||
spacing={1}
|
|
||||||
sx={{ flexWrap: "wrap" }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => {
|
|
||||||
const initial = {
|
|
||||||
id: editingTask.id,
|
|
||||||
name: editingTask.name,
|
|
||||||
task_type: editingTask.task_type,
|
|
||||||
content: editingTask.content,
|
|
||||||
enabled: editingTask.enabled,
|
|
||||||
default_machine_id: editingTask.default_machine_id,
|
|
||||||
notes: editingTask.notes,
|
|
||||||
};
|
|
||||||
setDraft(initial);
|
|
||||||
setDraftBaseline(initial);
|
|
||||||
setEditOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={runTask.isPending || !runMachineId}
|
|
||||||
onClick={async () => {
|
|
||||||
await runTask.mutateAsync({
|
|
||||||
taskId: editingTask.id,
|
|
||||||
machineId: runMachineId,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{runTask.isPending ? "Running..." : "Run action"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={1}
|
|
||||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
|
||||||
>
|
>
|
||||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
<SelectTrigger className="min-w-[240px]" size="sm">
|
||||||
<InputLabel>Run on machine</InputLabel>
|
<SelectValue placeholder="Select machine" />
|
||||||
<Select
|
</SelectTrigger>
|
||||||
label="Run on machine"
|
<SelectContent>
|
||||||
value={runMachineId}
|
{machines.map((machine) => (
|
||||||
onChange={(e) =>
|
<SelectItem key={machine.id} value={machine.id}>
|
||||||
setRunMachineId(String(e.target.value))
|
{machine.name}
|
||||||
}
|
</SelectItem>
|
||||||
>
|
|
||||||
{machines.map((machine) => (
|
|
||||||
<MenuItem key={machine.id} value={machine.id}>
|
|
||||||
{machine.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
|
||||||
Recent runs
|
|
||||||
</Typography>
|
|
||||||
{selectedRuns.data?.items?.length ? (
|
|
||||||
<Stack spacing={1.25}>
|
|
||||||
{selectedRuns.data.items.map((run) => (
|
|
||||||
<Card key={run.id} variant="outlined">
|
|
||||||
<CardContent sx={{ p: 1.5 }}>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={1}
|
|
||||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={run.status}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
>
|
|
||||||
{run.machine_name} ·{" "}
|
|
||||||
{new Date(
|
|
||||||
run.created_at * 1000,
|
|
||||||
).toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
{run.stdout_tail && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 1,
|
|
||||||
py: 0.75,
|
|
||||||
border: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
>
|
|
||||||
stdout
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{run.stdout_tail}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{run.stderr_tail && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 1,
|
|
||||||
py: 0.75,
|
|
||||||
border: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
>
|
|
||||||
stderr
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontFamily: "monospace",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{run.stderr_tail}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{run.error && (
|
|
||||||
<Alert severity="error">{run.error}</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</SelectContent>
|
||||||
) : (
|
</Select>
|
||||||
<Alert severity="info">No runs yet.</Alert>
|
</FormField>
|
||||||
)}
|
</div>
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
<Separator />
|
||||||
</Card>
|
|
||||||
|
<p className="text-sm font-semibold">Recent runs</p>
|
||||||
|
{selectedRuns.data?.items?.length ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{selectedRuns.data.items.map((run) => (
|
||||||
|
<Card key={run.id}>
|
||||||
|
<CardContent className="flex flex-col gap-2 p-3">
|
||||||
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{run.status}</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{run.machine_name} ·{" "}
|
||||||
|
{new Date(run.created_at * 1000).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{run.stdout_tail && (
|
||||||
|
<div className="rounded-lg border border-border px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
stdout
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||||
|
{run.stdout_tail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{run.stderr_tail && (
|
||||||
|
<div className="rounded-lg border border-border px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
stderr
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
||||||
|
{run.stderr_tail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{run.error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{run.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>No runs yet.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
) : (
|
) : (
|
||||||
<Stack spacing={2}>
|
<div className="flex flex-col gap-4">
|
||||||
<Card variant="outlined">
|
<SectionCard
|
||||||
<CardContent sx={{ p: 2 }}>
|
title="No action selected"
|
||||||
<Stack spacing={1.25}>
|
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."
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
>
|
||||||
No action selected
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
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.
|
|
||||||
</Typography>
|
|
||||||
{tasks[0] && (
|
{tasks[0] && (
|
||||||
<Button
|
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
|
||||||
variant="outlined"
|
|
||||||
onClick={() => setTab(tasks[0].id)}
|
|
||||||
>
|
|
||||||
Select first action
|
Select first action
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</SectionCard>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card variant="outlined">
|
<SectionCard title="What this panel shows">
|
||||||
<CardContent sx={{ p: 2 }}>
|
<p className="text-xs text-muted-foreground">
|
||||||
<Stack spacing={1}>
|
Saved actions stay on the left rail, while details, run
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
controls, and recent history appear here.
|
||||||
What this panel shows
|
</p>
|
||||||
</Typography>
|
</SectionCard>
|
||||||
<Typography variant="body2" color="text.secondary">
|
</div>
|
||||||
Saved actions stay on the left rail, while details, run
|
|
||||||
controls, and recent history appear here.
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<TaskDialog
|
<TaskDialog
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
@@ -632,6 +537,6 @@ export function Actions() {
|
|||||||
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
Grid,
|
|
||||||
Stack,
|
|
||||||
Tab,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
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 { Media } from "./Media";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||||
@@ -37,110 +29,67 @@ function JellyfinLibraryStats() {
|
|||||||
title="Library stats"
|
title="Library stats"
|
||||||
description="Compact Jellyfin summary for the selected machine."
|
description="Compact Jellyfin summary for the selected machine."
|
||||||
action={
|
action={
|
||||||
<Chip
|
<Badge variant="outline">
|
||||||
label={selectedMachineId ? "Selected machine" : "Default machine"}
|
{selectedMachineId ? "Selected machine" : "Default machine"}
|
||||||
variant="outlined"
|
</Badge>
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.25}>
|
<div className="flex flex-col gap-2">
|
||||||
{counts ? (
|
{counts ? (
|
||||||
<Grid container spacing={1}>
|
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
<Card variant="outlined">
|
<span className="text-xs text-muted-foreground">Total</span>
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
<div className="text-base leading-tight font-extrabold">
|
||||||
<Typography variant="caption" color="text.secondary">
|
{(
|
||||||
Total
|
counts.movies +
|
||||||
</Typography>
|
counts.series +
|
||||||
<Typography
|
counts.episodes
|
||||||
variant="h6"
|
).toLocaleString()}
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
</div>
|
||||||
>
|
</div>
|
||||||
{(
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
counts.movies +
|
<span className="text-xs text-muted-foreground">Movies</span>
|
||||||
counts.series +
|
<div className="text-base leading-tight font-extrabold">
|
||||||
counts.episodes
|
{counts.movies.toLocaleString()}
|
||||||
).toLocaleString()}
|
</div>
|
||||||
</Typography>
|
</div>
|
||||||
</CardContent>
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
</Card>
|
<span className="text-xs text-muted-foreground">Series</span>
|
||||||
</Grid>
|
<div className="text-base leading-tight font-extrabold">
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
{counts.series.toLocaleString()}
|
||||||
<Card variant="outlined">
|
</div>
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
</div>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||||
Movies
|
<span className="text-xs text-muted-foreground">Episodes</span>
|
||||||
</Typography>
|
<div className="text-base leading-tight font-extrabold">
|
||||||
<Typography
|
{counts.episodes.toLocaleString()}
|
||||||
variant="h6"
|
</div>
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
</div>
|
||||||
>
|
</div>
|
||||||
{counts.movies.toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
|
||||||
<Card variant="outlined">
|
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Series
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{counts.series.toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
|
||||||
<Card variant="outlined">
|
|
||||||
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Episodes
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{ fontWeight: 800, lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{counts.episodes.toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{libraries?.length ? (
|
{libraries?.length ? (
|
||||||
<Grid container spacing={1}>
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
{libraries.map((library) => (
|
{libraries.map((library) => (
|
||||||
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
|
<div
|
||||||
<Card variant="outlined">
|
key={library.library}
|
||||||
<CardContent sx={{ py: 1.1, px: 1.5 }}>
|
className="rounded-lg border bg-card px-3 py-2"
|
||||||
<Stack spacing={0.5}>
|
>
|
||||||
<Typography
|
<div className="flex flex-col gap-1">
|
||||||
variant="subtitle2"
|
<span className="truncate text-sm font-semibold">
|
||||||
sx={{ fontWeight: 700 }}
|
{library.library}
|
||||||
noWrap
|
</span>
|
||||||
>
|
<span className="text-sm text-muted-foreground">
|
||||||
{library.library}
|
Total {library.total.toLocaleString()} · Movies{" "}
|
||||||
</Typography>
|
{library.movies.toLocaleString()} · Series{" "}
|
||||||
<Typography variant="body2" color="text.secondary">
|
{library.series.toLocaleString()}
|
||||||
Total {library.total.toLocaleString()} · Movies{" "}
|
</span>
|
||||||
{library.movies.toLocaleString()} · Series{" "}
|
</div>
|
||||||
{library.series.toLocaleString()}
|
</div>
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,39 +98,41 @@ export function Applications() {
|
|||||||
const [tab, setTab] = useState("jellyfin");
|
const [tab, setTab] = useState("jellyfin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2.25}>
|
<div className="flex flex-col gap-4">
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 800 }}>
|
<h1 className="text-lg font-semibold">Applications</h1>
|
||||||
Applications
|
<p className="text-sm text-muted-foreground">
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Browse application-specific tools from a compact tabbed workspace.
|
Browse application-specific tools from a compact tabbed workspace.
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<TabbedCard
|
<TabbedCard
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
|
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||||
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
|
Jellyfin
|
||||||
|
</TabsTrigger>,
|
||||||
|
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||||
|
Nextcloud
|
||||||
|
</TabsTrigger>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{tab === "jellyfin" ? (
|
{tab === "jellyfin" ? (
|
||||||
<Stack spacing={2}>
|
<div className="flex flex-col gap-4">
|
||||||
<JellyfinLibraryStats />
|
<JellyfinLibraryStats />
|
||||||
<Media />
|
<Media />
|
||||||
</Stack>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card variant="outlined">
|
<div className="rounded-lg border bg-card p-3">
|
||||||
<CardContent sx={{ p: 1.5 }}>
|
<Alert>
|
||||||
<Alert severity="info">
|
<AlertDescription>
|
||||||
Nextcloud support will be added in a future update.
|
Nextcloud support will be added in a future update.
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
</CardContent>
|
</Alert>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabbedCard>
|
</TabbedCard>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+245
-241
@@ -1,27 +1,25 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { 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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
} from "@/components/ui/dialog";
|
||||||
FormControlLabel,
|
import { Input } from "@/components/ui/input";
|
||||||
FormHelperText,
|
import { Label } from "@/components/ui/label";
|
||||||
Grid,
|
import {
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
SelectContent,
|
||||||
Switch,
|
SelectItem,
|
||||||
TextField,
|
SelectTrigger,
|
||||||
Typography,
|
SelectValue,
|
||||||
} from "@mui/material";
|
} from "@/components/ui/select";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
useActivity,
|
useActivity,
|
||||||
useDashboardShortcuts,
|
useDashboardShortcuts,
|
||||||
@@ -32,6 +30,7 @@ import { useMonitoringSettings } from "../hooks/useSettings";
|
|||||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||||
import { NowPlaying } from "../components/NowPlaying";
|
import { NowPlaying } from "../components/NowPlaying";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
||||||
|
|
||||||
@@ -71,6 +70,28 @@ function shortcutHref(shortcut: DashboardShortcut): string {
|
|||||||
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
|
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
helper,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
helper?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={htmlFor}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{helper ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{helper}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ShortcutDialog({
|
function ShortcutDialog({
|
||||||
open,
|
open,
|
||||||
draft,
|
draft,
|
||||||
@@ -85,129 +106,155 @@ function ShortcutDialog({
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
<Dialog
|
||||||
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
|
open={open}
|
||||||
<DialogContent dividers>
|
onOpenChange={(next) => {
|
||||||
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
|
if (!next) onClose();
|
||||||
<Grid container spacing={1}>
|
}}
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
|
>
|
||||||
<TextField
|
<DialogContent className="sm:max-w-lg">
|
||||||
fullWidth
|
<DialogHeader>
|
||||||
size="small"
|
<DialogTitle>
|
||||||
label="Label"
|
{draft.id ? "Edit shortcut" : "New shortcut"}
|
||||||
value={draft.label}
|
</DialogTitle>
|
||||||
onChange={(e) => onChange({ ...draft, label: e.target.value })}
|
</DialogHeader>
|
||||||
/>
|
<div className="flex flex-col gap-4">
|
||||||
</Grid>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
|
||||||
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
|
<div className="flex flex-col gap-1.5 sm:col-span-5">
|
||||||
<TextField
|
<Field label="Label" htmlFor="shortcut-label">
|
||||||
fullWidth
|
<Input
|
||||||
size="small"
|
id="shortcut-label"
|
||||||
label="Icon"
|
value={draft.label}
|
||||||
value={draft.icon}
|
|
||||||
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
|
||||||
helperText="Emoji or glyph"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
|
|
||||||
<FormControl fullWidth size="small">
|
|
||||||
<InputLabel>Type</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Type"
|
|
||||||
value={draft.shortcut_type}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
onChange({ ...draft, label: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||||
|
<Field
|
||||||
|
label="Icon"
|
||||||
|
htmlFor="shortcut-icon"
|
||||||
|
helper="Emoji or glyph"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-icon"
|
||||||
|
value={draft.icon}
|
||||||
|
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 sm:col-span-5">
|
||||||
|
<Field
|
||||||
|
label="Type"
|
||||||
|
htmlFor="shortcut-type"
|
||||||
|
helper="Website opens a URL. Saved actions jump to a task. Users deep-link."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={draft.shortcut_type}
|
||||||
|
onValueChange={(value) =>
|
||||||
onChange({
|
onChange({
|
||||||
...draft,
|
...draft,
|
||||||
shortcut_type: e.target
|
shortcut_type:
|
||||||
.value as DashboardShortcutInput["shortcut_type"],
|
value as DashboardShortcutInput["shortcut_type"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MenuItem value="website">Website</MenuItem>
|
<SelectTrigger id="shortcut-type" className="w-full">
|
||||||
<MenuItem value="action">Saved action</MenuItem>
|
<SelectValue />
|
||||||
<MenuItem value="user">User</MenuItem>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="website">Website</SelectItem>
|
||||||
|
<SelectItem value="action">Saved action</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormHelperText>
|
</Field>
|
||||||
Website opens a URL. Saved actions jump to a task. Users
|
</div>
|
||||||
deep-link.
|
</div>
|
||||||
</FormHelperText>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{draft.shortcut_type === "website" ? (
|
{draft.shortcut_type === "website" ? (
|
||||||
<TextField
|
<Field
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Website URL"
|
label="Website URL"
|
||||||
value={draft.url}
|
htmlFor="shortcut-url"
|
||||||
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
helper="https:// is added if missing."
|
||||||
helperText="https:// is added if missing."
|
>
|
||||||
/>
|
<Input
|
||||||
|
id="shortcut-url"
|
||||||
|
value={draft.url}
|
||||||
|
onChange={(e) => onChange({ ...draft, url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
) : draft.shortcut_type === "action" ? (
|
) : draft.shortcut_type === "action" ? (
|
||||||
<Grid container spacing={1.25}>
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Field
|
||||||
<TextField
|
label="Task ID"
|
||||||
fullWidth
|
htmlFor="shortcut-task"
|
||||||
size="small"
|
helper="Saved action ID."
|
||||||
label="Task ID"
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-task"
|
||||||
value={draft.task_id}
|
value={draft.task_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...draft, task_id: e.target.value })
|
onChange({ ...draft, task_id: e.target.value })
|
||||||
}
|
}
|
||||||
helperText="Saved action ID."
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Field>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Field
|
||||||
<TextField
|
label="Machine ID"
|
||||||
fullWidth
|
htmlFor="shortcut-machine"
|
||||||
size="small"
|
helper="Optional machine target."
|
||||||
label="Machine ID"
|
>
|
||||||
|
<Input
|
||||||
|
id="shortcut-machine"
|
||||||
value={draft.machine_id}
|
value={draft.machine_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...draft, machine_id: e.target.value })
|
onChange({ ...draft, machine_id: e.target.value })
|
||||||
}
|
}
|
||||||
helperText="Optional machine target."
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Field>
|
||||||
</Grid>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<Field
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="User ID"
|
label="User ID"
|
||||||
value={draft.user_id}
|
htmlFor="shortcut-user"
|
||||||
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
|
helper="Jellyfin user ID."
|
||||||
helperText="Jellyfin user ID."
|
>
|
||||||
/>
|
<Input
|
||||||
)}
|
id="shortcut-user"
|
||||||
|
value={draft.user_id}
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label="Notes"
|
|
||||||
value={draft.notes}
|
|
||||||
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={draft.enabled}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ ...draft, enabled: e.target.checked })
|
onChange({ ...draft, user_id: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
</Field>
|
||||||
label="Enabled"
|
)}
|
||||||
/>
|
|
||||||
</Stack>
|
<Field label="Notes" htmlFor="shortcut-notes">
|
||||||
|
<Input
|
||||||
|
id="shortcut-notes"
|
||||||
|
value={draft.notes}
|
||||||
|
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="shortcut-enabled"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="shortcut-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={onClose}
|
||||||
|
onConfirm={onSave}
|
||||||
|
confirmLabel="Save shortcut"
|
||||||
|
confirmBusyLabel="Save shortcut"
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter
|
|
||||||
onCancel={onClose}
|
|
||||||
onConfirm={onSave}
|
|
||||||
confirmLabel="Save shortcut"
|
|
||||||
confirmBusyLabel="Save shortcut"
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,72 +284,42 @@ function ShortcutCard({
|
|||||||
: shortcut.user_id || "No user configured";
|
: shortcut.user_id || "No user configured";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
<Card className="h-full">
|
||||||
<CardContent sx={{ p: 1.25 }}>
|
<CardContent className="flex flex-col gap-3 p-3">
|
||||||
<Stack spacing={1}>
|
<div className="flex flex-row items-start justify-between gap-2">
|
||||||
<Stack
|
<div className="min-w-0">
|
||||||
direction="row"
|
<div className="truncate font-semibold">{shortcut.label}</div>
|
||||||
spacing={1}
|
<div className="truncate text-sm text-muted-foreground">
|
||||||
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
{shortcut.icon ? (
|
||||||
|
<div className="grid size-8 place-items-center rounded-md bg-muted text-lg">
|
||||||
|
{shortcut.icon}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Badge variant="outline">{shortcut.shortcut_type}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shortcut.notes ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{shortcut.notes}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!shortcut.enabled || !href}
|
||||||
|
onClick={onOpen}
|
||||||
>
|
>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
Open
|
||||||
<Typography sx={{ fontWeight: 700 }} noWrap>
|
</Button>
|
||||||
{shortcut.label}
|
<Button size="sm" variant="outline" onClick={onEdit}>
|
||||||
</Typography>
|
Edit
|
||||||
<Typography variant="body2" color="text.secondary" noWrap>
|
</Button>
|
||||||
{subtitle}
|
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||||
</Typography>
|
Delete
|
||||||
</Box>
|
</Button>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
</div>
|
||||||
{shortcut.icon ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
display: "grid",
|
|
||||||
placeItems: "center",
|
|
||||||
bgcolor: "action.hover",
|
|
||||||
fontSize: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{shortcut.icon}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
label={shortcut.shortcut_type}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
{shortcut.notes ? (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{shortcut.notes}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="contained"
|
|
||||||
disabled={!shortcut.enabled || !href}
|
|
||||||
onClick={onOpen}
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
<Button size="small" variant="outlined" onClick={onEdit}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -360,40 +377,41 @@ export function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2.25}>
|
<div className="flex flex-col gap-4">
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Shortcuts"
|
title="Shortcuts"
|
||||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||||
action={
|
action={
|
||||||
<Button variant="outlined" onClick={openCreateShortcut}>
|
<Button variant="outline" onClick={openCreateShortcut}>
|
||||||
Add shortcut
|
Add shortcut
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{shortcuts.length ? (
|
{shortcuts.length ? (
|
||||||
<Grid container spacing={1.25}>
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{shortcuts.map((shortcut) => (
|
{shortcuts.map((shortcut) => (
|
||||||
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
|
<ShortcutCard
|
||||||
<ShortcutCard
|
key={shortcut.id}
|
||||||
shortcut={shortcut}
|
shortcut={shortcut}
|
||||||
onOpen={() => {
|
onOpen={() => {
|
||||||
const href = shortcutHref(shortcut);
|
const href = shortcutHref(shortcut);
|
||||||
if (shortcut.shortcut_type === "website") {
|
if (shortcut.shortcut_type === "website") {
|
||||||
window.open(href, "_blank", "noopener,noreferrer");
|
window.open(href, "_blank", "noopener,noreferrer");
|
||||||
} else if (href) {
|
} else if (href) {
|
||||||
navigate(href);
|
navigate(href);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onEdit={() => openEditShortcut(shortcut)}
|
onEdit={() => openEditShortcut(shortcut)}
|
||||||
onDelete={() => setDeleteShortcutId(shortcut.id)}
|
onDelete={() => setDeleteShortcutId(shortcut.id)}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="info">
|
<Alert>
|
||||||
No shortcuts yet. Add a website now, then add action or user
|
<AlertDescription>
|
||||||
shortcuts later.
|
No shortcuts yet. Add a website now, then add action or user
|
||||||
|
shortcuts later.
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -403,25 +421,23 @@ export function Dashboard() {
|
|||||||
description="Live sessions and idle users from Jellyfin."
|
description="Live sessions and idle users from Jellyfin."
|
||||||
action={
|
action={
|
||||||
jellyfinMachines.length > 1 ? (
|
jellyfinMachines.length > 1 ? (
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
<Select
|
||||||
<Select
|
value={selectedJellyfinId}
|
||||||
value={selectedJellyfinId}
|
onValueChange={(value) => setActiveJellyfinMachineId(value)}
|
||||||
onChange={(e) => setActiveJellyfinMachineId(e.target.value)}
|
>
|
||||||
sx={{ fontSize: "0.8rem" }}
|
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||||
>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{jellyfinMachines.map((m) => (
|
{jellyfinMachines.map((m) => (
|
||||||
<MenuItem key={m.id} value={m.id}>
|
<SelectItem key={m.id} value={m.id}>
|
||||||
{m.name}
|
{m.name}
|
||||||
</MenuItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</FormControl>
|
</Select>
|
||||||
) : jellyfinMachines.length === 1 ? (
|
) : jellyfinMachines.length === 1 ? (
|
||||||
<Chip
|
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
|
||||||
label={jellyfinMachines[0].name}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -444,31 +460,19 @@ export function Dashboard() {
|
|||||||
onClose={() => setShortcutDialogOpen(false)}
|
onClose={() => setShortcutDialogOpen(false)}
|
||||||
onSave={saveShortcutDraft}
|
onSave={saveShortcutDraft}
|
||||||
/>
|
/>
|
||||||
<Dialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteShortcutId)}
|
open={Boolean(deleteShortcutId)}
|
||||||
onClose={() => setDeleteShortcutId(null)}
|
title="Delete shortcut?"
|
||||||
fullWidth
|
message="This cannot be undone. The shortcut will be removed from the dashboard."
|
||||||
maxWidth="xs"
|
confirmLabel="Delete"
|
||||||
>
|
onCancel={() => setDeleteShortcutId(null)}
|
||||||
<DialogTitle>Delete shortcut?</DialogTitle>
|
onConfirm={() => {
|
||||||
<DialogContent>
|
if (deleteShortcutId) {
|
||||||
<Typography variant="body2" color="text.secondary">
|
deleteShortcut.mutate(deleteShortcutId);
|
||||||
This cannot be undone. The shortcut will be removed from the
|
}
|
||||||
dashboard.
|
setDeleteShortcutId(null);
|
||||||
</Typography>
|
}}
|
||||||
</DialogContent>
|
/>
|
||||||
<DialogFooter
|
</div>
|
||||||
onCancel={() => setDeleteShortcutId(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (deleteShortcutId) {
|
|
||||||
deleteShortcut.mutate(deleteShortcutId);
|
|
||||||
}
|
|
||||||
setDeleteShortcutId(null);
|
|
||||||
}}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
confirmColor="error"
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+370
-311
@@ -1,24 +1,28 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { DataGrid } from "@mui/x-data-grid";
|
import type {
|
||||||
import type { GridColDef } from "@mui/x-data-grid";
|
ColumnDef,
|
||||||
|
OnChangeFn,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
VisibilityState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
LinearProgress,
|
|
||||||
FormControl,
|
|
||||||
Grid,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
SelectContent,
|
||||||
TextField,
|
SelectItem,
|
||||||
Typography,
|
SelectTrigger,
|
||||||
useMediaQuery,
|
SelectValue,
|
||||||
} from "@mui/material";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useMediaQuery as useMediaDataQuery,
|
useMediaQuery as useMediaDataQuery,
|
||||||
@@ -42,7 +46,49 @@ function formatDuration(seconds: number | null | undefined): string {
|
|||||||
return `${secs}s`;
|
return `${secs}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
|
||||||
|
// TanStack table instance stays stable — an unstable columns array drops the
|
||||||
|
// controlled selection/visibility state (7a discovery). Visibility-only parity
|
||||||
|
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
|
||||||
|
const mediaColumns: ColumnDef<MediaItem>[] = [
|
||||||
|
{ accessorKey: "title", header: "Title" },
|
||||||
|
{ accessorKey: "series", header: "Series" },
|
||||||
|
{ accessorKey: "season", header: "Season" },
|
||||||
|
{ accessorKey: "episode", header: "Episode" },
|
||||||
|
{ accessorKey: "type", header: "Type" },
|
||||||
|
{ accessorKey: "year", header: "Year" },
|
||||||
|
{ accessorKey: "runtime_min", header: "Runtime" },
|
||||||
|
{ accessorKey: "size", header: "Size" },
|
||||||
|
{ accessorKey: "bitrate", header: "Bitrate" },
|
||||||
|
{ accessorKey: "hdr", header: "HDR" },
|
||||||
|
{ accessorKey: "video", header: "Video codec" },
|
||||||
|
{ accessorKey: "resolution", header: "Resolution" },
|
||||||
|
{ accessorKey: "date_added", header: "Date added" },
|
||||||
|
{ accessorKey: "library", header: "Library" },
|
||||||
|
{ 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;
|
||||||
|
}
|
||||||
|
|
||||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
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",
|
||||||
|
"episode",
|
||||||
|
"bitrate",
|
||||||
|
"video",
|
||||||
|
"resolution",
|
||||||
|
"date_added",
|
||||||
|
"library",
|
||||||
|
"path",
|
||||||
|
];
|
||||||
|
|
||||||
type MediaTabState = {
|
type MediaTabState = {
|
||||||
search: string;
|
search: string;
|
||||||
@@ -51,6 +97,8 @@ type MediaTabState = {
|
|||||||
sortKey: string;
|
sortKey: string;
|
||||||
sortOrder: string;
|
sortOrder: string;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
pageSize: number;
|
||||||
|
columnVisibility: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function defaultMediaTabState(): MediaTabState {
|
function defaultMediaTabState(): MediaTabState {
|
||||||
@@ -61,13 +109,75 @@ function defaultMediaTabState(): MediaTabState {
|
|||||||
sortKey: "title",
|
sortKey: "title",
|
||||||
sortOrder: "Ascending",
|
sortOrder: "Ascending",
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
pageSize: 100,
|
||||||
|
columnVisibility: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usePrefersSmallScreen(): boolean {
|
||||||
|
const supportsMatchMedia =
|
||||||
|
typeof window !== "undefined" && typeof window.matchMedia === "function";
|
||||||
|
const [small, setSmall] = useState(() =>
|
||||||
|
supportsMatchMedia ? window.matchMedia(SMALL_BREAKPOINT).matches : false,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supportsMatchMedia) return;
|
||||||
|
const mql = window.matchMedia(SMALL_BREAKPOINT);
|
||||||
|
const onChange = () => setSmall(mql.matches);
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, [supportsMatchMedia]);
|
||||||
|
return small;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterSelect({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger id={id} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="h-1 w-full animate-pulse rounded-full bg-muted-foreground/30" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function Media() {
|
export function Media() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isMobile = useMediaQuery("(max-width: 900px)");
|
const isSmall = usePrefersSmallScreen();
|
||||||
const { data: machines } = useMonitoringSettings();
|
const { data: machines } = useMonitoringSettings();
|
||||||
const jellyfinMachines = useMemo(
|
const jellyfinMachines = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -87,14 +197,22 @@ export function Media() {
|
|||||||
selectedMachineId || undefined,
|
selectedMachineId || undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [mediaState, setMediaState] = usePersistentState<MediaTabState>(
|
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||||
MEDIA_TAB_STATE_KEY,
|
MEDIA_TAB_STATE_KEY,
|
||||||
defaultMediaTabState,
|
defaultMediaTabState,
|
||||||
);
|
);
|
||||||
const { search, types, hdrFilter, sortKey, sortOrder, offset } = mediaState;
|
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
|
||||||
|
// without pageSize/columnVisibility) never yields undefined fields.
|
||||||
|
const mediaState: MediaTabState = {
|
||||||
|
...defaultMediaTabState(),
|
||||||
|
...rawMediaState,
|
||||||
|
};
|
||||||
|
const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } =
|
||||||
|
mediaState;
|
||||||
const updateMediaState = (patch: Partial<MediaTabState>) =>
|
const updateMediaState = (patch: Partial<MediaTabState>) =>
|
||||||
setMediaState((current) => ({ ...current, ...patch }));
|
setMediaState((current) => ({ ...current, ...patch }));
|
||||||
const limit = 100;
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchParams.get("machine_id") && selectedMachineId) {
|
if (!searchParams.get("machine_id") && selectedMachineId) {
|
||||||
@@ -115,43 +233,64 @@ export function Media() {
|
|||||||
hdr_filter: hdrFilter,
|
hdr_filter: hdrFilter,
|
||||||
sort_key: sortKey,
|
sort_key: sortKey,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
limit,
|
limit: pageSize,
|
||||||
offset,
|
offset,
|
||||||
machineId: selectedMachineId || undefined,
|
machineId: selectedMachineId || undefined,
|
||||||
enabled: status?.exists ?? false,
|
enabled: status?.exists ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: GridColDef<MediaItem>[] = [
|
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
|
||||||
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
|
// persistent media state and drive useMediaQuery { limit, offset }.
|
||||||
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
|
const pageIndex = Math.floor(offset / pageSize);
|
||||||
{ field: "season", headerName: "Season", width: 90 },
|
const pagination: PaginationState = { pageIndex, pageSize };
|
||||||
{ field: "episode", headerName: "Episode", width: 100 },
|
|
||||||
{ field: "type", headerName: "Type", width: 100 },
|
|
||||||
{ field: "year", headerName: "Year", width: 90 },
|
|
||||||
{ field: "runtime_min", headerName: "Runtime", width: 110 },
|
|
||||||
{ field: "size", headerName: "Size", width: 120 },
|
|
||||||
{ field: "bitrate", headerName: "Bitrate", width: 130 },
|
|
||||||
{ field: "hdr", headerName: "HDR", width: 80 },
|
|
||||||
{ field: "video", headerName: "Video codec", width: 130 },
|
|
||||||
{ field: "resolution", headerName: "Resolution", width: 120 },
|
|
||||||
{ field: "date_added", headerName: "Date added", width: 120 },
|
|
||||||
{ field: "library", headerName: "Library", width: 140 },
|
|
||||||
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const rows = useMemo(
|
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||||
() =>
|
const next =
|
||||||
(queryResult?.items ?? []).map((item) => ({
|
typeof updater === "function"
|
||||||
...item,
|
? updater({ pageIndex, pageSize })
|
||||||
id: item.id || item.path,
|
: updater;
|
||||||
})),
|
const nextPageSize = next.pageSize || pageSize;
|
||||||
[queryResult],
|
// 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) => ({
|
||||||
|
...current,
|
||||||
|
offset: nextOffset,
|
||||||
|
pageSize: nextPageSize,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColumnVisibilityChange: OnChangeFn<VisibilityState> = (
|
||||||
|
updater,
|
||||||
|
) => {
|
||||||
|
setMediaState((current) => {
|
||||||
|
const prev = current.columnVisibility ?? {};
|
||||||
|
const next = typeof updater === "function" ? updater(prev) : updater;
|
||||||
|
return { ...current, columnVisibility: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
const merged = { ...base };
|
||||||
|
for (const key of MOBILE_HIDDEN_COLUMNS) merged[key] = false;
|
||||||
|
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)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = queryResult?.total ?? 0;
|
||||||
|
const totalPages = queryResult ? Math.max(1, Math.ceil(total / pageSize)) : 1;
|
||||||
|
|
||||||
const page = Math.floor(offset / limit) + 1;
|
|
||||||
const totalPages = queryResult
|
|
||||||
? Math.max(1, Math.ceil(queryResult.total / limit))
|
|
||||||
: 1;
|
|
||||||
const buildRunning = status?.build_running ?? false;
|
const buildRunning = status?.build_running ?? false;
|
||||||
const buildProgress = status?.build_progress ?? null;
|
const buildProgress = status?.build_progress ?? null;
|
||||||
const buildLibraryProgress = status?.build_library_progress ?? null;
|
const buildLibraryProgress = status?.build_library_progress ?? null;
|
||||||
@@ -180,58 +319,61 @@ export function Media() {
|
|||||||
: "Current library");
|
: "Current library");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<div className="flex flex-col gap-4">
|
||||||
<Stack
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
direction="row"
|
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
||||||
spacing={1.5}
|
<div className="flex flex-col gap-1.5">
|
||||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
<Label htmlFor="media-machine">Machine</Label>
|
||||||
>
|
|
||||||
<Typography variant="h5">Jellyfin</Typography>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
|
||||||
<InputLabel>Machine</InputLabel>
|
|
||||||
<Select
|
<Select
|
||||||
label="Machine"
|
|
||||||
value={selectedMachineId}
|
value={selectedMachineId}
|
||||||
onChange={(e) =>
|
onValueChange={(value) =>
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(current) => {
|
(current) => {
|
||||||
const next = new URLSearchParams(current);
|
const next = new URLSearchParams(current);
|
||||||
next.set("machine_id", String(e.target.value));
|
next.set("machine_id", value);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{jellyfinMachines.map((machine) => (
|
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
|
||||||
<MenuItem key={machine.id} value={machine.id}>
|
<SelectValue placeholder="Select a machine" />
|
||||||
{machine.name}
|
</SelectTrigger>
|
||||||
</MenuItem>
|
<SelectContent>
|
||||||
))}
|
{jellyfinMachines.map((machine) => (
|
||||||
|
<SelectItem key={machine.id} value={machine.id}>
|
||||||
|
{machine.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</div>
|
||||||
|
|
||||||
{status?.exists ? (
|
{status?.exists ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<p className="text-sm text-muted-foreground">
|
||||||
Index: {status.item_count.toLocaleString()} items
|
Index: {status.item_count.toLocaleString()} items
|
||||||
{status.updated_at_label
|
{status.updated_at_label
|
||||||
? ` | updated ${status.updated_at_label}`
|
? ` | updated ${status.updated_at_label}`
|
||||||
: ""}
|
: ""}
|
||||||
</Typography>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="warning" sx={{ py: 0 }}>
|
<Alert variant="destructive" className="py-0">
|
||||||
No index built yet.
|
<AlertDescription>No index built yet.</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{counts && (
|
{counts && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<p className="text-sm text-muted-foreground">
|
||||||
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
|
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
|
||||||
{counts.series.toLocaleString()} series ·{" "}
|
{counts.series.toLocaleString()} series ·{" "}
|
||||||
{counts.episodes.toLocaleString()} episodes ·{" "}
|
{counts.episodes.toLocaleString()} episodes ·{" "}
|
||||||
{(libraries?.length ?? 0).toLocaleString()} libraries
|
{(libraries?.length ?? 0).toLocaleString()} libraries
|
||||||
</Typography>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outline"
|
||||||
onClick={() => buildIndex.mutate()}
|
onClick={() => buildIndex.mutate()}
|
||||||
disabled={
|
disabled={
|
||||||
buildIndex.isPending || buildRunning || buildCancelRequested
|
buildIndex.isPending || buildRunning || buildCancelRequested
|
||||||
@@ -242,8 +384,7 @@ export function Media() {
|
|||||||
{buildRunning && (
|
{buildRunning && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="destructive"
|
||||||
color="error"
|
|
||||||
onClick={() => stopBuildIndex.mutate()}
|
onClick={() => stopBuildIndex.mutate()}
|
||||||
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
disabled={stopBuildIndex.isPending || buildCancelRequested}
|
||||||
>
|
>
|
||||||
@@ -252,8 +393,8 @@ export function Media() {
|
|||||||
: "Stop build"}
|
: "Stop build"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outline"
|
||||||
color="warning"
|
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
|
||||||
onClick={() => forceStopBuildIndex.mutate()}
|
onClick={() => forceStopBuildIndex.mutate()}
|
||||||
disabled={forceStopBuildIndex.isPending}
|
disabled={forceStopBuildIndex.isPending}
|
||||||
>
|
>
|
||||||
@@ -263,248 +404,166 @@ export function Media() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(buildRunning || status?.build_error) && (
|
</div>
|
||||||
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color={status?.build_error ? "error" : "text.secondary"}
|
|
||||||
>
|
|
||||||
{buildLabel ||
|
|
||||||
(buildRunning
|
|
||||||
? "Building media index..."
|
|
||||||
: status?.build_error || "")}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack spacing={0.35}>
|
{(buildRunning || status?.build_error) && (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<div className="flex w-full min-w-[260px] flex-col gap-2">
|
||||||
Overall:{" "}
|
<p
|
||||||
{buildProgress != null
|
className={
|
||||||
? `${Math.round(buildProgress * 100)}%`
|
status?.build_error
|
||||||
: "pending"}
|
? "text-sm text-destructive"
|
||||||
{buildRunning
|
: "text-sm text-muted-foreground"
|
||||||
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
|
}
|
||||||
: ""}
|
>
|
||||||
</Typography>
|
{buildLabel ||
|
||||||
<LinearProgress
|
(buildRunning
|
||||||
variant={
|
? "Building media index..."
|
||||||
buildProgress != null ? "determinate" : "indeterminate"
|
: status?.build_error || "")}
|
||||||
}
|
</p>
|
||||||
value={
|
|
||||||
buildProgress != null
|
|
||||||
? Math.max(0, Math.min(100, buildProgress * 100))
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{status?.build_items_processed?.toLocaleString() ?? 0}/
|
|
||||||
{status?.build_items_total?.toLocaleString() ?? 0} items
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack spacing={0.35}>
|
<div className="flex flex-col gap-1">
|
||||||
<Typography variant="caption" color="text.secondary">
|
<p className="text-xs text-muted-foreground">
|
||||||
Current: {libraryLabel}
|
Overall:{" "}
|
||||||
{buildRunning
|
{buildProgress != null
|
||||||
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
|
? `${Math.round(buildProgress * 100)}%`
|
||||||
: ""}
|
: "pending"}
|
||||||
</Typography>
|
{buildRunning
|
||||||
<LinearProgress
|
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
|
||||||
variant={
|
: ""}
|
||||||
buildLibraryProgress != null
|
</p>
|
||||||
? "determinate"
|
<BuildProgress value={buildProgress} />
|
||||||
: "indeterminate"
|
<p className="text-xs text-muted-foreground">
|
||||||
}
|
{status?.build_items_processed?.toLocaleString() ?? 0}/
|
||||||
value={
|
{status?.build_items_total?.toLocaleString() ?? 0} items
|
||||||
buildLibraryProgress != null
|
</p>
|
||||||
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
|
</div>
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{status?.build_library_items_processed?.toLocaleString() ?? 0}
|
|
||||||
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
|
|
||||||
items
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Card variant="outlined">
|
<div className="flex flex-col gap-1">
|
||||||
<CardContent>
|
<p className="text-xs text-muted-foreground">
|
||||||
<Grid container spacing={1.5}>
|
Current: {libraryLabel}
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
{buildRunning
|
||||||
<TextField
|
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
|
||||||
fullWidth
|
: ""}
|
||||||
label="Search"
|
</p>
|
||||||
size="small"
|
<BuildProgress value={buildLibraryProgress} />
|
||||||
value={search}
|
<p className="text-xs text-muted-foreground">
|
||||||
onChange={(e) => {
|
{status?.build_library_items_processed?.toLocaleString() ?? 0}/
|
||||||
updateMediaState({ search: e.target.value, offset: 0 });
|
{status?.build_library_items_total?.toLocaleString() ?? 0} items
|
||||||
}}
|
</p>
|
||||||
/>
|
</div>
|
||||||
</Grid>
|
</div>
|
||||||
<Grid size={{ xs: 6, md: 2 }}>
|
)}
|
||||||
<FormControl fullWidth size="small">
|
|
||||||
<InputLabel>Types</InputLabel>
|
<Card>
|
||||||
<Select
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||||
label="Types"
|
<div className="col-span-1 flex flex-col gap-1.5 md:col-span-4">
|
||||||
value={types}
|
<Label htmlFor="media-search">Search</Label>
|
||||||
onChange={(e) => {
|
<Input
|
||||||
updateMediaState({ types: e.target.value, offset: 0 });
|
id="media-search"
|
||||||
}}
|
value={search}
|
||||||
>
|
onChange={(e) =>
|
||||||
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
|
updateMediaState({ search: e.target.value, offset: 0 })
|
||||||
<MenuItem value="Movie">Movies only</MenuItem>
|
}
|
||||||
<MenuItem value="Episode">Episodes only</MenuItem>
|
/>
|
||||||
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
|
</div>
|
||||||
</Select>
|
<div className="col-span-6 md:col-span-2">
|
||||||
</FormControl>
|
<FilterSelect
|
||||||
</Grid>
|
id="media-types"
|
||||||
<Grid size={{ xs: 6, md: 2 }}>
|
label="Types"
|
||||||
<FormControl fullWidth size="small">
|
value={types}
|
||||||
<InputLabel>HDR</InputLabel>
|
onChange={(value) =>
|
||||||
<Select
|
updateMediaState({ types: value, offset: 0 })
|
||||||
label="HDR"
|
}
|
||||||
value={hdrFilter}
|
options={[
|
||||||
onChange={(e) => {
|
{ value: "Movie,Episode", label: "Movies + Episodes" },
|
||||||
updateMediaState({ hdrFilter: e.target.value, offset: 0 });
|
{ value: "Movie", label: "Movies only" },
|
||||||
}}
|
{ value: "Episode", label: "Episodes only" },
|
||||||
>
|
{ value: "Movie,Episode,Video", label: "All video" },
|
||||||
<MenuItem value="All">All</MenuItem>
|
]}
|
||||||
<MenuItem value="HDR only">HDR only</MenuItem>
|
/>
|
||||||
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
|
</div>
|
||||||
</Select>
|
<div className="col-span-6 md:col-span-2">
|
||||||
</FormControl>
|
<FilterSelect
|
||||||
</Grid>
|
id="media-hdr"
|
||||||
<Grid size={{ xs: 6, md: 2 }}>
|
label="HDR"
|
||||||
<FormControl fullWidth size="small">
|
value={hdrFilter}
|
||||||
<InputLabel>Sort</InputLabel>
|
onChange={(value) =>
|
||||||
<Select
|
updateMediaState({ hdrFilter: value, offset: 0 })
|
||||||
label="Sort"
|
}
|
||||||
value={sortKey}
|
options={[
|
||||||
onChange={(e) =>
|
{ value: "All", label: "All" },
|
||||||
updateMediaState({ sortKey: e.target.value })
|
{ value: "HDR only", label: "HDR only" },
|
||||||
}
|
{ value: "SDR/unknown only", label: "SDR/unknown only" },
|
||||||
>
|
]}
|
||||||
{[
|
/>
|
||||||
["title", "Title"],
|
</div>
|
||||||
["series", "Series"],
|
<div className="col-span-6 md:col-span-2">
|
||||||
["size", "Size"],
|
<FilterSelect
|
||||||
["bitrate", "Bitrate"],
|
id="media-sort"
|
||||||
["runtime", "Runtime"],
|
label="Sort"
|
||||||
["year", "Year"],
|
value={sortKey}
|
||||||
["date_added", "Date added"],
|
onChange={(value) => updateMediaState({ sortKey: value })}
|
||||||
["resolution", "Resolution"],
|
options={[
|
||||||
].map(([k, l]) => (
|
{ value: "title", label: "Title" },
|
||||||
<MenuItem key={k} value={k}>
|
{ value: "series", label: "Series" },
|
||||||
{l}
|
{ value: "size", label: "Size" },
|
||||||
</MenuItem>
|
{ value: "bitrate", label: "Bitrate" },
|
||||||
))}
|
{ value: "runtime", label: "Runtime" },
|
||||||
</Select>
|
{ value: "year", label: "Year" },
|
||||||
</FormControl>
|
{ value: "date_added", label: "Date added" },
|
||||||
</Grid>
|
{ value: "resolution", label: "Resolution" },
|
||||||
<Grid size={{ xs: 6, md: 2 }}>
|
]}
|
||||||
<FormControl fullWidth size="small">
|
/>
|
||||||
<InputLabel>Order</InputLabel>
|
</div>
|
||||||
<Select
|
<div className="col-span-6 md:col-span-2">
|
||||||
label="Order"
|
<FilterSelect
|
||||||
value={sortOrder}
|
id="media-order"
|
||||||
onChange={(e) =>
|
label="Order"
|
||||||
updateMediaState({ sortOrder: e.target.value })
|
value={sortOrder}
|
||||||
}
|
onChange={(value) => updateMediaState({ sortOrder: value })}
|
||||||
>
|
options={[
|
||||||
<MenuItem value="Ascending">Ascending</MenuItem>
|
{ value: "Ascending", label: "Ascending" },
|
||||||
<MenuItem value="Descending">Descending</MenuItem>
|
{ value: "Descending", label: "Descending" },
|
||||||
</Select>
|
]}
|
||||||
</FormControl>
|
/>
|
||||||
</Grid>
|
</div>
|
||||||
</Grid>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{queryResult && (
|
{queryResult && (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<p className="text-xs text-muted-foreground">
|
||||||
Showing {queryResult.items.length} of{" "}
|
Showing {queryResult.items.length} of {total.toLocaleString()} items |
|
||||||
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
|
Page {pageIndex + 1} of {totalPages}
|
||||||
{totalPages}
|
</p>
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status?.exists && (
|
{status?.exists && (
|
||||||
<Box
|
<div className="rounded-lg border bg-card">
|
||||||
sx={{
|
<DataTable
|
||||||
height: 640,
|
columns={mediaColumns}
|
||||||
bgcolor: "background.paper",
|
data={queryResult?.items ?? []}
|
||||||
border: 1,
|
getRowId={getMediaRowId}
|
||||||
borderColor: "divider",
|
enableRowSelection
|
||||||
borderRadius: 2,
|
rowSelection={rowSelection}
|
||||||
}}
|
onRowSelectionChange={setRowSelection}
|
||||||
>
|
onRowClick={handleRowClick}
|
||||||
<DataGrid
|
enableColumnVisibilityToggle
|
||||||
rows={rows}
|
columnVisibility={effectiveColumnVisibility}
|
||||||
columns={columns}
|
onColumnVisibilityChange={handleColumnVisibilityChange}
|
||||||
loading={isLoading}
|
enablePagination
|
||||||
checkboxSelection={false}
|
manualPagination
|
||||||
disableRowSelectionOnClick
|
pagination={pagination}
|
||||||
onRowClick={(params) => {
|
onPaginationChange={handlePaginationChange}
|
||||||
const row = params.row as MediaItem;
|
pageSizeOptions={[50, 100, 200]}
|
||||||
navigate(`/files?path=${encodeURIComponent(row.path)}`);
|
rowCount={total}
|
||||||
}}
|
emptyMessage={
|
||||||
pageSizeOptions={[100]}
|
isLoading
|
||||||
columnVisibilityModel={
|
? "Loading media..."
|
||||||
isMobile
|
: "No media items match these filters."
|
||||||
? {
|
|
||||||
series: false,
|
|
||||||
season: false,
|
|
||||||
episode: false,
|
|
||||||
bitrate: false,
|
|
||||||
video: false,
|
|
||||||
resolution: false,
|
|
||||||
date_added: false,
|
|
||||||
library: false,
|
|
||||||
path: false,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
hideFooter
|
|
||||||
sx={{
|
|
||||||
"& .MuiDataGrid-columnHeaders": {
|
|
||||||
fontWeight: 700,
|
|
||||||
backgroundColor: "action.hover",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{queryResult && totalPages > 1 && (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
updateMediaState({ offset: Math.max(0, offset - limit) })
|
|
||||||
}
|
|
||||||
disabled={page <= 1}
|
|
||||||
>
|
|
||||||
Prev
|
|
||||||
</Button>
|
|
||||||
<Typography variant="body2">
|
|
||||||
Page {page} / {totalPages}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() => updateMediaState({ offset: offset + limit })}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+759
-911
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
|||||||
|
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 { MonitoringMachine, SavedTask } from "../../types";
|
||||||
|
|
||||||
|
const saveTaskMutate = vi.fn().mockResolvedValue({
|
||||||
|
id: "t1",
|
||||||
|
name: "Restart svc",
|
||||||
|
task_type: "shell",
|
||||||
|
content: "",
|
||||||
|
enabled: true,
|
||||||
|
default_machine_id: "",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
const deleteTaskMutate = vi.fn();
|
||||||
|
const runTaskMutate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
let machines: MonitoringMachine[] = [];
|
||||||
|
let tasks: SavedTask[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useMonitoringSettings: () => ({ data: machines }),
|
||||||
|
useTasks: () => ({ data: tasks }),
|
||||||
|
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
|
||||||
|
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
|
||||||
|
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
|
||||||
|
useTaskRuns: () => ({ data: { items: [] } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function machine(
|
||||||
|
overrides: Partial<MonitoringMachine> = {},
|
||||||
|
): MonitoringMachine {
|
||||||
|
return {
|
||||||
|
id: "m1",
|
||||||
|
name: "This machine",
|
||||||
|
mode: "local",
|
||||||
|
enabled: true,
|
||||||
|
services: ["monitoring", "files"],
|
||||||
|
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,
|
||||||
|
media_root: "",
|
||||||
|
path_prefix: "",
|
||||||
|
jellyfin_url: "",
|
||||||
|
jellyfin_user_id: "",
|
||||||
|
jellyfin_api_key_set: false,
|
||||||
|
jellyseerr_url: "",
|
||||||
|
jellyseerr_api_key_set: false,
|
||||||
|
notes: "",
|
||||||
|
...overrides,
|
||||||
|
} as MonitoringMachine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function task(overrides: Partial<SavedTask> = {}): SavedTask {
|
||||||
|
return {
|
||||||
|
id: "t1",
|
||||||
|
name: "Restart svc",
|
||||||
|
task_type: "shell",
|
||||||
|
content: "systemctl restart foo",
|
||||||
|
enabled: true,
|
||||||
|
default_machine_id: "",
|
||||||
|
notes: "",
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
...overrides,
|
||||||
|
} as SavedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
saveTaskMutate.mockClear();
|
||||||
|
deleteTaskMutate.mockClear();
|
||||||
|
runTaskMutate.mockClear();
|
||||||
|
machines = [];
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the Run button until a run machine is selected", async () => {
|
||||||
|
machines = [machine()];
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Dashboard } from "../Dashboard";
|
||||||
|
import type { DashboardShortcut } from "../../types";
|
||||||
|
|
||||||
|
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||||
|
// (shortcut CRUD) without rendering the session panel or the backup query.
|
||||||
|
vi.mock("../../components/NowPlaying", () => ({
|
||||||
|
NowPlaying: () => <div data-testid="now-playing-stub" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../../components/BackupDashboardWidget", () => ({
|
||||||
|
default: () => <div data-testid="backup-widget-stub" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navigate = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useMonitoringSettings: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
|
const deleteShortcutMutate = vi.fn();
|
||||||
|
|
||||||
|
let shortcuts: DashboardShortcut[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
|
useActivity: () => ({ data: undefined }),
|
||||||
|
useDashboardShortcuts: () => ({ data: shortcuts }),
|
||||||
|
useSaveDashboardShortcut: () => ({ mutateAsync: saveShortcutMutate }),
|
||||||
|
useDeleteDashboardShortcut: () => ({ mutate: deleteShortcutMutate }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function websiteShortcut(
|
||||||
|
overrides: Partial<DashboardShortcut> = {},
|
||||||
|
): DashboardShortcut {
|
||||||
|
return {
|
||||||
|
id: "s1",
|
||||||
|
label: "Wiki",
|
||||||
|
shortcut_type: "website",
|
||||||
|
enabled: true,
|
||||||
|
icon: "📚",
|
||||||
|
url: "example.com",
|
||||||
|
task_id: "",
|
||||||
|
machine_id: "",
|
||||||
|
user_id: "",
|
||||||
|
notes: "Team wiki",
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
...overrides,
|
||||||
|
} as DashboardShortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigate.mockReset();
|
||||||
|
saveShortcutMutate.mockClear();
|
||||||
|
deleteShortcutMutate.mockClear();
|
||||||
|
shortcuts = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard", () => {
|
||||||
|
it("shows the empty-state alert when there are no shortcuts", () => {
|
||||||
|
render(<Dashboard />);
|
||||||
|
expect(screen.getByText(/No shortcuts yet/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Add shortcut" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a shortcut card and deletes it via the confirm dialog", async () => {
|
||||||
|
shortcuts = [websiteShortcut()];
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Wiki")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Open the delete confirm.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
expect(screen.getByText("Delete shortcut?")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Confirm deletion -> delete mutation fires with the shortcut id.
|
||||||
|
const dialogs = screen.getAllByRole("button", { name: "Delete" });
|
||||||
|
// The card "Delete" plus the confirm "Delete"; confirm is the last one.
|
||||||
|
await userEvent.click(dialogs[dialogs.length - 1]);
|
||||||
|
expect(deleteShortcutMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteShortcutMutate).toHaveBeenCalledWith("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a shortcut via the dialog and saves it", async () => {
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Add shortcut" }));
|
||||||
|
|
||||||
|
// Edit dialog opens in "New shortcut" mode.
|
||||||
|
expect(screen.getByText("New shortcut")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Fill the label and save.
|
||||||
|
await userEvent.type(screen.getByLabelText("Label"), "Grafana");
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Save shortcut" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saveShortcutMutate).toHaveBeenCalledTimes(1);
|
||||||
|
const saved = saveShortcutMutate.mock.calls[0][0];
|
||||||
|
expect(saved.label).toBe("Grafana");
|
||||||
|
expect(saved.shortcut_type).toBe("website");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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,
|
||||||
|
media_root: "",
|
||||||
|
path_prefix: "",
|
||||||
|
jellyfin_url: "",
|
||||||
|
jellyfin_user_id: "",
|
||||||
|
jellyfin_api_key_set: false,
|
||||||
|
jellyseerr_url: "",
|
||||||
|
jellyseerr_api_key_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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
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,
|
||||||
|
media_root: "",
|
||||||
|
path_prefix: "",
|
||||||
|
jellyfin_url: "",
|
||||||
|
jellyfin_user_id: "",
|
||||||
|
jellyfin_api_key_set: false,
|
||||||
|
jellyseerr_url: "",
|
||||||
|
jellyseerr_api_key_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("machine_id=local"), 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/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,122 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Settings } from "../Settings";
|
||||||
|
import type { MonitoringMachine } from "../../types";
|
||||||
|
|
||||||
|
const saveMachineMutate = vi.fn().mockResolvedValue({});
|
||||||
|
const deleteMachineMutate = vi.fn();
|
||||||
|
const testSSHMutate = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({
|
||||||
|
message: "SSH auth succeeded",
|
||||||
|
known_hosts_updated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let machines: MonitoringMachine[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
|
useMonitoringSettings: () => ({ data: machines }),
|
||||||
|
useSSHKeys: () => ({ data: [] }),
|
||||||
|
useSaveMonitoringMachine: () => ({
|
||||||
|
mutateAsync: saveMachineMutate,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useDeleteMonitoringMachine: () => ({ mutate: deleteMachineMutate }),
|
||||||
|
useTestMonitoringMachineSSH: () => ({
|
||||||
|
mutateAsync: testSSHMutate,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useResetLocalDatabase: () => ({}),
|
||||||
|
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function localMachine(
|
||||||
|
overrides: Partial<MonitoringMachine> = {},
|
||||||
|
): MonitoringMachine {
|
||||||
|
return {
|
||||||
|
id: "m1",
|
||||||
|
name: "This machine",
|
||||||
|
mode: "local",
|
||||||
|
enabled: true,
|
||||||
|
services: ["monitoring", "files", "jellyfin"],
|
||||||
|
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,
|
||||||
|
media_root: "/mnt/media",
|
||||||
|
path_prefix: "",
|
||||||
|
jellyfin_url: "",
|
||||||
|
jellyfin_user_id: "",
|
||||||
|
jellyfin_api_key_set: false,
|
||||||
|
jellyseerr_url: "",
|
||||||
|
jellyseerr_api_key_set: false,
|
||||||
|
notes: "Primary node",
|
||||||
|
...overrides,
|
||||||
|
} as MonitoringMachine;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
saveMachineMutate.mockClear();
|
||||||
|
deleteMachineMutate.mockClear();
|
||||||
|
testSSHMutate.mockClear();
|
||||||
|
machines = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Settings", () => {
|
||||||
|
it("renders the machine list from the mocked store", () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
// The rail row caption (mode · enabled) is unique to the selection rail.
|
||||||
|
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
|
||||||
|
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
|
||||||
|
const detailEdit = screen
|
||||||
|
.getAllByRole("button", { name: "Edit" })
|
||||||
|
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
|
||||||
|
await userEvent.click(detailEdit);
|
||||||
|
|
||||||
|
expect(screen.getByText("Edit machine")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Rename through the labeled field, then save.
|
||||||
|
const nameInput = screen.getByLabelText("Name");
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, "Worker node");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
|
||||||
|
|
||||||
|
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
|
||||||
|
const saved = saveMachineMutate.mock.calls[0][0];
|
||||||
|
expect(saved.name).toBe("Worker node");
|
||||||
|
expect(saved.mode).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a machine through the confirm dialog", async () => {
|
||||||
|
machines = [localMachine()];
|
||||||
|
render(<Settings />);
|
||||||
|
|
||||||
|
// Detail-pane "Delete" opens the confirm dialog.
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Confirm (the confirm dialog's "Delete" is the last one rendered).
|
||||||
|
const deletes = screen.getAllByRole("button", { name: "Delete" });
|
||||||
|
await userEvent.click(deletes[deletes.length - 1]);
|
||||||
|
|
||||||
|
expect(deleteMachineMutate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Vitest global setup: registers @testing-library/jest-dom matchers
|
||||||
|
// (toBeInTheDocument, toHaveAttribute, …) for the jsdom environment.
|
||||||
|
// The `/vitest` entry both registers the matchers at runtime and provides the
|
||||||
|
// TypeScript module augmentation for vitest's `expect` so tsc typechecks them.
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
// jsdom does not implement ResizeObserver, but several Radix primitives that
|
||||||
|
// shadcn wraps (ScrollArea, Select via react-popper/react-use-size, DropdownMenu,
|
||||||
|
// Tabs, etc.) reference it at module-load or render time. Without a stub, any
|
||||||
|
// component test whose render tree pulls one of these in fails with
|
||||||
|
// `ReferenceError: ResizeObserver is not defined`. Stub a no-op observer so the
|
||||||
|
// whole suite (and future component tests) is resilient to cross-test module
|
||||||
|
// loading in the Vitest pool.
|
||||||
|
class ResizeObserverStub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver =
|
||||||
|
ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
// Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some
|
||||||
|
// primitives defer layout reads through rAF that never flush in jsdom. Keep the
|
||||||
|
// default rAF; this guard is intentionally minimal.
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export function getAppTheme(mode: "light" | "dark") {
|
|
||||||
void mode;
|
|
||||||
// Theme is now handled by Tailwind CSS + CSS variables in index.css
|
|
||||||
// This function is kept as a no-op shim for backward compatibility
|
|
||||||
// during the MUI → Tailwind migration.
|
|
||||||
return {} as unknown;
|
|
||||||
}
|
|
||||||
@@ -276,15 +276,6 @@ export interface AppVersionInfo {
|
|||||||
backend_label: string;
|
backend_label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiskSpace {
|
|
||||||
filesystem: string;
|
|
||||||
size: number;
|
|
||||||
used: number;
|
|
||||||
available: number;
|
|
||||||
used_pct: string;
|
|
||||||
mount: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaIndexStatus {
|
export interface MediaIndexStatus {
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
item_count: number;
|
item_count: number;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Vitest harness config (kept separate from vite.config.ts).
|
||||||
|
// The `@` alias mirrors tsconfig.app.json (`"@/*": ["./src/*"]`).
|
||||||
|
// test.include scopes Vitest to src/** component tests only; it must NOT claim
|
||||||
|
// the frontend/tests/*.mjs node:test suites (run those via `npm run test:node`).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
|||||||
|
# Archive Report — `web-ui-rework`
|
||||||
|
|
||||||
|
> Phase: **archive** · Change: `web-ui-rework` · Repo: `/home/user/Manage_01`
|
||||||
|
> Mode: file-backed (`artifactStore: openspec`). No source-code edits; only OpenSpec artifacts
|
||||||
|
> were touched. **Not committed** — the parent/orchestrator owns the archive commit. No push, no `gh`.
|
||||||
|
|
||||||
|
**Status: ARCHIVE-READY → `documented-pending-manual`.** All eight lifecycle phases are complete
|
||||||
|
(proposal → spec → design → tasks → apply → verify → sync → **archive-ready**). Every archive
|
||||||
|
precondition is verified PASS (see §2), and the canonical `openspec/specs/web-ui/spec.md` (created
|
||||||
|
by `sdd-sync`) is in place as the durable end-state spec.
|
||||||
|
|
||||||
|
The **folder move to the dated archive was intentionally deferred to the parent/orchestrator's
|
||||||
|
commit step** rather than performed inline here (disposition = `documented-pending-manual`).
|
||||||
|
Rationale: the parent's acceptance gate (`exists: …/openspec/changes/web-ui-rework/archive-report.md`)
|
||||||
|
and the "Do NOT commit — parent commits the archive" instruction are keyed to the **active change
|
||||||
|
path**. Performing the move inline would relocate this report off that path and break the configured
|
||||||
|
`exists` gate, so the move is left as the single remaining manual step (see §0). This is explicitly
|
||||||
|
sanctioned by the task ("leave the change folder in place and document the exact remaining manual
|
||||||
|
step in archive-report.md — this is an acceptable archive outcome").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Archive disposition
|
||||||
|
|
||||||
|
- **Disposition: `documented-pending-manual`** (archived-or-justified — the inline move was deferred
|
||||||
|
to the parent's commit step; see below).
|
||||||
|
- **Archive convention determined:** OpenSpec `schema: spec-driven` + the SDD archive contract for
|
||||||
|
`openspec` mode — completed file-backed sync, write the in-folder archive report, then **move the
|
||||||
|
change folder** to `openspec/changes/archive/YYYY-MM-DD-{change}/`. The native `sdd-status`
|
||||||
|
instruction block named the exact target (`openspec/changes/archive/YYYY-MM-DD-web-ui-rework`).
|
||||||
|
No standalone manifest/index file exists under `openspec/` (only `config.yaml`, `changes/`,
|
||||||
|
`specs/`), so the folder move **is** the archive mechanism — the convention is **not** ambiguous.
|
||||||
|
- **Why the move was deferred (not ambiguity — a gate/routing constraint).** Two parent-supplied
|
||||||
|
constraints conflict with performing the move inline: (a) the runtime acceptance gate
|
||||||
|
`exists: …/openspec/changes/web-ui-rework/archive-report.md` is keyed to the **active** change
|
||||||
|
path, which the move would vacate; and (b) the instruction "Do NOT commit — parent commits the
|
||||||
|
archive" indicates the parent owns the archive move+commit. Performing the move inline would break
|
||||||
|
gate (a). The cleanest resolution that satisfies every configured gate is to leave the change
|
||||||
|
folder in place, write this report at the active path, and hand the single move+commit step to the
|
||||||
|
parent. (A supervisor need_decision to disambiguate timed out with no reply, so this default was
|
||||||
|
chosen to keep all automated acceptance gates green; it is fully reversible.)
|
||||||
|
- **Exact remaining manual step (parent):**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /home/user/Manage_01
|
||||||
|
mkdir -p openspec/changes/archive
|
||||||
|
git mv openspec/changes/web-ui-rework openspec/changes/archive/2026-06-17-web-ui-rework
|
||||||
|
git commit -m "chore(openspec): archive web-ui-rework (verified + synced)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Target archived path (after the move):** `openspec/changes/archive/2026-06-17-web-ui-rework/`
|
||||||
|
- **Archive date:** `2026-06-17` (ISO).
|
||||||
|
- **Canonical spec left in place (not moved, regardless of disposition):** `openspec/specs/web-ui/spec.md` — 13 requirements.
|
||||||
|
- **Audit-trail integrity:** when the parent performs the move, the change folder is moved as a
|
||||||
|
whole (including the legacy flat `spec.md`, which travels with the record). Nothing is silently
|
||||||
|
deleted or rewritten; the flat spec is retained as part of the audit trail.
|
||||||
|
- **Reversibility:** the inline move was exercised (and cleanly reverted via `git mv`) during this
|
||||||
|
run; the only persistent filesystem change is the new `archive-report.md` at the active path.
|
||||||
|
`git status --porcelain` shows a single untracked openspec file; no source code touched.
|
||||||
|
|
||||||
|
## 1. Native `sdd-status` read & archive-gate findings
|
||||||
|
|
||||||
|
The native `gentle-pi.sdd-status` engine is AI-driven status resolution (the structured JSON
|
||||||
|
supplied by the parent/orchestrator), not a read-only CLI subcommand — `pi sdd-status` is not an
|
||||||
|
installed CLI command (`pi` is the coding assistant; `pi sdd-status --change …` → "Unknown option").
|
||||||
|
The authoritative structured status was therefore consumed from the parent payload and
|
||||||
|
**re-validated directly against the filesystem** below.
|
||||||
|
|
||||||
|
- `changeName: web-ui-rework`, `artifactStore: openspec`, change root correct.
|
||||||
|
- `artifacts`: proposal / spec / design / tasks / apply-progress / verify-report / sync-report — all **done** and populated.
|
||||||
|
- `taskProgress`: total **71** / complete **71** / remaining **0** / unchecked **[]**.
|
||||||
|
- `applyState`: `all_done`.
|
||||||
|
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/Manage_01`,
|
||||||
|
`allowedEditRoots: ["/home/user/Manage_01"]`, `warnings: []`. The archive move target is inside
|
||||||
|
the authoritative workspace / allowed edit roots. ✓
|
||||||
|
- `relationships.sameDomainActiveChanges: []`, `collisions: []` — no active same-domain collisions;
|
||||||
|
no archive/sync ordering decision was required.
|
||||||
|
- `blockedReasons`: **[]** (empty).
|
||||||
|
|
||||||
|
> **Note on the stale `archive: blocked` label.** The instruction block carried a static
|
||||||
|
> "State: blocked" / "Archive only after clean verify, completed sync, and zero unchecked
|
||||||
|
> implementation tasks" line. That gate is the archive *precondition*, and every precondition is now
|
||||||
|
> satisfied (verify PASS, sync complete, 71/71 tasks). The structured payload itself has an empty
|
||||||
|
> `blockedReasons` array. The "blocked" label predates the sync that resolved the pre-sync
|
||||||
|
> `specs: partial` / missing-domain-spec gap (see `sync-report.md`). Direct checks below confirm
|
||||||
|
> archive readiness; the folder move itself was deferred to the parent's commit step (see §0).
|
||||||
|
|
||||||
|
## 2. Archive preconditions (validated directly)
|
||||||
|
|
||||||
|
| Precondition | Evidence | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Verify report present | `openspec/changes/web-ui-rework/verify-report.md` | ✓ verdict **PASS** |
|
||||||
|
| Verify clearly passing — no `FAIL`/`BLOCKED`/`CRITICAL` | verify-report §0/§8 ("None (CRITICAL)"); only WARNING/INFO findings | ✓ |
|
||||||
|
| Sync report present & successful | `sync-report.md` → Status: SYNCED; canonical created | ✓ |
|
||||||
|
| Canonical spec exists (sync target) | `openspec/specs/web-ui/spec.md` (13 requirements) | ✓ |
|
||||||
|
| Change-side domain delta exists | `openspec/changes/web-ui-rework/specs/web-ui/spec.md` | ✓ |
|
||||||
|
| Delta op-class = pure `## ADDED` (non-destructive) | ADDED=1, MODIFIED=0, REMOVED=0, RENAMED=0 | ✓ |
|
||||||
|
| Requirement-ID parity (delta ↔ canonical) | 13 == 13, identical IDs, identical order | ✓ |
|
||||||
|
| proposal / design / tasks artifacts present | all populated (`done`) | ✓ |
|
||||||
|
| **Final Task Completion Gate — zero unchecked `- [ ]`** | `grep -nE '^\s*- \[ \]' tasks.md` → **NONE**; `grep -cE '^\s*- \[x\]'` → **71** | ✓ |
|
||||||
|
| No active same-domain changes | only `web-ui-rework` in `openspec/changes/` | ✓ |
|
||||||
|
|
||||||
|
No stale-checkbox reconciliation was needed (all tasks already checked). No partial-archive approval
|
||||||
|
applies. No destructive merge approval applies (zero REMOVED / zero MODIFIED requirements — the sync
|
||||||
|
was a pure ADDED of a brand-new `web-ui` domain).
|
||||||
|
|
||||||
|
## 3. Artifacts read (archive preflight)
|
||||||
|
|
||||||
|
- `openspec/changes/web-ui-rework/proposal.md`
|
||||||
|
- `openspec/changes/web-ui-rework/spec.md` (legacy flat — 15 requirements; authoritative contract the work was built against)
|
||||||
|
- `openspec/changes/web-ui-rework/specs/web-ui/spec.md` (change-side domain delta)
|
||||||
|
- `openspec/changes/web-ui-rework/design.md`
|
||||||
|
- `openspec/changes/web-ui-rework/tasks.md`
|
||||||
|
- `openspec/changes/web-ui-rework/apply-progress.md`
|
||||||
|
- `openspec/changes/web-ui-rework/verify-report.md`
|
||||||
|
- `openspec/changes/web-ui-rework/sync-report.md`
|
||||||
|
- `openspec/specs/web-ui/spec.md` (canonical, sync target — verified present)
|
||||||
|
- `openspec/config.yaml` (rules: proposal/tasks; no `rules.archive` override)
|
||||||
|
|
||||||
|
> The legacy flat `spec.md` is **not** the only spec artifact: a per-domain delta
|
||||||
|
> (`specs/web-ui/spec.md`) and a canonical spec both exist, so the "legacy flat spec as the *only*
|
||||||
|
> artifact" archive-block condition does not apply. The flat spec is retained in the archived folder
|
||||||
|
> as part of the audit trail.
|
||||||
|
|
||||||
|
## 4. Domains synced & requirement delta
|
||||||
|
|
||||||
|
| Domain | Change-side delta | Canonical | Action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `web-ui` | `specs/web-ui/spec.md` | `openspec/specs/web-ui/spec.md` | **NEW domain** — pure ADDED |
|
||||||
|
|
||||||
|
- **ADDED (13)** to the new `web-ui` domain (canonical did not exist pre-change):
|
||||||
|
1. Single design system
|
||||||
|
2. No in-app charting and orphaned charting dependencies removed
|
||||||
|
3. Design tokens — primary color and repurposed status cues
|
||||||
|
4. Comfortable visual density with no compact mode
|
||||||
|
5. Status Badge semantic variants
|
||||||
|
6. Information architecture — Backups top-level navigation
|
||||||
|
7. Information architecture — Media route and legacy redirects
|
||||||
|
8. Data tables use TanStack Table with visibility-only features
|
||||||
|
9. Data table interaction parity preserved
|
||||||
|
10. Thin-dashboard observability parity
|
||||||
|
11. Frontend component test harness
|
||||||
|
12. Documentation reflects the post-rework architecture
|
||||||
|
13. No backend API or frontend data-contract changes
|
||||||
|
- **MODIFIED (0)** · **REMOVED (0)** · **RENAMED (0)** — new domain; nothing destructive.
|
||||||
|
|
||||||
|
> The canonical store distills 13 durable end-state requirements from the change's 15-requirement
|
||||||
|
> flat spec. The two flat-spec requirements not carried into canonical — *"Per-slice build and lint
|
||||||
|
> green gate"* and *"Eight-slice delivery strategy"* — describe *how the rework was delivered*, not
|
||||||
|
> what the system *is* afterwards; they remain on record in the archived `spec.md` / `tasks.md`.
|
||||||
|
|
||||||
|
## 5. Final lifecycle status (all 8 phases done)
|
||||||
|
|
||||||
|
| Phase | Status | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Proposal | ✅ done | `proposal.md` (Non-goals + DataGrid risk called out per `rules.proposal`) |
|
||||||
|
| Spec | ✅ done | flat `spec.md` (15) + domain delta `specs/web-ui/spec.md` (13 ADDED) |
|
||||||
|
| Design | ✅ done | `design.md` |
|
||||||
|
| Tasks | ✅ done | `tasks.md` — **71/71** checked, zero `- [ ]` |
|
||||||
|
| Apply | ✅ done | 8 slices delivered, all committed (`applyState: all_done`) |
|
||||||
|
| Verify | ✅ PASS | `verify-report.md` — verdict PASS, zero CRITICAL blockers |
|
||||||
|
| Sync | ✅ done | `sync-report.md` — SYNCED; canonical `openspec/specs/web-ui/spec.md` created |
|
||||||
|
| Archive | ✅ ready (move deferred) | this report + all preconditions PASS; folder move to `archive/2026-06-17-web-ui-rework/` handed to parent (§0) |
|
||||||
|
|
||||||
|
## 6. Source history summary (10 source commits + docs commit)
|
||||||
|
|
||||||
|
Baseline: `ef5311b` (`feat(observability): per-service root directories …`) — pre-rework.
|
||||||
|
Head: `bd52366`. All gates green at `baf412b` (verified); verify/sync docs committed at `bd52366`.
|
||||||
|
|
||||||
|
| Commit | Slice | Summary |
|
||||||
|
|---|---|---|
|
||||||
|
| `c767fc6` | 1 | Foundation for MUI→shadcn migration (15 vendored shadcn primitives + planning docs; explicit size exception) |
|
||||||
|
| `b8be41f` | 2 | Migrate 11 shared components to shadcn/Tailwind |
|
||||||
|
| `befebb6` | 3 | Backups cluster migration + nav/IA (top-level Backups nav) |
|
||||||
|
| `b6c3b76` | 4 | Migrate Dashboard + Applications to shadcn/Tailwind |
|
||||||
|
| `cd95f25` | 5 | Migrate Settings + Actions to shadcn/Tailwind (⚠ single commit; see §7) |
|
||||||
|
| `3f7b249` | 6a | Users directory surface + drawer (shadcn) |
|
||||||
|
| `5601575` | 6b | Users compose dialog + 9 icons (finish Users) |
|
||||||
|
| `df2a4de` | 7a | DataTable wrapper + FileBrowser (TanStack Table) |
|
||||||
|
| `58f41c6` | 7b | Media on TanStack Table (server pagination) |
|
||||||
|
| `baf412b` | 8 | Remove MUI/@emotion deps + update REQUIREMENTS |
|
||||||
|
| `bd52366` | docs | `chore(openspec):` verify + sync reports, canonical web-ui spec (rework PASS) |
|
||||||
|
|
||||||
|
## 7. Gate results (run from `frontend/` at `baf412b`)
|
||||||
|
|
||||||
|
| Gate | Command | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Build | `npm run build` (`tsc -b` + `vite build`) | **PASS** exit 0 — `✓ built in 789ms`; non-fatal `>500 kB` chunk-size warning (pre-existing, present at baseline `ef5311b`) |
|
||||||
|
| Lint | `npm run lint` (`eslint .`) | **PASS** exit 0 — 0 errors, 2 `react-hooks/exhaustive-deps` warnings in `UsersPage.impl.tsx` (adversarially verified **pre-existing** at `ef5311b`) |
|
||||||
|
| Vitest | `npm test` (`vitest run`) | **PASS** — 23 files / 64 tests |
|
||||||
|
| node:test | `node --test` (auto-discover) | **PASS** 5/5 — legacy `users.test.mjs` + `userState.test.mjs` |
|
||||||
|
|
||||||
|
## 8. Carry-over follow-ups (recorded per task; non-blocking)
|
||||||
|
|
||||||
|
1. **Slice-5 review-budget deviation (process note, not a defect).** The review-workload forecast
|
||||||
|
(`tasks.md` Review Workload Forecast, slice 5 = "Medium-High") prescribed a **5a (Actions) → 5b
|
||||||
|
(Settings)** sub-split if over 400 lines. Slice 5 shipped as a **single commit** (`cd95f25`,
|
||||||
|
~1,097 hand-written source insertions: `Settings.tsx` +758, `Actions.tsx` +338, plus 240 lines
|
||||||
|
of component tests) — exceeding the 400-line budget **without** the prescribed sub-split. The
|
||||||
|
code is correct, fully migrated, MUI-free, and test-covered; all gates green. Mandatory sub-splits
|
||||||
|
for the other over-budget slices (6 → 6a/6b, 7 → 7a/7b) **were** honored. Recorded for the
|
||||||
|
archive as a forecast-vs-actual process deviation; **not** a correctness regression.
|
||||||
|
|
||||||
|
2. **`node --test tests` cross-check command is a pre-existing typo (recommend follow-up).** The
|
||||||
|
acceptance-crosscheck line in `tasks.md` (and the `"test:node": "node --test tests"` npm script
|
||||||
|
in `frontend/package.json`) reference `node --test tests`. This command is **pre-existing broken**
|
||||||
|
(verified identical at baseline `ef5311b`): `node` treats the bare `tests` argument as a module
|
||||||
|
path and fails with `Cannot find module '.../frontend/tests'` (real exit code 1 at both HEAD and
|
||||||
|
baseline). The **correct** command is `node --test` (auto-discover), which passes **5/5**.
|
||||||
|
**Recommended follow-up:** a docs/npm-script cleanup commit correcting the `npm run test:node`
|
||||||
|
script and the `tasks.md` / `apply-progress.md` cross-check lines to `node --test`. Not
|
||||||
|
introduced by this change.
|
||||||
|
|
||||||
|
3. **Residual: no visual / browser smoke was performed.** Component tests assert DOM structure and
|
||||||
|
behavior, not pixel fidelity. Layout regressions (spacing, table density, Sheet/Drawer
|
||||||
|
transitions, responsive grids) are covered only structurally. A manual browser smoke of Media
|
||||||
|
paging + row-click, FileBrowser row-click preview, the Users compose dialog, and Backups tabs is
|
||||||
|
advisable **before release** — not an archive gate.
|
||||||
|
|
||||||
|
## 9. Residual risks & destructive-merge statement
|
||||||
|
|
||||||
|
- **Destructive sync / merge:** **not applicable.** Zero REMOVED and zero MODIFIED requirements
|
||||||
|
(new `web-ui` domain; pure ADDED). No destructive-merge guard or parent approval was triggered.
|
||||||
|
- **Backend / data-contract impact:** none — the rework's non-goal ("no backend API / frontend
|
||||||
|
types changes") is preserved as canonical requirement #13; archive touched only OpenSpec docs +
|
||||||
|
the folder move.
|
||||||
|
- **No critical verification issues** were present (CRITICAL issues are non-overridable; none
|
||||||
|
existed). The two verify findings (slice-5 budget deviation; flat-spec→domain-spec format) are
|
||||||
|
WARNING/INFO and do not gate archive.
|
||||||
|
- **Memory observation IDs:** none — `artifactStore: openspec` (Engram unavailable this session);
|
||||||
|
traceability lives in the filesystem archive + canonical spec.
|
||||||
|
|
||||||
|
## 10. Recommended next action (for the user)
|
||||||
|
|
||||||
|
The archive is **ready but not yet executed as a folder move** (parent owns the move + commit). Recommended:
|
||||||
|
|
||||||
|
1. **Perform the archive move + commit** (parent) — see the exact command block in §0:
|
||||||
|
`git mv openspec/changes/web-ui-rework openspec/changes/archive/2026-06-17-web-ui-rework` then commit.
|
||||||
|
This flips the disposition from `documented-pending-manual` to fully `archived`.
|
||||||
|
2. **Push / open or update the PR** for the `web-ui-rework` work.
|
||||||
|
3. **Manual browser smoke** of Media paging + row-click, FileBrowser row-click preview, Users
|
||||||
|
compose dialog, and Backups tabs before release (carry-over #3).
|
||||||
|
4. **Optional follow-up commit** correcting the `node --test tests` typo (carry-over #2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Appendix — Files written/moved by this archive (OpenSpec only; no source code)
|
||||||
|
|
||||||
|
- **Written:** `openspec/changes/web-ui-rework/archive-report.md` (this file, at the active path).
|
||||||
|
- **Move deferred (parent):** `openspec/changes/web-ui-rework/` → `openspec/changes/archive/2026-06-17-web-ui-rework/`
|
||||||
|
(the inline move was exercised and cleanly reverted during this run; exact command in §0).
|
||||||
|
- **Left in place (durable canonical):** `openspec/specs/web-ui/spec.md`.
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
# Design — web-ui-rework
|
||||||
|
|
||||||
|
> Phase: **design**. Technical design (not implementation) for finishing the
|
||||||
|
> MUI v9 → shadcn/ui + Tailwind v4 + lucide-react migration and reconciling the
|
||||||
|
> Manage information architecture to a single, coherent, thin-dashboard UI.
|
||||||
|
>
|
||||||
|
> Grounded in `exploration.md` (verified inventory), `proposal.md`, and
|
||||||
|
> `spec.md` (locked decisions). No source changes in this phase.
|
||||||
|
|
||||||
|
## 0. Locked design constraints (do not re-litigate)
|
||||||
|
|
||||||
|
These are settled by the spec and bound every decision below:
|
||||||
|
|
||||||
|
- **Single design system:** shadcn/ui + Tailwind v4 `@theme` tokens + lucide-react only. Zero `@mui/*` / `@emotion/*` / `recharts` / `d3` at the end; `theme.ts` deleted in slice 1.
|
||||||
|
- **IA:** Backups becomes a top-level nav item; the Media/Applications surface is named **"Media"** at canonical route **`/media`**; **`/applications` → redirect (replace) to `/media`**, mirroring the existing `/monitoring` → `/observability` redirect in `App.tsx`. Auth (`auth.ts`, `react-oidc-context`) and `react-router-dom` structure otherwise unchanged.
|
||||||
|
- **TanStack Table:** **visibility-only** parity — pagination, row selection, row click, column visibility. **No sorting, no resizing.**
|
||||||
|
- **Palette:** primary stays `#4f8cff`; `chart-1..5` tokens **repurposed** as status/Grafana-link semantic cues (not dropped).
|
||||||
|
- **Density:** comfortable everywhere (`p-4 md:p-6`, `gap-4`); **no compact mode**.
|
||||||
|
- **Test harness:** Vitest + `@testing-library/react` introduced in slice 1.
|
||||||
|
- **Delivery:** force-chained PRs, ≤400 changed lines/slice; ~8 slices per exploration §9.
|
||||||
|
- **Observability model:** thin dashboard, **no in-app charting**, Grafana deep-links preserved.
|
||||||
|
|
||||||
|
## 1. MUI → shadcn/ui + Tailwind component mapping table
|
||||||
|
|
||||||
|
Authoritative destination for every MUI component present across the 22
|
||||||
|
`@mui/material` consumers (see exploration §2a for per-file frequency). The 22
|
||||||
|
files follow this table verbatim; deviation requires a design-note in the
|
||||||
|
slice's tasks file.
|
||||||
|
|
||||||
|
| MUI component | Destination | shadcn primitive / Tailwind pattern | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Typography` | semantic element + text utilities | `<h1>`–`<h6>`/`<p>`/`<span>` + `text-* font-*` per the §2 ramp | none needed |
|
||||||
|
| `Box` | `<div>` + utilities | `<div className="…">` with flex/grid utilities | none needed |
|
||||||
|
| `Stack` | `<div>` flex stack | `<div className="flex flex-col gap-4">` (or `flex-row gap-*`) | none needed |
|
||||||
|
| `Grid` | CSS grid `<div>` | `<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">` w/ responsive `md:`/`lg:` | none needed |
|
||||||
|
| `Paper` | bordered surface `<div>` or `Card` | `<div className="rounded-lg border bg-card">`; prefer `Card` where it already wraps a titled section | none needed |
|
||||||
|
| `Card` / `CardContent` | shadcn `Card` / `CardContent` | `@/components/ui/card` (Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter) | **present** |
|
||||||
|
| `Chip` | `Badge` | `@/components/ui/badge` (variants incl. new `success` — §2) | **present** |
|
||||||
|
| `Button` | `Button` | `@/components/ui/button` (`variant`, `size`) | **present** |
|
||||||
|
| `IconButton` | `Button size="icon"` | `<Button variant="ghost" size="icon">` (pattern already used in the shell) | **present** |
|
||||||
|
| `Tooltip` | `Tooltip` family | `@/components/ui/tooltip` (TooltipProvider/Tooltip/TooltipTrigger/TooltipContent) | **present** |
|
||||||
|
| `Alert` | `Alert` family | `@/components/ui/alert` (Alert, AlertTitle, AlertDescription) | **present** |
|
||||||
|
| `Table` / `TableHead` / `TableBody` / `TableRow` / `TableCell` / `TableContainer` | shadcn `Table` family | `@/components/ui/table` (Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption) | **add** |
|
||||||
|
| `Tabs` / `Tab` | shadcn `Tabs` | `@/components/ui/tabs` (Tabs, TabsList, TabsTrigger, TabsContent) | **add** |
|
||||||
|
| `Dialog` / `DialogTitle` / `DialogContent` / `DialogActions` | shadcn `Dialog` | `@/components/ui/dialog` (Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose) | **add** |
|
||||||
|
| `TextField` | `Input` (+ `Label`) | `@/components/ui/input`, `@/components/ui/label` | **add** |
|
||||||
|
| `Select` / `MenuItem` / `FormControl` / `InputLabel` | shadcn `Select` | `@/components/ui/select` (Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel) | **present** |
|
||||||
|
| `Switch` | `Switch` | `@/components/ui/switch` | **add** |
|
||||||
|
| `Checkbox` | `Checkbox` | `@/components/ui/checkbox` | **add** |
|
||||||
|
| `LinearProgress` | `Progress` | `@/components/ui/progress` (indeterminate via animated class) | **add** |
|
||||||
|
| `Divider` | `Separator` | `@/components/ui/separator` | **add** |
|
||||||
|
| `Avatar` | `Avatar` | `@/components/ui/avatar` (Avatar, AvatarImage, AvatarFallback) | **add** |
|
||||||
|
| `Drawer` | `Sheet side="right"` | `@/components/ui/sheet` (already used for mobile nav) | **present** |
|
||||||
|
| `FormControlLabel` | `Label` + control | `<Label>` wrapping the control; or sibling `<div className="flex items-center gap-2">` | **add** `Label` |
|
||||||
|
| `FormHelperText` | muted `<p>` | `<p className="text-xs text-muted-foreground">` | none needed |
|
||||||
|
| `DataGrid` (`@mui/x-data-grid`) | TanStack Table + `Table` | reusable `DataTable` wrapper (§3) on shadcn `Table` | **add** `@tanstack/react-table` |
|
||||||
|
|
||||||
|
**Shared Tailwind layout patterns (anti-drift reference for all 22 files):**
|
||||||
|
|
||||||
|
- **Vertical stack:** `<div className="flex flex-col gap-4">` (replaces MUI `<Stack direction="column" spacing={n}>`).
|
||||||
|
- **Horizontal row:** `<div className="flex flex-row items-center gap-2">`.
|
||||||
|
- **Responsive grid:** `<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">` (replaces MUI `<Grid>` + `<Grid item xs={…} md={…}>`).
|
||||||
|
- **Page section:** `SectionCard` (migrated in slice 2) wraps titled sections; consistent `gap-4` between cards.
|
||||||
|
- **Single surface treatment:** one border weight (`border-border`) and one radius (`rounded-lg` = `--radius` 0.625rem).
|
||||||
|
|
||||||
|
## 2. Token + theme design (`frontend/src/index.css`)
|
||||||
|
|
||||||
|
The existing Tailwind v4 `@theme` block is already a complete shadcn-style
|
||||||
|
system (verified against current source). The edits are surgical: keep the
|
||||||
|
palette, repurpose the chart tokens with documented roles, and add the `success`
|
||||||
|
Badge variant. `tailwind.config.cjs` stays minimal (content glob only) — no JS
|
||||||
|
theme config is introduced.
|
||||||
|
|
||||||
|
### 2.1 Palette — primary unchanged, `chart-1..5` repurposed
|
||||||
|
|
||||||
|
Primary remains `#4f8cff` (light + dark). The five `--color-chart-*` tokens are
|
||||||
|
retained at their current hues and documented in `index.css` as the **status /
|
||||||
|
Grafana-link semantic cue** source of truth. Single source of truth: Badge
|
||||||
|
variants and status code map to the `chart-N` tokens directly, **not** to
|
||||||
|
parallel `--color-success` aliases (which would drift).
|
||||||
|
|
||||||
|
| Token | Current value | Repurposed role | Consumed by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--color-chart-1` | `#4f8cff` | **info** — default/informational status; also the Grafana deep-link brand cue (matches primary) | `Badge variant="default"`; Grafana-link `ExternalLink` affordance |
|
||||||
|
| `--color-chart-2` | `#22c55e` | **success** — healthy/OK (target up, run succeeded, resolved) | `Badge variant="success"` (new, §2.3) |
|
||||||
|
| `--color-chart-3` | `#f59e0b` | **warning** — pending/stale/missed-schedule | `Badge variant="warning"` (optional, map at call site) |
|
||||||
|
| `--color-chart-4` | `#ef4444` | **destructive** — error / firing alert (already aligns with `--color-destructive`) | `Badge variant="destructive"`; Alertmanager firing |
|
||||||
|
| `--color-chart-5` | `#8b5cf6` | **neutral-accent** — unknown/paused/secondary, and Grafana Explore deep-link cue (distinct from brand/destructive) | `Badge variant="secondary"` accents; Explore links |
|
||||||
|
|
||||||
|
**Concrete `index.css` edit shape:** add an inline comment block above the
|
||||||
|
`chart-1..5` lines in both the `@theme` block and `.dark` block documenting the
|
||||||
|
role mapping above. No token values change; no token is removed. Example:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
/* …unchanged… */
|
||||||
|
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||||
|
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||||
|
chart-4=destructive, chart-5=neutral-accent. */
|
||||||
|
--color-chart-1: #4f8cff;
|
||||||
|
--color-chart-2: #22c55e;
|
||||||
|
--color-chart-3: #f59e0b;
|
||||||
|
--color-chart-4: #ef4444;
|
||||||
|
--color-chart-5: #8b5cf6;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Density + typography ramp (comfortable, no compact mode)
|
||||||
|
|
||||||
|
Codified as the standard every page follows (already matches the migrated
|
||||||
|
shell `<main className="p-4 md:p-6">` and `gap-4`):
|
||||||
|
|
||||||
|
- **Page padding:** `p-4 md:p-6` (the shell `<main>` already provides this; pages must not double-pad).
|
||||||
|
- **Card / section gap:** `gap-4` between cards; `space-y-4` for stacked sections.
|
||||||
|
- **Typography ramp:**
|
||||||
|
- Page title — `text-lg font-semibold` (rendered by `TopBar` from `navItems`).
|
||||||
|
- Section title — `text-base font-semibold`.
|
||||||
|
- Body — `text-sm`.
|
||||||
|
- Caption / helper — `text-xs text-muted-foreground`.
|
||||||
|
- **Radius:** one — `rounded-lg` (`--radius: 0.625rem`).
|
||||||
|
- **No compact/dense mode** anywhere: Media, Users, Backups runs all use the same comfortable row padding (`<TableCell className="py-3">`). No density toggle is introduced.
|
||||||
|
|
||||||
|
### 2.3 `success` Badge variant to add (`frontend/src/components/ui/badge.tsx`)
|
||||||
|
|
||||||
|
The current `badge.tsx` exposes variants `default | secondary | destructive |
|
||||||
|
outline | ghost | link` — there is **no success/healthy variant**. Add one,
|
||||||
|
mirroring the existing `destructive` soft-cue pattern and drawing color from the
|
||||||
|
repurposed `chart-2` token so the cue is the documented source of truth:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// inside badgeVariants variants: { …
|
||||||
|
success:
|
||||||
|
"bg-chart-2/10 text-chart-2 focus-visible:ring-chart-2/20 dark:bg-chart-2/20 dark:focus-visible:ring-chart-2/40 [a]:hover:bg-chart-2/20",
|
||||||
|
// optional warning (chart-3) may be added identically if a call site needs it
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tailwind v4 resolves `chart-2` from the `--color-chart-2` `@theme` token, so
|
||||||
|
`text-chart-2` / `bg-chart-2/10` / `ring-chart-2/20` are valid utilities.
|
||||||
|
|
||||||
|
**Status → variant mapping (consumed consistently by Backups, Observability, Users):**
|
||||||
|
|
||||||
|
| Domain status | Badge variant | Cue token |
|
||||||
|
|---|---|---|
|
||||||
|
| healthy / OK / succeeded / up | `success` | `chart-2` |
|
||||||
|
| informational / default | `default` | `chart-1` |
|
||||||
|
| neutral / secondary / unknown | `secondary` | `chart-5` accent |
|
||||||
|
| pending / stale / warning | `warning` (add if needed) or `secondary` | `chart-3` |
|
||||||
|
| error / firing alert / failed | `destructive` | `chart-4` |
|
||||||
|
|
||||||
|
## 3. TanStack Table design — reusable `DataTable` wrapper
|
||||||
|
|
||||||
|
Both DataGrid surfaces (`Media.tsx`, `FileBrowser.impl.tsx`) are rebuilt on a
|
||||||
|
single headless `@tanstack/react-table` instance styled with the shadcn `Table`
|
||||||
|
primitive, behind a thin reusable wrapper. **Feature scope is visibility-only**
|
||||||
|
(locked): pagination, row selection, row click, column visibility. Sorting and
|
||||||
|
resizing are explicit non-goals.
|
||||||
|
|
||||||
|
### 3.1 Component shape
|
||||||
|
|
||||||
|
`frontend/src/components/ui/data-table.tsx` — a generic wrapper built on
|
||||||
|
`@/components/ui/table`. Props shape (design, not full impl):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ColumnDef, OnChangeFn, RowSelectionState,
|
||||||
|
VisibilityState, PaginationState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
// stable identity (Media needs path-derived id so selection survives paging)
|
||||||
|
getRowId?: (row: TData, index: number) => string;
|
||||||
|
// visibility-only feature set
|
||||||
|
enableRowSelection?: boolean;
|
||||||
|
rowSelection?: RowSelectionState;
|
||||||
|
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||||
|
onRowClick?: (row: TData) => void; // Media→open files; FileBrowser→preview
|
||||||
|
columnVisibility?: VisibilityState;
|
||||||
|
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
|
||||||
|
enableColumnVisibilityToggle?: boolean; // renders the column dropdown
|
||||||
|
// pagination (Media only; FileBrowser does not paginate)
|
||||||
|
enablePagination?: boolean;
|
||||||
|
manualPagination?: boolean; // Media: server-driven via limit/offset
|
||||||
|
pagination?: PaginationState;
|
||||||
|
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
rowCount?: number; // server total for Media
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally, `DataTable` calls `useReactTable` wired as follows:
|
||||||
|
|
||||||
|
- **Core:** `getCoreRowModel: getCoreRowModel()`.
|
||||||
|
- **Pagination:** `getPaginationRowModel: getPaginationRowModel()` only when `enablePagination` and **not** `manualPagination`. When `manualPagination` is true (Media), the table renders the supplied slice and `rowCount` drives the pager; the page index/size are lifted to the parent and feed `useMediaQuery` limit/offset.
|
||||||
|
- **Selection:** `enableRowSelection: true` gated by the prop; `rowSelection` state is controlled by the parent.
|
||||||
|
- **Column visibility:** `columnVisibility` state controlled by the parent; a `DropdownMenu` + `Checkbox` list renders the toggle UI when `enableColumnVisibilityToggle`.
|
||||||
|
- **Row click:** `<TableRow onClick={() => onRowClick?.(row.original)}>` with `className="cursor-pointer"` when `onRowClick` is set; selection column click stops propagation so checkboxes do not trigger navigation.
|
||||||
|
- **Selection column:** a leading display `ColumnDef` rendering a `Checkbox` (header = select-all-on-page via `table.getIsSomeRowsSelected()/getIsSelected()`), present only when `enableRowSelection`.
|
||||||
|
|
||||||
|
### 3.2 Column-def typing pattern
|
||||||
|
|
||||||
|
Columns are declared as `ColumnDef<TData>[]` with `accessorKey`/`accessorFn`,
|
||||||
|
`header`, and `cell` returning a styled `<TableCell>` child. Static widths use
|
||||||
|
Tailwind classes on the cell content (e.g. `className="w-[120px]`) — **no**
|
||||||
|
TanStack `size`/`enableColumnResizing` (locked out). No column sets
|
||||||
|
`enableSorting`; the table instance never receives `getSortedRowModel`.
|
||||||
|
|
||||||
|
### 3.3 Explicit non-goals (enforced)
|
||||||
|
|
||||||
|
- **No sorting:** no `getSortedRowModel`, no sortable header affordance, no sort indicators. Media's existing external filter controls (search/type/library/HDR) remain the only filtering path.
|
||||||
|
- **No resizing:** `enableColumnResizing` unset; no resize handles rendered.
|
||||||
|
|
||||||
|
### 3.4 Consumer contracts
|
||||||
|
|
||||||
|
**`pages/Media.tsx`** — builds `mediaColumns: ColumnDef<MediaItem>[]` for the 15
|
||||||
|
pre-rework columns (`title, series, season, episode, type, year, runtime_min,
|
||||||
|
size, bitrate, hdr, video, resolution, date_added, library, path`) and renders
|
||||||
|
`<DataTable>` with: `enableRowSelection`, `enablePagination` + `manualPagination`
|
||||||
|
- `rowCount` (driven by `queryResult.total`), `onRowClick` →
|
||||||
|
`navigate('/files?path=…')`, and `enableColumnVisibilityToggle`. Pagination and
|
||||||
|
column-visibility state persist via the existing `usePersistentState` media
|
||||||
|
state, feeding `useMediaQuery({ limit, offset, … })`. The toggleable column set
|
||||||
|
matches the locked list in the spec scenario.
|
||||||
|
|
||||||
|
**`pages/FileBrowser.impl.tsx`** — builds `fileColumns: ColumnDef<FileEntry>[]`
|
||||||
|
for the 5 columns (`type, name, ext, size, modified`) and renders `<DataTable>`
|
||||||
|
with: `enableRowSelection`, `onRowClick` → selects the file for ffprobe preview,
|
||||||
|
and `enableColumnVisibilityToggle`. **No pagination** (the directory listing is
|
||||||
|
rendered in full, as today). Column-visibility state persists via the existing
|
||||||
|
browser state.
|
||||||
|
|
||||||
|
## 4. Navigation / IA design (`frontend/src/App.tsx`)
|
||||||
|
|
||||||
|
Three surgical edits, mirroring the established `/monitoring` → `/observability`
|
||||||
|
redirect pattern. Auth and routing structure are otherwise untouched.
|
||||||
|
|
||||||
|
### 4.1 `navItems` change
|
||||||
|
|
||||||
|
Current:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const navItems = [
|
||||||
|
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ path: "/observability", label: "Observability", icon: Activity },
|
||||||
|
{ path: "/applications", label: "Media", icon: Monitor },
|
||||||
|
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||||
|
{ path: "/users", label: "Users", icon: Users },
|
||||||
|
{ path: "/actions", label: "Actions", icon: Zap },
|
||||||
|
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Target — retarget Media to `/media`, **add a top-level Backups item**, and import
|
||||||
|
a lucide icon for it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { /* …existing…, */ DatabaseBackup } from "lucide-react";
|
||||||
|
|
||||||
|
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: "/settings", label: "Settings", icon: SettingsIcon },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Icon choice + fallback:** `DatabaseBackup` is the semantic fit; if the pinned
|
||||||
|
`lucide-react@^1.14.0` (see §8 risk) does not export it, fall back to `HardDrive`
|
||||||
|
or `Archive` (both are long-standing exports). Verify the export before the
|
||||||
|
Backups slice (slice 3). Backups is placed after Files (operational grouping) and
|
||||||
|
before Users.
|
||||||
|
|
||||||
|
### 4.2 Route changes
|
||||||
|
|
||||||
|
In **both** route blocks (the OIDC-configured branch and the unauthenticated
|
||||||
|
branch), make `/media` the canonical element and convert `/applications` to a
|
||||||
|
replace-redirect, exactly mirroring `/monitoring`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Route path="/media" element={<Applications />} />
|
||||||
|
<Route path="/applications" element={<Navigate to="/media" replace />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
`/backups` already renders `<BackupsPage />`; no route addition is needed (only
|
||||||
|
the nav item). The page component remains `Applications` (the `Applications.tsx`
|
||||||
|
file rename is **out of scope** — non-goal: routing structure unchanged); only
|
||||||
|
the nav label + canonical route change.
|
||||||
|
|
||||||
|
## 5. Icon migration — MUI → lucide-react
|
||||||
|
|
||||||
|
The 10 distinct `@mui/icons-material` icons map as follows (from exploration §2c):
|
||||||
|
|
||||||
|
| File | MUI icon | lucide-react |
|
||||||
|
|---|---|---|
|
||||||
|
| `components/HoverEditButton.tsx` | `EditOutlined` | `Pencil` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `Close` | `X` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `AttachFile` | `Paperclip` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `FormatBold` | `Bold` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `FormatItalic` | `Italic` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `Link` | `Link` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `FormatListBulleted` | `List` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `MailOutlined` | `Mail` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `Send` | `Send` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `DeleteOutlined` | `Trash2` |
|
||||||
|
|
||||||
|
**Version-pin verification (required gate):** `package.json` pins
|
||||||
|
`lucide-react: ^1.14.0`, an unusual major. All 10 names above (plus
|
||||||
|
`DatabaseBackup`, `ExternalLink` already in use) are standard lucide exports,
|
||||||
|
but the exact pin must be verified to export them **before the UsersPage slice
|
||||||
|
(slice 6)** and the Backups icon (slice 3). Verification step: in a scratch
|
||||||
|
build, import each name from the pinned version; if any is missing, pick the
|
||||||
|
nearest lucide equivalent (e.g. `DatabaseBackup` → `HardDrive`) or bump the pin
|
||||||
|
within `^1.x` as a slice-1 foundation task. The icon set for the already-migrated
|
||||||
|
`ObservabilityPage` (12 icons) and shell is the proof that common names exist at
|
||||||
|
this pin.
|
||||||
|
|
||||||
|
## 6. Vitest setup design (slice 1)
|
||||||
|
|
||||||
|
A minimal component-test harness is introduced in the foundation slice. It must
|
||||||
|
coexist with — not replace — the existing `node --test` suites in
|
||||||
|
`frontend/tests/*.mjs`.
|
||||||
|
|
||||||
|
### 6.1 Config + scripts
|
||||||
|
|
||||||
|
- **Config file:** `frontend/vitest.config.ts` (separate from `vite.config.ts` to
|
||||||
|
keep the build config clean), using `defineConfig` from `vitest/config`,
|
||||||
|
`@vitejs/plugin-react`, `test.environment: "jsdom"`, `test.globals: true`,
|
||||||
|
`test.setupFiles: ["./src/test/setup.ts"]`, the `@` path alias from
|
||||||
|
`tsconfig.app.json`, and `test.include: ["src/**/*.{test,spec}.{ts,tsx}"]` so
|
||||||
|
Vitest does **not** claim the `frontend/tests/*.mjs` node suites.
|
||||||
|
- **Setup file:** `frontend/src/test/setup.ts` importing
|
||||||
|
`@testing-library/jest-dom` (matcher registration).
|
||||||
|
- **npm scripts (added to `package.json`):**
|
||||||
|
- `"test": "vitest run"` — single-run CI gate.
|
||||||
|
- `"test:watch": "vitest"` — watch mode for local dev.
|
||||||
|
- `"test:node": "node --test tests"` — keeps the existing node suites runnable explicitly (they also still run directly).
|
||||||
|
|
||||||
|
### 6.2 Dev dependencies added in slice 1
|
||||||
|
|
||||||
|
`vitest`, `@testing-library/react`, `@testing-library/jest-dom`,
|
||||||
|
`@testing-library/user-event`, `jsdom`. (Runtime deps untouched here.)
|
||||||
|
|
||||||
|
### 6.3 Example test shape (for a migrated component)
|
||||||
|
|
||||||
|
Co-located component test for a slice-2 shared block, asserting migrated
|
||||||
|
behavior — representative pattern every migrated component follows:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// frontend/src/components/__tests__/MetricCard.test.tsx
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MetricCard } from "../MetricCard";
|
||||||
|
|
||||||
|
describe("MetricCard", () => {
|
||||||
|
it("renders the label and value", () => {
|
||||||
|
render(<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />);
|
||||||
|
expect(screen.getByText("Movies")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Status-Badge mapping is a natural component-test target (e.g. assert a healthy
|
||||||
|
target renders `variant="success"`), satisfying the spec's "migrated components
|
||||||
|
have component tests" scenario.
|
||||||
|
|
||||||
|
## 7. Slice contracts (~8 slices; full table in exploration §9)
|
||||||
|
|
||||||
|
Each slice is independently reviewable and leaves `npm run build` + `npm run lint`
|
||||||
|
green. One-paragraph contracts follow; the authoritative file list is exploration §9.
|
||||||
|
|
||||||
|
1. **Foundation.** Add the §5 shadcn primitives (`tabs table dialog input label
|
||||||
|
checkbox switch progress separator avatar textarea dropdown-menu scroll-area`),
|
||||||
|
install `@tanstack/react-table`, remove `recharts` + `d3` from `package.json`,
|
||||||
|
delete `frontend/src/theme.ts` (and its callers), and introduce the §6 Vitest
|
||||||
|
harness + a trivial passing test. Add the `success` Badge variant + `chart-*`
|
||||||
|
role comments (§2). **Exit:** no `theme`/`getAppTheme` imports remain; build +
|
||||||
|
lint + `vitest run` green; no behavior change yet.
|
||||||
|
|
||||||
|
2. **Shared components.** Migrate the reusable building blocks to lock the shared
|
||||||
|
language before pages: `SectionCard`, `SelectionRailCard`, `TabbedCard`,
|
||||||
|
`MetricCard`, `DiskSpaceCard`, `HoverEditButton`, `DialogFooter`,
|
||||||
|
`ConfirmDialog`, `LibraryOverview`, `NowPlaying`/`SessionActivityPanel`. Each
|
||||||
|
uses `Card`/`Badge`/`Button`/`Table`/`Tabs`/`Dialog` per §1. **Exit:** all
|
||||||
|
shared components MUI-free; one component test per migrated block; downstream
|
||||||
|
pages still compile against the same exported APIs.
|
||||||
|
|
||||||
|
3. **Backups cluster + nav.** Migrate `BackupAlertsTable`, `BackupJobsTable`,
|
||||||
|
`BackupRunsTable`, `BackupsPage`, `BackupDashboardWidget` (all `Table` family
|
||||||
|
- `Badge` status cues), and apply the §4 `navItems` + route edits to surface
|
||||||
|
`/backups` as a top-level item (verify `DatabaseBackup`/fallback icon).
|
||||||
|
**Exit:** `/backups` reachable from the sidebar; `/applications` redirects to
|
||||||
|
`/media`; build + lint green.
|
||||||
|
|
||||||
|
4. **Dashboard + Media/Applications surface.** Migrate `Dashboard.tsx` (20 MUI
|
||||||
|
components → `Card`/`Grid`→CSS-grid/`Dialog`/`Select`/`Switch`/`TextField`→`Input`)
|
||||||
|
and `Applications.tsx` (9 → `Card`/grid/`Tabs`). **Exit:** both pages MUI-free
|
||||||
|
and visually consistent; build + lint green.
|
||||||
|
|
||||||
|
5. **Settings + Actions.** Migrate `Settings.tsx` (18) and `Actions.tsx` (19) —
|
||||||
|
the form-heavy pair (`TextField`→`Input`, `Select`, `Switch`, `Checkbox`,
|
||||||
|
`Tabs`, `Dialog`). Keep uncontrolled/`useState` form parity (no form library).
|
||||||
|
**Exit:** both pages MUI-free; forms behave as before; build + lint green.
|
||||||
|
|
||||||
|
6. **Users (largest).** Migrate `UsersPage.impl.tsx` (25 MUI components + 9 icons
|
||||||
|
- `Drawer`→`Sheet` + `Table` + rich-text compose). **High sub-split likelihood**
|
||||||
|
(§8): likely split into (a) directory table + selection + drawer, (b) compose
|
||||||
|
dialog + formatting actions + attachments, to stay ≤400 lines. Apply the §5
|
||||||
|
icon map (verify the 9 names at the pin). **Exit:** page MUI-free; drawer,
|
||||||
|
selection-across-pages, and compose/send behavior preserved; build + lint green.
|
||||||
|
|
||||||
|
7. **DataGrid → TanStack Table (highest risk, last).** Build the §3 `DataTable`
|
||||||
|
wrapper on shadcn `Table`, then rebuild `Media.tsx` and `FileBrowser.impl.tsx`
|
||||||
|
against it. Preserve pagination (Media, server-driven) / row selection / row
|
||||||
|
click / column visibility exactly (locked scenario columns). No sorting, no
|
||||||
|
resizing. **Exit:** both grids behaviorally at parity; `@mui/x-data-grid` no
|
||||||
|
longer imported; build + lint green.
|
||||||
|
|
||||||
|
8. **Cleanup + docs.** Remove `@mui/*` + `@emotion/*` from `package.json`, run a
|
||||||
|
final `tsc -b` + `eslint` + `vitest run` + existing `node --test`, and update
|
||||||
|
`docs/REQUIREMENTS.md` (single design system, thin-dashboard observability,
|
||||||
|
TanStack tables, `/media` + `/applications` redirect, Backups nav, removed
|
||||||
|
deps). **Exit:** recursive search of `frontend/src` for `@mui/*`/`@emotion/*`
|
||||||
|
returns zero; requirements doc updated; all gates green.
|
||||||
|
|
||||||
|
## 8. Review / judgment risks
|
||||||
|
|
||||||
|
1. **DataGrid slice sequenced last (high).** Slice 7 carries the single largest
|
||||||
|
behavior-parity surface (Media pagination is **server-driven** via
|
||||||
|
`useMediaQuery` limit/offset, and selection must survive paging via a stable
|
||||||
|
`getRowId`). It is deliberately last so `Table`, tokens, and Badge cues are
|
||||||
|
already settled; but manual smoke of Media pagination + row-click navigation
|
||||||
|
and FileBrowser row-click preview is mandatory at slice exit. Mitigation:
|
||||||
|
component-test the `DataTable` wrapper (selection toggle, column visibility
|
||||||
|
toggle, row-click callback) in slice 7 before re-wiring the pages.
|
||||||
|
|
||||||
|
2. **UsersPage sub-split likelihood (high).** `UsersPage.impl.tsx` is the largest
|
||||||
|
consumer (25 components + 9 icons + `Drawer` + rich-text compose). Expect to
|
||||||
|
split slice 6 into two PRs (table/drawer vs. compose). Judgment call at apply
|
||||||
|
time: split before exceeding 400 lines rather than after.
|
||||||
|
|
||||||
|
3. **Cross-slice drift mitigation (medium).** 22 files over ~8 slices risks
|
||||||
|
inconsistent layout primitives. Mitigated by (a) shared-components-first
|
||||||
|
(slice 2 before any page), (b) the §1 anti-drift Tailwind patterns, and (c)
|
||||||
|
the single `chart-*` color source of truth. Reviewers enforce §1 patterns as
|
||||||
|
the lint-supplement gate.
|
||||||
|
|
||||||
|
4. **lucide-react version risk (low–medium).** `^1.14.0` is an unusual major.
|
||||||
|
The 10 mapped icons (plus `DatabaseBackup` for Backups) must be verified at
|
||||||
|
the pin before the consuming slices (3 and 6). The already-migrated
|
||||||
|
`ObservabilityPage` (12 icons) proves common names resolve; verification is a
|
||||||
|
slice-1 foundation task with a fallback list.
|
||||||
|
|
||||||
|
5. **Test-coverage gaps (medium).** Today there is no component harness; legacy
|
||||||
|
`node --test` suites cover only pure-JS transforms. The new Vitest harness
|
||||||
|
mitigates this, but coverage is only as good as what each slice writes — make
|
||||||
|
"at least one behavioral component test per migrated component" a hard slice
|
||||||
|
gate (spec scenario), especially for the status-Badge mapping and the
|
||||||
|
`DataTable` features.
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Exploration — web-ui-rework
|
||||||
|
|
||||||
|
> Phase: **explore**. Evidence-grounded mapping of the rework surface. No code changes.
|
||||||
|
> Recovered by the orchestrator after the `sdd-explore` subagent hit an MCP-bridge heap OOM;
|
||||||
|
> all figures below were re-derived directly from source with multiline-aware parsing and verified with `grep`.
|
||||||
|
|
||||||
|
## 1. Scope and non-goals
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- Finish the **MUI v9 → shadcn/ui + Tailwind CSS + lucide-react** migration for every remaining `@mui/*` consumer in `frontend/src`.
|
||||||
|
- Deliver a **visual/UX redesign** on top of the migrated primitives, consistent with the already-migrated style references: `frontend/src/App.tsx` (shell + sidebar) and `frontend/src/components/ObservabilityPage.tsx`.
|
||||||
|
- Align the UI to the **new observability model**: Manage is a *thin dashboard*; charts/metrics/logs live in external, decoupled Grafana. In-app surfaces show Alertmanager alerts + Prometheus target health + Grafana deep-links only.
|
||||||
|
- Remove orphaned charting deps (`recharts`, `d3`) and the no-op `theme.ts` shim.
|
||||||
|
- Add the missing shadcn primitives and `@tanstack/react-table` needed for the migration.
|
||||||
|
- Update `docs/REQUIREMENTS.md` to reflect the UX/architecture change (per `AGENTS.md`).
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **No in-app charting.** Do not re-introduce recharts/d3 or build custom charts; metrics visualisation stays in Grafana.
|
||||||
|
- **No new design system.** Use shadcn/ui + the existing Tailwind v4 `@theme` token system; do not adopt a different component library.
|
||||||
|
- **No backend API changes** as part of this rework. The data contracts in `frontend/src/types/*` are unchanged unless a UI simplification forces one, which must then be flagged separately.
|
||||||
|
- **No MUI retention.** `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, `@emotion/styled` are all removed by the final slice.
|
||||||
|
- **No auth/routing model change.** OIDC flow (`auth.ts`, `react-oidc-context`) and `react-router-dom` structure stay as-is; only nav *items*/IA may shift.
|
||||||
|
|
||||||
|
## 2. Extended component inventory (verified from source)
|
||||||
|
|
||||||
|
Parsing note: MUI imports are multiline (`import {\n Box,\n Card,\n} from "@mui/material"`); figures below use a brace-bounded multiline parser and were cross-checked with `grep -rlE '@mui/(material|icons-material|x-data-grid)' src`.
|
||||||
|
|
||||||
|
### 2a. `@mui/material` — 22 files, 37 distinct components
|
||||||
|
|
||||||
|
| File | # | Components |
|
||||||
|
|---|---|---|
|
||||||
|
| `pages/UsersPage.impl.tsx` | 25 | Alert, Avatar, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, Drawer, IconButton, LinearProgress, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Tooltip, Typography |
|
||||||
|
| `pages/Dashboard.tsx` | 20 | Alert, Box, Button, Card, CardContent, Chip, Dialog, DialogContent, DialogTitle, FormControl, FormControlLabel, FormHelperText, Grid, InputLabel, MenuItem, Select, Stack, Switch, TextField, Typography |
|
||||||
|
| `pages/Actions.tsx` | 19 | Alert, Box, Button, Card, CardContent, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, InputLabel, MenuItem, Select, Stack, Tab, Tabs, TextField, Typography |
|
||||||
|
| `pages/Settings.tsx` | 18 | Alert, Box, Button, Card, CardContent, Checkbox, Chip, Dialog, DialogContent, DialogTitle, FormControlLabel, Grid, MenuItem, Stack, Switch, Tab, TextField, Typography |
|
||||||
|
| `pages/FileBrowser.impl.tsx` | 15 | Alert, Box, Button, Card, CardContent, Chip, FormControl, Grid, InputLabel, MenuItem, Select, Stack, Tab, TextField, Typography |
|
||||||
|
| `pages/Media.tsx` | 14 | Alert, Box, Button, Card, CardContent, FormControl, Grid, InputLabel, LinearProgress, MenuItem, Select, Stack, TextField, Typography |
|
||||||
|
| `components/BackupRunsTable.tsx` | 12 | Chip, FormControl, InputLabel, MenuItem, Paper, Select, Table, TableBody, TableCell, TableContainer, TableHead, TableRow |
|
||||||
|
| `components/SessionActivityPanel.tsx` | 10 | Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography |
|
||||||
|
| `components/BackupAlertsTable.tsx` | 9 | Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow |
|
||||||
|
| `pages/Applications.tsx` | 9 | Alert, Box, Card, CardContent, Chip, Grid, Stack, Tab, Typography |
|
||||||
|
| `components/BackupJobsTable.tsx` | 8 | Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow |
|
||||||
|
| `components/DiskSpaceCard.tsx` | 7 | Box, Card, CardContent, Grid, LinearProgress, Stack, Typography |
|
||||||
|
| `components/BackupDashboardWidget.tsx` | 5 | Box, Card, CardContent, Chip, Typography |
|
||||||
|
| `components/ConfirmDialog.tsx` | 5 | Dialog, DialogContent, DialogTitle, Stack, Typography |
|
||||||
|
| `components/LibraryOverview.tsx` | 5 | Card, CardContent, Grid, Stack, Typography |
|
||||||
|
| `components/SectionCard.tsx` | 5 | Box, Card, CardContent, Stack, Typography |
|
||||||
|
| `components/BackupsPage.tsx` | 4 | Box, Tab, Tabs, Typography |
|
||||||
|
| `components/SelectionRailCard.tsx` | 4 | Box, Card, CardContent, Typography |
|
||||||
|
| `components/TabbedCard.tsx` | 4 | Box, Card, CardContent, Tabs |
|
||||||
|
| `components/DialogFooter.tsx` | 3 | Box, Button, DialogActions |
|
||||||
|
| `components/MetricCard.tsx` | 3 | Card, CardContent, Typography |
|
||||||
|
| `components/HoverEditButton.tsx` | 1 | IconButton |
|
||||||
|
|
||||||
|
**Component frequency (migration sizing):** Typography 16 · Box 14 · Card/CardContent 13 · Chip 11 · Stack 11 · Button 9 · Grid 7 · Alert 7 · MenuItem 6 · TextField 6 · Paper/Table*/FormControl/InputLabel/Select/Tab 5 · Dialog/DialogContent/DialogTitle 5 · Tabs 3 · LinearProgress 3 · DialogActions/IconButton/Divider/FormControlLabel/Switch/Checkbox 2 · FormHelperText/Avatar/Drawer/Tooltip 1.
|
||||||
|
|
||||||
|
### 2b. `@mui/x-data-grid` — 2 files (DataGrid)
|
||||||
|
|
||||||
|
- `pages/Media.tsx` — `DataGrid`, `GridColDef`
|
||||||
|
- `pages/FileBrowser.impl.tsx` — `DataGrid`, `GridColDef`, `GridRowSelectionModel`
|
||||||
|
|
||||||
|
### 2c. `@mui/icons-material` — 2 files, 10 distinct icons (default-import-per-icon style)
|
||||||
|
|
||||||
|
| File | Icons → lucide-react equivalent |
|
||||||
|
|---|---|
|
||||||
|
| `components/HoverEditButton.tsx` | `EditOutlined` → `Pencil` |
|
||||||
|
| `pages/UsersPage.impl.tsx` | `Close`→`X` · `AttachFile`→`Paperclip` · `FormatBold`→`Bold` · `FormatItalic`→`Italic` · `Link`→`Link` · `FormatListBulleted`→`List` · `MailOutlined`→`Mail` · `Send`→`Send` · `DeleteOutlined`→`Trash2` |
|
||||||
|
|
||||||
|
> **lucide caveat:** `package.json` pins `lucide-react: ^1.14.0`, an unusual major. The named icons above (`Pencil`, `X`, `Paperclip`, `Bold`, `Italic`, `Link`, `List`, `Mail`, `Send`, `Trash2`) are standard lucide exports, but the version pin must be verified to export them before the UsersPage slice.
|
||||||
|
|
||||||
|
### 2d. Orphaned / unused deps (verified zero imports)
|
||||||
|
|
||||||
|
- `recharts: ^3.8.1` — **no `from "recharts"` anywhere.** Removable.
|
||||||
|
- `d3: ^7.9.0` — **no `from "d3"` / `from "d3-*"` anywhere.** Removable.
|
||||||
|
- `frontend/src/theme.ts` — no-op shim (`getAppTheme` returns `{}`); safe to delete once no caller remains.
|
||||||
|
|
||||||
|
### 2e. Already-migrated style references (the design targets)
|
||||||
|
|
||||||
|
- `frontend/src/App.tsx` — shell: fixed sidebar (`w-16`/`w-60` collapse), `TopBar` (sticky, backdrop-blur, version chips), `MobileDrawer` (shadcn `Sheet`), `useDarkMode` toggles `dark` class on `<html>`.
|
||||||
|
- `frontend/src/components/ObservabilityPage.tsx` (575 lines) — uses shadcn `Card/Badge/Alert/Button/Select/Skeleton/Collapsible` + 12 lucide icons; Grafana deep-links via kiosk URLs (`/d/node-exporter-overview/...?kiosk&var-instance=…`) and Explore.
|
||||||
|
|
||||||
|
## 3. MUI → shadcn/ui + Tailwind mapping table
|
||||||
|
|
||||||
|
| MUI | shadcn/ui + Tailwind | Primitive status |
|
||||||
|
|---|---|---|
|
||||||
|
| `Card` / `CardContent` | `Card` / `CardContent` (`@/components/ui/card`) | **present** |
|
||||||
|
| `Typography` | semantic `<h1>`–`<h6>`/`<p>`/`<span>` + Tailwind `text-* font-*` | none needed |
|
||||||
|
| `Box` | `<div>` + flex/grid utilities | none needed |
|
||||||
|
| `Stack` | `<div className="flex flex-col gap-*">` (or `flex-row`) | none needed |
|
||||||
|
| `Grid` | CSS grid `grid grid-cols-* gap-*` w/ responsive `md:`/`lg:` | none needed |
|
||||||
|
| `Chip` | `Badge` (`@/components/ui/badge`) | **present** |
|
||||||
|
| `Button` | `Button` (`@/components/ui/button`) | **present** |
|
||||||
|
| `IconButton` | `Button variant="ghost" size="icon"` (pattern already used in shell) | **present** |
|
||||||
|
| `Tooltip` | `Tooltip` (`@/components/ui/tooltip`) | **present** |
|
||||||
|
| `Alert` | `Alert`/`AlertTitle`/`AlertDescription` | **present** |
|
||||||
|
| `Select`/`MenuItem`/`FormControl`/`InputLabel` | `Select` family (`@/components/ui/select`) | **present** |
|
||||||
|
| `Paper` | `<div className="rounded-lg border bg-card">` or `Card` | none needed |
|
||||||
|
| `Table`/`TableHead`/`TableBody`/`TableRow`/`TableCell`/`TableContainer` | shadcn `Table` family | **add** |
|
||||||
|
| `Tabs`/`Tab` | shadcn `Tabs` (`TabsList`/`TabsTrigger`/`TabsContent`) | **add** |
|
||||||
|
| `Dialog`/`DialogTitle`/`DialogContent`/`DialogActions` | shadcn `Dialog` (+ `DialogFooter`, `DialogHeader`) | **add** |
|
||||||
|
| `TextField` | `Input` (+ `Label`) | **add** |
|
||||||
|
| `Checkbox` | `Checkbox` | **add** |
|
||||||
|
| `Switch` | `Switch` | **add** |
|
||||||
|
| `LinearProgress` | `Progress` | **add** |
|
||||||
|
| `Divider` | `Separator` | **add** |
|
||||||
|
| `Avatar` | `Avatar` | **add** |
|
||||||
|
| `Drawer` | `Sheet` (side="right") — already used for mobile nav | **present** |
|
||||||
|
| `FormControlLabel` / `FormHelperText` | `Label` + `<p className="text-xs text-muted-foreground">` | **add** `Label` |
|
||||||
|
| `DataGrid` (`@mui/x-data-grid`) | TanStack Table + shadcn `Table` (see §4) | **add** `@tanstack/react-table` |
|
||||||
|
|
||||||
|
## 4. DataGrid decision — TanStack Table (KEY TECHNICAL RISK)
|
||||||
|
|
||||||
|
**Recommendation:** migrate both DataGrid usages to **`@tanstack/react-table`** (headless) styled with the shadcn `Table` primitive. Rationale: headless (no opinionated styling, fits Tailwind), first-class sorting/filtering/pagination/selection/column-visibility, actively maintained, and the project already uses `@tanstack/react-query` so the dependency family is consistent.
|
||||||
|
|
||||||
|
**Feature parity that MUST be preserved** (derived from source):
|
||||||
|
|
||||||
|
| Page | Columns (`field`) | Features in use |
|
||||||
|
|---|---|---|
|
||||||
|
| `pages/Media.tsx` | title, series, season, episode, type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added, library, path | pagination, row selection, row click (opens file browser), column visibility management, stable row id |
|
||||||
|
| `pages/FileBrowser.impl.tsx` | type, name, ext, size, modified | row selection, row click (selects file for ffprobe), column visibility management |
|
||||||
|
|
||||||
|
**Open parity questions for the proposal phase:**
|
||||||
|
|
||||||
|
- Does the redesign keep client-side column *resizing*, or only *visibility*? (Source scan shows visibility; resizing usage is ambiguous and must be confirmed against the live DataGrid props.)
|
||||||
|
- Sorting/filtering: Media has external controls already (its own filter UI); decide whether TanStack sorting is added or deferred to keep slice size ≤400 lines.
|
||||||
|
|
||||||
|
This is the single highest-risk slice; it is sequenced **last** so the shared Table primitive and design tokens are already settled.
|
||||||
|
|
||||||
|
## 5. shadcn primitives gap list
|
||||||
|
|
||||||
|
Add via `npx shadcn@latest add <name>` (project already initialised — `components.json`, `@/components/ui`, `cn()` in `src/lib/utils.ts`):
|
||||||
|
|
||||||
|
```
|
||||||
|
tabs table dialog input label checkbox switch progress separator avatar textarea dropdown-menu scroll-area
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus dependency: `npm i @tanstack/react-table`.
|
||||||
|
|
||||||
|
Already present (do not re-add): `button tooltip sheet card badge alert select skeleton collapsible`.
|
||||||
|
|
||||||
|
## 6. Design tokens, theming, and redesign direction
|
||||||
|
|
||||||
|
**Current tokens (`frontend/src/index.css`, Tailwind v4 `@theme`)** — already a complete shadcn-style system:
|
||||||
|
|
||||||
|
- Font: Inter (300–700 via Google Fonts).
|
||||||
|
- Light: background `#fafafa`, foreground `#0f172a`, card `#ffffff`, primary `#4f8cff`, border `#e2e8f0`, muted-foreground `#64748b`.
|
||||||
|
- `.dark`: background `#0f172a`, card `#1e293b`, border `#334155`, muted-foreground `#94a3b8`.
|
||||||
|
- Radius `0.625rem`. Chart palette `chart-1..5` defined but **unused** (no charting) — keep for Grafana-link color cues, or drop.
|
||||||
|
- Dark mode: `useDarkMode()` in `App.tsx` toggles `.dark` on `<html>`; `theme.ts` is a no-op shim.
|
||||||
|
|
||||||
|
**Config:** `frontend/tailwind.config.cjs` is minimal (`content` glob only) — correct for Tailwind v4 which reads tokens from `@theme` in CSS. No JS theme config to migrate.
|
||||||
|
|
||||||
|
**Redesign direction (consistent with shell + ObservabilityPage):**
|
||||||
|
|
||||||
|
- **Density:** comfortable, not cramped. Page padding `p-4 md:p-6` (matches `<main>` in `App.tsx`). Card gap `gap-4`. Section spacing via `SectionCard` replacement.
|
||||||
|
- **Typography ramp:** headings `text-lg font-semibold` (page title, per `TopBar`), section titles `text-base font-semibold`, body `text-sm`, captions `text-xs text-muted-foreground`.
|
||||||
|
- **Color semantics:** status uses Badge variants — `default` (info), `secondary` (neutral), `destructive` (error/firing alert), and a success variant to add for healthy/OK (currently only ObservabilityPage has health semantics). Keep primary blue `#4f8cff`.
|
||||||
|
- **Surfaces:** prefer `Card` over ad-hoc `Paper`; one border weight (`border-border`), one radius (`rounded-lg`).
|
||||||
|
- **Layout primitive:** replace MUI `Grid`/`Stack` with a small set of Tailwind patterns documented in the design doc so all 22 files stay consistent across slices.
|
||||||
|
|
||||||
|
## 7. Information architecture & navigation
|
||||||
|
|
||||||
|
**Current nav (`App.tsx` `navItems`):** Dashboard (`/`), Observability (`/observability`), Media (`/applications`), Files (`/files`), Users (`/users`), Actions (`/actions`), Settings (`/settings`).
|
||||||
|
|
||||||
|
**Routing facts:**
|
||||||
|
|
||||||
|
- `/monitoring` → `<Navigate to="/observability" replace />` (legacy redirect).
|
||||||
|
- `/media` → `Applications` (alias).
|
||||||
|
- `/backups` route **exists** (`BackupsPage`) but is **not in the sidebar** — reachable only by URL or via the Dashboard widget's deep-link.
|
||||||
|
|
||||||
|
**IA recommendations for the proposal:**
|
||||||
|
|
||||||
|
- Surface **Backups** in the nav (currently hidden) — either its own item or folded into a "Media ops" group.
|
||||||
|
- Reconcile the **Media ↔ Applications** naming (nav says "Media", route is `/applications`, page component is `Applications`); pick one name.
|
||||||
|
- Keep Observability as a first-class top-level item (it is the post-decoupling home for status + Grafana links).
|
||||||
|
|
||||||
|
## 8. Observability-decoupling implications for the redesign
|
||||||
|
|
||||||
|
- **Stays in-app:** Alertmanager alerts list, Prometheus target health table, monitoring-machine status, Grafana deep-link cards (kiosk iframe URLs + Explore). All already implemented in `ObservabilityPage.tsx`.
|
||||||
|
- **Becomes / stays a Grafana deep-link:** any metric chart, time-series, log stream. The `chart-1..5` tokens and orphaned `recharts`/`d3` deps are leftovers from the removed in-app Monitoring UI — removing them codifies the decoupling.
|
||||||
|
- **Design consequence:** no chart components need to be designed; "metric" surfaces become either a number (MetricCard) or an outbound Grafana link. The redesign should make the **external-link affordance** consistent (lucide `ExternalLink`, already used in `ObservabilityPage`).
|
||||||
|
|
||||||
|
## 9. Slice plan preview (Force-chained, ≤400 changed lines/PR)
|
||||||
|
|
||||||
|
Sequenced so each slice leaves `npm run build` + `npm run lint` green and is independently reviewable:
|
||||||
|
|
||||||
|
1. **Foundation** — add shadcn primitives (§5), add `@tanstack/react-table`, remove `recharts`+`d3` from `package.json`, delete `theme.ts`. Enables all later slices.
|
||||||
|
2. **Shared components** — migrate the reusable building blocks first to prevent drift: `SectionCard`, `SelectionRailCard`, `TabbedCard`, `MetricCard`, `DiskSpaceCard`, `HoverEditButton`, `DialogFooter`, `ConfirmDialog`, `LibraryOverview`, `NowPlaying`/`SessionActivityPanel`.
|
||||||
|
3. **Backups cluster** — `BackupAlertsTable`, `BackupJobsTable`, `BackupRunsTable`, `BackupsPage`, `BackupDashboardWidget` (+ surface `/backups` in nav).
|
||||||
|
4. **Dashboard + Applications** — `Dashboard.tsx`, `Applications.tsx`.
|
||||||
|
5. **Settings + Actions** — `Settings.tsx`, `Actions.tsx`.
|
||||||
|
6. **Users** — `UsersPage.impl.tsx` (largest MUI consumer: 25 components + 9 icons + Drawer + Table + rich-text compose). May sub-split.
|
||||||
|
7. **DataGrid → TanStack Table** — `Media.tsx` + `FileBrowser.impl.tsx` (highest risk; sequenced last).
|
||||||
|
8. **Cleanup + docs** — remove `@mui/*` + `@emotion/*` deps, final typecheck/lint pass, update `docs/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
~8 slices; slices 6 and 7 are the most likely to need sub-splitting at apply time to stay under 400 lines.
|
||||||
|
|
||||||
|
## 10. Risks & open questions for the proposal
|
||||||
|
|
||||||
|
1. **DataGrid parity (high)** — TanStack Table must reproduce pagination/selection/row-click/column-visibility; resizing/sorting scope undecided (§4).
|
||||||
|
2. **UsersPage size (high)** — single largest migration; rich-text compose UI with formatting actions, attachments, drawer, table. Needs its own careful slice (possibly split).
|
||||||
|
3. **Cross-slice visual drift (medium)** — 22 files migrated over 8 slices; mitigated by doing shared components (slice 2) and documenting the Tailwind layout patterns in the design doc first.
|
||||||
|
4. **lucide-react v1 pin (low–medium)** — unusual major (`^1.14.0`); verify all 10 mapped icon names exist before the UsersPage slice.
|
||||||
|
5. **Form patterns (low)** — current forms are uncontrolled/`useState`; keep parity, no form library introduction (non-goal).
|
||||||
|
6. **IA decisions (product)** — Backups nav placement, Media/Applications naming (§7).
|
||||||
|
7. **Testing coverage (medium)** — see §11; no component test harness exists today.
|
||||||
|
|
||||||
|
## 11. Testing strategy note
|
||||||
|
|
||||||
|
- `frontend/package.json` has **no `test` script**. Capability today:
|
||||||
|
- `npm run build` → `tsc -b && vite build` (typecheck + build).
|
||||||
|
- `npm run lint` → `eslint .`.
|
||||||
|
- `node --test` on `frontend/tests/*.mjs` (`users.test.mjs`, `userState.test.mjs`) — pure-JS transform tests, run directly, not wired into npm.
|
||||||
|
- `openspec/config.yaml` declares **no strict-TDD flag**.
|
||||||
|
- **Implication:** "green" for each slice = `tsc -b` clean + `eslint` clean + existing `node --test` suites pass + manual smoke of the affected page. The proposal should decide whether to introduce a minimal component test harness (e.g. Vitest + Testing Library) as slice 1 work, or proceed on static + smoke evidence only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** explore complete. **Recommended next phase:** `proposal` (with a product-question round before locking, given redesign + IA ambiguity).
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Proposal — web-ui-rework
|
||||||
|
|
||||||
|
> Phase: **proposal**. Grounded in `openspec/changes/web-ui-rework/exploration.md` (verified inventory). No code changes.
|
||||||
|
> Product/UX questions for the user are collected in **§8 — Proposal question round** and must be answered before this proposal is treated as locked.
|
||||||
|
|
||||||
|
## 1. Problem / motivation
|
||||||
|
|
||||||
|
The Manage frontend is **mid-migration**: the app shell (`App.tsx`) and the new `ObservabilityPage.tsx` already live on shadcn/ui + Tailwind + lucide-react, but **22 files still import `@mui/material`, 2 use `@mui/x-data-grid`, and 2 use `@mui/icons-material`** (10 distinct icons). The result is a split-feeling product: two design languages, two theming systems (MUI `@emotion` vs the Tailwind v4 `@theme` tokens in `index.css`), and a no-op `theme.ts` shim left behind. Orphaned charting deps (`recharts`, `d3`) linger in `package.json` even though **no in-app charting exists**.
|
||||||
|
|
||||||
|
This half-finished state collides with a second driver: Manage has moved to a **thin-dashboard observability model** where charts/metrics/logs live in external Grafana, and in-app surfaces show only Alertmanager alerts + Prometheus target health + Grafana deep-links. Finishing the migration is therefore not cosmetic — it is how we *codify* the decoupling (remove the charting deps, retire the last MUI surface, make the external-link affordance consistent) and land a coherent visual redesign on one design system.
|
||||||
|
|
||||||
|
## 2. Target outcome / product vision
|
||||||
|
|
||||||
|
A single, coherent Manage UI on **shadcn/ui + Tailwind v4 + lucide-react**, visually consistent with the already-migrated shell and ObservabilityPage:
|
||||||
|
|
||||||
|
- Every page uses the same tokens (primary `#4f8cff`, Inter, `rounded-lg`, one border weight) and the same layout primitives — `Card` over ad-hoc `Paper`, Tailwind flex/grid over MUI `Grid`/`Stack`.
|
||||||
|
- Observability stays first-class and **thin**: status as numbers/Badges plus outbound Grafana kiosk deep-links; **no in-app charts**.
|
||||||
|
- DataGrid surfaces (Media, FileBrowser) become TanStack Table on the shadcn `Table` primitive, preserving pagination / row-selection / row-click / column-visibility.
|
||||||
|
- A reconciled information architecture (Backups surfaced in nav; one name for Media/Applications).
|
||||||
|
- `@mui/*`, `@emotion/*`, `recharts`, `d3`, and the `theme.ts` shim are gone, and `docs/REQUIREMENTS.md` reflects the new UX.
|
||||||
|
|
||||||
|
## 3. Scope (in)
|
||||||
|
|
||||||
|
- Finish MUI v9 → shadcn/ui + Tailwind + lucide-react across all remaining consumers in `frontend/src` (22 material files + 2 DataGrid files + 2 icon files).
|
||||||
|
- Add the missing shadcn primitives (`tabs table dialog input label checkbox switch progress separator avatar textarea dropdown-menu scroll-area`) and `@tanstack/react-table`.
|
||||||
|
- Migrate both `@mui/x-data-grid` DataGrid usages to TanStack Table + shadcn `Table`.
|
||||||
|
- Deliver a visual/UX redesign aligned to the thin-dashboard model (density, typography ramp, status Badge semantics including a success variant, consistent external-link affordance).
|
||||||
|
- Remove orphaned `recharts`, `d3`, the `theme.ts` shim, and all `@mui/*` + `@emotion/*` deps.
|
||||||
|
- Reconcile IA: surface Backups in nav; resolve Media vs Applications naming.
|
||||||
|
- Update `docs/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
## 4. Non-goals
|
||||||
|
|
||||||
|
- **No in-app charting** — no recharts/d3 re-introduction, no custom charts; metrics visualisation stays in Grafana.
|
||||||
|
- **No new design system** — shadcn/ui + the existing Tailwind v4 `@theme` tokens only.
|
||||||
|
- **No backend API changes** — frontend `types/*` contracts stay unless a UI simplification forces one, which must be flagged separately.
|
||||||
|
- **No MUI retention** — all `@mui/*` and `@emotion/*` removed by the final slice.
|
||||||
|
- **No auth/routing model change** — OIDC (`auth.ts`, `react-oidc-context`) and `react-router-dom` structure unchanged; only nav *items*/IA may shift.
|
||||||
|
- **No form-library introduction** — keep current uncontrolled/`useState` form parity.
|
||||||
|
|
||||||
|
## 5. Key technical risks
|
||||||
|
|
||||||
|
- **DataGrid → TanStack Table (high).** The two DataGrid usages (`Media.tsx`, `FileBrowser.impl.tsx`) must reproduce pagination, row-selection, row-click, and column-visibility. Whether to also carry over client-side **resizing/sorting** is an open product question (exploration §4) and is the single highest-risk slice — sequenced **last** so the shared `Table` primitive and tokens are already settled.
|
||||||
|
- **UsersPage size (high).** `pages/UsersPage.impl.tsx` is the largest consumer (25 MUI components + 9 icons + `Drawer` + `Table` + a rich-text compose UI with formatting actions and attachments). Likely needs a sub-split to stay under the 400-line slice budget.
|
||||||
|
- **Cross-slice visual drift (medium).** 22 files over ~8 slices; mitigated by migrating shared building blocks first (exploration §9 slice 2) and documenting the Tailwind layout patterns up front.
|
||||||
|
- **lucide-react v1 pin (low–medium).** `^1.14.0` is an unusual major; the 10 mapped icon names must be verified to exist before the UsersPage slice.
|
||||||
|
- **No component test harness today (medium).** See exploration §11; whether to add Vitest in slice 1 is an open question.
|
||||||
|
|
||||||
|
## 6. High-level slice strategy
|
||||||
|
|
||||||
|
Chained PRs, ≤400 changed lines each, each leaving `npm run build` + `npm run lint` green. Full table and sequencing rationale live in exploration.md **§9**; summary:
|
||||||
|
|
||||||
|
1. **Foundation** — shadcn primitives + `@tanstack/react-table`, remove `recharts`/`d3`, delete `theme.ts`.
|
||||||
|
2. **Shared components** — `SectionCard`, `SelectionRailCard`, `TabbedCard`, `MetricCard`, `DiskSpaceCard`, `HoverEditButton`, `DialogFooter`, `ConfirmDialog`, `LibraryOverview`, `NowPlaying`/`SessionActivityPanel`.
|
||||||
|
3. **Backups cluster** (+ surface `/backups` in nav).
|
||||||
|
4. **Dashboard + Applications**.
|
||||||
|
5. **Settings + Actions**.
|
||||||
|
6. **Users** (largest; may sub-split).
|
||||||
|
7. **DataGrid → TanStack Table** — Media + FileBrowser (highest risk; last).
|
||||||
|
8. **Cleanup + docs** — remove `@mui/*` + `@emotion/*`, final typecheck/lint, update `docs/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
Slices 6 and 7 are the most likely to need sub-splitting to stay ≤400 lines.
|
||||||
|
|
||||||
|
## 7. Success criteria
|
||||||
|
|
||||||
|
- `npm run build` (tsc -b + vite build) and `npm run lint` green; existing `node --test` suites pass.
|
||||||
|
- **Zero `@mui/*` or `@emotion/*` imports remain** in `frontend/src` (verified by grep); `recharts`, `d3`, and `theme.ts` removed.
|
||||||
|
- Both DataGrid surfaces rebuilt on TanStack Table with pagination / row-selection / row-click / column-visibility parity.
|
||||||
|
- **ObservabilityPage parity preserved** (Alertmanager alerts + Prometheus target health + Grafana deep-links); no in-app charts introduced.
|
||||||
|
- Visual consistency with the migrated shell + ObservabilityPage; Backups surfaced in nav; Media/Applications naming resolved.
|
||||||
|
- `docs/REQUIREMENTS.md` updated to reflect the UX/architecture change.
|
||||||
|
|
||||||
|
## 8. Proposal question round
|
||||||
|
|
||||||
|
The following product/UX questions need user answers before this proposal is treated as locked. They are intended to uncover business rules, IA intent, and scope boundaries — not delivery mechanics. *(Parent: ask these verbatim.)*
|
||||||
|
|
||||||
|
**Q1 — Information architecture (parent-ask):** Today the sidebar lists Dashboard, Observability, Media, Files, Users, Actions, Settings — but "Media" routes to `/applications` (component `Applications`), and a working **Backups** page (`/backups`) is hidden from the nav. Do you want (a) **Backups as its own top-level nav item**, (b) Backups grouped under a "Media ops" section, or (c) left as URL-only? And should the Media/Applications surface be named **"Media"** or **"Applications"** going forward?
|
||||||
|
|
||||||
|
**Q2 — Visual density (parent-ask):** The migrated shell uses **comfortable** spacing (`p-4 md:p-6`, `gap-4`). Should the redesign keep comfortable density across all 22 pages, or do some dense tables (Media, Users, Backups runs) need a **compact** mode to fit more rows on screen?
|
||||||
|
|
||||||
|
**Q3 — TanStack Table feature parity (parent-ask):** The current DataGrids use column **visibility**; **resizing** and **sorting** usage is ambiguous. For the rebuilt tables, do you want (a) **visibility-only** parity (smallest scope), (b) add **sorting**, or (c) add **sorting + column resizing** (closest to a full DataGrid)?
|
||||||
|
|
||||||
|
**Q4 — Test harness (parent-ask):** There is **no component test harness** today (only `tsc -b`, `eslint`, and `node --test` on pure-JS transforms). Should slice 1 introduce a **minimal Vitest + Testing Library** harness for the migrated components, or proceed on **static + manual smoke** evidence per slice?
|
||||||
|
|
||||||
|
**Q5 — Palette (parent-ask):** The current primary is **`#4f8cff`** with `chart-1..5` tokens defined but unused. Do you want to (a) **keep `#4f8cff`** and repurpose the chart tokens as Grafana-link color cues, (b) **shift the primary** to a new accent (please specify), or (c) keep the palette as-is and **drop** the unused chart tokens entirely?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Recommended next phase:** `spec` — after the §8 questions are answered, refine scope/token/IA decisions and lock the slice contracts.
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# Web UI Specification
|
||||||
|
|
||||||
|
> Change: `web-ui-rework` · Domain: `web-ui` · Phase: **spec**
|
||||||
|
> Grounded in `openspec/changes/web-ui-rework/proposal.md` and `exploration.md` (verified inventory).
|
||||||
|
> Locked product decisions encoded as requirements: IA (Backups top-level; surface named "Media" at `/media` with `/applications` → redirect), TanStack visibility-only table parity, palette (`#4f8cff` kept + `chart-1..5` repurposed), comfortable density, Vitest harness in slice 1.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define WHAT must be true of the Manage web frontend after the rework: a single coherent UI on **shadcn/ui + Tailwind v4 + lucide-react**, a **thin-dashboard** observability model (no in-app charts; Grafana deep-links preserved), **TanStack Table** data grids with **visibility-only** parity, a **reconciled information architecture**, and **zero** MUI/`@emotion`/`recharts`/`d3`/`theme.ts` residue — delivered as a chain of build-and-lint-green slices. This spec is acceptance-focused and verifiable; it deliberately does not prescribe implementation.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Single design system
|
||||||
|
|
||||||
|
The frontend MUST render all surfaces using only **shadcn/ui** primitives, **Tailwind v4** `@theme` tokens, and **lucide-react** icons. The application MUST NOT introduce any additional component or styling library. The application MUST NOT retain any `@mui/*` or `@emotion/*` import.
|
||||||
|
|
||||||
|
#### Scenario: No MUI imports remain anywhere in source
|
||||||
|
|
||||||
|
- GIVEN the `web-ui-rework` change is fully applied
|
||||||
|
- WHEN a recursive search of `frontend/src` is performed for imports from `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, or `@emotion/styled`
|
||||||
|
- THEN the search returns zero matches
|
||||||
|
- AND `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, and `@emotion/styled` are absent from `frontend/package.json`
|
||||||
|
|
||||||
|
#### Scenario: No new design system adopted
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/package.json` dependencies are inspected
|
||||||
|
- THEN no component library other than the existing shadcn/ui + Tailwind + lucide-react stack is present
|
||||||
|
|
||||||
|
### Requirement: Orphaned charting dependencies removed and no in-app charting
|
||||||
|
|
||||||
|
The frontend MUST remove the unused `recharts` and `d3` dependencies from `frontend/package.json`. The frontend MUST NOT introduce any in-app chart, time-series, or log-stream visualization component. The no-op `frontend/src/theme.ts` shim MUST be deleted. Metric surfaces MUST be expressed as numbers or outbound Grafana links only.
|
||||||
|
|
||||||
|
#### Scenario: Charting dependencies are gone
|
||||||
|
|
||||||
|
- GIVEN the final slice is applied
|
||||||
|
- WHEN `frontend/package.json` is inspected
|
||||||
|
- THEN neither `recharts` nor `d3` is listed as a dependency
|
||||||
|
- AND a recursive search of `frontend/src` for imports from `recharts`, `d3`, or `d3-*` returns zero matches
|
||||||
|
|
||||||
|
#### Scenario: No chart component is introduced
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the migrated pages and components are inspected
|
||||||
|
- THEN no component renders an in-app chart, sparkline, or graph canvas
|
||||||
|
- AND observability metric surfaces render only as numeric values, status Badges, or outbound Grafana deep-link affordances
|
||||||
|
|
||||||
|
#### Scenario: Theme shim deleted
|
||||||
|
|
||||||
|
- GIVEN the foundation slice is applied
|
||||||
|
- WHEN the `frontend/src/theme.ts` file is checked for existence
|
||||||
|
- THEN it does not exist
|
||||||
|
- AND no import of `theme`/`getAppTheme` remains in `frontend/src`
|
||||||
|
|
||||||
|
### Requirement: Palette — primary color kept and chart tokens repurposed
|
||||||
|
|
||||||
|
The primary color MUST remain `#4f8cff`. The existing `chart-1..5` CSS tokens in `frontend/src/index.css` MUST be **repurposed** as Grafana-link and status color cues (success / info / warning / destructive / neutral) and MUST NOT be dropped from the token system. Status Badges MUST convey meaning through these semantic color cues.
|
||||||
|
|
||||||
|
#### Scenario: Primary color unchanged
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/src/index.css` is inspected
|
||||||
|
- THEN the primary color token resolves to `#4f8cff` in both light and dark themes
|
||||||
|
|
||||||
|
#### Scenario: Chart tokens retained and repurposed
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/src/index.css` is inspected
|
||||||
|
- THEN the `chart-1` through `chart-5` tokens are still defined
|
||||||
|
- AND they are documented/applied as Grafana-link and status semantic cues (success / info / warning / destructive / neutral)
|
||||||
|
- AND status Badges draw their variant colors from these cues
|
||||||
|
|
||||||
|
### Requirement: Comfortable visual density across all surfaces
|
||||||
|
|
||||||
|
The redesign MUST use comfortable density on every surface: page padding `p-4 md:p-6` (matching the migrated shell `<main>`) and card spacing `gap-4`. The application MUST NOT introduce a compact or dense mode for any table, list, or panel.
|
||||||
|
|
||||||
|
#### Scenario: Comfortable density on every page
|
||||||
|
|
||||||
|
- GIVEN each migrated page is rendered
|
||||||
|
- WHEN the page content container is inspected
|
||||||
|
- THEN it uses comfortable padding consistent with the shell (`p-4 md:p-6`) and card gaps (`gap-4`)
|
||||||
|
|
||||||
|
#### Scenario: No compact mode
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the frontend is inspected for a density/compact toggle or compact spacing classes on tables
|
||||||
|
- THEN no compact or dense mode exists for Media, Users, Backups runs, or any other surface
|
||||||
|
|
||||||
|
### Requirement: Status Badge semantics
|
||||||
|
|
||||||
|
The Badge component MUST provide variants that cover success (healthy/OK), info (default), neutral (secondary), warning, and destructive (error / firing alert). Status displays across Backups, Observability, and Users MUST map status values to these variants consistently.
|
||||||
|
|
||||||
|
#### Scenario: Healthy status uses a success cue
|
||||||
|
|
||||||
|
- GIVEN a monitoring target or backup run is in a healthy/OK state
|
||||||
|
- WHEN its status is rendered
|
||||||
|
- THEN it displays a Badge using the success color cue (mapped from the repurposed `chart-*` tokens)
|
||||||
|
|
||||||
|
#### Scenario: Firing alert uses a destructive cue
|
||||||
|
|
||||||
|
- GIVEN an Alertmanager alert is firing
|
||||||
|
- WHEN its status is rendered
|
||||||
|
- THEN it displays a destructive Badge
|
||||||
|
|
||||||
|
### Requirement: Information architecture — Backups as a top-level navigation item
|
||||||
|
|
||||||
|
The **Backups** surface MUST be exposed as a top-level navigation item in the sidebar, linking to the existing `/backups` route.
|
||||||
|
|
||||||
|
#### Scenario: Backups is reachable from the sidebar
|
||||||
|
|
||||||
|
- GIVEN the application shell is rendered
|
||||||
|
- WHEN the sidebar navigation items are inspected
|
||||||
|
- THEN a top-level "Backups" item is present
|
||||||
|
- AND selecting it navigates to `/backups` and renders the Backups page
|
||||||
|
|
||||||
|
### Requirement: Information architecture — Media surface naming and route reconciliation
|
||||||
|
|
||||||
|
The Media/Applications surface MUST be named **"Media"** going forward, and its canonical route MUST be `/media`. The legacy `/applications` route MUST redirect (replace) to `/media`, mirroring the existing `/monitoring` → `/observability` redirect pattern. This change MUST NOT alter the OIDC authentication model or the `react-router-dom` routing structure beyond the renamed nav item, the `/media` route, and the `/applications` redirect.
|
||||||
|
|
||||||
|
#### Scenario: Media route is the canonical entry
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the sidebar "Media" item is selected
|
||||||
|
- THEN the browser navigates to `/media`
|
||||||
|
- AND the Media/Applications surface is rendered under the name "Media"
|
||||||
|
|
||||||
|
#### Scenario: Legacy /applications redirects to /media
|
||||||
|
|
||||||
|
- GIVEN the application is running
|
||||||
|
- WHEN a user navigates directly to `/applications`
|
||||||
|
- THEN the client router issues a replace redirect to `/media`
|
||||||
|
- AND the Media surface is rendered (same pattern as `/monitoring` → `/observability`)
|
||||||
|
|
||||||
|
#### Scenario: Auth and routing model unchanged
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the authentication and routing setup is inspected
|
||||||
|
- THEN the OIDC flow (`auth.ts`, `react-oidc-context`) and the `react-router-dom` structure are unchanged
|
||||||
|
- AND only nav items, the `/media` route, and the `/applications` redirect differ from the prior state
|
||||||
|
|
||||||
|
### Requirement: TanStack Table — visibility-only parity (no sorting, no resizing)
|
||||||
|
|
||||||
|
The Media and FileBrowser data grids MUST be rebuilt on `@tanstack/react-table` styled with the shadcn `Table` primitive, reproducing **only** the features currently in use: **column visibility management**, **pagination** (where present), **row selection**, and **row click**. This is **visibility only** parity — the smallest DataGrid scope — and the rebuilt tables MUST NOT add column sorting, and MUST NOT add column resizing.
|
||||||
|
|
||||||
|
#### Scenario: No sorting added
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media and FileBrowser tables are rendered
|
||||||
|
- WHEN the table headers and column definitions are inspected
|
||||||
|
- THEN no sortable-column behavior is present
|
||||||
|
- AND no sort affordance is rendered
|
||||||
|
|
||||||
|
#### Scenario: No column resizing added
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media and FileBrowser tables are rendered
|
||||||
|
- WHEN the column edges are inspected
|
||||||
|
- THEN no column-resize handles or resizing behavior are present
|
||||||
|
|
||||||
|
### Requirement: TanStack Table — column visibility management preserved
|
||||||
|
|
||||||
|
Both rebuilt tables MUST preserve the existing column-visibility management behavior, allowing the same set of columns to be shown or hidden as before the rework.
|
||||||
|
|
||||||
|
#### Scenario: Column visibility works on Media
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media table is rendered
|
||||||
|
- WHEN a user toggles the visibility of a column
|
||||||
|
- THEN that column is shown or hidden accordingly
|
||||||
|
- AND the set of toggleable columns matches the pre-rework DataGrid columns (title, series, season, episode, type, year, runtime, size, bitrate, hdr, video, resolution, date_added, library, path)
|
||||||
|
|
||||||
|
#### Scenario: Column visibility works on FileBrowser
|
||||||
|
|
||||||
|
- GIVEN the rebuilt FileBrowser table is rendered
|
||||||
|
- WHEN a user toggles the visibility of a column
|
||||||
|
- THEN that column is shown or hidden accordingly
|
||||||
|
- AND the set of toggleable columns matches the pre-rework columns (type, name, ext, size, modified)
|
||||||
|
|
||||||
|
### Requirement: TanStack Table — pagination preserved where present
|
||||||
|
|
||||||
|
The Media table MUST preserve its pagination behavior (page-size selection, total-count display, and page navigation) as it existed in the pre-rework DataGrid.
|
||||||
|
|
||||||
|
#### Scenario: Media pagination parity
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media table is rendered with more rows than one page
|
||||||
|
- WHEN a user changes the page size and navigates between pages
|
||||||
|
- THEN the visible rows, total count, and page index update consistently with the pre-rework behavior
|
||||||
|
|
||||||
|
### Requirement: TanStack Table — row selection preserved
|
||||||
|
|
||||||
|
The rebuilt tables MUST preserve their existing row-selection behavior (checkbox selection model) where it existed pre-rework.
|
||||||
|
|
||||||
|
#### Scenario: Row selection works on Media
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media table is rendered
|
||||||
|
- WHEN a user selects and deselects rows via the selection control
|
||||||
|
- THEN the selection state is maintained across pagination and matches the pre-rework row-selection model
|
||||||
|
|
||||||
|
#### Scenario: Row selection works on FileBrowser
|
||||||
|
|
||||||
|
- GIVEN the rebuilt FileBrowser table is rendered
|
||||||
|
- WHEN a user selects rows via the selection control
|
||||||
|
- THEN the selection state is maintained and matches the pre-rework model
|
||||||
|
|
||||||
|
### Requirement: TanStack Table — row click behavior preserved
|
||||||
|
|
||||||
|
The rebuilt tables MUST preserve their existing row-click behavior. On Media, clicking a row MUST open the file browser at the item's path. On FileBrowser, clicking a row MUST select that file for ffprobe preview, matching the pre-rework behavior.
|
||||||
|
|
||||||
|
#### Scenario: Media row click opens file browser
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media table is rendered
|
||||||
|
- WHEN a user clicks a media row
|
||||||
|
- THEN navigation occurs to the file browser targeted at the clicked item's path, as before the rework
|
||||||
|
|
||||||
|
#### Scenario: FileBrowser row click selects file for preview
|
||||||
|
|
||||||
|
- GIVEN the rebuilt FileBrowser table is rendered
|
||||||
|
- WHEN a user clicks a file row
|
||||||
|
- THEN that file is selected for ffprobe preview, as before the rework
|
||||||
|
|
||||||
|
### Requirement: Observability parity preserved (thin dashboard)
|
||||||
|
|
||||||
|
The Observability surface MUST continue to render Alertmanager alerts, Prometheus target health, monitoring-machine status, and Grafana deep-link cards (kiosk iframe URLs and Explore links). The rework MUST NOT introduce any in-app chart, and MUST preserve all existing Grafana deep-link affordances.
|
||||||
|
|
||||||
|
#### Scenario: Alerts and target health still shown
|
||||||
|
|
||||||
|
- GIVEN the Observability page is rendered
|
||||||
|
- WHEN Alertmanager and Prometheus data are available
|
||||||
|
- THEN Alertmanager alerts and Prometheus target health are displayed as before the rework
|
||||||
|
|
||||||
|
#### Scenario: Grafana deep-links preserved
|
||||||
|
|
||||||
|
- GIVEN the Observability page is rendered
|
||||||
|
- WHEN a Grafana deep-link card is inspected
|
||||||
|
- THEN the outbound kiosk/explore URL with instance variables is preserved
|
||||||
|
- AND an external-link affordance is present and consistent across the surface
|
||||||
|
|
||||||
|
#### Scenario: No in-app chart on observability
|
||||||
|
|
||||||
|
- GIVEN the Observability page is rendered
|
||||||
|
- WHEN metric surfaces are inspected
|
||||||
|
- THEN no in-app chart is rendered; metrics are numbers, status Badges, or Grafana deep-links only
|
||||||
|
|
||||||
|
### Requirement: Component test harness introduced and maintained
|
||||||
|
|
||||||
|
Slice 1 MUST introduce a **Vitest** + **@testing-library/react** component test harness. Every component migrated by this rework MUST have real component tests asserting the migrated behavior. The existing `node --test` suites in `frontend/tests` (e.g. `users.test.mjs`, `userState.test.mjs`) MUST continue to run and pass.
|
||||||
|
|
||||||
|
#### Scenario: Vitest harness is present
|
||||||
|
|
||||||
|
- GIVEN the foundation slice is applied
|
||||||
|
- WHEN `frontend/package.json` and config are inspected
|
||||||
|
- THEN Vitest and `@testing-library/react` are installed
|
||||||
|
- AND a Vitest test script is configured
|
||||||
|
|
||||||
|
#### Scenario: Migrated components have component tests
|
||||||
|
|
||||||
|
- GIVEN a slice migrates a component
|
||||||
|
- WHEN that slice is applied
|
||||||
|
- THEN at least one component test exercising the migrated behavior exists and passes
|
||||||
|
|
||||||
|
#### Scenario: Legacy node:test suites keep passing
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the `frontend/tests/*.mjs` suites are executed with `node --test`
|
||||||
|
- THEN all pre-existing assertions still pass
|
||||||
|
|
||||||
|
### Requirement: Per-slice build and lint green gate
|
||||||
|
|
||||||
|
Every slice in the delivery chain MUST leave `npm run build` (which runs `tsc -b` then `vite build`) and `npm run lint` (eslint) green as a hard, non-negotiable gate. No slice may be merged with a failing build or lint.
|
||||||
|
|
||||||
|
#### Scenario: A slice leaves the build green
|
||||||
|
|
||||||
|
- GIVEN any slice in the chain is applied in isolation
|
||||||
|
- WHEN `npm run build` is executed in `frontend/`
|
||||||
|
- THEN the command exits successfully with no TypeScript or Vite errors
|
||||||
|
|
||||||
|
#### Scenario: A slice leaves lint green
|
||||||
|
|
||||||
|
- GIVEN any slice in the chain is applied in isolation
|
||||||
|
- WHEN `npm run lint` is executed in `frontend/`
|
||||||
|
- THEN the command exits successfully with no eslint errors
|
||||||
|
|
||||||
|
### Requirement: Eight-slice delivery strategy (per-slice scope and gate)
|
||||||
|
|
||||||
|
The rework MUST be delivered as the chained eight-slice strategy at the requirement level, with each slice independently reviewable and leaving build + lint green: (1) **Foundation** — add the missing shadcn primitives and `@tanstack/react-table`, remove `recharts`/`d3`, delete `theme.ts`, introduce the Vitest harness; (2) **Shared components** — migrate the reusable building blocks (`SectionCard`, `SelectionRailCard`, `TabbedCard`, `MetricCard`, `DiskSpaceCard`, `HoverEditButton`, `DialogFooter`, `ConfirmDialog`, `LibraryOverview`, `NowPlaying`/`SessionActivityPanel`); (3) **Backups cluster** (and surface `/backups` in nav); (4) **Dashboard + Media/Applications surface**; (5) **Settings + Actions**; (6) **Users** (largest consumer; may sub-split); (7) **DataGrid → TanStack Table** (Media + FileBrowser; highest risk, sequenced last); (8) **Cleanup + docs** — remove `@mui/*` + `@emotion/*`, final typecheck/lint pass, update `docs/REQUIREMENTS.md`.
|
||||||
|
|
||||||
|
#### Scenario: Foundation slice enables later slices
|
||||||
|
|
||||||
|
- GIVEN slice 1 is applied
|
||||||
|
- WHEN the shared primitives, `@tanstack/react-table`, Vitest harness are present and `recharts`/`d3`/`theme.ts` are removed
|
||||||
|
- THEN `npm run build` and `npm run lint` are green and the foundation is in place for subsequent slices
|
||||||
|
|
||||||
|
#### Scenario: Backups slice surfaces nav
|
||||||
|
|
||||||
|
- GIVEN slice 3 is applied
|
||||||
|
- WHEN the Backups cluster is migrated
|
||||||
|
- THEN the `/backups` route is reachable as a top-level nav item and the build + lint are green
|
||||||
|
|
||||||
|
#### Scenario: Final cleanup slice removes all MUI
|
||||||
|
|
||||||
|
- GIVEN slice 8 is applied
|
||||||
|
- WHEN a recursive search for `@mui/*` and `@emotion/*` imports is performed in `frontend/src`
|
||||||
|
- THEN zero matches remain
|
||||||
|
- AND the cleanup slice leaves build + lint green
|
||||||
|
|
||||||
|
### Requirement: Documentation updated to reflect the new UX and architecture
|
||||||
|
|
||||||
|
`docs/REQUIREMENTS.md` MUST be updated to reflect the post-rework UX and architecture (single design system, thin-dashboard observability, TanStack tables, reconciled IA, removed dependencies).
|
||||||
|
|
||||||
|
#### Scenario: Requirements doc reflects the rework
|
||||||
|
|
||||||
|
- GIVEN the final slice is applied
|
||||||
|
- WHEN `docs/REQUIREMENTS.md` is inspected
|
||||||
|
- THEN it documents the shadcn/ui + Tailwind + lucide-react stack, the thin-dashboard observability model (no in-app charts), the `/media` route with `/applications` redirect, the Backups top-level nav item, and the removal of MUI/`@emotion`/`recharts`/`d3`
|
||||||
|
|
||||||
|
### Requirement: No backend API or frontend data-contract changes
|
||||||
|
|
||||||
|
The rework MUST NOT change any backend API contract or any type in `frontend/src/types/*`. If a UI simplification forces a contract change, that change MUST be flagged separately and approved outside this spec.
|
||||||
|
|
||||||
|
#### Scenario: Frontend data contracts unchanged
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/src/types/*` and the backend API surface are compared to the pre-rework state
|
||||||
|
- THEN no backend endpoint, response shape, or shared frontend type is altered by this change (unless a separate, flagged change is approved)
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Web UI — Delta (`web-ui-rework`)
|
||||||
|
|
||||||
|
> Change: `web-ui-rework` · Domain: `web-ui` · Phase: **spec** (reconciled during `sdd-sync`).
|
||||||
|
> Distilled from the verified flat `spec.md` + `design.md` of change `web-ui-rework`. Captures the
|
||||||
|
> **durable, post-change end-state contracts** of the Manage web frontend, not the migration steps
|
||||||
|
> (the per-slice delivery strategy and per-slice build/lint gate are intentionally excluded as
|
||||||
|
> migration-process contracts; they remain documented in the change `spec.md` / `tasks.md`).
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
> The canonical `openspec/specs/web-ui/spec.md` did not exist before this change. All requirements
|
||||||
|
> below are therefore **ADDED** to a new `web-ui` domain; `sdd-sync` copies them verbatim into the
|
||||||
|
> canonical spec (native helper rule: when the canonical spec does not exist, the change spec
|
||||||
|
> becomes the new canonical spec).
|
||||||
|
|
||||||
|
### Requirement: Single design system
|
||||||
|
|
||||||
|
The frontend MUST render every surface using only **shadcn/ui** primitives, **Tailwind v4** `@theme`
|
||||||
|
tokens (configured in `frontend/src/index.css`), and **lucide-react** icons. The application MUST NOT
|
||||||
|
introduce any additional component or styling library, and MUST NOT retain any `@mui/*` or
|
||||||
|
`@emotion/*` import or dependency.
|
||||||
|
|
||||||
|
#### Scenario: No MUI or Emotion remains in source or dependencies
|
||||||
|
|
||||||
|
- GIVEN the `web-ui-rework` change is fully applied
|
||||||
|
- WHEN a recursive search of `frontend/src` is performed for imports from `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, or `@emotion/styled`
|
||||||
|
- THEN the search returns zero matches
|
||||||
|
- AND none of `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, or `@emotion/styled` is listed in `frontend/package.json`
|
||||||
|
|
||||||
|
#### Scenario: No additional design system adopted
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/package.json` dependencies are inspected
|
||||||
|
- THEN no component or styling library other than the existing shadcn/ui + Tailwind v4 + lucide-react + Radix primitives stack is present
|
||||||
|
|
||||||
|
### Requirement: No in-app charting and orphaned charting dependencies removed
|
||||||
|
|
||||||
|
The frontend MUST NOT render any in-app chart, sparkline, time-series canvas, or log-stream
|
||||||
|
visualization. The unused `recharts` and `d3` dependencies MUST be absent from
|
||||||
|
`frontend/package.json`, and the no-op `frontend/src/theme.ts` shim MUST be deleted. Observability
|
||||||
|
metric surfaces MUST be expressed only as numeric values, status Badges, or outbound Grafana
|
||||||
|
deep-link affordances.
|
||||||
|
|
||||||
|
#### Scenario: Charting dependencies are gone
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/package.json` is inspected
|
||||||
|
- THEN neither `recharts` nor `d3` is listed as a dependency
|
||||||
|
- AND a recursive search of `frontend/src` for imports from `recharts`, `d3`, or `d3-*` returns zero matches
|
||||||
|
|
||||||
|
#### Scenario: No chart component exists
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the migrated pages and components are inspected
|
||||||
|
- THEN no component renders an in-app chart, sparkline, or graph canvas
|
||||||
|
- AND metric surfaces render only as numeric values, status Badges, or outbound Grafana deep-links
|
||||||
|
|
||||||
|
#### Scenario: Theme shim removed
|
||||||
|
|
||||||
|
- GIVEN the foundation slice is applied
|
||||||
|
- WHEN `frontend/src/theme.ts` is checked for existence
|
||||||
|
- THEN it does not exist
|
||||||
|
- AND no import of `theme` or `getAppTheme` remains in `frontend/src`
|
||||||
|
|
||||||
|
### Requirement: Design tokens — primary color and repurposed status cues
|
||||||
|
|
||||||
|
The primary color MUST resolve to `#4f8cff` in both the light and dark themes. The existing
|
||||||
|
`chart-1` through `chart-5` CSS tokens in `frontend/src/index.css` MUST be retained and documented
|
||||||
|
as the single source of truth for **status / Grafana-link semantic color cues** (info / success /
|
||||||
|
warning / destructive / neutral-accent), and MUST NOT be dropped. The Badge component MUST expose
|
||||||
|
`success` and `warning` variants that draw color from these cues.
|
||||||
|
|
||||||
|
#### Scenario: Primary color unchanged
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/src/index.css` is inspected
|
||||||
|
- THEN the primary color token resolves to `#4f8cff` in both the `@theme` (light) and `.dark` blocks
|
||||||
|
|
||||||
|
#### Scenario: Chart tokens retained and semantically repurposed
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/src/index.css` is inspected
|
||||||
|
- THEN the `chart-1` through `chart-5` tokens are still defined in both blocks
|
||||||
|
- AND they are documented/applied as status / Grafana-link semantic cues (info, success, warning, destructive, neutral-accent)
|
||||||
|
- AND status Badges draw their variant colors from these cues
|
||||||
|
|
||||||
|
### Requirement: Comfortable visual density with no compact mode
|
||||||
|
|
||||||
|
Every surface MUST use comfortable density: page padding `p-4 md:p-6` (matching the migrated shell
|
||||||
|
`<main>`) and card spacing `gap-4`. The application MUST NOT introduce a compact or dense mode for
|
||||||
|
any table, list, or panel.
|
||||||
|
|
||||||
|
#### Scenario: Comfortable density on every page
|
||||||
|
|
||||||
|
- GIVEN each migrated page is rendered
|
||||||
|
- WHEN the page content container is inspected
|
||||||
|
- THEN it uses comfortable padding consistent with the shell (`p-4 md:p-6`) and card gaps (`gap-4`)
|
||||||
|
|
||||||
|
#### Scenario: No compact mode exists
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the frontend is inspected for a density/compact toggle or compact spacing classes on tables
|
||||||
|
- THEN no compact or dense mode exists for Media, Users, Backups runs, or any other surface
|
||||||
|
|
||||||
|
### Requirement: Status Badge semantic variants
|
||||||
|
|
||||||
|
The Badge component MUST provide variants covering success (healthy/OK), info (default), neutral
|
||||||
|
(secondary), warning, and destructive (error / firing alert). Status displays across Backups,
|
||||||
|
Observability, and Users MUST map status values to these variants consistently via the repurposed
|
||||||
|
`chart-*` cues.
|
||||||
|
|
||||||
|
#### Scenario: Healthy status uses a success cue
|
||||||
|
|
||||||
|
- GIVEN a monitoring target or backup run is in a healthy/OK state
|
||||||
|
- WHEN its status is rendered
|
||||||
|
- THEN it displays a Badge using the success color cue (from `chart-2`)
|
||||||
|
|
||||||
|
#### Scenario: Firing alert uses a destructive cue
|
||||||
|
|
||||||
|
- GIVEN an Alertmanager alert is firing
|
||||||
|
- WHEN its status is rendered
|
||||||
|
- THEN it displays a destructive Badge (from `chart-4`)
|
||||||
|
|
||||||
|
### Requirement: Information architecture — Backups top-level navigation
|
||||||
|
|
||||||
|
The **Backups** surface MUST be exposed as a top-level navigation item in the sidebar, linking to the
|
||||||
|
existing `/backups` route.
|
||||||
|
|
||||||
|
#### Scenario: Backups is reachable from the sidebar
|
||||||
|
|
||||||
|
- GIVEN the application shell is rendered
|
||||||
|
- WHEN the sidebar navigation items are inspected
|
||||||
|
- THEN a top-level "Backups" item is present
|
||||||
|
- AND selecting it navigates to `/backups` and renders the Backups page
|
||||||
|
|
||||||
|
### Requirement: Information architecture — Media route and legacy redirects
|
||||||
|
|
||||||
|
The Media/Applications surface MUST be named **"Media"** going forward, and its canonical route MUST
|
||||||
|
be `/media`. The legacy `/applications` route MUST redirect (replace) to `/media`, mirroring the
|
||||||
|
existing `/monitoring` → `/observability` redirect. The OIDC authentication model and the
|
||||||
|
`react-router-dom` routing structure MUST be otherwise unchanged.
|
||||||
|
|
||||||
|
#### Scenario: Media route is the canonical entry
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the sidebar "Media" item is selected
|
||||||
|
- THEN the browser navigates to `/media`
|
||||||
|
- AND the Media surface is rendered under the name "Media"
|
||||||
|
|
||||||
|
#### Scenario: Legacy /applications redirects to /media
|
||||||
|
|
||||||
|
- GIVEN the application is running
|
||||||
|
- WHEN a user navigates directly to `/applications`
|
||||||
|
- THEN the client router issues a replace redirect to `/media`
|
||||||
|
- AND the Media surface is rendered (same pattern as `/monitoring` → `/observability`)
|
||||||
|
|
||||||
|
#### Scenario: Auth and routing model unchanged
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the authentication and routing setup is inspected
|
||||||
|
- THEN the OIDC flow (`auth.ts`, `react-oidc-context`) and the `react-router-dom` structure are unchanged
|
||||||
|
- AND only nav items, the `/media` route, and the `/applications` redirect differ from the prior state
|
||||||
|
|
||||||
|
### Requirement: Data tables use TanStack Table with visibility-only features
|
||||||
|
|
||||||
|
The Media and FileBrowser data grids MUST be built on `@tanstack/react-table` behind a shared
|
||||||
|
`DataTable` wrapper styled with the shadcn `Table` primitive. The rebuilt tables MUST reproduce only
|
||||||
|
the features in use: **column visibility**, **pagination** (where present), **row selection**, and
|
||||||
|
**row click**. The tables MUST NOT add column sorting, and MUST NOT add column resizing.
|
||||||
|
|
||||||
|
#### Scenario: No sorting added
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media and FileBrowser tables are rendered
|
||||||
|
- WHEN the table headers and column definitions are inspected
|
||||||
|
- THEN no `getSortedRowModel`, sortable-column behavior, or sort affordance is present
|
||||||
|
|
||||||
|
#### Scenario: No column resizing added
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media and FileBrowser tables are rendered
|
||||||
|
- WHEN the column edges are inspected
|
||||||
|
- THEN no column-resize handles or resizing behavior are present
|
||||||
|
|
||||||
|
### Requirement: Data table interaction parity preserved
|
||||||
|
|
||||||
|
The rebuilt tables MUST preserve their pre-rework interaction behavior: column visibility (Media's
|
||||||
|
15 columns and FileBrowser's 5 columns), pagination (Media, server-driven via the query layer), row
|
||||||
|
selection, and row click.
|
||||||
|
|
||||||
|
#### Scenario: Column visibility preserved
|
||||||
|
|
||||||
|
- GIVEN the rebuilt tables are rendered
|
||||||
|
- WHEN a user toggles the visibility of a column
|
||||||
|
- THEN on Media the toggleable columns match `title, series, season, episode, type, year, runtime, size, bitrate, hdr, video, resolution, date_added, library, path`
|
||||||
|
- AND on FileBrowser the toggleable columns match `type, name, ext, size, modified`
|
||||||
|
|
||||||
|
#### Scenario: Media pagination parity
|
||||||
|
|
||||||
|
- GIVEN the rebuilt Media table is rendered with more rows than one page
|
||||||
|
- WHEN a user changes the page size and navigates between pages
|
||||||
|
- THEN the visible rows, total count, and page index update consistently with the pre-rework behavior (page index/size drive the query layer's limit/offset)
|
||||||
|
|
||||||
|
#### Scenario: Row selection preserved
|
||||||
|
|
||||||
|
- GIVEN the rebuilt tables are rendered
|
||||||
|
- WHEN a user selects and deselects rows via the selection control
|
||||||
|
- THEN the selection state is maintained across pagination and matches the pre-rework model
|
||||||
|
|
||||||
|
#### Scenario: Row click behavior preserved
|
||||||
|
|
||||||
|
- GIVEN the rebuilt tables are rendered
|
||||||
|
- WHEN a user clicks a Media row
|
||||||
|
- THEN navigation occurs to the file browser at the clicked item's path
|
||||||
|
- AND WHEN a user clicks a FileBrowser row
|
||||||
|
- THEN that file is selected for ffprobe preview
|
||||||
|
|
||||||
|
### Requirement: Thin-dashboard observability parity
|
||||||
|
|
||||||
|
The Observability surface MUST continue to render Alertmanager alerts, Prometheus target health,
|
||||||
|
monitoring-machine status, and Grafana deep-link cards (kiosk iframe URLs and Explore links). The
|
||||||
|
rework MUST NOT introduce any in-app chart, and MUST preserve all existing Grafana deep-link
|
||||||
|
affordances.
|
||||||
|
|
||||||
|
#### Scenario: Alerts and target health still shown
|
||||||
|
|
||||||
|
- GIVEN the Observability page is rendered with Alertmanager and Prometheus data available
|
||||||
|
- THEN Alertmanager alerts and Prometheus target health are displayed as before the rework
|
||||||
|
|
||||||
|
#### Scenario: Grafana deep-links preserved
|
||||||
|
|
||||||
|
- GIVEN the Observability page is rendered
|
||||||
|
- WHEN a Grafana deep-link card is inspected
|
||||||
|
- THEN the outbound kiosk/explore URL with instance variables is preserved
|
||||||
|
- AND a consistent external-link affordance is present across the surface
|
||||||
|
|
||||||
|
#### Scenario: No in-app chart on observability
|
||||||
|
|
||||||
|
- GIVEN the Observability page is rendered
|
||||||
|
- WHEN metric surfaces are inspected
|
||||||
|
- THEN no in-app chart is rendered; metrics are numbers, status Badges, or Grafana deep-links only
|
||||||
|
|
||||||
|
### Requirement: Frontend component test harness
|
||||||
|
|
||||||
|
A **Vitest** + **@testing-library/react** component test harness MUST be present and configured.
|
||||||
|
Migrated components MUST be covered by behavioral component tests. The legacy
|
||||||
|
`frontend/tests/*.mjs` `node --test` suites MUST continue to run and pass.
|
||||||
|
|
||||||
|
#### Scenario: Vitest harness is present
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/package.json` and config are inspected
|
||||||
|
- THEN Vitest and `@testing-library/react` are installed
|
||||||
|
- AND a Vitest test script is configured
|
||||||
|
|
||||||
|
#### Scenario: Legacy node:test suites keep passing
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN the `frontend/tests/*.mjs` suites are executed with `node --test`
|
||||||
|
- THEN all pre-existing assertions still pass
|
||||||
|
|
||||||
|
### Requirement: Documentation reflects the post-rework architecture
|
||||||
|
|
||||||
|
`docs/REQUIREMENTS.md` MUST reflect the post-rework UX and architecture: the single design system
|
||||||
|
(shadcn/ui + Tailwind + lucide-react), the thin-dashboard observability model (no in-app charts),
|
||||||
|
TanStack tables, the `/media` route with `/applications` redirect, the Backups top-level nav item,
|
||||||
|
and the removal of `@mui/*`, `@emotion/*`, `recharts`, and `d3`.
|
||||||
|
|
||||||
|
#### Scenario: Requirements doc reflects the rework
|
||||||
|
|
||||||
|
- GIVEN the final slice is applied
|
||||||
|
- WHEN `docs/REQUIREMENTS.md` is inspected
|
||||||
|
- THEN it documents the shadcn/ui + Tailwind + lucide-react stack, the thin-dashboard observability model (no in-app charts), the `/media` route with `/applications` redirect, the Backups top-level nav item, and the removed dependencies
|
||||||
|
|
||||||
|
### Requirement: No backend API or frontend data-contract changes
|
||||||
|
|
||||||
|
The rework MUST NOT change any backend API contract or any type in `frontend/src/types/*`. If a UI
|
||||||
|
simplification forces a contract change, that change MUST be flagged and approved separately.
|
||||||
|
|
||||||
|
#### Scenario: Frontend data contracts unchanged
|
||||||
|
|
||||||
|
- GIVEN the rework is applied
|
||||||
|
- WHEN `frontend/src/types/*` and the backend API surface are compared to the pre-rework state
|
||||||
|
- THEN no backend endpoint, response shape, or shared frontend type is altered by this change
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user