feat(observability): add Prometheus/Grafana/Loki/Alertmanager/Alloy stack and remove legacy Monitoring UI
This commit is contained in:
+190
-125
@@ -11,23 +11,23 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.clients.ssh import CommandResult
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_ssh_client,
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
get_settings_store,
|
||||
get_ssh_client,
|
||||
get_user_id,
|
||||
)
|
||||
from media_library_viewer_api.clients.ssh import CommandResult
|
||||
from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.routers.media import get_media_index
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jellyfin():
|
||||
"""Mock Jellyfin client."""
|
||||
@@ -145,15 +145,29 @@ def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh, tmp_path):
|
||||
|
||||
# --- Health ---
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health(self, test_client):
|
||||
response = test_client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
def test_metrics_endpoint(self, test_client):
|
||||
response = test_client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "manage_api_requests_total" in response.text
|
||||
assert response.headers["content-type"].startswith("text/plain")
|
||||
|
||||
def test_request_id_header(self, test_client):
|
||||
response = test_client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert "x-request-id" in response.headers
|
||||
assert len(response.headers["x-request-id"]) > 0
|
||||
|
||||
|
||||
# --- Dashboard ---
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
def test_counts(self, test_client):
|
||||
response = test_client.get("/api/dashboard/counts")
|
||||
@@ -163,78 +177,6 @@ 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
|
||||
@@ -268,6 +210,7 @@ class TestDashboard:
|
||||
|
||||
# --- Settings reset ---
|
||||
|
||||
|
||||
class TestSettingsReset:
|
||||
def test_reset_local_database_requires_full_confirmation(self, test_client, tmp_path):
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
@@ -329,8 +272,10 @@ class TestSettingsReset:
|
||||
assert store.get_machine("local") is None
|
||||
assert len(store.list_machines()) == 0
|
||||
|
||||
|
||||
# --- Users ---
|
||||
|
||||
|
||||
class TestUsers:
|
||||
def test_users_list_enriched(self, test_client):
|
||||
response = test_client.get("/api/users")
|
||||
@@ -418,7 +363,7 @@ class TestUsers:
|
||||
)
|
||||
|
||||
try:
|
||||
with patch("media_library_viewer_api.routers.users.get_settings", return_value=settings):
|
||||
with patch("media_library_viewer_api.routers.users_impl.get_settings", return_value=settings):
|
||||
response = test_client.post(
|
||||
"/api/users/message",
|
||||
data={
|
||||
@@ -443,8 +388,10 @@ class TestUsers:
|
||||
assert kwargs["subject"] == "Hello team"
|
||||
assert kwargs["settings"] is settings
|
||||
|
||||
|
||||
# --- Files ---
|
||||
|
||||
|
||||
class TestFiles:
|
||||
def test_list_directory(self, test_client):
|
||||
response = test_client.get("/api/files/list", params={"path": "/media"})
|
||||
@@ -486,6 +433,7 @@ class TestFiles:
|
||||
|
||||
# --- Media index build ---
|
||||
|
||||
|
||||
class TestMediaIndexApi:
|
||||
def test_status_includes_build_progress(self, test_client, tmp_path):
|
||||
index = MediaIndex(tmp_path / "index.sqlite")
|
||||
@@ -586,9 +534,11 @@ class TestMediaIndexApi:
|
||||
alive_calls["count"] += 1
|
||||
return alive_calls["count"] <= 2
|
||||
|
||||
with patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive), patch(
|
||||
"media_library_viewer_api.routers.media.os.killpg"
|
||||
) as killpg, patch("media_library_viewer_api.routers.media.time.sleep", return_value=None):
|
||||
with (
|
||||
patch("media_library_viewer_api.routers.media._pid_is_alive", side_effect=fake_pid_is_alive),
|
||||
patch("media_library_viewer_api.routers.media.os.killpg") as killpg,
|
||||
patch("media_library_viewer_api.routers.media.time.sleep", return_value=None),
|
||||
):
|
||||
try:
|
||||
response = test_client.post("/api/media/force-stop")
|
||||
assert response.status_code == 202
|
||||
@@ -615,6 +565,7 @@ class TestMediaIndexApi:
|
||||
|
||||
# --- Jobs ---
|
||||
|
||||
|
||||
class TestJobs:
|
||||
def test_list_templates(self, test_client):
|
||||
response = test_client.get("/api/jobs/templates")
|
||||
@@ -645,37 +596,21 @@ class TestJobs:
|
||||
|
||||
# --- Monitoring ---
|
||||
|
||||
|
||||
class TestMonitoring:
|
||||
def _ensure_machine(self):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
if not store.list_machines():
|
||||
store.upsert_machine({
|
||||
"name": "Test Machine",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring", "files", "jellyfin"],
|
||||
"host": "test-host",
|
||||
"username": "test-user",
|
||||
})
|
||||
|
||||
def test_status(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="running pid=1234\n", stderr=""
|
||||
)
|
||||
response = test_client.get("/api/monitoring/status")
|
||||
assert response.status_code == 200
|
||||
assert "running" in response.json()["status"]
|
||||
|
||||
def test_metrics_empty(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="", stderr=""
|
||||
)
|
||||
response = test_client.get("/api/monitoring/metrics")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["samples"] == []
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "Test Machine",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring", "files", "jellyfin"],
|
||||
"host": "test-host",
|
||||
"username": "test-user",
|
||||
}
|
||||
)
|
||||
|
||||
def test_disk(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
@@ -685,32 +620,162 @@ class TestMonitoring:
|
||||
stdout='{"filesystem":"/dev/sda1","size":1000000000,"used":500000000,"available":500000000,"used_pct":"50%","mount":"/"}',
|
||||
stderr="",
|
||||
)
|
||||
response = test_client.get("/api/monitoring/disk")
|
||||
with patch("media_library_viewer_api.services.monitoring_actions.build_machine_client", return_value=mock_ssh):
|
||||
response = test_client.get("/api/monitoring/disk")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["used_pct"] == "50%"
|
||||
|
||||
def test_start(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="started pid=5678\n", stderr=""
|
||||
)
|
||||
response = test_client.post("/api/monitoring/start")
|
||||
def test_prometheus_targets_empty(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||
assert response.status_code == 200
|
||||
assert "started" in response.json()["message"]
|
||||
assert response.json() == []
|
||||
|
||||
def test_stop(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="stopped pid=5678\n", stderr=""
|
||||
def test_prometheus_targets_returns_enabled_ssh_node_exporter(self, test_client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
}
|
||||
)
|
||||
response = test_client.post("/api/monitoring/stop")
|
||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["targets"] == ["1.2.3.4:9200"]
|
||||
assert data[0]["labels"]["job"] == "node-exporter-remote"
|
||||
|
||||
def test_restart(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="...", exit_status=0, stdout="stopped pid=5678\nstarted pid=9999\n", stderr=""
|
||||
|
||||
class TestSettingsMachines:
|
||||
def test_post_machine_rewrites_prometheus_targets(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
||||
with patch("media_library_viewer_api.routers.settings._validate_saved_machine_ssh"):
|
||||
response = test_client.post(
|
||||
"/api/settings/machines",
|
||||
json={
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
"node_exporter_enabled": True,
|
||||
"node_exporter_port": 9200,
|
||||
"node_exporter_scrape_host": "1.2.3.4",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
write_targets.assert_called_once()
|
||||
|
||||
def test_delete_machine_rewrites_prometheus_targets(self, test_client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
machine = store.upsert_machine(
|
||||
{
|
||||
"name": "remote1",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring"],
|
||||
"host": "10.0.0.5",
|
||||
"username": "u",
|
||||
}
|
||||
)
|
||||
response = test_client.post("/api/monitoring/restart")
|
||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
||||
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
||||
assert response.status_code == 200
|
||||
write_targets.assert_called_once()
|
||||
|
||||
|
||||
class TestAlertmanager:
|
||||
def test_alerts_endpoint_when_alertmanager_unreachable(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = Exception("connection refused")
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["error"] == "alertmanager_unreachable"
|
||||
|
||||
def test_alerts_endpoint_returns_summary(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.return_value.json.return_value = {
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
|
||||
"annotations": {"summary": "Backup failed", "description": "details"},
|
||||
"startsAt": "2026-05-11T02:00:00Z",
|
||||
"status": "firing",
|
||||
}
|
||||
],
|
||||
}
|
||||
session.get.return_value.raise_for_status = MagicMock()
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["by_severity"]["critical"] == 1
|
||||
assert data["alerts"][0]["name"] == "BackupJobFailed"
|
||||
|
||||
def test_alertmanager_status_endpoint_when_unreachable(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = Exception("connection refused")
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
|
||||
|
||||
class TestAlertmanagerWebhook:
|
||||
"""Tests for POST /api/monitoring/alertmanager-webhook."""
|
||||
|
||||
def test_webhook_receives_payload_and_logs(self, test_client, caplog):
|
||||
payload = {
|
||||
"status": "firing",
|
||||
"alerts": [
|
||||
{
|
||||
"status": "firing",
|
||||
"labels": {"alertname": "BackupJobFailed", "severity": "critical"},
|
||||
"annotations": {"summary": "Backup failed"},
|
||||
"startsAt": "2026-06-13T10:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
|
||||
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "received"
|
||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||
|
||||
def test_webhook_forwards_when_configured(self, test_client, caplog, monkeypatch):
|
||||
payload = {"status": "resolved", "alerts": []}
|
||||
forwarded = {"captured": False}
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
forwarded["captured"] = True
|
||||
forwarded["url"] = url
|
||||
forwarded["payload"] = json
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("requests.Session.post", lambda _self, url, json, timeout: fake_post(url, json, timeout))
|
||||
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
|
||||
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert forwarded["captured"] is False
|
||||
|
||||
Reference in New Issue
Block a user