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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
package/src/ui/store.tsx
ADDED
|
@@ -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
|
+
}
|