Dashboard YAML Syntax Reference
Dashboard YAML Syntax Reference
Section titled “Dashboard YAML Syntax Reference”This page provides a detailed reference for the Dashboard YAML syntax, designed for AI-powered dashboard generation.
Overview
Section titled “Overview”A dashboard is a YAML-authored, independent top-level entity — its own admin section, its own storage — made of tiles (table / stat / bar_chart / scatter / line_chart), each backed by an existing connector data_processor or the native asset registry, and parameterized at view-time by dashboard controls (filter widgets). Dashboards share the approvalml package/parser with workflows, reusing data_processor:/params:/join: and the section grid/columns layout model verbatim — but they parse and store as a completely separate document type, discriminated by kind:.
Dashboards are named dashboards (plural) everywhere — the API is mounted at /dashboards, the admin UI lives under /admin/dashboards — to avoid colliding with the pre-existing singular /dashboard home-page (“pending approvals / my submissions”) endpoints.
Core Structure
Section titled “Core Structure”kind: dashboard # Required — distinguishes this file from a workflow YAMLname: "Vendor Spend Overview"description: "Quarterly vendor spend by region"view_roles: ["finance_manager", "admin"] # Optional — company_roles allowed to view
controls: [] # Optional — filter widgets, see belowtiles: [] # Required, at least onelayout: # Required — placement, reuses workflow form layout sections: []subscriptions: [] # Optional — see "Subscriptions" belowkind: dashboard is required and has no default — unlike a workflow file, where kind defaults to "workflow" when omitted, so none of the existing workflow YAML files needed to change. A file missing kind: is rejected as a dashboard.
Controls
Section titled “Controls”Dashboard-level filter widgets. A tile’s data_processor.params[] reads a control’s resolved value via from_control: <name> (see “Data Processor Parameters” below).
controls: - name: date_from label: "From" # Optional — display text; falls back to `name` when omitted type: date - name: date_to label: "To" type: date - name: pillar label: "Pillar" type: select # static options, no dynamic discovery options: - { label: "Growth & Web", value: "growth" } - { label: "Systems & Security", value: "systems" } - name: severity label: "Severity" type: multi_select # resolves to a list options: - { label: "High", value: "high" } - { label: "Critical", value: "critical" } - name: timeframe label: "Timeframe" type: date_range_preset # resolves server-side into {name}_from / {name}_to options: - { label: "Last 7 Days", value: "last_7_days" } - { label: "Last 30 Days", value: "last_30_days" } - { label: "This Quarter", value: "this_quarter" }| Type | Description |
|---|---|
date |
A single date picker. |
select |
Dropdown, one value, from static options. |
multi_select |
Dropdown, multiple values, from static options. |
date_range_preset |
Dropdown of named presets; each options[].value must be one of the known preset keys below. |
controls[].name must be unique within a dashboard — it’s the wire identifier from_control: references, so keep it a stable slug and never rename it once a tile depends on it. label is optional human-facing display text shown next to the widget; when omitted, the viewer falls back to showing name directly, which reads as a raw slug (“date_from”) rather than a proper filter label — set label on any control shown to end users. select/multi_select/date_range_preset all require a non-empty options list.
date_range_preset known keys
Section titled “date_range_preset known keys”last_7_days, last_30_days, this_month, last_month, this_quarter, last_quarter, this_year. An options[].value outside this set is rejected at validation time.
When a control of this type is applied, it expands server-side into two derived control values before any tile’s params are resolved: {control_name}_from and {control_name}_to (ISO date strings). A tile’s from_control: can target either the raw preset name or the resolved _from/_to bound directly:
data_processor: source_name: "Vendor Spend SQL" params: - name: date_from from_control: timeframe_from # resolved bound, not the raw "last_30_days" string - name: date_to from_control: timeframe_toDefaults (default:)
Section titled “Defaults (default:)”controls[].default pre-fills a control before the dashboard’s first render — without it, every control starts unset, and the viewer sees {} for control_values until they pick something and click Apply.
controls: - name: date_from type: date default: today # or a literal ISO date, e.g. "2026-01-01" - name: pillar type: select default: growth # must be one of this control's own options[].value - name: timeframe type: date_range_preset default: last_30_days # must be one of this control's own options[].valueSet a default on every control any tile reads via from_control for a connector param that’s actually required — otherwise the dashboard errors on first load, before the viewer has touched anything: the param gets resolved from an empty value, and most connectors reject that outright (or, worse, a {{placeholder}} in the connector’s own config is left unsubstituted and sent through literally). A control with no default is a deliberate choice to force the viewer to pick a value first — the studio and the viewer both treat it that way: the tile just doesn’t execute until the control is filled, rather than firing with it missing.
multi_select accepts a YAML list as its default (each element must be one of options[].value).
Every tile is one flat object discriminated by type. Each tile needs exactly one of data_processor or source — never both, never neither.
tiles: - id: top_vendors # Required, unique within the dashboard type: table # table | stat | bar_chart | scatter | line_chart label: "Top Vendors" # Optional display title view_roles: ["admin"] # Optional — narrows Dashboard.view_roles for this one tile
data_processor: { ... } # One of data_processor / source, see below # source: { type: asset, category: server }
row_path: "data" # JSONata — which array in the response is "the rows" # unused when source: {type: asset} (see below) filter: "..." # Optional JSONata predicate, valid on every tile typeCommon fields
Section titled “Common fields”| Field | Applies to | Description |
|---|---|---|
id |
all | Unique tile identifier, referenced from layout.sections[].grid/.columns. |
type |
all | table | stat | bar_chart | scatter | line_chart. |
label |
all | Display title. |
view_roles |
all | Optional — narrows Dashboard.view_roles for this one tile. Omit to inherit the dashboard’s own view_roles; a dashboard admin or its creator can always see every tile regardless. A viewer who can open the dashboard but not this tile never receives it in the tile list at all. |
data_processor |
all except native-asset tiles | Same shape as a workflow automatic step’s data source config — source_id/source_name, params, join. |
source |
asset-backed tiles only | { type: asset, category: <str> } — see “Native Asset Source” below. |
row_path |
all except source: asset |
JSONata expression selecting the row array out of the raw response. |
filter |
all | JSONata predicate over the whole rows array, applied after row extraction and join enrichment, before type-specific processing. |
The raw connector response is always wrapped as {"data": <response>} before row_path runs, even when the connector already returns a bare row list. So the row array lives at data, never at $ ($ here means the {"data": ...} wrapper itself, not its contents):
row_path: "data" -> correct: the row arrayrow_path: "$" -> WRONG: returns the {"data": [...]} wrapper object, not the rowsThis fails silently, not loudly — for table/line_chart tiles that build the row array with an inline group-by (data {...} ~> $each(...)), starting from $ {...} instead of data {...} evaluates to an empty result, so the tile just renders with no rows and no error. For other tile types, the wrapper object gets treated as a single bogus row instead. Always start row-array-building expressions from data, not $.
- id: top_vendors type: table data_processor: source_name: "Vendor Spend SQL" row_path: "data" columns: - { label: "Vendor", path: "vendor.name" } # JSONata, evaluated per row - { label: "Total Spend", path: "totals.amount" }columns is required and non-empty — each entry is { label, path }, path a per-row JSONata expression.
A column value that isn’t plain text or a number — an object, or an array from a join’s as_array: true — renders as compact JSON (e.g. {"id":123,"name":"Acme"}) rather than plain text, so it stays readable instead of showing as raw text or losing its structure.
Renders as a KPI card, chosen by template: — templates can be added over time without changing the dashboard schema. metric/compare_metric/format aren’t fixed stat-tile fields; they’re defined entirely by the chosen template’s own settings schema (each template validates its own settings shape).
- id: total_spend type: stat template: kpi_badge # Required — must exist in the template registry data_processor: source_name: "Vendor Spend SQL" row_path: "data" settings: # Validated against kpi_badge's OWN schema layout: badge # inline | badge | value_first | historical format: currency # number | currency | percent metric: "totals.amount" agg: sum # shorthand for $sum(rows.totals.amount) compare_metric: "totals.prior_period_amount" # optional, same row set, no second query compare_label: "vs last month" higher_is_better: true # default true; flips change_type polarity when falseThe v1 template registry ships exactly one template, kpi_badge:
| Setting | Required | Description |
|---|---|---|
layout |
No (default inline) |
inline | badge | value_first | historical — presentation only, never changes the computed numbers. |
format |
No (default number) |
number | currency | percent. |
metric |
Yes | JSONata expression (whole-set {"rows": [...]} context, or a plain field path when agg: is set). |
agg |
No | sum | avg | count | min | max — shorthand: when present, metric must be a plain per-row field path, reduced in Python. |
compare_metric |
No | Same shape as metric, evaluated against the same row set — no second query. |
compare_label |
No | Text shown next to the computed delta, e.g. "vs last month". |
higher_is_better |
No (default true) |
Set false so an increase renders as a negative-styled change (e.g. rising error counts). |
change_amount/change_pct/change_type are always computed server-side from metric/compare_metric — never authored directly in YAML.
Additional templates can be added later without any change to the dashboard YAML schema or its validation rules.
bar_chart
Section titled “bar_chart”- id: spend_by_region type: bar_chart data_processor: source_name: "Vendor Spend SQL" row_path: "data" x: "vendor.region.code" # per-row JSONata — the grouping key y: "totals.amount" # plain field, aggregated via agg: below agg: sum # sum | avg | count | min | max — only used when y: is a plain fieldOmit agg: to evaluate y as a full JSONata expression per x-group instead of a plain-field reduction — rows are first bucketed by x, then y is evaluated against each bucket’s own {"rows": [...]} context:
- id: net_spend_by_region type: bar_chart data_processor: source_name: "Vendor Spend SQL" row_path: "data" x: "vendor.region.code" y: "$sum(rows.totals.amount) - $sum(rows.totals.refund_amount)" # per-group JSONata — note the rows. prefixx and y are both required.
scatter
Section titled “scatter”Unaggregated — every row becomes one point, no grouping.
- id: error_vs_delay type: scatter data_processor: source_name: "Audit Findings SQL" row_path: "data" x: "error_rate" # per-row JSONata y: "sla_delay_hours" size: "dollar_risk" # optional — bubble radius source point_label: "vendor_name" # optional — per-point tooltip field quadrants: # optional threshold lines + region labels x_threshold: 5.0 y_threshold: 24 labels: high_high: "Critical Process Failure" high_x_low_y: "Fast but Sloppy" low_x_high_y: "Accurate but Constricted" low_low: "Healthy"x and y are required. A row missing size falls back to a default radius rather than erroring.
line_chart
Section titled “line_chart”Multi-series comparison sharing one x — e.g. “Approved vs. Rejected count, by day”. Each series is a plain per-row field path off the same row x came from; no separate fetch, no re-grouping.
- id: approved_vs_rejected_trend type: line_chart label: "Approved vs. Rejected, by Day" data_processor: source_name: "Aptiwise Internal DB" # SQL aggregates in the query, already wide-format, one row per day: # SELECT date_trunc('day', created_at) AS day, # count(*) FILTER (WHERE status='approved') AS approved, # count(*) FILTER (WHERE status='rejected') AS rejected # FROM approval_instances GROUP BY 1 ORDER BY 1 row_path: "data" x: "day" # per-row, shared across all series series: - { label: "Approved", path: "approved" } - { label: "Rejected", path: "rejected" }x and at least one series entry are required. A row missing one series’ field renders a gap in that line rather than crashing or misaligning the others. If a series needs real aggregation (not already computed by the underlying query), that belongs in the query — line_chart has no Python-side reduction path of its own, the same principle as bar_chart’s agg: shorthand.
Native Asset Source
Section titled “Native Asset Source”Assets (internal, company-scoped JSON records) get their own tile source instead of being forced through connector configuration — a GET /assets/-equivalent, not an external system.
- id: server_baselines type: table source: type: asset # replaces data_processor entirely for this tile category: server # same category filter list_by_category uses filter: "properties.tier = 'production' and properties.os_version = 'Ubuntu 22.04'" columns: - { label: "Name", path: "name" } - { label: "OS Version", path: "properties.os_version" }Visibility is scoped per viewer (owner / view_roles), the same rule GET /assets/ already enforces — not the unrestricted company-wide read list_by_category uses internally for trusted workflow execution. A dashboard is browsed by a person, not run as a trusted workflow step, so it never surfaces assets the viewer isn’t authorized to see in the Assets UI itself.
source: {type: asset} tiles have no row_path — the query result is already [{name, properties}], the exact row array shape, so there’s nothing for JSONata array-extraction to do. schema_id/schema_name filtering is not supported — only category, matching the one existing internal precedent.
Data Processor Parameters
Section titled “Data Processor Parameters”Tile data_processor.params[] reuses the workflow DataSourceParameterMapping model, plus two dashboard-only sources:
data_processor: source_name: "Vendor Spend SQL" params: - name: date_from from_control: date_from # reads a Dashboard control's resolved value - name: cursor from_asset: sync-checkpoint property: $.last_cursor - name: api_version value: "v3" - name: member_email from_viewer: email # NEW — the live viewer executing this tile: employee_id or emailEach param entry requires exactly one source: from_control, from_viewer, from_asset (+ optional property), or value.
from_field is not valid on a dashboard tile. It reads a workflow request’s form fields, and a dashboard has no form, so the param would never be sent. The dashboard validator rejects it; use from_control for a user-chosen value or from_viewer for the current user instead.
The reverse also holds: both from_control and from_viewer are validation errors on a workflow automatic step — workflows have no control_values/live-viewer context at runtime; the parser allows either structurally (the model is shared) but both the YAML validator and the execution layer reject them there.
from_viewer personalizes a data_processor tile per viewer — e.g. “rows where I am a member” — the same effect a source: {type: asset} tile already gets for free via its own per-viewer owner_id/view_roles scoping (see “Native Asset Source” below). It resolves to the live user for a normal execute_tile call, and to each employee recipient in turn for a scheduled subscription run. A recipient added by bare email address (no employee record) never receives a from_viewer tile.
Cross-Source Joins
Section titled “Cross-Source Joins”data_processor.join[] reuses the identical batch-fetch-then-lookup mechanism workflow automatic steps already have — one batched fetch across every distinct FK value, an on-keyed lookup dict, pick/as copied onto each matching row, non-matches left unenriched rather than erroring. See the ApprovalML Syntax Reference — Inline Join for the full mechanics.
Dashboards extend it with source_type, to join against the asset registry instead of an external connector:
- id: billing_with_server_tier type: table data_processor: source_name: "Billing Export SQL" join: - field: server_name source_type: asset # NEW — data_source (default) | asset category: server # optional, narrows the candidate set on: name # assets are matched by name pick: { tier: properties.tier, owner: properties.owner_name } row_path: "data"| Field | Required | Default | Description |
|---|---|---|---|
field |
Yes | — | FK field name on each primary row — a single id or a list of ids (e.g. an order’s order_line: [123, 456]); every id in the list is looked up. |
source_type |
No | data_source |
data_source | asset. |
source_id / source_name |
One required, unless source_type: asset |
— | Which second data source to fetch. Must be absent when source_type: asset. |
category |
No | — | Narrows the asset candidate set — only meaningful when source_type: asset. |
on |
No | "id" |
Key field to match against on the joined records (assets: typically "name"). |
pick |
No | "name" |
Single field, or {output_field: source_field, ...} for several at once. |
as |
Required if pick is a string |
— | Output field name. |
param |
No | "ids" |
Param name the collected IDs are sent as (data-source joins only). |
separator |
No | ", " |
Joiner when a row has multiple matches. |
as_array |
No | false |
Return a list instead of a joined string. |
field/on/pick are shallow dotted paths, not JSONata: properties.tier walks into a nested object, and a purely-numeric segment indexes into a list — product_id.1 reads the display-name half of a connector’s [id, name] pair for a relational field (e.g. Odoo many2one), product_id.0 reads the id half. Picking a field like that without an index (pick: product_id) returns the raw pair stringified as one value (e.g. "[2778, 'Widget']") rather than the name — index into it instead. This dotted-path/list-index behavior is dashboard-specific; the workflow join linked above only reads flat top-level field names.
Whole-record pick. pick: "$" (or {output_field: "$"} inside a dict pick) attaches each matched record as-is instead of extracting one field — use it to pull in every joined field at once rather than listing them one by one:
join: - field: order_line source_id: src_... on: id pick: { lines: "$" } # every field of each matched order line as_array: trueType preservation. With as_array: true, picked values keep their original type from the source record — a numeric field stays a number so $sum(rows.lines.price_total)-style arithmetic works downstream without casting. Without as_array (the default), multiple matches are joined into a single string via separator, which necessarily stringifies each value first — that’s the only case where a picked value becomes text.
Row Filtering (filter:)
Section titled “Row Filtering (filter:)”Available on every tile type — not just table. A JSONata predicate evaluated against the whole rows array, applied once, after row extraction and join enrichment, before any tile-type-specific processing:
filter: "properties.tier = 'production' and properties.os_version = 'Ubuntu 22.04'"rows[properties.tier='production'] -> matchesrows[properties.tier='production' and properties.os_version='Ubuntu 22.04'] -> and: native operator, worksrows[not properties.tier='staging'] -> PARSE ERROR — not a keywordrows[$not(properties.tier='staging')] -> correct: $not() is a functionand/or compose as native keyword operators; negation is not a not keyword — it’s the $not(...) function. The natural-looking not syntax parses successfully at YAML-author time and only fails later, so the YAML validator try-parses it up front.
Sorting and Top-N (^(...), slicing)
Section titled “Sorting and Top-N (^(...), slicing)”Sort with ^(<field) (ascending) / ^(>field) (descending). To then take the first N rows, the range operator (..) only parses as an array literal — it is not a valid index predicate directly against an expression:
rows ^(>amount) [0..4] -> PARSE ERROR — "Expected ], got .."rows ^(>amount) [[0..4]] -> correct: top 5 rows by amount, descendingWrap the range in its own brackets: the inner [0..4] builds the index array [0,1,2,3,4], and the outer [...] applies it against the sorted array. This differs from the standalone JSONata reference implementation (where single-bracket expr[0..4] slicing is valid) — this dashboard engine’s parser only accepts the range operator as a literal array constructor.
Count, Sum, and Arithmetic
Section titled “Count, Sum, and Arithmetic”stat.settings.metric/compare_metric and bar_chart.y (when agg: is omitted) are evaluated as a single JSONata expression against the whole extracted row array — {"rows": [...]} — not per row:
$sum(rows.amount) -> 1600$count(rows) -> 3$average(rows.amount) -> 533.33...$count(rows[status="error"]) -> 2 (filtered count)(1 - $count(rows[status="error"]) / $count(rows)) * 100 -> 33.33 (ratio / health-score style)$sum(rows.amount) + $sum(rows[status="error"].amount) -> 2300 (combined sum)rows{region: $sum(amount)} -> {"US": 700, "EU": 900} (group-by)JSONata’s own aggregate functions ($sum, $count, $average, $max, $min), arithmetic operators, predicate filtering, and object-grouping cover count/sum/ratio/combined-sum/group-by natively — no Python-side aggregation engine is needed for any of it. agg: sum|avg|count|min|max remains as optional shorthand for the common single-plain-field case; when present, the field is a plain path aggregated by the named function instead.
Combined with a join — since join enrichment runs before metric/y evaluation, an arithmetic expression can reference a field a join just attached:
data_processor: source_name: "Vendor Spend SQL" join: - field: vendor_id source_name: "Vendor Master" on: id pick: { vendor_tier: risk_tier } # attaches vendor_tier to each rowsettings: metric: "$sum(rows[vendor_tier='high_risk'].totals.amount)" # arithmetic over the join-enriched rowsPer-row vs. whole-set context, at a glance:
| Context | Fields |
|---|---|
| Per-row (a single row dict) | table.columns[].path, scatter.x/y/size/point_label, bar_chart.x (the grouping key), line_chart.x/series[].path |
Whole-set ({"rows": [...]}) |
stat.settings.metric/compare_metric, bar_chart.y when agg: is absent |
Layout
Section titled “Layout”Reuses the exact form.layout.sections shape workflow forms already use, just pointing grid/columns cells at tile ids instead of form field names:
layout: sections: - id: overview title: "Overview" columns: - [total_spend] - [top_vendors, spend_by_region] - id: correlation title: "Correlation" columns: - [error_vs_delay]columns mode (independent vertical stacks) suits dashboards particularly well since tiles are commonly mixed-height — a short stat tile next to a tall table. Both grid and columns modes are checked for unknown tile-id references (a broader check than the workflow layout validator, which only ever checks grid).
Subscriptions (Scheduled Delivery)
Section titled “Subscriptions (Scheduled Delivery)”Runs on the given cron schedule, executes every tile in the dashboard, and emails a static HTML digest (tables as HTML, stats as formatted KPI text, charts as inline SVG snapshots) to the subscription’s recipients, with a CSV attached per table tile.
Each recipient only receives the tiles they are allowed to see:
- Being listed as a recipient counts as access to the dashboard, so a tile without its own
view_rolesgoes to every recipient. - A tile with its own
view_rolesgoes only to recipients whose company roles match it (plus company admins and the dashboard’s creator). - Tiles whose data depends on who is looking — a
from_viewerparam, orsource: {type: asset}— are run separately for each employee recipient, so everyone gets their own data. - A recipient added by bare email address (no employee record) only receives tiles with no
view_rolesof their own and no per-viewer data. - A recipient left with no visible tiles is not sent an email.
subscriptions: - name: "Monday Morning Digest" schedule: "0 8 * * 1" # same cron syntax as workflow triggers recipients: - role: "finance_manager" # same recipient shape as notification steps (role | email) control_values: date_from: "2026-01-01" # fixed control values for the scheduled run date_to: "2026-01-31"recipients requires at least one entry, each with role or email.
Mounted at /dashboards (plural) — coexists with the pre-existing singular /dashboard home-page endpoints with no route collision.
| Method | Path | Description |
|---|---|---|
GET |
/dashboards/ |
List dashboards visible to the current employee. |
GET |
/dashboards/{uuid} |
Fetch one dashboard, including its parsed definition. |
POST |
/dashboards/ |
Create — body: { "yaml_content": "<raw YAML>" }. |
PUT |
/dashboards/{uuid} |
Update — same body shape. |
DELETE |
/dashboards/{uuid} |
Soft-delete. |
POST |
/dashboards/{uuid}/tiles/{tile_id}/execute |
Execute one tile — body: { "control_values": {...} }. Add ?format=csv to export a table/bar_chart/scatter/line_chart tile’s rows as CSV (not available for stat). |
POST/PUT accept only raw yaml_content — name/description/view_roles are read back out of the parsed document after validation, so the stored row can never drift from what the YAML actually says.
Access control:
- View: company admin, OR the dashboard’s creator, OR an employee whose
company_rolesintersectview_roles. - Write (
POST/PUT/DELETE): company admin, OR the dashboard’s creator only — no org-path-ancestor escalation. - Per-tile view: the same rule, narrowed to a tile’s own
view_roleswhen it sets one (falling back to the dashboard’sview_rolesotherwise).GET /dashboards/{uuid}omits any tile the viewer can’t see fromdefinition.tilesentirely;POST .../tiles/{tile_id}/execute403s if called for one directly.
Full Example
Section titled “Full Example”kind: dashboardname: "Vendor Spend Overview"description: "Quarterly vendor spend by region"view_roles: ["finance_manager", "admin"]
controls: - name: date_from label: "From" type: date - name: date_to label: "To" type: date - name: timeframe label: "Timeframe" type: date_range_preset options: - { label: "Last 7 Days", value: "last_7_days" } - { label: "Last 30 Days", value: "last_30_days" } - { label: "This Quarter", value: "this_quarter" }
tiles: - id: top_vendors type: table label: "Top Vendors This Quarter" data_processor: source_name: "Vendor Spend SQL" params: - name: date_from from_control: date_from - name: date_to from_control: date_to join: - field: vendor_id source_name: "Vendor Master" on: id pick: vendor_name: name vendor_region: region_code row_path: "data" columns: - { label: "Vendor", path: "vendor_name" } - { label: "Region", path: "vendor_region" } - { label: "Total Spend", path: "totals.amount" }
- id: net_spend_by_region type: bar_chart label: "Net Spend by Region (after refunds)" data_processor: source_name: "Vendor Spend SQL" row_path: "data" x: "vendor.region.code" y: "$sum(rows.totals.amount) - $sum(rows.totals.refund_amount)"
- id: total_spend type: stat label: "Total Spend" template: kpi_badge data_processor: source_name: "Vendor Spend SQL" row_path: "data" settings: layout: badge format: currency metric: "totals.amount" agg: sum compare_metric: "totals.prior_period_amount" compare_label: "vs last month"
- id: server_baselines type: table label: "Production Server Baselines" source: type: asset category: server filter: "properties.tier = 'production'" columns: - { label: "Name", path: "name" } - { label: "OS Version", path: "properties.os_version" }
- id: error_vs_delay type: scatter label: "Accuracy Errors vs. SLA Bottlenecks" data_processor: source_name: "Audit Findings SQL" row_path: "data" x: "error_rate" y: "sla_delay_hours" size: "dollar_risk" point_label: "vendor_name" quadrants: x_threshold: 5.0 y_threshold: 24 labels: high_high: "Critical Process Failure" low_low: "Healthy"
- id: approved_vs_rejected_trend type: line_chart label: "Approved vs. Rejected, by Day" data_processor: source_name: "Aptiwise Internal DB" row_path: "data" x: "day" series: - { label: "Approved", path: "approved" } - { label: "Rejected", path: "rejected" }
layout: sections: - id: overview title: "Overview" columns: - [total_spend] - [top_vendors, net_spend_by_region] - id: assets title: "Assets" columns: - [server_baselines] - id: correlation title: "Correlation" columns: - [error_vs_delay] - id: trends title: "Trends" columns: - [approved_vs_rejected_trend]
subscriptions: - name: "Monday Morning Digest" schedule: "0 8 * * 1" recipients: - role: "finance_manager" control_values: date_from: "2026-01-01" date_to: "2026-01-31"A larger worked example covering every tile/control combination, both join variants, and filter: lives at docs/examples/dashboards/vendor-spend-dashboard.yaml in the repository.
