feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog
Remove the entire Grafana surface: integrations/grafana.py, GrafanaWidgetSource (+ _fetch_chart, now redundant since prometheus chart exists), GrafanaLinkWidget, LinksTab, get_grafana_status endpoint, useGrafanaStatus hook, GrafanaStatus type, fetchGrafanaStatus client fn, registry/nav/tab entries (FE+BE). Rewrite config.yaml thin-dashboard rule to match reality (recharts is sanctioned for Prometheus-backed series). CHANGELOG migration note added. Backend: 293 pytest pass, ruff clean. Frontend: build+lint green (0 errors). SC-115/116 grep-clean (only prometheus_range.py migration comments + Dashboard.test shortcut fixture remain — both spec-allowed).
This commit is contained in:
@@ -4,6 +4,24 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — Direct Prometheus charting
|
||||
|
||||
- **Prometheus is now the direct source for in-app charts.** New widget kinds
|
||||
on the `prometheus` service: `chart` (multi-series line chart via recharts,
|
||||
backed by `/api/v1/query_range`), `gauge` (instant scalar with configurable
|
||||
threshold bands), and `mean` (client-side average over a time window).
|
||||
|
||||
### **BREAKING** — Grafana service type removed
|
||||
|
||||
- The `grafana` service type, Grafana link widget, Grafana chart widget, and
|
||||
`GET /api/monitoring/grafana-status` endpoint were **removed**. Manage now
|
||||
queries Prometheus directly for all chart data.
|
||||
- **Migration:** Delete any existing Grafana service instances and create
|
||||
Prometheus service instances instead (pointing at your Prometheus URL). Any
|
||||
configured `grafana/chart` widgets must be recreated as `prometheus/chart`
|
||||
widgets. Grafana link widgets are gone — use Prometheus chart/metric widgets
|
||||
instead.
|
||||
|
||||
### Added — Observability service registry
|
||||
|
||||
- **Alertmanager is now a service type.** Configure Alertmanager, Grafana, and
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||
the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
|
||||
|
||||
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||
* the secret fields it accepts (API keys / tokens),
|
||||
@@ -24,7 +24,7 @@ from pydantic import BaseModel, BeforeValidator, Field
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
Relative hosts (e.g. ``example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Grafana service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
"""Deep-link to a Grafana dashboard or panel."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
class GrafanaChartWidgetConfig(WidgetConfigBase):
|
||||
"""Render a time-series chart from a Grafana datasource query."""
|
||||
|
||||
datasource_uid: str = "prometheus"
|
||||
query: str = ""
|
||||
from_ts: str = "now-1h"
|
||||
to_ts: str = "now"
|
||||
interval_ms: int = 30_000
|
||||
max_data_points: int = 100
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_model=GrafanaConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
model_cls=GrafanaLinkWidgetConfig,
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
widget_kind(
|
||||
kind="chart",
|
||||
name="Chart",
|
||||
description="Live time-series chart from a Grafana datasource query.",
|
||||
model_cls=GrafanaChartWidgetConfig,
|
||||
default_config={
|
||||
"datasource_uid": "prometheus",
|
||||
"query": "",
|
||||
"from_ts": "now-1h",
|
||||
"to_ts": "now",
|
||||
"interval_ms": 30_000,
|
||||
"max_data_points": 100,
|
||||
},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -10,14 +10,12 @@ from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALE
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Widgets are either:
|
||||
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||
by that service's definition (Grafana link, Prometheus metric, Jellyfin
|
||||
by that service's definition (Prometheus metric, Jellyfin
|
||||
activity, SSH task output); or
|
||||
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
|
||||
service-less kinds (backups, static).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Monitoring router — observability service status.
|
||||
|
||||
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
||||
Observability components (Alertmanager, Prometheus) are resolved from
|
||||
the service registry, not environment variables. The endpoints pick the first
|
||||
enabled instance of a type when no ``service_id`` is given, and return graceful
|
||||
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
||||
@@ -172,29 +172,6 @@ def get_alertmanager_status(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grafana-status")
|
||||
def get_grafana_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||
service = resolve_service_record(store, "grafana", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/health",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Grafana status")
|
||||
return _status_response(service, error="grafana_unreachable")
|
||||
return _status_response(service, version=data.get("version", ""))
|
||||
|
||||
|
||||
@router.get("/prometheus-status")
|
||||
def get_prometheus_status(
|
||||
service_id: str | None = None,
|
||||
|
||||
@@ -81,7 +81,7 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
|
||||
# metrics now live in Prometheus/node_exporter. Drop the orphan
|
||||
# table on startup so existing databases get a clean slate.
|
||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||
conn.execute(
|
||||
|
||||
@@ -102,118 +102,6 @@ class StaticWidgetSource:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GrafanaWidgetSource:
|
||||
"""Build a Grafana deep-link or query datasource for a chart."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Grafana widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_key = str(service.secrets.get("api_key") or "")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(base_url, api_key, timeout, config)
|
||||
|
||||
# Default: deep-link
|
||||
dashboard_uid = config.get("dashboard_uid")
|
||||
if not dashboard_uid:
|
||||
return {"error": "dashboard_uid is required"}
|
||||
url = f"{base_url}/d/{dashboard_uid}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
url = f"{url}?viewPanel={panel_id}"
|
||||
return {"url": url}
|
||||
except Exception as exc:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
async def _fetch_chart(self, base_url: str, api_key: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Query Grafana /api/ds/query and normalize to {series: [...]}."""
|
||||
if not api_key:
|
||||
return {"error": "Grafana api_key is required for chart queries"}
|
||||
query = config.get("query", "")
|
||||
if not query:
|
||||
return {"error": "query is required"}
|
||||
|
||||
datasource_uid = config.get("datasource_uid", "prometheus")
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": query,
|
||||
"format": "time_series",
|
||||
"intervalMs": int(config.get("interval_ms", 30_000)),
|
||||
"maxDataPoints": int(config.get("max_data_points", 100)),
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": config.get("from_ts", "now-1h"),
|
||||
"to": config.get("to_ts", "now"),
|
||||
}
|
||||
|
||||
def _do_post() -> dict[str, Any]:
|
||||
resp = requests.post(
|
||||
f"{base_url}/api/ds/query",
|
||||
json=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
try:
|
||||
raw = await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Grafana query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
return {"error": f"Grafana query failed: {exc}"}
|
||||
|
||||
# Normalize Grafana's /api/ds/query response into series.
|
||||
series: list[dict[str, Any]] = []
|
||||
results = raw.get("results", {})
|
||||
seen_labels: dict[str, int] = {}
|
||||
for ref_id, ref_data in results.items():
|
||||
for frame in ref_data.get("frames", []):
|
||||
values = frame.get("data", {}).get("values", [])
|
||||
if len(values) < 2:
|
||||
continue
|
||||
timestamps = values[0]
|
||||
vals = values[1]
|
||||
# Derive a meaningful series label from the frame metadata.
|
||||
# Prometheus frames carry metric labels in schema.fields[-1].labels.
|
||||
fields = frame.get("schema", {}).get("fields", [])
|
||||
value_field = fields[-1] if fields else {}
|
||||
# Prefer displayName (explicitly set in Grafana), then Prometheus
|
||||
# labels (e.g. {instance: "server:9100", mode: "iowait"}), then
|
||||
# the field name as a last resort.
|
||||
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
|
||||
frame_labels = value_field.get("labels") or {}
|
||||
if display_name:
|
||||
label = str(display_name)
|
||||
elif frame_labels:
|
||||
# Build a readable label from the Prometheus labels, excluding
|
||||
# redundant ones like __name__.
|
||||
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not k.startswith("__")]
|
||||
label = " ".join(parts) if parts else "value"
|
||||
else:
|
||||
label = value_field.get("name", "value")
|
||||
# Ensure unique labels when multiple series share the same name.
|
||||
if label in seen_labels:
|
||||
seen_labels[label] += 1
|
||||
label = f"{label} ({seen_labels[label]})"
|
||||
else:
|
||||
seen_labels[label] = 0
|
||||
points = [{"t": int(t), "v": float(v) if v is not None else None} for t, v in zip(timestamps, vals)]
|
||||
series.append({"label": label, "points": points})
|
||||
|
||||
return {"series": series}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run PromQL queries against a Prometheus service (instant + range)."""
|
||||
|
||||
@@ -485,7 +373,6 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
|
||||
@@ -522,7 +522,7 @@ class TestResolveServiceRecord:
|
||||
def test_service_id_type_mismatch_returns_none(self):
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
||||
row = {"id": "x1", "service_type": "prometheus", "name": "P", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert resolve_service_record(store, "alertmanager", "x1") is None
|
||||
|
||||
@@ -741,48 +741,6 @@ class TestAlertmanagerWebhook:
|
||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||
|
||||
|
||||
class TestGrafanaStatus:
|
||||
def test_grafana_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "no_service_configured"
|
||||
|
||||
def test_grafana_status_when_unreachable(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
with (
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "grafana_unreachable"
|
||||
assert data["name"] == "Grafana"
|
||||
|
||||
def test_grafana_status_returns_version(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is True
|
||||
assert data["version"] == "11.3.1"
|
||||
assert data["service_id"] == "g1"
|
||||
|
||||
|
||||
class TestPrometheusStatus:
|
||||
def test_prometheus_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
|
||||
@@ -57,9 +57,8 @@ def client(tmp_path):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_contains_eight_service_types():
|
||||
def test_registry_contains_seven_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
@@ -99,7 +98,6 @@ def test_authentik_service_definition():
|
||||
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
|
||||
@@ -110,13 +108,13 @@ def test_definitions_declare_widget_kinds():
|
||||
|
||||
|
||||
def test_widget_kind_lookup():
|
||||
assert get_widget_kind("grafana", "link") is not None
|
||||
assert get_widget_kind("grafana", "missing") is None
|
||||
assert get_widget_kind("unknown", "link") is None
|
||||
assert get_widget_kind("prometheus", "metric") is not None
|
||||
assert get_widget_kind("prometheus", "missing") is None
|
||||
assert get_widget_kind("unknown", "metric") is None
|
||||
|
||||
|
||||
def test_service_config_schema_is_json_schema():
|
||||
schema = get_service_definition("grafana").config_schema
|
||||
schema = get_service_definition("prometheus").config_schema
|
||||
assert schema["type"] == "object"
|
||||
assert "base_url" in schema["properties"]
|
||||
|
||||
@@ -172,7 +170,6 @@ def test_list_service_types(client):
|
||||
"alertmanager",
|
||||
"authentik",
|
||||
"backups",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
@@ -182,9 +179,9 @@ def test_list_service_types(client):
|
||||
|
||||
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
response = client.get("/api/services/types")
|
||||
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
||||
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link", "chart"]
|
||||
prom = next(item for item in response.json() if item["service_type"] == "prometheus")
|
||||
assert [sf["key"] for sf in prom["secret_fields"]] == ["api_key"]
|
||||
assert set(wk["kind"] for wk in prom["widget_kinds"]) == {"metric", "chart", "gauge", "mean"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,11 +189,11 @@ def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _grafana_payload(**overrides):
|
||||
def _prometheus_payload(**overrides):
|
||||
payload = {
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "secret-token"},
|
||||
"enabled": True,
|
||||
}
|
||||
@@ -205,11 +202,11 @@ def _grafana_payload(**overrides):
|
||||
|
||||
|
||||
def test_create_and_list_service(client):
|
||||
response = client.post("/api/services/instances", json=_grafana_payload())
|
||||
response = client.post("/api/services/instances", json=_prometheus_payload())
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_type"] == "grafana"
|
||||
assert created["config"]["base_url"] == "https://grafana.example.com"
|
||||
assert created["service_type"] == "prometheus"
|
||||
assert created["config"]["base_url"] == "https://prometheus.example.com"
|
||||
# Plaintext secrets are never returned.
|
||||
assert "secrets" not in created
|
||||
assert created["secrets_set"] == {"api_key": True}
|
||||
@@ -220,44 +217,44 @@ def test_create_and_list_service(client):
|
||||
|
||||
|
||||
def test_list_instances_filters_by_type(client):
|
||||
client.post("/api/services/instances", json=_grafana_payload())
|
||||
client.post("/api/services/instances", json=_prometheus_payload())
|
||||
client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "prometheus",
|
||||
"name": "Prom",
|
||||
"config": {"base_url": "http://prometheus:9090"},
|
||||
"service_type": "alertmanager",
|
||||
"name": "AM",
|
||||
"config": {"base_url": "http://am:9093"},
|
||||
},
|
||||
)
|
||||
response = client.get("/api/services/instances?service_type=grafana")
|
||||
response = client.get("/api/services/instances?service_type=prometheus")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["service_type"] == "grafana"
|
||||
assert response.json()[0]["service_type"] == "prometheus"
|
||||
|
||||
|
||||
def test_update_service_preserves_unsent_secrets(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
# Update without sending secrets; the existing key should remain set.
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Renamed Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||
"service_type": "prometheus",
|
||||
"name": "Renamed Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com", "timeout_seconds": 10},
|
||||
},
|
||||
).json()
|
||||
assert updated["name"] == "Renamed Grafana"
|
||||
assert updated["name"] == "Renamed Prometheus"
|
||||
assert updated["secrets_set"] == {"api_key": True}
|
||||
|
||||
|
||||
def test_update_service_can_clear_secret(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": ""},
|
||||
},
|
||||
).json()
|
||||
@@ -275,30 +272,28 @@ def test_unknown_service_type_rejected(client):
|
||||
def test_invalid_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"base_url": ""}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
# Force a real validation error via bad type.
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
|
||||
"bad_url", ["prometheus.example.com", "localhost:3000", "//bad.example.com", "ftp://bad.example.com"]
|
||||
)
|
||||
def test_service_base_url_requires_http_schema(bad_url):
|
||||
"""Every service base_url must include an http:// or https:// schema."""
|
||||
model = get_service_definition("grafana").config_model
|
||||
model = get_service_definition("prometheus").config_model
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
|
||||
)
|
||||
@pytest.mark.parametrize("service_type", ["prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"])
|
||||
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||
model = get_service_definition(service_type).config_model
|
||||
instance = model.model_validate({"base_url": "https://example.com"})
|
||||
@@ -309,9 +304,9 @@ def test_unknown_secret_field_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"password": "leak"},
|
||||
},
|
||||
)
|
||||
@@ -322,9 +317,9 @@ def test_credential_key_in_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
|
||||
"config": {"base_url": "https://prometheus.example.com", "api_key": "leak"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -333,22 +328,22 @@ def test_credential_key_in_config_rejected(client):
|
||||
def test_update_nonexistent_returns_404(client):
|
||||
response = client.put(
|
||||
"/api/services/instances/missing",
|
||||
json=_grafana_payload(id="missing"),
|
||||
json=_prometheus_payload(id="missing"),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_id_mismatch_returns_400(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json=_grafana_payload(id="other-id"),
|
||||
json=_prometheus_payload(id="other-id"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_delete_service(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.delete(f"/api/services/instances/{created['id']}")
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/services/instances").json() == []
|
||||
@@ -372,7 +367,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
"""
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
service = store.upsert_service(
|
||||
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
|
||||
{"service_type": "prometheus", "name": "Prometheus", "config": {"base_url": "u"}, "enabled": True}
|
||||
)
|
||||
|
||||
# Ensure the service_id column exists and seed a referencing widget.
|
||||
@@ -386,7 +381,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
enabled, sort_order, created_at, updated_at, service_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
("w1", "prometheus", "prometheus.metric", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
)
|
||||
|
||||
store.delete_service(service["id"])
|
||||
|
||||
+50
-250
@@ -15,7 +15,6 @@ from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
AlertmanagerWidgetSource,
|
||||
BackupsWidgetSource,
|
||||
GrafanaWidgetSource,
|
||||
JellyfinWidgetSource,
|
||||
ServiceRecord,
|
||||
StaticWidgetSource,
|
||||
@@ -42,12 +41,12 @@ def client(tmp_path):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
|
||||
config = {"base_url": "https://grafana.example.com"}
|
||||
def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
|
||||
config = {"base_url": "https://prometheus.example.com"}
|
||||
config.update(config_overrides)
|
||||
return client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
|
||||
json={"service_type": "prometheus", "name": name, "config": config, "enabled": True},
|
||||
).json()
|
||||
|
||||
|
||||
@@ -93,7 +92,7 @@ def test_create_backups_widget(client):
|
||||
|
||||
def test_widget_filtering_by_service_id_and_scope(client):
|
||||
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
|
||||
client.post(
|
||||
"/api/widgets/instances",
|
||||
@@ -103,9 +102,9 @@ def test_widget_filtering_by_service_id_and_scope(client):
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "Dash",
|
||||
"config": {"dashboard_uid": "o"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -151,29 +150,29 @@ def test_credential_key_in_config_rejected(client):
|
||||
|
||||
|
||||
def test_create_service_bound_widget(client):
|
||||
service = _make_grafana_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "link"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "metric",
|
||||
"title": "Metrics",
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "metric"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "nonexistent_kind",
|
||||
"title": "x",
|
||||
"config": {},
|
||||
},
|
||||
@@ -186,33 +185,23 @@ def test_service_bound_widget_service_not_found_rejected(client):
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": "missing",
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_service_bound_widget_invalid_config_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
|
||||
},
|
||||
)
|
||||
# Empty string passes Pydantic; force a real failure with a bad type.
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": 123},
|
||||
"config": {"promql": 123}, # bad type: promql must be a string
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -280,31 +269,15 @@ def test_fetch_backups_widget_data(client):
|
||||
assert "total_jobs" in response.json()["data"]
|
||||
|
||||
|
||||
def test_fetch_grafana_link_widget_data(client):
|
||||
service = _make_grafana_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview", "panel_id": 2},
|
||||
},
|
||||
).json()
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
|
||||
|
||||
|
||||
def test_fetch_widget_service_not_found(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
# Deleting the service cascade-deletes its widgets, so the widget is gone.
|
||||
@@ -314,22 +287,22 @@ def test_fetch_widget_service_not_found(client):
|
||||
|
||||
|
||||
def test_fetch_widget_service_disabled(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
client.put(
|
||||
f"/api/services/instances/{service['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": service["name"],
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"enabled": False,
|
||||
},
|
||||
)
|
||||
@@ -347,23 +320,6 @@ def test_fetch_widget_not_found(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_builds_url():
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
|
||||
assert result["url"] == "http://g:3000/d/ov"
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
|
||||
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_missing_service():
|
||||
adapter = GrafanaWidgetSource()
|
||||
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_summarizes_alerts():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
@@ -498,7 +454,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New widget kind tests (jellyfin now_playing + grafana panel)
|
||||
# New widget kind tests (jellyfin now_playing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -511,162 +467,6 @@ def test_jellyfin_definition_has_now_playing_widget():
|
||||
assert "activity" in kinds
|
||||
|
||||
|
||||
def test_grafana_definition_has_chart_widget():
|
||||
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||
|
||||
definition = get_service_definition("grafana")
|
||||
kinds = {wk.kind for wk in definition.widget_kinds}
|
||||
assert "chart" in kinds
|
||||
assert "link" in kinds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_queries_datasource():
|
||||
"""Chart widget should POST to /api/ds/query and normalize the response."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000", "timeout_seconds": 5},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"results": {
|
||||
"A": {
|
||||
"frames": [
|
||||
{
|
||||
"data": {"values": [[1000, 2000], [0.5, 0.8]]},
|
||||
"schema": {"fields": [{"name": "Time"}, {"name": "cpu_usage"}]},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=mock_resp):
|
||||
result = await adapter.fetch(
|
||||
service,
|
||||
"chart",
|
||||
{"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||
)
|
||||
|
||||
assert "series" in result
|
||||
assert len(result["series"]) == 1
|
||||
assert result["series"][0]["label"] == "cpu_usage"
|
||||
assert result["series"][0]["points"] == [
|
||||
{"t": 1000, "v": 0.5},
|
||||
{"t": 2000, "v": 0.8},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_extracts_prometheus_labels():
|
||||
"""Multiple Prometheus series should get unique labels from frame metadata."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000", "timeout_seconds": 5},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"results": {
|
||||
"A": {
|
||||
"frames": [
|
||||
{
|
||||
"data": {"values": [[1000], [0.5]]},
|
||||
"schema": {
|
||||
"fields": [
|
||||
{"name": "Time"},
|
||||
{
|
||||
"name": "Value",
|
||||
"labels": {
|
||||
"instance": "server1:9100",
|
||||
"mode": "iowait",
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {"values": [[1000], [0.3]]},
|
||||
"schema": {
|
||||
"fields": [
|
||||
{"name": "Time"},
|
||||
{
|
||||
"name": "Value",
|
||||
"labels": {
|
||||
"instance": "server2:9100",
|
||||
"mode": "iowait",
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=mock_resp):
|
||||
result = await adapter.fetch(service, "chart", {"query": "rate(cpu[5m])"})
|
||||
|
||||
assert len(result["series"]) == 2
|
||||
assert result["series"][0]["label"] == "instance=server1:9100 mode=iowait"
|
||||
assert result["series"][1]["label"] == "instance=server2:9100 mode=iowait"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_requires_api_key():
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000"},
|
||||
)
|
||||
result = await adapter.fetch(service, "chart", {"query": "up"})
|
||||
assert "error" in result
|
||||
assert "api_key" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_handles_http_failure():
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests as req_mod
|
||||
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000", "timeout_seconds": 2},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"media_library_viewer_api.widgets.sources.requests.post",
|
||||
side_effect=req_mod.ConnectionError("refused"),
|
||||
):
|
||||
result = await adapter.fetch(service, "chart", {"query": "up"})
|
||||
|
||||
assert "error" in result
|
||||
assert "failed" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_runs_range_query():
|
||||
"""SC-101: chart kind hits /api/v1/query_range and returns {series}."""
|
||||
@@ -827,23 +627,23 @@ def test_widget_reference_lifecycle(widget_ref_client):
|
||||
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
# Create a service-bound widget (simulating one on a Grafana Overview).
|
||||
# Create a service-bound widget (simulating one on a Prometheus service).
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "grafana",
|
||||
"name": "Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("grafana")[0]
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "CPU IOWait",
|
||||
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||
"config": {"promql": "rate(cpu[5m])", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
@@ -888,20 +688,20 @@ def test_widget_reference_detach(widget_ref_client):
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "grafana",
|
||||
"name": "Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("grafana")[0]
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "Memory",
|
||||
"config": {"query": "mem", "datasource_uid": "prometheus"},
|
||||
"config": {"promql": "mem", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
@@ -924,7 +724,7 @@ def test_widget_reference_detach(widget_ref_client):
|
||||
assert cloned["title"] == "Memory"
|
||||
assert cloned["widget_kind"] == "chart"
|
||||
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
|
||||
assert cloned["config"]["query"] == "mem"
|
||||
assert cloned["config"]["promql"] == "mem"
|
||||
assert cloned["id"] != widget["id"] # new independent widget
|
||||
|
||||
# Reference is gone.
|
||||
|
||||
@@ -32,7 +32,6 @@ import type {
|
||||
DashboardShortcutInput,
|
||||
AlertmanagerAlertSummary,
|
||||
AlertmanagerStatus,
|
||||
GrafanaStatus,
|
||||
PrometheusStatus,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
@@ -299,9 +298,6 @@ export const fetchAlertmanagerAlerts = () =>
|
||||
export const fetchAlertmanagerStatus = () =>
|
||||
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
||||
|
||||
export const fetchGrafanaStatus = () =>
|
||||
get<GrafanaStatus>("/api/monitoring/grafana-status");
|
||||
|
||||
export const fetchPrometheusStatus = () =>
|
||||
get<PrometheusStatus>("/api/monitoring/prometheus-status");
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchAlertmanagerAlerts,
|
||||
fetchAlertmanagerStatus,
|
||||
fetchGrafanaStatus,
|
||||
fetchPrometheusStatus,
|
||||
fetchPrometheusTargets,
|
||||
fetchMonitoringMachines,
|
||||
@@ -28,16 +27,6 @@ export function useAlertmanagerStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrafanaStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "grafana-status"],
|
||||
queryFn: fetchGrafanaStatus,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrometheusStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "prometheus-status"],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #4f8cff;
|
||||
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||
/* Status semantic cues — single source of truth for Badges.
|
||||
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||
chart-4=destructive, chart-5=neutral-accent. */
|
||||
--color-chart-1: #4f8cff;
|
||||
@@ -62,7 +62,7 @@
|
||||
--color-border: #334155;
|
||||
--color-input: #334155;
|
||||
--color-ring: #4f8cff;
|
||||
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||
/* Status semantic cues — single source of truth for Badges.
|
||||
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||
chart-4=destructive, chart-5=neutral-accent. */
|
||||
--color-chart-1: #4f8cff;
|
||||
|
||||
@@ -21,13 +21,9 @@ describe("navEntries", () => {
|
||||
|
||||
it("returns all observability entries", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["alertmanager", "grafana", "prometheus"]),
|
||||
new Set(["alertmanager", "prometheus"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Alertmanager",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
]);
|
||||
expect(entries.map((e) => e.label)).toEqual(["Alertmanager", "Prometheus"]);
|
||||
});
|
||||
|
||||
it("returns Backups + Authentik when configured", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
GanttChartSquare,
|
||||
Link2,
|
||||
Monitor,
|
||||
Server,
|
||||
Users,
|
||||
@@ -49,12 +48,6 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||
icon: Activity,
|
||||
path: "/services/alertmanager",
|
||||
},
|
||||
{
|
||||
serviceType: "grafana",
|
||||
label: "Grafana",
|
||||
icon: Link2,
|
||||
path: "/services/grafana",
|
||||
},
|
||||
{
|
||||
serviceType: "prometheus",
|
||||
label: "Prometheus",
|
||||
|
||||
@@ -12,7 +12,6 @@ describe("service registry", () => {
|
||||
it("registers the backend service types", () => {
|
||||
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||
"alertmanager",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
@@ -21,9 +20,6 @@ describe("service registry", () => {
|
||||
});
|
||||
|
||||
it("binds widget kinds per service", () => {
|
||||
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
||||
"link",
|
||||
]);
|
||||
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
|
||||
"metric",
|
||||
"chart",
|
||||
@@ -43,12 +39,12 @@ describe("service registry", () => {
|
||||
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
|
||||
});
|
||||
|
||||
it("resolves a service-bound widget via the services list", () => {
|
||||
it("resolves a prometheus metric widget via the services list", () => {
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "link",
|
||||
title: "Dashboard",
|
||||
widget_kind: "metric",
|
||||
title: "Metric",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
@@ -58,9 +54,9 @@ describe("service registry", () => {
|
||||
const services: ServiceInstance[] = [
|
||||
{
|
||||
id: "s1",
|
||||
service_type: "grafana",
|
||||
name: "Grafana",
|
||||
config: { base_url: "https://grafana.example.com" },
|
||||
service_type: "prometheus",
|
||||
name: "Prometheus",
|
||||
config: { base_url: "https://prometheus.example.com" },
|
||||
secrets_set: { api_key: true },
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
@@ -69,7 +65,7 @@ describe("service registry", () => {
|
||||
];
|
||||
const resolved = resolveWidget(widget, services);
|
||||
expect(resolved).toBeDefined();
|
||||
expect(resolved?.refreshIntervalMs).toBe(0);
|
||||
expect(resolved?.refreshIntervalMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it("resolves an alertmanager active_alerts widget", () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
|
||||
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
|
||||
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
|
||||
@@ -69,29 +68,6 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
grafana: {
|
||||
serviceType: "grafana",
|
||||
name: "Grafana",
|
||||
description: "Dashboards, metrics, and logs.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "link",
|
||||
name: "Dashboard link",
|
||||
description: "Deep-link to a Grafana dashboard or panel.",
|
||||
refreshIntervalMs: 0,
|
||||
defaultConfig: { dashboard_uid: "" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dashboard_uid: { type: "string" },
|
||||
panel_id: { type: "integer" },
|
||||
},
|
||||
required: ["dashboard_uid"],
|
||||
},
|
||||
component: GrafanaLinkWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
prometheus: {
|
||||
serviceType: "prometheus",
|
||||
name: "Prometheus",
|
||||
|
||||
@@ -65,7 +65,7 @@ const SECTION_META: Record<
|
||||
custom: { label: "Custom", icon: LayoutDashboard },
|
||||
};
|
||||
|
||||
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
|
||||
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus"]);
|
||||
|
||||
function widgetSection(
|
||||
widget: WidgetInstance,
|
||||
|
||||
@@ -548,8 +548,8 @@ export function ServicesPage() {
|
||||
{services.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud,
|
||||
or SSH task runner.
|
||||
No services yet. Add a Prometheus, Jellyfin, Nextcloud, or SSH
|
||||
task runner.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Grafana Links tab (spec R2.4, R8.2).
|
||||
*
|
||||
* Lifts the Grafana deep-link content from the old cross-service
|
||||
* ObservabilityPage into an instance-scoped tab. Shows service health + the
|
||||
* configured Grafana deep-links (node-exporter dashboard, Loki logs per
|
||||
* machine).
|
||||
*
|
||||
* The hooks (useGrafanaStatus, useMonitoringMachines) are global /
|
||||
* first-configured for now. Wiring `instance.id` into the status hook is a
|
||||
* follow-up. The machine links use the configured Grafana base_url from the
|
||||
* instance's config.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
|
||||
import {
|
||||
useGrafanaStatus,
|
||||
useMonitoringMachines,
|
||||
} from "../../hooks/useObservability";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
function GrafanaLinkCard({
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
Open in Grafana
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinksTab({ instance }: { instance: ServiceInstance }) {
|
||||
const { data: status, isLoading, error } = useGrafanaStatus();
|
||||
const { data: machines = [], isLoading: machinesLoading } =
|
||||
useMonitoringMachines();
|
||||
const [selectedMachineId, setSelectedMachineId] = useState("");
|
||||
|
||||
const grafanaBaseUrl =
|
||||
(instance.config?.base_url as string | undefined) ?? "";
|
||||
|
||||
const selectedMachine = useMemo(
|
||||
() =>
|
||||
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
|
||||
[machines, selectedMachineId],
|
||||
);
|
||||
|
||||
const nodeExporterDashboardUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const inst = `${selectedMachine.host || "localhost"}:9100`;
|
||||
return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const logsUrl = useMemo(() => {
|
||||
if (!selectedMachine || !grafanaBaseUrl) return "";
|
||||
const container =
|
||||
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
|
||||
return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
datasource: "Loki",
|
||||
queries: [{ refId: "A", expr: `{container="${container}"}` }],
|
||||
range: { from: "now-1h", to: "now" },
|
||||
}),
|
||||
)}`;
|
||||
}, [selectedMachine, grafanaBaseUrl]);
|
||||
|
||||
const statusDetail = status?.up
|
||||
? status.version
|
||||
? `version ${status.version}`
|
||||
: "reachable"
|
||||
: isLoading
|
||||
? "checking…"
|
||||
: error
|
||||
? "unreachable"
|
||||
: "not configured";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Gauge className="h-4 w-4" />
|
||||
Grafana {statusDetail}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to reach Grafana</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Machine Dashboard
|
||||
</CardTitle>
|
||||
{machines.length > 0 ? (
|
||||
<Select
|
||||
value={selectedMachine?.id ?? ""}
|
||||
onValueChange={setSelectedMachineId}
|
||||
disabled={machinesLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[240px]">
|
||||
<SelectValue placeholder="Select machine" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{machines.map((machine) => (
|
||||
<SelectItem key={machine.id} value={machine.id}>
|
||||
{machine.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : selectedMachine && grafanaBaseUrl ? (
|
||||
<>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} metrics`}
|
||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||
href={nodeExporterDashboardUrl}
|
||||
/>
|
||||
<GrafanaLinkCard
|
||||
title={`${selectedMachine.name} logs`}
|
||||
description="Explore Loki logs for this machine in Grafana."
|
||||
href={logsUrl}
|
||||
/>
|
||||
</>
|
||||
) : !grafanaBaseUrl ? (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<Gauge className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No Grafana base URL configured</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add a Grafana service instance to enable deep-links to
|
||||
dashboards and logs.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/services">Open Services</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
|
||||
<ServerOff className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="font-medium">No machine selected</div>
|
||||
<div className="max-w-md text-sm text-muted-foreground">
|
||||
Add monitoring machines in Settings to see Grafana drill-down
|
||||
links.
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings">Open Settings</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LinksTab } from "../LinksTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "graf-1",
|
||||
service_type: "grafana",
|
||||
name: "Main Grafana",
|
||||
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useObservability", () => ({
|
||||
useGrafanaStatus: () => ({
|
||||
data: {
|
||||
up: true,
|
||||
version: "11.0.0",
|
||||
service_id: "graf-1",
|
||||
name: "Main Grafana",
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useMonitoringMachines: () => ({
|
||||
data: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "storage",
|
||||
mode: "ssh",
|
||||
host: "10.0.0.5",
|
||||
enabled: true,
|
||||
services: [],
|
||||
port: 22,
|
||||
username: "admin",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LinksTab", () => {
|
||||
it("renders the Grafana version and machine dashboard links", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/storage logs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open-in-grafana link buttons", () => {
|
||||
render(<LinksTab instance={instance} />);
|
||||
const links = screen.getAllByText("Open in Grafana");
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type { ComponentType } from "react";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { OverviewTab } from "./OverviewTab";
|
||||
import { AlertsTab } from "./AlertsTab";
|
||||
import { LinksTab } from "./LinksTab";
|
||||
import { MetricsTab } from "./MetricsTab";
|
||||
import { MediaTab } from "./MediaTab";
|
||||
import { RequestsTab } from "./RequestsTab";
|
||||
@@ -56,8 +55,6 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
|
||||
];
|
||||
case "alertmanager":
|
||||
return [{ label: "Alerts", Component: AlertsTab }];
|
||||
case "grafana":
|
||||
return [{ label: "Links", Component: LinksTab }];
|
||||
case "prometheus":
|
||||
return [{ label: "Metrics", Component: MetricsTab }];
|
||||
default:
|
||||
|
||||
@@ -386,14 +386,6 @@ export interface AlertmanagerStatus {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface GrafanaStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
service_id: string;
|
||||
name: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PrometheusStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function GrafanaLinkWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const url = data?.data?.url as string | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-10 w-48" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : url ? (
|
||||
<Button asChild>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
Open Grafana
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No Grafana URL configured.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||
export { BackupsWidget } from "./BackupsWidget";
|
||||
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||
export { PrometheusChartWidget } from "./PrometheusChartWidget";
|
||||
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
|
||||
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
|
||||
|
||||
@@ -5,7 +5,7 @@ context: |
|
||||
Frontend stack: React 18, TypeScript, Vite, TanStack Query, Tailwind CSS v4 (CSS @theme config in src/index.css), shadcn/ui (Radix primitives), lucide-react, react-router-dom, react-oidc-context.
|
||||
MIGRATION IN PROGRESS: MUI v9 (@mui/material, @mui/icons-material, @mui/x-data-grid, @emotion/react, @emotion/styled) -> shadcn/ui + Tailwind + lucide-react. The shell (frontend/src/App.tsx) and frontend/src/components/ObservabilityPage.tsx are already migrated and are the style targets. @mui/x-data-grid (pages/Media.tsx, pages/FileBrowser.impl.tsx) migrates to TanStack Table (@tanstack/react-table) with shadcn table styling.
|
||||
DESIGN TOKENS: src/index.css already defines a full Tailwind v4 @theme token system (light + .dark), Inter font, primary #4f8cff, radius 0.625rem. tailwind.config.cjs is minimal. theme.ts is a no-op shim, safe to delete.
|
||||
OBSERVABILITY MODEL: Manage is a THIN dashboard. Charts/metrics/logs live in EXTERNAL, decoupled Grafana. In-app surfaces show Alertmanager alerts + Prometheus target health + Grafana deep-links (kiosk iframe). Do NOT re-implement charting in-app. No recharts/d3 is in use.
|
||||
OBSERVABILITY MODEL: Manage renders Prometheus-backed metrics directly via recharts (line charts, gauges, mean widgets) querying Prometheus /api/v1/query_range. Alertmanager alerts + Prometheus target health are shown in-app. Grafana is no longer integrated.
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
|
||||
Reference in New Issue
Block a user