From 67ca0fc3bc13ee7c4aef814107d9e349ce354e2e Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 8 Jul 2026 22:33:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(prometheus-direct-charting):=20slice=203?= =?UTF-8?q?=20=E2=80=94=20remove=20grafana=20+=20config=20rewrite=20+=20ch?= =?UTF-8?q?angelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 18 ++ .../integrations/base.py | 4 +- .../integrations/grafana.py | 73 ----- .../integrations/registry.py | 2 - .../models/widgets.py | 2 +- .../routers/monitoring.py | 25 +- .../services/settings_store.py | 2 +- .../widgets/sources.py | 113 ------- backend/tests/test_api.py | 44 +-- backend/tests/test_services.py | 95 +++--- backend/tests/test_widgets.py | 300 +++--------------- frontend/src/api/client.ts | 4 - frontend/src/hooks/useObservability.ts | 11 - frontend/src/index.css | 4 +- .../integrations/__tests__/navEntries.test.ts | 8 +- frontend/src/integrations/navEntries.ts | 7 - frontend/src/integrations/registry.test.ts | 18 +- frontend/src/integrations/registry.ts | 24 -- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/ServicesPage.tsx | 4 +- frontend/src/pages/service-tabs/LinksTab.tsx | 194 ----------- .../service-tabs/__tests__/LinksTab.test.tsx | 58 ---- frontend/src/pages/service-tabs/index.ts | 3 - frontend/src/types/index.ts | 8 - frontend/src/widgets/GrafanaLinkWidget.tsx | 45 --- frontend/src/widgets/index.ts | 1 - openspec/config.yaml | 2 +- 27 files changed, 134 insertions(+), 937 deletions(-) delete mode 100644 backend/src/media_library_viewer_api/integrations/grafana.py delete mode 100644 frontend/src/pages/service-tabs/LinksTab.tsx delete mode 100644 frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx delete mode 100644 frontend/src/widgets/GrafanaLinkWidget.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab41cd..1f58413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/src/media_library_viewer_api/integrations/base.py b/backend/src/media_library_viewer_api/integrations/base.py index d116f7f..4204684 100644 --- a/backend/src/media_library_viewer_api/integrations/base.py +++ b/backend/src/media_library_viewer_api/integrations/base.py @@ -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. """ diff --git a/backend/src/media_library_viewer_api/integrations/grafana.py b/backend/src/media_library_viewer_api/integrations/grafana.py deleted file mode 100644 index beee2d8..0000000 --- a/backend/src/media_library_viewer_api/integrations/grafana.py +++ /dev/null @@ -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, - ), - ], -) diff --git a/backend/src/media_library_viewer_api/integrations/registry.py b/backend/src/media_library_viewer_api/integrations/registry.py index 5d2c87f..3ffb46f 100644 --- a/backend/src/media_library_viewer_api/integrations/registry.py +++ b/backend/src/media_library_viewer_api/integrations/registry.py @@ -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, diff --git a/backend/src/media_library_viewer_api/models/widgets.py b/backend/src/media_library_viewer_api/models/widgets.py index 4a29ebc..ebc57b5 100644 --- a/backend/src/media_library_viewer_api/models/widgets.py +++ b/backend/src/media_library_viewer_api/models/widgets.py @@ -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). diff --git a/backend/src/media_library_viewer_api/routers/monitoring.py b/backend/src/media_library_viewer_api/routers/monitoring.py index 2ac8675..f30f6bf 100644 --- a/backend/src/media_library_viewer_api/routers/monitoring.py +++ b/backend/src/media_library_viewer_api/routers/monitoring.py @@ -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, diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index c6cac9c..104e61f 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -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( diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index 86931ee..fbb1e12 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -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(), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 42fde98..756edb0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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") diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 643ac8b..bb67f79 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -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"]) diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 26c3ca9..7b6754e 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -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. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f73bba8..280f707 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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("/api/monitoring/alertmanager-status"); -export const fetchGrafanaStatus = () => - get("/api/monitoring/grafana-status"); - export const fetchPrometheusStatus = () => get("/api/monitoring/prometheus-status"); diff --git a/frontend/src/hooks/useObservability.ts b/frontend/src/hooks/useObservability.ts index a4a4ccd..9c213f5 100644 --- a/frontend/src/hooks/useObservability.ts +++ b/frontend/src/hooks/useObservability.ts @@ -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"], diff --git a/frontend/src/index.css b/frontend/src/index.css index 80b727f..c094072 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/integrations/__tests__/navEntries.test.ts b/frontend/src/integrations/__tests__/navEntries.test.ts index c485260..15906fb 100644 --- a/frontend/src/integrations/__tests__/navEntries.test.ts +++ b/frontend/src/integrations/__tests__/navEntries.test.ts @@ -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", () => { diff --git a/frontend/src/integrations/navEntries.ts b/frontend/src/integrations/navEntries.ts index 9bbd007..f6f808d 100644 --- a/frontend/src/integrations/navEntries.ts +++ b/frontend/src/integrations/navEntries.ts @@ -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", diff --git a/frontend/src/integrations/registry.test.ts b/frontend/src/integrations/registry.test.ts index 4b3a558..1d745a8 100644 --- a/frontend/src/integrations/registry.test.ts +++ b/frontend/src/integrations/registry.test.ts @@ -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", () => { diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 2b7b984..add5c40 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -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 = { }, ], }, - 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", diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ff8bb70..9276f40 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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, diff --git a/frontend/src/pages/ServicesPage.tsx b/frontend/src/pages/ServicesPage.tsx index 794622d..a54dcb3 100644 --- a/frontend/src/pages/ServicesPage.tsx +++ b/frontend/src/pages/ServicesPage.tsx @@ -548,8 +548,8 @@ export function ServicesPage() { {services.length === 0 ? ( - 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. ) : ( diff --git a/frontend/src/pages/service-tabs/LinksTab.tsx b/frontend/src/pages/service-tabs/LinksTab.tsx deleted file mode 100644 index 07ea98d..0000000 --- a/frontend/src/pages/service-tabs/LinksTab.tsx +++ /dev/null @@ -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 ( -
-
-
-
{title}
-
{description}
-
- -
-
- ); -} - -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 ( -
-
- - Grafana {statusDetail} -
- - {error && ( - - Failed to reach Grafana - {error.message} - - )} - - - - - - Machine Dashboard - - {machines.length > 0 ? ( - - ) : null} - - - {isLoading ? ( - - ) : selectedMachine && grafanaBaseUrl ? ( - <> - - - - ) : !grafanaBaseUrl ? ( -
- -
No Grafana base URL configured
-
- Add a Grafana service instance to enable deep-links to - dashboards and logs. -
- -
- ) : ( -
- -
No machine selected
-
- Add monitoring machines in Settings to see Grafana drill-down - links. -
- -
- )} -
-
-
- ); -} diff --git a/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx deleted file mode 100644 index 8f9d4b6..0000000 --- a/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx +++ /dev/null @@ -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(); - 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(); - const links = screen.getAllByText("Open in Grafana"); - expect(links).toHaveLength(2); - }); -}); diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts index db60310..c03b8b6 100644 --- a/frontend/src/pages/service-tabs/index.ts +++ b/frontend/src/pages/service-tabs/index.ts @@ -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: diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c8cce83..43f5cb7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; diff --git a/frontend/src/widgets/GrafanaLinkWidget.tsx b/frontend/src/widgets/GrafanaLinkWidget.tsx deleted file mode 100644 index 5840fd8..0000000 --- a/frontend/src/widgets/GrafanaLinkWidget.tsx +++ /dev/null @@ -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 ( - - {isLoading && !data ? ( - - ) : data?.error ? ( - - {data.error} - - ) : url ? ( - - ) : ( - - No Grafana URL configured. - - )} - - ); -} diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index 2f1c008..a0aba87 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -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"; diff --git a/openspec/config.yaml b/openspec/config.yaml index 726122c..d874b89 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -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: