# ТЗ: Custom Panels for Reanimator Chart **Status:** proposal / draft for review **Requesting app:** LOGPile (customer-data / privacy scan panel) **Applies to:** `reanimator/chart` viewer module, all embedding apps **Note:** authored in the LOGPile repo; to be moved into the `reanimator/chart` repo when accepted. Paths below (`docs/`, `bible-local/`) are chart-repo-relative. ## 1. Problem `chart` renders one Reanimator JSON snapshot as a fixed set of hardware sections (`board`, `firmware`, `cpus`, ...). Embedding apps increasingly need to show their own presentational blocks alongside that data - LOGPile wants a "Customer data" panel above `cpus`; other apps have asked for build/QA notes, RMA context, collection warnings, links back to a ticket. Today the only lever is `RenderOptions.NoticeTitle` / `NoticeBody` - a single top-of-page title + one paragraph. It cannot carry a table, cannot be positioned, and there is only one of it. Apps work around this by post-processing the chart HTML or forking the templates, which breaks on every `chart` update. `chart` is embedded in several apps, so the mechanism must be **standardized and reusable**, not LOGPile-specific. ## 2. Goals - A stable, versioned data contract for host-supplied panels. - Panels can be placed relative to the built-in sections (e.g. before `cpus`). - Reuse the existing section-card / kv-table / data-table presentation and CSS - no new heavy UI, no client framework. - Works through every integration path: in-process `RenderHTML*`, embedded `POST /render`, and standalone file upload. - Preserves the "read-only, schema-preserving, does not compute" contract (see `bible-local/decisions/2026-03-15-read-only-schema-preserving-viewer.md`). - Backward compatible: no field / key present -> byte-identical output. ## 3. Non-goals - Interactivity beyond what built-in sections already have (severity filter). - Panels influencing or overriding built-in section data. - Arbitrary HTML/CSS/JS injection from the host. - Per-viewer persistence, collapse state sync, theming knobs. - Replacing `platform_config` or the hardware sections. ## 4. Data contract ### 4.1 Envelope ```jsonc { "version": "1.0", "panels": [ Panel, ... ] } ``` `version` is the panels contract version (`chart` exposes `viewer.PanelsContractVersion`). `chart` renders a known major version; an unknown major version renders each panel's `title` plus a muted "panel format vX not supported by this viewer" line (never a silent drop). ### 4.2 Panel | Field | Type | Required | Notes | |-------|------|----------|-------| | `id` | string | yes | Stable slug, `[a-z0-9-]+`, unique within the set. Rendered as the card's DOM id, prefixed `ext-` to avoid collision with built-in section ids. | | `title` | string | yes | Card header text. | | `placement` | string | no | `top` (default), `bottom`, `before:`, `after:`. `` is a built-in section id (`board`, `firmware`, `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`, `licenses`, `sensors`, `event_logs`, `platform_config`) or another panel's rendered id. | | `severity` | string | no | `""` \| `info` \| `warning` \| `critical`. Drives the card's left-accent color only, reusing existing status colors. | | `blocks` | Block[] | yes | 1..N content blocks, rendered top to bottom. | | `collapsed` | bool | no | Initial collapsed state (default expanded). Uses the same collapse affordance as built-in cards if/when present; otherwise ignored. | ### 4.3 Block Tagged union on `kind`: ```jsonc { "kind": "text", "text": "one or more paragraphs, \n splits paragraphs" } { "kind": "keyvalue", "rows": [ { "key": "Customer", "value": "acme.ru" }, ... ] } { "kind": "list", "items": [ "line 1", "line 2", ... ] } { "kind": "links", "links": [ { "label": "Ticket OPS-649", "href": "https://..." } ] } { "kind": "table", "columns": [ "severity", "category", "location", "match" ], "rows": [ { "cells": { "category": "domain", "location": "resolv.conf:4", "match": "acme.ru" }, "severity": "warning" } ] } ``` Table block details: - `columns` is the display order. A `severity` column renders as the existing icon-only status badge; all other cells render as text (multi-line on `\n`). - `rows[].severity` (optional) feeds row coloring and the severity filter, exactly like built-in table sections. - No sorting/pagination in v1. The existing per-table severity filter applies. Unknown `kind` -> a muted "unsupported block kind: X" line. Never dropped. ### 4.4 Escaping / safety - Every string is HTML-escaped by the template (`html/template`), as today. - `links[].href`: only `http`, `https`, or root-relative (`/...`) accepted; anything else is rendered as inert text. `rel="noopener noreferrer"`, `target="_top"`. - Total injected payload is size-capped (proposal: 256 KiB after JSON decode); over the cap -> panels ignored + one top notice "custom panels omitted (too large)". - Panel count cap (proposal: 32). ## 5. Delivery paths The same envelope reaches `chart` three ways; all are merged (options win over snapshot key on `id` conflict): 1. **In-process** - `viewer.RenderOptions.Panels []viewer.Panel` (and a matching `viewer.RenderHTMLWithOptions` already exists). Type-safe, preferred for embedders that server-render (LOGPile does). 2. **Embedded HTTP** - `POST /render` accepts an optional part: - multipart field `panels` (JSON), or - request header `X-Chart-Panels: base64(json)` for the raw-JSON body case. 3. **Snapshot-embedded** - reserved top-level key `"_chart_panels"` in the snapshot JSON (underscore prefix = explicitly not Reanimator payload). `chart` already tolerates unknown top-level keys; this one is consumed and not shown in Snapshot Metadata. `NoticeTitle` / `NoticeBody` stay as a convenience shorthand, internally converted to a single `{id:"notice", placement:"top", severity:"info", blocks:[{kind:"text",...}]}` panel. Documented as legacy-but-supported. ## 6. Rendering rules - Panels render as `section-card section-card-full` with an optional `section-card--sev-{severity}` accent class (new CSS, ~6 lines). - Placement resolution order: build the ordered list of built-in sections, then for each panel insert at its anchor. `before:/after:` an unknown id -> fall back to `top` and emit nothing visible (no error). `top` = after Snapshot Metadata, before the first section. `bottom` = after the last section, before the error/empty panels. - Panel-to-panel anchors are resolved in input order (a panel can anchor to an earlier panel's id). - Section nav (if present) lists panels alongside built-in sections using `title` + `id`. ## 7. Public API additions (`viewer` package) ```go const PanelsContractVersion = "1.0" type Panel struct { ID string Title string Placement string // "", "top", "bottom", "before:", "after:" Severity string // "", "info", "warning", "critical" Collapsed bool Blocks []Block } type Block struct { Kind string // "text" | "keyvalue" | "list" | "links" | "table" Text string `json:",omitempty"` Rows []KeyValue `json:",omitempty"` // keyvalue Items []string `json:",omitempty"` // list Links []Link `json:",omitempty"` Table *TableBlock `json:",omitempty"` } type KeyValue struct{ Key, Value string } type Link struct{ Label, Href string } type TableBlock struct { Columns []string Rows []TableBlockRow } type TableBlockRow struct { Cells map[string]string Severity string } // RenderOptions gains: type RenderOptions struct { // ... existing fields ... Panels []Panel } ``` JSON tags on these types are the wire contract for paths 2 and 3. ## 8. Backward compatibility & versioning - No panels supplied anywhere -> output unchanged (golden test). - `chart` module version: minor bump (feature, no breaking change). - Contract version `1.0`; `chart` accepts `1.x`, rejects `2.x` gracefully. - Existing `NoticeTitle/Body` behavior unchanged. ## 9. Testing requirements - Placement: `top`, `bottom`, `before:cpus`, `after:storage`, unknown target, panel-anchored-to-panel, multiple panels same anchor (stable order). - Each block kind renders; `keyvalue` and `table` reuse existing table CSS classes. - `table` severity column -> icon badge; row severity -> filter + row class. - Escaping: `