diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index aae19fd..d1a899c 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -170,6 +170,7 @@ class GrafanaWidgetSource: # 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", []) @@ -177,9 +178,37 @@ class GrafanaWidgetSource: continue timestamps = values[0] vals = values[1] - # Derive series label from the field schema. + # 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", []) - label = fields[-1].get("name", "value") if fields else "value" + 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}) diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index 40c3b94..76f4dd7 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -565,6 +565,69 @@ async def test_grafana_adapter_chart_queries_datasource(): ] +@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() diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index 2255246..b360773 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -134,7 +134,7 @@ function WidgetConfigEditor({ return (
- {properties.map(([key, schema]) => { + {properties.map(([key, schema]) => { const isNumber = (schema as { type?: string }).type === "integer" || (schema as { type?: string }).type === "number"; @@ -143,43 +143,40 @@ function WidgetConfigEditor({ // config schema can opt in via `format: "textarea"`; the well-known // `query` field is treated as textarea by default. const schemaFormat = (schema as { format?: string }).format; - const isTextarea = - schemaFormat === "textarea" || key === "query"; + const isTextarea = schemaFormat === "textarea" || key === "query"; return ( - - {isTextarea ? ( -