1import * as Axiom from "@distilled.cloud/axiom";
2import * as Effect from "effect/Effect";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import type { Providers } from "./Providers.ts";
6
7/** Dashboard input. */
8export type DashboardProps = Axiom.CreateDashboardRequest;
9
10export type Dashboard = Resource<
11 "Axiom.Dashboard",
12 DashboardProps,
13 {
14 /** Stable Axiom dashboard `uid` (used as the path identifier). */
15 uid: string;
16 id: string;
17 createdAt: string;
18 createdBy: string;
19 updatedAt: string;
20 updatedBy: string;
21 /** The full dashboard document as returned by Axiom. */
22 dashboard: Axiom.DashboardWriteResponse["dashboard"]["dashboard"];
23 },
24 never,
25 Providers
26>;
27
28/**
29 * An Axiom dashboard — a named, layout-driven collection of charts. Each
30 * dashboard takes a full document (`charts` + `layout` array of grid cells +
31 * `timeWindow` + `refreshTime`) at version `schemaVersion: 2`.
32 *
33 * Charts are typed via {@link Chart} (a discriminated union over the
34 * Axiom-validated chart kinds — `TimeSeries`, `Table`, `Pie`, `Statistic`,
35 * `Heatmap`, `LogStream`, `Note`). The `id` on each chart is a free-form
36 * string the author picks; layout cells join via `LayoutCell.i`.
37 *
38 * The path identifier is `uid` (auto-assigned by Axiom). `id` is also
39 * exposed as an output but the API uses `uid` everywhere.
40 *
41 * Notes from probing `POST /v2/dashboards`:
42 *
43 * - Relative time windows must use the `qr-now-{duration}` form (e.g.
44 * `"qr-now-7d"` / `"qr-now"`); plain `"now-7d"` is rejected.
45 * - When authenticating with an API token, `dashboard.owner` must be `""`
46 * (Axiom rewrites this to the org-shared `X-AXIOM-EVERYONE`); per-user
47 * "private" dashboards aren't allowed for tokens.
48 * - The chart payload is strict: only `id`, `name`, `type`, `query`. Extra
49 * keys (e.g. `dataset`, `description`) trigger
50 * `Unrecognized keys: "<name>"`.
51 * @see https://axiom.co/docs/query-data/dashboards
52 *
53 * ### Creating a Dashboard
54 * **Example:** Minimal empty dashboard
55 * ```typescript
56 * yield* Axiom.Dashboard("ops", {
57 * dashboard: {
58 * name: "Ops Overview",
59 * owner: "", // org-shared (required for API tokens)
60 * description: "Top-level service health",
61 * charts: [],
62 * layout: [],
63 * refreshTime: 60, // seconds: 15 | 60 | 300
64 * schemaVersion: 2,
65 * timeWindowStart: "qr-now-1h",
66 * timeWindowEnd: "qr-now",
67 * },
68 * });
69 * ```
70 *
71 * **Example:** One-chart dashboard
72 * ```typescript
73 * import type { Chart, LayoutCell } from "alchemy/Axiom";
74 *
75 * const errors: Chart = {
76 * id: "errors-5m",
77 * name: "5xx errors / 5m",
78 * type: "TimeSeries",
79 * query: {
80 * apl: `['my-app-traces']
81 * | where status >= 500
82 * | summarize count() by bin_auto(_time)`,
83 * },
84 * };
85 *
86 * yield* Axiom.Dashboard("errors", {
87 * dashboard: {
88 * name: "Errors",
89 * owner: "",
90 * refreshTime: 60,
91 * schemaVersion: 2,
92 * timeWindowStart: "qr-now-24h",
93 * timeWindowEnd: "qr-now",
94 * charts: [errors],
95 * layout: [{ i: errors.id, x: 0, y: 0, w: 12, h: 6 } satisfies LayoutCell],
96 * },
97 * });
98 * ```
99 *
100 * **Example:** Compare to last 24h
101 * ```typescript
102 * yield* Axiom.Dashboard("compare", {
103 * dashboard: {
104 * name: "Compare vs yesterday",
105 * owner: "",
106 * refreshTime: 300,
107 * schemaVersion: 2,
108 * timeWindowStart: "qr-now-1h",
109 * timeWindowEnd: "qr-now",
110 * against: "-1d", // overlay the same window from 24h ago
111 * charts: [],
112 * layout: [],
113 * },
114 * });
115 * ```
116 *
117 * @resource
118 * @product Dashboard
119 */
120export const Dashboard = Resource<Dashboard>("Axiom.Dashboard");
121
122export const DashboardProvider = () =>
123 Provider.effect(
124 Dashboard,
125 Effect.gen(function* () {
126 const create = yield* Axiom.createDashboard;
127 const update = yield* Axiom.updateDashboard;
128 const get = yield* Axiom.getDashboard;
129 const del = yield* Axiom.deleteDashboard;
130 const listAll = yield* Axiom.listDashboards;
131
132 const toAttrsFromCreate = (envelope: Axiom.DashboardWriteResponse) => ({
133 uid: envelope.dashboard.uid,
134 id: envelope.dashboard.id,
135 createdAt: envelope.dashboard.createdAt,
136 createdBy: envelope.dashboard.createdBy,
137 updatedAt: envelope.dashboard.updatedAt,
138 updatedBy: envelope.dashboard.updatedBy,
139 dashboard: envelope.dashboard.dashboard,
140 });
141 const toAttrsFromGet = (current: Axiom.DashboardResource) => ({
142 uid: current.uid,
143 id: current.id,
144 createdAt: current.createdAt,
145 createdBy: current.createdBy,
146 updatedAt: current.updatedAt,
147 updatedBy: current.updatedBy,
148 dashboard: current.dashboard,
149 });
150
151 return {
152 stables: ["uid", "id", "createdAt", "createdBy"],
153 reconcile: Effect.fn(function* ({ news, output }) {
154 // Observe — `uid` is server-assigned at create time. We probe
155 // Axiom for the dashboard via the cached uid; treat NotFound
156 // (deleted out-of-band) as "no observed state" so we converge
157 // by re-creating.
158 const observed = output?.uid
159 ? yield* get({ uid: output.uid }).pipe(
160 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
161 )
162 : undefined;
163
164 // Ensure — POST mints a new dashboard with a fresh uid.
165 if (observed === undefined) {
166 return toAttrsFromCreate(yield* create(news));
167 }
168
169 // Sync — `overwrite: true` short-circuits Axiom's optimistic-
170 // concurrency check (otherwise the API requires the caller to
171 // echo back the server-side `version`, which we don't track).
172 return toAttrsFromCreate(
173 yield* update({ ...news, uid: observed.uid, overwrite: true }),
174 );
175 }),
176 delete: Effect.fn(function* ({ output }) {
177 yield* del({ uid: output.uid }).pipe(
178 Effect.catchTag("NotFound", () => Effect.void),
179 );
180 }),
181 read: Effect.fn(function* ({ output }) {
182 if (!output?.uid) return undefined;
183 return yield* get({ uid: output.uid }).pipe(
184 Effect.map(toAttrsFromGet),
185 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
186 );
187 }),
188 // Enumerate every dashboard visible to the caller. `GET /v2/dashboards`
189 // returns the full collection in one response (no pagination wrapper);
190 // each item carries the same shape as `getDashboard`, so we map through
191 // the shared `toAttrsFromGet` hydrator to produce the exact `read`
192 // Attributes shape.
193 list: () =>
194 listAll({}).pipe(
195 Effect.map((dashboards) => dashboards.map(toAttrsFromGet)),
196 ),
197 };
198 }),
199 );