spinuptui 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -0,0 +1,4253 @@
1
+ // Global data + navigation store, exposed via React context.
2
+ //
3
+ // Holds the API client, the fetched collections (servers / sites / events),
4
+ // loading + error state, and the active navigation route. A single source of
5
+ // truth keeps the splash screen, header, and views in sync, and lets any view
6
+ // trigger a refresh.
7
+
8
+ import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
9
+ import { toast } from "@opentui-ui/toast"
10
+ import { SpinupWPClient, ApiError, type ServerService, type SpinupWPClientLike } from "../api/client.ts"
11
+ import { isDevMode } from "../dev/devMode.ts"
12
+ import { createMockClient } from "../dev/mockClient.ts"
13
+ import type { Server, Site, Event, ProviderMetadata, CreateServerPayload, CreateSitePayload, AdditionalDomain } from "../api/types.ts"
14
+ import { loadConfig, saveConfig, type ServerProviderRef, type KumaMonitorRef, type UptimeKumaConn } from "../config.ts"
15
+ import { saveJob, removeJob } from "../lib/jobs.ts"
16
+ import { APP_VERSION } from "../version.ts"
17
+ import { cachedUpdateInfo, refreshUpdateInfo, type UpdateInfo } from "../lib/appUpdate.ts"
18
+ import { fetchReleaseNotes, type ReleaseNotesInfo } from "../lib/releaseNotes.ts"
19
+ import { resolveLocalLink, expandPath, normalizeLink, type LocalLink } from "../lib/local.ts"
20
+ import type { Stack } from "../lib/stack.ts"
21
+ import { openTerminalAt, openUrl, openSshSession } from "../lib/open.ts"
22
+ import { gitDrift, type Drift } from "../lib/gitStatus.ts"
23
+ import { probeSite } from "../lib/probe.ts"
24
+ import { resolveZone, normalizeDomain, candidateHostnames, type ZoneHost } from "../lib/dns.ts"
25
+ import { queryAuthoritative } from "../lib/dnsQuery.ts"
26
+ import { DnsCache, type CachedDns } from "../lib/dnsCache.ts"
27
+ import {
28
+ verifyConnection as verifyProviderConnection,
29
+ apiProviderFor,
30
+ nameserversMatch,
31
+ PROVIDER_CONSOLE,
32
+ PROVIDER_REGISTRY,
33
+ consoleForHost,
34
+ DEFAULT_WEB_ACCESS_NOTE,
35
+ ALL_PROVIDERS,
36
+ type Connection,
37
+ type ConnProvider,
38
+ type VerifyResult,
39
+ type AccessState,
40
+ } from "../lib/providers.ts"
41
+ import { ProvidersCache, type VerifiedConn } from "../lib/providersCache.ts"
42
+ import { recordProviderFor, type DnsRecord, type RecordResult } from "../lib/dnsRecords.ts"
43
+ import { deriveSiteUser, seedVanityIndex, seedVanityPushCron, probeServerRedis, aRecordResolves, isVanityPair } from "../lib/vanitySite.ts"
44
+ import { withKuma, KumaClient, registerMonitors, registerFingerprintMonitor, setMonitorNotifications, genPushToken, rotatePushToken, retargetHealthKeyMonitors, LOAD_PUSH_SCALE } from "../lib/uptimeKuma.ts"
45
+ import { deriveFingerprint } from "../lib/siteFingerprint.ts"
46
+ import type { StoredProviders } from "../config.ts"
47
+ import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
48
+ import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
49
+ import { CloneLogger } from "../lib/cloneLog.ts"
50
+ import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
51
+ import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
52
+ import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword } from "../lib/keychain.ts"
53
+ import { StackCache, siteSignature, type CachedProbe } from "../lib/stackCache.ts"
54
+ import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offeredPhpVersions as offeredPhpVersionsWith, type PhpEolDates } from "../lib/phpEol.ts"
55
+ import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
56
+ import { planDbBackup, runDbBackup, type DbBackupProgress, type PlanResult } from "../lib/dbBackup.ts"
57
+ import { planDbSync, runDbSync, type DbSyncProgress, type SyncPlanResult } from "../lib/dbSync.ts"
58
+ import { planMediaFallback, type MediaFallbackResult } from "../lib/mediaFallback.ts"
59
+
60
+ export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
61
+
62
+ // Progress of a PHP-version upgrade, tracked in the store so it survives the
63
+ // modal being closed. `status` mirrors the SpinupWP event status
64
+ // (queued/creating/updating/… → deployed | failed); non-terminal means in-flight.
65
+ export interface PhpUpgradeProgress {
66
+ target: string
67
+ status: string
68
+ error?: string
69
+ }
70
+
71
+ // SpinupWP event statuses that mean the operation has settled.
72
+ const UPGRADE_DONE = "deployed"
73
+ const UPGRADE_FAIL = "failed"
74
+ const UPGRADE_POLL_MS = 2500
75
+ // Kuma status refresh — its monitors beat at 60s, so faster polling buys nothing.
76
+ const KUMA_POLL_MS = 60_000
77
+
78
+ export function isUpgradeInFlight(p: PhpUpgradeProgress | undefined): boolean {
79
+ return p != null && p.status !== UPGRADE_DONE && p.status !== UPGRADE_FAIL
80
+ }
81
+
82
+ // Progress of an HTTPS enable/disable, tracked the same way as PhpUpgradeProgress.
83
+ // A DELETE may settle synchronously (no event) — callers set status straight to
84
+ // UPGRADE_DONE in that case, so isHttpsToggleInFlight reads the same as above.
85
+ export interface HttpsToggleProgress {
86
+ action: "enable" | "disable"
87
+ status: string
88
+ error?: string
89
+ }
90
+
91
+ export function isHttpsToggleInFlight(p: HttpsToggleProgress | undefined): boolean {
92
+ return p != null && p.status !== UPGRADE_DONE && p.status !== UPGRADE_FAIL
93
+ }
94
+
95
+ // Progress of a cache purge — two independent sub-purges (page cache + WordPress
96
+ // object cache) fired together under one confirm, since there's no way to
97
+ // enable/disable either on an existing site, only purge. Both must settle (and
98
+ // neither fail) before the whole action reads as "done".
99
+ interface PurgeSub {
100
+ status: string
101
+ error?: string
102
+ }
103
+ export interface PurgeCacheProgress {
104
+ page: PurgeSub
105
+ object: PurgeSub
106
+ }
107
+
108
+ function purgeSubSettled(s: PurgeSub): boolean {
109
+ return s.status === UPGRADE_DONE || s.status === UPGRADE_FAIL
110
+ }
111
+ export function isPurgeCacheInFlight(p: PurgeCacheProgress | undefined): boolean {
112
+ return p != null && !(purgeSubSettled(p.page) && purgeSubSettled(p.object))
113
+ }
114
+ export function purgeCacheFailed(p: PurgeCacheProgress | undefined): boolean {
115
+ return !!p && (p.page.status === UPGRADE_FAIL || p.object.status === UPGRADE_FAIL)
116
+ }
117
+
118
+ // A server-level operation (reboot or a service restart), tracked in the store
119
+ // so progress survives closing the Server-actions overlay (same model as
120
+ // PhpUpgradeProgress, keyed by server id). `label` is a short display verb.
121
+ export type ServerOpKind = "reboot" | ServerService
122
+ export interface ServerOpProgress {
123
+ kind: ServerOpKind
124
+ label: string
125
+ status: string
126
+ error?: string
127
+ }
128
+
129
+ export function isServerOpInFlight(p: ServerOpProgress | undefined): boolean {
130
+ return p != null && p.status !== UPGRADE_DONE && p.status !== UPGRADE_FAIL
131
+ }
132
+
133
+ // Display names for the restartable services (used in completion toasts; `reboot`
134
+ // is handled separately). Keyed by ServerService.
135
+ const SERVICE_NAMES: Record<ServerService, string> = {
136
+ nginx: "Nginx",
137
+ php: "PHP-FPM",
138
+ mysql: "MySQL",
139
+ redis: "Redis",
140
+ }
141
+
142
+ // Progress of a server-provisioning job (POST /servers), tracked in the store so
143
+ // it survives the New-server overlay being closed (provisioning takes ~10 min).
144
+ // `status`: queued → <event status> → done | failed. Single-slot (one create at a
145
+ // time). serverId is filled once we learn it from the settled event/refresh.
146
+ export interface NewServerJob {
147
+ hostname: string
148
+ status: string
149
+ serverId?: number
150
+ error?: string
151
+ startedAt?: number // ms epoch when the create was fired (drives the elapsed readout)
152
+ eventId?: number // SpinupWP event being polled — the key that lets us resume after a restart
153
+ }
154
+
155
+ // The vanity-site build: a multi-step orchestration (DNS A record → propagate →
156
+ // create site → enable HTTPS → SSH-key handoff → seed index.php → register Kuma
157
+ // monitors → install heartbeat cron) that connects a fresh, empty server. Steps
158
+ // dns/site/https are event/poll-backed (true resume); sshkey is a manual park;
159
+ // seed/monitor/cron are idempotent pushes. monitor/cron auto-skip (straight to
160
+ // done) when no Uptime Kuma connection is configured, and a failure there can be
161
+ // skipped — the site itself is already live.
162
+ export type VanityStep = "dns" | "propagate" | "site" | "https" | "sshkey" | "seed" | "monitor" | "cron" | "done" | "error"
163
+ export interface VanityJob {
164
+ serverId: number
165
+ serverIp: string
166
+ hostname: string // = domain = server name
167
+ apex: string // the zone apex the A record is written into
168
+ siteUser: string
169
+ publicFolder: string | null
170
+ port: number | null // server ssh_port, captured for the seed step
171
+ step: VanityStep
172
+ failedStep?: VanityStep
173
+ error?: string
174
+ startedAt: number
175
+ connId?: string // the DNS provider connection serving the zone
176
+ siteId?: number // the created site (for the HTTPS call + deep links)
177
+ sslSkipped?: boolean
178
+ propagateTimedOut?: boolean // DNS hasn't resolved within the window → offer skip/wait
179
+ propagateStartedAt?: number
180
+ keepWaiting?: boolean // user chose "keep waiting" after the first timeout → poll
181
+ // indefinitely (count-up in the UI), no more timeout prompts
182
+ kumaPushToken?: string // token feeding the load push monitor (cron step input)
183
+ }
184
+ export function isVanityInFlight(j: VanityJob | null | undefined): boolean {
185
+ return j != null && j.step !== "done" && j.step !== "error"
186
+ }
187
+
188
+ // A per-site Uptime Kuma setup run (the `m` overlay): optional page re-seed →
189
+ // register monitors → install the heartbeat cron.
190
+ export interface KumaOp {
191
+ status: "running" | "done" | "error"
192
+ detail: string
193
+ error?: string
194
+ }
195
+
196
+ // One notification provider (Settings → Notifications in Kuma) + whether it's
197
+ // attached to a given site's Spinup monitors — the `n` overlay's row model.
198
+ export interface KumaAlertProvider {
199
+ id: number
200
+ name: string
201
+ active: boolean // paused providers exist in Kuma but never fire
202
+ attachedAll: boolean // attached to every one of this site's monitors
203
+ attachedAny: boolean // attached to at least one (partial wiring)
204
+ }
205
+
206
+ // One domain's live status as Kuma sees it, refreshed by the store's poll loop.
207
+ // `responseBeats`/`loadBeats` are ping series (ms / 1-min load) oldest-first,
208
+ // ready for sparkline().
209
+ export interface KumaDomainStatus {
210
+ up: boolean | null // last beat status; null = no beats yet
211
+ uptime24: number | null // 0..1 fraction from Kuma's 24h uptime event
212
+ responseBeats: number[]
213
+ loadBeats: number[]
214
+ lastLoad: number | null
215
+ // The front-page fingerprint monitor's last beat. Kept separate from `up`:
216
+ // false means "answering 200 but serving the WRONG page" — a distinct, worse
217
+ // signal than down, and it must not be averaged away by a healthy up/down beat.
218
+ fingerprintUp: boolean | null
219
+ // The server's Redis sentinel (vanity domains only): false = the cron reports
220
+ // Redis unreachable while the server itself is beating fine.
221
+ redisUp: boolean | null
222
+ }
223
+
224
+ // Clone a server to a new server (backlog item 5). Five server-level steps wrapping
225
+ // an N-wide per-site fan-out. The two heavy steps (clone, cutover) expand into a
226
+ // per-site roster. See docs/2026-06-24_clone-to-server-spec.md. (Slice 2 = shell +
227
+ // Plan; server/trust/clone/cutover orchestration land in later slices.)
228
+ export type CloneStep = "plan" | "server" | "trust" | "gitaccess" | "clone" | "cutover" | "done" | "error"
229
+ // Deploy-key onboarding state for one repo (git-native Bedrock dest). Each repo gets
230
+ // its OWN locally-generated keypair — GitHub attaches a deploy key to exactly ONE repo
231
+ // account-wide, so the server-wide git_publickey can never cover a second repo on the
232
+ // same server (verified 2026-07-06). The public half must be on the repo before the
233
+ // `git` create; the pair rides the create payload (git.deploy_key_enabled) so SpinupWP
234
+ // installs it as the site's git identity.
235
+ export type RepoKeyStatus = "checking" | "present" | "missing" | "adding" | "added" | "manual" | "error"
236
+ export interface RepoKeyState {
237
+ repo: string // raw source git.repo (the map key)
238
+ owner: string
239
+ name: string
240
+ host: string
241
+ kind: RepoHost
242
+ settingsUrl: string | null // where to add a deploy key by hand (manual fallback)
243
+ status: RepoKeyStatus
244
+ auto: boolean // gh+GitHub available → we can detect/add automatically
245
+ publicKey?: string // the generated per-repo keypair — memory only, never logged
246
+ privateKey?: string
247
+ error?: string
248
+ }
249
+ export type CloneSiteStep = "queued" | "create" | "pull" | "config" | "deploy" | "verify" | "done" | "error"
250
+ export interface CloneSiteState {
251
+ sourceSiteId: number
252
+ domain: string
253
+ siteUser: string // source site_user — reused on the dest + for the source-side pull
254
+ selected: boolean // unchecked in Plan → skipped entirely
255
+ isWordPress: boolean // API is_wordpress — false (non-git) = redirect/static site the WP chain can't clone
256
+ sizeBytes?: number // webroot + DB estimate (sizing + progress); undefined until sized
257
+ sizeWebBytes?: number // webroot-only bytes (uncompressed) — the files-transfer soft ceiling
258
+ stack: "wp" | "bedrock" | "files" // git repo → bedrock; is_wordpress → wp; else files-only (redirect shells, static/PHP sites)
259
+ gitRepo?: string // source git.repo (Bedrock) — the dest is created as a `git` site of it
260
+ gitBranch?: string // source git.branch
261
+ gitDeployKey?: { privateKey: string; publicKey: string } // unique per-site key (stamped leaving gitaccess) → create payload
262
+ additionalDomains?: string[] // extra domains served by the site → extra cutover records
263
+ additionalDomainConfigs?: AdditionalDomain[] // full source configs (redirects) — re-created on the dest
264
+ excludeUploads: boolean // per-site opt-out (default false = sync uploads/)
265
+ phpVersion?: string // matched from source for the dest create
266
+ publicFolder?: string // matched from source for the dest create + where WP lives under files/
267
+ tablePrefix?: string // source DB table_prefix — the dest DB is created to match
268
+ sourceWebrootRel?: string // DETECTED source WP dir rel to files/ ("" = files root), from the pull
269
+ destWebrootRel?: string // the clone's WP dir rel to files/ (post-normalization), from the pull
270
+ destSiteId?: number
271
+ destDbName?: string // dest DB creds (generated at create; reused on retry)
272
+ destDbPassword?: string
273
+ step: CloneSiteStep
274
+ detail?: string // current sub-activity (the pull stage), for the roster
275
+ stageStartedAt?: number // when the current step/stage began — drives the roster's elapsed timer
276
+ transferBytes?: number // bytes received so far in the current transfer (sidecar stat poll)
277
+ transferRate?: number // smoothed bytes/sec derived from successive polls
278
+ transferTarget?: number // expected final size when known
279
+ transferExact?: boolean // true = target is a fact (staged DB dump) → real percent; false = soft ceiling
280
+ failedStep?: CloneSiteStep
281
+ error?: string
282
+ verifying?: boolean // slice 5: verify drill-down in flight
283
+ verify?: CloneVerifyResult // source-vs-clone comparison + HTTP check
284
+ verifyError?: string
285
+ cutover?: CloneCutoverState // slice 6: DNS repoint state for this site's domain
286
+ }
287
+ // Slice 6: per-site DNS cutover. Each A record among the site's domains (primary +
288
+ // additional_domains) is repointed from the old server IP to the new one. www-style
289
+ // CNAMEs that point at the apex follow it automatically, so a hostname with no A
290
+ // record is simply skipped (not flagged). "ready" = editable + on the old IP (will
291
+ // flip); "manual" = not API-editable (proxied/alias/no account) → repoint by hand.
292
+ export type CloneCutoverStatus = "pending" | "checking" | "ready" | "flipping" | "done" | "manual" | "error"
293
+ export interface CutoverRecord {
294
+ name: string // the hostname this A record sits at
295
+ status: CloneCutoverStatus
296
+ currentValue?: string // value read from the zone (the old IP, usually)
297
+ targetValue?: string // the new server IP
298
+ reason?: string // when manual: why it can't be auto-flipped
299
+ error?: string
300
+ apex?: string // the zone apex (for the registrar web handoff + note lookup)
301
+ hostKey?: string // the DNS host key (drives consoleForHost)
302
+ excluded?: boolean // ready record the user deselected (space) — c skips it
303
+ }
304
+ export interface CloneCutoverState {
305
+ status: CloneCutoverStatus // aggregate over records (rail badge + summary)
306
+ records: CutoverRecord[]
307
+ }
308
+ // Roll the per-record statuses into one site status: anything still in motion wins,
309
+ // then any flippable work, then errors, then manual, else fully done.
310
+ export function aggregateCutover(records: CutoverRecord[]): CloneCutoverStatus {
311
+ if (records.length === 0) return "checking"
312
+ if (records.some((r) => r.status === "checking")) return "checking"
313
+ if (records.some((r) => r.status === "flipping")) return "flipping"
314
+ if (records.some((r) => r.status === "ready")) return "ready"
315
+ if (records.some((r) => r.status === "error")) return "error"
316
+ if (records.some((r) => r.status === "manual")) return "manual"
317
+ return "done"
318
+ }
319
+ export interface CloneJob {
320
+ sourceServerId: number
321
+ sourceServerName: string
322
+ step: CloneStep
323
+ failedStep?: CloneStep
324
+ error?: string
325
+ // captured inputs (server step fills/edits these; Plan seeds from the source)
326
+ specs: { providerName: string; region: string; size: string; cost?: number; enableBackups?: boolean }
327
+ destServerName: string
328
+ concurrency: number // default 3 (protects the live source's I/O)
329
+ lowerTtlEarly: boolean
330
+ destServerId?: number
331
+ destServerIp?: string
332
+ sites: CloneSiteState[] // the fan-out
333
+ repoKeys?: RepoKeyState[] // deploy-key onboarding (Bedrock dests; gitaccess step)
334
+ fanoutStarted?: boolean // startClone ran — survives the wizard being backgrounded/reopened
335
+ startedAt: number
336
+ logPath?: string // per-job JSONL log (full stage output; the roster only shows truncated errors)
337
+ }
338
+
339
+ // The gitaccess step is only needed when a selected site is Bedrock (a git repo whose
340
+ // dest the new server must be authorized to clone).
341
+ export function cloneNeedsGitAccess(j: CloneJob): boolean {
342
+ return j.sites.some((s) => s.selected && s.stack === "bedrock" && !!s.gitRepo)
343
+ }
344
+ export function isCloneInFlight(j: CloneJob | null | undefined): boolean {
345
+ return j != null && j.step !== "done" && j.step !== "error"
346
+ }
347
+ // Detect a site's stack the way the clone branches: a git repo → Bedrock
348
+ // (is_wordpress is unreliable for git sites — see the API findings doc);
349
+ // is_wordpress → Standard WP; anything else → files-only (no DB, no wp-cli).
350
+ export function cloneStackFor(site: Site): "wp" | "bedrock" | "files" {
351
+ return site.git?.repo ? "bedrock" : site.is_wordpress ? "wp" : "files"
352
+ }
353
+ // Alphanumeric token for generated dest DB passwords (never printed/persisted).
354
+ function randomToken(n: number): string {
355
+ const a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
356
+ const buf = new Uint8Array(n)
357
+ crypto.getRandomValues(buf)
358
+ return Array.from(buf, (b) => a[b % a.length]).join("")
359
+ }
360
+
361
+ // Resumable-job ids. The server create is a singleton; the per-site jobs are
362
+ // keyed by site id so several can be in flight (and resume) at once.
363
+ const NEW_SERVER_JOB_ID = "newServer"
364
+ const VANITY_JOB_ID = "vanity"
365
+ const VANITY_POLL_MS = 2500
366
+ // ~2 min, then offer skip/keep-waiting. Exported so the overlay can render the
367
+ // matching count-down (and, after keep-waiting, the count-up from this baseline).
368
+ export const VANITY_PROPAGATE_TIMEOUT_MS = 120_000
369
+ const phpJobId = (siteId: number) => `phpUpgrade:${siteId}`
370
+ const httpsJobId = (siteId: number) => `httpsToggle:${siteId}`
371
+ const dbSyncJobId = (siteId: number) => `dbSync:${siteId}`
372
+ const dbBackupJobId = (siteId: number) => `dbBackup:${siteId}`
373
+
374
+ // SSH-orchestrated jobs (no SpinupWP event to re-attach to) can't truly resume —
375
+ // their child processes died with the app. On restart we surface them as
376
+ // interrupted rather than pretend, since the local DB may be half-applied.
377
+ const INTERRUPTED_SYNC_MSG = "Interrupted by a restart — your local database may be partially imported. Re-run the sync (p)."
378
+ const INTERRUPTED_BACKUP_MSG = "Interrupted by a restart — the download didn't finish. Re-run the backup (d)."
379
+ // Server provisioning events settle with one of these (broader than PHP/site
380
+ // writes, whose terminal is "deployed"); finished_at also implies done.
381
+ const SERVER_DONE = new Set(["deployed", "completed", "provisioned", "finished", "success"])
382
+ const SERVER_FAIL = new Set(["failed", "errored", "error"])
383
+
384
+ export function isNewServerInFlight(j: NewServerJob | null | undefined): boolean {
385
+ return j != null && j.status !== "done" && j.status !== "failed"
386
+ }
387
+
388
+ // Progress of an SSH-key grant (drop Spinup's machine key into a site user's
389
+ // authorized_keys, via sudo), keyed by site id. SSH-orchestrated and fast, so —
390
+ // unlike the event-backed writes — it isn't persisted/resumed; it just lives for
391
+ // the session so the overlay can follow it. `status`: probing → granting → done | error.
392
+ export interface KeyGrantProgress {
393
+ status: string
394
+ target?: string
395
+ error?: string
396
+ }
397
+ export function isKeyGrantInFlight(p: KeyGrantProgress | undefined): boolean {
398
+ return p != null && p.status !== "done" && p.status !== "error"
399
+ }
400
+
401
+ // Which single field of a hosting record an edit targets: its TTL, or its VALUE
402
+ // (where the record points — the migration repoint). One editor, two modes.
403
+ export type RecordEditKind = "ttl" | "value"
404
+
405
+ // A record change (Phase 3), tracked in the store (same model as the other
406
+ // writes) so it survives the records overlay being closed and a Route 53 change
407
+ // can keep polling to INSYNC in the background. Keyed by the record's stable key.
408
+ // `status`: queued → pending (Route 53 propagating) → done | failed. Cloudflare
409
+ // applies synchronously, so it jumps straight to done. `kind` says which field
410
+ // changed; `ttl`/`value` carry the target for that kind.
411
+ export interface RecordWriteProgress {
412
+ kind: RecordEditKind
413
+ ttl?: number
414
+ value?: string
415
+ status: string
416
+ error?: string
417
+ host: string // record hostname (lowercased) — lets the inventory match a write to a row
418
+ type: string // record type (A / AAAA / CNAME …)
419
+ }
420
+ const WRITE_DONE = "done"
421
+ const WRITE_FAIL = "failed"
422
+ const WRITE_POLL_MS = 3000
423
+
424
+ export function isRecordWriteInFlight(p: RecordWriteProgress | undefined): boolean {
425
+ return p != null && p.status !== WRITE_DONE && p.status !== WRITE_FAIL
426
+ }
427
+
428
+ // A resolved website-hosting record for one hostname (apex / www / additional
429
+ // domain), read cred-free from the zone's authoritative NS for the DNS inventory.
430
+ // `type === "none"` means the hostname has no record (e.g. www isn't configured).
431
+ export interface HostRecord {
432
+ host: string // the hostname (lowercased)
433
+ apex: string // its zone apex
434
+ type: string // A | AAAA | CNAME | none
435
+ ttl: number | null // configured TTL (from the authoritative answer)
436
+ value: string // record value (IP or CNAME target)
437
+ // Every A value in the authoritative answer (following CNAME chains). Kept
438
+ // server-NEUTRAL on purpose: the cache is keyed by hostname and shared across
439
+ // servers, so "points at this server" is computed at render time against the
440
+ // server being viewed — a baked-in boolean went stale the moment the same
441
+ // hostname was viewed from a different server's inventory.
442
+ resolvedIps: string[]
443
+ checkedAt: number
444
+ }
445
+
446
+ export interface DataState {
447
+ servers: Server[]
448
+ sites: Site[]
449
+ events: Event[]
450
+ loading: boolean
451
+ // Set once the very first load (servers + sites) has completed — drives the splash.
452
+ ready: boolean
453
+ error: string | null
454
+ lastUpdated: Date | null
455
+ // Latest released version vs. the running one (null until/unless we learn it).
456
+ updateInfo: UpdateInfo | null
457
+ // "What's new" notes for the running version, shown once after an update
458
+ // (null once dismissed, or when there's nothing to show — see releaseNotes.ts).
459
+ releaseNotesInfo: ReleaseNotesInfo | null
460
+ }
461
+
462
+ interface StoreValue extends DataState {
463
+ client: SpinupWPClientLike
464
+ route: Route
465
+ setRoute: (r: Route) => void
466
+ refresh: () => Promise<void>
467
+ // When true, global keyboard shortcuts are suppressed (e.g. while typing in a search box).
468
+ inputMode: boolean
469
+ setInputMode: (v: boolean) => void
470
+ // When true, a modal overlay (e.g. help) is open and views should ignore navigation keys.
471
+ overlayOpen: boolean
472
+ setOverlayOpen: (v: boolean) => void
473
+ // The server whose live health view is open, or null. Set by the Browser.
474
+ healthServer: Server | null
475
+ setHealthServer: (s: Server | null) => void
476
+ // The site whose plugins/themes (wp-cli) view is open, or null. Set by site views.
477
+ wpInventorySite: Site | null
478
+ setWpInventorySite: (s: Site | null) => void
479
+ // The site whose PHP-upgrade overlay is open, or null. Set by site views.
480
+ phpUpgradeSite: Site | null
481
+ setPhpUpgradeSite: (s: Site | null) => void
482
+ // In-flight (and just-failed) PHP upgrades, keyed by site id. Tracked in the
483
+ // store — not the overlay — so progress survives closing the modal; site rows
484
+ // and detail panels read this to show a spinner/marker.
485
+ phpUpgrades: Map<number, PhpUpgradeProgress>
486
+ // Fire a PHP upgrade and poll its event to completion in the background.
487
+ startPhpUpgrade: (site: Site, version: string) => void
488
+ // Drop a terminal (deployed/failed) entry — e.g. when the modal is dismissed.
489
+ clearPhpUpgrade: (siteId: number) => void
490
+ // The site whose HTTPS enable/disable overlay is open, or null.
491
+ httpsToggleSite: Site | null
492
+ setHttpsToggleSite: (s: Site | null) => void
493
+ // In-flight (and just-settled) HTTPS toggles, keyed by site id — same
494
+ // background-progress model as phpUpgrades.
495
+ httpsToggles: Map<number, HttpsToggleProgress>
496
+ // Fire an enable/disable (derived from the site's current https.enabled) and
497
+ // poll it to completion in the background.
498
+ startHttpsToggle: (site: Site) => void
499
+ clearHttpsToggle: (siteId: number) => void
500
+ // The site whose purge-cache overlay is open, or null.
501
+ purgeCacheSite: Site | null
502
+ setPurgeCacheSite: (s: Site | null) => void
503
+ // In-flight (and just-settled) cache purges, keyed by site id.
504
+ purgeCacheProgress: Map<number, PurgeCacheProgress>
505
+ // Fire both purge-page-cache and purge-object-cache and poll each to
506
+ // completion in the background.
507
+ startPurgeCache: (site: Site) => void
508
+ clearPurgeCache: (siteId: number) => void
509
+ // The server whose actions overlay (reboot / service restart) is open, or null.
510
+ serverActionsServer: Server | null
511
+ setServerActionsServer: (s: Server | null) => void
512
+ // In-flight (and just-failed) server operations, keyed by server id.
513
+ serverOps: Map<number, ServerOpProgress>
514
+ // Fire a server op (reboot or service restart) and poll its event in the background.
515
+ startServerOp: (server: Server, kind: ServerOpKind, label: string) => void
516
+ clearServerOp: (serverId: number) => void
517
+ // Whether the "create a server" overlay is open. Separate from the source so the
518
+ // flow can run with no seed (general "from scratch" create, and the empty-fleet
519
+ // case where there's no server to select).
520
+ newServerOpen: boolean
521
+ setNewServerOpen: (v: boolean) => void
522
+ // The source server whose specs seed the form (match-source default), or null
523
+ // for a from-scratch create.
524
+ newServerSource: Server | null
525
+ setNewServerSource: (s: Server | null) => void
526
+ // The single in-flight (or just-settled) provisioning job, tracked in the store
527
+ // so it survives closing the overlay. Null when no create is pending/recent.
528
+ newServerJob: NewServerJob | null
529
+ // Fire POST /servers and poll its event to completion in the background.
530
+ startNewServer: (payload: CreateServerPayload, hostname: string) => void
531
+ clearNewServer: () => void
532
+ // Vanity-site build (connect a fresh, empty server). `vanityServer` opens the
533
+ // overlay; `vanityJob` is the resumable multi-step progress.
534
+ vanityServer: Server | null
535
+ setVanityServer: (s: Server | null) => void
536
+ vanityJob: VanityJob | null
537
+ startVanity: (server: Server, opts: { siteUser: string; skipSsl?: boolean }) => void
538
+ vanitySshKeyDone: () => void // user confirms the SSH key is on the server → seed
539
+ vanitySkipSsl: () => void // from the propagation-timeout prompt: create site, skip HTTPS
540
+ vanityKeepWaiting: () => void // from the prompt: poll indefinitely (count-up), no re-prompt
541
+ vanityStopWaiting: () => void // from keep-waiting: stop waiting, continue the normal flow
542
+ vanityRetry: () => void // re-enter the failed step
543
+ vanitySkipKuma: () => void // failed monitor/cron step → settle as done (site is live)
544
+ clearVanity: () => void
545
+ vanityHealthKeyFor: (domain: string) => string | null // key baked into the seeded page's ?format=json mode
546
+ kumaConfigured: boolean // an Uptime Kuma connection exists (config or env)
547
+ kumaSite: Site | null // site whose Kuma overlay (m) is open
548
+ setKumaSite: (s: Site | null) => void
549
+ kumaOps: Map<number, KumaOp> // in-flight/settled monitor setups, by site id
550
+ connectKuma: (conn: { url: string; username: string; password: string }, twofa?: string) => Promise<{ ok: true; version: string | null } | { ok: false; error: string; tokenRequired?: boolean }>
551
+ kumaMonitorFor: (domain: string) => KumaMonitorRef | null // monitors Spinup registered for a domain
552
+ startKumaSetup: (site: Site, opts?: { reseed?: boolean }) => void
553
+ startVanityReseed: (site: Site) => void // refresh an existing vanity page (no Kuma needed)
554
+ startKumaRotate: (site: Site) => void // rotate the push token + health key (vanity; old URLs die immediately)
555
+ startFingerprintSetup: (site: Site, intervalSec: number) => void // calibrate + register the front-page fingerprint monitor
556
+ fetchKumaAlerts: (site: Site) => Promise<{ ok: true; providers: KumaAlertProvider[]; monitorIds: number[] } | { ok: false; error: string }> // live read of provider/attachment state
557
+ toggleKumaAlert: (site: Site, providerId: number, on: boolean, monitorIds: number[]) => Promise<{ ok: true } | { ok: false; error: string }>
558
+ clearKumaOp: (siteId: number) => void // forget a settled op so the overlay opens fresh
559
+ kumaStatus: Map<string, KumaDomainStatus> // live per-domain status, refreshed every minute
560
+ // Clone a server to a new server (item 5). `cloneServer` opens the wizard; `cloneJob`
561
+ // is the (Plan-draft then running) job whose heavy work lives in its `sites[]` vector.
562
+ cloneServer: Server | null
563
+ setCloneServer: (s: Server | null) => void
564
+ cloneJob: CloneJob | null
565
+ beginClone: (server: Server) => void // build the Plan draft (sites[] from the source)
566
+ toggleCloneSite: (sourceSiteId: number) => void // include/exclude a site in Plan
567
+ toggleCloneSiteUploads: (sourceSiteId: number) => void // per-site uploads opt-out
568
+ setCloneConcurrency: (n: number) => void // throttle (1..N, protects the source)
569
+ toggleCloneLowerTtl: () => void // drop apex/www TTLs at the start so cutover is fast
570
+ cloneAdvanceFromPlan: () => void // Plan → New server step
571
+ cloneSetDest: (server: Server) => void // capture the dest (provisioned or existing) → Connect dest
572
+ cloneTrustContinue: () => void // Connect dest → (gitaccess if Bedrock | clone)
573
+ cloneDetectRepoKeys: () => Promise<void> // detect dest deploy key on each Bedrock repo
574
+ cloneAddRepoKey: (repo: string) => Promise<void> // gh: add dest key as read-only deploy key
575
+ cloneGitAccessContinue: () => void // gitaccess → Clone sites
576
+ cloneSizeSites: () => Promise<void> // measure source sites' webroot+DB over source sudo → sizeBytes
577
+ startClone: () => void // run the fan-out (worker pool, cap = concurrency) over selected sites
578
+ cloneRetrySite: (siteId: number) => void // re-run one failed site
579
+ cloneContinueToCutover: () => void // explicit user continue: settled roster → DNS cutover
580
+ cloneGoBack: () => void // ← one screen back (config steps freely; post-fan-out only cutover → roster)
581
+ toggleCutoverExclude: (siteId: number, name: string) => void // space: include/exclude a ready cutover record
582
+ verifyCloneSite: (siteId: number) => void // slice 5: verify one cloned site (source-vs-clone)
583
+ cutoverCheck: (siteIds?: number[]) => Promise<void> // slice 6: read + classify each site's DNS records (optionally only the given sites)
584
+ startCutover: () => void // slice 6: flip every "ready" record to the new IP (batched)
585
+ cloneCutoverFinish: () => void // slice 6: cutover → done summary
586
+ backgroundClone: () => void // hide the wizard, keep the in-flight clone running (reopen with C)
587
+ clearClone: () => void
588
+ // Provider size/region catalog (with pricing), cached per provider key for the
589
+ // session. loadProviderMetadata fetches lazily on demand.
590
+ providerMetadata: Map<string, ProviderMetadata>
591
+ providerMetadataLoading: Set<string>
592
+ providerMetadataError: Map<string, string>
593
+ loadProviderMetadata: (providerKey: string) => void
594
+ // SpinupWP server-provider connections (provider key → {id}), from config.
595
+ serverProviders: Record<string, ServerProviderRef>
596
+ // Persist a provider id supplied in-app (writes config.json).
597
+ saveServerProviderId: (providerKey: string, id: number) => void
598
+ // Reboot "why" — SSH-probed Ubuntu reboot-required detail, keyed by server id.
599
+ rebootInfo: Map<number, RebootInfo>
600
+ rebootInfoLoading: Set<number>
601
+ rebootInfoErrors: Map<number, string>
602
+ loadRebootInfo: (server: Server) => void
603
+ // Grant-SSH-key overlay (privileged write-over-SSH): the site whose machine-key
604
+ // grant overlay is open, or null. Set by site views.
605
+ grantKeySite: Site | null
606
+ setGrantKeySite: (s: Site | null) => void
607
+ // In-flight (and just-settled) key grants, keyed by site id. Tracked in the
608
+ // store so the grant survives closing the modal (mirrors the other writes).
609
+ keyGrants: Map<number, KeyGrantProgress>
610
+ // Grant the given public keys (machine and/or personal) to one or many sites'
611
+ // authorized_keys via the server's sudo user (all sites must share a server).
612
+ // Reads the sudo user + (in-memory) password from the store; per-site progress
613
+ // lands in keyGrants. The overlay resolves which key lines and which sites.
614
+ startGrantKey: (sites: Site[], pubkeys: string[]) => void
615
+ // Remove the given keys from one or many sites' authorized_keys (reverse of grant).
616
+ startRevokeKey: (sites: Site[], pubkeys: string[]) => void
617
+ // Grant the user's remembered keys to the given sites (resolves saved selection
618
+ // to key lines). Used by auto-grant flows like the vanity build.
619
+ startGrantRemembered: (sites: Site[]) => void
620
+ // The key bodies the user last chose to grant (pre-selected next time), persisted.
621
+ preferredGrantKeys: string[]
622
+ setPreferredGrantKeys: (ids: string[]) => void
623
+ // Whether Spinup has granted any key on a site (drives the row badge), and the
624
+ // recorded key bodies. forgetGrantedKeys is used by revoke.
625
+ siteHasGrantedKey: (siteId: number) => boolean
626
+ // Granted keys on a site split into personal (yours) vs machine (spinup-tui).
627
+ grantedKeyKinds: (siteId: number) => { personal: number; machine: number }
628
+ grantedKeys: Map<number, Set<string>>
629
+ forgetGrantedKeys: (siteId: number, bodies: string[]) => void
630
+ clearGrantKey: (siteId: number) => void
631
+ // Per-zone access-note overrides (apex → text); see setZoneAccessNote's comment.
632
+ zoneAccessNotes: Map<string, string>
633
+ setZoneAccessNote: (apex: string, note: string) => void
634
+ // Dismiss the release-notes overlay and persist the running version as seen.
635
+ dismissReleaseNotes: () => void
636
+ // Show the running version's release notes on demand (Help's `n`), regardless
637
+ // of whether they've already been seen/dismissed.
638
+ showReleaseNotes: () => void
639
+ // Per-server sudo user (persisted, username only) for privileged writes.
640
+ sudoUserFor: (serverId: number) => string | undefined
641
+ // Sudo "connection" on a server: validate the sudo user + password against the
642
+ // live server, then hold them for the session so every privileged action on that
643
+ // server just works (the explicit ● connected model). The password is in-memory only.
644
+ sudoConnectServer: Server | null
645
+ setSudoConnectServer: (s: Server | null) => void
646
+ isSudoConnected: (serverId: number) => boolean
647
+ connectSudo: (server: Server, user: string, password: string, remember?: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
648
+ connectSudoFromKeychain: (server: Server) => Promise<{ ok: true } | { ok: false; error: string }>
649
+ disconnectSudo: (serverId: number) => void
650
+ sudoSavedFor: (serverId: number) => boolean // a sudo password is saved in the Keychain
651
+ forgetSudoKeychain: (serverId: number) => Promise<void>
652
+ keychainAvailable: boolean // macOS Keychain storage is available (opt-in sudo save)
653
+ // The site whose local-link overlay is open, or null. Set by site views.
654
+ localLinkSite: Site | null
655
+ setLocalLinkSite: (s: Site | null) => void
656
+ // Local working-copy links, keyed by site id (hydrated from config; persisted
657
+ // on every change). Phase 1: manual link/unlink + view, no mutation.
658
+ localLinks: Map<number, LocalLink>
659
+ // Create or update a site's local link and persist it to the config file.
660
+ linkSite: (siteId: number, link: LocalLink) => void
661
+ // Remove a site's local link and persist the removal.
662
+ unlinkSite: (siteId: number) => void
663
+ // Configured scan roots for auto-discovery (hydrated from config, persisted on
664
+ // change). The discovery overlay scans these for local working copies.
665
+ localRoots: string[]
666
+ addLocalRoot: (dir: string) => void
667
+ // Whether the local-copy discovery overlay is open.
668
+ discoverOpen: boolean
669
+ setDiscoverOpen: (v: boolean) => void
670
+ // Whether the "needs a local copy" (forgotten) report overlay is open, and an
671
+ // optional stack filter (set from the selected Stacks group when opened).
672
+ forgottenOpen: boolean
673
+ setForgottenOpen: (v: boolean) => void
674
+ forgottenStack: Stack | null
675
+ setForgottenStack: (s: Stack | null) => void
676
+ // When true, closing the link overlay reopens the forgotten report (set only
677
+ // when the link overlay was opened from that report, so Esc behaves normally
678
+ // everywhere else).
679
+ linkReturnToForgotten: boolean
680
+ setLinkReturnToForgotten: (v: boolean) => void
681
+ // Open the local working copy in a terminal / the local URL in a browser.
682
+ // Centralized so every surface (overlay, Stacks, Browser) behaves identically;
683
+ // each returns a short status message for the caller to flash.
684
+ openLocalTerminal: (siteId: number) => string
685
+ openLocalUrl: (siteId: number) => string
686
+ // Open a terminal and SSH into the site (site_user@server_ip). Returns a flash.
687
+ sshSite: (siteId: number) => string
688
+ // The site whose DB-backup overlay is open, or null. Set by site views.
689
+ dbBackupSite: Site | null
690
+ setDbBackupSite: (s: Site | null) => void
691
+ // In-flight (and just-settled) DB-backup downloads, keyed by site id. Tracked in
692
+ // the store — not the overlay — so the download survives closing the modal.
693
+ dbBackups: Map<number, DbBackupProgress>
694
+ // Resolve a site's backup plan (SSH target, remote docroot, local destination)
695
+ // or a reason it can't run — used by the overlay's confirm screen.
696
+ planDbBackupFor: (site: Site) => PlanResult
697
+ // Export the production DB and download it to the linked project's sql/ dir.
698
+ startDbBackup: (site: Site) => void
699
+ clearDbBackup: (siteId: number) => void
700
+ // The site whose DB-sync overlay is open, or null. Set by site views.
701
+ dbSyncSite: Site | null
702
+ setDbSyncSite: (s: Site | null) => void
703
+ // In-flight (and just-settled) DB syncs, keyed by site id. Tracked in the store
704
+ // so the (longer, destructive-on-local) sync survives closing the modal.
705
+ dbSyncs: Map<number, DbSyncProgress>
706
+ // Resolve a site's sync plan (remote + local detection) or a reason it can't run.
707
+ planDbSyncFor: (site: Site) => SyncPlanResult
708
+ // Pull production into the local copy: backup local → export/download prod →
709
+ // import → search-replace URLs → run post-import hook. Destructive on LOCAL.
710
+ startDbSync: (site: Site) => void
711
+ clearDbSync: (siteId: number) => void
712
+
713
+ // Production media fallback overlay (mu-plugin in the linked local copy). State
714
+ // is the plugin file's presence, resolved fresh by planMediaFallbackFor.
715
+ mediaFallbackSite: Site | null
716
+ setMediaFallbackSite: (s: Site | null) => void
717
+ planMediaFallbackFor: (site: Site) => MediaFallbackResult
718
+ // Local git drift for linked sites, keyed by site id (null = not a git repo,
719
+ // undefined = not yet computed). Computed lazily + cached; cleared on refresh.
720
+ drift: Map<number, Drift | null>
721
+ ensureDrift: (siteId: number, linkPath: string) => void
722
+ // Optional SSH user override for the health view (from env/config).
723
+ sshUser: string | null
724
+ // Opt-in for the destructive production→local DB sync (`p`); default off.
725
+ localSync: boolean
726
+ // SpinupWP account slug (from env/config) for building web deep links.
727
+ accountSlug: string | null
728
+ sitesForServer: (serverId: number) => Site[]
729
+ serverById: (id: number | null | undefined) => Server | undefined
730
+ // Tier-2 stack probes (on-demand SSH), hydrated from disk at startup.
731
+ probes: Map<number, CachedProbe> // by site id
732
+ probingIds: Set<number> // sites with an in-flight probe
733
+ probeErrors: Map<number, string> // last error per site id
734
+ // Probe a single site over SSH (fire-and-forget); write-through to the cache.
735
+ runProbe: (site: Site) => void
736
+ // Probe many sites with a bounded concurrency pool (skips in-flight sites).
737
+ runProbeMany: (sites: Site[]) => void
738
+ // Whether a cached probe for this site is stale (site shape changed since).
739
+ isProbeStale: (site: Site) => boolean
740
+ // DNS zone-host lookups (read-only). Hydrated from disk at startup; resolved
741
+ // lazily on demand (never auto-fired on selection — network cost).
742
+ dnsZones: Map<string, CachedDns> // by normalized domain (www-stripped, lowercased)
743
+ dnsResolving: Set<string> // normalized domains with an in-flight lookup
744
+ // Resolve every domain of a site / of all sites on a server (bounded conc).
745
+ lookupSiteDns: (site: Site, force?: boolean) => void
746
+ lookupServerDns: (serverId: number, force?: boolean) => void
747
+ // The cached zone-host for a domain (undefined = never looked up).
748
+ zoneForDomain: (domain: string) => CachedDns | undefined
749
+ isDnsResolving: (domain: string) => boolean
750
+ // Every distinct zone apex already known (fleet-wide) to be hosted at a host
751
+ // (e.g. "godaddy"). Sorted.
752
+ zonesForHostKey: (hostKey: string) => string[]
753
+ // Resolve every site's domain(s) fleet-wide, filling gaps in the lazy DNS cache.
754
+ resolveAllFleetDomains: (force?: boolean) => void
755
+ // Website-hosting records (apex/www/additional + TTLs), keyed by hostname.
756
+ // Read cred-free from each zone's authoritative NS; resolved lazily once the
757
+ // zone's NS are known. The migration-focused inventory reads these.
758
+ hostingRecords: Map<string, HostRecord>
759
+ // Resolve every site's hosting hostnames on a server (needs the zone NS first).
760
+ resolveServerHosting: (server: Server, force?: boolean) => void
761
+ hostingFor: (host: string) => HostRecord | undefined
762
+ isHostingResolving: (host: string) => boolean
763
+ // The server whose DNS-inventory overlay is open, or null. Set by site views.
764
+ // `focusSiteId` (optional) scopes the overlay to a single site (opened via `n`);
765
+ // null shows every site on the server (opened via `N`).
766
+ dnsInventoryServer: Server | null
767
+ dnsInventoryFocusSiteId: number | null
768
+ setDnsInventoryServer: (s: Server | null, focusSiteId?: number | null) => void
769
+ setDnsInventoryFocusSiteId: (id: number | null) => void
770
+ // DNS provider connections (Phase 2), keyed by provider — each is a credential
771
+ // ("account"). The overlay lists + manages them. `providerZones` holds each
772
+ // connection's last verified zone set (hydrated from disk). Secrets persist to
773
+ // config (chmod 600).
774
+ connections: Record<ConnProvider, Connection[]>
775
+ connectionsFor: (provider: ConnProvider) => Connection[]
776
+ connectionCount: number // total across all providers
777
+ providerZones: Map<string, VerifiedConn> // by connection id
778
+ // Add a connection: verify FIRST, persist + cache only on success; returns the
779
+ // verify result so the overlay can show zones or the error.
780
+ addConnection: (provider: ConnProvider, label: string, creds: Record<string, string>) => Promise<VerifyResult>
781
+ removeConnection: (id: string) => void // stored connections only (not env)
782
+ verifyConnectionById: (id: string) => void // re-verify + refresh cache
783
+ // Access state for a zone, from its live host, the live authoritative NS, and
784
+ // the verified zones we can reach. Provider-scoped + NS-aware (see store impl).
785
+ accessForZone: (apex: string, hostKey: string, liveNs: string[]) => AccessState
786
+ // The connection (account) label that owns/serves a zone, or "" if none.
787
+ accountForZone: (apex: string, hostKey: string, liveNs: string[]) => string
788
+ // The connection (with creds) that serves a zone, or null — drives record editing.
789
+ connForZone: (apex: string, hostKey: string, liveNs: string[]) => Connection | null
790
+ // The zone whose provider-connect overlay is open (apex + its host key), or null.
791
+ connectZoneTarget: { apex: string; hostKey: string } | null
792
+ setConnectZoneTarget: (t: { apex: string; hostKey: string } | null) => void
793
+ // The zone whose DNS-records overlay is open (Phase 3: edit TTL, or repoint the
794
+ // value), with the connection id we'll authenticate the record calls with.
795
+ // `record` is the single hosting record to edit (migration lens — never a whole
796
+ // zone); `edit` picks which field the editor targets. null = closed.
797
+ dnsRecordsTarget: { apex: string; hostKey: string; connId: string; record: { name: string; type: string }; edit: RecordEditKind } | null
798
+ setDnsRecordsTarget: (t: { apex: string; hostKey: string; connId: string; record: { name: string; type: string }; edit: RecordEditKind } | null) => void
799
+ // In-flight (and just-settled) record changes (TTL + value), keyed by the record
800
+ // key. Tracked in the store so a Route 53 change keeps polling after the overlay closes.
801
+ recordWrites: Map<string, RecordWriteProgress>
802
+ // Read ONE hosting record via the serving connection (scoped; on demand).
803
+ getZoneRecord: (connId: string, apex: string, name: string, type: string) => Promise<RecordResult>
804
+ // The latest write of a kind for a hostname+type (drives the inventory's updating status).
805
+ recordWriteForHost: (host: string, type: string, kind: RecordEditKind) => RecordWriteProgress | undefined
806
+ // Change a record's TTL via the host's API and follow it to completion.
807
+ startTtlChange: (connId: string, zoneId: string, record: DnsRecord, ttl: number) => void
808
+ // Repoint a record's value (where it points) and follow it to completion.
809
+ startValueChange: (connId: string, zoneId: string, record: DnsRecord, value: string) => void
810
+ clearRecordWrite: (key: string) => void
811
+ // Whether a PHP version is past end-of-life (real dates vs today, refreshed).
812
+ isPhpEol: (version: string | null | undefined) => boolean
813
+ // PHP versions to offer in the upgrade picker (dynamic; current always included).
814
+ offeredPhpVersions: (current?: string | null) => string[]
815
+ // Whether a server's Ubuntu release is past end-of-life (real dates, refreshed).
816
+ isServerOsEol: (server: Server) => boolean
817
+ }
818
+
819
+ // Pure: from a connId→verified-zones map, find the editable account that SERVES a
820
+ // zone. Mirrors the cached `matchedAccount` below, but takes the zone map explicitly
821
+ // so a just-re-verified snapshot (read straight off the cache ref) can be matched
822
+ // without waiting for the `providerZones` React state to settle. Provider-scoped:
823
+ // a Route 53 zone is only reachable via a connected AWS account, etc.
824
+ function matchAccountFrom(
825
+ zonesByConn: Map<string, VerifiedConn>,
826
+ allConnections: Connection[],
827
+ apex: string,
828
+ hostKey: string,
829
+ liveNs: string[],
830
+ ): { editable: boolean; label: string; id: string } {
831
+ const prov = apiProviderFor(hostKey)
832
+ if (!prov) return { editable: false, label: "", id: "" }
833
+ const candidates: { id: string; label: string; ns: string[] }[] = []
834
+ for (const conn of allConnections) {
835
+ if (conn.provider !== prov) continue
836
+ const v = zonesByConn.get(conn.id)
837
+ if (!v?.ok) continue
838
+ for (const z of v.zones) if (z.apex === apex) candidates.push({ id: conn.id, label: conn.label || conn.id, ns: z.nameservers ?? [] })
839
+ }
840
+ if (candidates.length === 0) return { editable: false, label: "", id: "" }
841
+ const withNs = candidates.filter((c) => c.ns.length > 0)
842
+ // If we know any candidate's NS, require a live match to count as editable
843
+ // (catches stale / duplicate zones across accounts).
844
+ if (withNs.length > 0 && liveNs.length > 0) {
845
+ const live = withNs.find((c) => nameserversMatch(c.ns, liveNs))
846
+ if (live) return { editable: true, label: live.label, id: live.id }
847
+ // Some candidates have NS but none serve the live zone → only editable if
848
+ // another candidate's NS are unknown (can't disprove it).
849
+ const unknown = candidates.find((c) => c.ns.length === 0)
850
+ return unknown ? { editable: true, label: unknown.label, id: unknown.id } : { editable: false, label: "", id: "" }
851
+ }
852
+ // No NS info to match on → membership (provider-scoped) is the best we have.
853
+ return { editable: true, label: candidates[0]!.label, id: candidates[0]!.id }
854
+ }
855
+
856
+ const StoreContext = createContext<StoreValue | null>(null)
857
+
858
+ export function useStore(): StoreValue {
859
+ const ctx = useContext(StoreContext)
860
+ if (!ctx) throw new Error("useStore must be used within <StoreProvider>")
861
+ return ctx
862
+ }
863
+
864
+ export function StoreProvider({ children }: { children: ReactNode }) {
865
+ const cfgRef = useRef(loadConfig())
866
+ const clientRef = useRef<SpinupWPClientLike | null>(null)
867
+ if (!clientRef.current) {
868
+ clientRef.current = isDevMode() ? createMockClient() : new SpinupWPClient(cfgRef.current)
869
+ }
870
+ const client = clientRef.current
871
+
872
+ const [servers, setServers] = useState<Server[]>([])
873
+ const [sites, setSites] = useState<Site[]>([])
874
+ const [events, setEvents] = useState<Event[]>([])
875
+ const [loading, setLoading] = useState(true)
876
+ const [ready, setReady] = useState(false)
877
+ const [error, setError] = useState<string | null>(null)
878
+ const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
879
+ // Update check: show any cached result immediately, then refresh once per launch
880
+ // (disk-cached, 6h TTL — see appUpdate.ts).
881
+ const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(() => cachedUpdateInfo(APP_VERSION))
882
+ useEffect(() => {
883
+ void refreshUpdateInfo(APP_VERSION).then((info) => {
884
+ if (info) setUpdateInfo(info)
885
+ })
886
+ }, [])
887
+ // Release notes: shown once after an update, sourced from the GitHub release
888
+ // for the exact running version (releaseNotes.ts — reuses the same API
889
+ // appUpdate.ts already polls, no new infrastructure).
890
+ const [releaseNotesInfo, setReleaseNotesInfo] = useState<ReleaseNotesInfo | null>(null)
891
+ useEffect(() => {
892
+ const seen = cfgRef.current.lastSeenVersion
893
+ if (seen === APP_VERSION) return
894
+ if (seen === null) {
895
+ // Fresh install, or upgrading from a pre-this-feature version — nothing
896
+ // meaningful to announce. Seed silently so future bumps trigger correctly.
897
+ cfgRef.current.lastSeenVersion = APP_VERSION
898
+ void saveConfig({ lastSeenVersion: APP_VERSION })
899
+ return
900
+ }
901
+ void fetchReleaseNotes(APP_VERSION).then((notes) => {
902
+ if (notes) setReleaseNotesInfo(notes)
903
+ })
904
+ }, [])
905
+ // Dismissing marks the version seen — only AFTER the user actually saw it
906
+ // (not on fetch success), so a crash/quit before dismissal retries next launch.
907
+ const dismissReleaseNotes = useCallback(() => {
908
+ setReleaseNotesInfo(null)
909
+ cfgRef.current.lastSeenVersion = APP_VERSION
910
+ void saveConfig({ lastSeenVersion: APP_VERSION })
911
+ }, [])
912
+ // On-demand replay (Help overlay's `n`) — bypasses the "seen" gate entirely,
913
+ // since viewing the current version's notes again is always valid regardless
914
+ // of whether they've already been dismissed. Silently no-ops on failure (no
915
+ // release published for this tag yet, offline) — same best-effort philosophy
916
+ // as the rest of appUpdate.ts/releaseNotes.ts.
917
+ const showReleaseNotes = useCallback(() => {
918
+ void fetchReleaseNotes(APP_VERSION).then((notes) => {
919
+ if (notes) setReleaseNotesInfo(notes)
920
+ })
921
+ }, [])
922
+ const [route, setRoute] = useState<Route>("dashboard")
923
+ const [inputMode, setInputMode] = useState(false)
924
+ const [overlayOpen, setOverlayOpen] = useState(false)
925
+ const [healthServer, setHealthServer] = useState<Server | null>(null)
926
+ const [wpInventorySite, setWpInventorySite] = useState<Site | null>(null)
927
+ const [phpUpgradeSite, setPhpUpgradeSite] = useState<Site | null>(null)
928
+ const [phpUpgrades, setPhpUpgrades] = useState<Map<number, PhpUpgradeProgress>>(new Map())
929
+ const [httpsToggleSite, setHttpsToggleSite] = useState<Site | null>(null)
930
+ const [httpsToggles, setHttpsToggles] = useState<Map<number, HttpsToggleProgress>>(new Map())
931
+ const [purgeCacheSite, setPurgeCacheSite] = useState<Site | null>(null)
932
+ const [purgeCacheProgress, setPurgeCacheProgress] = useState<Map<number, PurgeCacheProgress>>(new Map())
933
+ const [serverActionsServer, setServerActionsServer] = useState<Server | null>(null)
934
+ const [serverOps, setServerOps] = useState<Map<number, ServerOpProgress>>(new Map())
935
+ const [newServerOpen, setNewServerOpen] = useState(false)
936
+ const [newServerSource, setNewServerSource] = useState<Server | null>(null)
937
+ const [newServerJob, setNewServerJob] = useState<NewServerJob | null>(null)
938
+ // Vanity-site build. `vanityServer` drives the overlay (the server we're connecting);
939
+ // `vanityJob` is the resumable progress that outlives the overlay.
940
+ const [vanityServer, setVanityServer] = useState<Server | null>(null)
941
+ const [vanityJob, setVanityJob] = useState<VanityJob | null>(null)
942
+ // Clone-to-new-server: `cloneServer` drives the wizard overlay; `cloneJob` is the
943
+ // Plan draft (and, in later slices, the running fan-out).
944
+ const [cloneServer, setCloneServer] = useState<Server | null>(null)
945
+ const [cloneJob, setCloneJob] = useState<CloneJob | null>(null)
946
+ // Per-job clone log (survives backgrounding/retries; a new job gets a new file).
947
+ const cloneLogRef = useRef<CloneLogger | null>(null)
948
+ const [providerMetadata, setProviderMetadata] = useState<Map<string, ProviderMetadata>>(new Map())
949
+ const [providerMetadataLoading, setProviderMetadataLoading] = useState<Set<string>>(new Set())
950
+ const [providerMetadataError, setProviderMetadataError] = useState<Map<string, string>>(new Map())
951
+ const [serverProviders, setServerProviders] = useState<Record<string, ServerProviderRef>>(() => cfgRef.current.serverProviders)
952
+ const [localLinkSite, setLocalLinkSite] = useState<Site | null>(null)
953
+ // Privileged SSH-key grant. The overlay target + per-site progress; the sudo
954
+ // user is persisted (state, hydrated from config) while the sudo password is
955
+ // kept in a ref (in-memory only — never rendered, never written to disk).
956
+ const [grantKeySite, setGrantKeySite] = useState<Site | null>(null)
957
+ const [keyGrants, setKeyGrants] = useState<Map<number, KeyGrantProgress>>(new Map())
958
+ const [sudoUsers, setSudoUsers] = useState<Map<number, string>>(
959
+ () => new Map(Object.entries(cfgRef.current.sudoUsers).map(([id, v]) => [Number(id), v.user])),
960
+ )
961
+ const sudoPwRef = useRef<Map<number, string>>(new Map())
962
+ // Servers whose sudo password is saved in the macOS Keychain (opt-in). Hydrated
963
+ // from the config flag; the password itself is never in config — only this marker.
964
+ const [sudoSaved, setSudoSaved] = useState<Set<number>>(
965
+ () => new Set(Object.entries(cfgRef.current.sudoUsers).filter(([, v]) => v.keychain).map(([id]) => Number(id))),
966
+ )
967
+ const [preferredGrantKeys, setPreferredGrantKeysState] = useState<string[]>(() => [...cfgRef.current.preferredGrantKeys])
968
+ // Keys Spinup has granted, by site id → set of key bodies. Drives the row badge.
969
+ const [grantedKeys, setGrantedKeys] = useState<Map<number, Set<string>>>(
970
+ () => new Map(Object.entries(cfgRef.current.grantedKeys).map(([id, bodies]) => [Number(id), new Set(bodies)])),
971
+ )
972
+ // Per-zone access-note overrides (apex → text), e.g. "Integracon" for a
973
+ // GoDaddy zone a third party manages. Empty for a zone means "use the
974
+ // provider's defaultAccessNote" (e.g. "Delegate Access") — see providers.ts.
975
+ const [zoneAccessNotes, setZoneAccessNotes] = useState<Map<string, string>>(
976
+ () => new Map(Object.entries(cfgRef.current.zoneAccessNotes)),
977
+ )
978
+ // The spinup-tui machine key's body (base64), resolved once — lets us classify a
979
+ // granted key as the MACHINE key vs one of the user's PERSONAL keys.
980
+ const [machineKeyBody, setMachineKeyBody] = useState<string | null>(null)
981
+ useEffect(() => {
982
+ void ensureSpinupKey()
983
+ .then((k) => setMachineKeyBody(keyBody(k.pub)))
984
+ .catch(() => {})
985
+ }, [])
986
+ // Servers with sudo "connected" this session (validated user + held password). The
987
+ // password lives in sudoPwRef (in-memory); this set just tracks which servers
988
+ // are connected so the UI can show ● and privileged actions can gate on it.
989
+ const [sudoConnected, setSudoConnected] = useState<Set<number>>(new Set())
990
+ const [sudoConnectServer, setSudoConnectServer] = useState<Server | null>(null)
991
+ const [dbBackupSite, setDbBackupSite] = useState<Site | null>(null)
992
+ const [dbBackups, setDbBackups] = useState<Map<number, DbBackupProgress>>(new Map())
993
+ const [dbSyncSite, setDbSyncSite] = useState<Site | null>(null)
994
+ const [dbSyncs, setDbSyncs] = useState<Map<number, DbSyncProgress>>(new Map())
995
+ const [mediaFallbackSite, setMediaFallbackSite] = useState<Site | null>(null)
996
+ const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
997
+ const [discoverOpen, setDiscoverOpen] = useState(false)
998
+ const [forgottenOpen, setForgottenOpen] = useState(false)
999
+ const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
1000
+ const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
1001
+ const [drift, setDrift] = useState<Map<number, Drift | null>>(new Map())
1002
+ // Tracks which site ids have had a drift check requested (so we compute once
1003
+ // per site per session); a ref keeps ensureDrift stable across renders.
1004
+ const driftRequested = useRef<Set<number>>(new Set())
1005
+ // Hydrate local links from the stored config (JSON keys are strings → number).
1006
+ const [localLinks, setLocalLinks] = useState<Map<number, LocalLink>>(
1007
+ () => new Map(Object.entries(cfgRef.current.localSites).map(([id, link]) => [Number(id), normalizeLink(link)])),
1008
+ )
1009
+ const [rebootInfo, setRebootInfo] = useState<Map<number, RebootInfo>>(new Map())
1010
+ const [rebootInfoLoading, setRebootInfoLoading] = useState<Set<number>>(new Set())
1011
+ const [rebootInfoErrors, setRebootInfoErrors] = useState<Map<number, string>>(new Map())
1012
+
1013
+ // Tier-2 stack-probe cache: hydrate from disk once (read-only at startup; no
1014
+ // SSH). Probes run lazily on demand and write through to disk.
1015
+ const cacheRef = useRef<StackCache | null>(null)
1016
+ if (!cacheRef.current) {
1017
+ cacheRef.current = new StackCache()
1018
+ cacheRef.current.load()
1019
+ }
1020
+ const [probes, setProbes] = useState<Map<number, CachedProbe>>(() => cacheRef.current!.snapshot())
1021
+ const [probingIds, setProbingIds] = useState<Set<number>>(new Set())
1022
+ const [probeErrors, setProbeErrors] = useState<Map<number, string>>(new Map())
1023
+
1024
+ // DNS zone-host cache: hydrate from disk once (no network at startup); lookups
1025
+ // run lazily on demand and write through to disk. `dnsInFlight` is a ref so the
1026
+ // lookup actions can dedupe concurrent requests without re-rendering.
1027
+ const dnsCacheRef = useRef<DnsCache | null>(null)
1028
+ if (!dnsCacheRef.current) {
1029
+ dnsCacheRef.current = new DnsCache()
1030
+ dnsCacheRef.current.load()
1031
+ }
1032
+ const dnsInFlight = useRef<Set<string>>(new Set())
1033
+ const [dnsZones, setDnsZones] = useState<Map<string, CachedDns>>(() => dnsCacheRef.current!.snapshot())
1034
+ const [dnsResolving, setDnsResolving] = useState<Set<string>>(new Set())
1035
+ const [dnsInventoryServer, setDnsInventoryServerState] = useState<Server | null>(null)
1036
+ const [dnsInventoryFocusSiteId, setDnsInventoryFocusSiteId] = useState<number | null>(null)
1037
+ const setDnsInventoryServer = useCallback((s: Server | null, focusSiteId: number | null = null) => {
1038
+ setDnsInventoryServerState(s)
1039
+ setDnsInventoryFocusSiteId(focusSiteId)
1040
+ }, [])
1041
+
1042
+ // Website-hosting record lookups (Phase 3 inventory). In-memory for the session;
1043
+ // resolved on demand once the hostname's zone NS are known. `hostingInFlight`
1044
+ // dedupes concurrent queries without re-rendering.
1045
+ const hostingInFlight = useRef<Set<string>>(new Set())
1046
+ const [hostingRecords, setHostingRecords] = useState<Map<string, HostRecord>>(new Map())
1047
+ const [hostingResolving, setHostingResolving] = useState<Set<string>>(new Set())
1048
+
1049
+ // DNS provider connections (Phase 2). Connections hydrate from config (stored +
1050
+ // env); their verified zone sets hydrate from disk and re-verify on demand.
1051
+ const providersCacheRef = useRef<ProvidersCache | null>(null)
1052
+ if (!providersCacheRef.current) {
1053
+ providersCacheRef.current = new ProvidersCache()
1054
+ providersCacheRef.current.load()
1055
+ }
1056
+ const [connections, setConnections] = useState<Record<ConnProvider, Connection[]>>(() => cfgRef.current.providerConnections)
1057
+ const [providerZones, setProviderZones] = useState<Map<string, VerifiedConn>>(() => providersCacheRef.current!.snapshot())
1058
+ const [connectZoneTarget, setConnectZoneTarget] = useState<{ apex: string; hostKey: string } | null>(null)
1059
+ const [dnsRecordsTarget, setDnsRecordsTarget] = useState<{ apex: string; hostKey: string; connId: string; record: { name: string; type: string }; edit: RecordEditKind } | null>(null)
1060
+ const [recordWrites, setRecordWrites] = useState<Map<string, RecordWriteProgress>>(new Map())
1061
+
1062
+ // PHP EOL dates: embedded defaults overlaid with the last cached fetch; a
1063
+ // background refresh (endoflife.date) updates them when the cache is stale.
1064
+ const [phpEolDates, setPhpEolDates] = useState<PhpEolDates>(() => resolvePhpEolDates())
1065
+ useEffect(() => {
1066
+ void refreshPhpEolDates().then((updated) => {
1067
+ if (updated) setPhpEolDates(updated)
1068
+ })
1069
+ }, [])
1070
+
1071
+ // Ubuntu EOL dates: same embedded+cached-refresh approach as phpEolDates above.
1072
+ const [ubuntuEolDates, setUbuntuEolDates] = useState<UbuntuEolDates>(() => resolveUbuntuEolDates())
1073
+ useEffect(() => {
1074
+ void refreshUbuntuEolDates().then((updated) => {
1075
+ if (updated) setUbuntuEolDates(updated)
1076
+ })
1077
+ }, [])
1078
+
1079
+ const refresh = useCallback(async () => {
1080
+ setLoading(true)
1081
+ setError(null)
1082
+ // Drift can go stale once the user commits/pushes in their terminal — clear
1083
+ // the cache on refresh so it recomputes next time a linked site is shown.
1084
+ driftRequested.current.clear()
1085
+ setDrift(new Map())
1086
+ try {
1087
+ // Servers + sites first (the core data); events are best-effort.
1088
+ const [srv, ste] = await Promise.all([client.listServers(), client.listSites()])
1089
+ setServers(srv)
1090
+ setSites(ste)
1091
+ setLastUpdated(new Date())
1092
+ setReady(true)
1093
+ try {
1094
+ setEvents(await client.listEvents(2))
1095
+ } catch {
1096
+ // Events are non-critical; ignore failures so the app still loads.
1097
+ }
1098
+ } catch (err) {
1099
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1100
+ setError(msg)
1101
+ setReady(true) // allow the app to render the error state rather than hang on splash
1102
+ } finally {
1103
+ setLoading(false)
1104
+ }
1105
+ }, [client])
1106
+
1107
+ useEffect(() => {
1108
+ void refresh()
1109
+ }, [refresh])
1110
+
1111
+ const sitesForServer = useCallback(
1112
+ (serverId: number) => sites.filter((s) => s.server_id === serverId),
1113
+ [sites],
1114
+ )
1115
+ const serverById = useCallback(
1116
+ (id: number | null | undefined) => (id == null ? undefined : servers.find((s) => s.id === id)),
1117
+ [servers],
1118
+ )
1119
+
1120
+ // Probe one site and reconcile state + cache. Concurrency-safe (all state
1121
+ // updates are functional), so the batch runner can pool several at once.
1122
+ const probeOne = useCallback(
1123
+ async (site: Site) => {
1124
+ const cache = cacheRef.current!
1125
+ setProbingIds((prev) => new Set(prev).add(site.id))
1126
+ setProbeErrors((prev) => {
1127
+ if (!prev.has(site.id)) return prev
1128
+ const next = new Map(prev)
1129
+ next.delete(site.id)
1130
+ return next
1131
+ })
1132
+ const server = servers.find((s) => s.id === site.server_id)
1133
+ const outcome = await probeSite(site, server, cfgRef.current.sshUser)
1134
+ if (outcome.ok) {
1135
+ await cache.set(site.id, outcome.result, siteSignature(site))
1136
+ setProbes(cache.snapshot())
1137
+ } else {
1138
+ setProbeErrors((prev) => new Map(prev).set(site.id, outcome.error))
1139
+ }
1140
+ setProbingIds((prev) => {
1141
+ const next = new Set(prev)
1142
+ next.delete(site.id)
1143
+ return next
1144
+ })
1145
+ },
1146
+ [servers],
1147
+ )
1148
+
1149
+ const runProbe = useCallback(
1150
+ (site: Site) => {
1151
+ if (probingIds.has(site.id)) return // already in flight
1152
+ void probeOne(site)
1153
+ },
1154
+ [probeOne, probingIds],
1155
+ )
1156
+
1157
+ // Probe many sites with a bounded SSH concurrency pool. Skips sites already
1158
+ // in flight; callers decide whether to pass un-probed/stale sites only.
1159
+ const runProbeMany = useCallback(
1160
+ (sitesToProbe: Site[]) => {
1161
+ const queue = sitesToProbe.filter((s) => !probingIds.has(s.id))
1162
+ if (queue.length === 0) return
1163
+ const CONCURRENCY = 5
1164
+ let cursor = 0
1165
+ const worker = async () => {
1166
+ while (cursor < queue.length) {
1167
+ const site = queue[cursor++]
1168
+ await probeOne(site)
1169
+ }
1170
+ }
1171
+ for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) void worker()
1172
+ },
1173
+ [probeOne, probingIds],
1174
+ )
1175
+
1176
+ const isProbeStale = useCallback((site: Site) => cacheRef.current!.isStale(site.id, siteSignature(site)), [])
1177
+
1178
+ const isPhpEol = useCallback((version: string | null | undefined) => isPhpEolWith(version, phpEolDates), [phpEolDates])
1179
+ const offeredPhpVersions = useCallback(
1180
+ (current?: string | null) => offeredPhpVersionsWith(phpEolDates, current),
1181
+ [phpEolDates],
1182
+ )
1183
+ const isServerOsEol = useCallback((server: Server) => isUbuntuEolWith(server.ubuntu_version, ubuntuEolDates), [ubuntuEolDates])
1184
+
1185
+ const setUpgrade = (siteId: number, progress: PhpUpgradeProgress) =>
1186
+ setPhpUpgrades((prev) => new Map(prev).set(siteId, progress))
1187
+
1188
+ const clearPhpUpgrade = useCallback(
1189
+ (siteId: number) => {
1190
+ void removeJob(phpJobId(siteId))
1191
+ setPhpUpgrades((prev) => {
1192
+ if (!prev.has(siteId)) return prev
1193
+ const next = new Map(prev)
1194
+ next.delete(siteId)
1195
+ return next
1196
+ })
1197
+ },
1198
+ [],
1199
+ )
1200
+
1201
+ // Poll a PHP-upgrade event to completion, mirroring status into the per-site
1202
+ // marker and clearing the persisted job once settled. Shared by a fresh upgrade
1203
+ // and resume-on-startup (event-backed, so it reconnects cleanly).
1204
+ const trackPhpUpgradeEvent = useCallback(
1205
+ (siteId: number, version: string, eventId: number, domain: string) => {
1206
+ const poll = async () => {
1207
+ try {
1208
+ const ev = await client.getEvent(eventId)
1209
+ if (ev.status === UPGRADE_DONE) {
1210
+ await refresh() // pull the new php_version into the store…
1211
+ clearPhpUpgrade(siteId) // …then the row reflects truth, no marker needed
1212
+ // Non-focus-stealing nudge — the upgrade often finishes after the user
1213
+ // has closed the overlay and moved on (it tracks in the background).
1214
+ toast.success(`${domain} upgraded to PHP ${version}`)
1215
+ } else if (ev.status === UPGRADE_FAIL) {
1216
+ setUpgrade(siteId, { target: version, status: UPGRADE_FAIL, error: ev.output?.trim() || "The upgrade event failed on SpinupWP." })
1217
+ void removeJob(phpJobId(siteId))
1218
+ } else {
1219
+ setUpgrade(siteId, { target: version, status: ev.status })
1220
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1221
+ }
1222
+ } catch (err) {
1223
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1224
+ setUpgrade(siteId, { target: version, status: UPGRADE_FAIL, error: msg })
1225
+ void removeJob(phpJobId(siteId))
1226
+ }
1227
+ }
1228
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1229
+ },
1230
+ [client, refresh, clearPhpUpgrade],
1231
+ )
1232
+
1233
+ const startPhpUpgrade = useCallback(
1234
+ (site: Site, version: string) => {
1235
+ // Ignore a duplicate request while one is already running for this site.
1236
+ const existing = phpUpgrades.get(site.id)
1237
+ if (existing && existing.status !== UPGRADE_DONE && existing.status !== UPGRADE_FAIL) return
1238
+
1239
+ const startedAt = Date.now()
1240
+ const run = async () => {
1241
+ setUpgrade(site.id, { target: version, status: "queued" })
1242
+ let eventId: number
1243
+ try {
1244
+ const res = await client.upgradeSitePhp(site.id, version)
1245
+ eventId = res.event_id
1246
+ } catch (err) {
1247
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1248
+ setUpgrade(site.id, { target: version, status: UPGRADE_FAIL, error: msg })
1249
+ return
1250
+ }
1251
+ // Persist so a restart resumes tracking this site's upgrade, then poll.
1252
+ setUpgrade(site.id, { target: version, status: "running" })
1253
+ void saveJob({ id: phpJobId(site.id), kind: "phpUpgrade", status: "running", startedAt, eventId, inputs: { siteId: site.id, version, domain: site.domain } })
1254
+ trackPhpUpgradeEvent(site.id, version, eventId, site.domain)
1255
+ }
1256
+ void run()
1257
+ },
1258
+ [client, trackPhpUpgradeEvent, phpUpgrades],
1259
+ )
1260
+
1261
+ // ---- HTTPS enable/disable -----------------------------------------------
1262
+
1263
+ const setHttpsProgress = (siteId: number, progress: HttpsToggleProgress) =>
1264
+ setHttpsToggles((prev) => new Map(prev).set(siteId, progress))
1265
+
1266
+ const clearHttpsToggle = useCallback((siteId: number) => {
1267
+ void removeJob(httpsJobId(siteId))
1268
+ setHttpsToggles((prev) => {
1269
+ if (!prev.has(siteId)) return prev
1270
+ const next = new Map(prev)
1271
+ next.delete(siteId)
1272
+ return next
1273
+ })
1274
+ }, [])
1275
+
1276
+ // Poll an HTTPS-toggle event to completion, mirroring status into the
1277
+ // per-site marker. Shared by a fresh toggle and resume-on-startup.
1278
+ const trackHttpsToggleEvent = useCallback(
1279
+ (siteId: number, action: "enable" | "disable", eventId: number, domain: string) => {
1280
+ const poll = async () => {
1281
+ try {
1282
+ const ev = await client.getEvent(eventId)
1283
+ if (ev.status === UPGRADE_DONE) {
1284
+ await refresh()
1285
+ clearHttpsToggle(siteId)
1286
+ toast.success(`HTTPS ${action === "enable" ? "enabled" : "disabled"} on ${domain}`)
1287
+ } else if (ev.status === UPGRADE_FAIL) {
1288
+ setHttpsProgress(siteId, { action, status: UPGRADE_FAIL, error: ev.output?.trim() || "The event failed on SpinupWP." })
1289
+ void removeJob(httpsJobId(siteId))
1290
+ } else {
1291
+ setHttpsProgress(siteId, { action, status: ev.status })
1292
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1293
+ }
1294
+ } catch (err) {
1295
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1296
+ setHttpsProgress(siteId, { action, status: UPGRADE_FAIL, error: msg })
1297
+ void removeJob(httpsJobId(siteId))
1298
+ }
1299
+ }
1300
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1301
+ },
1302
+ [client, refresh, clearHttpsToggle],
1303
+ )
1304
+
1305
+ const startHttpsToggle = useCallback(
1306
+ (site: Site) => {
1307
+ // Ignore a duplicate request while one is already running for this site.
1308
+ const existing = httpsToggles.get(site.id)
1309
+ if (existing && existing.status !== UPGRADE_DONE && existing.status !== UPGRADE_FAIL) return
1310
+
1311
+ // The direction is fully determined by current state — nothing to pick.
1312
+ const action: "enable" | "disable" = site.https?.enabled ? "disable" : "enable"
1313
+ const startedAt = Date.now()
1314
+ const run = async () => {
1315
+ setHttpsProgress(site.id, { action, status: "queued" })
1316
+ let res: { event_id: number } | undefined
1317
+ try {
1318
+ res = action === "enable" ? await client.enableHttps(site.id) : await client.disableHttps(site.id)
1319
+ } catch (err) {
1320
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1321
+ setHttpsProgress(site.id, { action, status: UPGRADE_FAIL, error: msg })
1322
+ return
1323
+ }
1324
+ if (!res?.event_id) {
1325
+ // Settled synchronously (e.g. a 204 on disable) — nothing to poll.
1326
+ await refresh()
1327
+ clearHttpsToggle(site.id)
1328
+ toast.success(`HTTPS ${action === "enable" ? "enabled" : "disabled"} on ${site.domain}`)
1329
+ return
1330
+ }
1331
+ // Persist so a restart resumes tracking this site's toggle, then poll.
1332
+ setHttpsProgress(site.id, { action, status: "running" })
1333
+ void saveJob({
1334
+ id: httpsJobId(site.id),
1335
+ kind: "httpsToggle",
1336
+ status: "running",
1337
+ startedAt,
1338
+ eventId: res.event_id,
1339
+ inputs: { siteId: site.id, action, domain: site.domain },
1340
+ })
1341
+ trackHttpsToggleEvent(site.id, action, res.event_id, site.domain)
1342
+ }
1343
+ void run()
1344
+ },
1345
+ [client, trackHttpsToggleEvent, httpsToggles, refresh, clearHttpsToggle],
1346
+ )
1347
+
1348
+ // ---- Purge cache (page + object) ---------------------------------------
1349
+
1350
+ // Merge one sub-purge's status into the site's combined progress, returning
1351
+ // the merged result synchronously so callers (trackPurgeEvent) can check
1352
+ // whether BOTH sub-purges have now settled without a second render round-trip.
1353
+ const setPurgeSub = useCallback((siteId: number, kind: "page" | "object", sub: PurgeSub): PurgeCacheProgress => {
1354
+ let result!: PurgeCacheProgress
1355
+ setPurgeCacheProgress((prev) => {
1356
+ const next = new Map(prev)
1357
+ const cur = next.get(siteId) ?? { page: { status: "queued" }, object: { status: "queued" } }
1358
+ const merged = { ...cur, [kind]: sub }
1359
+ next.set(siteId, merged)
1360
+ result = merged
1361
+ return next
1362
+ })
1363
+ return result
1364
+ }, [])
1365
+
1366
+ const clearPurgeCache = useCallback((siteId: number) => {
1367
+ setPurgeCacheProgress((prev) => {
1368
+ if (!prev.has(siteId)) return prev
1369
+ const next = new Map(prev)
1370
+ next.delete(siteId)
1371
+ return next
1372
+ })
1373
+ }, [])
1374
+
1375
+ // Poll one sub-purge's event to completion. Fires the completion toast only
1376
+ // once BOTH sub-purges have settled successfully — one summary, not two.
1377
+ const trackPurgeEvent = useCallback(
1378
+ (siteId: number, kind: "page" | "object", eventId: number, domain: string) => {
1379
+ const poll = async () => {
1380
+ try {
1381
+ const ev = await client.getEvent(eventId)
1382
+ if (ev.status === UPGRADE_DONE) {
1383
+ const merged = setPurgeSub(siteId, kind, { status: UPGRADE_DONE })
1384
+ if (!isPurgeCacheInFlight(merged) && !purgeCacheFailed(merged)) {
1385
+ toast.success(`Cache purged for ${domain}`)
1386
+ }
1387
+ } else if (ev.status === UPGRADE_FAIL) {
1388
+ setPurgeSub(siteId, kind, { status: UPGRADE_FAIL, error: ev.output?.trim() || "The event failed on SpinupWP." })
1389
+ } else {
1390
+ setPurgeSub(siteId, kind, { status: ev.status })
1391
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1392
+ }
1393
+ } catch (err) {
1394
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1395
+ setPurgeSub(siteId, kind, { status: UPGRADE_FAIL, error: msg })
1396
+ }
1397
+ }
1398
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1399
+ },
1400
+ [client, setPurgeSub],
1401
+ )
1402
+
1403
+ // Fires both page-cache and object-cache purges independently under one
1404
+ // confirm — no job-persistence (unlike PHP upgrade/HTTPS toggle): a purge
1405
+ // settles in seconds and is trivially safe to just re-fire if interrupted.
1406
+ const startPurgeCache = useCallback(
1407
+ (site: Site) => {
1408
+ const existing = purgeCacheProgress.get(site.id)
1409
+ if (existing && isPurgeCacheInFlight(existing)) return
1410
+
1411
+ const runOne = async (kind: "page" | "object") => {
1412
+ setPurgeSub(site.id, kind, { status: "queued" })
1413
+ let eventId: number
1414
+ try {
1415
+ const res = kind === "page" ? await client.purgePageCache(site.id) : await client.purgeObjectCache(site.id)
1416
+ eventId = res.event_id
1417
+ } catch (err) {
1418
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1419
+ setPurgeSub(site.id, kind, { status: UPGRADE_FAIL, error: msg })
1420
+ return
1421
+ }
1422
+ setPurgeSub(site.id, kind, { status: "running" })
1423
+ trackPurgeEvent(site.id, kind, eventId, site.domain)
1424
+ }
1425
+ void runOne("page")
1426
+ void runOne("object")
1427
+ },
1428
+ [client, trackPurgeEvent, purgeCacheProgress, setPurgeSub],
1429
+ )
1430
+
1431
+ // ---- Server operations (reboot / service restart) ---------------------
1432
+
1433
+ const setOp = (serverId: number, progress: ServerOpProgress) =>
1434
+ setServerOps((prev) => new Map(prev).set(serverId, progress))
1435
+
1436
+ const clearServerOp = useCallback(
1437
+ (serverId: number) =>
1438
+ setServerOps((prev) => {
1439
+ if (!prev.has(serverId)) return prev
1440
+ const next = new Map(prev)
1441
+ next.delete(serverId)
1442
+ return next
1443
+ }),
1444
+ [],
1445
+ )
1446
+
1447
+ const startServerOp = useCallback(
1448
+ (server: Server, kind: ServerOpKind, label: string) => {
1449
+ const existing = serverOps.get(server.id)
1450
+ if (existing && existing.status !== UPGRADE_DONE && existing.status !== UPGRADE_FAIL) return
1451
+
1452
+ const run = async () => {
1453
+ setOp(server.id, { kind, label, status: "queued" })
1454
+
1455
+ // Reboot only: shield this server's Kuma monitors behind a manual
1456
+ // maintenance window so the planned outage never pages. Best-effort in
1457
+ // both directions — Kuma being unreachable must never block a reboot.
1458
+ let kumaMaintId: number | null = null
1459
+ const kumaConn = isDevMode() ? null : cfgRef.current.uptimeKuma
1460
+ if (kind === "reboot" && kumaConn) {
1461
+ const domains = new Set(sitesForServer(server.id).map((s) => s.domain))
1462
+ const monitorIds: number[] = []
1463
+ for (const [domain, ref] of Object.entries(cfgRef.current.kumaMonitors)) {
1464
+ if (!domains.has(domain)) continue
1465
+ if (ref.healthId) monitorIds.push(ref.healthId)
1466
+ if (ref.pushId) monitorIds.push(ref.pushId)
1467
+ }
1468
+ if (monitorIds.length) {
1469
+ try {
1470
+ const { result } = await withKuma(kumaConn, async (kuma) => {
1471
+ const id = await kuma.addManualMaintenance(`Reboot ${server.name}`, "Planned reboot fired from SpinupTUI")
1472
+ try {
1473
+ await kuma.setMaintenanceMonitors(id, monitorIds)
1474
+ } catch (err) {
1475
+ // Half-created window shields nothing — remove it rather than
1476
+ // orphaning an active manual window in Kuma.
1477
+ await kuma.deleteMaintenance(id).catch(() => {})
1478
+ throw err
1479
+ }
1480
+ return id
1481
+ })
1482
+ kumaMaintId = result
1483
+ } catch {
1484
+ // No window — worst case is one expected down alert.
1485
+ }
1486
+ }
1487
+ }
1488
+ const endKumaMaintenance = () => {
1489
+ if (kumaMaintId == null || !kumaConn) return
1490
+ const id = kumaMaintId
1491
+ kumaMaintId = null
1492
+ withKuma(kumaConn, (kuma) => kuma.deleteMaintenance(id)).catch(() => {
1493
+ // Stale window left behind in Kuma — visible + deletable there.
1494
+ })
1495
+ }
1496
+
1497
+ let eventId: number
1498
+ try {
1499
+ const res = kind === "reboot" ? await client.rebootServer(server.id) : await client.restartService(server.id, kind)
1500
+ eventId = res.event_id
1501
+ } catch (err) {
1502
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1503
+ setOp(server.id, { kind, label, status: UPGRADE_FAIL, error: msg })
1504
+ endKumaMaintenance()
1505
+ return
1506
+ }
1507
+
1508
+ // A reboot leaves the API briefly flaky (and 429s happen) — one transient
1509
+ // getEvent failure must not mark the op failed and tear the maintenance
1510
+ // window down mid-reboot. Only consecutive failures settle it.
1511
+ let pollErrors = 0
1512
+ const poll = async () => {
1513
+ try {
1514
+ const ev = await client.getEvent(eventId)
1515
+ pollErrors = 0
1516
+ if (ev.status === UPGRADE_DONE) {
1517
+ endKumaMaintenance()
1518
+ await refresh() // reboot clears reboot_required; status may flip too
1519
+ clearServerOp(server.id)
1520
+ // Background-completion nudge — a reboot can take minutes and the user
1521
+ // has usually closed the overlay (it tracks in the background).
1522
+ toast.success(kind === "reboot" ? `${server.name} rebooted` : `${SERVICE_NAMES[kind] ?? kind} restarted on ${server.name}`)
1523
+ } else if (ev.status === UPGRADE_FAIL) {
1524
+ endKumaMaintenance()
1525
+ setOp(server.id, { kind, label, status: UPGRADE_FAIL, error: ev.output?.trim() || "The operation failed on SpinupWP." })
1526
+ } else {
1527
+ setOp(server.id, { kind, label, status: ev.status })
1528
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1529
+ }
1530
+ } catch (err) {
1531
+ pollErrors += 1
1532
+ if (pollErrors < 3) {
1533
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1534
+ return
1535
+ }
1536
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1537
+ setOp(server.id, { kind, label, status: UPGRADE_FAIL, error: msg })
1538
+ endKumaMaintenance()
1539
+ }
1540
+ }
1541
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1542
+ }
1543
+ void run()
1544
+ },
1545
+ [client, refresh, clearServerOp, serverOps, sitesForServer],
1546
+ )
1547
+
1548
+ // ---- Create a server (POST /servers) ----------------------------------
1549
+
1550
+ // Fetch a provider's size/region catalog once per session (lazy, cached).
1551
+ const loadProviderMetadata = useCallback(
1552
+ (providerKey: string) => {
1553
+ // Skip if already cached or a fetch is in flight for this provider.
1554
+ if (providerMetadata.has(providerKey)) return
1555
+ if (providerMetadataLoading.has(providerKey)) return
1556
+ setProviderMetadataLoading((prev) => new Set(prev).add(providerKey))
1557
+ setProviderMetadataError((prev) => {
1558
+ if (!prev.has(providerKey)) return prev
1559
+ const next = new Map(prev)
1560
+ next.delete(providerKey)
1561
+ return next
1562
+ })
1563
+ void (async () => {
1564
+ try {
1565
+ const md = await client.providerMetadata(providerKey)
1566
+ setProviderMetadata((prev) => new Map(prev).set(providerKey, md))
1567
+ } catch (err) {
1568
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1569
+ setProviderMetadataError((prev) => new Map(prev).set(providerKey, msg))
1570
+ } finally {
1571
+ setProviderMetadataLoading((prev) => {
1572
+ const next = new Set(prev)
1573
+ next.delete(providerKey)
1574
+ return next
1575
+ })
1576
+ }
1577
+ })()
1578
+ },
1579
+ [client, providerMetadata, providerMetadataLoading],
1580
+ )
1581
+
1582
+ const clearNewServer = useCallback(() => {
1583
+ setNewServerJob(null)
1584
+ void removeJob(NEW_SERVER_JOB_ID)
1585
+ }, [])
1586
+
1587
+ // Persist a SpinupWP server-provider id (the API can't list these, so the user
1588
+ // supplies it once; saved to config.json like accountSlug). cfgRef is the source
1589
+ // of truth so this never closes over a stale map.
1590
+ const saveServerProviderId = useCallback((providerKey: string, id: number) => {
1591
+ const prev = cfgRef.current.serverProviders
1592
+ const next = { ...prev, [providerKey]: { ...(prev[providerKey] ?? {}), id } }
1593
+ cfgRef.current.serverProviders = next
1594
+ setServerProviders(next)
1595
+ void saveConfig({ serverProviders: next })
1596
+ }, [])
1597
+
1598
+ // Poll a server-create event to completion, mirroring its status into the job
1599
+ // and clearing the persisted entry once it settles. Shared by a fresh create
1600
+ // and by resume-on-startup, so the job survives a quit/relaunch.
1601
+ const trackServerEvent = useCallback(
1602
+ (eventId: number, hostname: string, startedAt: number) => {
1603
+ const poll = async () => {
1604
+ try {
1605
+ const ev = await client.getEvent(eventId)
1606
+ if (SERVER_FAIL.has(ev.status)) {
1607
+ setNewServerJob({ hostname, status: "failed", error: ev.output?.trim() || "The server build failed on SpinupWP.", startedAt, eventId })
1608
+ void removeJob(NEW_SERVER_JOB_ID)
1609
+ } else if (SERVER_DONE.has(ev.status) || ev.finished_at) {
1610
+ await refresh() // pull the new server into the list
1611
+ setNewServerJob({ hostname, status: "done", serverId: ev.server_id ?? undefined, startedAt, eventId })
1612
+ void removeJob(NEW_SERVER_JOB_ID)
1613
+ } else {
1614
+ setNewServerJob({ hostname, status: ev.status, startedAt, eventId })
1615
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1616
+ }
1617
+ } catch (err) {
1618
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1619
+ setNewServerJob({ hostname, status: "failed", error: msg, startedAt, eventId })
1620
+ void removeJob(NEW_SERVER_JOB_ID)
1621
+ }
1622
+ }
1623
+ setTimeout(() => void poll(), UPGRADE_POLL_MS)
1624
+ },
1625
+ [client, refresh],
1626
+ )
1627
+
1628
+ const startNewServer = useCallback(
1629
+ (payload: CreateServerPayload, hostname: string) => {
1630
+ // One create at a time; ignore a duplicate fire while one is in flight.
1631
+ if (newServerJob && newServerJob.status !== "done" && newServerJob.status !== "failed") return
1632
+
1633
+ const startedAt = Date.now()
1634
+ const run = async () => {
1635
+ setNewServerJob({ hostname, status: "queued", startedAt })
1636
+ let eventId: number
1637
+ try {
1638
+ const res = await client.createServer(payload)
1639
+ eventId = res.event_id
1640
+ } catch (err) {
1641
+ const msg = err instanceof ApiError ? err.message : (err as Error).message
1642
+ setNewServerJob({ hostname, status: "failed", error: msg, startedAt })
1643
+ return
1644
+ }
1645
+ // Event id known → persist so a restart can resume tracking, then poll.
1646
+ setNewServerJob({ hostname, status: "running", startedAt, eventId })
1647
+ void saveJob({ id: NEW_SERVER_JOB_ID, kind: "newServer", status: "running", startedAt, eventId, inputs: { hostname } })
1648
+ trackServerEvent(eventId, hostname, startedAt)
1649
+ }
1650
+ void run()
1651
+ },
1652
+ [client, newServerJob, trackServerEvent],
1653
+ )
1654
+
1655
+ // SSH-probe a server's Ubuntu reboot-required detail (the "why"). On-demand,
1656
+ // cached in memory for the session (read-only; reuses the health SSH path).
1657
+ const loadRebootInfo = useCallback(
1658
+ (server: Server) => {
1659
+ if (rebootInfoLoading.has(server.id)) return
1660
+ const run = async () => {
1661
+ setRebootInfoLoading((prev) => new Set(prev).add(server.id))
1662
+ setRebootInfoErrors((prev) => {
1663
+ if (!prev.has(server.id)) return prev
1664
+ const next = new Map(prev)
1665
+ next.delete(server.id)
1666
+ return next
1667
+ })
1668
+ const res = await fetchRebootInfo(server, sites, cfgRef.current.sshUser)
1669
+ if (res.ok) setRebootInfo((prev) => new Map(prev).set(server.id, res.info))
1670
+ else setRebootInfoErrors((prev) => new Map(prev).set(server.id, res.error))
1671
+ setRebootInfoLoading((prev) => {
1672
+ const next = new Set(prev)
1673
+ next.delete(server.id)
1674
+ return next
1675
+ })
1676
+ }
1677
+ void run()
1678
+ },
1679
+ [rebootInfoLoading, sites],
1680
+ )
1681
+
1682
+ // Persist the link map to the config file (write-through). The stored shape
1683
+ // keys by site id as a string; cfgRef is kept in sync so a later reload of the
1684
+ // store sees the change.
1685
+ const persistLinks = useCallback((next: Map<number, LocalLink>) => {
1686
+ const record: Record<string, LocalLink> = {}
1687
+ for (const [id, link] of next) record[String(id)] = link
1688
+ cfgRef.current.localSites = record
1689
+ void saveConfig({ localSites: record })
1690
+ }, [])
1691
+
1692
+ const linkSite = useCallback(
1693
+ (siteId: number, link: LocalLink) =>
1694
+ setLocalLinks((prev) => {
1695
+ const next = new Map(prev).set(siteId, link)
1696
+ persistLinks(next)
1697
+ return next
1698
+ }),
1699
+ [persistLinks],
1700
+ )
1701
+
1702
+ const unlinkSite = useCallback(
1703
+ (siteId: number) =>
1704
+ setLocalLinks((prev) => {
1705
+ if (!prev.has(siteId)) return prev
1706
+ const next = new Map(prev)
1707
+ next.delete(siteId)
1708
+ persistLinks(next)
1709
+ return next
1710
+ }),
1711
+ [persistLinks],
1712
+ )
1713
+
1714
+ const addLocalRoot = useCallback((dir: string) => {
1715
+ const trimmed = dir.trim()
1716
+ if (!trimmed) return
1717
+ setLocalRoots((prev) => {
1718
+ if (prev.includes(trimmed)) return prev
1719
+ const next = [...prev, trimmed]
1720
+ cfgRef.current.localRoots = next
1721
+ void saveConfig({ localRoots: next })
1722
+ return next
1723
+ })
1724
+ }, [])
1725
+
1726
+ const openLocalTerminal = useCallback(
1727
+ (siteId: number) => {
1728
+ const link = localLinks.get(siteId)
1729
+ if (!link) return "Not linked — press L to link a local copy"
1730
+ if (!resolveLocalLink(link).exists) return "Local path is missing — press L to fix it"
1731
+ openTerminalAt(expandPath(link.path), cfgRef.current.terminalApp)
1732
+ return "Opening a terminal at the local path…"
1733
+ },
1734
+ [localLinks],
1735
+ )
1736
+
1737
+ const openLocalUrl = useCallback(
1738
+ (siteId: number) => {
1739
+ const link = localLinks.get(siteId)
1740
+ if (!link) return "Not linked — press L to link a local copy"
1741
+ if (!link.localUrl) return "No local URL set — press L to add one"
1742
+ openUrl(link.localUrl)
1743
+ return "Opening the local URL…"
1744
+ },
1745
+ [localLinks],
1746
+ )
1747
+
1748
+ const sshSite = useCallback(
1749
+ (siteId: number) => {
1750
+ const site = sites.find((s) => s.id === siteId)
1751
+ if (!site) return ""
1752
+ const server = servers.find((s) => s.id === site.server_id)
1753
+ const host = server?.ip_address
1754
+ const user = site.site_user ?? cfgRef.current.sshUser
1755
+ if (!host || !user) return "Can't SSH — missing site user or server IP"
1756
+ openSshSession(user, host, server?.ssh_port, cfgRef.current.terminalApp)
1757
+ return `Opening SSH to ${site.domain}…`
1758
+ },
1759
+ [sites, servers],
1760
+ )
1761
+
1762
+ // ---- Privileged SSH-key grant (sudo write-over-SSH) -------------------
1763
+
1764
+ const sudoUserFor = useCallback((serverId: number) => sudoUsers.get(serverId), [sudoUsers])
1765
+
1766
+ // Persist per-server sudo metadata: the username + the keychain-saved flag (the
1767
+ // password itself never touches disk — it's in-memory + optionally the Keychain).
1768
+ const persistSudoMeta = useCallback((users: Map<number, string>, saved: Set<number>) => {
1769
+ const record: Record<string, { user: string; keychain?: boolean }> = {}
1770
+ for (const [id, u] of users) record[String(id)] = saved.has(id) ? { user: u, keychain: true } : { user: u }
1771
+ cfgRef.current.sudoUsers = record
1772
+ void saveConfig({ sudoUsers: record })
1773
+ }, [])
1774
+ const isSudoConnected = useCallback((serverId: number) => sudoConnected.has(serverId), [sudoConnected])
1775
+ const sudoSavedFor = useCallback((serverId: number) => sudoSaved.has(serverId), [sudoSaved])
1776
+
1777
+ // Mark a verified connection live + hold the password in memory; persist username +
1778
+ // (opt-in, macOS) the password into the Keychain. `remember` toggles Keychain
1779
+ // storage — unchecking it on a server that had one saved removes it.
1780
+ const finishConnect = useCallback(
1781
+ async (server: Server, sudoUser: string, password: string, remember: boolean) => {
1782
+ sudoPwRef.current.set(server.id, password)
1783
+ setSudoConnected((prev) => new Set(prev).add(server.id))
1784
+ const users = new Map(sudoUsers).set(server.id, sudoUser)
1785
+ setSudoUsers(users)
1786
+ let saved = sudoSaved
1787
+ if (keychainAvailable()) {
1788
+ if (remember && !sudoSaved.has(server.id)) {
1789
+ if (await setSudoPassword(server.id, password)) saved = new Set(sudoSaved).add(server.id)
1790
+ } else if (remember && sudoSaved.has(server.id)) {
1791
+ await setSudoPassword(server.id, password) // refresh in case the password changed
1792
+ } else if (!remember && sudoSaved.has(server.id)) {
1793
+ await deleteSudoPassword(server.id)
1794
+ saved = new Set(sudoSaved)
1795
+ saved.delete(server.id)
1796
+ }
1797
+ if (saved !== sudoSaved) setSudoSaved(saved)
1798
+ }
1799
+ persistSudoMeta(users, saved)
1800
+ },
1801
+ [sudoUsers, sudoSaved, persistSudoMeta],
1802
+ )
1803
+
1804
+ // Connect sudo on a server: validate the credentials live, then (on success) mark it
1805
+ // connected, hold the password in memory, persist the username, and optionally save
1806
+ // the password to the Keychain. Returns the verify result so the overlay shows pass/fail.
1807
+ const connectSudo = useCallback(
1808
+ async (server: Server, user: string, password: string, remember = false): Promise<{ ok: true } | { ok: false; error: string }> => {
1809
+ const sudoUser = user.trim()
1810
+ if (!sudoUser) return { ok: false, error: "Enter a sudo username." }
1811
+ const res = await verifySudo(server, { sudoUser, sudoPassword: password })
1812
+ if (!res.ok) return res
1813
+ await finishConnect(server, sudoUser, password, remember)
1814
+ return { ok: true }
1815
+ },
1816
+ [finishConnect],
1817
+ )
1818
+
1819
+ // Auto-connect from the Keychain (the `S` overlay calls this on open when a password
1820
+ // is saved). Retrieve → verify → connect, all without prompting for the password.
1821
+ const connectSudoFromKeychain = useCallback(
1822
+ async (server: Server): Promise<{ ok: true } | { ok: false; error: string }> => {
1823
+ const sudoUser = sudoUsers.get(server.id)
1824
+ if (!sudoUser) return { ok: false, error: "No saved sudo user for this server." }
1825
+ const password = await getSudoPassword(server.id)
1826
+ if (password == null) return { ok: false, error: "Couldn't read the saved password from the Keychain." }
1827
+ const res = await verifySudo(server, { sudoUser, sudoPassword: password })
1828
+ if (!res.ok) return res
1829
+ await finishConnect(server, sudoUser, password, true)
1830
+ return { ok: true }
1831
+ },
1832
+ [sudoUsers, finishConnect],
1833
+ )
1834
+
1835
+ // Forget a server's Keychain-saved password (the username + any live session stay).
1836
+ const forgetSudoKeychain = useCallback(
1837
+ async (serverId: number) => {
1838
+ await deleteSudoPassword(serverId)
1839
+ setSudoSaved((prev) => {
1840
+ if (!prev.has(serverId)) return prev
1841
+ const next = new Set(prev)
1842
+ next.delete(serverId)
1843
+ persistSudoMeta(sudoUsers, next)
1844
+ return next
1845
+ })
1846
+ },
1847
+ [sudoUsers, persistSudoMeta],
1848
+ )
1849
+
1850
+ // Disconnect sudo on a server: forget the held password and clear the connected flag.
1851
+ const disconnectSudo = useCallback((serverId: number) => {
1852
+ sudoPwRef.current.delete(serverId)
1853
+ setSudoConnected((prev) => {
1854
+ if (!prev.has(serverId)) return prev
1855
+ const next = new Set(prev)
1856
+ next.delete(serverId)
1857
+ return next
1858
+ })
1859
+ }, [])
1860
+
1861
+ const setPreferredGrantKeys = useCallback((ids: string[]) => {
1862
+ cfgRef.current.preferredGrantKeys = ids
1863
+ setPreferredGrantKeysState(ids)
1864
+ void saveConfig({ preferredGrantKeys: ids })
1865
+ }, [])
1866
+
1867
+ // Persist the granted-keys map (only non-empty entries) to config, write-through.
1868
+ const persistGrantedKeys = useCallback((next: Map<number, Set<string>>) => {
1869
+ const rec: Record<string, string[]> = {}
1870
+ for (const [id, set] of next) if (set.size) rec[String(id)] = [...set]
1871
+ cfgRef.current.grantedKeys = rec
1872
+ void saveConfig({ grantedKeys: rec })
1873
+ }, [])
1874
+
1875
+ // Record / forget which keys (by body) Spinup has granted on a site.
1876
+ const recordGrantedKeys = useCallback(
1877
+ (siteId: number, bodies: string[]) =>
1878
+ setGrantedKeys((prev) => {
1879
+ const next = new Map(prev)
1880
+ const set = new Set(next.get(siteId) ?? [])
1881
+ for (const b of bodies) if (b) set.add(b)
1882
+ next.set(siteId, set)
1883
+ persistGrantedKeys(next)
1884
+ return next
1885
+ }),
1886
+ [persistGrantedKeys],
1887
+ )
1888
+ const forgetGrantedKeys = useCallback(
1889
+ (siteId: number, bodies: string[]) =>
1890
+ setGrantedKeys((prev) => {
1891
+ if (!prev.has(siteId)) return prev
1892
+ const next = new Map(prev)
1893
+ const set = new Set(next.get(siteId))
1894
+ for (const b of bodies) set.delete(b)
1895
+ if (set.size) next.set(siteId, set)
1896
+ else next.delete(siteId)
1897
+ persistGrantedKeys(next)
1898
+ return next
1899
+ }),
1900
+ [persistGrantedKeys],
1901
+ )
1902
+ const siteHasGrantedKey = useCallback((siteId: number) => (grantedKeys.get(siteId)?.size ?? 0) > 0, [grantedKeys])
1903
+
1904
+ // Set (or, given "", clear) a zone's access-note override. Clearing removes the
1905
+ // key entirely so config stays empty for the common "use the default" case.
1906
+ const setZoneAccessNote = useCallback((apex: string, note: string) => {
1907
+ setZoneAccessNotes((prev) => {
1908
+ const next = new Map(prev)
1909
+ const trimmed = note.trim()
1910
+ if (trimmed) next.set(apex, trimmed)
1911
+ else next.delete(apex)
1912
+ const rec = Object.fromEntries(next)
1913
+ cfgRef.current.zoneAccessNotes = rec
1914
+ void saveConfig({ zoneAccessNotes: rec })
1915
+ return next
1916
+ })
1917
+ }, [])
1918
+ // Break a site's granted keys into "personal" (yours) vs "machine" (spinup-tui),
1919
+ // so the UI can say which is on the site instead of an ambiguous "Spinup key".
1920
+ // Before the machine-key body resolves, everything counts as personal (brief).
1921
+ const grantedKeyKinds = useCallback(
1922
+ (siteId: number): { personal: number; machine: number } => {
1923
+ const set = grantedKeys.get(siteId)
1924
+ if (!set || set.size === 0) return { personal: 0, machine: 0 }
1925
+ let machine = 0
1926
+ let personal = 0
1927
+ for (const b of set) {
1928
+ if (machineKeyBody && b === machineKeyBody) machine++
1929
+ else personal++
1930
+ }
1931
+ return { personal, machine }
1932
+ },
1933
+ [grantedKeys, machineKeyBody],
1934
+ )
1935
+
1936
+ const setGrant = (siteId: number, progress: KeyGrantProgress) =>
1937
+ setKeyGrants((prev) => new Map(prev).set(siteId, progress))
1938
+
1939
+ const clearGrantKey = useCallback(
1940
+ (siteId: number) =>
1941
+ setKeyGrants((prev) => {
1942
+ if (!prev.has(siteId)) return prev
1943
+ const next = new Map(prev)
1944
+ next.delete(siteId)
1945
+ return next
1946
+ }),
1947
+ [],
1948
+ )
1949
+
1950
+ // Grant or REVOKE the keys on one or many sites (all on the same server). Each
1951
+ // site's progress is tracked independently in keyGrants, so the overlay can show
1952
+ // a per-site readout; a bounded pool keeps the SSH fan-out modest (the persistent
1953
+ // ControlMaster connection makes the per-site probes cheap). On success, grant
1954
+ // records the keys for the row badge; revoke forgets them.
1955
+ const runKeyOp = useCallback(
1956
+ (sites: Site[], pubkeys: string[], op: "grant" | "revoke") => {
1957
+ if (sites.length === 0) return
1958
+ const verb = op === "grant" ? "grant" : "remove"
1959
+ if (pubkeys.length === 0) {
1960
+ for (const s of sites) setGrant(s.id, { status: "error", error: `No keys selected to ${verb}.` })
1961
+ return
1962
+ }
1963
+ const server = servers.find((s) => s.id === sites[0].server_id)
1964
+ if (!server) {
1965
+ for (const s of sites) setGrant(s.id, { status: "error", error: "Couldn't find the site's server." })
1966
+ return
1967
+ }
1968
+ const sudoUser = sudoUsers.get(server.id)
1969
+ if (!sudoUser) {
1970
+ for (const s of sites) setGrant(s.id, { status: "error", error: "No sudo user set for this server." })
1971
+ return
1972
+ }
1973
+ const sudoPassword = sudoPwRef.current.get(server.id) ?? ""
1974
+ const bodies = pubkeys.map(keyBody)
1975
+
1976
+ // Skip sites already mid-op; queue the rest (so the overlay sees them
1977
+ // immediately) and drain with a small worker pool.
1978
+ const queue = sites.filter((s) => {
1979
+ const ex = keyGrants.get(s.id)
1980
+ return !(ex && ex.status !== "done" && ex.status !== "error")
1981
+ })
1982
+ for (const s of queue) setGrant(s.id, { status: "queued" })
1983
+
1984
+ const CONCURRENCY = 4
1985
+ let cursor = 0
1986
+ const worker = async () => {
1987
+ while (cursor < queue.length) {
1988
+ const s = queue[cursor++]
1989
+ setGrant(s.id, { status: op === "grant" ? "granting" : "removing" })
1990
+ const fn = op === "grant" ? grantSiteSshKey : revokeSiteSshKey
1991
+ const res = await fn(server, s, { sudoUser, sudoPassword, pubkeys })
1992
+ if (res.ok) {
1993
+ if (op === "grant") recordGrantedKeys(s.id, bodies)
1994
+ else forgetGrantedKeys(s.id, bodies)
1995
+ setGrant(s.id, { status: "done", target: res.target })
1996
+ } else {
1997
+ // A rejected/stale password means the server is no longer truly connected
1998
+ // — disconnect it so the ● disappears and the user reconnects.
1999
+ if (/password was rejected|password is required|can't run sudo|couldn't connect/i.test(res.error)) {
2000
+ disconnectSudo(server.id)
2001
+ }
2002
+ setGrant(s.id, { status: "error", error: res.error, target: res.target })
2003
+ }
2004
+ }
2005
+ }
2006
+ for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) void worker()
2007
+ },
2008
+ [keyGrants, servers, sudoUsers, disconnectSudo, recordGrantedKeys, forgetGrantedKeys],
2009
+ )
2010
+
2011
+ const startGrantKey = useCallback((sites: Site[], pubkeys: string[]) => runKeyOp(sites, pubkeys, "grant"), [runKeyOp])
2012
+ const startRevokeKey = useCallback((sites: Site[], pubkeys: string[]) => runKeyOp(sites, pubkeys, "revoke"), [runKeyOp])
2013
+
2014
+ // Grant the user's REMEMBERED keys (preferredGrantKeys) to the given sites,
2015
+ // resolving the saved key bodies to their current key lines. Used by auto-grant
2016
+ // flows (e.g. the vanity build) so a created site gets the key with one keypress.
2017
+ // Sets a clear error on the sites if there's no saved selection to resolve.
2018
+ const startGrantRemembered = useCallback(
2019
+ (sites: Site[]) => {
2020
+ if (sites.length === 0) return
2021
+ void (async () => {
2022
+ let lines: string[] = []
2023
+ try {
2024
+ const [machine, personal] = await Promise.all([ensureSpinupKey(), listPersonalKeys()])
2025
+ const all = [...personal, { id: keyBody(machine.pub), line: machine.pub }]
2026
+ lines = all.filter((k) => preferredGrantKeys.includes(k.id)).map((k) => k.line)
2027
+ } catch {
2028
+ /* fall through to the no-keys error below */
2029
+ }
2030
+ if (lines.length === 0) {
2031
+ for (const s of sites) setGrant(s.id, { status: "error", error: "No saved keys to grant — grant one on a site (K) first." })
2032
+ return
2033
+ }
2034
+ startGrantKey(sites, lines)
2035
+ })()
2036
+ },
2037
+ [preferredGrantKeys, startGrantKey],
2038
+ )
2039
+
2040
+ // ---- Production DB backup download ------------------------------------
2041
+
2042
+ const setBackup = (siteId: number, progress: DbBackupProgress) =>
2043
+ setDbBackups((prev) => new Map(prev).set(siteId, progress))
2044
+
2045
+ const clearDbBackup = useCallback(
2046
+ (siteId: number) =>
2047
+ setDbBackups((prev) => {
2048
+ if (!prev.has(siteId)) return prev
2049
+ const next = new Map(prev)
2050
+ next.delete(siteId)
2051
+ return next
2052
+ }),
2053
+ [],
2054
+ )
2055
+
2056
+ const planDbBackupFor = useCallback(
2057
+ (site: Site): PlanResult =>
2058
+ planDbBackup(site, servers.find((s) => s.id === site.server_id), cfgRef.current.sshUser, localLinks.get(site.id), new Date()),
2059
+ [servers, localLinks],
2060
+ )
2061
+
2062
+ const startDbBackup = useCallback(
2063
+ (site: Site) => {
2064
+ // Ignore a duplicate request while one is already running for this site.
2065
+ const existing = dbBackups.get(site.id)
2066
+ if (existing && existing.stage !== "done" && existing.stage !== "error") return
2067
+ const res = planDbBackupFor(site)
2068
+ if (!res.ok) {
2069
+ setBackup(site.id, { stage: "error", domain: site.domain, error: res.error })
2070
+ return
2071
+ }
2072
+ // SSH-orchestrated (no event): persist only for visibility/interrupted
2073
+ // detection on restart; drop it the moment it settles.
2074
+ void saveJob({ id: dbBackupJobId(site.id), kind: "dbBackup", status: "running", startedAt: Date.now(), inputs: { siteId: site.id, domain: site.domain } })
2075
+ void runDbBackup(res.plan, site.domain, (p) => {
2076
+ setBackup(site.id, p)
2077
+ if (p.stage === "done" || p.stage === "error") void removeJob(dbBackupJobId(site.id))
2078
+ })
2079
+ },
2080
+ [dbBackups, planDbBackupFor],
2081
+ )
2082
+
2083
+ // ---- Production → local DB sync ---------------------------------------
2084
+
2085
+ const setSync = (siteId: number, progress: DbSyncProgress) =>
2086
+ setDbSyncs((prev) => new Map(prev).set(siteId, progress))
2087
+
2088
+ const clearDbSync = useCallback(
2089
+ (siteId: number) =>
2090
+ setDbSyncs((prev) => {
2091
+ if (!prev.has(siteId)) return prev
2092
+ const next = new Map(prev)
2093
+ next.delete(siteId)
2094
+ return next
2095
+ }),
2096
+ [],
2097
+ )
2098
+
2099
+ const planDbSyncFor = useCallback(
2100
+ (site: Site): SyncPlanResult =>
2101
+ planDbSync(site, servers.find((s) => s.id === site.server_id), cfgRef.current.sshUser, localLinks.get(site.id), new Date()),
2102
+ [servers, localLinks],
2103
+ )
2104
+
2105
+ const planMediaFallbackFor = useCallback((site: Site): MediaFallbackResult => planMediaFallback(site, localLinks.get(site.id)), [localLinks])
2106
+
2107
+ const startDbSync = useCallback(
2108
+ (site: Site) => {
2109
+ const existing = dbSyncs.get(site.id)
2110
+ if (existing && existing.stage !== "done" && existing.stage !== "error") return
2111
+ const res = planDbSyncFor(site)
2112
+ if (!res.ok) {
2113
+ setSync(site.id, { stage: "error", domain: site.domain, error: res.error })
2114
+ return
2115
+ }
2116
+ // SSH-orchestrated (no event): persist only so a restart can flag it as
2117
+ // interrupted (local DB may be partial); drop it the moment it settles.
2118
+ void saveJob({ id: dbSyncJobId(site.id), kind: "dbSync", status: "running", startedAt: Date.now(), inputs: { siteId: site.id, domain: site.domain } })
2119
+ void runDbSync(res.plan, site.domain, (p) => {
2120
+ setSync(site.id, p)
2121
+ if (p.stage === "done" || p.stage === "error") void removeJob(dbSyncJobId(site.id))
2122
+ })
2123
+ },
2124
+ [dbSyncs, planDbSyncFor],
2125
+ )
2126
+
2127
+ // Compute a linked site's git drift once (cached), fire-and-forget. Stable
2128
+ // across renders (uses a ref for the dedup set), so views can call it freely
2129
+ // from an effect when a linked site comes into view.
2130
+ const ensureDrift = useCallback((siteId: number, linkPath: string) => {
2131
+ if (driftRequested.current.has(siteId)) return
2132
+ driftRequested.current.add(siteId)
2133
+ void gitDrift(linkPath).then((d) => setDrift((prev) => new Map(prev).set(siteId, d)))
2134
+ }, [])
2135
+
2136
+ // ---- DNS zone-host lookups -------------------------------------------------
2137
+
2138
+ // Resolve one domain's zone host (cache first). Concurrency-safe — the ref-based
2139
+ // in-flight set dedupes, and a fresh cache entry short-circuits unless forced.
2140
+ const dnsResolveOne = useCallback(async (domain: string, force: boolean) => {
2141
+ const cache = dnsCacheRef.current!
2142
+ const key = normalizeDomain(domain)
2143
+ if (!key || dnsInFlight.current.has(key)) return
2144
+ if (!force && !cache.isStale(key)) return
2145
+ dnsInFlight.current.add(key)
2146
+ setDnsResolving((prev) => new Set(prev).add(key))
2147
+ try {
2148
+ const zone = await resolveZone(key)
2149
+ await cache.set(key, zone)
2150
+ setDnsZones(cache.snapshot())
2151
+ } finally {
2152
+ dnsInFlight.current.delete(key)
2153
+ setDnsResolving((prev) => {
2154
+ const next = new Set(prev)
2155
+ next.delete(key)
2156
+ return next
2157
+ })
2158
+ }
2159
+ }, [])
2160
+
2161
+ // Resolve many domains with a bounded pool (skips fresh/in-flight unless
2162
+ // forced). Returns how many were actually queued, once every worker settles —
2163
+ // most callers fire-and-forget it, but resolveAllFleetDomains awaits it to
2164
+ // fire a completion toast.
2165
+ const dnsLookupMany = useCallback(
2166
+ (domains: string[], force = false): Promise<number> => {
2167
+ const cache = dnsCacheRef.current!
2168
+ const keys = Array.from(new Set(domains.map(normalizeDomain).filter(Boolean)))
2169
+ const queue = keys.filter((k) => !dnsInFlight.current.has(k) && (force || cache.isStale(k)))
2170
+ if (queue.length === 0) return Promise.resolve(0)
2171
+ const CONCURRENCY = 5
2172
+ let cursor = 0
2173
+ const worker = async () => {
2174
+ while (cursor < queue.length) await dnsResolveOne(queue[cursor++], force)
2175
+ }
2176
+ const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, () => worker())
2177
+ return Promise.all(workers).then(() => queue.length)
2178
+ },
2179
+ [dnsResolveOne],
2180
+ )
2181
+
2182
+ const domainsForSite = (site: Site): string[] => [
2183
+ site.domain,
2184
+ ...(site.additional_domains?.map((a) => a.domain) ?? []),
2185
+ ]
2186
+
2187
+ const lookupSiteDns = useCallback(
2188
+ (site: Site, force = false) => dnsLookupMany(domainsForSite(site), force),
2189
+ [dnsLookupMany],
2190
+ )
2191
+
2192
+ const lookupServerDns = useCallback(
2193
+ (serverId: number, force = false) => {
2194
+ const domains: string[] = []
2195
+ for (const s of sites) if (s.server_id === serverId) domains.push(...domainsForSite(s))
2196
+ dnsLookupMany(domains, force)
2197
+ },
2198
+ [dnsLookupMany, sites],
2199
+ )
2200
+
2201
+ const zoneForDomain = useCallback((domain: string) => dnsZones.get(normalizeDomain(domain)), [dnsZones])
2202
+ const isDnsResolving = useCallback((domain: string) => dnsResolving.has(normalizeDomain(domain)), [dnsResolving])
2203
+
2204
+ // Every distinct zone apex already known (from prior lookups, fleet-wide) to be
2205
+ // hosted at a given DNS host (e.g. "godaddy") — sorted. Used by the GoDaddy
2206
+ // "Manage Access" screen, which has no API to list zones itself.
2207
+ const zonesForHostKey = useCallback(
2208
+ (hostKey: string) => {
2209
+ const apexes = new Set<string>()
2210
+ for (const c of dnsZones.values()) if (c.zone?.providerKey === hostKey) apexes.add(c.zone.apex)
2211
+ return [...apexes].sort()
2212
+ },
2213
+ [dnsZones],
2214
+ )
2215
+
2216
+ // Resolve every site's domain(s) fleet-wide (bounded pool, skips fresh/in-flight
2217
+ // unless forced) — fills gaps in the lazy dnsZones cache so a provider's zone
2218
+ // list (e.g. GoDaddy's Manage Access screen) can become complete on demand.
2219
+ // Runs in the background regardless of whether the triggering overlay is still
2220
+ // open (same as the PHP-upgrade/reboot toasts), so the completion toast is the
2221
+ // "it's done" signal for a lookup that can span hundreds of domains.
2222
+ const resolveAllFleetDomains = useCallback(
2223
+ (force = false) => {
2224
+ void dnsLookupMany(sites.flatMap(domainsForSite), force).then((count) => {
2225
+ if (count > 0) toast.success(`Fleet DNS resolved — ${count} domain${count === 1 ? "" : "s"} checked`)
2226
+ })
2227
+ },
2228
+ [dnsLookupMany, sites],
2229
+ )
2230
+
2231
+ // ---- Website-hosting record lookups (Phase 3 inventory) --------------------
2232
+
2233
+ // Resolve one hostname's record at its zone's authoritative NS. Records "none"
2234
+ // when the hostname has no record (so it isn't retried); a network miss also
2235
+ // caches none until a forced refresh.
2236
+ const resolveHostingOne = useCallback(async (host: string, apex: string, ns: string[]) => {
2237
+ if (hostingInFlight.current.has(host)) return
2238
+ hostingInFlight.current.add(host)
2239
+ setHostingResolving((prev) => new Set(prev).add(host))
2240
+ try {
2241
+ const ans = await queryAuthoritative(host, "A", ns)
2242
+ const own = ans?.find((a) => a.name.toLowerCase() === host)
2243
+ const ips = ans?.filter((a) => a.type === "A").map((a) => a.value) ?? []
2244
+ const rec: HostRecord = own
2245
+ ? { host, apex, type: own.type, ttl: own.ttl, value: own.value, resolvedIps: ips, checkedAt: Date.now() }
2246
+ : { host, apex, type: "none", ttl: null, value: "", resolvedIps: [], checkedAt: Date.now() }
2247
+ setHostingRecords((prev) => new Map(prev).set(host, rec))
2248
+ } finally {
2249
+ hostingInFlight.current.delete(host)
2250
+ setHostingResolving((prev) => {
2251
+ const next = new Set(prev)
2252
+ next.delete(host)
2253
+ return next
2254
+ })
2255
+ }
2256
+ }, [])
2257
+
2258
+ const resolveServerHosting = useCallback(
2259
+ (server: Server, force = false) => {
2260
+ const hosts = new Set<string>()
2261
+ for (const s of sites) if (s.server_id === server.id) for (const h of candidateHostnames(domainsForSite(s))) hosts.add(h)
2262
+ // Only resolve hostnames whose zone NS we already know (needed to query the
2263
+ // authoritative server). The rest get picked up when their zone lands and
2264
+ // this re-runs (it's cheap — already-resolved/in-flight are skipped).
2265
+ const pool: { host: string; apex: string; ns: string[] }[] = []
2266
+ for (const host of hosts) {
2267
+ if (hostingInFlight.current.has(host)) continue
2268
+ if (!force && hostingRecords.has(host)) continue
2269
+ const z = dnsZones.get(normalizeDomain(host))
2270
+ const ns = z?.zone?.nameservers
2271
+ const apex = z?.zone?.apex
2272
+ if (!ns || ns.length === 0 || !apex) continue
2273
+ pool.push({ host, apex, ns })
2274
+ }
2275
+ if (pool.length === 0) return
2276
+ const CONCURRENCY = 5
2277
+ let cursor = 0
2278
+ const worker = async () => {
2279
+ while (cursor < pool.length) {
2280
+ const { host, apex, ns } = pool[cursor++]
2281
+ await resolveHostingOne(host, apex, ns)
2282
+ }
2283
+ }
2284
+ for (let i = 0; i < Math.min(CONCURRENCY, pool.length); i++) void worker()
2285
+ },
2286
+ [sites, dnsZones, hostingRecords, resolveHostingOne],
2287
+ )
2288
+
2289
+ const hostingFor = useCallback((host: string) => hostingRecords.get(host.toLowerCase()), [hostingRecords])
2290
+ const isHostingResolving = useCallback((host: string) => hostingResolving.has(host.toLowerCase()), [hostingResolving])
2291
+
2292
+ // ---- DNS provider connections (Phase 2 access detection) -------------------
2293
+
2294
+ const genConnId = (provider: ConnProvider) =>
2295
+ `${provider}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
2296
+
2297
+ const allConnections = useMemo(() => ALL_PROVIDERS.flatMap((p) => connections[p]), [connections])
2298
+ const connectionsFor = useCallback((provider: ConnProvider) => connections[provider], [connections])
2299
+
2300
+ // Pick the candidate account that actually SERVES a zone, using the live
2301
+ // authoritative nameservers when the account's NS are known (catches stale /
2302
+ // duplicate zones across accounts). When no NS are known (e.g. AWS), fall back
2303
+ // to membership — provider-scoping already guarantees the right provider.
2304
+ const matchedAccount = useCallback(
2305
+ (apex: string, hostKey: string, liveNs: string[]): { editable: boolean; label: string; id: string } =>
2306
+ matchAccountFrom(providerZones, allConnections, apex, hostKey, liveNs),
2307
+ [providerZones, allConnections],
2308
+ )
2309
+
2310
+ const accessForZone = useCallback(
2311
+ (apex: string, hostKey: string, liveNs: string[]): AccessState => {
2312
+ const prov = apiProviderFor(hostKey)
2313
+ if (prov) {
2314
+ if (matchedAccount(apex, hostKey, liveNs).editable) return "editable"
2315
+ // Not reachable via API: providers with a console fallback (GoDaddy, whose
2316
+ // API is gated) show `web`; others show `needs-key`.
2317
+ return PROVIDER_REGISTRY[prov].console ? "web" : "needs-key"
2318
+ }
2319
+ if (PROVIDER_CONSOLE[hostKey]) return "web"
2320
+ return "unknown"
2321
+ },
2322
+ [matchedAccount],
2323
+ )
2324
+
2325
+ const accountForZone = useCallback(
2326
+ (apex: string, hostKey: string, liveNs: string[]) => matchedAccount(apex, hostKey, liveNs).label,
2327
+ [matchedAccount],
2328
+ )
2329
+
2330
+ const connForZone = useCallback(
2331
+ (apex: string, hostKey: string, liveNs: string[]): Connection | null => {
2332
+ const m = matchedAccount(apex, hostKey, liveNs)
2333
+ if (!m.editable || !m.id) return null
2334
+ return allConnections.find((c) => c.id === m.id) ?? null
2335
+ },
2336
+ [matchedAccount, allConnections],
2337
+ )
2338
+
2339
+ // Persist the stored (non-env) connections to config, keeping cfgRef in sync.
2340
+ const persistConnections = useCallback((next: Record<ConnProvider, Connection[]>) => {
2341
+ cfgRef.current.providerConnections = next
2342
+ const providers: StoredProviders = {}
2343
+ for (const p of ALL_PROVIDERS) {
2344
+ providers[p] = next[p].filter((c) => !c.env).map((c) => ({ id: c.id, label: c.label, creds: c.creds }))
2345
+ }
2346
+ void saveConfig({ providers })
2347
+ }, [])
2348
+
2349
+ // Verify a connection and write the result through to the cache.
2350
+ const verifyAndCache = useCallback(async (conn: Connection): Promise<VerifyResult> => {
2351
+ const res = await verifyProviderConnection(conn)
2352
+ await providersCacheRef.current!.set(conn.id, {
2353
+ ok: res.ok,
2354
+ zones: res.zones,
2355
+ accountLabel: res.accountLabel,
2356
+ error: res.error,
2357
+ verifiedAt: Date.now(),
2358
+ })
2359
+ setProviderZones(providersCacheRef.current!.snapshot())
2360
+ return res
2361
+ }, [])
2362
+
2363
+ const addConnection = useCallback(
2364
+ async (provider: ConnProvider, label: string, creds: Record<string, string>): Promise<VerifyResult> => {
2365
+ const trimmed: Record<string, string> = {}
2366
+ for (const [k, v] of Object.entries(creds)) trimmed[k] = v.trim()
2367
+ const conn: Connection = { id: genConnId(provider), provider, label: label.trim(), creds: trimmed }
2368
+ const res = await verifyAndCache(conn)
2369
+ if (!res.ok) {
2370
+ // Don't keep a credential we couldn't verify; drop its cache entry.
2371
+ await providersCacheRef.current!.delete(conn.id)
2372
+ setProviderZones(providersCacheRef.current!.snapshot())
2373
+ return res
2374
+ }
2375
+ conn.label = conn.label || res.accountLabel || provider
2376
+ setConnections((prev) => {
2377
+ const next = { ...prev, [provider]: [...prev[provider], conn] }
2378
+ persistConnections(next)
2379
+ return next
2380
+ })
2381
+ return res
2382
+ },
2383
+ [verifyAndCache, persistConnections],
2384
+ )
2385
+
2386
+ const removeConnection = useCallback(
2387
+ (id: string) => {
2388
+ setConnections((prev) => {
2389
+ let changed = false
2390
+ const next = {} as Record<ConnProvider, Connection[]>
2391
+ for (const p of ALL_PROVIDERS) {
2392
+ const filtered = prev[p].filter((c) => c.id !== id || c.env) // env can't be removed
2393
+ if (filtered.length !== prev[p].length) changed = true
2394
+ next[p] = filtered
2395
+ }
2396
+ if (!changed) return prev
2397
+ persistConnections(next)
2398
+ void providersCacheRef.current!.delete(id).then(() => setProviderZones(providersCacheRef.current!.snapshot()))
2399
+ return next
2400
+ })
2401
+ },
2402
+ [persistConnections],
2403
+ )
2404
+
2405
+ const verifyConnectionById = useCallback(
2406
+ (id: string) => {
2407
+ const conn = allConnections.find((c) => c.id === id)
2408
+ if (conn) void verifyAndCache(conn)
2409
+ },
2410
+ [allConnections, verifyAndCache],
2411
+ )
2412
+
2413
+ // Resolve a hostname to its apex zone + the editable connection that serves it.
2414
+ // The load-bearing bit for freshly-registered zones: when the cached zone list
2415
+ // has no match but an account for that provider IS connected, the cache is most
2416
+ // likely just stale (a zone added since the last verify) — so we re-verify that
2417
+ // provider's accounts and retry against the fresh result before giving up. This
2418
+ // is what lets a vanity build for a zone you registered today succeed without a
2419
+ // manual cache bust. The two failure messages distinguish "no account connected"
2420
+ // from "account connected but it doesn't serve this zone" so the remedy is right.
2421
+ //
2422
+ // PROVIDER-AGNOSTIC by construction (dispatches via apiProviderFor + the per-
2423
+ // provider verifyProviderConnection), so Cloudflare/GoDaddy/etc. get the self-heal
2424
+ // for free — no per-provider code. But the MATCH inside matchAccountFrom differs by
2425
+ // provider and that matters here: AWS verify returns NO nameservers (rate-limit
2426
+ // cost) → membership match, so a re-verified R53 zone matches immediately. Cloudflare
2427
+ // verify DOES return nameservers → live-NS-must-match, so a freshly-ADDED but NOT-YET-
2428
+ // DELEGATED CF zone (registrar NS not switched) correctly stays "doesn't serve (re-
2429
+ // checked just now)" — re-verify can't cure a zone that genuinely isn't live yet; the
2430
+ // user must finish NS delegation. See memory dns-zone-resolution-staleness.
2431
+ const resolveZoneConn = useCallback(
2432
+ async (hostname: string): Promise<{ zone: ZoneHost; conn: Connection } | { error: string; zone?: ZoneHost }> => {
2433
+ const zone = await resolveZone(hostname)
2434
+ if (!zone) return { error: `Couldn't find the DNS zone for ${hostname}.` }
2435
+ // Match against the cache ref (not the providerZones state) so a re-verify
2436
+ // below is visible immediately within this same async flow.
2437
+ const find = (): Connection | null => {
2438
+ const m = matchAccountFrom(providersCacheRef.current!.snapshot(), allConnections, zone.apex, zone.providerKey, zone.nameservers)
2439
+ return m.editable && m.id ? allConnections.find((c) => c.id === m.id) ?? null : null
2440
+ }
2441
+ let conn = find()
2442
+ if (!conn) {
2443
+ const prov = apiProviderFor(zone.providerKey)
2444
+ const accounts = prov ? allConnections.filter((c) => c.provider === prov) : []
2445
+ if (accounts.length === 0) {
2446
+ return { error: `No ${zone.providerLabel} account is connected for ${zone.apex} — add one in the DNS view (N) first.`, zone }
2447
+ }
2448
+ // An account is connected but its cached zones don't list this zone — most
2449
+ // likely a stale cache (zone added since the last verify). Re-verify + retry.
2450
+ await Promise.all(accounts.map((c) => verifyAndCache(c)))
2451
+ conn = find()
2452
+ if (!conn) {
2453
+ return { error: `Your ${zone.providerLabel} account doesn't serve ${zone.apex} (re-checked just now) — confirm the zone exists in that account.`, zone }
2454
+ }
2455
+ }
2456
+ return { zone, conn }
2457
+ },
2458
+ [allConnections, verifyAndCache],
2459
+ )
2460
+
2461
+ // ---- DNS record changes (Phase 3: TTL, then value/repoint) ----------------
2462
+
2463
+ const getZoneRecord = useCallback(
2464
+ async (connId: string, apex: string, name: string, type: string): Promise<RecordResult> => {
2465
+ const conn = allConnections.find((c) => c.id === connId)
2466
+ const provider = conn ? recordProviderFor(conn.provider) : null
2467
+ if (!conn || !provider) return { ok: false, zoneId: "", error: "No reachable connection for this zone." }
2468
+ return provider.getRecord(conn.creds, apex, name, type)
2469
+ },
2470
+ [allConnections],
2471
+ )
2472
+
2473
+ const setRecordWrite = (key: string, progress: RecordWriteProgress) =>
2474
+ setRecordWrites((prev) => new Map(prev).set(key, progress))
2475
+
2476
+ // The latest write of a kind for a given hostname+type, if any — so the inventory
2477
+ // can show an "updating"/just-changed status on the matching record cell.
2478
+ const recordWriteForHost = useCallback(
2479
+ (host: string, type: string, kind: RecordEditKind): RecordWriteProgress | undefined => {
2480
+ const h = host.toLowerCase()
2481
+ for (const p of recordWrites.values()) if (p.kind === kind && p.host === h && p.type === type) return p
2482
+ return undefined
2483
+ },
2484
+ [recordWrites],
2485
+ )
2486
+
2487
+ const clearRecordWrite = useCallback(
2488
+ (key: string) =>
2489
+ setRecordWrites((prev) => {
2490
+ if (!prev.has(key)) return prev
2491
+ const next = new Map(prev)
2492
+ next.delete(key)
2493
+ return next
2494
+ }),
2495
+ [],
2496
+ )
2497
+
2498
+ // Shared runner for both single-field record writes (TTL + value): fire the
2499
+ // provider call, then follow an async change (Route 53) to INSYNC. Progress is
2500
+ // stamped with the record's host/type so the inventory can match an in-flight
2501
+ // write back to its row after the editor closes.
2502
+ const runRecordChange = useCallback(
2503
+ (connId: string, record: DnsRecord, base: { kind: RecordEditKind; ttl?: number; value?: string }, exec: (provider: NonNullable<ReturnType<typeof recordProviderFor>>, creds: Record<string, string>) => Promise<{ ok: boolean; pollId?: string; error?: string }>) => {
2504
+ const existing = recordWrites.get(record.key)
2505
+ if (existing && isRecordWriteInFlight(existing)) return // one change per record at a time
2506
+
2507
+ const put = (status: string, error?: string) =>
2508
+ setRecordWrite(record.key, { ...base, status, host: record.name.toLowerCase(), type: record.type, ...(error ? { error } : {}) })
2509
+
2510
+ const conn = allConnections.find((c) => c.id === connId)
2511
+ const provider = conn ? recordProviderFor(conn.provider) : null
2512
+ if (!conn || !provider) {
2513
+ put(WRITE_FAIL, "Lost the provider connection — reopen the records view.")
2514
+ return
2515
+ }
2516
+
2517
+ const run = async () => {
2518
+ put("queued")
2519
+ let res
2520
+ try {
2521
+ res = await exec(provider, conn.creds)
2522
+ } catch (err) {
2523
+ put(WRITE_FAIL, (err as Error).message)
2524
+ return
2525
+ }
2526
+ if (!res.ok) {
2527
+ put(WRITE_FAIL, res.error || "The provider rejected the change.")
2528
+ return
2529
+ }
2530
+ // No poll id (Cloudflare) → already applied. Otherwise poll to INSYNC.
2531
+ if (!res.pollId || !provider.pollChange) {
2532
+ put(WRITE_DONE)
2533
+ return
2534
+ }
2535
+ const pollId = res.pollId
2536
+ put("pending")
2537
+ const poll = async () => {
2538
+ try {
2539
+ const status = await provider.pollChange!(conn.creds, pollId)
2540
+ if (status === "done") put(WRITE_DONE)
2541
+ else if (status === "failed") put(WRITE_FAIL, "The change failed to propagate.")
2542
+ else setTimeout(() => void poll(), WRITE_POLL_MS)
2543
+ } catch (err) {
2544
+ put(WRITE_FAIL, (err as Error).message)
2545
+ }
2546
+ }
2547
+ setTimeout(() => void poll(), WRITE_POLL_MS)
2548
+ }
2549
+ void run()
2550
+ },
2551
+ [allConnections, recordWrites],
2552
+ )
2553
+
2554
+ const startTtlChange = useCallback(
2555
+ (connId: string, zoneId: string, record: DnsRecord, ttl: number) =>
2556
+ runRecordChange(connId, record, { kind: "ttl", ttl }, (provider, creds) => provider.setTtl(creds, zoneId, record, ttl)),
2557
+ [runRecordChange],
2558
+ )
2559
+
2560
+ // The repoint write. setValue is optional on a provider (GoDaddy has none), but
2561
+ // any zone reachable here came through an API-editable connection — the guard is
2562
+ // just belt-and-braces.
2563
+ const startValueChange = useCallback(
2564
+ (connId: string, zoneId: string, record: DnsRecord, value: string) =>
2565
+ runRecordChange(connId, record, { kind: "value", value }, (provider, creds) =>
2566
+ provider.setValue ? provider.setValue(creds, zoneId, record, value) : Promise.resolve({ ok: false, error: "This provider can't repoint records via its API." }),
2567
+ ),
2568
+ [runRecordChange],
2569
+ )
2570
+
2571
+ // ---- Vanity site (connect a fresh, empty server) ----------------------
2572
+
2573
+ // One stable health key per domain — a resume/re-seed must reuse it so a
2574
+ // monitor URL built against the page's ?format=json mode never goes stale.
2575
+ const ensureVanityHealthKey = useCallback((domain: string): string => {
2576
+ const keys = { ...cfgRef.current.vanityHealthKeys }
2577
+ const key = keys[domain] ?? crypto.randomUUID().replace(/-/g, "")
2578
+ if (keys[domain] !== key) {
2579
+ keys[domain] = key
2580
+ cfgRef.current.vanityHealthKeys = keys
2581
+ void saveConfig({ vanityHealthKeys: keys })
2582
+ }
2583
+ return key
2584
+ }, [])
2585
+
2586
+ // Deliberately break the stable-key rule above: mint a NEW key (the rotate
2587
+ // action's whole point — a key shown on a screencast must stop working).
2588
+ // Callers re-seed the page and retarget key-bearing monitor URLs afterwards.
2589
+ const rotateVanityHealthKey = useCallback((domain: string): { oldKey: string | null; newKey: string } => {
2590
+ const keys = { ...cfgRef.current.vanityHealthKeys }
2591
+ const oldKey = keys[domain] ?? null
2592
+ const newKey = crypto.randomUUID().replace(/-/g, "")
2593
+ keys[domain] = newKey
2594
+ cfgRef.current.vanityHealthKeys = keys
2595
+ void saveConfig({ vanityHealthKeys: keys })
2596
+ return { oldKey, newKey }
2597
+ }, [])
2598
+
2599
+ // Keep the Kuma JWT fresh so later sessions log in by token (2FA-friendly).
2600
+ // The env-sourced connection is never overwritten.
2601
+ const persistKumaAuth = useCallback((conn: UptimeKumaConn, jwt: string) => {
2602
+ if (conn.env || jwt === conn.jwt) return
2603
+ cfgRef.current.uptimeKuma = { ...conn, jwt }
2604
+ void saveConfig({ uptimeKuma: { ...conn, jwt } })
2605
+ }, [])
2606
+
2607
+ const persistKumaMonitors = useCallback((domain: string, ref: KumaMonitorRef) => {
2608
+ const map = { ...cfgRef.current.kumaMonitors, [domain]: ref }
2609
+ cfgRef.current.kumaMonitors = map
2610
+ void saveConfig({ kumaMonitors: map })
2611
+ }, [])
2612
+
2613
+ // The step machine. Each step performs its work then hands off to the next; it
2614
+ // re-enters at job.step (so resume continues mid-flight). Steps are written to be
2615
+ // idempotent where they can't re-attach to a remote handle (site/seed), so a
2616
+ // resume never duplicates work.
2617
+ const driveVanity = useCallback(
2618
+ (job: VanityJob) => {
2619
+ const persist = (j: VanityJob) => {
2620
+ setVanityJob(j)
2621
+ // Forget only a truly-finished build. An errored/incomplete one stays
2622
+ // persisted so it's reopenable (and survives a restart) until the user
2623
+ // retries it to completion or explicitly discards it.
2624
+ if (j.step === "done") void removeJob(VANITY_JOB_ID)
2625
+ else void saveJob({ id: VANITY_JOB_ID, kind: "vanity", status: "running", startedAt: j.startedAt, inputs: j })
2626
+ }
2627
+ const fail = (j: VanityJob, step: VanityStep, error: string) => persist({ ...j, step: "error", failedStep: step, error })
2628
+ const apiMsg = (err: unknown) => (err instanceof ApiError ? err.message : (err as Error).message)
2629
+
2630
+ const pollEvent = (eventId: number, step: VanityStep, j: VanityJob, onDone: () => void) => {
2631
+ const poll = async () => {
2632
+ try {
2633
+ const e = await client.getEvent(eventId)
2634
+ if (SERVER_FAIL.has(e.status)) return fail(j, step, e.output?.trim() || `The ${step} step failed on SpinupWP.`)
2635
+ if (SERVER_DONE.has(e.status) || e.finished_at) return onDone()
2636
+ setTimeout(() => void poll(), VANITY_POLL_MS)
2637
+ } catch (err) {
2638
+ fail(j, step, apiMsg(err))
2639
+ }
2640
+ }
2641
+ setTimeout(() => void poll(), VANITY_POLL_MS)
2642
+ }
2643
+
2644
+ async function doDns(j: VanityJob) {
2645
+ persist({ ...j, step: "dns" })
2646
+ let conn = j.connId ? allConnections.find((c) => c.id === j.connId) ?? null : null
2647
+ let provider = conn ? recordProviderFor(conn.provider) : null
2648
+ // No usable connection on the job yet (first run, or a retry after the start
2649
+ // path couldn't match a stale cache) → resolve the zone and re-verify the
2650
+ // provider's accounts, so a zone registered today is picked up. This makes
2651
+ // `r retry` actually recover instead of re-failing against the same cache.
2652
+ if (!conn || !provider?.createRecord) {
2653
+ const r = await resolveZoneConn(j.hostname)
2654
+ if ("error" in r) return fail(j, "dns", r.error)
2655
+ conn = r.conn
2656
+ provider = recordProviderFor(conn.provider)
2657
+ j = { ...j, apex: r.zone.apex, connId: conn.id }
2658
+ persist({ ...j, step: "dns" })
2659
+ if (!provider?.createRecord) return fail(j, "dns", `The ${conn.provider} connection can't write DNS records.`)
2660
+ }
2661
+ // Past the guard both are non-null; bind consts so the poll closure narrows.
2662
+ const dnsConn = conn!
2663
+ const dnsProvider = provider!
2664
+ let res
2665
+ try {
2666
+ res = await dnsProvider.createRecord!(dnsConn.creds, j.apex, j.hostname, "A", j.serverIp, 300)
2667
+ } catch (err) {
2668
+ return fail(j, "dns", (err as Error).message)
2669
+ }
2670
+ if (!res.ok) return fail(j, "dns", res.error || "The DNS provider rejected the A record.")
2671
+ const next: VanityJob = { ...j, step: "propagate", propagateStartedAt: Date.now(), propagateTimedOut: false }
2672
+ if (res.pollId && dnsProvider.pollChange) {
2673
+ const pollId = res.pollId
2674
+ const poll = async () => {
2675
+ try {
2676
+ const st = await dnsProvider.pollChange!(dnsConn.creds, pollId)
2677
+ if (st === "failed") return fail(j, "dns", "The DNS change failed to apply at the provider.")
2678
+ if (st === "done") return void doPropagate(next)
2679
+ setTimeout(() => void poll(), VANITY_POLL_MS)
2680
+ } catch (err) {
2681
+ fail(j, "dns", (err as Error).message)
2682
+ }
2683
+ }
2684
+ setTimeout(() => void poll(), VANITY_POLL_MS)
2685
+ } else {
2686
+ void doPropagate(next)
2687
+ }
2688
+ }
2689
+
2690
+ async function doPropagate(j: VanityJob) {
2691
+ const startedAt = j.propagateStartedAt ?? Date.now()
2692
+ persist({ ...j, step: "propagate", propagateStartedAt: startedAt, propagateTimedOut: false })
2693
+ const check = async () => {
2694
+ if (await aRecordResolves(j.hostname, j.serverIp)) return void doSite({ ...j, step: "site" })
2695
+ // Time out into the skip/keep-waiting prompt ONCE. After the user chooses
2696
+ // to keep waiting (keepWaiting), poll indefinitely — the overlay shows a
2697
+ // count-up from the original start and a key to quit waiting — so we never
2698
+ // re-prompt. startedAt is preserved across keep-waiting (count-up baseline).
2699
+ if (!j.keepWaiting && Date.now() - startedAt > VANITY_PROPAGATE_TIMEOUT_MS) {
2700
+ return persist({ ...j, step: "propagate", propagateStartedAt: startedAt, propagateTimedOut: true })
2701
+ }
2702
+ setTimeout(() => void check(), VANITY_POLL_MS)
2703
+ }
2704
+ void check()
2705
+ }
2706
+
2707
+ async function doSite(j: VanityJob) {
2708
+ persist({ ...j, step: "site", propagateTimedOut: false })
2709
+ // Idempotent: if the site already exists (e.g. resuming), reuse it.
2710
+ let existing
2711
+ try {
2712
+ existing = (await client.listSites(j.serverId)).find((s) => s.domain === j.hostname)
2713
+ } catch {
2714
+ /* fall through to create */
2715
+ }
2716
+ const afterSite = (siteId: number | undefined, httpsOn: boolean) => {
2717
+ const withSite = { ...j, siteId }
2718
+ if (j.sslSkipped || httpsOn) return void doSshkey({ ...withSite, step: "sshkey" })
2719
+ return void doHttps({ ...withSite, step: "https" })
2720
+ }
2721
+ if (existing) return afterSite(existing.id, existing.https?.enabled === true)
2722
+ let ev
2723
+ try {
2724
+ ev = await client.createSite({ server_id: j.serverId, domain: j.hostname, site_user: j.siteUser, installation_method: "blank" })
2725
+ } catch (err) {
2726
+ return fail(j, "site", apiMsg(err))
2727
+ }
2728
+ pollEvent(ev.event_id, "site", j, async () => {
2729
+ await refresh()
2730
+ let siteId: number | undefined
2731
+ try {
2732
+ siteId = (await client.listSites(j.serverId)).find((s) => s.domain === j.hostname)?.id
2733
+ } catch {
2734
+ /* https step guards on a missing siteId */
2735
+ }
2736
+ afterSite(siteId, false)
2737
+ })
2738
+ }
2739
+
2740
+ async function doHttps(j: VanityJob) {
2741
+ if (!j.siteId) return fail(j, "https", "Couldn't find the new site to enable HTTPS — open it in SpinupWP to add SSL.")
2742
+ persist({ ...j, step: "https" })
2743
+ let ev
2744
+ try {
2745
+ ev = await client.enableHttps(j.siteId)
2746
+ } catch (err) {
2747
+ return fail(j, "https", apiMsg(err))
2748
+ }
2749
+ pollEvent(ev.event_id, "https", j, () => void doSshkey({ ...j, step: "sshkey" }))
2750
+ }
2751
+
2752
+ // Manual park: the user adds their SSH key in SpinupWP (deep-link), then
2753
+ // advances via vanitySshKeyDone(). We just surface the step.
2754
+ function doSshkey(j: VanityJob) {
2755
+ persist({ ...j, step: "sshkey" })
2756
+ }
2757
+
2758
+ async function doSeed(j: VanityJob) {
2759
+ persist({ ...j, step: "seed" })
2760
+ const healthKey = ensureVanityHealthKey(j.hostname)
2761
+ const res = await seedVanityIndex({ host: j.serverIp, user: j.siteUser, port: j.port, domain: j.hostname, publicFolder: j.publicFolder, healthKey })
2762
+ if (!res.ok) return fail(j, "seed", res.error || "Couldn't seed index.php over SSH.")
2763
+ await refresh()
2764
+ void doMonitor({ ...j, step: "monitor" })
2765
+ }
2766
+
2767
+ // Register the two Uptime Kuma monitors (healthz HTTP + load push) via the
2768
+ // shared registerMonitors (same code path as the m overlay). Auto-skips to
2769
+ // done when no Kuma connection is configured (or in Dev Mode — never touch
2770
+ // a real Kuma from a demo).
2771
+ async function doMonitor(j: VanityJob) {
2772
+ const conn = cfgRef.current.uptimeKuma
2773
+ if (!conn || isDevMode()) return persist({ ...j, step: "done" })
2774
+ persist({ ...j, step: "monitor" })
2775
+ try {
2776
+ const known = cfgRef.current.kumaMonitors[j.hostname] ?? {}
2777
+ const { result, jwt } = await withKuma(conn, (kuma) =>
2778
+ registerMonitors(kuma, j.hostname, {
2779
+ proto: j.sslSkipped ? "http" : "https",
2780
+ healthzPath: "/?healthz",
2781
+ wantPush: true,
2782
+ known,
2783
+ pushToken: j.kumaPushToken,
2784
+ }),
2785
+ )
2786
+ persistKumaAuth(conn, jwt)
2787
+ persistKumaMonitors(j.hostname, { ...known, healthId: result.healthId, pushId: result.pushId ?? known.pushId, pushToken: result.pushToken ?? known.pushToken })
2788
+ void doCron({ ...j, step: "cron", kumaPushToken: result.pushToken ?? known.pushToken })
2789
+ } catch (err) {
2790
+ fail(j, "monitor", (err as Error).message)
2791
+ }
2792
+ }
2793
+
2794
+ // Install the once-a-minute heartbeat cron in the site user's crontab (feeds
2795
+ // the push monitor: load value graphed, silence = dead-man's switch).
2796
+ async function doCron(j: VanityJob) {
2797
+ const conn = cfgRef.current.uptimeKuma
2798
+ if (!conn || !j.kumaPushToken) return persist({ ...j, step: "done" })
2799
+ persist({ ...j, step: "cron" })
2800
+ const res = await seedVanityPushCron({ host: j.serverIp, user: j.siteUser, port: j.port, kumaUrl: conn.url, pushToken: j.kumaPushToken })
2801
+ if (!res.ok) return fail(j, "cron", res.error || "Couldn't install the heartbeat cron.")
2802
+ persist({ ...j, step: "done" })
2803
+ }
2804
+
2805
+ switch (job.step) {
2806
+ case "dns":
2807
+ return void doDns(job)
2808
+ case "propagate":
2809
+ return void doPropagate(job)
2810
+ case "site":
2811
+ return void doSite(job)
2812
+ case "https":
2813
+ return void doHttps(job)
2814
+ case "sshkey":
2815
+ return doSshkey(job)
2816
+ case "seed":
2817
+ return void doSeed(job)
2818
+ case "monitor":
2819
+ return void doMonitor(job)
2820
+ case "cron":
2821
+ return void doCron(job)
2822
+ default:
2823
+ return
2824
+ }
2825
+ },
2826
+ [client, allConnections, refresh, resolveZoneConn, ensureVanityHealthKey, persistKumaAuth, persistKumaMonitors],
2827
+ )
2828
+
2829
+ const startVanity = useCallback(
2830
+ (server: Server, opts: { siteUser: string; skipSsl?: boolean }) => {
2831
+ if (vanityJob && isVanityInFlight(vanityJob)) return // one vanity build at a time
2832
+ const startedAt = Date.now()
2833
+ const base: VanityJob = {
2834
+ serverId: server.id,
2835
+ serverIp: server.ip_address ?? "",
2836
+ hostname: server.name,
2837
+ apex: "",
2838
+ siteUser: opts.siteUser,
2839
+ publicFolder: "/",
2840
+ port: server.ssh_port ?? null,
2841
+ step: "dns",
2842
+ startedAt,
2843
+ sslSkipped: opts.skipSsl ?? false,
2844
+ }
2845
+ const run = async () => {
2846
+ // Resolve the zone + its editable account, re-verifying a stale cache if
2847
+ // needed (e.g. a zone registered today). On success we capture the apex +
2848
+ // connId and let driveVanity write the A record; on failure the message
2849
+ // already says whether to connect an account or fix the zone.
2850
+ const r = await resolveZoneConn(server.name)
2851
+ if ("error" in r) return setVanityJob({ ...base, step: "error", failedStep: "dns", error: r.error })
2852
+ driveVanity({ ...base, apex: r.zone.apex, connId: r.conn.id })
2853
+ }
2854
+ void run()
2855
+ },
2856
+ [vanityJob, resolveZoneConn, driveVanity],
2857
+ )
2858
+
2859
+ const vanitySshKeyDone = useCallback(() => {
2860
+ if (!vanityJob || vanityJob.step !== "sshkey") return
2861
+ const next: VanityJob = { ...vanityJob, step: "seed" }
2862
+ setVanityJob(next)
2863
+ driveVanity(next)
2864
+ }, [vanityJob, driveVanity])
2865
+
2866
+ // From the propagation-timeout prompt: skip SSL → create the site now and bypass
2867
+ // the HTTPS step (SSL can be added later in SpinupWP).
2868
+ const vanitySkipSsl = useCallback(() => {
2869
+ if (!vanityJob || vanityJob.step !== "propagate") return
2870
+ const next: VanityJob = { ...vanityJob, sslSkipped: true, propagateTimedOut: false, step: "site" }
2871
+ setVanityJob(next)
2872
+ driveVanity(next)
2873
+ }, [vanityJob, driveVanity])
2874
+
2875
+ // From the timeout prompt: keep waiting. Preserve the ORIGINAL propagateStartedAt
2876
+ // so the overlay's timer becomes a count-up from the 2-min baseline, and set
2877
+ // keepWaiting so doPropagate polls indefinitely without re-prompting.
2878
+ const vanityKeepWaiting = useCallback(() => {
2879
+ if (!vanityJob || vanityJob.step !== "propagate") return
2880
+ const next: VanityJob = { ...vanityJob, propagateTimedOut: false, keepWaiting: true }
2881
+ setVanityJob(next)
2882
+ driveVanity(next)
2883
+ }, [vanityJob, driveVanity])
2884
+
2885
+ // From keep-waiting mode: stop waiting and continue the normal flow (create the
2886
+ // site → attempt HTTPS). If DNS truly isn't resolved yet HTTPS may fail — the
2887
+ // deliberate HTTP-only path is the separate "skip SSL" choice.
2888
+ const vanityStopWaiting = useCallback(() => {
2889
+ if (!vanityJob || vanityJob.step !== "propagate") return
2890
+ const next: VanityJob = { ...vanityJob, propagateTimedOut: false, keepWaiting: false, step: "site" }
2891
+ setVanityJob(next)
2892
+ driveVanity(next)
2893
+ }, [vanityJob, driveVanity])
2894
+
2895
+ const vanityRetry = useCallback(() => {
2896
+ if (!vanityJob || vanityJob.step !== "error") return
2897
+ const next: VanityJob = { ...vanityJob, step: vanityJob.failedStep ?? "dns", error: undefined, failedStep: undefined }
2898
+ setVanityJob(next)
2899
+ driveVanity(next)
2900
+ }, [vanityJob, driveVanity])
2901
+
2902
+ // A Kuma monitor/cron failure shouldn't hold the build hostage — the site itself
2903
+ // is already live. Skip settles the job as done; monitors can be added later.
2904
+ const vanitySkipKuma = useCallback(() => {
2905
+ if (!vanityJob || vanityJob.step !== "error") return
2906
+ if (vanityJob.failedStep !== "monitor" && vanityJob.failedStep !== "cron") return
2907
+ setVanityJob({ ...vanityJob, step: "done", error: undefined, failedStep: undefined })
2908
+ void removeJob(VANITY_JOB_ID)
2909
+ }, [vanityJob])
2910
+
2911
+ const clearVanity = useCallback(() => {
2912
+ setVanityJob(null)
2913
+ void removeJob(VANITY_JOB_ID)
2914
+ }, [])
2915
+
2916
+ // Reads through cfgRef (not reactive state): the key is written by doSeed before
2917
+ // the job reaches "done", so any render that can show it already sees it.
2918
+ const vanityHealthKeyFor = useCallback((domain: string) => cfgRef.current.vanityHealthKeys[domain] ?? null, [])
2919
+
2920
+ // ---- Uptime Kuma (connect + per-site monitor setup) ----------------------
2921
+
2922
+ const [kumaSite, setKumaSite] = useState<Site | null>(null)
2923
+ const [kumaOps, setKumaOps] = useState<Map<number, KumaOp>>(new Map())
2924
+ const [kumaStatus, setKumaStatus] = useState<Map<string, KumaDomainStatus>>(new Map())
2925
+ // Bumped when a connection is added mid-session so the poll loop (re)starts.
2926
+ const [kumaEpoch, setKumaEpoch] = useState(0)
2927
+
2928
+ // Poll Kuma every minute for the registered monitors' status: one short-lived
2929
+ // connection per tick; the post-login event burst (beats + uptime) is the
2930
+ // snapshot. Kuma being unreachable keeps the last snapshot (stale > empty).
2931
+ // Never runs in Dev Mode — a demo must not touch (or leak) the real Kuma.
2932
+ useEffect(() => {
2933
+ if (isDevMode()) return
2934
+ const conn = cfgRef.current.uptimeKuma
2935
+ if (!conn) return
2936
+ let cancelled = false
2937
+ let timer: ReturnType<typeof setTimeout> | undefined
2938
+ const tick = async () => {
2939
+ // Nothing registered → nothing to show; skip the connection entirely
2940
+ // (re-checked each tick, since registration doesn't bump kumaEpoch).
2941
+ if (Object.keys(cfgRef.current.kumaMonitors).length === 0) {
2942
+ if (!cancelled) timer = setTimeout(() => void tick(), KUMA_POLL_MS)
2943
+ return
2944
+ }
2945
+ try {
2946
+ const { result } = await withKuma(conn, async (kuma) => {
2947
+ const monitors = await kuma.getMonitors()
2948
+ await kuma.waitForSnapshot(monitors.length)
2949
+ const byId = new Map(monitors.map((m) => [m.id, m]))
2950
+ const map = new Map<string, KumaDomainStatus>()
2951
+ // A beat is "down" only at status 0 — pending (2) and maintenance (3)
2952
+ // must not raise the down badge (that would false-alarm during the
2953
+ // very maintenance windows we create around reboots).
2954
+ const beatUp = (s: number) => s !== 0
2955
+ for (const [domain, ref] of Object.entries(cfgRef.current.kumaMonitors)) {
2956
+ const status: KumaDomainStatus = { up: null, uptime24: null, responseBeats: [], loadBeats: [], lastLoad: null, fingerprintUp: null, redisUp: null }
2957
+ if (ref.healthId && byId.has(ref.healthId)) {
2958
+ const beats = kuma.beatsFor(ref.healthId)
2959
+ const last = beats[beats.length - 1]
2960
+ status.up = last ? beatUp(last.status) : null
2961
+ status.responseBeats = beats.map((b) => Number(b.ping) || 0)
2962
+ status.uptime24 = kuma.uptimeFor(ref.healthId, 24)
2963
+ }
2964
+ if (ref.pushId && byId.has(ref.pushId)) {
2965
+ const beats = kuma.beatsFor(ref.pushId)
2966
+ // The cron pushes load ×LOAD_PUSH_SCALE as an integer (some Kuma
2967
+ // builds drop float pings) — scale back to real load here.
2968
+ status.loadBeats = beats.map((b) => (Number(b.ping) || 0) / LOAD_PUSH_SCALE)
2969
+ const last = beats[beats.length - 1]
2970
+ status.lastLoad = last?.ping != null ? Number(last.ping) / LOAD_PUSH_SCALE : null
2971
+ if (status.up === null && last) status.up = beatUp(last.status)
2972
+ }
2973
+ if (ref.fingerprintId && byId.has(ref.fingerprintId)) {
2974
+ const beats = kuma.beatsFor(ref.fingerprintId)
2975
+ const last = beats[beats.length - 1]
2976
+ status.fingerprintUp = last ? beatUp(last.status) : null
2977
+ }
2978
+ if (ref.redisId && byId.has(ref.redisId)) {
2979
+ const beats = kuma.beatsFor(ref.redisId)
2980
+ const last = beats[beats.length - 1]
2981
+ status.redisUp = last ? beatUp(last.status) : null
2982
+ }
2983
+ map.set(domain, status)
2984
+ }
2985
+ return map
2986
+ })
2987
+ if (!cancelled) setKumaStatus(result)
2988
+ } catch {
2989
+ // Unreachable this tick — retry on the next one.
2990
+ }
2991
+ if (!cancelled) timer = setTimeout(() => void tick(), KUMA_POLL_MS)
2992
+ }
2993
+ void tick()
2994
+ return () => {
2995
+ cancelled = true
2996
+ if (timer) clearTimeout(timer)
2997
+ }
2998
+ }, [kumaEpoch])
2999
+
3000
+ // Verify a Kuma connection by actually logging in, then persist it (with the
3001
+ // minted JWT, so later sessions use loginByToken). 2FA accounts: the first
3002
+ // attempt comes back `tokenRequired`, the view reveals a code field, and the
3003
+ // retry carries the TOTP — needed exactly once, since every later login uses
3004
+ // the stored JWT. The env-sourced connection is never overwritten.
3005
+ const connectKuma = useCallback(
3006
+ async (conn: { url: string; username: string; password: string }, twofa?: string): Promise<{ ok: true; version: string | null } | { ok: false; error: string; tokenRequired?: boolean }> => {
3007
+ if (isDevMode()) return { ok: false, error: "Dev Mode never talks to a real Uptime Kuma." }
3008
+ try {
3009
+ const url = conn.url.trim().replace(/\/+$/, "")
3010
+ const kuma = await KumaClient.connect(url)
3011
+ try {
3012
+ const login = await kuma.login({ username: conn.username, password: conn.password, url }, twofa)
3013
+ if (!login.ok) return { ok: false, error: login.error, tokenRequired: login.tokenRequired }
3014
+ const stored = { url, username: conn.username, password: conn.password, jwt: login.jwt }
3015
+ cfgRef.current.uptimeKuma = stored
3016
+ void saveConfig({ uptimeKuma: stored })
3017
+ setKumaEpoch((e) => e + 1) // start the status poll loop now
3018
+ await kuma.waitForVersion() // `info` can land after the login ack
3019
+ return { ok: true, version: kuma.version }
3020
+ } finally {
3021
+ kuma.disconnect()
3022
+ }
3023
+ } catch (err) {
3024
+ return { ok: false, error: (err as Error).message }
3025
+ }
3026
+ },
3027
+ [],
3028
+ )
3029
+
3030
+ const kumaMonitorFor = useCallback((domain: string) => cfgRef.current.kumaMonitors[domain] ?? null, [])
3031
+
3032
+ // Drop a site's settled (done/error) op so the overlay opens fresh next time —
3033
+ // without this, a finished op's message replaces the action menu forever.
3034
+ // Callers must not clear RUNNING ops (esc backgrounds those deliberately).
3035
+ const clearKumaOp = useCallback((siteId: number) => {
3036
+ setKumaOps((prev) => {
3037
+ if (!prev.has(siteId)) return prev
3038
+ const next = new Map(prev)
3039
+ next.delete(siteId)
3040
+ return next
3041
+ })
3042
+ }, [])
3043
+
3044
+ // Push the current embedded page (with its health-endpoint modes) into an
3045
+ // existing vanity site's docroot, minting/reusing the domain's stable health
3046
+ // key. Shared by the standalone refresh and the Kuma setup's reseed option.
3047
+ const reseedVanityPage = useCallback(
3048
+ async (site: Site, server: Server) => {
3049
+ const domain = site.domain
3050
+ const healthKey = ensureVanityHealthKey(domain)
3051
+ return seedVanityIndex({ host: server.ip_address ?? "", user: site.site_user ?? deriveSiteUser(domain), port: server.ssh_port ?? null, domain, publicFolder: site.public_folder, healthKey })
3052
+ },
3053
+ [ensureVanityHealthKey],
3054
+ )
3055
+
3056
+ // Standalone vanity-page refresh — no Uptime Kuma involvement, works without a
3057
+ // connection. This is how a page seeded by an older Spinup gets the current
3058
+ // version (health endpoints included).
3059
+ const startVanityReseed = useCallback(
3060
+ (site: Site) => {
3061
+ const server = servers.find((s) => s.id === site.server_id)
3062
+ if (!server) return
3063
+ const setOp = (op: KumaOp) => setKumaOps((prev) => new Map(prev).set(site.id, op))
3064
+ if (!server.ip_address) {
3065
+ setOp({ status: "error", detail: "", error: "The server has no IP address yet — wait for provisioning to finish." })
3066
+ return
3067
+ }
3068
+ setOp({ status: "running", detail: "Publishing the current page…" })
3069
+ void reseedVanityPage(site, server).then((res) => {
3070
+ if (res.ok) setOp({ status: "done", detail: "Page re-published — health endpoints are live." })
3071
+ else setOp({ status: "error", detail: "", error: res.error || "Couldn't re-publish the page over SSH." })
3072
+ })
3073
+ },
3074
+ [servers, reseedVanityPage],
3075
+ )
3076
+
3077
+ // Set up monitoring for an existing site. A vanity site (domain = server name)
3078
+ // gets the full treatment — optional page re-seed (the upgrade path for pages
3079
+ // published before the health-endpoint feature), healthz monitor, load push
3080
+ // monitor + heartbeat cron. A regular site gets a plain HTTPS monitor on its
3081
+ // homepage (up/down + cert expiry); we never touch a client site's files.
3082
+ const startKumaSetup = useCallback(
3083
+ (site: Site, opts: { reseed?: boolean } = {}) => {
3084
+ const conn = cfgRef.current.uptimeKuma
3085
+ if (!conn || isDevMode()) return
3086
+ const server = servers.find((s) => s.id === site.server_id)
3087
+ const isVanity = !!server && isVanityPair(site.domain, server.name)
3088
+ const setOp = (op: KumaOp) => setKumaOps((prev) => new Map(prev).set(site.id, op))
3089
+ // The vanity treatment SSHes into the box (re-seed + cron) — that needs a
3090
+ // real IP, so fail with a plain precondition instead of a cryptic ssh error.
3091
+ if (isVanity && server && !server.ip_address) {
3092
+ setOp({ status: "error", detail: "", error: "The server has no IP address yet — wait for provisioning to finish." })
3093
+ return
3094
+ }
3095
+ const run = async () => {
3096
+ try {
3097
+ const domain = site.domain
3098
+ const ssh = { host: server?.ip_address ?? "", user: site.site_user ?? deriveSiteUser(domain), port: server?.ssh_port ?? null }
3099
+ if (isVanity && opts.reseed && server) {
3100
+ setOp({ status: "running", detail: "Publishing the updated page…" })
3101
+ const seed = await reseedVanityPage(site, server)
3102
+ if (!seed.ok) throw new Error(seed.error || "Couldn't re-seed the page over SSH.")
3103
+ }
3104
+ // The Redis sentinel is server-wide, so it rides the vanity treatment.
3105
+ // Probe redis-cli BEFORE creating its monitor: a box where Redis isn't
3106
+ // answerable would otherwise get a monitor that's red forever.
3107
+ let wantRedis = false
3108
+ let redisNote: string | null = null
3109
+ if (isVanity && server) {
3110
+ setOp({ status: "running", detail: "Checking Redis on the server…" })
3111
+ const probe = await probeServerRedis(ssh)
3112
+ wantRedis = probe.ok
3113
+ if (!probe.ok) redisNote = probe.error ?? "Redis sentinel skipped."
3114
+ }
3115
+ setOp({ status: "running", detail: "Registering monitors in Uptime Kuma…" })
3116
+ const known = cfgRef.current.kumaMonitors[domain] ?? {}
3117
+ // Monitor over the protocol the site actually serves — an https monitor
3118
+ // on an http-only site would be permanently down.
3119
+ const proto = site.https?.enabled ? "https" : "http"
3120
+ const { result, jwt } = await withKuma(conn, (kuma) =>
3121
+ registerMonitors(kuma, domain, { proto, healthzPath: isVanity ? "/?healthz" : "/", wantPush: isVanity, wantRedis, known }),
3122
+ )
3123
+ persistKumaAuth(conn, jwt)
3124
+ persistKumaMonitors(domain, {
3125
+ ...known,
3126
+ healthId: result.healthId,
3127
+ pushId: result.pushId ?? known.pushId,
3128
+ pushToken: result.pushToken ?? known.pushToken,
3129
+ redisId: result.redisId ?? known.redisId,
3130
+ redisToken: result.redisToken ?? known.redisToken,
3131
+ })
3132
+ if (isVanity && result.pushId && result.pushToken && server) {
3133
+ setOp({ status: "running", detail: "Installing the heartbeat cron…" })
3134
+ const cron = await seedVanityPushCron({ ...ssh, kumaUrl: conn.url, pushToken: result.pushToken, redisToken: result.redisId != null ? (result.redisToken ?? known.redisToken ?? null) : null })
3135
+ if (!cron.ok) throw new Error(cron.error || "Couldn't install the heartbeat cron.")
3136
+ }
3137
+ setOp({
3138
+ status: "done",
3139
+ detail: isVanity
3140
+ ? `Monitors live: healthz checks + load graph${result.redisId != null ? " + Redis sentinel" : ""} (cron beating every minute).${redisNote ? ` ${redisNote}` : ""}`
3141
+ : "Monitor live: homepage checks + certificate-expiry alerts.",
3142
+ })
3143
+ } catch (err) {
3144
+ setOp({ status: "error", detail: "", error: (err as Error).message })
3145
+ }
3146
+ }
3147
+ void run()
3148
+ },
3149
+ [servers, reseedVanityPage, persistKumaAuth, persistKumaMonitors],
3150
+ )
3151
+
3152
+ // Calibrate + register the front-page fingerprint monitor (site monitoring
3153
+ // Phase 1). Reads the LIVE front page — presumed healthy right now — derives a
3154
+ // template-identity keyword (body class / canonical; survives copy edits,
3155
+ // unlike hand-picked headline keywords), and registers a Kuma keyword monitor
3156
+ // asserting it at the chosen check window. Re-running recalibrates: the
3157
+ // existing monitor row is edited in place, so its history survives a redesign.
3158
+ const startFingerprintSetup = useCallback(
3159
+ (site: Site, intervalSec: number) => {
3160
+ const conn = cfgRef.current.uptimeKuma
3161
+ if (!conn || isDevMode()) return
3162
+ const setOp = (op: KumaOp) => setKumaOps((prev) => new Map(prev).set(site.id, op))
3163
+ const run = async () => {
3164
+ try {
3165
+ const domain = site.domain
3166
+ const proto = site.https?.enabled ? "https" : "http"
3167
+ setOp({ status: "running", detail: "Reading the live front page…" })
3168
+ const derived = await deriveFingerprint(`${proto}://${domain}/`)
3169
+ if (!derived.ok) throw new Error(derived.error)
3170
+ setOp({ status: "running", detail: "Registering the front-page monitor…" })
3171
+ const known = cfgRef.current.kumaMonitors[domain] ?? {}
3172
+ const { result: fingerprintId, jwt } = await withKuma(conn, (kuma) =>
3173
+ registerFingerprintMonitor(kuma, domain, { proto, keyword: derived.fingerprint.keyword, interval: intervalSec, knownId: known.fingerprintId }),
3174
+ )
3175
+ persistKumaAuth(conn, jwt)
3176
+ persistKumaMonitors(domain, {
3177
+ ...known,
3178
+ fingerprintId,
3179
+ fingerprint: { ...derived.fingerprint, interval: intervalSec, derivedAt: new Date().toISOString() },
3180
+ })
3181
+ const win = intervalSec % 3600 === 0 ? `${intervalSec / 3600}h` : `${Math.round(intervalSec / 60)}m`
3182
+ setOp({
3183
+ status: "done",
3184
+ detail: `Front-page check live — asserts ${derived.fingerprint.detail} every ${win}${derived.validated ? "" : ` (${derived.note ?? "unvalidated"})`}.`,
3185
+ })
3186
+ } catch (err) {
3187
+ setOp({ status: "error", detail: "", error: (err as Error).message })
3188
+ }
3189
+ }
3190
+ void run()
3191
+ },
3192
+ [persistKumaAuth, persistKumaMonitors],
3193
+ )
3194
+
3195
+ // Load the alert wiring for one site: every notification provider configured
3196
+ // in Kuma, and whether each is attached to this site's Spinup monitors. The
3197
+ // view calls this on demand (`n` in the overlay) — it's a live read, not
3198
+ // cached state, so what's shown always reflects Kuma right now.
3199
+ const fetchKumaAlerts = useCallback(
3200
+ async (site: Site): Promise<{ ok: true; providers: KumaAlertProvider[]; monitorIds: number[] } | { ok: false; error: string }> => {
3201
+ const conn = cfgRef.current.uptimeKuma
3202
+ if (isDevMode()) return { ok: false, error: "Dev Mode never talks to a real Uptime Kuma." }
3203
+ if (!conn) return { ok: false, error: "Connect Uptime Kuma first (c)." }
3204
+ const ref = cfgRef.current.kumaMonitors[site.domain] ?? {}
3205
+ // Every monitor registered for this site: any numeric `*Id` field on the
3206
+ // ref (healthId, pushId, redisId, fingerprintId, …). Collected by
3207
+ // convention rather than by name so new monitor kinds are alert-wired
3208
+ // automatically — see the KumaMonitorRef note in config.ts.
3209
+ const candidates = Object.entries(ref)
3210
+ .filter((e): e is [string, number] => e[0].endsWith("Id") && typeof e[1] === "number")
3211
+ .map(([, id]) => id)
3212
+ if (candidates.length === 0) return { ok: false, error: "No monitors registered for this site yet — a or f adds one." }
3213
+ try {
3214
+ const { result, jwt } = await withKuma(conn, async (kuma) => {
3215
+ const monitors = await kuma.getMonitors()
3216
+ const byId = new Map(monitors.map((m) => [m.id, m]))
3217
+ const ids = candidates.filter((id) => byId.has(id))
3218
+ if (ids.length === 0) throw new Error("The recorded monitors no longer exist in Kuma — a or f re-creates them.")
3219
+ const notifs = await kuma.getNotifications()
3220
+ const attachCount = new Map<number, number>()
3221
+ for (const id of ids) {
3222
+ const full = await kuma.getMonitor(id)
3223
+ const list = (full.notificationIDList as Record<string, boolean> | undefined) ?? {}
3224
+ for (const [pid, on] of Object.entries(list)) {
3225
+ if (on) attachCount.set(Number(pid), (attachCount.get(Number(pid)) ?? 0) + 1)
3226
+ }
3227
+ }
3228
+ const providers: KumaAlertProvider[] = notifs.map((n) => ({
3229
+ id: n.id,
3230
+ name: n.name,
3231
+ active: n.active,
3232
+ attachedAll: (attachCount.get(n.id) ?? 0) === ids.length,
3233
+ attachedAny: (attachCount.get(n.id) ?? 0) > 0,
3234
+ }))
3235
+ return { providers, monitorIds: ids }
3236
+ })
3237
+ persistKumaAuth(conn, jwt)
3238
+ return { ok: true, ...result }
3239
+ } catch (err) {
3240
+ return { ok: false, error: (err as Error).message }
3241
+ }
3242
+ },
3243
+ [persistKumaAuth],
3244
+ )
3245
+
3246
+ // Attach/detach one provider across all of a site's Spinup monitors — the
3247
+ // write half of the `n` overlay step. Edit-in-place per monitor (history,
3248
+ // tags and other notification wiring survive).
3249
+ const toggleKumaAlert = useCallback(
3250
+ async (site: Site, providerId: number, on: boolean, monitorIds: number[]): Promise<{ ok: true } | { ok: false; error: string }> => {
3251
+ const conn = cfgRef.current.uptimeKuma
3252
+ if (!conn || isDevMode()) return { ok: false, error: "Connect Uptime Kuma first (c)." }
3253
+ try {
3254
+ const { jwt } = await withKuma(conn, (kuma) => setMonitorNotifications(kuma, monitorIds, providerId, on))
3255
+ persistKumaAuth(conn, jwt)
3256
+ return { ok: true }
3257
+ } catch (err) {
3258
+ return { ok: false, error: (err as Error).message }
3259
+ }
3260
+ },
3261
+ [persistKumaAuth],
3262
+ )
3263
+
3264
+ // Rotate a vanity site's monitoring secrets — the "clean up after a screencast"
3265
+ // action. A new push token is edited into the EXISTING push monitor (same row:
3266
+ // history, uptime stats and notifications survive), the heartbeat cron is
3267
+ // rewritten to the new URL, a new health key is re-seeded into the page, and any
3268
+ // monitor URL still carrying the old key is retargeted. Each old secret is dead
3269
+ // the moment its step lands. Works without a Kuma connection too — then only
3270
+ // the health key rotates (there's no push monitor to edit).
3271
+ const startKumaRotate = useCallback(
3272
+ (site: Site) => {
3273
+ const server = servers.find((s) => s.id === site.server_id)
3274
+ if (!server || !isVanityPair(site.domain, server.name)) return
3275
+ const setOp = (op: KumaOp) => setKumaOps((prev) => new Map(prev).set(site.id, op))
3276
+ if (!server.ip_address) {
3277
+ setOp({ status: "error", detail: "", error: "The server has no IP address yet — wait for provisioning to finish." })
3278
+ return
3279
+ }
3280
+ const conn = cfgRef.current.uptimeKuma
3281
+ const domain = site.domain
3282
+ const known = cfgRef.current.kumaMonitors[domain] ?? {}
3283
+ const ssh = { host: server.ip_address ?? "", user: site.site_user ?? deriveSiteUser(domain), port: server.ssh_port ?? null }
3284
+ const run = async () => {
3285
+ try {
3286
+ setOp({ status: "running", detail: "Minting a new health key + re-publishing the page…" })
3287
+ const { oldKey, newKey } = rotateVanityHealthKey(domain)
3288
+ const seed = await seedVanityIndex({ ...ssh, domain, publicFolder: site.public_folder, healthKey: newKey })
3289
+ if (!seed.ok) throw new Error(seed.error || "Couldn't re-seed the page over SSH.")
3290
+ if (!conn || isDevMode() || known.pushId == null) {
3291
+ setOp({
3292
+ status: "done",
3293
+ detail: conn && !isDevMode() ? "Health key rotated (no push monitor registered — a adds one)." : "Health key rotated. Connect Uptime Kuma to rotate the push token too.",
3294
+ })
3295
+ return
3296
+ }
3297
+ setOp({ status: "running", detail: "Rotating the push token in Uptime Kuma…" })
3298
+ const newToken = genPushToken()
3299
+ // The Redis sentinel has its own push URL — rotate it in the same pass
3300
+ // so no cron-embedded secret survives the rotation.
3301
+ const newRedisToken = known.redisId != null ? genPushToken() : null
3302
+ const { result: retargeted, jwt } = await withKuma(conn, async (kuma) => {
3303
+ await rotatePushToken(kuma, known.pushId!, newToken)
3304
+ if (known.redisId != null && newRedisToken) await rotatePushToken(kuma, known.redisId, newRedisToken)
3305
+ return oldKey ? retargetHealthKeyMonitors(kuma, oldKey, newKey) : 0
3306
+ })
3307
+ persistKumaAuth(conn, jwt)
3308
+ persistKumaMonitors(domain, { ...known, pushToken: newToken, redisToken: newRedisToken ?? known.redisToken })
3309
+ setOp({ status: "running", detail: "Rewriting the heartbeat cron…" })
3310
+ const cron = await seedVanityPushCron({ ...ssh, kumaUrl: conn.url, pushToken: newToken, redisToken: newRedisToken })
3311
+ if (!cron.ok) throw new Error(cron.error || "Couldn't rewrite the heartbeat cron.")
3312
+ setOp({ status: "done", detail: `Secrets rotated — the old push URL & health key are dead${retargeted ? ` (${retargeted} monitor URL${retargeted === 1 ? "" : "s"} re-keyed)` : ""}.` })
3313
+ } catch (err) {
3314
+ setOp({ status: "error", detail: "", error: (err as Error).message })
3315
+ }
3316
+ }
3317
+ void run()
3318
+ },
3319
+ [servers, rotateVanityHealthKey, persistKumaAuth, persistKumaMonitors],
3320
+ )
3321
+
3322
+ // ---- Clone a server to a new server (item 5) -----------------------------
3323
+
3324
+ // Build the Plan draft from the source server: every site becomes a row, all
3325
+ // selected by default, stack detected from git.repo. The draft lives in memory
3326
+ // (step "plan") — nothing is created or persisted until the user commits at the
3327
+ // New-server step (the first prod write, hard-gated).
3328
+ const beginClone = useCallback(
3329
+ (server: Server) => {
3330
+ // A clone is already in flight (possibly backgrounded): reopen IT rather than
3331
+ // start/clobber another — pressing C on any server brings back the live one.
3332
+ if (cloneJob && isCloneInFlight(cloneJob)) {
3333
+ setCloneServer(servers.find((s) => s.id === cloneJob.sourceServerId) ?? server)
3334
+ return
3335
+ }
3336
+ const srcSites = sitesForServer(server.id)
3337
+ const sites: CloneSiteState[] = srcSites.map((s) => {
3338
+ const stack = cloneStackFor(s)
3339
+ const isWordPress = s.is_wordpress
3340
+ return {
3341
+ sourceSiteId: s.id,
3342
+ domain: s.domain,
3343
+ siteUser: s.site_user ?? "",
3344
+ // files-only sites (redirect shells, placeholders) are cloneable but opt-in —
3345
+ // they're often vanity/placeholder sites nobody wants copied.
3346
+ selected: stack !== "files",
3347
+ isWordPress,
3348
+ stack,
3349
+ gitRepo: s.git?.repo ?? undefined,
3350
+ gitBranch: s.git?.branch ?? undefined,
3351
+ additionalDomains: (s.additional_domains ?? []).map((d) => d.domain),
3352
+ additionalDomainConfigs: s.additional_domains ?? [],
3353
+ excludeUploads: false,
3354
+ phpVersion: s.php_version ?? undefined,
3355
+ publicFolder: s.public_folder ?? undefined,
3356
+ tablePrefix: s.database?.table_prefix ?? undefined,
3357
+ step: "queued" as CloneSiteStep,
3358
+ }
3359
+ })
3360
+ const draft: CloneJob = {
3361
+ sourceServerId: server.id,
3362
+ sourceServerName: server.name,
3363
+ step: "plan",
3364
+ specs: { providerName: server.provider_name ?? "", region: server.region ?? "", size: server.size ?? "" },
3365
+ destServerName: "",
3366
+ concurrency: 3,
3367
+ lowerTtlEarly: false,
3368
+ sites,
3369
+ startedAt: Date.now(),
3370
+ }
3371
+ setCloneServer(server)
3372
+ setCloneJob(draft)
3373
+ },
3374
+ [cloneJob, sitesForServer, servers],
3375
+ )
3376
+
3377
+ // Background an in-flight clone: hide the wizard but KEEP the job running (the
3378
+ // worker pool lives in the store). Reopen with C on the source server. Distinct
3379
+ // from clearClone, which discards the job entirely.
3380
+ const backgroundClone = useCallback(() => setCloneServer(null), [])
3381
+
3382
+ const mutateCloneSite = useCallback((sourceSiteId: number, fn: (s: CloneSiteState) => CloneSiteState) => {
3383
+ setCloneJob((j) => (j ? { ...j, sites: j.sites.map((s) => (s.sourceSiteId === sourceSiteId ? fn(s) : s)) } : j))
3384
+ }, [])
3385
+
3386
+ const toggleCloneSite = useCallback(
3387
+ (id: number) => mutateCloneSite(id, (s) => ({ ...s, selected: !s.selected })),
3388
+ [mutateCloneSite],
3389
+ )
3390
+ const toggleCloneSiteUploads = useCallback(
3391
+ (id: number) => mutateCloneSite(id, (s) => ({ ...s, excludeUploads: !s.excludeUploads })),
3392
+ [mutateCloneSite],
3393
+ )
3394
+ const setCloneConcurrency = useCallback((n: number) => {
3395
+ setCloneJob((j) => (j ? { ...j, concurrency: Math.max(1, Math.min(8, n)) } : j))
3396
+ }, [])
3397
+ const toggleCloneLowerTtl = useCallback(() => {
3398
+ setCloneJob((j) => (j ? { ...j, lowerTtlEarly: !j.lowerTtlEarly } : j))
3399
+ }, [])
3400
+ const cloneAdvanceFromPlan = useCallback(() => {
3401
+ setCloneJob((j) => (j && j.step === "plan" && j.sites.some((s) => s.selected) ? { ...j, step: "server" } : j))
3402
+ }, [])
3403
+ // Capture the destination server (either freshly provisioned via the reused
3404
+ // NewServer flow, or an existing box in the dev-override path) and move to the
3405
+ // Connect-dest step. Idempotent — re-setting the same dest is a no-op advance.
3406
+ const cloneSetDest = useCallback((server: Server) => {
3407
+ setCloneJob((j) =>
3408
+ j && (j.step === "server" || j.step === "trust")
3409
+ ? { ...j, destServerId: server.id, destServerName: server.name, destServerIp: server.ip_address ?? "", step: j.step === "server" ? "trust" : j.step }
3410
+ : j,
3411
+ )
3412
+ }, [])
3413
+ // Advance to the fan-out. The caller (wizard) gates this on sudo being connected
3414
+ // on BOTH ends (it has live isSudoConnected); we just move the step.
3415
+ const cloneTrustContinue = useCallback(() => {
3416
+ setCloneJob((j) => (j && j.step === "trust" && j.destServerId != null ? { ...j, step: cloneNeedsGitAccess(j) ? "gitaccess" : "clone" } : j))
3417
+ }, [])
3418
+
3419
+ // Mutate one repo's key-onboarding state by repo string.
3420
+ const setRepoKey = useCallback((repo: string, fn: (k: RepoKeyState) => RepoKeyState) => {
3421
+ setCloneJob((j) => (j ? { ...j, repoKeys: j.repoKeys?.map((k) => (k.repo === repo ? fn(k) : k)) } : j))
3422
+ }, [])
3423
+
3424
+ // Prepare per-repo deploy keys: generate a UNIQUE keypair for each distinct Bedrock
3425
+ // repo (a GitHub deploy key fits exactly one repo, so the server-wide git_publickey
3426
+ // only ever worked for the FIRST repo per server — hit live on a 2026-07-06
3427
+ // production clone). GitHub + `gh` authed → auto-check/add; anything else → manual
3428
+ // (the pane shows the generated public key). Keypairs survive re-entry (r / back).
3429
+ const cloneDetectRepoKeys = useCallback(async () => {
3430
+ const j = cloneJob
3431
+ if (!j || j.destServerId == null) return
3432
+ const repos = Array.from(new Set(j.sites.filter((s) => s.selected && s.stack === "bedrock" && s.gitRepo).map((s) => s.gitRepo as string)))
3433
+ if (repos.length === 0) return
3434
+ const gh = await ghAvailable()
3435
+ const existing = new Map((j.repoKeys ?? []).map((k) => [k.repo, k]))
3436
+ setCloneJob((cur) =>
3437
+ cur
3438
+ ? {
3439
+ ...cur,
3440
+ repoKeys: repos.map((repo) => {
3441
+ const prev = existing.get(repo)
3442
+ const p = parseRepo(repo)
3443
+ const auto = gh && p?.kind === "github"
3444
+ return {
3445
+ repo,
3446
+ owner: p?.owner ?? "",
3447
+ name: p?.name ?? "",
3448
+ host: p?.host ?? "",
3449
+ kind: p?.kind ?? ("other" as RepoHost),
3450
+ settingsUrl: p ? deployKeysSettingsUrl(p) : null,
3451
+ auto,
3452
+ publicKey: prev?.publicKey,
3453
+ privateKey: prev?.privateKey,
3454
+ status: auto ? ("checking" as RepoKeyStatus) : ("manual" as RepoKeyStatus),
3455
+ }
3456
+ }),
3457
+ }
3458
+ : cur,
3459
+ )
3460
+ for (const repo of repos) {
3461
+ const p = parseRepo(repo)
3462
+ const prev = existing.get(repo)
3463
+ let pub = prev?.publicKey
3464
+ if (!pub || !prev?.privateKey) {
3465
+ const site = j.sites.find((s) => s.selected && s.gitRepo === repo)
3466
+ const gen = await generateDeployKeypair(`spinup-${site?.domain ?? p?.name ?? "site"}@${j.destServerName}`)
3467
+ if (!gen.ok) {
3468
+ setRepoKey(repo, (k) => ({ ...k, status: "error", error: gen.error }))
3469
+ continue
3470
+ }
3471
+ pub = gen.key.publicKey
3472
+ setRepoKey(repo, (k) => ({ ...k, publicKey: gen.key.publicKey, privateKey: gen.key.privateKey }))
3473
+ }
3474
+ if (!(gh && p?.kind === "github")) continue // manual — the pane shows the key to add by hand
3475
+ try {
3476
+ const present = await ghDeployKeyPresent(p, pub)
3477
+ setRepoKey(repo, (k) => ({ ...k, status: present ? "present" : "missing" }))
3478
+ } catch (err) {
3479
+ setRepoKey(repo, (k) => ({ ...k, status: "error", error: (err as Error).message }))
3480
+ }
3481
+ }
3482
+ }, [cloneJob, setRepoKey])
3483
+
3484
+ // Add the repo's generated key as a read-only deploy key on it (gh path).
3485
+ const cloneAddRepoKey = useCallback(
3486
+ async (repo: string) => {
3487
+ const j = cloneJob
3488
+ if (!j) return
3489
+ const k = j.repoKeys?.find((x) => x.repo === repo)
3490
+ const p = parseRepo(repo)
3491
+ if (!p || !k?.publicKey) return
3492
+ setRepoKey(repo, (kk) => ({ ...kk, status: "adding", error: undefined }))
3493
+ const site = j.sites.find((s) => s.selected && s.gitRepo === repo)
3494
+ const title = `spinup-${site?.domain ?? p.name}@${j.destServerName}`
3495
+ const res = await ghAddDeployKey(p, k.publicKey, title)
3496
+ setRepoKey(repo, (kk) => ({ ...kk, status: res.ok ? "added" : "error", error: res.ok ? undefined : res.error }))
3497
+ },
3498
+ [cloneJob, setRepoKey],
3499
+ )
3500
+
3501
+ // Advance gitaccess → clone (the wizard gates on no auto repo still "missing"),
3502
+ // stamping each Bedrock site with its repo's keypair for the create payload.
3503
+ const cloneGitAccessContinue = useCallback(() => {
3504
+ setCloneJob((j) => {
3505
+ if (!j || j.step !== "gitaccess") return j
3506
+ const byRepo = new Map((j.repoKeys ?? []).map((k) => [k.repo, k]))
3507
+ return {
3508
+ ...j,
3509
+ step: "clone",
3510
+ sites: j.sites.map((s) => {
3511
+ const k = s.gitRepo ? byRepo.get(s.gitRepo) : undefined
3512
+ return k?.privateKey && k.publicKey ? { ...s, gitDeployKey: { privateKey: k.privateKey, publicKey: k.publicKey } } : s
3513
+ }),
3514
+ }
3515
+ })
3516
+ }, [])
3517
+ const clearClone = useCallback(() => {
3518
+ setCloneServer(null)
3519
+ setCloneJob(null)
3520
+ }, [])
3521
+
3522
+ // Measure each selected source site's payload (webroot + DB) over the source sudo
3523
+ // connection and write the results into sites[].sizeBytes (drives the Plan total +
3524
+ // disk-fit). Needs source sudo connected (Plan pre-flight); a no-op otherwise.
3525
+ const cloneSizeSites = useCallback(async () => {
3526
+ const j = cloneJob
3527
+ if (!j) return
3528
+ const srv = servers.find((s) => s.id === j.sourceServerId)
3529
+ const sudoUser = srv ? sudoUsers.get(srv.id) : undefined
3530
+ const pw = srv ? sudoPwRef.current.get(srv.id) : undefined
3531
+ if (!srv || !sudoUser || pw == null) return
3532
+ // publicFolder only for Standard WP: Bedrock's wp-cli runs from the project root
3533
+ // (files/, via wp-cli.yml), not from its /web/ public folder.
3534
+ const inputs = j.sites.map((s) => ({ siteId: s.sourceSiteId, domain: s.domain, siteUser: s.siteUser, publicFolder: s.stack === "wp" ? s.publicFolder : undefined }))
3535
+ const sizes = await estimateSourceSiteSizes(srv, sudoUser, pw, inputs)
3536
+ if (sizes.size === 0) return
3537
+ setCloneJob((cur) =>
3538
+ cur ? { ...cur, sites: cur.sites.map((s) => { const e = sizes.get(s.sourceSiteId); return e ? { ...s, sizeBytes: e.total, sizeWebBytes: e.web } : s }) } : cur,
3539
+ )
3540
+ }, [cloneJob, servers, sudoUsers])
3541
+
3542
+ // ---- Clone fan-out (slice 4c): concurrent per-site pull chain -----------
3543
+ // Per-site state updates compose via functional setState (each worker owns one
3544
+ // site, so no two workers touch the same entry). Scheduling is a worker pool over
3545
+ // the selected sites (cap = job.concurrency) — no self-scheduling via setState.
3546
+ const setCloneSite = useCallback((siteId: number, fn: (s: CloneSiteState) => CloneSiteState) => {
3547
+ setCloneJob((j) => (j ? { ...j, sites: j.sites.map((s) => (s.sourceSiteId === siteId ? fn(s) : s)) } : j))
3548
+ }, [])
3549
+
3550
+ // Build the SudoCtx for a server from the connected (in-memory) sudo creds.
3551
+ const sudoCtxFor = useCallback(
3552
+ (serverId: number): SudoCtx | null => {
3553
+ const srv = servers.find((s) => s.id === serverId)
3554
+ const u = sudoUsers.get(serverId)
3555
+ const pw = sudoPwRef.current.get(serverId)
3556
+ return srv && u && pw != null ? { server: srv, sudoUser: u, sudoPassword: pw } : null
3557
+ },
3558
+ [servers, sudoUsers],
3559
+ )
3560
+
3561
+ // Drive ONE site: create the dest site (event-polled), then the stack-appropriate
3562
+ // pull chain. Standard WP is wired; Bedrock is slice 4d.
3563
+ const driveCloneSite = useCallback(
3564
+ async (site: CloneSiteState, source: SudoCtx, dest: SudoCtx, destServerId: number) => {
3565
+ const set = (fn: (s: CloneSiteState) => CloneSiteState) => setCloneSite(site.sourceSiteId, fn)
3566
+ const logger = cloneLogRef.current
3567
+ const fail = (failedStep: CloneSiteStep, error: string) => {
3568
+ logger?.log({ event: "site-failed", domain: site.domain, failedStep, error })
3569
+ set((s) => ({ ...s, step: "error", failedStep, error, detail: undefined }))
3570
+ }
3571
+ try {
3572
+ if (site.stack === "bedrock" && !site.gitRepo) return fail("create", "the source Bedrock site has no git repo to clone from.")
3573
+ // create — Bedrock → `git` site (SpinupWP clones the repo); Standard WP →
3574
+ // blank + database; files-only → blank with NO database (nothing to import).
3575
+ set((s) => ({ ...s, step: "create", error: undefined, failedStep: undefined, detail: undefined, stageStartedAt: Date.now() }))
3576
+ const dbName = site.destDbName ?? site.siteUser
3577
+ const dbPw = site.destDbPassword ?? randomToken(28)
3578
+ if (site.stack !== "files") logger?.redact(dbPw)
3579
+ if (site.gitDeployKey) logger?.redact(site.gitDeployKey.privateKey)
3580
+ logger?.log({ event: "site-start", domain: site.domain, stack: site.stack, publicFolder: site.publicFolder, tablePrefix: site.tablePrefix, reusedDestSiteId: site.destSiteId })
3581
+ if (site.stack !== "files") set((s) => ({ ...s, destDbName: dbName, destDbPassword: dbPw }))
3582
+ // Reuse only within THIS job (destSiteId already captured = retry/resume).
3583
+ // We deliberately do NOT adopt a pre-existing dest site found by domain: its
3584
+ // DB password can't be recovered to re-stamp wp-config, so a leftover from a
3585
+ // prior run must be removed first (create will surface a clear conflict).
3586
+ let destSiteId = site.destSiteId
3587
+ if (destSiteId == null) {
3588
+ let ev
3589
+ try {
3590
+ const payload: CreateSitePayload =
3591
+ site.stack === "bedrock"
3592
+ ? {
3593
+ server_id: destServerId,
3594
+ domain: site.domain,
3595
+ site_user: site.siteUser,
3596
+ installation_method: "git",
3597
+ php_version: site.phpVersion,
3598
+ public_folder: site.publicFolder ?? "/web/",
3599
+ database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3600
+ // deploy_script is TOP-LEVEL; we send the corrected canonical (the
3601
+ // source's stored value has a known typo) for future push-to-deploy —
3602
+ // we composer install over SSH regardless (git/deploy won't run it).
3603
+ deploy_script: "composer install -o --no-dev",
3604
+ git: {
3605
+ repo: site.gitRepo!,
3606
+ branch: site.gitBranch ?? "main",
3607
+ push_to_deploy: true,
3608
+ // unique per-site key (gitaccess step) — the server-wide
3609
+ // git_publickey fits only ONE GitHub repo, so it can't be
3610
+ // reused; SpinupWP installs this pair as the site's git identity.
3611
+ ...(site.gitDeployKey ? { deploy_key_enabled: true, deploy_key: { privatekey: site.gitDeployKey.privateKey, publickey: site.gitDeployKey.publicKey } } : {}),
3612
+ },
3613
+ }
3614
+ : site.stack === "files"
3615
+ ? {
3616
+ server_id: destServerId,
3617
+ domain: site.domain,
3618
+ site_user: site.siteUser,
3619
+ installation_method: "blank",
3620
+ php_version: site.phpVersion,
3621
+ public_folder: site.publicFolder,
3622
+ }
3623
+ : {
3624
+ server_id: destServerId,
3625
+ domain: site.domain,
3626
+ site_user: site.siteUser,
3627
+ installation_method: "blank",
3628
+ php_version: site.phpVersion,
3629
+ public_folder: site.publicFolder,
3630
+ database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3631
+ }
3632
+ ev = await client.createSite(payload)
3633
+ logger?.log({ event: "create-requested", domain: site.domain, eventId: ev?.event_id })
3634
+ } catch (err) {
3635
+ return fail("create", err instanceof ApiError ? err.message : (err as Error).message)
3636
+ }
3637
+ // poll the create event. 5s cadence (3 workers share the API window) and
3638
+ // up to 3 consecutive API errors tolerated — a transient failure to READ
3639
+ // the event is not the event failing (the 2026-07-02 run misreported a
3640
+ // successful create as failed off one rate-limited poll).
3641
+ const ok = await new Promise<boolean>((resolve) => {
3642
+ let apiErrs = 0
3643
+ const poll = async () => {
3644
+ try {
3645
+ const e = await client.getEvent(ev!.event_id)
3646
+ apiErrs = 0
3647
+ if (SERVER_FAIL.has(e.status)) return resolve(false)
3648
+ if (SERVER_DONE.has(e.status) || e.finished_at) return resolve(true)
3649
+ } catch {
3650
+ if (++apiErrs >= 3) return resolve(false)
3651
+ }
3652
+ setTimeout(() => void poll(), 5000)
3653
+ }
3654
+ setTimeout(() => void poll(), 5000)
3655
+ })
3656
+ if (!ok) return fail("create", "The dest site create failed on SpinupWP.")
3657
+ try {
3658
+ destSiteId = (await client.listSites(destServerId)).find((s) => s.domain === site.domain)?.id
3659
+ } catch {
3660
+ /* destSiteId stays undefined */
3661
+ }
3662
+ }
3663
+ set((s) => ({ ...s, destSiteId }))
3664
+ // additional domains — a fresh dest site answers ONLY its primary domain;
3665
+ // re-create the source's set (with redirect settings) or the later DNS
3666
+ // cutover would repoint hostnames the dest doesn't serve. Idempotent on retry.
3667
+ const extraDomains = site.additionalDomainConfigs ?? []
3668
+ if (destSiteId != null && extraDomains.length > 0) {
3669
+ set((s) => ({ ...s, detail: "domains" }))
3670
+ const dres = await syncAdditionalDomains(client, destSiteId, extraDomains, logger ? (e) => logger.log(e) : undefined)
3671
+ if (dres.failed.length > 0) {
3672
+ const f = dres.failed[0]!
3673
+ return fail("create", `couldn't add ${dres.failed.length === 1 ? `domain ${f.domain}` : `${dres.failed.length} domains (${dres.failed.map((x) => x.domain).join(", ")})`}: ${f.error}`)
3674
+ }
3675
+ }
3676
+ // pull chain — stack-appropriate runner. Map its stage → roster step.
3677
+ set((s) => ({ ...s, step: "pull", detail: "starting" }))
3678
+ const stageToStep = (stage: CloneStage): CloneSiteStep =>
3679
+ stage === "build" ? "deploy" : stage === "config" ? "config" : stage === "verify" ? "verify" : "pull"
3680
+ const onProgress = (stage: CloneStage, status: "start" | "ok" | "fail") => {
3681
+ if (status !== "start") return
3682
+ set((s) => ({ ...s, step: stageToStep(stage), detail: stage, stageStartedAt: Date.now(), transferBytes: undefined, transferRate: undefined, transferTarget: undefined, transferExact: undefined }))
3683
+ }
3684
+ // Sidecar byte progress: rate from successive samples (poll cadence ~5s).
3685
+ const transferAtRef = { at: 0 }
3686
+ const onTransfer = (_stage: CloneStage, bytes: number, target?: number, exact?: boolean) => {
3687
+ const at = Date.now()
3688
+ set((s) => {
3689
+ const rate = s.transferBytes != null && bytes > s.transferBytes && transferAtRef.at > 0 ? ((bytes - s.transferBytes) / (at - transferAtRef.at)) * 1000 : s.transferRate
3690
+ return { ...s, transferBytes: bytes, transferRate: rate, transferTarget: target, transferExact: exact }
3691
+ })
3692
+ transferAtRef.at = at
3693
+ }
3694
+ const onExec = logger ? (e: CloneExecRecord) => logger.log({ event: "exec", ...e }) : undefined
3695
+ const res =
3696
+ site.stack === "bedrock"
3697
+ ? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads }, onProgress, onExec, onTransfer)
3698
+ : site.stack === "files"
3699
+ ? await runFilesOnlyPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
3700
+ : await runStandardWpPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, publicFolder: site.publicFolder, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
3701
+ if (!res.ok) {
3702
+ const stage = (res.error?.split(":")[0] ?? "pull") as CloneStage
3703
+ return fail(stageToStep(stage), res.error ?? "pull failed")
3704
+ }
3705
+ const { sourceWebrootRel, destWebrootRel } = res as { sourceWebrootRel?: string; destWebrootRel?: string }
3706
+ logger?.log({ event: "site-done", domain: site.domain, sourceWebrootRel, destWebrootRel })
3707
+ set((s) => ({ ...s, step: "done", detail: undefined, error: undefined, failedStep: undefined, sourceWebrootRel, destWebrootRel }))
3708
+ } catch (err) {
3709
+ fail(site.step, (err as Error).message)
3710
+ }
3711
+ },
3712
+ [client, setCloneSite],
3713
+ )
3714
+
3715
+ // Run the fan-out: a worker pool (cap = concurrency) over the selected sites.
3716
+ // Guarded by fanoutStarted so reopening a backgrounded wizard never re-queues a
3717
+ // running clone (the workers live in the store, not the unmounted component).
3718
+ const startClone = useCallback(() => {
3719
+ const j = cloneJob
3720
+ if (!j || j.destServerId == null || j.fanoutStarted) return
3721
+ const source = sudoCtxFor(j.sourceServerId)
3722
+ const dest = sudoCtxFor(j.destServerId)
3723
+ if (!source || !dest) return // both ends must be sudo-connected (trust step ensured)
3724
+ const destServerId = j.destServerId
3725
+ // One log file per job; sudo passwords registered for redaction up front.
3726
+ const logger = new CloneLogger(`${j.sourceServerName}-to-${j.destServerName || String(destServerId)}`)
3727
+ logger.redact(source.sudoPassword, dest.sudoPassword)
3728
+ logger.log({ event: "job-start", source: j.sourceServerName, dest: j.destServerName, destServerId, sites: j.sites.filter((s) => s.selected).map((s) => s.domain), concurrency: j.concurrency })
3729
+ cloneLogRef.current = logger
3730
+ setCloneJob((cur) => (cur ? { ...cur, step: "clone", fanoutStarted: true, logPath: logger.path, sites: cur.sites.map((s) => (s.selected ? { ...s, step: "queued" as CloneSiteStep, error: undefined, failedStep: undefined } : s)) } : cur))
3731
+ const queue = j.sites.filter((s) => s.selected)
3732
+ let cursor = 0
3733
+ const worker = async () => {
3734
+ while (cursor < queue.length) {
3735
+ const site = queue[cursor++]
3736
+ if (site) await driveCloneSite(site, source, dest, destServerId)
3737
+ }
3738
+ }
3739
+ for (let i = 0; i < Math.min(j.concurrency, queue.length); i++) void worker()
3740
+ }, [cloneJob, sudoCtxFor, driveCloneSite])
3741
+
3742
+ // Retry one failed site (recompute contexts; reuse any captured destSiteId/DB).
3743
+ const cloneRetrySite = useCallback(
3744
+ (siteId: number) => {
3745
+ const j = cloneJob
3746
+ if (!j || j.destServerId == null) return
3747
+ const site = j.sites.find((s) => s.sourceSiteId === siteId)
3748
+ const source = sudoCtxFor(j.sourceServerId)
3749
+ const dest = sudoCtxFor(j.destServerId)
3750
+ if (!site || !source || !dest) return
3751
+ cloneLogRef.current?.log({ event: "site-retry", domain: site.domain })
3752
+ void driveCloneSite(site, source, dest, j.destServerId)
3753
+ },
3754
+ [cloneJob, sudoCtxFor, driveCloneSite],
3755
+ )
3756
+
3757
+ // Verify one cloned site (slice 5): read-only source-vs-clone comparison + HTTP
3758
+ // check over the existing sudo contexts. Idempotent; results land in site.verify.
3759
+ const verifyCloneSite = useCallback(
3760
+ (siteId: number) => {
3761
+ const j = cloneJob
3762
+ if (!j || j.destServerId == null) return
3763
+ const site = j.sites.find((s) => s.sourceSiteId === siteId)
3764
+ const source = sudoCtxFor(j.sourceServerId)
3765
+ const dest = sudoCtxFor(j.destServerId)
3766
+ if (!site || !source || !dest) return
3767
+ const destIp = j.destServerIp ?? dest.server.ip_address ?? ""
3768
+ setCloneSite(siteId, (s) => ({ ...s, verifying: true, verifyError: undefined }))
3769
+ void (async () => {
3770
+ try {
3771
+ const res = site.stack === "files"
3772
+ ? await verifyFilesClone(source, dest, { domain: site.domain, destIp })
3773
+ : await verifyClone(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destIp, publicFolder: site.stack === "wp" ? site.publicFolder : undefined, sourceWebrootRel: site.stack === "wp" ? site.sourceWebrootRel : undefined, destWebrootRel: site.stack === "wp" ? site.destWebrootRel : undefined })
3774
+ setCloneSite(siteId, (s) => ({ ...s, verifying: false, verify: res }))
3775
+ } catch (err) {
3776
+ setCloneSite(siteId, (s) => ({ ...s, verifying: false, verifyError: (err as Error).message }))
3777
+ }
3778
+ })()
3779
+ },
3780
+ [cloneJob, sudoCtxFor, setCloneSite],
3781
+ )
3782
+
3783
+ // When every selected site has settled: all-failed → the done summary. With ANY
3784
+ // success the wizard STAYS on the roster — DNS cutover moves live traffic, so
3785
+ // advancing requires the user's explicit continue (c → cloneContinueToCutover);
3786
+ // the user always gets to eyeball the roster's final state first (2026-07-02).
3787
+ useEffect(() => {
3788
+ if (!cloneJob || cloneJob.step !== "clone") return
3789
+ const sel = cloneJob.sites.filter((s) => s.selected)
3790
+ if (sel.length > 0 && sel.every((s) => s.step === "done" || s.step === "error")) {
3791
+ const anyDone = sel.some((s) => s.step === "done")
3792
+ if (!anyDone) setCloneJob((j) => (j && j.step === "clone" ? { ...j, step: "done" } : j))
3793
+ }
3794
+ }, [cloneJob])
3795
+
3796
+ // Space on a ready cutover row: include/exclude it from the batch flip.
3797
+ const toggleCutoverExclude = useCallback((siteId: number, name: string) => {
3798
+ setCloneJob((j) => {
3799
+ if (!j) return j
3800
+ return {
3801
+ ...j,
3802
+ sites: j.sites.map((s) =>
3803
+ s.sourceSiteId === siteId && s.cutover
3804
+ ? { ...s, cutover: { ...s.cutover, records: s.cutover.records.map((r) => (r.name === name && r.status === "ready" ? { ...r, excluded: !r.excluded } : r)) } }
3805
+ : s,
3806
+ ),
3807
+ }
3808
+ })
3809
+ }, [])
3810
+
3811
+ // The explicit continue: only valid once every selected site has settled and at
3812
+ // least one clone succeeded.
3813
+ const cloneContinueToCutover = useCallback(() => {
3814
+ setCloneJob((j) => {
3815
+ if (!j || j.step !== "clone") return j
3816
+ const sel = j.sites.filter((s) => s.selected)
3817
+ const settled = sel.length > 0 && sel.every((s) => s.step === "done" || s.step === "error")
3818
+ const anyDone = sel.some((s) => s.step === "done")
3819
+ return settled && anyDone ? { ...j, step: "cutover" } : j
3820
+ })
3821
+ }, [])
3822
+
3823
+ // ← back one screen. The config steps (plan → server → trust → gitaccess) step back
3824
+ // freely — nothing has executed yet, so re-picking sites or the dest is safe. Once
3825
+ // the fan-out has fired (sites created on the dest = prod writes), re-opening the
3826
+ // config screens would invite editing a plan that's already executing, so the ONLY
3827
+ // post-fan-out back edge is cutover → the clone roster (re-verify / retry a site
3828
+ // before flipping DNS). Cutover state is per-site and survives the round trip.
3829
+ const cloneGoBack = useCallback(() => {
3830
+ setCloneJob((j) => {
3831
+ if (!j) return j
3832
+ if (j.step === "cutover") return { ...j, step: "clone" }
3833
+ if (j.fanoutStarted) return j
3834
+ if (j.step === "server") return { ...j, step: "plan" }
3835
+ if (j.step === "trust") return { ...j, step: "server" }
3836
+ if (j.step === "gitaccess") return { ...j, step: "trust" }
3837
+ return j
3838
+ })
3839
+ }, [])
3840
+
3841
+ // ---- DNS cutover (slice 6) ------------------------------------------------
3842
+ const destIpOf = useCallback(
3843
+ (j: CloneJob) => j.destServerIp ?? (j.destServerId != null ? servers.find((s) => s.id === j.destServerId)?.ip_address ?? "" : ""),
3844
+ [servers],
3845
+ )
3846
+ // Update one record within a site's cutover, recomputing the aggregate status.
3847
+ const setCutoverRecord = useCallback(
3848
+ (siteId: number, name: string, fn: (r: CutoverRecord) => CutoverRecord) => {
3849
+ setCloneSite(siteId, (s) => {
3850
+ if (!s.cutover) return s
3851
+ const records = s.cutover.records.map((r) => (r.name === name ? fn(r) : r))
3852
+ return { ...s, cutover: { status: aggregateCutover(records), records } }
3853
+ })
3854
+ },
3855
+ [setCloneSite],
3856
+ )
3857
+
3858
+ // Read every A record across each cloned site's domains (primary + additional),
3859
+ // classify it (already-on-new = done; editable-on-old = ready; not-API-editable =
3860
+ // manual), and stash the plan in site.cutover. A hostname with no A record (a www
3861
+ // CNAME → apex, or absent) is skipped — only a missing PRIMARY A is flagged.
3862
+ // Read-only; the flip is a separate explicit action. `siteIds` scopes the check
3863
+ // (the wizard passes just-arrived sites so a re-entry never resets in-flight rows).
3864
+ const cutoverCheck = useCallback(async (siteIds?: number[]) => {
3865
+ const j = cloneJob
3866
+ if (!j) return
3867
+ const targetIp = destIpOf(j)
3868
+ const dones = j.sites.filter((s) => s.selected && s.step === "done" && (siteIds == null || siteIds.includes(s.sourceSiteId)))
3869
+ await Promise.all(
3870
+ dones.map(async (site) => {
3871
+ const hostnames = [site.domain, ...(site.additionalDomains ?? [])]
3872
+ setCloneSite(site.sourceSiteId, (s) => ({ ...s, cutover: { status: "checking", records: hostnames.map((name) => ({ name, status: "checking" as CloneCutoverStatus, targetValue: targetIp })) } }))
3873
+ const records: CutoverRecord[] = []
3874
+ for (const name of hostnames) {
3875
+ try {
3876
+ const rc = await resolveZoneConn(name)
3877
+ if ("error" in rc) {
3878
+ // A web-handoff host (GoDaddy delegate access, Namecheap, …) is a
3879
+ // deliberate way of working, not a misconfiguration — its manual
3880
+ // reason is the user's access note (per-zone override, else the
3881
+ // global default), not a "connect an account" scold. API-connectable
3882
+ // hosts (Cloudflare/Route 53) keep the actionable error.
3883
+ const zi = rc.zone
3884
+ const web = zi ? consoleForHost(zi.providerKey) : null
3885
+ const reason = web ? zoneAccessNotes.get(zi!.apex) || DEFAULT_WEB_ACCESS_NOTE : rc.error
3886
+ records.push({ name, status: "manual", targetValue: targetIp, reason, apex: zi?.apex, hostKey: zi?.providerKey })
3887
+ continue
3888
+ }
3889
+ const res = await getZoneRecord(rc.conn.id, rc.zone.apex, name, "A")
3890
+ if (!res.ok || !res.record) {
3891
+ // No A record here — typically a www CNAME that follows the apex, or
3892
+ // absent. Skip silently; only flag a missing PRIMARY domain A.
3893
+ if (name === site.domain) records.push({ name, status: "manual", targetValue: targetIp, reason: res.error ?? "no A record", apex: rc.zone.apex, hostKey: rc.zone.providerKey })
3894
+ continue
3895
+ }
3896
+ const cur = res.record.values[0] ?? ""
3897
+ const base = { name, currentValue: cur, targetValue: targetIp, apex: rc.zone.apex, hostKey: rc.zone.providerKey }
3898
+ // Cutover repoints the VALUE, not the TTL — a Cloudflare proxied
3899
+ // record can't have its TTL edited but its origin IS still
3900
+ // PATCHable, so this checks valueEditable (falls back to editable
3901
+ // for providers that don't set it explicitly), not editable alone.
3902
+ const canRepoint = res.record.valueEditable ?? res.record.editable
3903
+ if (cur === targetIp) records.push({ ...base, status: "done" })
3904
+ else if (!canRepoint) records.push({ ...base, status: "manual", reason: res.record.reason ?? "not editable" })
3905
+ else records.push({ ...base, status: "ready" })
3906
+ } catch (err) {
3907
+ records.push({ name, status: "error", targetValue: targetIp, error: (err as Error).message })
3908
+ }
3909
+ }
3910
+ setCloneSite(site.sourceSiteId, (s) => ({ ...s, cutover: { status: aggregateCutover(records), records } }))
3911
+ }),
3912
+ )
3913
+ }, [cloneJob, destIpOf, resolveZoneConn, getZoneRecord, setCloneSite, zoneAccessNotes])
3914
+
3915
+ // Flip every "ready" A record to the new IP, together (batched, across all sites).
3916
+ // Re-resolves zone+record at flip time (no creds held in state), repoints via
3917
+ // setValue, polls async providers to INSYNC. Partial-aware: manual/error left as-is.
3918
+ const startCutover = useCallback(() => {
3919
+ const j = cloneJob
3920
+ if (!j) return
3921
+ const targetIp = destIpOf(j)
3922
+ if (!targetIp) return
3923
+ for (const site of j.sites.filter((s) => s.selected && s.cutover)) {
3924
+ for (const rec of site.cutover!.records.filter((r) => r.status === "ready" && !r.excluded)) {
3925
+ const name = rec.name
3926
+ const upd = (fn: (r: CutoverRecord) => CutoverRecord) => setCutoverRecord(site.sourceSiteId, name, fn)
3927
+ upd((r) => ({ ...r, status: "flipping", error: undefined }))
3928
+ void (async () => {
3929
+ try {
3930
+ const rc = await resolveZoneConn(name)
3931
+ if ("error" in rc) return upd((r) => ({ ...r, status: "manual", reason: rc.error }))
3932
+ const conn = rc.conn
3933
+ const provider = recordProviderFor(conn.provider)
3934
+ if (!provider?.setValue) return upd((r) => ({ ...r, status: "manual", reason: "this provider isn't API-editable" }))
3935
+ const res = await getZoneRecord(conn.id, rc.zone.apex, name, "A")
3936
+ if (!res.ok || !res.record) return upd((r) => ({ ...r, status: "error", error: res.error ?? "couldn't read the record" }))
3937
+ const change = await provider.setValue(conn.creds, res.zoneId, res.record, targetIp)
3938
+ if (!change.ok) return upd((r) => ({ ...r, status: "error", error: change.error ?? "repoint failed" }))
3939
+ if (change.pollId && provider.pollChange) {
3940
+ for (let i = 0; i < 40; i++) {
3941
+ const st = await provider.pollChange(conn.creds, change.pollId)
3942
+ if (st === "done") break
3943
+ if (st === "failed") return upd((r) => ({ ...r, status: "error", error: "the DNS change failed to apply" }))
3944
+ await new Promise((r) => setTimeout(r, 3000))
3945
+ }
3946
+ }
3947
+ upd((r) => ({ ...r, status: "done", currentValue: targetIp }))
3948
+ } catch (err) {
3949
+ upd((r) => ({ ...r, status: "error", error: (err as Error).message }))
3950
+ }
3951
+ })()
3952
+ }
3953
+ }
3954
+ }, [cloneJob, destIpOf, resolveZoneConn, getZoneRecord, setCutoverRecord])
3955
+
3956
+ const cloneCutoverFinish = useCallback(() => {
3957
+ setCloneJob((j) => (j && j.step === "cutover" ? { ...j, step: "done" } : j))
3958
+ }, [])
3959
+
3960
+ // Resume persisted jobs after a restart. Runs exactly once (the ref guards
3961
+ // against dep churn re-firing it). Iterates every in-flight job and dispatches by
3962
+ // kind: event-backed jobs (newServer, phpUpgrade) reconnect their poller via the
3963
+ // stored event id; the vanity job re-enters its step machine (idempotent steps);
3964
+ // SSH-orchestrated jobs (dbSync, dbBackup) can't reconnect a dead process, so
3965
+ // they're surfaced as interrupted and dropped from the persisted set.
3966
+ const resumedRef = useRef(false)
3967
+ useEffect(() => {
3968
+ if (resumedRef.current) return
3969
+ resumedRef.current = true
3970
+ for (const job of Object.values(cfgRef.current.jobs ?? {})) {
3971
+ if (job.status === "done" || job.status === "failed") continue
3972
+ const inp = (job.inputs ?? {}) as { hostname?: string; siteId?: number; version?: string; domain?: string; action?: "enable" | "disable" }
3973
+ switch (job.kind) {
3974
+ case "newServer":
3975
+ if (job.eventId == null) {
3976
+ void removeJob(job.id)
3977
+ break
3978
+ }
3979
+ {
3980
+ const hostname = inp.hostname ?? "your server"
3981
+ setNewServerJob({ hostname, status: job.status, startedAt: job.startedAt, eventId: job.eventId })
3982
+ trackServerEvent(job.eventId, hostname, job.startedAt)
3983
+ }
3984
+ break
3985
+ case "phpUpgrade":
3986
+ if (job.eventId == null || inp.siteId == null || !inp.version) {
3987
+ void removeJob(job.id)
3988
+ break
3989
+ }
3990
+ setUpgrade(inp.siteId, { target: inp.version, status: job.status })
3991
+ trackPhpUpgradeEvent(inp.siteId, inp.version, job.eventId, inp.domain ?? "Your site")
3992
+ break
3993
+ case "httpsToggle":
3994
+ if (job.eventId == null || inp.siteId == null || !inp.action) {
3995
+ void removeJob(job.id)
3996
+ break
3997
+ }
3998
+ setHttpsProgress(inp.siteId, { action: inp.action, status: job.status })
3999
+ trackHttpsToggleEvent(inp.siteId, inp.action, job.eventId, inp.domain ?? "Your site")
4000
+ break
4001
+ case "vanity": {
4002
+ const vj = job.inputs as VanityJob | undefined
4003
+ if (!vj) {
4004
+ void removeJob(job.id)
4005
+ break
4006
+ }
4007
+ setVanityJob(vj)
4008
+ // sshkey is a manual park — just re-show it. Every other step re-enters
4009
+ // the (idempotent) machine and continues.
4010
+ if (vj.step !== "sshkey") driveVanity(vj)
4011
+ break
4012
+ }
4013
+ case "dbSync":
4014
+ if (inp.siteId != null) setSync(inp.siteId, { stage: "error", domain: inp.domain ?? "", error: INTERRUPTED_SYNC_MSG })
4015
+ void removeJob(job.id)
4016
+ break
4017
+ case "dbBackup":
4018
+ if (inp.siteId != null) setBackup(inp.siteId, { stage: "error", domain: inp.domain ?? "", error: INTERRUPTED_BACKUP_MSG })
4019
+ void removeJob(job.id)
4020
+ break
4021
+ default:
4022
+ void removeJob(job.id) // unknown/unresumable kind — clear it
4023
+ }
4024
+ }
4025
+ }, [trackServerEvent, trackPhpUpgradeEvent, trackHttpsToggleEvent, driveVanity])
4026
+
4027
+ const value: StoreValue = {
4028
+ servers,
4029
+ sites,
4030
+ events,
4031
+ loading,
4032
+ ready,
4033
+ error,
4034
+ lastUpdated,
4035
+ updateInfo,
4036
+ releaseNotesInfo,
4037
+ client,
4038
+ route,
4039
+ setRoute,
4040
+ refresh,
4041
+ inputMode,
4042
+ setInputMode,
4043
+ overlayOpen,
4044
+ setOverlayOpen,
4045
+ healthServer,
4046
+ setHealthServer,
4047
+ wpInventorySite,
4048
+ setWpInventorySite,
4049
+ phpUpgradeSite,
4050
+ setPhpUpgradeSite,
4051
+ phpUpgrades,
4052
+ startPhpUpgrade,
4053
+ clearPhpUpgrade,
4054
+ httpsToggleSite,
4055
+ setHttpsToggleSite,
4056
+ httpsToggles,
4057
+ startHttpsToggle,
4058
+ clearHttpsToggle,
4059
+ purgeCacheSite,
4060
+ setPurgeCacheSite,
4061
+ purgeCacheProgress,
4062
+ startPurgeCache,
4063
+ clearPurgeCache,
4064
+ serverActionsServer,
4065
+ setServerActionsServer,
4066
+ serverOps,
4067
+ startServerOp,
4068
+ clearServerOp,
4069
+ newServerOpen,
4070
+ setNewServerOpen,
4071
+ newServerSource,
4072
+ setNewServerSource,
4073
+ newServerJob,
4074
+ startNewServer,
4075
+ clearNewServer,
4076
+ vanityServer,
4077
+ setVanityServer,
4078
+ vanityJob,
4079
+ startVanity,
4080
+ vanitySshKeyDone,
4081
+ vanitySkipSsl,
4082
+ vanityKeepWaiting,
4083
+ vanityStopWaiting,
4084
+ vanityRetry,
4085
+ vanitySkipKuma,
4086
+ clearVanity,
4087
+ vanityHealthKeyFor,
4088
+ kumaConfigured: !isDevMode() && cfgRef.current.uptimeKuma != null,
4089
+ kumaSite,
4090
+ setKumaSite,
4091
+ kumaOps,
4092
+ connectKuma,
4093
+ kumaMonitorFor,
4094
+ startKumaSetup,
4095
+ startVanityReseed,
4096
+ startKumaRotate,
4097
+ startFingerprintSetup,
4098
+ fetchKumaAlerts,
4099
+ toggleKumaAlert,
4100
+ clearKumaOp,
4101
+ kumaStatus,
4102
+ cloneServer,
4103
+ setCloneServer,
4104
+ cloneJob,
4105
+ beginClone,
4106
+ toggleCloneSite,
4107
+ toggleCloneSiteUploads,
4108
+ setCloneConcurrency,
4109
+ toggleCloneLowerTtl,
4110
+ cloneAdvanceFromPlan,
4111
+ cloneSetDest,
4112
+ cloneTrustContinue,
4113
+ cloneDetectRepoKeys,
4114
+ cloneAddRepoKey,
4115
+ cloneGitAccessContinue,
4116
+ cloneSizeSites,
4117
+ startClone,
4118
+ cloneRetrySite,
4119
+ cloneContinueToCutover,
4120
+ cloneGoBack,
4121
+ toggleCutoverExclude,
4122
+ verifyCloneSite,
4123
+ cutoverCheck,
4124
+ startCutover,
4125
+ cloneCutoverFinish,
4126
+ backgroundClone,
4127
+ clearClone,
4128
+ providerMetadata,
4129
+ providerMetadataLoading,
4130
+ providerMetadataError,
4131
+ loadProviderMetadata,
4132
+ serverProviders,
4133
+ saveServerProviderId,
4134
+ grantKeySite,
4135
+ setGrantKeySite,
4136
+ keyGrants,
4137
+ startGrantKey,
4138
+ startRevokeKey,
4139
+ startGrantRemembered,
4140
+ clearGrantKey,
4141
+ preferredGrantKeys,
4142
+ setPreferredGrantKeys,
4143
+ siteHasGrantedKey,
4144
+ grantedKeyKinds,
4145
+ grantedKeys,
4146
+ forgetGrantedKeys,
4147
+ zoneAccessNotes,
4148
+ setZoneAccessNote,
4149
+ dismissReleaseNotes,
4150
+ showReleaseNotes,
4151
+ sudoUserFor,
4152
+ sudoConnectServer,
4153
+ setSudoConnectServer,
4154
+ isSudoConnected,
4155
+ connectSudo,
4156
+ connectSudoFromKeychain,
4157
+ disconnectSudo,
4158
+ sudoSavedFor,
4159
+ forgetSudoKeychain,
4160
+ keychainAvailable: keychainAvailable(),
4161
+ localLinkSite,
4162
+ setLocalLinkSite,
4163
+ localLinks,
4164
+ linkSite,
4165
+ unlinkSite,
4166
+ localRoots,
4167
+ addLocalRoot,
4168
+ discoverOpen,
4169
+ setDiscoverOpen,
4170
+ forgottenOpen,
4171
+ setForgottenOpen,
4172
+ forgottenStack,
4173
+ setForgottenStack,
4174
+ linkReturnToForgotten,
4175
+ setLinkReturnToForgotten,
4176
+ openLocalTerminal,
4177
+ openLocalUrl,
4178
+ sshSite,
4179
+ dbBackupSite,
4180
+ setDbBackupSite,
4181
+ dbBackups,
4182
+ planDbBackupFor,
4183
+ startDbBackup,
4184
+ clearDbBackup,
4185
+ dbSyncSite,
4186
+ setDbSyncSite,
4187
+ mediaFallbackSite,
4188
+ setMediaFallbackSite,
4189
+ planMediaFallbackFor,
4190
+ dbSyncs,
4191
+ planDbSyncFor,
4192
+ startDbSync,
4193
+ clearDbSync,
4194
+ drift,
4195
+ ensureDrift,
4196
+ rebootInfo,
4197
+ rebootInfoLoading,
4198
+ rebootInfoErrors,
4199
+ loadRebootInfo,
4200
+ sshUser: cfgRef.current.sshUser,
4201
+ localSync: cfgRef.current.localSync,
4202
+ accountSlug: cfgRef.current.accountSlug,
4203
+ sitesForServer,
4204
+ serverById,
4205
+ probes,
4206
+ probingIds,
4207
+ probeErrors,
4208
+ runProbe,
4209
+ runProbeMany,
4210
+ isProbeStale,
4211
+ dnsZones,
4212
+ dnsResolving,
4213
+ lookupSiteDns,
4214
+ lookupServerDns,
4215
+ zoneForDomain,
4216
+ isDnsResolving,
4217
+ zonesForHostKey,
4218
+ resolveAllFleetDomains,
4219
+ hostingRecords,
4220
+ resolveServerHosting,
4221
+ hostingFor,
4222
+ isHostingResolving,
4223
+ dnsInventoryServer,
4224
+ dnsInventoryFocusSiteId,
4225
+ setDnsInventoryServer,
4226
+ setDnsInventoryFocusSiteId,
4227
+ connections,
4228
+ connectionsFor,
4229
+ connectionCount: allConnections.length,
4230
+ providerZones,
4231
+ addConnection,
4232
+ removeConnection,
4233
+ verifyConnectionById,
4234
+ accessForZone,
4235
+ accountForZone,
4236
+ connForZone,
4237
+ connectZoneTarget,
4238
+ setConnectZoneTarget,
4239
+ dnsRecordsTarget,
4240
+ setDnsRecordsTarget,
4241
+ recordWrites,
4242
+ getZoneRecord,
4243
+ recordWriteForHost,
4244
+ startTtlChange,
4245
+ startValueChange,
4246
+ clearRecordWrite,
4247
+ isPhpEol,
4248
+ offeredPhpVersions,
4249
+ isServerOsEol,
4250
+ }
4251
+
4252
+ return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
4253
+ }