From d8d80867ce8b5b107b319661d9718e94c2b7b075 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 6 May 2026 15:09:15 +0200 Subject: [PATCH] now supports multi machine monitoring --- README.md | 40 +++- backend/README.md | 53 ++++- .../src/media_library_viewer_api/config.py | 5 + .../media_library_viewer_api/dependencies.py | 15 ++ backend/src/media_library_viewer_api/main.py | 7 +- .../routers/dashboard.py | 21 +- .../routers/monitoring.py | 176 ++++++++++---- backend/tests/test_api.py | 73 ++++++ docker-compose.dev.yml | 21 +- docker-compose.yml | 61 +++-- docs/REQUIREMENTS.md | 21 +- frontend/README.md | 14 +- frontend/src/App.tsx | 8 + frontend/src/api/client.ts | 91 ++++++- .../src/components/SessionActivityPanel.tsx | 20 +- frontend/src/hooks/useDashboard.ts | 15 +- frontend/src/hooks/useMonitoring.ts | 59 +++-- frontend/src/pages/Dashboard.tsx | 167 +------------ frontend/src/pages/Monitoring.tsx | 223 +++++------------- frontend/src/types/index.ts | 93 ++++++++ 20 files changed, 757 insertions(+), 426 deletions(-) diff --git a/README.md b/README.md index c5a9bac..f9dd9e8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ The project consists of two subprojects: ## Features - Dashboard with now-playing sessions, server monitoring overview, and per-library media counts -- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts +- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines +- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine - SQLite-indexed media table with full-library sort/filter - Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment - Remote file browser with ffprobe preview and job execution @@ -32,7 +33,7 @@ The project consists of two subprojects: ### Docker Compose (recommended) -Production-style deployment with the frontend serving the SPA and proxying `/api` to the backend: +Production-style deployment with the frontend serving the SPA and proxying `/api` to the backend. The compose files rely on environment-variable interpolation, so export the required values in your shell before running them (no `env_file` is needed): ```bash docker compose up --build @@ -48,6 +49,7 @@ docker compose -f docker-compose.dev.yml up --build Frontend runs on http://localhost:5173 and the backend on http://localhost:8000. The backend media index is persisted in a Docker volume (`backend_cache`) so rebuilds and container restarts do not force a full re-index. +Monitoring machine definitions and recent machine activity are stored in the backend so the UI can show one section per configured machine and preserve history across restarts. ### Manual backend/frontend development @@ -67,7 +69,37 @@ npm run dev ## Configuration -Create a `.env` file in the project root: +The Compose files use environment-variable interpolation. Export the required variables in your shell or pass them inline; a `.env` file is optional, not required. + +### Compose examples + +Production-style example with shell exports: + +```bash +export JELLYFIN_URL=https://jellyfin.example.com +export JELLYFIN_API_KEY=your-api-key +export SSH_HOST=media-server.example.com +export SSH_USERNAME=username +export SSH_KEY_HOST_DIR=$HOME/.ssh +export SSH_KEY_NAME=id_ed25519 +export BACKEND_APP_HOST=manage.example.com +export FRONTEND_APP_HOST=manage.example.com +export CERT_RESOLVER=letsencrypt +export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ +export VITE_OIDC_CLIENT_ID=manage +export VITE_OIDC_REDIRECT_URI=https://manage.example.com/ +export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ + +docker compose up --build +``` + +Inline one-liner example: + +```bash +JELLYFIN_URL=https://jellyfin.example.com JELLYFIN_API_KEY=your-api-key SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_KEY_HOST_DIR=$HOME/.ssh SSH_KEY_NAME=id_ed25519 BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build +``` + +Example environment variables: ```bash JELLYFIN_URL=https://jellyfin.example.com @@ -140,4 +172,4 @@ cd frontend && npx tsc --noEmit && npm run build - SSH commands run through `/bin/sh -c` regardless of remote login shell. - Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`. - Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines. -- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`). +- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries. diff --git a/backend/README.md b/backend/README.md index 928b6b6..352d3bc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -22,17 +22,20 @@ backend/ │ │ ├── monitoring.py │ │ ├── media.py │ │ ├── users.py +│ │ ├── settings.py │ │ ├── files.py │ │ └── jobs.py │ ├── clients/ │ │ ├── jellyfin.py │ │ ├── jellyseerr.py +│ │ ├── local.py │ │ ├── resources.py │ │ └── ssh.py │ ├── domain/ │ │ └── media.py │ └── services/ -│ └── media_index.py +│ ├── media_index.py +│ └── settings_store.py └── tests/ ``` @@ -47,7 +50,9 @@ pip install -e '.[dev]' ## Configuration -Create a `.env` file in the project root (or set environment variables): +Set environment variables directly in your shell or a wrapper script before running the app or Compose. The Docker Compose files use interpolation and do not require an `env_file` entry. + +For local `.env` development, you can still create one if you prefer, but it is optional. ```bash JELLYFIN_URL=https://jellyfin.example.com @@ -101,17 +106,51 @@ API docs available at: http://localhost:8000/docs The repository root includes a production `docker-compose.yml` and a development `docker-compose.dev.yml`. The backend media index is stored in the `backend_cache` Docker volume so it survives container restarts and image rebuilds. +The production compose file expects required environment variables to be supplied via interpolation (shell exports or inline `VAR=value docker compose ...`). + +### Configuration workflow examples + +1. Export your runtime variables before launching Compose: + +```bash +export JELLYFIN_URL=https://jellyfin.example.com +export JELLYFIN_API_KEY=your-api-key +export SSH_HOST=media-server.example.com +export SSH_USERNAME=username +export SSH_KEY_HOST_DIR=$HOME/.ssh +export SSH_KEY_NAME=id_ed25519 +export BACKEND_APP_HOST=manage.example.com +export FRONTEND_APP_HOST=manage.example.com +export CERT_RESOLVER=letsencrypt +export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ +export VITE_OIDC_CLIENT_ID=manage +export VITE_OIDC_REDIRECT_URI=https://manage.example.com/ +export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ + +docker compose up --build +``` + +2. After the API is running, open the app, go to **Settings**, and add machine entries: + - **Local**: monitors the API host itself without SSH. + - **SSH**: monitors another machine using a host, username, key directory, and key name. + +3. Open **Monitoring** to see one section per configured machine. Each section uses its own collector state, disk path, metrics queries, and recent action history, which are populated automatically by the backend poller. ## API Endpoints - `GET /api/dashboard/counts` — Movie/series/episode totals - `GET /api/dashboard/libraries` — Per-library breakdown - `GET /api/dashboard/now-playing` — Active playback sessions -- `GET /api/monitoring/status` — Collector status -- `GET /api/monitoring/metrics` — Resource samples (last hour) -- `GET /api/monitoring/disk` — Disk space -- `POST /api/monitoring/start|stop|restart` — Collector controls -- `GET /api/monitoring/diagnostics` — Collector debug info +- `GET /api/monitoring/machines` — Persistent monitoring machine definitions +- `GET /api/monitoring/status?machine_id=` — Collector status for a machine +- `GET /api/monitoring/metrics?machine_id=` — Resource samples (last hour) +- `GET /api/monitoring/disk?machine_id=` — Disk space +- `POST /api/monitoring/start|stop|restart?machine_id=` — Collector controls +- `GET /api/monitoring/diagnostics?machine_id=` — Collector debug info +- `GET /api/monitoring/poller` — Backend poller status and configuration +- `GET /api/monitoring/machines/{machine_id}/actions` — Recent machine action history +- `GET /api/dashboard/monitoring` — Dashboard-wide per-machine monitoring summary table with 10-minute averages and min/max subtext +- `GET /api/settings/machines` — Manage machine definitions - `GET /api/media/status` — Index status - `POST /api/media/build` — Rebuild index - `GET /api/media/query` — Query with filters/sort/pagination diff --git a/backend/src/media_library_viewer_api/config.py b/backend/src/media_library_viewer_api/config.py index 14c5957..c47671e 100644 --- a/backend/src/media_library_viewer_api/config.py +++ b/backend/src/media_library_viewer_api/config.py @@ -59,6 +59,11 @@ class Settings(BaseSettings): ssh_key_name: str = "" ssh_password: str = "" + # Monitoring poller + monitoring_poll_interval_seconds: int = 300 + monitoring_poll_initial_delay_seconds: int = 20 + monitoring_action_retention_days: int = 30 + # Remote paths remote_media_root: str = "" remote_path_prefix: str = "" diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index c7731d8..ffa9d4e 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -14,6 +14,11 @@ from media_library_viewer_api.clients.jellyseerr import JellyseerrClient from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.config import get_settings from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue +from media_library_viewer_api.services.monitoring_poller import ( + MonitoringPoller, + get_monitoring_poller as _get_monitoring_poller, +) +from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store as _get_settings_store logger = logging.getLogger(__name__) @@ -76,6 +81,16 @@ def get_mail_queue() -> MailQueue: return _get_mail_queue() +def get_monitoring_poller() -> MonitoringPoller: + """Return the singleton background monitoring poller.""" + return _get_monitoring_poller() + + +def get_settings_store() -> SettingsStore: + """Return the singleton persistent settings store.""" + return _get_settings_store() + + def get_user_id() -> str: """Return the configured Jellyfin user ID, or discover the first available user.""" settings = get_settings() diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index 098d686..17f1fbb 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -14,7 +14,8 @@ from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settin from media_library_viewer_api.config import get_settings from media_library_viewer_api.logging_utils import configure_logging, describe_settings from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users -from media_library_viewer_api.dependencies import get_mail_queue +from media_library_viewer_api.routers.settings import router as settings_router +from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller logger = logging.getLogger(__name__) @@ -27,8 +28,11 @@ async def lifespan(app: FastAPI): validate_auth_settings(settings) logger.info("Backend startup complete: %s", describe_settings(settings)) mail_queue = get_mail_queue() + monitoring_poller = get_monitoring_poller() mail_queue.start() + monitoring_poller.start() yield + monitoring_poller.stop() mail_queue.stop() logger.info("Backend shutdown complete") @@ -87,6 +91,7 @@ app.include_router(media.router) app.include_router(files.router) app.include_router(jobs.router) app.include_router(users.router) +app.include_router(settings_router) @app.get("/api/health") diff --git a/backend/src/media_library_viewer_api/routers/dashboard.py b/backend/src/media_library_viewer_api/routers/dashboard.py index 802eb32..d20fb95 100644 --- a/backend/src/media_library_viewer_api/routers/dashboard.py +++ b/backend/src/media_library_viewer_api/routers/dashboard.py @@ -7,8 +7,9 @@ from typing import Any from fastapi import APIRouter, Depends -from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id from media_library_viewer_api.clients.jellyfin import JellyfinClient +from media_library_viewer_api.dependencies import get_jellyfin_client, get_monitoring_poller, get_settings_store, get_user_id +from media_library_viewer_api.services.monitoring_actions import collect_machine_overview logger = logging.getLogger(__name__) @@ -37,6 +38,24 @@ def get_library_counts( return client.library_item_counts(user_id, libraries) +@router.get("/monitoring") +def get_monitoring_overview( + store=Depends(get_settings_store), +) -> dict[str, Any]: + """Return one lightweight monitoring row per configured machine.""" + machines = store.list_machines() + rows = [collect_machine_overview(machine) for machine in machines] + poller = get_monitoring_poller().snapshot() + enabled_count = sum(1 for machine in machines if machine.get("enabled")) + logger.info("Dashboard monitoring machines=%s enabled=%s", len(machines), enabled_count) + return { + "poller": poller, + "machines": rows, + "total": len(machines), + "enabled": enabled_count, + } + + 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]] = [] diff --git a/backend/src/media_library_viewer_api/routers/monitoring.py b/backend/src/media_library_viewer_api/routers/monitoring.py index f0741ca..84e4ffe 100644 --- a/backend/src/media_library_viewer_api/routers/monitoring.py +++ b/backend/src/media_library_viewer_api/routers/monitoring.py @@ -1,4 +1,4 @@ -"""Monitoring router — metrics, collector controls, disk space.""" +"""Monitoring router — metrics, collector controls, and per-machine status.""" from __future__ import annotations @@ -6,31 +6,90 @@ import logging import time from typing import Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Query -from media_library_viewer_api.clients.ssh import RemoteSSHClient -from media_library_viewer_api.dependencies import get_ssh_client - -logger = logging.getLogger(__name__) from media_library_viewer_api.clients.resources import ( disk_space, read_resource_metrics, - resource_collector_debug_info, resource_collector_status, - restart_resource_collector, - start_resource_collector, - stop_resource_collector, ) from media_library_viewer_api.config import get_settings +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.services.monitoring_actions import ( + poll_machine_diagnostics, + run_machine_operation, + start_collector, + stop_collector, + restart_collector, +) +from media_library_viewer_api.services.settings_store import SettingsStore + +logger = logging.getLogger(__name__) 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") +def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]: + """Return monitoring machines for the UI.""" + return store.list_machines() + + +@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("/status") -def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: - """Return collector running status.""" - status = resource_collector_status(ssh) - logger.info("Monitoring status requested: %s", status) +def get_status( + machine_id: str | None = Query(default=None), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, str]: + """Return collector running status for a given machine.""" + machine = _resolve_machine(store, machine_id) + status = run_machine_operation(machine, store, "status lookup", resource_collector_status) + logger.info("Monitoring status requested machine_id=%s status=%s", machine["id"], status) return {"status": status} @@ -38,17 +97,29 @@ def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str] def get_metrics( max_lines: int = 70_000, last_seconds: int | None = None, - ssh: RemoteSSHClient = Depends(get_ssh_client), + machine_id: str | None = Query(default=None), + store: SettingsStore = Depends(get_settings_store), ) -> dict[str, Any]: - """Return resource metric samples from the remote collector.""" - rows = read_resource_metrics(ssh, max_lines=max_lines) + """Return resource metric samples for a given machine.""" + machine = _resolve_machine(store, machine_id) + + def _read_metrics(client): + return read_resource_metrics(client, max_lines=max_lines) + + rows = run_machine_operation(machine, store, "metrics read", _read_metrics) if last_seconds is None: filtered = rows cutoff_ts = 0.0 else: cutoff_ts = time.time() - last_seconds filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts] - logger.info("Monitoring metrics requested total=%s filtered=%s last_seconds=%s", len(rows), len(filtered), last_seconds) + logger.info( + "Monitoring metrics requested machine_id=%s total=%s filtered=%s last_seconds=%s", + machine["id"], + len(rows), + len(filtered), + last_seconds, + ) return { "samples": filtered, "total_samples": len(rows), @@ -58,41 +129,66 @@ def get_metrics( @router.get("/disk") -def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, Any]: - """Return disk space for the configured media root.""" - settings = get_settings() - path = settings.media_root or "/" - logger.info("Monitoring disk requested path=%s", path) - return disk_space(ssh, path) +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.post("/start") -def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: - """Start the remote resource collector.""" - message = start_resource_collector(ssh) - logger.info("Monitoring collector start result: %s", message) +def post_start( + machine_id: str | None = Query(default=None), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, str]: + """Start the machine's resource collector.""" + machine = _resolve_machine(store, machine_id) + message = start_collector(machine, store) + logger.info("Monitoring collector start result machine_id=%s: %s", machine["id"], message) return {"message": message} @router.post("/stop") -def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: - """Stop the remote resource collector.""" - message = stop_resource_collector(ssh) - logger.info("Monitoring collector stop result: %s", message) +def post_stop( + machine_id: str | None = Query(default=None), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, str]: + """Stop the machine's resource collector.""" + machine = _resolve_machine(store, machine_id) + message = stop_collector(machine, store) + logger.info("Monitoring collector stop result machine_id=%s: %s", machine["id"], message) return {"message": message} @router.post("/restart") -def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: - """Restart the remote resource collector.""" - message = restart_resource_collector(ssh) - logger.info("Monitoring collector restart result: %s", message) +def post_restart( + machine_id: str | None = Query(default=None), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, str]: + """Restart the machine's resource collector.""" + machine = _resolve_machine(store, machine_id) + message = restart_collector(machine, store) + logger.info("Monitoring collector restart result machine_id=%s: %s", machine["id"], message) return {"message": message} @router.get("/diagnostics") -def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]: - """Return collector debug info for troubleshooting.""" - diagnostics = resource_collector_debug_info(ssh) - logger.info("Monitoring diagnostics requested") +def get_diagnostics( + machine_id: str | None = Query(default=None), + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, str]: + """Return collector debug info for a given machine.""" + machine = _resolve_machine(store, machine_id) + diagnostics = poll_machine_diagnostics(machine, store)["diagnostics"] + logger.info("Monitoring diagnostics requested machine_id=%s", machine["id"]) return {"diagnostics": diagnostics} diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6057eb9..e8a2e97 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -17,6 +17,7 @@ from media_library_viewer_api.dependencies import ( get_jellyfin_client, get_jellyseerr_client, get_mail_queue, + get_settings_store, get_user_id, ) from media_library_viewer_api.clients.ssh import CommandResult @@ -159,6 +160,78 @@ class TestDashboard: assert data["series"] == 20 assert data["episodes"] == 500 + def test_monitoring_overview(self, test_client): + store = MagicMock() + store.list_machines.return_value = [ + { + "id": "local", + "name": "This machine", + "mode": "local", + "enabled": True, + "host": "localhost", + "port": 22, + "username": "", + "media_root": "/srv/media", + "path_prefix": "", + "notes": "", + }, + { + "id": "remote1", + "name": "Remote", + "mode": "ssh", + "enabled": True, + "host": "server.example.com", + "port": 22, + "username": "alex", + "media_root": "/srv/media", + "path_prefix": "", + "notes": "", + }, + ] + app.dependency_overrides[get_settings_store] = lambda: store + try: + with ( + patch("media_library_viewer_api.services.monitoring_actions.build_machine_client", return_value=object()), + patch("media_library_viewer_api.services.monitoring_actions.resource_collector_status", return_value="running pid=123"), + patch( + "media_library_viewer_api.services.monitoring_actions.read_resource_metrics", + return_value=[ + { + "ts": 123.0, + "cpu_pct": 10.0, + "iowait_pct": 1.0, + "mem_pct": 20.0, + "net_rx_bytes_per_sec": 100.0, + "net_tx_bytes_per_sec": 50.0, + "disk_read_bps": 1.0, + "disk_write_bps": 2.0, + } + ], + ), + patch( + "media_library_viewer_api.services.monitoring_actions.disk_space", + return_value={ + "filesystem": "/dev/sda1", + "size": 1000, + "used": 200, + "available": 800, + "used_pct": "20.0%", + "mount": "/srv/media", + }, + ), + ): + response = test_client.get("/api/dashboard/monitoring") + finally: + app.dependency_overrides.pop(get_settings_store, None) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + assert data["enabled"] == 2 + assert len(data["machines"]) == 2 + assert data["machines"][0]["latest_sample"]["cpu_pct"] == 10.0 + assert data["machines"][0]["disk"]["used_pct"] == "20.0%" + def test_libraries(self, test_client): response = test_client.get("/api/dashboard/libraries") assert response.status_code == 200 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2357279..7852112 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,17 +5,30 @@ services: dockerfile: backend/Dockerfile container_name: backend command: uvicorn media_library_viewer_api.main:app --host 0.0.0.0 --port 8000 --reload - env_file: - - .env environment: AUTH_ENABLED: "false" + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} + OIDC_AUDIENCE: ${OIDC_AUDIENCE:-} + OIDC_JWKS_URL: ${OIDC_JWKS_URL:-} + JELLYFIN_URL: ${JELLYFIN_URL:-http://host.docker.internal:8096} + JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:-} + JELLYFIN_USER_ID: ${JELLYFIN_USER_ID:-} + JELLYSEERR_URL: ${JELLYSEERR_URL:-} + JELLYSEERR_API_KEY: ${JELLYSEERR_API_KEY:-} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + SSH_HOST: ${SSH_HOST:-host.docker.internal} + SSH_USERNAME: ${SSH_USERNAME:-} + SSH_PORT: ${SSH_PORT:-22} SSH_KEY_DIRECTORY: /root/.ssh - SSH_KEY_NAME: ${SSH_KEY_NAME} + SSH_KEY_NAME: ${SSH_KEY_NAME:-id_ed25519} + SSH_PASSWORD: ${SSH_PASSWORD:-} + REMOTE_MEDIA_ROOT: ${REMOTE_MEDIA_ROOT:-} + REMOTE_PATH_PREFIX: ${REMOTE_PATH_PREFIX:-} ports: - "8000:8000" volumes: - ./backend:/app/backend - - ${SSH_KEY_HOST_DIR}:/root/.ssh:ro + - ${SSH_KEY_HOST_DIR:-./secrets/ssh}:/root/.ssh:ro - backend_cache:/app/backend/.cache restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 1f9c427..cd10d43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,37 @@ services: build: context: . dockerfile: backend/Dockerfile - env_file: - - .env environment: - AUTH_ENABLED: "true" + AUTH_ENABLED: ${AUTH_ENABLED:-true} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:?set OIDC_ISSUER_URL} + OIDC_AUDIENCE: ${OIDC_AUDIENCE:?set OIDC_AUDIENCE} + OIDC_JWKS_URL: ${OIDC_JWKS_URL:-} + OIDC_CLOCK_SKEW_SECONDS: ${OIDC_CLOCK_SKEW_SECONDS:-30} + JELLYFIN_URL: ${JELLYFIN_URL:?set JELLYFIN_URL} + JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:?set JELLYFIN_API_KEY} + JELLYFIN_USER_ID: ${JELLYFIN_USER_ID:-} + JELLYSEERR_URL: ${JELLYSEERR_URL:-} + JELLYSEERR_API_KEY: ${JELLYSEERR_API_KEY:-} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM_ADDRESS: ${SMTP_FROM_ADDRESS:-} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Manage} + SMTP_USE_TLS: ${SMTP_USE_TLS:-true} + SMTP_USE_SSL: ${SMTP_USE_SSL:-false} + SMTP_TIMEOUT: ${SMTP_TIMEOUT:-30} + SSH_HOST: ${SSH_HOST:?set SSH_HOST} + SSH_USERNAME: ${SSH_USERNAME:?set SSH_USERNAME} + SSH_PORT: ${SSH_PORT:-22} SSH_KEY_DIRECTORY: /root/.ssh - SSH_KEY_NAME: ${SSH_KEY_NAME} + SSH_KEY_NAME: ${SSH_KEY_NAME:?set SSH_KEY_NAME} + SSH_PASSWORD: ${SSH_PASSWORD:-} + REMOTE_MEDIA_ROOT: ${REMOTE_MEDIA_ROOT:-} + REMOTE_PATH_PREFIX: ${REMOTE_PATH_PREFIX:-} volumes: - - ${SSH_KEY_HOST_DIR}:/root/.ssh:ro + - ${SSH_KEY_HOST_DIR:?set SSH_KEY_HOST_DIR}:/root/.ssh:ro - backend_cache:/app/backend/.cache restart: unless-stopped networks: @@ -19,10 +42,10 @@ services: - "8000" labels: - "traefik.enable=true" - - "traefik.http.routers.${BACKEND_APP_NAME}.rule=Host(`${BACKEND_APP_HOST}`)" - - "traefik.http.routers.${BACKEND_APP_NAME}.entrypoints=websecure" - - "traefik.http.routers.${BACKEND_APP_NAME}.tls.certresolver=${CERT_RESOLVER}" - - "traefik.http.services.${BACKEND_APP_NAME}.loadbalancer.server.port=${BACKEND_APP_PORT}" + - "traefik.http.routers.${BACKEND_APP_NAME:-manage-backend}.rule=Host(`${BACKEND_APP_HOST:?set BACKEND_APP_HOST}`)" + - "traefik.http.routers.${BACKEND_APP_NAME:-manage-backend}.entrypoints=websecure" + - "traefik.http.routers.${BACKEND_APP_NAME:-manage-backend}.tls.certresolver=${CERT_RESOLVER:?set CERT_RESOLVER}" + - "traefik.http.services.${BACKEND_APP_NAME:-manage-backend}.loadbalancer.server.port=${BACKEND_APP_PORT:-8000}" healthcheck: test: [ @@ -42,14 +65,14 @@ services: dockerfile: frontend/Dockerfile target: prod args: - VITE_API_URL: "/api" + VITE_API_URL: ${VITE_API_URL:-/api} VITE_OIDC_ENABLED: ${VITE_OIDC_ENABLED:-true} - VITE_OIDC_ISSUER: ${VITE_OIDC_ISSUER} - VITE_OIDC_CLIENT_ID: ${VITE_OIDC_CLIENT_ID} + VITE_OIDC_ISSUER: ${VITE_OIDC_ISSUER:?set VITE_OIDC_ISSUER} + VITE_OIDC_CLIENT_ID: ${VITE_OIDC_CLIENT_ID:?set VITE_OIDC_CLIENT_ID} VITE_OIDC_SCOPE: ${VITE_OIDC_SCOPE:-openid profile email} - VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI} - VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} - VITE_DEV_API_PROXY_TARGET: "http://backend:8000" + 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_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000} depends_on: backend: condition: service_healthy @@ -57,10 +80,10 @@ services: - web labels: - "traefik.enable=true" - - "traefik.http.routers.${FRONTEND_APP_NAME}.rule=Host(`${FRONTEND_APP_HOST}`)" - - "traefik.http.routers.${FRONTEND_APP_NAME}.entrypoints=websecure" - - "traefik.http.routers.${FRONTEND_APP_NAME}.tls.certresolver=${CERT_RESOLVER}" - - "traefik.http.services.${FRONTEND_APP_NAME}.loadbalancer.server.port=${FRONTEND_APP_PORT}" + - "traefik.http.routers.${FRONTEND_APP_NAME:-manage-frontend}.rule=Host(`${FRONTEND_APP_HOST:?set FRONTEND_APP_HOST}`)" + - "traefik.http.routers.${FRONTEND_APP_NAME:-manage-frontend}.entrypoints=websecure" + - "traefik.http.routers.${FRONTEND_APP_NAME:-manage-frontend}.tls.certresolver=${CERT_RESOLVER:?set CERT_RESOLVER}" + - "traefik.http.services.${FRONTEND_APP_NAME:-manage-frontend}.loadbalancer.server.port=${FRONTEND_APP_PORT:-80}" ports: - "8080:80" restart: unless-stopped diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 774ce7a..e84d0c4 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -128,19 +128,23 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo ### Dashboard / Server Monitoring -- Provide a dashboard tab with a compact Jellyfin media library overview and server resource overview. +- Provide a dashboard tab with a compact Jellyfin media library overview and a sortable table-style server resource overview covering all configured monitoring machines. - Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests. - Persist frontend OIDC auth state across tab reloads by storing the OIDC user and request state in browser localStorage. - Provide Docker Compose deployment files at the repository root for production and local development. - Backend Docker deployment should mount a private SSH key and a known_hosts file into the container rather than baking them into the image. - Production compose should also pass the root `.env` into the backend container so runtime auth settings like `OIDC_ISSUER_URL` are available there, not just at compose interpolation time. - The backend media index should persist in a Docker volume so a container restart or image rebuild does not force a new full index build. +- Compose deployment should not require `env_file`; required values should be supplied through environment interpolation or inline shell exports. - The SSH key configuration should support separate directory/name inputs so Docker Compose can mount an arbitrary host SSH directory into `/root/.ssh` while the app assembles the full key path. - Show Jellyfin media counts for movies, series, and series episodes on the dashboard. - Show dashboard session activity from Jellyfin, including both currently playing sessions and logged-in idle sessions. - Activity rows should include user, media title (or `(idle)`), playback state (`playing`/`paused`/`idle`), and whether transcoding is active. - Provide a dashboard tab with a compact server resource overview over SSH. -- Provide a separate Monitoring tab for detailed resource charts, collector controls, diagnostics, and raw samples. +- Provide a separate Monitoring tab for detailed resource charts, collector controls, diagnostics, raw samples, and per-machine recent action history gathered automatically by the backend. +- Expose backend poller status/configuration so the dashboard and Monitoring page can surface whether monitoring snapshots are being gathered automatically. +- The Monitoring tab should present one section per configured machine, and local vs remote machines should be treated the same in the UI with different connection/configuration data. +- Provide a Settings tab where monitoring machines can be added, edited, enabled/disabled, or deleted persistently. - The Monitoring tab should request all retained collector samples by default, while the dashboard overview can continue to use a shorter recent window. - Show CPU and RAM usage for the last hour. - Show IO wait percentage for the last hour. @@ -154,6 +158,9 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 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 be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack. +- Machine definitions for monitoring should persist in backend-owned storage so local and remote monitoring targets survive restarts. +- Monitoring machine actions/history should also persist in backend-owned storage so each machine section can show recent status/metrics/disk/collector activity. +- The backend should periodically poll defined monitoring machines itself; no remote agent or push model should be required. - Last-hour charts require the collector to have been running long enough to collect samples. - Because the collector keeps only a bounded history, the Monitoring tab can safely load all retained samples up to the retention/max-lines cap. - Network throughput should be shown as a combined traffic chart with download and upload lines. @@ -195,3 +202,13 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 2026-05-04: API requests now fall back to the persisted OIDC user store for the bearer token so the first render after reload can avoid spurious 401s. - 2026-05-04: Oversized frontend/backend modules were split into thin re-export entrypoints plus implementation modules to keep page/router/service code maintainable without changing behavior. - 2026-05-04: The Monitoring charts and File Browser were also split into implementation modules behind thin entrypoints so the larger UI surfaces stay easier to navigate without changing runtime behavior. +- 2026-05-06: Monitoring became machine-based: a Settings tab now persists local/remote machine definitions, and the Monitoring tab renders a section per configured machine so API-host and remote targets are handled through the same UI model. +- 2026-05-06: Compose files were switched away from `env_file` and now rely on environment-variable interpolation, so deployments can be driven entirely by shell exports or inline environment values. +- 2026-05-06: Monitoring endpoints now translate machine-specific transport/runtime failures into user-facing HTTP errors so a broken machine only affects its own section instead of taking down the whole Monitoring page. +- 2026-05-06: Documentation now includes explicit Compose interpolation examples plus a monitoring-machine configuration workflow showing how to add local and SSH machines in the Settings tab. +- 2026-05-06: Monitoring machine action history was added so each machine section can display recent operation results, durations, and failures alongside the charts. +- 2026-05-06: Monitoring history collection was shifted to a backend-scheduled poller that reads the defined machines over SSH/local shell and stores snapshots in SQLite, avoiding any remote agent or push requirement. +- 2026-05-06: The dashboard monitoring section was converted from summary cards into a table of all configured machines, paired with backend poller status so the whole fleet can be reviewed at a glance. +- 2026-05-06: The dashboard monitoring table now shows 10-minute averages with min/max subtext and can be sorted by machine, status, and metric columns. +- 2026-05-06: The Monitoring page now includes a poller-health badge in the header so users can quickly see whether backend collection is active. +- 2026-05-06: The dashboard monitoring table now renders each metric summary with compact stacked low/high lines to keep the table narrower, and the activity/session table no longer hides columns on mobile so all details remain available. diff --git a/frontend/README.md b/frontend/README.md index f1d733b..5bc1dde 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -45,18 +45,26 @@ Output goes to `frontend/dist/`. ## Pages -- **Dashboard** (`/`) — Now playing, server overview, library stats -- **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls +- **Dashboard** (`/`) — Now playing, backend-collected per-machine monitoring table with 10-minute averages/min/max, library stats +- **Monitoring** (`/monitoring`) — Per-machine CPU/IO wait/RAM/network/disk charts, collector controls, and backend-collected recent action history - **Media** (`/media`) — Full-library table with sort/filter/search - **Users** (`/users`) — Read-only Jellyfin user list with optional Jellyseerr enrichment - **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs +- **Settings** (`/settings`) — Persistent monitoring machine definitions and setup workflow ## Environment Variables -Create a `.env` file in `frontend/` if the API is not at `http://localhost:8000`: +Set `VITE_API_URL` and any OIDC variables directly in your shell or Compose build args if the API is not at `http://localhost:8000`. ```bash VITE_API_URL=http://your-backend-host:8000 ``` +## Configuration workflow examples + +- **Local development**: run `docker compose -f docker-compose.dev.yml up --build`, then open the app and add monitoring machines in the **Settings** tab. +- **Production**: export the required Compose variables in your shell, run `docker compose up --build`, and manage local/remote machines from **Settings**. +- **Dashboard monitoring UI**: the dashboard shows a compact, sortable table with one row per configured machine plus poller status. +- **Monitoring UI**: the **Monitoring** tab shows one card per configured machine, including a recent action-history table populated by the backend poller. A machine can be `local` (the API host itself) or `ssh` (a remote host), and the UI treats both the same after configuration. The page also shows the backend poller health badge. + In development, the Vite proxy handles `/api` requests automatically. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index db810d7..c18a41d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import { AuthProvider, useAuth } from "react-oidc-context"; import { Dashboard } from "./pages/Dashboard"; import { Monitoring } from "./pages/Monitoring"; import { Media } from "./pages/Media"; +import { Settings } from "./pages/Settings"; import { UsersPage } from "./pages/Users"; import { FileBrowser } from "./pages/FileBrowser"; import { getAppTheme } from "./theme"; @@ -175,6 +176,12 @@ function Shell({ + @@ -188,6 +195,7 @@ function Shell({ } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index edc67d1..df31b1a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,9 +10,14 @@ import type { UserMessageResponse, UserMessageQueueStatus, NowPlayingSession, + MonitoringPollerStatus, + MonitoringOverviewResponse, MonitoringStatus, MonitoringMetrics, DiskSpace, + MonitoringMachine, + MonitoringMachineInput, + MonitoringMachineAction, MediaIndexStatus, MediaIndexActionResponse, MediaQueryResponse, @@ -109,6 +114,17 @@ async function postForm(path: string, body: FormData): Promise { return response.json(); } +async function del(path: string): Promise { + const response = await fetch(buildUrl(path), { + method: "DELETE", + headers: buildHeaders(false), + }); + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json(); +} + // Dashboard export const fetchCounts = () => get("/api/dashboard/counts"); export const fetchLibraries = () => @@ -121,23 +137,80 @@ export const fetchUsers = () => get("/api/users"); export const fetchNowPlaying = fetchActivity; // Monitoring -export const fetchMonitoringStatus = () => - get("/api/monitoring/status"); +export const fetchMonitoringMachines = () => + get("/api/monitoring/machines"); +export const fetchMonitoringPoller = () => + get("/api/monitoring/poller"); +export const fetchMonitoringOverview = () => + get("/api/dashboard/monitoring"); +export const fetchMonitoringStatus = (machineId?: string) => + get( + "/api/monitoring/status", + machineId ? { machine_id: machineId } : undefined, + ); export const fetchMonitoringMetrics = ( lastSeconds?: number | null, maxLines = 70_000, + machineId?: string, ) => get("/api/monitoring/metrics", { ...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }), max_lines: String(maxLines), + ...(machineId ? { machine_id: machineId } : {}), }); -export const fetchDiskSpace = () => get("/api/monitoring/disk"); -export const startCollector = () => - post<{ message: string }>("/api/monitoring/start"); -export const stopCollector = () => - post<{ message: string }>("/api/monitoring/stop"); -export const restartCollector = () => - post<{ message: string }>("/api/monitoring/restart"); +export const fetchDiskSpace = (machineId?: string) => + get( + "/api/monitoring/disk", + machineId ? { machine_id: machineId } : undefined, + ); +export const startCollector = (machineId?: string) => + post<{ message: string }>( + machineId + ? `/api/monitoring/start?machine_id=${encodeURIComponent(machineId)}` + : "/api/monitoring/start", + ); +export const stopCollector = (machineId?: string) => + post<{ message: string }>( + machineId + ? `/api/monitoring/stop?machine_id=${encodeURIComponent(machineId)}` + : "/api/monitoring/stop", + ); +export const restartCollector = (machineId?: string) => + post<{ message: string }>( + machineId + ? `/api/monitoring/restart?machine_id=${encodeURIComponent(machineId)}` + : "/api/monitoring/restart", + ); + +export const fetchMonitoringSettings = () => + get("/api/settings/machines"); +export const fetchMonitoringMachineActions = (machineId: string, limit = 10) => + get<{ items: MonitoringMachineAction[]; total: number }>( + `/api/monitoring/machines/${encodeURIComponent(machineId)}/actions`, + { limit: String(limit) }, + ); +export const saveMonitoringMachine = (machine: MonitoringMachineInput) => + fetch( + buildUrl( + machine.id + ? `/api/settings/machines/${encodeURIComponent(machine.id)}` + : "/api/settings/machines", + ), + { + method: machine.id ? "PUT" : "POST", + headers: buildHeaders(true), + body: JSON.stringify(machine), + }, + ).then(async (response) => { + if (!response.ok) { + throw new Error(`${response.status}: ${await readErrorDetail(response)}`); + } + return response.json() as Promise; + }); +export const deleteMonitoringMachine = (machineId: string) => + del<{ status: string }>( + `/api/settings/machines/${encodeURIComponent(machineId)}`, + ); // Media export const fetchMediaStatus = () => diff --git a/frontend/src/components/SessionActivityPanel.tsx b/frontend/src/components/SessionActivityPanel.tsx index f87ab5a..f9a5db2 100644 --- a/frontend/src/components/SessionActivityPanel.tsx +++ b/frontend/src/components/SessionActivityPanel.tsx @@ -79,9 +79,19 @@ export function SessionActivityPanel({ - +
- + - + {session.device || "Unknown device"} - + {session.transcoding === "yes" ? session.transcoding_type diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index 0da8a79..c679e1f 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -1,5 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client"; +import { + fetchActivity, + fetchCounts, + fetchLibraries, + fetchMonitoringOverview, +} from "../api/client"; export function useCounts() { return useQuery({ @@ -25,5 +30,13 @@ export function useActivity() { }); } +export function useMonitoringOverview() { + return useQuery({ + queryKey: ["dashboard", "monitoring"], + queryFn: fetchMonitoringOverview, + refetchInterval: 30_000, + }); +} + // Backward-compatible alias used by older code. export const useNowPlaying = useActivity; diff --git a/frontend/src/hooks/useMonitoring.ts b/frontend/src/hooks/useMonitoring.ts index 4618aaf..b76917c 100644 --- a/frontend/src/hooks/useMonitoring.ts +++ b/frontend/src/hooks/useMonitoring.ts @@ -1,52 +1,83 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchMonitoringStatus, fetchMonitoringMetrics, fetchDiskSpace, + fetchMonitoringMachines, + fetchMonitoringMachineActions, + fetchMonitoringPoller, startCollector, stopCollector, restartCollector, } from "../api/client"; -export function useMonitoringStatus() { +export function useMonitoringMachines() { return useQuery({ - queryKey: ["monitoring", "status"], - queryFn: fetchMonitoringStatus, + queryKey: ["monitoring", "machines"], + queryFn: fetchMonitoringMachines, refetchInterval: 30_000, }); } -export function useMonitoringMetrics() { +export function useMonitoringPoller() { return useQuery({ - queryKey: ["monitoring", "metrics"], - queryFn: () => fetchMonitoringMetrics(), + queryKey: ["monitoring", "poller"], + queryFn: fetchMonitoringPoller, + refetchInterval: 30_000, + }); +} + +export function useMonitoringStatus(machineId?: string, enabled = true) { + return useQuery({ + queryKey: ["monitoring", "status", machineId ?? "default"], + queryFn: () => fetchMonitoringStatus(machineId), + refetchInterval: 30_000, + enabled, + }); +} + +export function useMonitoringMetrics(machineId?: string, enabled = true) { + return useQuery({ + queryKey: ["monitoring", "metrics", machineId ?? "default"], + queryFn: () => fetchMonitoringMetrics(undefined, 70_000, machineId), refetchInterval: 15_000, + enabled, }); } -export function useDiskSpace() { +export function useDiskSpace(machineId?: string, enabled = true) { return useQuery({ - queryKey: ["monitoring", "disk"], - queryFn: fetchDiskSpace, + queryKey: ["monitoring", "disk", machineId ?? "default"], + queryFn: () => fetchDiskSpace(machineId), staleTime: 60_000, + enabled, }); } -export function useCollectorControls() { +export function useMachineActions(machineId: string, enabled = true) { + return useQuery({ + queryKey: ["monitoring", "actions", machineId], + queryFn: () => fetchMonitoringMachineActions(machineId), + refetchInterval: 15_000, + enabled, + }); +} + +export function useCollectorControls(machineId?: string) { const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ queryKey: ["monitoring"] }); const start = useMutation({ - mutationFn: startCollector, + mutationFn: () => startCollector(machineId), onSuccess: invalidate, }); const stop = useMutation({ - mutationFn: stopCollector, + mutationFn: () => stopCollector(machineId), onSuccess: invalidate, }); const restart = useMutation({ - mutationFn: restartCollector, + mutationFn: () => restartCollector(machineId), onSuccess: invalidate, }); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e2fcc4f..016e844 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,83 +1,22 @@ -import { useMemo } from "react"; import { Box, Divider, Grid, Stack, Typography } from "@mui/material"; import { useNavigate } from "react-router-dom"; -import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard"; -import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring"; +import { + useCounts, + useLibraries, + useActivity, + useMonitoringOverview, +} from "../hooks/useDashboard"; import { NowPlaying } from "../components/NowPlaying"; import { MetricCard } from "../components/MetricCard"; -import { DiskSpaceCard } from "../components/DiskSpaceCard"; import { LibraryOverview } from "../components/LibraryOverview"; - -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]}`; -} - -function formatRate(bytes: number): string { - return `${formatBytes(bytes)}/s`; -} - -function formatPct(value: number): string { - return `${value.toFixed(1)}%`; -} - -function summarize(values: number[]) { - if (values.length === 0) return null; - const total = values.reduce((sum, value) => sum + value, 0); - return { - avg: total / values.length, - min: Math.min(...values), - max: Math.max(...values), - }; -} +import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable"; export function Dashboard() { const navigate = useNavigate(); const { data: counts } = useCounts(); const { data: libraries } = useLibraries(); const { data: activity } = useActivity(); - const { data: metrics } = useMonitoringMetrics(); - const { data: disk } = useDiskSpace(); - - const monitoringWindow = useMemo(() => { - const samples = metrics?.samples ?? []; - if (samples.length === 0) return []; - const latestTs = samples.at(-1)?.ts ?? 0; - const windowStart = latestTs - 10 * 60; - const windowed = samples.filter((sample) => sample.ts >= windowStart); - return windowed.length > 0 ? windowed : samples; - }, [metrics?.samples]); - - const cpuSummary = summarize( - monitoringWindow.map((sample) => sample.cpu_pct), - ); - const iowaitSummary = summarize( - monitoringWindow - .map((sample) => sample.iowait_pct) - .filter((value): value is number => value !== undefined), - ); - const memSummary = summarize( - monitoringWindow.map((sample) => sample.mem_pct), - ); - const netRxSummary = summarize( - monitoringWindow.map((sample) => sample.net_rx_bytes_per_sec), - ); - const netTxSummary = summarize( - monitoringWindow.map((sample) => sample.net_tx_bytes_per_sec), - ); - const diskReadSummary = summarize( - monitoringWindow.map((sample) => sample.disk_read_bps), - ); - const diskWriteSummary = summarize( - monitoringWindow.map((sample) => sample.disk_write_bps), - ); + const { data: monitoringOverview } = useMonitoringOverview(); return ( @@ -101,95 +40,7 @@ export function Dashboard() { Monitoring Overview - - - - - - - - - - - - - - - - - - - - - - - - {disk && ( - - - - )} + diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx index 9ff09b2..830192e 100644 --- a/frontend/src/pages/Monitoring.tsx +++ b/frontend/src/pages/Monitoring.tsx @@ -1,173 +1,80 @@ +import { Alert, Button, Chip, Stack, Typography } from "@mui/material"; import { - Box, - Button, - Chip, - Divider, - Grid, - Stack, - Typography, -} from "@mui/material"; -import { - useMonitoringStatus, - useMonitoringMetrics, - useDiskSpace, - useCollectorControls, + useMonitoringMachines, + useMonitoringPoller, } from "../hooks/useMonitoring"; -import { MetricCard } from "../components/MetricCard"; -import { MonitoringCharts } from "../components/MonitoringCharts"; - -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]}`; -} - -function formatRate(bytes: number): string { - return `${formatBytes(bytes)}/s`; -} +import { MachineMonitoringSection } from "../components/MachineMonitoringSection"; +import { useNavigate } from "react-router-dom"; export function Monitoring() { - const { data: status } = useMonitoringStatus(); - const { data: metrics } = useMonitoringMetrics(); - const { data: disk } = useDiskSpace(); - const { start, stop, restart } = useCollectorControls(); - - const samples = metrics?.samples ?? []; - const latest = samples.at(-1); - - const avg = (arr: number[]) => - arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; - const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0); - - const cpuArr = samples.map((s) => s.cpu_pct); - const iowArr = samples.map((s) => s.iowait_pct ?? 0); - const memArr = samples.map((s) => s.mem_pct); - const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec); - const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec); - const diskReadArr = samples.map((s) => s.disk_read_bps); - const diskWriteArr = samples.map((s) => s.disk_write_bps); + const { data: machines, isLoading, error } = useMonitoringMachines(); + const { data: poller } = useMonitoringPoller(); + const navigate = useNavigate(); return ( - - Monitoring - - - - + Monitoring + {poller && ( + + )} + + + Each machine below is monitored with its own settings and collector + state, and recent activity is gathered automatically by the backend. + - - - - - - - - - - - - - - - - - - - - - - - - - {disk && ( - - - - - - - - - - - - - - + {error && {String(error)}} + {isLoading && ( + Loading monitoring machines... + )} + {!isLoading && (machines?.length ?? 0) === 0 && ( + navigate("/settings")} + > + Open Settings + + } + > + No monitoring machines are configured yet. + )} - - - - + {machines?.map((machine) => ( + + + + ))} ); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0af24a8..4a387d3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -89,6 +89,99 @@ export interface NowPlayingSession { session_id: string; } +export interface MonitoringMachine { + id: string; + name: string; + mode: "local" | "ssh"; + enabled: boolean; + host: string; + port: number; + username: string; + key_directory: string; + key_name: string; + password_set: boolean; + media_root: string; + path_prefix: string; + notes: string; +} + +export interface MonitoringMachineInput { + id?: string | null; + name: string; + mode: "local" | "ssh"; + enabled: boolean; + host: string; + port: number; + username: string; + key_directory: string; + key_name: string; + password: string; + media_root: string; + path_prefix: string; + notes: string; +} + +export interface MonitoringMachineAction { + machine_id: string; + machine_name: string; + mode: "local" | "ssh"; + action: string; + status: string; + created_at: number; + duration_ms: number; + request_id: string; + message: string; + error: string; + stdout_tail: string; + stderr_tail: string; +} + +export interface MetricSummary { + avg: number; + min: number; + max: number; + count: number; +} + +export interface MonitoringPollerStatus { + worker_running: boolean; + stop_requested: boolean; + last_run_at: number | null; + last_success_at: number | null; + last_error: string; + last_cycle_ms: number | null; + poll_count: number; + error_count: number; + interval_seconds: number; + initial_delay_seconds: number; + retention_days: number; +} + +export interface MonitoringMachineOverview { + machine: MonitoringMachine; + status: string; + status_error: string; + metrics_error: string; + disk_error: string; + latest_sample: MonitoringSample | null; + sample_count: number; + cpu_summary: MetricSummary | null; + iowait_summary: MetricSummary | null; + mem_summary: MetricSummary | null; + net_rx_summary: MetricSummary | null; + net_tx_summary: MetricSummary | null; + disk_read_summary: MetricSummary | null; + disk_write_summary: MetricSummary | null; + disk: DiskSpace | null; +} + +export interface MonitoringOverviewResponse { + poller: MonitoringPollerStatus; + machines: MonitoringMachineOverview[]; + total: number; + enabled: number; +} + export interface MonitoringStatus { status: string; }