Fix invisible chart lines + extract Prometheus labels for multi-series
Two fixes for the Grafana chart widget: 1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the CSS variable is named '--color-chart-1' and already contains a hex color (#4f8cff). The hsl() wrapper produced invalid CSS, making every stroke invisible. Fixed to var(--color-chart-1). 2. Multiple series collision: the backend labeled all Prometheus series with the value field name (often just 'Value'), so multiple time series collided on the same recharts dataKey and overwrote each other. Now extracts meaningful labels from the Grafana frame metadata: prefers displayName, then Prometheus metric labels (e.g. 'instance=server1:9100 mode=iowait'), then falls back to the field name. Duplicate labels get a numeric suffix for uniqueness. Multi-series queries now render correctly: each Prometheus time series gets its own colored line with a unique label in the legend/tooltip. 280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/ eslint clean.
This commit is contained in:
@@ -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})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -143,8 +143,7 @@ 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 (
|
||||
<Field
|
||||
key={key}
|
||||
@@ -158,9 +157,7 @@ function WidgetConfigEditor({
|
||||
rows={4}
|
||||
className="resize-y font-mono text-xs"
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, [key]: e.target.value })
|
||||
}
|
||||
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
|
||||
@@ -52,11 +52,11 @@ function formatTime(ms: number): string {
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
"hsl(var(--chart-1))",
|
||||
"hsl(var(--chart-2))",
|
||||
"hsl(var(--chart-3))",
|
||||
"hsl(var(--chart-4))",
|
||||
"hsl(var(--chart-5))",
|
||||
"var(--color-chart-1)",
|
||||
"var(--color-chart-2)",
|
||||
"var(--color-chart-3)",
|
||||
"var(--color-chart-4)",
|
||||
"var(--color-chart-5)",
|
||||
];
|
||||
|
||||
export function GrafanaChartWidget({
|
||||
|
||||
Reference in New Issue
Block a user