1import type { ActionApply, ActionDelete, CRUD, Plan } from "../../../Plan.ts";
2import type {
3 ApplyEvent,
4 ApplyStatus,
5 ResourceStatusChanged,
6} from "../../../Report.ts";
7import type { ProviderMode } from "../../../ProviderMode.ts";
8import {
9 buildNamespaceTree,
10 buildPlanSummary,
11 flattenTree,
12 type ActionVerb,
13 type FlattenedItem,
14 type PlanSummaryCounts,
15} from "../../NamespaceTree.ts";
16import type { DeclaredPropertyYaml } from "../../PropertyDiff.ts";
17import { isInProgress, isTerminalStatus } from "./statusStyle.ts";
18
19export type PlanRow =
20 | {
21 key: string;
22 type: "namespace";
23 id: string;
24 depth: number;
25 action: FlattenedItem["action"];
26 }
27 | {
28 key: string;
29 type: "resource";
30 id: string;
31 resourceType: string;
32 /** Secondary identity for inventories without logical resource names. */
33 detail?: string;
34 depth: number;
35 action: CRUD["action"];
36 persistedApplyStatus?: "created" | "updated";
37 providerMode?: ProviderMode;
38 fromProviderMode?: ProviderMode;
39 propertyYaml?: DeclaredPropertyYaml;
40 }
41 | {
42 key: string;
43 type: "binding";
44 id: string;
45 depth: number;
46 action: "create" | "update" | "delete" | "noop";
47 /**
48 * Row key of the resource that carries this binding. Bindings have no
49 * lifecycle of their own — the host provider reconciles them — so the
50 * row mirrors the host's apply status instead of tracking its own.
51 */
52 hostKey: string;
53 }
54 | {
55 key: string;
56 type: "task";
57 id: string;
58 depth: number;
59 action: ActionVerb;
60 };
61
62export type ResourceRow = Extract<PlanRow, { type: "resource" }>;
63
64/**
65 * Only resources and actions receive apply events and settle. Namespaces are
66 * grouping only, and bindings are reconciled by their host resource, so
67 * counting them would leave the progress total unreachable.
68 */
69const isProgressRow = (row: PlanRow) =>
70 row.type === "resource" || row.type === "task";
71
72export interface RowState extends Required<
73 Pick<ResourceStatusChanged, "id" | "status">
74> {
75 key: string;
76 message?: string;
77 startedAt?: number;
78 elapsedMs?: number;
79}
80
81export type PlanViewport = "full" | "virtual";
82export type PlanView = "plan" | "output";
83export type PlanOutcome = "success" | "failure";
84
85export interface PlanProgress {
86 readonly completed: number;
87 readonly failures: number;
88 readonly total: number;
89}
90
91export interface PlanTreeState {
92 readonly tasks: Map<string, RowState>;
93 readonly label: string;
94 readonly expanded: boolean;
95 readonly viewport: PlanViewport;
96 readonly busy: boolean;
97 readonly outcome: PlanOutcome | undefined;
98 readonly output: unknown;
99 readonly view: PlanView;
100}
101
102export interface PlanTreeOptions {
103 readonly detailed?: boolean;
104 readonly mode?: "review" | "apply";
105 readonly label?: string;
106 readonly titleDetail?: string;
107 readonly viewport?: PlanViewport;
108 readonly busy?: boolean;
109 /**
110 * Initial collapse state of a collapsible tree. Dev passes the user's
111 * last choice so a hot reload doesn't bring back a widget they hid.
112 * @default true
113 */
114 readonly expanded?: boolean;
115}
116
117/** Display-only plans, such as cloud inventories, need no engine state or providers. */
118export interface PlanTreeData {
119 readonly rows: readonly PlanRow[];
120 readonly summary: PlanSummaryCounts;
121 readonly defaultMode?: ProviderMode;
122}
123
124const getRowKey = (item: FlattenedItem) => item.path.join("/");
125
126const findCrudByLogicalId = (plan: Plan, id: string): CRUD | undefined =>
127 [...Object.values(plan.resources), ...Object.values(plan.deletions)].find(
128 (item): item is CRUD => item?.resource.LogicalId === id,
129 );
130
131const buildRows = (plan: Plan, detailed: boolean): PlanRow[] => {
132 const resources = [
133 ...Object.values(plan.resources),
134 ...Object.values(plan.deletions).filter(
135 (item): item is NonNullable<Plan["deletions"][string]> =>
136 item !== undefined,
137 ),
138 ] as CRUD[];
139 const actions = [
140 ...Object.values(plan.actions ?? {}),
141 ...Object.values(plan.actionDeletions ?? {}),
142 ].filter((task): task is ActionApply | ActionDelete => task !== undefined);
143
144 return flattenTree(buildNamespaceTree(resources, actions), {
145 includePropertyYaml: detailed,
146 }).map((item) => {
147 if (item.type === "namespace") {
148 return {
149 key: getRowKey(item),
150 type: "namespace" as const,
151 id: item.id,
152 depth: item.depth,
153 action: item.action,
154 };
155 }
156 if (item.type === "binding") {
157 return {
158 key: getRowKey(item),
159 type: "binding" as const,
160 id: item.bindingSid ?? item.id,
161 depth: item.depth,
162 action: item.action as "create" | "update" | "delete" | "noop",
163 hostKey: item.path.slice(0, -1).join("/"),
164 };
165 }
166 if (item.type === "action") {
167 return {
168 key: getRowKey(item),
169 type: "task" as const,
170 id: item.id,
171 depth: item.depth,
172 action: item.action as ActionVerb,
173 };
174 }
175 return {
176 key: getRowKey(item),
177 type: "resource" as const,
178 id: item.id,
179 resourceType: item.resourceType ?? "Unknown",
180 depth: item.depth,
181 action: item.action as CRUD["action"],
182 providerMode: item.providerMode,
183 fromProviderMode: item.fromProviderMode,
184 propertyYaml: item.propertyYaml,
185 persistedApplyStatus:
186 item.action === "noop"
187 ? (() => {
188 const crud = findCrudByLogicalId(plan, item.id);
189 return crud?.action === "noop" ? crud.state.status : undefined;
190 })()
191 : undefined,
192 };
193 });
194};
195
196export const initialResourceState = (row: ResourceRow): RowState => ({
197 key: row.key,
198 id: row.id,
199 status:
200 row.action === "noop" ? (row.persistedApplyStatus ?? "created") : "pending",
201});
202
203const buildInitialTasks = (rows: readonly PlanRow[]) =>
204 new Map(
205 rows.flatMap((row): Array<[string, RowState]> => {
206 if (row.type === "resource")
207 return [[row.key, initialResourceState(row)]];
208 if (row.type === "task") {
209 return [
210 [
211 row.key,
212 {
213 key: row.key,
214 id: row.id,
215 status: row.action === "noop" ? "skipped" : "pending",
216 },
217 ],
218 ];
219 }
220 return [];
221 }),
222 );
223
224export class PlanTree {
225 readonly rows: readonly PlanRow[];
226 readonly progressRows: readonly PlanRow[];
227 readonly summary: PlanSummaryCounts;
228 readonly mode: "review" | "apply";
229 readonly detailed: boolean;
230 readonly titleDetail?: string;
231 readonly defaultMode?: ProviderMode;
232 private state: PlanTreeState;
233 private readonly listeners = new Set<() => void>();
234
235 constructor(plan: Plan | PlanTreeData, options: PlanTreeOptions = {}) {
236 this.detailed = options.detailed ?? false;
237 this.mode = options.mode ?? "review";
238 this.titleDetail = options.titleDetail;
239 this.defaultMode = plan.defaultMode;
240 this.rows = "rows" in plan ? plan.rows : buildRows(plan, this.detailed);
241 this.progressRows = this.rows.filter(isProgressRow);
242 this.summary = "rows" in plan ? plan.summary : buildPlanSummary(plan);
243 this.state = {
244 tasks: buildInitialTasks(this.rows),
245 label: options.label ?? "Plan",
246 expanded: options.expanded ?? true,
247 viewport: options.viewport ?? "virtual",
248 busy: options.busy ?? false,
249 outcome: undefined,
250 output: undefined,
251 view: "plan",
252 };
253 }
254
255 subscribe(listener: () => void) {
256 this.listeners.add(listener);
257 return () => this.listeners.delete(listener);
258 }
259
260 snapshot() {
261 return this.state;
262 }
263
264 progress(): PlanProgress {
265 let completed = 0;
266 let failures = 0;
267 let total = 0;
268 for (const row of this.progressRows) {
269 const status = this.state.tasks.get(row.key)?.status;
270 if (row.action !== "noop") {
271 total++;
272 if (status !== undefined && isTerminalStatus(status)) completed++;
273 }
274 if (status === "fail") failures++;
275 }
276 return { completed, failures, total };
277 }
278
279 setExpanded(expanded: boolean) {
280 this.update({ expanded });
281 }
282
283 setLabel(label: string) {
284 this.update({ label });
285 }
286
287 setViewport(viewport: PlanViewport) {
288 this.update({ viewport });
289 }
290
291 setBusy(busy: boolean) {
292 this.update({
293 busy,
294 outcome: busy ? undefined : this.state.outcome,
295 view: busy ? "plan" : this.state.view,
296 });
297 }
298
299 finish(
300 outcome: PlanOutcome,
301 label: string,
302 view: PlanView = this.state.view,
303 ) {
304 this.update({ busy: false, outcome, label, view });
305 }
306
307 setOutput(output: unknown) {
308 this.update({ output });
309 }
310
311 setView(view: PlanView) {
312 this.update({ view });
313 }
314
315 emit(event: ApplyEvent) {
316 const tasks = new Map(this.state.tasks);
317 const key = event.fqn;
318 const now = Date.now();
319 const timing = (current: RowState | undefined, status: ApplyStatus) => {
320 const startedAt =
321 current?.startedAt ?? (isInProgress(status) ? now : undefined);
322 return {
323 startedAt,
324 elapsedMs:
325 isTerminalStatus(status) && startedAt !== undefined
326 ? now - startedAt
327 : current?.elapsedMs,
328 };
329 };
330
331 if (event._tag === "apply.resource.status") {
332 const current = tasks.get(key);
333 if (current) {
334 tasks.set(key, {
335 key,
336 id: event.id,
337 status: event.status,
338 message: event.message ?? current.message,
339 ...timing(current, event.status),
340 });
341 }
342 } else {
343 const current = tasks.get(key);
344 if (current) tasks.set(key, { ...current, message: event.message });
345 }
346
347 this.update({ tasks });
348 }
349
350 private update(patch: Partial<PlanTreeState>) {
351 this.state = { ...this.state, ...patch };
352 for (const listener of this.listeners) listener();
353 }
354}