| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Exit from "effect/Exit"; |
| 4 | import type { Result as NukeResult, Target } from "../../../Nuke.ts"; |
| 5 | import type { ProviderMode } from "../../../ProviderMode.ts"; |
| 6 | import { Plan, PlanTree } from "./PlanView.tsx"; |
| 7 | import type { PlanTreeData } from "./PlanTree.ts"; |
| 8 | import { planDecisionScreen } from "./PlanDecision.tsx"; |
| 9 | import { Progress, type ProgressEvent } from "../../../Report.ts"; |
| 10 | import { CliKit } from "../../CliKit/index.ts"; |
| 11 | import { |
| 12 | LiveStore, |
| 13 | ProgressBar, |
| 14 | Row, |
| 15 | SpinnerGlyph, |
| 16 | Stack, |
| 17 | Text, |
| 18 | useLiveStore, |
| 19 | } from "../ui/index.ts"; |
| 20 | |
| 21 | interface NukeProgressState { |
| 22 | readonly total: number; |
| 23 | readonly completed: number; |
| 24 | readonly resources: number; |
| 25 | readonly inFlight: ReadonlyArray<string>; |
| 26 | } |
| 27 | |
| 28 | export 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 | |
| 54 | export 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 | |
| 94 | const 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. */ |
| 111 | export 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. */ |
| 133 | export 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. */ |
| 181 | export 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 | |
| 222 | export 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 | }); |