SuperPlane console and widgets
Use this skill when working on per-canvas consoles: the console mode overlay, typed panels, widget renderers, YAML import/export, or backend validation.
Canonical reference: docs/prd/console-and-widgets.md — read it for full schemas, examples, and maintenance notes. This skill is the operational subset for agents.
Product rules (do not break)
- One console per canvas (not templates). Stored as versioned JSON
panels + layout on the canvas version.
- Console mode hides the graph; 12-column
react-grid-layout (ConsoleView).
- Edit (panels, layout, YAML import):
canvases:update, not template, canvas not deleted.
- Run (node panel Run, table / board row actions): same as edit —
InvokeNodeTriggerHook; UI uses canRunNodes.
- YAML import is replace-all (max 50 panels, 1 MiB payload).
- User-facing name: SuperPlane (capital P).
- Row actions are
kind: trigger only — they fire trigger nodes; they do not call HTTP Request nodes directly.
Layer map
| Layer |
Key paths |
| Console page |
web_src/src/pages/app/console/ConsoleView.tsx — grid, Add Panel picker, YAML modal wiring |
| Context |
console/ConsoleContext.tsx, ConsoleContextProvider.tsx |
| Trigger hook |
console/useConsoleRunTrigger.ts, useConsoleTriggerLock.ts |
| Panel router |
console/ConsolePanelCards.tsx |
| Schema |
console/panelTypes.ts — types, templates, validators, normalizeTablePanelContent, normalizeBoardPanelContent |
| YAML (FE) |
console/consoleYaml.ts, ConsoleYamlModal.tsx |
| Widget data |
console/widget/useWidgetData.ts |
| Widget UI |
console/widget/WidgetTable.tsx, WidgetBoard.tsx, WidgetChart.tsx, WidgetNumber.tsx, WidgetScorecard.tsx |
| Backend |
pkg/yaml/console.go — YAML import/export + validators |
| Proto |
protos/canvases.proto — console panels live on the canvas version |
Invariant: panelTypes.ts validators (plus satellite modules like boardPanelContent.ts and nodesPanelContent.ts), pkg/yaml/console.go, and widget types.ts must agree. Frontend fast-fails; backend is authoritative on import.
Node references: always accept id or name via resolveConsoleNode in ConsoleContext.tsx.
Panel types
type |
Runtime |
Main content |
markdown |
GFM body with {{ name.field }} interpolation |
title?, body?, variables? |
html |
Sanitized HTML body with {{ name.field }} interpolation, scoped <style>, Tailwind via safelist |
title?, body?, variables? |
nodes |
Adaptive card: one entry uses the compact single-node layout; multiple entries render as a row list. Optional per-entry Run button (manual-run triggers only). Optional formMode: "inline" renders the trigger parameter form directly in the widget body (prompt-submission style) for manual-run start triggers that have parameters. Inline entries can suppress the redundant node/field labels and customize submit copy. |
title?, nodes[] with node, label?, description?, showRun?, triggerName?, promptConfirmation?, formMode?, showNodeLabel?, showFieldLabels?, submitLabel? |
node (legacy) |
Same renderer as nodes — the merged card folds legacy single-node content into a one-entry list. Kept for import compatibility; migrates to nodes on first save. |
node, showRun?, triggerName? |
table |
WidgetTable |
dataSource, render.kind: "table" |
board |
WidgetBoard — kanban lanes grouped by a scalar groupBy field; same data sources / filters / row actions as the table panel |
dataSource, render.kind: "board" with groupBy, lanes[], card, optional otherLane, where, sort, rowActions |
chart |
WidgetChart (SVG) |
dataSource, render.kind: "chart" |
number |
WidgetNumber |
dataSource, render.kind: "number" |
scorecard |
WidgetScorecard — single KPI only (no multi-KPI or composite memory); adds change vs the immediately previous value in the series, direction-aware target/progress, and a status-colored sparkline via the shared Sparkline |
dataSource, render.kind: "scorecard" with aggregation, optional field, better, target, showProgress, sparklineField, showChange, changeCaption |
New panels: templateForPanelType in panelTypes.ts. Draft states (e.g. empty memory namespace) should stay valid where possible.
Data sources (useWidgetData)
{ kind: "memory", namespace: string, fieldPath?: string }
{ kind: "executions", node?: string, limit?: number }
{ kind: "runs", limit?: number }
| Kind |
Query |
Notes |
memory |
useCanvasMemoryEntries |
Filter by namespace; fieldPath flattens nested lists (memoryRow.ts) |
executions |
useInfiniteCanvasEvents |
Flatten executions[]; optional node filter; eager pages until limit or cap (~500 events) |
runs |
useInfiniteCanvasRuns |
totalCount for count KPIs |
Execution rows get status, nodeName, durationMs. Status vocabulary: passed, failed, running, pending, cancelled, unknown.
Table panels (most complex)
Columns
Non-empty field; optional label, format (text, number, status, relative, link, trend, …), show, href. format: trend also accepts trendBetter (up/down, default up) and trendDisplay (percent/value/none, default percent); the cell compares against the row directly below in the filtered/sorted table (or the first already-loaded row still hidden by the progressive display window).
Filters
render.where[] — AND list; ops: eq, neq, contains, not_contains, gt, lt, exists, not_exists.
Row actions (trigger)
Required: kind: trigger, node (id or name). Optional: hook (default run), template, payload, confirm, show, variant, icon.
Runtime flow: WidgetTable / WidgetBoard → WidgetRowActionButton → mergeTriggerPayload → onTriggerNode → useConsoleRunTrigger → InvokeNodeTriggerHook → invalidate events/runs/memory queries.
Legacy fields normalized in FE: target → node, triggerName → template.
Manual-run gate: only the built-in start and schedule triggers expose a user-invokable run hook. The UI filters on node.component against the hardcoded allowlist in web_src/src/pages/app/console/manualRunTriggers.ts — TablePanelForm and BoardPanelForm hide non-manual triggers from the dropdown, WidgetTable / WidgetBoard hide their row actions, and NodesPanelCard/NodesPanelForm hide the Run affordance. Backend authorization stays in InvokeNodeTriggerHook; adding a new manual-run trigger requires a matching entry in the frontend allowlist.
Expressions
{{ CEL }} — @marcbachmann/cel-js via widget/celExpr.ts; row env + now (Unix seconds). The adapter upfront-coerces safe-integer JS numbers (and, on retry, numeric strings) to BigInt for int arithmetic, and normalizes safe-integer BigInt results back to plain number on the way out.
- Legacy
show — e.g. status == "running" (showExpression.ts, rowVisibility.ts).
- Prefer structured
where for simple validated filters.
Lint: loose equality in legacy expressions is intentional (scalar normalization). Do not add eslint-disable for == in dashboard code; refactor instead.
Editor memory hints: MemoryDiscoveryPanel.tsx, useMemoryCatalog.ts (suggestions only; YAML still validated).
Markdown variables
content.variables[] carries named live data refs; body uses {{ name.field }} (or {{ name.$["Node"].data.x }} for runs).
- Sources:
{ kind: "memory", namespace, orderBy?, direction?, matches?, mode?, limit? } (default mode: single first-row wins, orderBy: createdAt desc) or { kind: "run", select: latest | latest_passed | latest_failed }.
mode: list resolves the memory variable to the full sorted array of matching rows (optionally capped by limit), unlocking CEL list macros (rows.map(r, ...).filter(...)) inside {{ }}; pair with the join(list, sep) builtin in celExpr.ts to flatten into Markdown / HTML.
- Resolution lives in
useMarkdownVariables.ts (pickMemoryRows is the exported helper that branches on mode); interpolation in markdownInterpolation.ts (reuses celExpr.compileTemplate/evalTemplate). Validation: markdownVariables.ts (FE, including validateMarkdownContent) + validateMarkdownContent / validateHTMLContent in pkg/models/console_yml.go (BE).
- Run vars expose
status, nodeName, payload, durationMs, and a $ map of node executions (same shape as the table widget).
HTML widget safety
- Render pipeline (
HtmlBody.tsx): interpolate variables → DOMPurify allow-list → scope <style> blocks → dangerouslySetInnerHTML into div[data-console-html-root="<id>"].
- Sanitizer (
htmlSanitize.ts) blocks <script> and all on* handlers, removes head-like and resource-fetching elements (link, meta, base, iframe, object, embed, audio, video, form, svg, math, …), allows <img src>/<img srcset> for http(s)/relative URLs (cross-origin image fetches are permitted by policy), strips poster/background/data/xlink:href, restricts href/src/srcset to http(s)/mailto:/tel:/fragments, and rewrites every <style> rule to scope selectors under the widget root while dropping @import, url(...), and unknown at-rules.
- Tailwind v4 classes must be in the curated
@source inline(...) safelist in web_src/src/App.css to apply at runtime — extend it conservatively, never bypass it.
Chart and number
Chart render.type: bar, stacked-bar, line, area, donut. xField + series[]; omit series[].field to count rows per bucket.
Number aggregations: count, sum, avg, min, max, first, last — non-count requires field.
Scorecard shares the number aggregation vocabulary but is single-KPI only (no multi-KPI / composite memory). Comparison model:
- Change = current value vs the immediately previous value in the series. The series is derived from
sparklineField when set, or the primary field as a fallback. Only first / last aggregations expose a natural "previous" (adjacent anchor via pickChangeAnchors); combining aggregations (sum / avg / min / max / count) hide the chip. Reuses computeTrend (widgetTrend.ts) for percent/absolute math.
- Target = literal number or
{{ CEL }} (evaluated against the newest filtered row + now), used for optional showProgress and fallback status color.
better: "up" | "down" controls the polarity for the value change, the sparkline, and the vs-target status.
- The form relabels the two directional aggregations as
Latest / Earliest because all data sources are newest-first (first → Latest, last → Earliest). Persisted YAML still uses first / last.
Helpers live in widget/scorecardMath.ts (extractScorecardSeries, pickChangeAnchors, resolveScorecardTarget, computeScorecardProgress, computeScorecardChange, resolveScorecardStatus, formatScorecardChangeLabel). Rendering is in widget/WidgetScorecard.tsx; the sparkline itself comes from the shared widget/Sparkline.tsx (shared with WidgetNumber) with a className prop for status coloring.
YAML
apiVersion: v1
kind: Console
metadata:
canvasId: <uuid> # export only; ignored on import
name: <display>
spec:
panels: [{ id, type, content }]
layout: [{ i, x, y, w, h, minW?, minH? }]
- FE:
consoleYaml.ts — parse/serialize + validatePanelContent
- BE:
ConsoleFromYML / VersionToConsoleYML in pkg/yaml/console.go
- Unknown fields rejected; missing
panels/layout → empty lists
Agent workflows
Fix a console bug
- Reproduce in console mode (not template); note panel
type and dataSource.kind.
- Trace: panel card →
useWidgetData → widget renderer → (if trigger) useConsoleRunTrigger.
- Check permissions in
pkg/authorization/interceptor.go if RPC-related.
- Add/update test under
web_src/src/pages/app/console/**/*.spec.ts.
Add or change panel content fields
widget/types.ts (if widget-facing)
panelTypes.ts — interface, templateForPanelType, validatePanelContent, normalization (satellite modules like boardPanelContent.ts / nodesPanelContent.ts follow the same pattern)
pkg/yaml/console.go — mirror validation + tests
- Panel card + form component
- YAML tests:
consoleYaml.spec.ts / consoleYaml.validation.spec.ts, pkg/yaml/console_test.go
Add a new panel type
PANEL_TYPES, PANEL_TYPE_META, validator, template in panelTypes.ts
ConsolePanelType* constant + AllowedConsolePanelTypes in pkg/yaml/console.go and a per-type validator
*PanelCard.tsx + case in ConsolePanelCards.tsx
- Icon in
ConsoleView.tsx PANEL_TYPE_ICONS
- Update docs/prd/console-and-widgets.md
Add a data source kind
- Extend types in
widget/types.ts + panelTypes.ts
DataSourceForm.tsx editor
- Branch in
useWidgetData.ts
- Backend YAML validator + tests
Configure memory table (user/agent task)
Use PRD example; namespace must match canvas memory keys. Row actions target trigger nodes only.
Verification
# Frontend unit tests (console package)
cd web_src && npm run test:run -- src/pages/app/console
# After UI edits (Docker dev env)
make format.js
make check.lint.ui
make check.build.ui
# After Go validation/API edits
make format.go
make lint
make check.build.app
go test ./pkg/yaml -count=1
go test ./pkg/grpc/actions/canvases -count=1
Repo conventions
- No
web_src/src/utils/* — use lib/ or hooks/.
- Console has strict ESLint budget — refactor touched code; do not raise the budget.
- Split large components for Fast Refresh where the codebase already does.
- Never hand-write DB migrations;
make db.migration.create NAME=<dash-name> if persistence changes.
- AGENTS.md: protobuf enum mapping, authorization on new RPCs.
Quick file index
| Task |
Start here |
| Grid / add panel |
ConsoleView.tsx |
| Table CEL / filters / actions |
WidgetTable.tsx, WidgetRowActionButton.tsx, celExpr.ts, evalTableWhere.ts, mergeTriggerPayload.ts |
| Table editor |
TablePanelForm.tsx, TablePanelFormRows.tsx |
| Board renderer / editor |
WidgetBoard.tsx, BoardPanelCard.tsx, BoardPanelForm.tsx, boardPanelContent.ts |
| Trigger from console |
useConsoleRunTrigger.ts, consoleTriggerParameters.ts |
| Node status chip / Run button |
NodesPanelCard.tsx, NodesPanelInlineRunForm.tsx, useConsoleRunTrigger.ts, useConsoleTriggerLock.ts, deriveNodeStatuses.ts |
| API hooks |
web_src/src/hooks/useCanvasData.ts — useCanvasVersion, useUpdateCanvasVersion |
1---2name: superplane-dashboard-and-widgets3description: Implements and configures SuperPlane canvas consoles (markdown, node, table, board, chart, number, scorecard panels), widget data sources, CEL/templates, table row trigger actions, and console YAML. Use when editing console UI, panelTypes, useWidgetData, WidgetTable, WidgetBoard, canvas_console_yml, Get/UpdateCanvasConsole, or docs/prd/console-and-widgets.md.4---56# SuperPlane console and widgets78Use this skill when working on **per-canvas consoles**: the console mode overlay, typed panels, widget renderers, YAML import/export, or backend validation.910**Canonical reference:** [docs/prd/console-and-widgets.md](../../../docs/prd/console-and-widgets.md) — read it for full schemas, examples, and maintenance notes. This skill is the operational subset for agents.1112---1314## Product rules (do not break)1516- One console per **canvas** (not templates). Stored as versioned JSON `panels` + `layout` on the canvas version.17- Console mode hides the graph; **12-column** `react-grid-layout` (`ConsoleView`).18- **Edit** (panels, layout, YAML import): `canvases:update`, not template, canvas not deleted.19- **Run** (node panel Run, table / board row actions): same as edit — `InvokeNodeTriggerHook`; UI uses `canRunNodes`.20- YAML import is **replace-all** (max **50** panels, **1 MiB** payload).21- User-facing name: **SuperPlane** (capital P).22- Row actions are **`kind: trigger` only** — they fire trigger nodes; they do not call HTTP Request nodes directly.2324---2526## Layer map2728| Layer | Key paths |29| --- | --- |30| Console page | `web_src/src/pages/app/console/ConsoleView.tsx` — grid, Add Panel picker, YAML modal wiring |31| Context | `console/ConsoleContext.tsx`, `ConsoleContextProvider.tsx` |32| Trigger hook | `console/useConsoleRunTrigger.ts`, `useConsoleTriggerLock.ts` |33| Panel router | `console/ConsolePanelCards.tsx` |34| Schema | `console/panelTypes.ts` — types, templates, validators, `normalizeTablePanelContent`, `normalizeBoardPanelContent` |35| YAML (FE) | `console/consoleYaml.ts`, `ConsoleYamlModal.tsx` |36| Widget data | `console/widget/useWidgetData.ts` |37| Widget UI | `console/widget/WidgetTable.tsx`, `WidgetBoard.tsx`, `WidgetChart.tsx`, `WidgetNumber.tsx`, `WidgetScorecard.tsx` |38| Backend | `pkg/yaml/console.go` — YAML import/export + validators |39| Proto | `protos/canvases.proto` — console panels live on the canvas version |4041**Invariant:** `panelTypes.ts` validators (plus satellite modules like `boardPanelContent.ts` and `nodesPanelContent.ts`), `pkg/yaml/console.go`, and widget `types.ts` must agree. Frontend fast-fails; backend is authoritative on import.4243**Node references:** always accept **id or name** via `resolveConsoleNode` in `ConsoleContext.tsx`.4445---4647## Panel types4849| `type` | Runtime | Main `content` |50| --- | --- | --- |51| `markdown` | GFM body with `{{ name.field }}` interpolation | `title?`, `body?`, `variables?` |52| `html` | Sanitized HTML body with `{{ name.field }}` interpolation, scoped `<style>`, Tailwind via safelist | `title?`, `body?`, `variables?` |53| `nodes` | Adaptive card: one entry uses the compact single-node layout; multiple entries render as a row list. Optional per-entry Run button (manual-run triggers only). Optional `formMode: "inline"` renders the trigger parameter form directly in the widget body (prompt-submission style) for manual-run start triggers that have parameters. Inline entries can suppress the redundant node/field labels and customize submit copy. | `title?`, `nodes[]` with `node`, `label?`, `description?`, `showRun?`, `triggerName?`, `promptConfirmation?`, `formMode?`, `showNodeLabel?`, `showFieldLabels?`, `submitLabel?` |54| `node` *(legacy)* | Same renderer as `nodes` — the merged card folds legacy single-node content into a one-entry list. Kept for import compatibility; migrates to `nodes` on first save. | `node`, `showRun?`, `triggerName?` |55| `table` | `WidgetTable` | `dataSource`, `render.kind: "table"` |56| `board` | `WidgetBoard` — kanban lanes grouped by a scalar `groupBy` field; same data sources / filters / row actions as the table panel | `dataSource`, `render.kind: "board"` with `groupBy`, `lanes[]`, `card`, optional `otherLane`, `where`, `sort`, `rowActions` |57| `chart` | `WidgetChart` (SVG) | `dataSource`, `render.kind: "chart"` |58| `number` | `WidgetNumber` | `dataSource`, `render.kind: "number"` |59| `scorecard` | `WidgetScorecard` — single KPI only (no multi-KPI or composite memory); adds change vs the immediately previous value in the series, direction-aware target/progress, and a status-colored sparkline via the shared `Sparkline` | `dataSource`, `render.kind: "scorecard"` with `aggregation`, optional `field`, `better`, `target`, `showProgress`, `sparklineField`, `showChange`, `changeCaption` |6061New panels: `templateForPanelType` in `panelTypes.ts`. Draft states (e.g. empty memory namespace) should stay valid where possible.6263---6465## Data sources (`useWidgetData`)6667```ts68{ kind: "memory", namespace: string, fieldPath?: string }69{ kind: "executions", node?: string, limit?: number }70{ kind: "runs", limit?: number }71```7273| Kind | Query | Notes |74| --- | --- | --- |75| `memory` | `useCanvasMemoryEntries` | Filter by namespace; `fieldPath` flattens nested lists (`memoryRow.ts`) |76| `executions` | `useInfiniteCanvasEvents` | Flatten `executions[]`; optional node filter; eager pages until `limit` or cap (~500 events) |77| `runs` | `useInfiniteCanvasRuns` | `totalCount` for count KPIs |7879Execution rows get `status`, `nodeName`, `durationMs`. Status vocabulary: `passed`, `failed`, `running`, `pending`, `cancelled`, `unknown`.8081---8283## Table panels (most complex)8485### Columns8687Non-empty `field`; optional `label`, `format` (`text`, `number`, `status`, `relative`, `link`, `trend`, …), `show`, `href`. `format: trend` also accepts `trendBetter` (`up`/`down`, default `up`) and `trendDisplay` (`percent`/`value`/`none`, default `percent`); the cell compares against the row directly below in the filtered/sorted table (or the first already-loaded row still hidden by the progressive display window).8889### Filters9091`render.where[]` — AND list; ops: `eq`, `neq`, `contains`, `not_contains`, `gt`, `lt`, `exists`, `not_exists`.9293### Row actions (trigger)9495Required: `kind: trigger`, `node` (id or name). Optional: `hook` (default `run`), `template`, `payload`, `confirm`, `show`, `variant`, `icon`.9697Runtime flow: `WidgetTable` / `WidgetBoard` → `WidgetRowActionButton` → `mergeTriggerPayload` → `onTriggerNode` → `useConsoleRunTrigger` → `InvokeNodeTriggerHook` → invalidate events/runs/memory queries.9899Legacy fields normalized in FE: `target` → `node`, `triggerName` → `template`.100101Manual-run gate: only the built-in `start` and `schedule` triggers expose a user-invokable `run` hook. The UI filters on `node.component` against the hardcoded allowlist in `web_src/src/pages/app/console/manualRunTriggers.ts` — `TablePanelForm` and `BoardPanelForm` hide non-manual triggers from the dropdown, `WidgetTable` / `WidgetBoard` hide their row actions, and `NodesPanelCard`/`NodesPanelForm` hide the Run affordance. Backend authorization stays in `InvokeNodeTriggerHook`; adding a new manual-run trigger requires a matching entry in the frontend allowlist.102103### Expressions104105- **`{{ CEL }}`** — `@marcbachmann/cel-js` via `widget/celExpr.ts`; row env + `now` (Unix seconds). The adapter upfront-coerces safe-integer JS numbers (and, on retry, numeric strings) to `BigInt` for int arithmetic, and normalizes safe-integer BigInt results back to plain `number` on the way out.106- **Legacy** `show` — e.g. `status == "running"` (`showExpression.ts`, `rowVisibility.ts`).107- Prefer structured `where` for simple validated filters.108109**Lint:** loose equality in legacy expressions is intentional (scalar normalization). Do not add `eslint-disable` for `==` in dashboard code; refactor instead.110111Editor memory hints: `MemoryDiscoveryPanel.tsx`, `useMemoryCatalog.ts` (suggestions only; YAML still validated).112113### Markdown variables114115- `content.variables[]` carries named live data refs; body uses `{{ name.field }}` (or `{{ name.$["Node"].data.x }}` for runs).116- Sources: `{ kind: "memory", namespace, orderBy?, direction?, matches?, mode?, limit? }` (default `mode: single` first-row wins, `orderBy: createdAt desc`) or `{ kind: "run", select: latest | latest_passed | latest_failed }`.117- `mode: list` resolves the memory variable to the full sorted array of matching rows (optionally capped by `limit`), unlocking CEL list macros (`rows.map(r, ...).filter(...)`) inside `{{ }}`; pair with the `join(list, sep)` builtin in `celExpr.ts` to flatten into Markdown / HTML.118- Resolution lives in `useMarkdownVariables.ts` (`pickMemoryRows` is the exported helper that branches on mode); interpolation in `markdownInterpolation.ts` (reuses `celExpr.compileTemplate`/`evalTemplate`). Validation: `markdownVariables.ts` (FE, including `validateMarkdownContent`) + `validateMarkdownContent` / `validateHTMLContent` in `pkg/models/console_yml.go` (BE).119- Run vars expose `status`, `nodeName`, `payload`, `durationMs`, and a `$` map of node executions (same shape as the table widget).120121### HTML widget safety122123- Render pipeline (`HtmlBody.tsx`): interpolate variables → DOMPurify allow-list → scope `<style>` blocks → `dangerouslySetInnerHTML` into `div[data-console-html-root="<id>"]`.124- Sanitizer (`htmlSanitize.ts`) blocks `<script>` and all `on*` handlers, removes head-like and resource-fetching elements (`link`, `meta`, `base`, `iframe`, `object`, `embed`, `audio`, `video`, `form`, `svg`, `math`, …), allows `<img src>`/`<img srcset>` for `http(s)`/relative URLs (cross-origin image fetches are permitted by policy), strips `poster`/`background`/`data`/`xlink:href`, restricts `href`/`src`/`srcset` to `http(s)`/`mailto:`/`tel:`/fragments, and rewrites every `<style>` rule to scope selectors under the widget root while dropping `@import`, `url(...)`, and unknown at-rules.125- Tailwind v4 classes must be in the curated `@source inline(...)` safelist in `web_src/src/App.css` to apply at runtime — extend it conservatively, never bypass it.126127---128129## Chart and number130131**Chart** `render.type`: `bar`, `stacked-bar`, `line`, `area`, `donut`. `xField` + `series[]`; omit `series[].field` to count rows per bucket.132133**Number** aggregations: `count`, `sum`, `avg`, `min`, `max`, `first`, `last` — non-`count` requires `field`.134135**Scorecard** shares the number aggregation vocabulary but is single-KPI only (no multi-KPI / composite memory). Comparison model:136137- **Change** = current value vs the immediately previous value in the series. The series is derived from `sparklineField` when set, or the primary `field` as a fallback. Only `first` / `last` aggregations expose a natural "previous" (adjacent anchor via `pickChangeAnchors`); combining aggregations (`sum` / `avg` / `min` / `max` / `count`) hide the chip. Reuses `computeTrend` (`widgetTrend.ts`) for percent/absolute math.138- **Target** = literal number or `{{ CEL }}` (evaluated against the newest filtered row + `now`), used for optional `showProgress` and fallback status color.139- `better: "up" | "down"` controls the polarity for the value change, the sparkline, and the vs-target status.140- The form relabels the two directional aggregations as `Latest` / `Earliest` because all data sources are newest-first (`first` → Latest, `last` → Earliest). Persisted YAML still uses `first` / `last`.141142Helpers live in `widget/scorecardMath.ts` (`extractScorecardSeries`, `pickChangeAnchors`, `resolveScorecardTarget`, `computeScorecardProgress`, `computeScorecardChange`, `resolveScorecardStatus`, `formatScorecardChangeLabel`). Rendering is in `widget/WidgetScorecard.tsx`; the sparkline itself comes from the shared `widget/Sparkline.tsx` (shared with `WidgetNumber`) with a `className` prop for status coloring.143144---145146## YAML147148```yaml149apiVersion: v1150kind: Console151metadata:152 canvasId: <uuid> # export only; ignored on import153 name: <display>154spec:155 panels: [{ id, type, content }]156 layout: [{ i, x, y, w, h, minW?, minH? }]157```158159- FE: `consoleYaml.ts` — parse/serialize + `validatePanelContent`160- BE: `ConsoleFromYML` / `VersionToConsoleYML` in `pkg/yaml/console.go`161- Unknown fields rejected; missing `panels`/`layout` → empty lists162163---164165## Agent workflows166167### Fix a console bug1681691. Reproduce in console mode (not template); note panel `type` and `dataSource.kind`.1702. Trace: panel card → `useWidgetData` → widget renderer → (if trigger) `useConsoleRunTrigger`.1713. Check permissions in `pkg/authorization/interceptor.go` if RPC-related.1724. Add/update test under `web_src/src/pages/app/console/**/*.spec.ts`.173174### Add or change panel `content` fields1751761. `widget/types.ts` (if widget-facing)1772. `panelTypes.ts` — interface, `templateForPanelType`, `validatePanelContent`, normalization (satellite modules like `boardPanelContent.ts` / `nodesPanelContent.ts` follow the same pattern)1783. `pkg/yaml/console.go` — mirror validation + tests1794. Panel card + form component1805. YAML tests: `consoleYaml.spec.ts` / `consoleYaml.validation.spec.ts`, `pkg/yaml/console_test.go`181182### Add a new panel type1831841. `PANEL_TYPES`, `PANEL_TYPE_META`, validator, template in `panelTypes.ts`1852. `ConsolePanelType*` constant + `AllowedConsolePanelTypes` in `pkg/yaml/console.go` and a per-type validator1863. `*PanelCard.tsx` + case in `ConsolePanelCards.tsx`1874. Icon in `ConsoleView.tsx` `PANEL_TYPE_ICONS`1885. Update [docs/prd/console-and-widgets.md](../../../docs/prd/console-and-widgets.md)189190### Add a data source kind1911921. Extend types in `widget/types.ts` + `panelTypes.ts`1932. `DataSourceForm.tsx` editor1943. Branch in `useWidgetData.ts`1954. Backend YAML validator + tests196197### Configure memory table (user/agent task)198199Use PRD example; namespace must match canvas memory keys. Row actions target **trigger nodes** only.200201---202203## Verification204205```bash206# Frontend unit tests (console package)207cd web_src && npm run test:run -- src/pages/app/console208209# After UI edits (Docker dev env)210make format.js211make check.lint.ui212make check.build.ui213214# After Go validation/API edits215make format.go216make lint217make check.build.app218go test ./pkg/yaml -count=1219go test ./pkg/grpc/actions/canvases -count=1220```221222---223224## Repo conventions225226- No `web_src/src/utils/*` — use `lib/` or `hooks/`.227- Console has **strict ESLint budget** — refactor touched code; do not raise the budget.228- Split large components for Fast Refresh where the codebase already does.229- **Never** hand-write DB migrations; `make db.migration.create NAME=<dash-name>` if persistence changes.230- AGENTS.md: protobuf enum mapping, authorization on new RPCs.231232---233234## Quick file index235236| Task | Start here |237| --- | --- |238| Grid / add panel | `ConsoleView.tsx` |239| Table CEL / filters / actions | `WidgetTable.tsx`, `WidgetRowActionButton.tsx`, `celExpr.ts`, `evalTableWhere.ts`, `mergeTriggerPayload.ts` |240| Table editor | `TablePanelForm.tsx`, `TablePanelFormRows.tsx` |241| Board renderer / editor | `WidgetBoard.tsx`, `BoardPanelCard.tsx`, `BoardPanelForm.tsx`, `boardPanelContent.ts` |242| Trigger from console | `useConsoleRunTrigger.ts`, `consoleTriggerParameters.ts` |243| Node status chip / Run button | `NodesPanelCard.tsx`, `NodesPanelInlineRunForm.tsx`, `useConsoleRunTrigger.ts`, `useConsoleTriggerLock.ts`, `deriveNodeStatuses.ts` |244| API hooks | `web_src/src/hooks/useCanvasData.ts` — `useCanvasVersion`, `useUpdateCanvasVersion` |