1---
2title: Dashboards as code
3description: Declare Axiom dashboards — charts, layout, filter bars, saved views, and deploy annotations — as alchemy resources next to the services they observe.
4---
5
6An Axiom dashboard is a single JSON document: charts, a grid layout,
7a time window, a refresh interval. In alchemy that document is the
8input to one `Dashboard` resource, so a chart tweak is a reviewable
9diff and every stage gets the same dashboard.
10
11This guide builds an ops dashboard chart by chart, then adds the two
12resources that round it out: saved `View`s and deploy `Annotation`s.
13You'll need the Axiom provider registered ([setup](/axiom/setup)) and
14a dataset to query ([ingest](/axiom/data/ingest)).
15
16## Start with an empty dashboard
17
18```typescript
19import * as Axiom from "alchemy/Axiom";
20
21yield* Axiom.Dashboard("ops", {
22 dashboard: {
23 name: "Ops Overview",
24 owner: "", // org-shared — required for API tokens
25 description: "Top-level service health",
26 charts: [],
27 layout: [],
28 refreshTime: 60, // seconds: 15 | 60 | 300
29 schemaVersion: 2,
30 timeWindowStart: "qr-now-1h",
31 timeWindowEnd: "qr-now",
32 },
33});
34```
35
36The resource takes the full dashboard document at `schemaVersion: 2`.
37Axiom assigns the `uid` on create (exposed as an output along with
38`id` and the timestamps); updates overwrite the whole document in
39place, so the deployed dashboard always matches what's in your code.
40
41## Add a chart
42
43```typescript
44import type { Chart, LayoutCell } from "alchemy/Axiom";
45
46const errors: Chart = {
47 id: "errors-5m",
48 name: "5xx errors / 5m",
49 type: "TimeSeries",
50 query: {
51 apl: `['my-app-traces']
52 | where status >= 500
53 | summarize count() by bin_auto(_time)`,
54 },
55};
56
57yield* Axiom.Dashboard("errors", {
58 dashboard: {
59 name: "Errors",
60 owner: "",
61 refreshTime: 60,
62 schemaVersion: 2,
63 timeWindowStart: "qr-now-24h",
64 timeWindowEnd: "qr-now",
65 charts: [errors],
66 layout: [{ i: errors.id, x: 0, y: 0, w: 12, h: 6 } satisfies LayoutCell],
67 },
68});
69```
70
71`chart.id` is a free-form string you pick — the layout cell's `i`
72joins to it. There's no `dataset` field: the dataset is implicit in
73the APL query (`['my-app-traces']`).
74
75## The chart union
76
77```typescript
78const p99: Chart = {
79 id: "p99",
80 name: "p99 latency",
81 type: "Statistic",
82 query: {
83 apl: `['my-app-traces'] | summarize percentile(duration_ms, 99)`,
84 },
85};
86```
87
88`Chart` is a discriminated union over the kinds Axiom's API
89validates: `TimeSeries`, `Table`, `Pie`, `Statistic`, `Heatmap`, and
90`LogStream` for data charts, plus `Note` and `SmartFilter` (below).
91Every data chart carries exactly the same payload — `{ id, name,
92type, query: { apl } }` — and switching kinds is just changing
93`type`.
94
95## Add a note
96
97```typescript
98const readme: Chart = {
99 id: "readme",
100 type: "Note",
101 text: "## Ops overview\n\nEscalation: #ops-oncall. Runbooks linked per chart.",
102};
103```
104
105`Note` is the one chart without a `query` or a `name` — its payload
106is strictly `{ id, type, text, variant? }`, with `text` rendered as
107markdown in the cell.
108
109## Add a filter bar
110
111```typescript
112const filters: Chart = {
113 id: "env-filter",
114 type: "SmartFilter",
115 filters: [
116 {
117 id: "environment",
118 type: "select",
119 name: "Environment",
120 selectType: "list",
121 options: [
122 { key: "production", value: "production", default: true },
123 { key: "staging", value: "staging" },
124 ],
125 },
126 ],
127};
128```
129
130`SmartFilter` is the wire name for what the UI calls a filter bar.
131Each entry is either a free-text `search` filter or a dropdown
132`select` filter — dropdown options come from an inline `list` (as
133here) or, with `selectType: "apl"`, from a query returning
134`{ key, value }` rows. Other charts pick the value up by declaring a
135query parameter named after the filter's `id`:
136`declare query_parameters (environment:string = "")`.
137
138## Lay out the grid
139
140```typescript
141layout: [
142 { i: "env-filter", x: 0, y: 0, w: 12, h: 2 },
143 { i: "errors-5m", x: 0, y: 2, w: 6, h: 6 },
144 { i: "p99", x: 6, y: 2, w: 6, h: 6 },
145],
146```
147
148Each `LayoutCell` positions one chart by its `i`: `x`/`y` place it on
149the grid, `w`/`h` size it, and optional `minW`/`minH`/`maxW`/`maxH`
150and `static` constrain how it can be resized or moved in the UI.
151Every chart in `charts` needs a matching cell.
152
153## Compare against yesterday
154
155```typescript
156yield* Axiom.Dashboard("compare", {
157 dashboard: {
158 name: "Compare vs yesterday",
159 owner: "",
160 refreshTime: 300,
161 schemaVersion: 2,
162 timeWindowStart: "qr-now-1h",
163 timeWindowEnd: "qr-now",
164 against: "-1d", // overlay the same window from 24h ago
165 charts: [],
166 layout: [],
167 },
168});
169```
170
171`against` overlays each chart with the same query shifted by the
172given offset — the classic "is this spike new or does it happen every
173day at this hour" view.
174
175## Payload gotchas
176
177Axiom's `POST /v2/dashboards` is strict, and the failure modes are
178worth knowing up front:
179
180- Relative time windows use the `qr-now-{duration}` form
181 (`"qr-now-7d"`, `"qr-now"`) — plain `"now-7d"` is rejected.
182- When authenticating with an API token, `dashboard.owner` must be
183 `""`. Axiom rewrites it to the org-shared `X-AXIOM-EVERYONE`;
184 per-user "private" dashboards aren't allowed for tokens.
185- Data-chart payloads accept only `id`, `name`, `type`, `query` —
186 extra keys like `dataset` or `description` fail with
187 `Unrecognized keys: "<name>"`.
188- `refreshTime` is in seconds: `15`, `60`, or `300`.
189
190## Save a view
191
192```typescript
193yield* Axiom.View("recent-errors", {
194 name: "recent-errors",
195 description: "Last 100 5xx responses",
196 datasets: ["my-app-traces"],
197 aplQuery: `
198 ['my-app-traces']
199 | where status >= 500
200 | order by _time desc
201 | take 100
202 `,
203});
204```
205
206A `View` is a named, shareable APL query — the investigation you keep
207re-typing, pinned as a resource. Its `name` is the identifier, so
208renaming a view is a replacement (delete old, create new).
209
210## Mark deploys with annotations
211
212```typescript
213yield* Axiom.Annotation("deploy-1.2.3", {
214 type: "deploy",
215 title: "Release 1.2.3",
216 datasets: ["my-app-traces", "my-app-logs"],
217 time: new Date().toISOString(),
218 url: "https://github.com/acme/app/releases/tag/v1.2.3",
219});
220```
221
222An `Annotation` draws a vertical marker on every chart over the
223listed datasets — pass a single `time` for a point event or `time` +
224`endTime` for a range (an incident window), with `type` grouping
225markers visually. Deploy markers next to your error charts answer
226"did the release cause this" at a glance.
227
228## Where next
229
230- [Alerting](/axiom/guides/alerting) — turn the same APL queries into
231 monitors and notifiers.
232- [Ingest](/axiom/data/ingest) — datasets, tokens, and wiring OTEL output
233 into Axiom.
234- [Axiom overview](/axiom) — all resources at a glance.
235
236Reference:
237
238- [Dashboard API reference](/providers/axiom/dashboard)
239- [View API reference](/providers/axiom/view)
240- [Annotation API reference](/providers/axiom/annotation)