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;
}