1/** @jsxImportSource @alchemy.run/sigil */
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import type { Result as NukeResult, Target } from "../../../Nuke.ts";
5import type { ProviderMode } from "../../../ProviderMode.ts";
6import { Plan, PlanTree } from "./PlanView.tsx";
7import type { PlanTreeData } from "./PlanTree.ts";
8import { planDecisionScreen } from "./PlanDecision.tsx";
9import { Progress, type ProgressEvent } from "../../../Report.ts";
10import { CliKit } from "../../CliKit/index.ts";
11import {
12 LiveStore,
13 ProgressBar,
14 Row,
15 SpinnerGlyph,
16 Stack,
17 Text,
18 useLiveStore,
19} from "../ui/index.ts";
20
21interface NukeProgressState {
22 readonly total: number;
23 readonly completed: number;
24 readonly resources: number;
25 readonly inFlight: ReadonlyArray<string>;
26}
27
28export class NukeProgressStore extends LiveStore<NukeProgressState> {
29 constructor() {
30 super({ total: 0, completed: 0, resources: 0, inFlight: [] });
31 }
32
33 emit(event: ProgressEvent) {
34 this.update((state) => {
35 switch (event._tag) {
36 case "nuke.scan.started":
37 return { ...state, total: event.total };
38 case "nuke.scan.provider.started":
39 return { ...state, inFlight: [...state.inFlight, event.provider] };
40 case "nuke.scan.provider.completed":
41 return {
42 ...state,
43 completed: state.completed + 1,
44 resources: state.resources + event.resources,
45 inFlight: state.inFlight.filter((id) => id !== event.provider),
46 };
47 default:
48 return state;
49 }
50 });
51 }
52}
53
54export function NukeProgress({ store }: { store: NukeProgressStore }) {
55 const state = useLiveStore(store);
56 return (
57 <Stack paddingX={2} paddingY={1} gap={1}>
58 <Row gap={2}>
59 <ProgressBar
60 value={state.total === 0 ? 0 : state.completed / state.total}
61 width={32}
62 showPercent={false}
63 variant="info"
64 />
65 <Text bold>
66 [{state.completed}/{state.total}]
67 </Text>
68 </Row>
69 <Stack>
70 <Text tone="muted">
71 Scanning providers · {state.resources} resource
72 {state.resources === 1 ? "" : "s"} found
73 </Text>
74 {state.inFlight.slice(0, 10).map((id) => (
75 <Row key={id} gap={1}>
76 <SpinnerGlyph />
77 <Text>Scanning {id}</Text>
78 </Row>
79 ))}
80 {state.inFlight.length > 10 ? (
81 <Text tone="muted">…and {state.inFlight.length - 10} more</Text>
82 ) : null}
83 {state.total === 0 ? (
84 <Row gap={1}>
85 <SpinnerGlyph />
86 <Text>Discovering providers</Text>
87 </Row>
88 ) : null}
89 </Stack>
90 </Stack>
91 );
92}
93
94const logNukeEvent = (event: ProgressEvent, interactive: boolean) => {
95 if (event._tag === "nuke.scan.provider.completed") {
96 if (event.error !== undefined)
97 return Effect.logWarning(`${event.provider}: ${event.error}`);
98 if (!interactive)
99 return Effect.logInfo(`scanned ${event.provider} (${event.resources})`);
100 } else if (event._tag === "nuke.resource.failed") {
101 return Effect.logWarning(
102 `${event.provider} ${event.resource}: ${event.message}`,
103 );
104 } else if (!interactive && event._tag === "nuke.resource.deleted") {
105 return Effect.logInfo(`deleted ${event.provider} ${event.resource}`);
106 }
107 return Effect.void;
108};
109
110/** Scan progress stays separate until the inventory is ready to review. */
111export const renderNukeScan =
112 () =>
113 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
114 Effect.gen(function* () {
115 const cli = yield* CliKit;
116 const store = new NukeProgressStore();
117 const live =
118 cli.terminal.input && !process.env.DEBUG
119 ? yield* cli.live.open(<NukeProgress store={store} />)
120 : undefined;
121 return yield* effect.pipe(
122 Effect.provideService(Progress, (event) =>
123 Effect.gen(function* () {
124 if (live) store.emit(event);
125 yield* logNukeEvent(event, live !== undefined);
126 }),
127 ),
128 Effect.ensuring(live?.close ?? Effect.void),
129 );
130 });
131
132/** Drive the same resource rows used by the preview through their deletion lifecycle. */
133export const renderNukeDelete =
134 (
135 targets: ReadonlyArray<Pick<Target, "providerId" | "displayName">>,
136 mode: ProviderMode,
137 ) =>
138 <A extends NukeResult, E, R>(effect: Effect.Effect<A, E, R>) =>
139 Effect.gen(function* () {
140 const cli = yield* CliKit;
141 const tree = new PlanTree(nukePlan(targets, { mode }), {
142 mode: "apply",
143 label: "Deleting resources",
144 busy: true,
145 });
146 const live =
147 cli.terminal.input && !process.env.DEBUG
148 ? yield* cli.live.open(<Plan tree={tree} />, { persistOnClose: true })
149 : undefined;
150 return yield* effect.pipe(
151 Effect.provideService(Progress, (event) =>
152 Effect.gen(function* () {
153 if (
154 event._tag === "apply.resource.status" ||
155 event._tag === "apply.resource.note"
156 )
157 tree.emit(event);
158 else if (event._tag === "nuke.pass.started")
159 tree.setLabel(`Deleting resources · pass ${event.pass}`);
160 yield* logNukeEvent(event, live !== undefined);
161 }),
162 ),
163 Effect.onExit((exit) =>
164 Effect.sync(() => {
165 const success =
166 Exit.isSuccess(exit) &&
167 exit.value.failed.length === 0 &&
168 exit.value.held.length === 0;
169 tree.finish(
170 success ? "success" : "failure",
171 success ? "Nuke complete" : "Nuke incomplete",
172 );
173 tree.setViewport("full");
174 }),
175 ),
176 Effect.ensuring(live?.close ?? Effect.void),
177 );
178 });
179
180/** Cloud inventory rows carry display identity only, never fabricated stack state. */
181export const nukePlan = (
182 targets: ReadonlyArray<Pick<Target, "providerId" | "displayName">>,
183 options: { mode: ProviderMode },
184): PlanTreeData => {
185 return {
186 defaultMode: options.mode,
187 rows: targets
188 .map((target, index) => ({
189 key: `nuke/${index}`,
190 type: "resource" as const,
191 id: target.providerId,
192 resourceType: target.providerId,
193 detail:
194 target.displayName && target.displayName !== "unknown"
195 ? target.displayName
196 : undefined,
197 depth: 0,
198 action: "delete" as const,
199 providerMode: options.mode,
200 }))
201 .sort(
202 (a, b) =>
203 a.id.localeCompare(b.id) ||
204 (a.detail ?? "").localeCompare(b.detail ?? ""),
205 ),
206 summary: {
207 counts: {
208 create: 0,
209 update: 0,
210 adopted: 0,
211 delete: targets.length,
212 orphaned: 0,
213 replace: 0,
214 noop: 0,
215 },
216 taskCounts: { run: 0, delete: 0, noop: 0 },
217 bindingChanges: 0,
218 },
219 };
220};
221
222export const reviewNuke = Effect.fn(function* (
223 targets: ReadonlyArray<Pick<Target, "providerId" | "displayName">>,
224 options: {
225 mode: ProviderMode;
226 yes: boolean;
227 dryRun: boolean;
228 },
229) {
230 const cli = yield* CliKit;
231 const plan = nukePlan(targets, options);
232 if (options.yes || options.dryRun) {
233 yield* cli.output.print(
234 <Plan
235 tree={
236 new PlanTree(plan, {
237 mode: "review",
238 label: "Nuke",
239 viewport: "full",
240 })
241 }
242 />,
243 );
244 return true;
245 }
246 return yield* cli.prompt.custom(
247 planDecisionScreen({
248 plan,
249 label: "Nuke",
250 message: `Permanently DELETE ${targets.length} ${options.mode === "local" ? "locally emulated " : ""}resource(s)? This cannot be undone.`,
251 choices: [
252 { value: true, label: "Delete" },
253 { value: false, label: "Cancel" },
254 ],
255 initialValue: false,
256 }),
257 );
258});