1import * as cloudwatch from "@distilled.cloud/aws/cloudwatch";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { AWSEnvironment, type AccountID } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import { createName, retryConcurrent } from "./common.ts";
10
11export type DashboardName = string;
12export type DashboardArn =
13 `arn:aws:cloudwatch::${AccountID}:dashboard/${string}`;
14
15export type DashboardPeriodOverride = "inherit" | "auto";
16export type DashboardMetricRow = (string | number | boolean | null)[];
17
18export interface DashboardMetricWidgetProperties {
19 title?: string;
20 region?: string;
21 stat?: string;
22 period?: number;
23 view?: "timeSeries" | "singleValue" | "gauge" | "bar" | "pie";
24 stacked?: boolean;
25 metrics: DashboardMetricRow[];
26 annotations?: Record<string, unknown>;
27 yAxis?: Record<string, unknown>;
28 legend?: Record<string, unknown>;
29 [key: string]: unknown;
30}
31
32export interface DashboardTextWidgetProperties {
33 markdown: string;
34 [key: string]: unknown;
35}
36
37export interface DashboardAlarmStatusWidgetProperties {
38 alarms: string[];
39 title?: string;
40 sortBy?: string;
41 states?: string[];
42 [key: string]: unknown;
43}
44
45export interface DashboardLogWidgetProperties {
46 query: string;
47 region?: string;
48 title?: string;
49 view?: "table" | "timeSeries" | "bar" | "pie";
50 [key: string]: unknown;
51}
52
53export interface DashboardMetricWidget {
54 type: "metric";
55 x?: number;
56 y?: number;
57 width?: number;
58 height?: number;
59 properties: DashboardMetricWidgetProperties;
60}
61
62export interface DashboardTextWidget {
63 type: "text";
64 x?: number;
65 y?: number;
66 width?: number;
67 height?: number;
68 properties: DashboardTextWidgetProperties;
69}
70
71export interface DashboardAlarmStatusWidget {
72 type: "alarm";
73 x?: number;
74 y?: number;
75 width?: number;
76 height?: number;
77 properties: DashboardAlarmStatusWidgetProperties;
78}
79
80export interface DashboardLogWidget {
81 type: "log";
82 x?: number;
83 y?: number;
84 width?: number;
85 height?: number;
86 properties: DashboardLogWidgetProperties;
87}
88
89export type DashboardWidget =
90 | DashboardMetricWidget
91 | DashboardTextWidget
92 | DashboardAlarmStatusWidget
93 | DashboardLogWidget;
94
95export interface DashboardBody {
96 start?: string;
97 end?: string;
98 periodOverride?: DashboardPeriodOverride;
99 widgets: DashboardWidget[];
100 variables?: Record<string, unknown>[];
101}
102
103export interface DashboardProps extends Omit<
104 cloudwatch.PutDashboardInput,
105 "DashboardName" | "DashboardBody"
106> {
107 /**
108 * Name of the dashboard. If omitted, a unique name is generated.
109 */
110 name?: DashboardName;
111 /**
112 * Structured dashboard document. The provider serializes it to the JSON
113 * string expected by the CloudWatch API.
114 */
115 DashboardBody: DashboardBody;
116 /**
117 * Optional tags to apply to the dashboard.
118 *
119 * CloudWatch dashboards do not currently support the generic CloudWatch
120 * tagging APIs, so these values are accepted for API consistency but are not
121 * persisted remotely.
122 */
123 tags?: Record<string, string>;
124}
125
126export interface Dashboard extends Resource<
127 "AWS.CloudWatch.Dashboard",
128 DashboardProps,
129 {
130 /** Physical name of the dashboard. */
131 dashboardName: DashboardName;
132 /** ARN of the dashboard. */
133 dashboardArn: DashboardArn;
134 /** The parsed dashboard document as last read from CloudWatch. */
135 dashboardBody: DashboardBody | undefined;
136 /** Tags accepted for API consistency (not persisted remotely). */
137 tags: Record<string, string>;
138 },
139 never,
140 Providers
141> {}
142
143/**
144 * An Amazon CloudWatch dashboard. The `DashboardBody` is a structured,
145 * typed document (metric, text, alarm-status, and log widgets) that the
146 * provider serializes to the JSON string CloudWatch expects.
147 * ### Creating Dashboards
148 * **Example:** Basic Dashboard
149 * ```typescript
150 * const dashboard = yield* Dashboard("OpsDashboard", {
151 * DashboardBody: {
152 * widgets: [],
153 * },
154 * });
155 * ```
156 *
157 * **Example:** Dashboard with Metric and Text Widgets
158 * ```typescript
159 * const dashboard = yield* Dashboard("PaymentsDashboard", {
160 * DashboardBody: {
161 * widgets: [
162 * {
163 * type: "text",
164 * x: 0, y: 0, width: 6, height: 3,
165 * properties: { markdown: "# Payments service" },
166 * },
167 * {
168 * type: "metric",
169 * x: 0, y: 3, width: 12, height: 6,
170 * properties: {
171 * title: "Payments processed",
172 * metrics: [["MyApp/Payments", "PaymentProcessed"]],
173 * stat: "Sum",
174 * period: 60,
175 * view: "timeSeries",
176 * },
177 * },
178 * ],
179 * },
180 * });
181 * ```
182 *
183 * ### Reading Dashboards at Runtime
184 * **Example:** Read the Dashboard Body from a Function
185 * ```typescript
186 * // init — bind the dashboard to the function (see GetDashboard)
187 * const getDashboard = yield* AWS.CloudWatch.GetDashboard(dashboard);
188 *
189 * // runtime
190 * const result = yield* getDashboard();
191 * const body = JSON.parse(result.DashboardBody ?? "{}");
192 * ```
193 *
194 * @resource
195 */
196export const Dashboard = Resource<Dashboard>("AWS.CloudWatch.Dashboard");
197
198const serializeDashboardBody = (body: DashboardBody) => JSON.stringify(body);
199
200const parseDashboardBody = (body: string | undefined) => {
201 if (!body) {
202 return undefined;
203 }
204 return JSON.parse(body) as DashboardBody;
205};
206
207export const DashboardProvider = () =>
208 Provider.effect(
209 Dashboard,
210 Effect.gen(function* () {
211 const createDashboardName = (id: string, props: { name?: string } = {}) =>
212 createName(id, props.name, 255);
213
214 const dashboardArn = (dashboardName: string) =>
215 AWSEnvironment.current.pipe(
216 Effect.map(
217 (env) =>
218 `arn:aws:cloudwatch::${env.accountId}:dashboard/${dashboardName}` as DashboardArn,
219 ),
220 );
221
222 const readDashboard = Effect.fn(function* (dashboardName: string) {
223 const output = yield* cloudwatch
224 .getDashboard({
225 DashboardName: dashboardName,
226 })
227 .pipe(
228 Effect.catchTag("DashboardNotFoundError", () =>
229 Effect.succeed(undefined),
230 ),
231 );
232
233 if (!output?.DashboardName) {
234 return undefined;
235 }
236
237 return {
238 dashboardName: output.DashboardName,
239 dashboardArn: yield* dashboardArn(output.DashboardName),
240 dashboardBody: parseDashboardBody(output.DashboardBody),
241 tags: {},
242 };
243 });
244
245 return {
246 stables: ["dashboardName", "dashboardArn"],
247 diff: Effect.fn(function* ({
248 id,
249 olds = {},
250 news = {} as DashboardProps,
251 }) {
252 if (!isResolved(news)) return undefined;
253 const oldName = yield* createDashboardName(id, olds);
254 const newName = yield* createDashboardName(id, news);
255
256 if (oldName !== newName) {
257 return { action: "replace" } as const;
258 }
259 }),
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const name =
262 output?.dashboardName ??
263 (yield* createDashboardName(id, olds ?? {}));
264 return yield* readDashboard(name);
265 }),
266 reconcile: Effect.fn(function* ({ id, news, output, session }) {
267 // Observe — pin the physical name from `output` if present so we
268 // never rename an existing dashboard; otherwise derive from
269 // desired props.
270 const name =
271 output?.dashboardName ?? (yield* createDashboardName(id, news));
272
273 // Ensure — `putDashboard` is a pure upsert. The CloudWatch
274 // dashboard API has no separate update path, so we always send
275 // the full body and let the API converge.
276 yield* retryConcurrent(
277 cloudwatch.putDashboard({
278 DashboardName: name,
279 DashboardBody: serializeDashboardBody(news.DashboardBody),
280 }),
281 );
282
283 yield* session.note(yield* dashboardArn(name));
284
285 const state = yield* readDashboard(name);
286 if (!state) {
287 return yield* Effect.fail(
288 new Error(`failed to read reconciled dashboard '${name}'`),
289 );
290 }
291
292 // Dashboards do not support the generic CloudWatch tagging APIs,
293 // so `tags` is always returned as an empty record.
294 return {
295 ...state,
296 tags: {},
297 };
298 }),
299 delete: Effect.fn(function* ({ output }) {
300 // `DeleteDashboards` is idempotent: AWS returns success (not
301 // `DashboardNotFoundError`) for names that don't exist, so a
302 // re-delete after a state-persistence failure is a safe no-op.
303 yield* retryConcurrent(
304 cloudwatch.deleteDashboards({
305 DashboardNames: [output.dashboardName],
306 }),
307 );
308 }),
309 list: () =>
310 Effect.gen(function* () {
311 // `listDashboards` only returns entry metadata (name/arn/size),
312 // not the body, so we re-read each dashboard to produce the full
313 // Attributes shape `read` returns.
314 const names = yield* cloudwatch.listDashboards.pages({}).pipe(
315 Stream.runCollect,
316 Effect.map((chunk) =>
317 Array.from(chunk).flatMap((page) =>
318 (page.DashboardEntries ?? [])
319 .map((entry) => entry.DashboardName)
320 .filter((name): name is string => name != null),
321 ),
322 ),
323 );
324
325 const states = yield* Effect.forEach(
326 names,
327 (name) => readDashboard(name),
328 { concurrency: 10 },
329 );
330
331 return states.filter(
332 (state): state is NonNullable<typeof state> =>
333 state !== undefined,
334 );
335 }),
336 };
337 }),
338 );