ADORSYS-GIS / ADORSYS-GIS/converse-frontends

[Ticket]: CSV export data is malformed (units, shapes, headers)

Ouverte
#495 0 commentaires 0 réactions 1 personne assignée Réclamée par @stephane-segning Voir sur GitHub
ticket
Langage dominant
TypeScript
Étoiles
0
Forks
0
Merge moyen
1 h 49 min
PR mergées (30 j)
253

Description

### Type

Bug

### Summary

We need to fix `GET /api/reports/page?format=csv` because the file it produces is not a CSV in any useful sense: it is 17 stacked mini-tables with 5 different header schemas, no `panel` column, no timestamp anywhere, money and counts written as **display strings** (`$0.0038`, `"74,112"`, `102 ms`, `<1%`, `—`), a `#`-comment preamble that no CSV parser skips, and — worst — every `table` panel's identity column reduced to a literal em dash, so the rows cannot be attributed to an actor, account or project at all.

Expected result:

> One machine-readable CSV per exported page: a single header row, one row per observation in long format, a `panel_id` column, raw numeric values (USD at 6 decimals, counts as integers), ISO 8601 UTC timestamps including the per-bucket `bucket_start` that is currently thrown away, stable English snake_case column names regardless of the reader's locale, and a UTF-8 BOM + CRLF so Excel opens it correctly.

### Intent

The CSV format exists so a person can take the numbers *out* of the console — into Excel, pandas, DuckDB, a finance sheet — and do arithmetic the dashboard does not do. Right now it can only be *looked at*, and even that is unreliable: the identity column is blank and the values are prose. The PDF/HTML formats are for reading; the CSV must be for **computing**. This ticket makes `format=csv` a data export instead of a text rendering of a document that was designed for paper.

Note that the older `/api/reports/consumption?format=csv` already gets this right (`apps/console/src/server/consumption-csv.ts`) — raw integers, `total_cost_usd` at fixed 6 decimals, snake_case headers. The new page export regressed against a shape this repo already had.

### Source of truth (links)

- https://github.com/ADORSYS-GIS/converse-frontends/issues/453 — C10, the story that shipped `format=csv` (`reportCsv`, `apps/console/src/server/reports/report-csv.ts`).
- Owner report, 2026-09-03: *"CSV export works but data are kind of broken."*
- Precedent for the correct numeric shape: `apps/console/src/server/consumption-csv.ts` (`total_cost_usd` at 6dp, raw integer counts) and `docs/` unit contract "money is integer micro-USD".

### Current Behavior

Reproduced against `origin/main` @ `ef6ff09`, driving `buildReport` + `reportCsv` through the same harness `src/server/reports/report-data.test.ts` uses (the real `dashboards.yaml`, the real `resolveDashboard`, the real `toPanelView`, a stubbed usage backend returning 3 buckets × 2 models with distinct per-bucket values, English and German locales).

**`/admin/usage` — first 20 lines (en):**

```csv
# /admin/usage
# route,/admin/usage
# range,This month
# window_start_utc,2026-08-01T00:00:00.000Z
# window_end_utc,2026-09-01T00:00:00.000Z
# generated_at_utc,2026-09-03T09:00:00.000Z
# lens,user

# panel,total-cost,Total cost
label,value,delta
Total cost,$15.00,no change vs Jul 1 – Jul 31

# panel,total-requests,Total requests
label,value,delta
Total requests,"74,112",no change vs Jul 1 – Jul 31

# panel,accounts-by-plan,"Accounts with usage, by plan"
label,value,delta

# panel,plan-in-use,Plans in use
Name,Value,Share
```

**`/admin/usage` — the two `table` panels (last 8 lines):**

```csv
# panel,actors-table,Actors
Actor,Type,Cost,Requests,Tokens,Last active
—,Actor,$15.00,"74,070","7,407,402",2026-08-03 00:00 UTC
—,Actor,$0.0038,42,534,2026-08-03 00:00 UTC

# panel,channels-table,Channels
Channel,Cost,Requests
—,$15.00,"74,112"
```

**`/admin/overview` — first 16 lines:**

```csv
# /admin/overview
# route,/admin/overview
# range,This month
# window_start_utc,2026-08-01T00:00:00.000Z
# window_end_utc,2026-09-01T00:00:00.000Z
# generated_at_utc,2026-09-03T09:00:00.000Z

# panel,estate-spend,Total spend vs previous period
Series,Total,Share
Total,$15.00,50%
Previous period,$15.00,50%

# panel,spend-by-account,Spend by account
Series,Total,Share
acc_0,$15.00,100%
acc_1,$0.0038,<1%
```

**`/admin/overview` — latency and the derived-count series:**

```csv
# panel,latency-by-model,Latency by model
Model,p50,p95,p99,Samples
gpt-4o,102 ms,202 ms,302 ms,"15,003"
claude-sonnet,102 ms,202 ms,—,"15,003"

# panel,adoption-over-time,Active accounts & projects per day
Series,Total,Share
Active accounts,6,50%
Active projects,6,50%
```

**`/admin/usage/actors/[actorId]` — first 12 lines:**

```csv
# /admin/usage/actors/[actorId]
# route,/admin/usage/actors/[actorId]
# range,This month
# window_start_utc,2026-08-01T00:00:00.000Z
# window_end_utc,2026-09-01T00:00:00.000Z
# generated_at_utc,2026-09-03T09:00:00.000Z
# actorid,usr_0
# type,user

# panel,actor-total-cost,Total cost
label,value,delta
Total cost,$15.00,no change vs Jul 1 – Jul 31
```

**`/accounts/[accountId]/overview` — first 12 lines:**

```csv
# /accounts/[accountId]/overview
# route,/accounts/[accountId]/overview
# range,This month
# window_start_utc,2026-08-01T00:00:00.000Z
# window_end_utc,2026-09-01T00:00:00.000Z
# generated_at_utc,2026-09-03T09:00:00.000Z
# accountid,acc_0

# panel,spend-total,Spend in this range
label,value,delta
Spend in this range,$15.00,no change vs Jul 1 – Jul 31
```

**Same `/admin/usage` export with `lb.locale=de` — the header row changes language, and two contradictory number notations appear in one file:**

```csv
# panel,cost-by-model,Kosten je Periode nach Modell
Reihe,Gesamt,Anteil
gpt-4o,$15.00,100%
claude-sonnet,$0.0038,<1%

# panel,actors-table,Akteure
Akteur,Type,Cost,Requests,Tokens,Last active
—,Akteur,$15.00,"74,070","7,407,402",2026-08-03 00:00 UTC

# panel,latency-by-model,Latenz nach Modell
Modell,p50,p95,p99,Messwerte
gpt-4o,102 ms,202 ms,302 ms,15.003
```

**For contrast, the legacy `/api/reports/consumption?format=csv` over the identical fixture — this is already the right shape:**

```csv
project,model,requests,prompt_tokens,completion_tokens,total_tokens,total_cost_usd
prj_0,gpt-4o,74070,300000,150000,7407402,15.000000
prj_1,claude-sonnet,42,300000,150000,534,0.003804
TOTAL,,74112,600000,300000,7407936,15.003804
```

#### What is actually broken, itemised

1. **Identity columns are destroyed.** `panelTable`'s `table` branch (`report-data.ts`) does `typeof cell === 'string' || typeof cell === 'number' ? String(cell) : '—'`. `panel-adapters.tsx:849` builds the name cell as ``, a React element — so **every** row of `actors-table`, `channels-table`, `top-spender-accounts` and `top-spender-projects` gets `—` in its first column. Four table panels across three pages export unattributable rows. The underlying `row.key` and `row.label` are both available and neither is used.
2. **Series panels lose the entire time dimension.** A `series` panel is summed to one grand total per series (`report-data.ts`, `case 'series'`). The fixture has 3 buckets × 2 models with distinct values; the CSV has 2 rows and **no `bucket_start` column exists anywhere in the file**. `spend-over-time`, `cost-by-model`, `tokens-by-actor`, `adoption-over-time`, `estate-spend` etc. all export as a 2-row summary of a chart the reader can no longer reconstruct.
3. **Money is a display string, not a number.** `$15.00`, `$0.0038`, and for sub-micro amounts `<$0.000001`. `formatUsd`'s adaptive-precision ladder is right for a screen and wrong for a column: the decimal count varies per row, the `$` blocks `SUM()`, and `<$0.000001` is not parseable at all. (Micro-USD is *not* printed raw — that part is correct — but the string form is unusable.)
4. **Counts carry thousands separators.** `74,112` / `7,407,402` from `toLocaleString`, which forces RFC 4180 quoting and lands in Excel as text. In German they become `15.003` — a dot-grouped integer that en-US Excel reads as **15.003**, three orders of magnitude wrong — *in the same file* as `74,112`, because `panel-adapters.tsx` hardcodes `toLocaleString('en-US')` for table cells while `report-data.ts` uses `toLocaleString(locale)` for latency samples.
5. **Latency is `"102 ms"`,** a number glued to a unit.
6. **`Share` is prose:** `100%`, `<1%`, `—`. Not a number, and derivable anyway — it does not belong in an export.
7. **Missing values are em dashes.** `—` for a null p99 and for every destroyed identity cell. Unknown must be an empty field, not a glyph.
8. **The `#` preamble is not CSV.** Seven leading lines of 1–3 fields, then ragged section headers (`# panel,total-cost,Total cost`) and bare `\r\n` separator lines. `pandas.read_csv`, `duckdb read_csv_auto` and Excel's own importer all choke or mis-infer; nothing in the file declares `#` as a comment character.
9. **Five different header schemas in one file, and no `panel` column.** `label,value,delta` / `Series,Total,Share` / `Name,Value,Share` / `Model,p50,p95,p99,Samples` / `Actor,Type,Cost,Requests,Tokens,Last active`. A row cannot be traced to its panel without parsing the comment lines above it.
10. **Column names follow the reader's locale.** `Reihe,Gesamt,Anteil` under `lb.locale=de`. Any pipeline keyed on the English names breaks when a colleague exports the same page. Worse, it is *half* translated: `label,value,delta` and `Type,Cost,Requests,Tokens,Last active` stay hardcoded English inside an otherwise German file, and the derived-metric series labels (`Active accounts`, `Active projects`) never translate at all.
11. **Compare twins are mixed into the rows.** `estate-spend` and `spend-over-time` emit `Total` and `Previous period` as sibling rows of one table, and then compute `Share` across both — producing a meaningless `50% / 50%` where the two "series" are the same metric over two different windows.
12. **Derived distinct-counts are summed across buckets.** `adoption-over-time` reports `Active accounts = 6` for a fixture with 2 accounts, because three per-bucket distinct counts were added together. That figure appears nowhere on screen and is simply wrong.
13. **Empty sections with a header and no rows.** `# panel,accounts-by-plan` emits `label,value,delta` and nothing — the panel legitimately has `stats: []`, but the file gives no way to tell that apart from a truncated section.
14. **No UTF-8 BOM.** Verified: the file starts `# /admin/usa`, and it does contain non-ASCII bytes (`—` U+2014, `–` U+2013). Excel on Windows opens it as CP-1252 and mojibakes them. (CRLF is already correct — 92 CRLF, 0 bare LF.)
15. **`.csv` is a lie about the file.** `Content-Disposition: attachment; filename="-.csv"` on a 17-table document. Related: `reportSlug` lowercases and collapses non-`[a-z0-9-]` to `-`, so an account id `acc_0` becomes `acc-0` in the filename.
16. **Deltas are pre-worded prose in a data column.** `no change vs Jul 1 – Jul 31` — English even in the German export, and the compared-against value itself is nowhere in the file.

### Expected Behavior

`GET /api/reports/page?path=&format=csv` returns **one** UTF-8-with-BOM, CRLF, RFC 4180 CSV per exported page. One header row, every subsequent line a record with the same field count, no comment lines, no blank separators, no per-panel sub-headers. Values are raw numbers; formatting is the consumer's job.

Header (fixed, English, snake_case, locale-independent):

```
route,window_start,window_end,generated_at,panel_id,panel_title,panel_type,series_key,series_label,series_role,bucket_start,metric,unit,value,value_text
```

Semantics:

| column | meaning |
| --- | --- |
| `route` | the `dashboards.yaml` route pattern this export is of |
| `window_start`, `window_end`, `generated_at` | ISO 8601 UTC, `Z`-suffixed, second precision (`2026-08-01T00:00:00Z`) |
| `panel_id` | the `dashboards.yaml` panel id — the column that replaces the `# panel,…` sections |
| `panel_title` | the reader-facing translated title (copy, so it follows the locale; the *column names* never do) |
| `panel_type` | `stat` \| `stat-group` \| `series` \| `latency-series` \| `ranked` \| `donut` \| `share` \| `table` \| `latency-cards` |
| `series_key` | the raw grouping key — the model id, actor id, account id, project id, channel id, api-key id. **Never** derived from a rendered React node. Empty for an ungrouped total. |
| `series_label` | the resolved human label for that key, or empty when unresolved. Never a substitute for `series_key`. |
| `series_role` | `current` for the window being reported, `previous` for a comparison twin. Twins are rows, but tagged, so they can be filtered apart. |
| `bucket_start` | ISO 8601 UTC start of the time bucket for a per-bucket observation; **empty** for a whole-window aggregate |
| `metric` | `cost_usd` \| `requests` \| `prompt_tokens` \| `completion_tokens` \| `total_tokens` \| `latency_p50_ms` \| `latency_p95_ms` \| `latency_p99_ms` \| `latency_samples` \| `active_accounts` \| `active_projects` \| `last_active` … — the metric the row states |
| `unit` | `usd` \| `count` \| `tokens` \| `ms` \| `timestamp` |
| `value` | the raw number: `.` as decimal separator, **no** thousands separator, **no** currency symbol, **no** unit suffix. Money in **USD with exactly 6 decimals** (micro-USD resolution, matching the legacy `total_cost_usd`). Counts, tokens and ms as integers. Empty when the value is unknown — never `0`, never `—`. |
| `value_text` | only for a genuinely non-numeric fact (an ISO 8601 `last_active`, a plan name). Empty otherwise. |

Shapes per panel type: a `series`/`latency-series` panel emits **one row per (series_key, bucket_start, metric)** — long format, the time dimension preserved. A `stat`/`stat-group` panel emits one row per stat with `bucket_start` empty. A `ranked`/`donut`/`share` panel emits one row per segment with `bucket_start` empty. A `table` panel emits one row per (row key, metric) — `cost_usd`, `requests`, `tokens` as numeric rows, `last_active` as a `value_text` row. Percentage shares are **not** emitted (derivable, and today they are prose). A panel whose query failed emits one row with `metric=unavailable` and `value_text` carrying the reason, so its absence is stated rather than inferred.

`/api/reports/consumption?format=csv` keeps its current shape byte-for-byte (it is already correct) and gains only the UTF-8 BOM.

**Recommendation: one file with a `panel_id` column, not a zip of one CSV per panel.** A zip cannot be opened by `pandas.read_csv`, `duckdb read_csv_auto`, a Google Sheets import, or `curl … | csvlook`; it needs a different client download path than the single `fetch`-and-save the export dialog uses today; and it multiplies filename-collision and encoding questions by the number of panels. The only thing the zip buys — a per-panel table — is recovered from the single file by one `WHERE panel_id = …` filter or one spreadsheet auto-filter, which is a smaller cost than "unzip first". The single long-format file is also the only shape that keeps the whole page joinable: comparing `cost_usd` against `requests` across panels is one pivot, not a join across archive members.

### Acceptance Criteria

- [ ] Given any exportable page (`/admin/overview`, `/admin/usage`, `/admin/usage/actors/[actorId]`, `/admin/usage/channels/[channelId]`, `/admin/usage/chats`, `/accounts/[accountId]/overview`, `/settings/overview/{account,project,user}`), when `GET /api/reports/page?format=csv` is called, then the body is a single RFC 4180 table: exactly one header row, no `#` comment lines, no blank lines, and every record has the same field count as the header.
- [ ] Given that export, then the header is exactly `route,window_start,window_end,generated_at,panel_id,panel_title,panel_type,series_key,series_label,series_role,bucket_start,metric,unit,value,value_text` — identical byte-for-byte for `lb.locale=en` and `lb.locale=de`. Only `panel_title`, `series_label` and `value_text` follow the reader's locale.
- [ ] Given a monetary metric, then `unit=usd` and `value` is USD rendered with exactly 6 decimals, `.` decimal separator, no `$`, no grouping — e.g. `15.000000`, `0.003804`. A sub-micro amount is `0.000000`, never `<$0.000001`; micro-USD is never emitted raw.
- [ ] Given a count, token or latency metric, then `value` is a bare integer — `74112`, not `74,112`, not `74.112`, not `102 ms` — under every locale.
- [ ] Given a `series` or `latency-series` panel over N buckets and M series, then the export contains one row per (series_key, bucket_start, metric) with `bucket_start` set — N×M rows for a single metric, not M summary rows — and `bucket_start`, `window_start`, `window_end`, `generated_at` are ISO 8601 UTC with a `Z` suffix, never epoch milliseconds.
- [ ] Given a `table` panel (`actors-table`, `channels-table`, `top-spender-accounts`, `top-spender-projects`), then `series_key` carries the real underlying key (`usr_…`, `acc_…`, `prj_…`, the channel id) and `series_label` the resolved name. No row anywhere in any export contains `—`; a React node is never stringified into a cell.
- [ ] Given a panel that carries a comparison twin (`compare: true` — `estate-spend`, `spend-over-time`, `total-cost`, `total-requests`), then the twin's rows are tagged `series_role=previous` and the current window's `series_role=current`; no share or percentage is computed across the two.
- [ ] Given a derived distinct-count series (`adoption-over-time`), then per-bucket counts are emitted per bucket and are **never** summed across buckets into a whole-window figure.
- [ ] Given an unknown or absent value (a null p99, an unresolved label, a panel with no stats), then the field is empty — never `—`, never `0`, never a fabricated figure. A failed panel emits exactly one row with `metric=unavailable` and the reason in `value_text`.
- [ ] Given the response body, then it begins with a UTF-8 BOM (`EF BB BF`) and every line ends `\r\n`; opening it by double-click in Excel (en-US and de-DE) shows correct `—`/`–`/umlaut glyphs and every `value` column right-aligned as a number, with `SUM()` over a `cost_usd` filter returning the panel total.
- [ ] Given `format=csv`, then `Content-Type: text/csv; charset=utf-8` and `Content-Disposition: attachment; filename="-.csv"` where `` preserves the route's param values without corrupting them (`acc_0` stays `acc_0`).
- [ ] `GET /api/reports/consumption?format=csv` still emits its current header and rows byte-for-byte (`project,model,requests,prompt_tokens,completion_tokens,total_tokens,total_cost_usd` + `TOTAL`), gaining only the BOM; its existing tests still pass unchanged.
- [ ] `format=pdf` and `format=html` are unaffected — the same `ReportDocument` still drives them, and the display-formatted `stats`/`table` fields they render are unchanged.
- [ ] Error cases are handled safely: `404 unknown_route`, `400 unexportable_route`/`invalid_filter` and the usage-backend failure paths behave exactly as today.
- [ ] Tests are added or updated: a snapshot/golden CSV per exported route, an en-vs-de header-identity test, a "no `—` anywhere" assertion, a bucket-count assertion (N×M rows for a series panel), and a numeric-parse assertion (`Number(value)` is finite for every non-empty `value`).
- [ ] Verification evidence is provided: the golden CSVs, the `pnpm --filter console test` tail, and a screenshot of one export opened in Excel/LibreOffice.

### Out of Scope

- Any change to `format=pdf` or `format=html`, to the `.typ` templates, or to `_lib/report.typ`.
- Any change to the on-screen rendering, to `formatUsd`'s adaptive ladder, or to `panel-adapters.tsx`'s **display** output. (Reading `row.key`/`row.label` instead of the rendered node is in scope; changing what the screen shows is not.)
- Adding new panels, new metrics, or new dashboard routes.
- A zip / multi-file download, XLSX output, or a scheduled-export feature.
- `/settings/overview/usage` (`scope: family`) stays unexportable — that is #455's decision, not this ticket's.

### Technical Context

- `apps/console/src/server/reports/report-csv.ts` — `reportCsvLines`/`reportCsv`: the `#` preamble, the per-panel sections, the ragged header rows. This is the module that has to change shape.
- `apps/console/src/server/reports/report-data.ts` — `panelTable` (the `series` sum-to-total, the `'—'` fallback at the `table` branch, `formatShare`, the `${Math.round(...)} ms` strings) and `panelStats` (`deltaWording`). `ReportDocument` currently carries only *display* strings; the CSV needs the raw values, so either `buildReport` grows a parallel machine-readable projection or the CSV path walks `toPanelView`'s output itself.
- `apps/console/src/dashboards/panel-adapters.tsx:849` — `cells.label: ` is the direct cause of the `—` identity column; `row.key` and `row.label` are right there in the same closure. Also hardcodes `toLocaleString('en-US')` for `requests`/`tokens`.
- `apps/console/src/app/api/reports/page/route.ts` — `finish()` sets the headers; the BOM and the filename live here.
- `apps/console/src/server/consumption-csv.ts` — the reference implementation for the numeric contract (`microUsdToUsd(...).toFixed(6)`, raw integer counts). Do not diverge from it; do add the BOM.
- `packages/ui-web/src/lib/money.ts` — `formatUsd` is display-only by design and must **not** be used on the CSV path.
- Harness for reproduction: `apps/console/src/server/reports/report-data.test.ts` builds a real `ResolvedDashboard` from the shipped `dashboards.yaml` with stubbed `UsageQueryResponse`s — the fastest way to golden-file every route.

### Risks

- **The report pipeline is shared with PDF/HTML.** Changing `ReportDocument` in place would ripple into `_lib/report.typ` and every shipped template. Mitigation: keep `ReportDocument` as the display document and give the CSV its own projection built from the same `toPanelView` output, so the Typst path is untouched (assert this with the existing template tests).
- **Consumers of the current CSV.** It is ~6 weeks old and structurally unparseable, so a downstream parser is unlikely — but the shape change is breaking by design. Mitigation: hard cutover in one PR, no `format=csv-v2`, and say so in the release note.
- **BOM + a streamed body.** The consumption route streams line-by-line; the BOM must be the first chunk, exactly once. Mitigation: a byte-level test on the first three bytes.
- **Locale-independent headers vs. the ADR 0017 "report follows the reader" rule.** These are deliberately different jobs: the *document* follows the reader, the *data column names* are an interface. Worth an explicit line in the ADR or the module docstring so it is not "fixed" back later.

### Test Plan

- `pnpm install`
- `pnpm --filter console test` — unit + the new golden CSVs.
- New unit tests in `apps/console/src/server/reports/`:
- golden CSV per exportable route (en), asserted whole-file;
- `de` export has a byte-identical header row to `en`;
- `expect(csv).not.toContain('—')` and `not.toMatch(/[$%]|\sms\b/)` on the `value` column;
- a series panel over 3 buckets × 2 series yields 6 rows with distinct `bucket_start` values;
- every non-empty `value` satisfies `Number.isFinite(Number(value))`;
- first three bytes are `EF BB BF`; every line ends `\r\n`.
- `apps/console/src/app/api/reports/consumption/route.test.ts` passes unchanged apart from the BOM assertion.
- `pnpm --filter console exec tsc --noEmit`, `pnpm lint`.
- `pnpm --filter console build` (the real Next build).
- Manual: export all four pages from the running console, open each in Excel (en-US and de-DE) and in LibreOffice, `SUM()` a `cost_usd` filter and check it matches the panel's on-screen total; `pandas.read_csv(path)` with no `comment=`/`skiprows=` argument.

### Verification evidence

Reproduction was run against `origin/main` @ `ef6ff09` in an isolated worktree.

```
$ git checkout --detach origin/main # ef6ff09
$ pnpm install # exit 0
$ pnpm --filter console exec vitest run --project node src/server/reports/.test.ts
✓ |node| src/server/reports/.test.ts (2 tests) 73ms
Test Files 1 passed (1)
Tests 2 passed (2)
```

The scratch harness (deleted afterwards; the worktree is clean) called `buildReport` + `reportCsv` with the shipped `dashboards.yaml`, `translateDashboardPage`, `resolveDashboard` and `toPanelView`, over a stubbed usage backend of 3 buckets × 2 models with per-bucket-distinct cost/requests/token values, for both `en` and `de`. All excerpts under **Current Behavior** are verbatim output from that run.

Byte-level check on the produced `/admin/usage` CSV:

```
first 12 bytes: b'# /admin/usa'
has BOM: False
CRLF count: 92 bare LF: 0
non-ascii bytes present: [0x80, 0x93, 0x94, 0xe2] # — U+2014 and – U+2013
```

Cross-checked in the emitted `data.json` that the `—` cells are produced by the builder and are not a rendering artefact:

```
actors-table ['Actor', 'Type', 'Cost', 'Requests', 'Tokens', 'Last active']
[['—', 'Actor', '$15.00', '74,070', '7,407,402', '2026-08-03 00:00 UTC'],
['—', 'Actor', '$0.0038', '42', '534', '2026-08-03 00:00 UTC']]
channels-table ['Channel', 'Cost', 'Requests']
[['—', '$15.00', '74,112']]
```

Remaining limitations of this investigation: the reproduction drove the pure builder rather than the HTTP route, so `Content-Disposition`/`Content-Type` were read from `apps/console/src/app/api/reports/page/route.ts` rather than observed on a live response; `/admin/usage/channels/[channelId]`, `/admin/usage/chats` and the three `/settings/overview/*` pages were not dumped, but they go through the identical `reportCsv` path and the same panel types, so the same defects apply. No fix was attempted and no code was changed — the worktree is at a clean `ef6ff09`.

### Human accountable owner

@stephane-segning

### AI Usage Declaration

Drafting the ticket, Understanding code

### Human verification completed

- [x] I understood the intent
- [x] I checked the source of truth
- [x] I reviewed all AI-generated text/code
- [x] I verified the implementation manually
- [x] I checked for hallucinated assumptions
- [x] I documented remaining risks
- [x] I am the accountable owner and accept responsibility for this ticket.

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.