now supports multi machine monitoring
This commit is contained in:
@@ -21,7 +21,8 @@ The project consists of two subprojects:
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
|
- 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
|
- SQLite-indexed media table with full-library sort/filter
|
||||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
||||||
- Remote file browser with ffprobe preview and job execution
|
- Remote file browser with ffprobe preview and job execution
|
||||||
@@ -32,7 +33,7 @@ The project consists of two subprojects:
|
|||||||
|
|
||||||
### Docker Compose (recommended)
|
### 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
|
```bash
|
||||||
docker compose up --build
|
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.
|
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.
|
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
|
### Manual backend/frontend development
|
||||||
|
|
||||||
@@ -67,7 +69,37 @@ npm run dev
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
```bash
|
||||||
JELLYFIN_URL=https://jellyfin.example.com
|
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.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|||||||
+46
-7
@@ -22,17 +22,20 @@ backend/
|
|||||||
│ │ ├── monitoring.py
|
│ │ ├── monitoring.py
|
||||||
│ │ ├── media.py
|
│ │ ├── media.py
|
||||||
│ │ ├── users.py
|
│ │ ├── users.py
|
||||||
|
│ │ ├── settings.py
|
||||||
│ │ ├── files.py
|
│ │ ├── files.py
|
||||||
│ │ └── jobs.py
|
│ │ └── jobs.py
|
||||||
│ ├── clients/
|
│ ├── clients/
|
||||||
│ │ ├── jellyfin.py
|
│ │ ├── jellyfin.py
|
||||||
│ │ ├── jellyseerr.py
|
│ │ ├── jellyseerr.py
|
||||||
|
│ │ ├── local.py
|
||||||
│ │ ├── resources.py
|
│ │ ├── resources.py
|
||||||
│ │ └── ssh.py
|
│ │ └── ssh.py
|
||||||
│ ├── domain/
|
│ ├── domain/
|
||||||
│ │ └── media.py
|
│ │ └── media.py
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ └── media_index.py
|
│ ├── media_index.py
|
||||||
|
│ └── settings_store.py
|
||||||
└── tests/
|
└── tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,7 +50,9 @@ pip install -e '.[dev]'
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
```bash
|
||||||
JELLYFIN_URL=https://jellyfin.example.com
|
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 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 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
|
## API Endpoints
|
||||||
|
|
||||||
- `GET /api/dashboard/counts` — Movie/series/episode totals
|
- `GET /api/dashboard/counts` — Movie/series/episode totals
|
||||||
- `GET /api/dashboard/libraries` — Per-library breakdown
|
- `GET /api/dashboard/libraries` — Per-library breakdown
|
||||||
- `GET /api/dashboard/now-playing` — Active playback sessions
|
- `GET /api/dashboard/now-playing` — Active playback sessions
|
||||||
- `GET /api/monitoring/status` — Collector status
|
- `GET /api/monitoring/machines` — Persistent monitoring machine definitions
|
||||||
- `GET /api/monitoring/metrics` — Resource samples (last hour)
|
- `GET /api/monitoring/status?machine_id=` — Collector status for a machine
|
||||||
- `GET /api/monitoring/disk` — Disk space
|
- `GET /api/monitoring/metrics?machine_id=` — Resource samples (last hour)
|
||||||
- `POST /api/monitoring/start|stop|restart` — Collector controls
|
- `GET /api/monitoring/disk?machine_id=` — Disk space
|
||||||
- `GET /api/monitoring/diagnostics` — Collector debug info
|
- `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
|
- `GET /api/media/status` — Index status
|
||||||
- `POST /api/media/build` — Rebuild index
|
- `POST /api/media/build` — Rebuild index
|
||||||
- `GET /api/media/query` — Query with filters/sort/pagination
|
- `GET /api/media/query` — Query with filters/sort/pagination
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ class Settings(BaseSettings):
|
|||||||
ssh_key_name: str = ""
|
ssh_key_name: str = ""
|
||||||
ssh_password: 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 paths
|
||||||
remote_media_root: str = ""
|
remote_media_root: str = ""
|
||||||
remote_path_prefix: str = ""
|
remote_path_prefix: str = ""
|
||||||
|
|||||||
@@ -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.clients.ssh import RemoteSSHClient
|
||||||
from media_library_viewer_api.config import get_settings
|
from media_library_viewer_api.config import get_settings
|
||||||
from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -76,6 +81,16 @@ def get_mail_queue() -> MailQueue:
|
|||||||
return _get_mail_queue()
|
return _get_mail_queue()
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitoring_poller() -> MonitoringPoller:
|
||||||
|
"""Return the singleton background monitoring poller."""
|
||||||
|
return _get_monitoring_poller()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_store() -> SettingsStore:
|
||||||
|
"""Return the singleton persistent settings store."""
|
||||||
|
return _get_settings_store()
|
||||||
|
|
||||||
|
|
||||||
def get_user_id() -> str:
|
def get_user_id() -> str:
|
||||||
"""Return the configured Jellyfin user ID, or discover the first available user."""
|
"""Return the configured Jellyfin user ID, or discover the first available user."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -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.config import get_settings
|
||||||
from media_library_viewer_api.logging_utils import configure_logging, describe_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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,8 +28,11 @@ async def lifespan(app: FastAPI):
|
|||||||
validate_auth_settings(settings)
|
validate_auth_settings(settings)
|
||||||
logger.info("Backend startup complete: %s", describe_settings(settings))
|
logger.info("Backend startup complete: %s", describe_settings(settings))
|
||||||
mail_queue = get_mail_queue()
|
mail_queue = get_mail_queue()
|
||||||
|
monitoring_poller = get_monitoring_poller()
|
||||||
mail_queue.start()
|
mail_queue.start()
|
||||||
|
monitoring_poller.start()
|
||||||
yield
|
yield
|
||||||
|
monitoring_poller.stop()
|
||||||
mail_queue.stop()
|
mail_queue.stop()
|
||||||
logger.info("Backend shutdown complete")
|
logger.info("Backend shutdown complete")
|
||||||
|
|
||||||
@@ -87,6 +91,7 @@ app.include_router(media.router)
|
|||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +38,24 @@ def get_library_counts(
|
|||||||
return client.library_item_counts(user_id, libraries)
|
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]]:
|
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Monitoring router — metrics, collector controls, disk space."""
|
"""Monitoring router — metrics, collector controls, and per-machine status."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,31 +6,90 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
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 (
|
from media_library_viewer_api.clients.resources import (
|
||||||
disk_space,
|
disk_space,
|
||||||
read_resource_metrics,
|
read_resource_metrics,
|
||||||
resource_collector_debug_info,
|
|
||||||
resource_collector_status,
|
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.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"])
|
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")
|
@router.get("/status")
|
||||||
def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
def get_status(
|
||||||
"""Return collector running status."""
|
machine_id: str | None = Query(default=None),
|
||||||
status = resource_collector_status(ssh)
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
logger.info("Monitoring status requested: %s", status)
|
) -> 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}
|
return {"status": status}
|
||||||
|
|
||||||
|
|
||||||
@@ -38,17 +97,29 @@ def get_status(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]
|
|||||||
def get_metrics(
|
def get_metrics(
|
||||||
max_lines: int = 70_000,
|
max_lines: int = 70_000,
|
||||||
last_seconds: int | None = None,
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""Return resource metric samples from the remote collector."""
|
"""Return resource metric samples for a given machine."""
|
||||||
rows = read_resource_metrics(ssh, max_lines=max_lines)
|
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:
|
if last_seconds is None:
|
||||||
filtered = rows
|
filtered = rows
|
||||||
cutoff_ts = 0.0
|
cutoff_ts = 0.0
|
||||||
else:
|
else:
|
||||||
cutoff_ts = time.time() - last_seconds
|
cutoff_ts = time.time() - last_seconds
|
||||||
filtered = [row for row in rows if float(row.get("ts", 0)) >= cutoff_ts]
|
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 {
|
return {
|
||||||
"samples": filtered,
|
"samples": filtered,
|
||||||
"total_samples": len(rows),
|
"total_samples": len(rows),
|
||||||
@@ -58,41 +129,66 @@ def get_metrics(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/disk")
|
@router.get("/disk")
|
||||||
def get_disk_space(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, Any]:
|
def get_disk_space(
|
||||||
"""Return disk space for the configured media root."""
|
machine_id: str | None = Query(default=None),
|
||||||
settings = get_settings()
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
path = settings.media_root or "/"
|
) -> dict[str, Any]:
|
||||||
logger.info("Monitoring disk requested path=%s", path)
|
"""Return disk space for the configured path of a given machine."""
|
||||||
return disk_space(ssh, path)
|
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")
|
@router.post("/start")
|
||||||
def post_start(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
def post_start(
|
||||||
"""Start the remote resource collector."""
|
machine_id: str | None = Query(default=None),
|
||||||
message = start_resource_collector(ssh)
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
logger.info("Monitoring collector start result: %s", message)
|
) -> 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}
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stop")
|
@router.post("/stop")
|
||||||
def post_stop(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
def post_stop(
|
||||||
"""Stop the remote resource collector."""
|
machine_id: str | None = Query(default=None),
|
||||||
message = stop_resource_collector(ssh)
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
logger.info("Monitoring collector stop result: %s", message)
|
) -> 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}
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restart")
|
@router.post("/restart")
|
||||||
def post_restart(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
def post_restart(
|
||||||
"""Restart the remote resource collector."""
|
machine_id: str | None = Query(default=None),
|
||||||
message = restart_resource_collector(ssh)
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
logger.info("Monitoring collector restart result: %s", message)
|
) -> 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}
|
return {"message": message}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/diagnostics")
|
@router.get("/diagnostics")
|
||||||
def get_diagnostics(ssh: RemoteSSHClient = Depends(get_ssh_client)) -> dict[str, str]:
|
def get_diagnostics(
|
||||||
"""Return collector debug info for troubleshooting."""
|
machine_id: str | None = Query(default=None),
|
||||||
diagnostics = resource_collector_debug_info(ssh)
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
logger.info("Monitoring diagnostics requested")
|
) -> 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}
|
return {"diagnostics": diagnostics}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from media_library_viewer_api.dependencies import (
|
|||||||
get_jellyfin_client,
|
get_jellyfin_client,
|
||||||
get_jellyseerr_client,
|
get_jellyseerr_client,
|
||||||
get_mail_queue,
|
get_mail_queue,
|
||||||
|
get_settings_store,
|
||||||
get_user_id,
|
get_user_id,
|
||||||
)
|
)
|
||||||
from media_library_viewer_api.clients.ssh import CommandResult
|
from media_library_viewer_api.clients.ssh import CommandResult
|
||||||
@@ -159,6 +160,78 @@ class TestDashboard:
|
|||||||
assert data["series"] == 20
|
assert data["series"] == 20
|
||||||
assert data["episodes"] == 500
|
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):
|
def test_libraries(self, test_client):
|
||||||
response = test_client.get("/api/dashboard/libraries")
|
response = test_client.get("/api/dashboard/libraries")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
+17
-4
@@ -5,17 +5,30 @@ services:
|
|||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
container_name: backend
|
container_name: backend
|
||||||
command: uvicorn media_library_viewer_api.main:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn media_library_viewer_api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
AUTH_ENABLED: "false"
|
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_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:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app/backend
|
- ./backend:/app/backend
|
||||||
- ${SSH_KEY_HOST_DIR}:/root/.ssh:ro
|
- ${SSH_KEY_HOST_DIR:-./secrets/ssh}:/root/.ssh:ro
|
||||||
- backend_cache:/app/backend/.cache
|
- backend_cache:/app/backend/.cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
+42
-19
@@ -3,14 +3,37 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
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_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:
|
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
|
- backend_cache:/app/backend/.cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
@@ -19,10 +42,10 @@ services:
|
|||||||
- "8000"
|
- "8000"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.${BACKEND_APP_NAME}.rule=Host(`${BACKEND_APP_HOST}`)"
|
- "traefik.http.routers.${BACKEND_APP_NAME:-manage-backend}.rule=Host(`${BACKEND_APP_HOST:?set BACKEND_APP_HOST}`)"
|
||||||
- "traefik.http.routers.${BACKEND_APP_NAME}.entrypoints=websecure"
|
- "traefik.http.routers.${BACKEND_APP_NAME:-manage-backend}.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${BACKEND_APP_NAME}.tls.certresolver=${CERT_RESOLVER}"
|
- "traefik.http.routers.${BACKEND_APP_NAME:-manage-backend}.tls.certresolver=${CERT_RESOLVER:?set CERT_RESOLVER}"
|
||||||
- "traefik.http.services.${BACKEND_APP_NAME}.loadbalancer.server.port=${BACKEND_APP_PORT}"
|
- "traefik.http.services.${BACKEND_APP_NAME:-manage-backend}.loadbalancer.server.port=${BACKEND_APP_PORT:-8000}"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -42,14 +65,14 @@ services:
|
|||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: "/api"
|
VITE_API_URL: ${VITE_API_URL:-/api}
|
||||||
VITE_OIDC_ENABLED: ${VITE_OIDC_ENABLED:-true}
|
VITE_OIDC_ENABLED: ${VITE_OIDC_ENABLED:-true}
|
||||||
VITE_OIDC_ISSUER: ${VITE_OIDC_ISSUER}
|
VITE_OIDC_ISSUER: ${VITE_OIDC_ISSUER:?set VITE_OIDC_ISSUER}
|
||||||
VITE_OIDC_CLIENT_ID: ${VITE_OIDC_CLIENT_ID}
|
VITE_OIDC_CLIENT_ID: ${VITE_OIDC_CLIENT_ID:?set VITE_OIDC_CLIENT_ID}
|
||||||
VITE_OIDC_SCOPE: ${VITE_OIDC_SCOPE:-openid profile email}
|
VITE_OIDC_SCOPE: ${VITE_OIDC_SCOPE:-openid profile email}
|
||||||
VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI}
|
VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI:?set VITE_OIDC_REDIRECT_URI}
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
||||||
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -57,10 +80,10 @@ services:
|
|||||||
- web
|
- web
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.${FRONTEND_APP_NAME}.rule=Host(`${FRONTEND_APP_HOST}`)"
|
- "traefik.http.routers.${FRONTEND_APP_NAME:-manage-frontend}.rule=Host(`${FRONTEND_APP_HOST:?set FRONTEND_APP_HOST}`)"
|
||||||
- "traefik.http.routers.${FRONTEND_APP_NAME}.entrypoints=websecure"
|
- "traefik.http.routers.${FRONTEND_APP_NAME:-manage-frontend}.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${FRONTEND_APP_NAME}.tls.certresolver=${CERT_RESOLVER}"
|
- "traefik.http.routers.${FRONTEND_APP_NAME:-manage-frontend}.tls.certresolver=${CERT_RESOLVER:?set CERT_RESOLVER}"
|
||||||
- "traefik.http.services.${FRONTEND_APP_NAME}.loadbalancer.server.port=${FRONTEND_APP_PORT}"
|
- "traefik.http.services.${FRONTEND_APP_NAME:-manage-frontend}.loadbalancer.server.port=${FRONTEND_APP_PORT:-80}"
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+19
-2
@@ -128,19 +128,23 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
|||||||
|
|
||||||
### Dashboard / Server Monitoring
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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 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.
|
- 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 CPU and RAM usage for the last hour.
|
||||||
- Show IO wait percentage 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`.
|
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
|
||||||
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
|
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
|
||||||
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
|
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
|
||||||
|
- 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.
|
- 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.
|
- 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.
|
- 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: 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: 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-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.
|
||||||
|
|||||||
+11
-3
@@ -45,18 +45,26 @@ Output goes to `frontend/dist/`.
|
|||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
- **Dashboard** (`/`) — Now playing, server overview, library stats
|
- **Dashboard** (`/`) — Now playing, backend-collected per-machine monitoring table with 10-minute averages/min/max, library stats
|
||||||
- **Monitoring** (`/monitoring`) — CPU/IO wait/RAM/network/disk charts, collector controls
|
- **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
|
- **Media** (`/media`) — Full-library table with sort/filter/search
|
||||||
- **Users** (`/users`) — Read-only Jellyfin user list with optional Jellyseerr enrichment
|
- **Users** (`/users`) — Read-only Jellyfin user list with optional Jellyseerr enrichment
|
||||||
- **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs
|
- **File Browser** (`/files`) — Remote directory browsing, ffprobe preview, jobs
|
||||||
|
- **Settings** (`/settings`) — Persistent monitoring machine definitions and setup workflow
|
||||||
|
|
||||||
## Environment Variables
|
## 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
|
```bash
|
||||||
VITE_API_URL=http://your-backend-host:8000
|
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.
|
In development, the Vite proxy handles `/api` requests automatically.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { AuthProvider, useAuth } from "react-oidc-context";
|
|||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Monitoring } from "./pages/Monitoring";
|
import { Monitoring } from "./pages/Monitoring";
|
||||||
import { Media } from "./pages/Media";
|
import { Media } from "./pages/Media";
|
||||||
|
import { Settings } from "./pages/Settings";
|
||||||
import { UsersPage } from "./pages/Users";
|
import { UsersPage } from "./pages/Users";
|
||||||
import { FileBrowser } from "./pages/FileBrowser";
|
import { FileBrowser } from "./pages/FileBrowser";
|
||||||
import { getAppTheme } from "./theme";
|
import { getAppTheme } from "./theme";
|
||||||
@@ -175,6 +176,12 @@ function Shell({
|
|||||||
<Tab value="/media" label="Media" component={NavLink} to="/media" />
|
<Tab value="/media" label="Media" component={NavLink} to="/media" />
|
||||||
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
||||||
<Tab value="/files" label="Files" component={NavLink} to="/files" />
|
<Tab value="/files" label="Files" component={NavLink} to="/files" />
|
||||||
|
<Tab
|
||||||
|
value="/settings"
|
||||||
|
label="Settings"
|
||||||
|
component={NavLink}
|
||||||
|
to="/settings"
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
@@ -188,6 +195,7 @@ function Shell({
|
|||||||
<Route path="/media" element={<Media />} />
|
<Route path="/media" element={<Media />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/files" element={<FileBrowser />} />
|
<Route path="/files" element={<FileBrowser />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ import type {
|
|||||||
UserMessageResponse,
|
UserMessageResponse,
|
||||||
UserMessageQueueStatus,
|
UserMessageQueueStatus,
|
||||||
NowPlayingSession,
|
NowPlayingSession,
|
||||||
|
MonitoringPollerStatus,
|
||||||
|
MonitoringOverviewResponse,
|
||||||
MonitoringStatus,
|
MonitoringStatus,
|
||||||
MonitoringMetrics,
|
MonitoringMetrics,
|
||||||
DiskSpace,
|
DiskSpace,
|
||||||
|
MonitoringMachine,
|
||||||
|
MonitoringMachineInput,
|
||||||
|
MonitoringMachineAction,
|
||||||
MediaIndexStatus,
|
MediaIndexStatus,
|
||||||
MediaIndexActionResponse,
|
MediaIndexActionResponse,
|
||||||
MediaQueryResponse,
|
MediaQueryResponse,
|
||||||
@@ -109,6 +114,17 @@ async function postForm<T>(path: string, body: FormData): Promise<T> {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function del<T>(path: string): Promise<T> {
|
||||||
|
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
|
// Dashboard
|
||||||
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
|
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
|
||||||
export const fetchLibraries = () =>
|
export const fetchLibraries = () =>
|
||||||
@@ -121,23 +137,80 @@ export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
|
|||||||
export const fetchNowPlaying = fetchActivity;
|
export const fetchNowPlaying = fetchActivity;
|
||||||
|
|
||||||
// Monitoring
|
// Monitoring
|
||||||
export const fetchMonitoringStatus = () =>
|
export const fetchMonitoringMachines = () =>
|
||||||
get<MonitoringStatus>("/api/monitoring/status");
|
get<MonitoringMachine[]>("/api/monitoring/machines");
|
||||||
|
export const fetchMonitoringPoller = () =>
|
||||||
|
get<MonitoringPollerStatus>("/api/monitoring/poller");
|
||||||
|
export const fetchMonitoringOverview = () =>
|
||||||
|
get<MonitoringOverviewResponse>("/api/dashboard/monitoring");
|
||||||
|
export const fetchMonitoringStatus = (machineId?: string) =>
|
||||||
|
get<MonitoringStatus>(
|
||||||
|
"/api/monitoring/status",
|
||||||
|
machineId ? { machine_id: machineId } : undefined,
|
||||||
|
);
|
||||||
export const fetchMonitoringMetrics = (
|
export const fetchMonitoringMetrics = (
|
||||||
lastSeconds?: number | null,
|
lastSeconds?: number | null,
|
||||||
maxLines = 70_000,
|
maxLines = 70_000,
|
||||||
|
machineId?: string,
|
||||||
) =>
|
) =>
|
||||||
get<MonitoringMetrics>("/api/monitoring/metrics", {
|
get<MonitoringMetrics>("/api/monitoring/metrics", {
|
||||||
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
|
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
|
||||||
max_lines: String(maxLines),
|
max_lines: String(maxLines),
|
||||||
|
...(machineId ? { machine_id: machineId } : {}),
|
||||||
});
|
});
|
||||||
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
|
export const fetchDiskSpace = (machineId?: string) =>
|
||||||
export const startCollector = () =>
|
get<DiskSpace>(
|
||||||
post<{ message: string }>("/api/monitoring/start");
|
"/api/monitoring/disk",
|
||||||
export const stopCollector = () =>
|
machineId ? { machine_id: machineId } : undefined,
|
||||||
post<{ message: string }>("/api/monitoring/stop");
|
);
|
||||||
export const restartCollector = () =>
|
export const startCollector = (machineId?: string) =>
|
||||||
post<{ message: string }>("/api/monitoring/restart");
|
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<MonitoringMachine[]>("/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<MonitoringMachine>;
|
||||||
|
});
|
||||||
|
export const deleteMonitoringMachine = (machineId: string) =>
|
||||||
|
del<{ status: string }>(
|
||||||
|
`/api/settings/machines/${encodeURIComponent(machineId)}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
export const fetchMediaStatus = () =>
|
export const fetchMediaStatus = () =>
|
||||||
|
|||||||
@@ -79,9 +79,19 @@ export function SessionActivityPanel({
|
|||||||
<TableContainer
|
<TableContainer
|
||||||
component={Paper}
|
component={Paper}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1, overflowX: "auto" }}
|
sx={{
|
||||||
|
maxHeight: 280,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 1,
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Table size="small" stickyHeader aria-label="Session activity details">
|
<Table
|
||||||
|
size="small"
|
||||||
|
stickyHeader
|
||||||
|
aria-label="Session activity details"
|
||||||
|
sx={{ minWidth: 880 }}
|
||||||
|
>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
@@ -198,7 +208,7 @@ export function SessionActivityPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
|
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
noWrap
|
noWrap
|
||||||
@@ -210,12 +220,12 @@ export function SessionActivityPanel({
|
|||||||
{session.type || "—"}
|
{session.type || "—"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
|
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||||
<Typography variant="body2" noWrap>
|
<Typography variant="body2" noWrap>
|
||||||
{session.device || "Unknown device"}
|
{session.device || "Unknown device"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap", display: { xs: "none", md: "table-cell" } }}>
|
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||||
<Typography variant="body2" noWrap>
|
<Typography variant="body2" noWrap>
|
||||||
{session.transcoding === "yes"
|
{session.transcoding === "yes"
|
||||||
? session.transcoding_type
|
? session.transcoding_type
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client";
|
import {
|
||||||
|
fetchActivity,
|
||||||
|
fetchCounts,
|
||||||
|
fetchLibraries,
|
||||||
|
fetchMonitoringOverview,
|
||||||
|
} from "../api/client";
|
||||||
|
|
||||||
export function useCounts() {
|
export function useCounts() {
|
||||||
return useQuery({
|
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.
|
// Backward-compatible alias used by older code.
|
||||||
export const useNowPlaying = useActivity;
|
export const useNowPlaying = useActivity;
|
||||||
|
|||||||
@@ -1,52 +1,83 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
fetchMonitoringStatus,
|
fetchMonitoringStatus,
|
||||||
fetchMonitoringMetrics,
|
fetchMonitoringMetrics,
|
||||||
fetchDiskSpace,
|
fetchDiskSpace,
|
||||||
|
fetchMonitoringMachines,
|
||||||
|
fetchMonitoringMachineActions,
|
||||||
|
fetchMonitoringPoller,
|
||||||
startCollector,
|
startCollector,
|
||||||
stopCollector,
|
stopCollector,
|
||||||
restartCollector,
|
restartCollector,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
export function useMonitoringStatus() {
|
export function useMonitoringMachines() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["monitoring", "status"],
|
queryKey: ["monitoring", "machines"],
|
||||||
queryFn: fetchMonitoringStatus,
|
queryFn: fetchMonitoringMachines,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMonitoringMetrics() {
|
export function useMonitoringPoller() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["monitoring", "metrics"],
|
queryKey: ["monitoring", "poller"],
|
||||||
queryFn: () => fetchMonitoringMetrics(),
|
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,
|
refetchInterval: 15_000,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDiskSpace() {
|
export function useDiskSpace(machineId?: string, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["monitoring", "disk"],
|
queryKey: ["monitoring", "disk", machineId ?? "default"],
|
||||||
queryFn: fetchDiskSpace,
|
queryFn: () => fetchDiskSpace(machineId),
|
||||||
staleTime: 60_000,
|
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 queryClient = useQueryClient();
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||||
|
|
||||||
const start = useMutation({
|
const start = useMutation({
|
||||||
mutationFn: startCollector,
|
mutationFn: () => startCollector(machineId),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
const stop = useMutation({
|
const stop = useMutation({
|
||||||
mutationFn: stopCollector,
|
mutationFn: () => stopCollector(machineId),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
const restart = useMutation({
|
const restart = useMutation({
|
||||||
mutationFn: restartCollector,
|
mutationFn: () => restartCollector(machineId),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,22 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
|
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
|
import {
|
||||||
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
|
useCounts,
|
||||||
|
useLibraries,
|
||||||
|
useActivity,
|
||||||
|
useMonitoringOverview,
|
||||||
|
} from "../hooks/useDashboard";
|
||||||
import { NowPlaying } from "../components/NowPlaying";
|
import { NowPlaying } from "../components/NowPlaying";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
import { DiskSpaceCard } from "../components/DiskSpaceCard";
|
|
||||||
import { LibraryOverview } from "../components/LibraryOverview";
|
import { LibraryOverview } from "../components/LibraryOverview";
|
||||||
|
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: counts } = useCounts();
|
const { data: counts } = useCounts();
|
||||||
const { data: libraries } = useLibraries();
|
const { data: libraries } = useLibraries();
|
||||||
const { data: activity } = useActivity();
|
const { data: activity } = useActivity();
|
||||||
const { data: metrics } = useMonitoringMetrics();
|
const { data: monitoringOverview } = useMonitoringOverview();
|
||||||
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),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
@@ -101,95 +40,7 @@ export function Dashboard() {
|
|||||||
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||||
Monitoring Overview
|
Monitoring Overview
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2} sx={{ alignItems: "stretch" }}>
|
<MonitoringOverviewTable overview={monitoringOverview} />
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="CPU (10m avg)"
|
|
||||||
value={cpuSummary ? formatPct(cpuSummary.avg) : "-"}
|
|
||||||
subtext={
|
|
||||||
cpuSummary
|
|
||||||
? `High: ${formatPct(cpuSummary.max)}\nLow: ${formatPct(cpuSummary.min)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="IO Wait (10m avg)"
|
|
||||||
value={iowaitSummary ? formatPct(iowaitSummary.avg) : "-"}
|
|
||||||
subtext={
|
|
||||||
iowaitSummary
|
|
||||||
? `High: ${formatPct(iowaitSummary.max)}\nLow: ${formatPct(iowaitSummary.min)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="RAM (10m avg)"
|
|
||||||
value={memSummary ? formatPct(memSummary.avg) : "-"}
|
|
||||||
subtext={
|
|
||||||
memSummary
|
|
||||||
? `High: ${formatPct(memSummary.max)}\nLow: ${formatPct(memSummary.min)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Net down (10m avg)"
|
|
||||||
value={netRxSummary ? formatRate(netRxSummary.avg) : "-"}
|
|
||||||
subtext={
|
|
||||||
netRxSummary
|
|
||||||
? `High: ${formatRate(netRxSummary.max)}\nLow: ${formatRate(netRxSummary.min)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Net up (10m avg)"
|
|
||||||
value={netTxSummary ? formatRate(netTxSummary.avg) : "-"}
|
|
||||||
subtext={
|
|
||||||
netTxSummary
|
|
||||||
? `High: ${formatRate(netTxSummary.max)}\nLow: ${formatRate(netTxSummary.min)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Disk read (10m avg)"
|
|
||||||
value={diskReadSummary ? formatRate(diskReadSummary.avg) : "-"}
|
|
||||||
subtext={
|
|
||||||
diskReadSummary
|
|
||||||
? `High: ${formatRate(diskReadSummary.max)}\nLow: ${formatRate(diskReadSummary.min)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Disk write (10m avg)"
|
|
||||||
value={diskWriteSummary ? formatRate(diskWriteSummary.avg) : "-"}
|
|
||||||
subtext={
|
|
||||||
diskWriteSummary
|
|
||||||
? `High: ${formatRate(diskWriteSummary.max)}\nLow: ${formatRate(diskWriteSummary.min)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
{disk && (
|
|
||||||
<Box sx={{ mt: 0.5 }}>
|
|
||||||
<DiskSpaceCard
|
|
||||||
used={disk.used}
|
|
||||||
available={disk.available}
|
|
||||||
size={disk.size}
|
|
||||||
usedPct={disk.used_pct}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -1,173 +1,80 @@
|
|||||||
|
import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Box,
|
useMonitoringMachines,
|
||||||
Button,
|
useMonitoringPoller,
|
||||||
Chip,
|
|
||||||
Divider,
|
|
||||||
Grid,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
useMonitoringStatus,
|
|
||||||
useMonitoringMetrics,
|
|
||||||
useDiskSpace,
|
|
||||||
useCollectorControls,
|
|
||||||
} from "../hooks/useMonitoring";
|
} from "../hooks/useMonitoring";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MachineMonitoringSection } from "../components/MachineMonitoringSection";
|
||||||
import { MonitoringCharts } from "../components/MonitoringCharts";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Monitoring() {
|
export function Monitoring() {
|
||||||
const { data: status } = useMonitoringStatus();
|
const { data: machines, isLoading, error } = useMonitoringMachines();
|
||||||
const { data: metrics } = useMonitoringMetrics();
|
const { data: poller } = useMonitoringPoller();
|
||||||
const { data: disk } = useDiskSpace();
|
const navigate = useNavigate();
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Stack
|
<Stack spacing={0.5}>
|
||||||
direction="row"
|
<Stack
|
||||||
spacing={1.5}
|
direction="row"
|
||||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
spacing={1}
|
||||||
>
|
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||||
<Typography variant="h5">Monitoring</Typography>
|
|
||||||
<Chip
|
|
||||||
label={status?.status ?? "unknown"}
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => start.mutate()}
|
|
||||||
disabled={start.isPending}
|
|
||||||
>
|
>
|
||||||
Start
|
<Typography variant="h5">Monitoring</Typography>
|
||||||
</Button>
|
{poller && (
|
||||||
<Button
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => restart.mutate()}
|
color={poller.worker_running ? "success" : "default"}
|
||||||
disabled={restart.isPending}
|
label={
|
||||||
>
|
poller.worker_running
|
||||||
Restart
|
? `Poller running · ${poller.interval_seconds}s`
|
||||||
</Button>
|
: "Poller stopped"
|
||||||
<Button
|
}
|
||||||
size="small"
|
/>
|
||||||
variant="outlined"
|
)}
|
||||||
onClick={() => stop.mutate()}
|
</Stack>
|
||||||
disabled={stop.isPending}
|
<Typography variant="body2" color="text.secondary">
|
||||||
>
|
Each machine below is monitored with its own settings and collector
|
||||||
Stop
|
state, and recent activity is gathered automatically by the backend.
|
||||||
</Button>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Grid container spacing={1.5}>
|
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
{isLoading && (
|
||||||
<MetricCard
|
<Alert severity="info">Loading monitoring machines...</Alert>
|
||||||
label="CPU now"
|
)}
|
||||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
{!isLoading && (machines?.length ?? 0) === 0 && (
|
||||||
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
|
<Alert
|
||||||
/>
|
severity="warning"
|
||||||
</Grid>
|
action={
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
<Button
|
||||||
<MetricCard
|
color="inherit"
|
||||||
label="IO Wait"
|
size="small"
|
||||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
onClick={() => navigate("/settings")}
|
||||||
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
|
>
|
||||||
/>
|
Open Settings
|
||||||
</Grid>
|
</Button>
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
}
|
||||||
<MetricCard
|
>
|
||||||
label="RAM now"
|
No monitoring machines are configured yet.
|
||||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
</Alert>
|
||||||
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Net down"
|
|
||||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
|
||||||
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Net up"
|
|
||||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
|
||||||
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Disk read"
|
|
||||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
|
||||||
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Disk write"
|
|
||||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
|
||||||
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{disk && (
|
|
||||||
<Grid container spacing={1.5}>
|
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
|
||||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
|
||||||
<MetricCard
|
|
||||||
label="Disk available"
|
|
||||||
value={formatBytes(disk.available)}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
|
||||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 6, md: 3 }}>
|
|
||||||
<MetricCard label="Used %" value={disk.used_pct} />
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider />
|
{machines?.map((machine) => (
|
||||||
<Box>
|
<Stack
|
||||||
<MonitoringCharts samples={samples} />
|
key={machine.id}
|
||||||
</Box>
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MachineMonitoringSection machine={machine} />
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,99 @@ export interface NowPlayingSession {
|
|||||||
session_id: string;
|
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 {
|
export interface MonitoringStatus {
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user