# Grafana Chart Widget — worker output

## Files changed (10 files, ~400 lines)

| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +12/-12 (panel→chart config + kind) |
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +70/-12 (chart query adapter replaces panel URL logic) |
| `backend/tests/test_widgets.py` | modified | +55/-20 (3 new chart tests replace 2 panel tests) |
| `backend/tests/test_services.py` | modified | +2/-2 (grafana widget-kind + API-metadata assertions) |
| `frontend/src/widgets/GrafanaChartWidget.tsx` | **new** | 100 |
| `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` | **new** | 57 |
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | **deleted** | -50 |
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | **deleted** | -72 |
| `frontend/src/integrations/registry.ts` | modified | +24/-14 (chart binding replaces panel) |
| `frontend/src/integrations/registry.test.ts` | modified | +1/-1 (panel→chart) |
| `frontend/src/widgets/index.ts` | modified | +1/-0 (export GrafanaChartWidget) |
| `frontend/package.json` + `package-lock.json` | modified | +1 dep (recharts ^3.9.2) |

**recharts version installed:** `^3.9.2`

## Grafana `/api/ds/query` request/response shape

**Request** (POST):

```json
{
  "queries": [{
    "datasource": {"uid": "prometheus", "type": "prometheus"},
    "expr": "rate(cpu[5m])",
    "format": "time_series",
    "intervalMs": 30000,
    "maxDataPoints": 100,
    "refId": "A"
  }],
  "from": "now-1h",
  "to": "now"
}
```

Headers: `Authorization: Bearer {api_key}`, `Content-Type: application/json`

**Response** (abbreviated):

```json
{
  "results": {
    "A": {
      "frames": [{
        "data": { "values": [[1000, 2000], [0.5, 0.8]] },
        "schema": { "fields": [{"name":"Time"}, {"name":"cpu_usage"}] }
      }]
    }
  }
}
```

## Series normalization logic

Iterates `results[*].frames[]`. For each frame with `values` having >=2 arrays (timestamps + values), extracts the series label from `schema.fields[-1].name` and zips timestamps+values into `[{t: int, v: float|null}]`. Returns `{"series": [{"label": "...", "points": [...]}]}`.

## Frontend chart rendering

`GrafanaChartWidget` fetches widget data, extracts `data.series`, merges all series by timestamp into a single recharts data array (`[{time, cpu_usage: 0.5, mem: 0.3}, ...]`), and renders a `<LineChart>` with one `<Line>` per series. Uses Tailwind CSS variables (`--chart-1` through `--chart-5`) for colors so it respects dark mode. Includes loading skeleton, error Alert, and empty-state Alert.

## Validation

```
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest  → 279 passed, ruff clean
cd frontend && npm run lint && npm run build && npm run test         → 127 passed, lint/build clean
```

## Deviations

1. **No deviations from spec.** The `link` widget kind is unchanged. The `panel` kind is fully replaced by `chart`.
2. **recharts `labelFormatter` type workaround.** Recharts 3.x types `labelFormatter` as `(label: ReactNode, ...) => ReactNode`, not `(number) => string`. Wrapped with `(label) => formatTime(Number(label))` to satisfy TS strict.

## skill_resolution

`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.

## Residual risks

- The chart widget assumes the Grafana datasource is Prometheus-type (hardcoded `"type": "prometheus"` in the query body). If the user has a non-Prometheus datasource (InfluxDB, etc.), the query body format may need adjustment. The `datasource_uid` is configurable but the `type` is not.
- recharts is ~45KB gzipped added to the bundle.

```acceptance-report
{
  "criteriaSatisfied": [
    {
      "id": "criterion-1",
      "status": "satisfied",
      "evidence": "Replaces the broken iframe panel widget with a server-side chart query widget. Backend queries /api/ds/query with stored api_key; frontend renders recharts LineChart. No iframe, no browser auth, no CORS. The link widget kind is unchanged. 279 backend + 127 frontend tests pass; lint/build green both sides."
    }
  ],
  "changedFiles": [
    "backend/src/media_library_viewer_api/integrations/grafana.py",
    "backend/src/media_library_viewer_api/widgets/sources.py",
    "backend/tests/test_widgets.py",
    "backend/tests/test_services.py",
    "frontend/src/widgets/GrafanaChartWidget.tsx",
    "frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
    "frontend/src/widgets/GrafanaPanelWidget.tsx (deleted)",
    "frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx (deleted)",
    "frontend/src/integrations/registry.ts",
    "frontend/src/integrations/registry.test.ts",
    "frontend/src/widgets/index.ts",
    "frontend/package.json"
  ],
  "testsAddedOrUpdated": [
    "backend/tests/test_widgets.py",
    "backend/tests/test_services.py",
    "frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
    "frontend/src/integrations/registry.test.ts"
  ],
  "commandsRun": [
    { "command": "cd backend && .venv/bin/ruff check .", "result": "passed", "summary": "All checks passed" },
    { "command": "cd backend && .venv/bin/python -m pytest tests/ -q", "result": "passed", "summary": "279 passed, 2 pre-existing warnings" },
    { "command": "cd frontend && npm run lint", "result": "passed", "summary": "0 errors, 0 warnings" },
    { "command": "cd frontend && npm run build", "result": "passed", "summary": "tsc + vite build clean" },
    { "command": "cd frontend && npm run test", "result": "passed", "summary": "39 files / 127 tests passed" }
  ],
  "validationOutput": [
    "Backend ruff clean; 279 tests pass (was 278; -2 panel + 3 chart = +1 net).",
    "Frontend eslint clean; tsc + vite build clean; 127 tests pass (-3 panel + 3 chart = net 0).",
    "GrafanaWidgetSource._fetch_chart POSTs to /api/ds/query with Bearer token; normalizes response to {series:[{label,points}]}",
    "GrafanaChartWidget renders recharts LineChart with dark-mode CSS variable colors.",
    "link widget kind unchanged; panel widget kind fully removed."
  ],
  "residualRisks": [
    "Chart query body hardcodes datasource type 'prometheus' — non-Prometheus datasources (InfluxDB etc.) may need a type field on the config.",
    "recharts adds ~45KB gzipped to the frontend bundle."
  ],
  "noStagedFiles": true,
  "diffSummary": "~400 lines: replaces Grafana panel iframe widget with server-side datasource-query chart widget. Backend: /api/ds/query POST with api_key + series normalization (70 lines). Frontend: recharts LineChart component with dark-mode support (100 lines). 3 backend + 3 frontend tests. recharts ^3.9.2 installed.",
  "reviewFindings": [
    "no blockers"
  ],
  "manualNotes": "recharts labelFormatter type workaround: recharts 3.x types it as (ReactNode) => ReactNode, not (number) => string. Wrapped with Number() cast. The link widget kind is fully preserved. The panel widget kind and all its code/tests are fully deleted."
}
```
