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
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
// "Clone a server to a new server" wizard (backlog item 5). A two-pane overlay: a
|
|
2
|
+
// fixed left Journey Rail (the five server-level steps + a src→dst footer) and a
|
|
3
|
+
// right pane that changes per step. The two heavy steps (Clone sites, DNS cutover)
|
|
4
|
+
// expand into a per-site roster. Driven by the store's CloneJob whose fan-out lives
|
|
5
|
+
// in `sites[]`. See docs/2026-06-24_clone-to-server-spec.md.
|
|
6
|
+
//
|
|
7
|
+
// SLICE 2 (this file): the shell + the Plan step (site-select + pre-flight + the two
|
|
8
|
+
// "all sites" toggles). The New-server / Connect-dest / Clone / Cutover steps render
|
|
9
|
+
// as scaffolding (rail advances, rosters preview) — their orchestration lands in
|
|
10
|
+
// later slices.
|
|
11
|
+
|
|
12
|
+
import { useEffect, useRef, useState } from "react"
|
|
13
|
+
import { useKeyboard } from "@opentui/react"
|
|
14
|
+
import { theme } from "../../lib/theme.ts"
|
|
15
|
+
import { Panel, Spinner } from "../components.tsx"
|
|
16
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
17
|
+
import { openUrl, copyToClipboard } from "../../lib/open.ts"
|
|
18
|
+
import { consoleForHost } from "../../lib/providers.ts"
|
|
19
|
+
import { serverWebUrl } from "../../lib/spinupweb.ts"
|
|
20
|
+
import { useStore, cloneNeedsGitAccess, type CloneStep, type CloneSiteStep, type RepoKeyState } from "../store.tsx"
|
|
21
|
+
import type { VerifyCheck } from "../../lib/serverClone.ts"
|
|
22
|
+
|
|
23
|
+
// Choosing an existing server as the dest is a first-class feature now (the `d` picker
|
|
24
|
+
// on the Destination step). SPINUP_DEV_CLONE_DEST just pre-points the picker cursor at
|
|
25
|
+
// a given server id — a dev convenience for repeated testing, never required.
|
|
26
|
+
const DEV_CLONE_DEST = Number(process.env.SPINUP_DEV_CLONE_DEST) || null
|
|
27
|
+
// Dev-only: auto-connect sudo on both ends from .env (SPINUP_DEV_SUDO_SOURCE/DEST,
|
|
28
|
+
// each "user:password"), so the full clone is testable headlessly without typing
|
|
29
|
+
// passwords into SudoConnect. The real flow never reads these.
|
|
30
|
+
const DEV_SUDO = { source: process.env.SPINUP_DEV_SUDO_SOURCE || "", dest: process.env.SPINUP_DEV_SUDO_DEST || "" }
|
|
31
|
+
|
|
32
|
+
const STEP_PLAN = { step: "plan" as CloneStep, label: "Plan" }
|
|
33
|
+
const STEP_SERVER = { step: "server" as CloneStep, label: "Destination" }
|
|
34
|
+
const STEP_TRUST = { step: "trust" as CloneStep, label: "Connect dest" }
|
|
35
|
+
const STEP_GITACCESS = { step: "gitaccess" as CloneStep, label: "Git access" }
|
|
36
|
+
const STEP_CLONE = { step: "clone" as CloneStep, label: "Clone sites" }
|
|
37
|
+
const STEP_CUTOVER = { step: "cutover" as CloneStep, label: "DNS cutover" }
|
|
38
|
+
// The Git-access step only appears when a Bedrock site is selected (deploy-key onboarding).
|
|
39
|
+
function stepsFor(needsGit: boolean): { step: CloneStep; label: string }[] {
|
|
40
|
+
return needsGit
|
|
41
|
+
? [STEP_PLAN, STEP_SERVER, STEP_TRUST, STEP_GITACCESS, STEP_CLONE, STEP_CUTOVER]
|
|
42
|
+
: [STEP_PLAN, STEP_SERVER, STEP_TRUST, STEP_CLONE, STEP_CUTOVER]
|
|
43
|
+
}
|
|
44
|
+
const RAIL_W = 24
|
|
45
|
+
|
|
46
|
+
export function CloneWizard() {
|
|
47
|
+
const {
|
|
48
|
+
cloneServer: server,
|
|
49
|
+
cloneJob: job,
|
|
50
|
+
servers,
|
|
51
|
+
accountSlug,
|
|
52
|
+
toggleCloneSite,
|
|
53
|
+
toggleCloneSiteUploads,
|
|
54
|
+
cloneAdvanceFromPlan,
|
|
55
|
+
cloneSetDest,
|
|
56
|
+
cloneTrustContinue,
|
|
57
|
+
cloneDetectRepoKeys,
|
|
58
|
+
cloneAddRepoKey,
|
|
59
|
+
cloneGitAccessContinue,
|
|
60
|
+
cloneSizeSites,
|
|
61
|
+
startClone,
|
|
62
|
+
cloneRetrySite,
|
|
63
|
+
cloneContinueToCutover,
|
|
64
|
+
cloneGoBack,
|
|
65
|
+
toggleCutoverExclude,
|
|
66
|
+
verifyCloneSite,
|
|
67
|
+
cutoverCheck,
|
|
68
|
+
startCutover,
|
|
69
|
+
cloneCutoverFinish,
|
|
70
|
+
backgroundClone,
|
|
71
|
+
toggleCloneLowerTtl,
|
|
72
|
+
clearClone,
|
|
73
|
+
isSudoConnected,
|
|
74
|
+
setSudoConnectServer,
|
|
75
|
+
connectSudo,
|
|
76
|
+
newServerOpen,
|
|
77
|
+
newServerJob,
|
|
78
|
+
setNewServerOpen,
|
|
79
|
+
setNewServerSource,
|
|
80
|
+
clearNewServer,
|
|
81
|
+
sudoConnectServer,
|
|
82
|
+
} = useStore()
|
|
83
|
+
|
|
84
|
+
const [idx, setIdx] = useState(0)
|
|
85
|
+
const [sizeTried, setSizeTried] = useState(false)
|
|
86
|
+
const [verifyOpen, setVerifyOpen] = useState<number | null>(null) // slice 5: drilled-into site
|
|
87
|
+
const [pickExisting, setPickExisting] = useState(false) // dest step: choosing an existing server
|
|
88
|
+
const [destIdx, setDestIdx] = useState(0) // cursor in the existing-server picker
|
|
89
|
+
|
|
90
|
+
// Kick the fan-out once, when we land on the Clone step. Guarded by the job's
|
|
91
|
+
// fanoutStarted flag (not component state) so reopening a backgrounded wizard
|
|
92
|
+
// never re-queues a clone that's already running in the store.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (job?.step === "clone" && !job.fanoutStarted) {
|
|
95
|
+
setIdx(0)
|
|
96
|
+
startClone()
|
|
97
|
+
}
|
|
98
|
+
}, [job?.step, job?.fanoutStarted, startClone])
|
|
99
|
+
|
|
100
|
+
// Detect deploy keys once, when we land on the Git-access step.
|
|
101
|
+
const detectTried = useRef(false)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (job?.step === "gitaccess" && !detectTried.current) {
|
|
104
|
+
detectTried.current = true
|
|
105
|
+
void cloneDetectRepoKeys()
|
|
106
|
+
}
|
|
107
|
+
}, [job?.step, cloneDetectRepoKeys])
|
|
108
|
+
|
|
109
|
+
// Every screen owns its own cursor run — reset it whenever the step changes
|
|
110
|
+
// (forward or back), so a cursor position from a longer roster never lands
|
|
111
|
+
// out-of-range on the next screen.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
setIdx(0)
|
|
114
|
+
}, [job?.step])
|
|
115
|
+
|
|
116
|
+
// Tick once a second while any selected site is mid-stage so the roster's
|
|
117
|
+
// elapsed readout counts up (long file pulls otherwise look frozen).
|
|
118
|
+
const [now, setNow] = useState(Date.now())
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (job?.step !== "clone") return
|
|
121
|
+
if (!job.sites.some((s) => s.selected && !["queued", "done", "error"].includes(s.step))) return
|
|
122
|
+
const t = setInterval(() => setNow(Date.now()), 1000)
|
|
123
|
+
return () => clearInterval(t)
|
|
124
|
+
}, [job?.step, job?.sites])
|
|
125
|
+
|
|
126
|
+
// Read + classify each site's DNS records when we land on the cutover step — but
|
|
127
|
+
// per-site, only the ones never checked, so reopening a backgrounded wizard (or
|
|
128
|
+
// returning after ← back → retry) never resets in-progress flips, and a site that
|
|
129
|
+
// became "done" via retry after backing out still gets its records read.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (job?.step !== "cutover") return
|
|
132
|
+
const unchecked = job.sites.filter((s) => s.selected && s.step === "done" && !s.cutover)
|
|
133
|
+
if (unchecked.length > 0) void cutoverCheck(unchecked.map((s) => s.sourceSiteId))
|
|
134
|
+
}, [job?.step, job?.sites, cutoverCheck])
|
|
135
|
+
|
|
136
|
+
// Dev-only auto-sudo (both ends) from .env so the flow is testable headlessly.
|
|
137
|
+
const devSudoTried = useRef(new Set<number>())
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!server || !job) return
|
|
140
|
+
const tryConn = (srv: typeof server | undefined, cred: string) => {
|
|
141
|
+
if (!srv || !cred || isSudoConnected(srv.id) || devSudoTried.current.has(srv.id)) return
|
|
142
|
+
const [u, ...r] = cred.split(":")
|
|
143
|
+
if (!u) return
|
|
144
|
+
devSudoTried.current.add(srv.id)
|
|
145
|
+
void connectSudo(srv, u, r.join(":"))
|
|
146
|
+
}
|
|
147
|
+
tryConn(servers.find((s) => s.id === job.sourceServerId), DEV_SUDO.source)
|
|
148
|
+
const destId = job.destServerId ?? DEV_CLONE_DEST
|
|
149
|
+
tryConn(destId != null ? servers.find((s) => s.id === destId) : undefined, DEV_SUDO.dest)
|
|
150
|
+
}, [server, job, servers, isSudoConnected, connectSudo])
|
|
151
|
+
|
|
152
|
+
const devDest = DEV_CLONE_DEST != null ? servers.find((s) => s.id === DEV_CLONE_DEST) ?? null : null
|
|
153
|
+
|
|
154
|
+
// Measure source site sizes once, the moment source sudo is connected (Plan total
|
|
155
|
+
// + per-site GB). Runs a single du+wp-db-size pass over the sudo connection.
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!server || sizeTried) return
|
|
158
|
+
if (job?.step === "plan" && isSudoConnected(server.id) && job.sites.length > 0) {
|
|
159
|
+
setSizeTried(true)
|
|
160
|
+
void cloneSizeSites()
|
|
161
|
+
}
|
|
162
|
+
}, [server, job, sizeTried, isSudoConnected, cloneSizeSites])
|
|
163
|
+
|
|
164
|
+
// Bridge: when the reused NewServer flow finishes provisioning, capture the new
|
|
165
|
+
// box as the clone dest and close it. (Standalone NewServer use sets no cloneJob,
|
|
166
|
+
// so this only fires inside the wizard's "server" step.)
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (job?.step !== "server" || job.destServerId != null) return
|
|
169
|
+
if (newServerJob?.status === "done" && newServerJob.serverId != null) {
|
|
170
|
+
const created = servers.find((s) => s.id === newServerJob.serverId)
|
|
171
|
+
if (created) {
|
|
172
|
+
cloneSetDest(created)
|
|
173
|
+
clearNewServer()
|
|
174
|
+
setNewServerOpen(false)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}, [job, newServerJob, servers, cloneSetDest, clearNewServer, setNewServerOpen])
|
|
178
|
+
|
|
179
|
+
const close = () => clearClone()
|
|
180
|
+
|
|
181
|
+
// Existing servers eligible as a clone destination (anything but the source),
|
|
182
|
+
// name-sorted. The DEV override (SPINUP_DEV_CLONE_DEST) just pre-points the cursor.
|
|
183
|
+
const eligibleDests = servers.filter((s) => s.id !== job?.sourceServerId).slice().sort((a, b) => a.name.localeCompare(b.name))
|
|
184
|
+
|
|
185
|
+
useKeyboard((key) => {
|
|
186
|
+
if (!job || !server) return
|
|
187
|
+
// A layered overlay (NewServer / SudoConnect) owns the keyboard while open.
|
|
188
|
+
if (newServerOpen || sudoConnectServer) return
|
|
189
|
+
const raw = key.name ?? ""
|
|
190
|
+
const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
|
|
191
|
+
|
|
192
|
+
if (name === "escape" || name === "q") {
|
|
193
|
+
if (verifyOpen != null) return setVerifyOpen(null) // back out of the verify drill-down first
|
|
194
|
+
if (pickExisting) return setPickExisting(false) // back out of the server picker first
|
|
195
|
+
// Once the fan-out has launched, esc BACKGROUNDS the clone (it keeps running);
|
|
196
|
+
// reopen with C on the source server. Before launch / at the summary, esc cancels.
|
|
197
|
+
if (job.step === "clone" || job.step === "cutover") return backgroundClone()
|
|
198
|
+
return close()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ← / h — back one screen (layered views close first, matching esc). The config
|
|
202
|
+
// steps go back freely; after the fan-out has fired, the only back edge is
|
|
203
|
+
// cutover → the clone roster (re-verify / retry) — cloneGoBack enforces this.
|
|
204
|
+
if (name === "left" || name === "h") {
|
|
205
|
+
if (verifyOpen != null) return setVerifyOpen(null)
|
|
206
|
+
if (pickExisting) return setPickExisting(false)
|
|
207
|
+
return cloneGoBack()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (job.step === "plan") {
|
|
211
|
+
// S — connect sudo on the SOURCE (pre-flight; the pull needs it later).
|
|
212
|
+
if (name === "S" && !isSudoConnected(server.id)) return setSudoConnectServer(server)
|
|
213
|
+
const n = job.sites.length
|
|
214
|
+
if (name === "up" || name === "k") return setIdx((i) => (i - 1 + n) % n)
|
|
215
|
+
if (name === "down" || name === "j") return setIdx((i) => (i + 1) % n)
|
|
216
|
+
const cur = job.sites[idx]
|
|
217
|
+
if (name === "space" && cur) return toggleCloneSite(cur.sourceSiteId)
|
|
218
|
+
if (name === "u" && cur) return toggleCloneSiteUploads(cur.sourceSiteId)
|
|
219
|
+
if (name === "t") return toggleCloneLowerTtl()
|
|
220
|
+
if (name === "return") return cloneAdvanceFromPlan()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (job.step === "server") {
|
|
224
|
+
if (pickExisting) {
|
|
225
|
+
const n = eligibleDests.length
|
|
226
|
+
if (n === 0) return
|
|
227
|
+
if (name === "up" || name === "k") return setDestIdx((i) => (i - 1 + n) % n)
|
|
228
|
+
if (name === "down" || name === "j") return setDestIdx((i) => (i + 1) % n)
|
|
229
|
+
if (name === "return") {
|
|
230
|
+
const chosen = eligibleDests[destIdx]
|
|
231
|
+
if (chosen) {
|
|
232
|
+
setPickExisting(false)
|
|
233
|
+
cloneSetDest(chosen)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
// d — choose an existing server as the destination (open the picker).
|
|
239
|
+
if (name === "d" && eligibleDests.length > 0) {
|
|
240
|
+
const start = devDest ? Math.max(0, eligibleDests.findIndex((s) => s.id === devDest.id)) : 0
|
|
241
|
+
setDestIdx(start)
|
|
242
|
+
return setPickExisting(true)
|
|
243
|
+
}
|
|
244
|
+
if (name === "return") {
|
|
245
|
+
// Reuse the standalone New-server flow, seeded from the source.
|
|
246
|
+
setNewServerSource(server)
|
|
247
|
+
setNewServerOpen(true)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (job.step === "trust") {
|
|
252
|
+
const destServer = job.destServerId != null ? servers.find((s) => s.id === job.destServerId) ?? null : null
|
|
253
|
+
// S — connect sudo on the DEST; G — re-check/connect sudo on the SOURCE.
|
|
254
|
+
if (name === "S" && destServer && !isSudoConnected(destServer.id)) return setSudoConnectServer(destServer)
|
|
255
|
+
if (name === "G" && !isSudoConnected(server.id)) return setSudoConnectServer(server)
|
|
256
|
+
if (name === "w" && destServer && accountSlug) return openUrl(serverWebUrl(destServer.id, accountSlug))
|
|
257
|
+
if (name === "return") {
|
|
258
|
+
const bothConnected = isSudoConnected(server.id) && destServer != null && isSudoConnected(destServer.id)
|
|
259
|
+
if (bothConnected) return cloneTrustContinue()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (job.step === "gitaccess") {
|
|
264
|
+
const keys = job.repoKeys ?? []
|
|
265
|
+
// a — add the dest key to every repo we can auto-add (gh + GitHub) that's missing.
|
|
266
|
+
if (name === "a") {
|
|
267
|
+
for (const k of keys) if (k.auto && (k.status === "missing" || k.status === "error")) void cloneAddRepoKey(k.repo)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
// r — re-detect (after a manual add, or to retry a check).
|
|
271
|
+
if (name === "r") {
|
|
272
|
+
detectTried.current = false
|
|
273
|
+
void cloneDetectRepoKeys()
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
// o — open the first repo's deploy-keys settings page (manual path).
|
|
277
|
+
if (name === "o") {
|
|
278
|
+
const url = keys.find((k) => k.settingsUrl)?.settingsUrl
|
|
279
|
+
if (url) return openUrl(url)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
// y — copy a generated public key (first manual repo, else first repo).
|
|
283
|
+
if (name === "y") {
|
|
284
|
+
const k = keys.find((x) => !x.auto && x.publicKey) ?? keys.find((x) => x.publicKey)
|
|
285
|
+
if (k?.publicKey) copyToClipboard(k.publicKey)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
// ⏎ — continue once no auto repo is still missing/in-flight (create verifies the rest).
|
|
289
|
+
if (name === "return") {
|
|
290
|
+
const blocked = keys.some((k) => k.status === "checking" || k.status === "adding" || (k.auto && k.status === "missing"))
|
|
291
|
+
if (!blocked) return cloneGitAccessContinue()
|
|
292
|
+
}
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (job.step === "clone" || job.step === "done") {
|
|
297
|
+
const sel = job.sites.filter((s) => s.selected)
|
|
298
|
+
// While drilled into a verify/failure view: v re-runs verify (done sites),
|
|
299
|
+
// r retries the drilled FAILED site, ←/h/Esc (handled above) goes back.
|
|
300
|
+
if (verifyOpen != null) {
|
|
301
|
+
const drilled = job.sites.find((s) => s.sourceSiteId === verifyOpen)
|
|
302
|
+
if (name === "r" && drilled?.step === "error") {
|
|
303
|
+
setVerifyOpen(null)
|
|
304
|
+
return cloneRetrySite(verifyOpen)
|
|
305
|
+
}
|
|
306
|
+
if (name === "v" && drilled?.step === "done") return verifyCloneSite(verifyOpen)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
const n = sel.length
|
|
310
|
+
if (n > 0 && (name === "up" || name === "k")) return setIdx((i) => (i - 1 + n) % n)
|
|
311
|
+
if (n > 0 && (name === "down" || name === "j")) return setIdx((i) => (i + 1) % n)
|
|
312
|
+
const cur = sel[idx]
|
|
313
|
+
// v / Enter — verify the highlighted DONE site (drill in + run the comparison).
|
|
314
|
+
if ((name === "v" || name === "return") && cur && cur.step === "done") {
|
|
315
|
+
setVerifyOpen(cur.sourceSiteId)
|
|
316
|
+
verifyCloneSite(cur.sourceSiteId)
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
// Enter on a FAILED site — drill into the full (untruncated) error.
|
|
320
|
+
if (name === "return" && cur && cur.step === "error") {
|
|
321
|
+
setVerifyOpen(cur.sourceSiteId)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
// r — retry every failed site.
|
|
325
|
+
if (name === "r") {
|
|
326
|
+
for (const s of job.sites) if (s.selected && s.step === "error") cloneRetrySite(s.sourceSiteId)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
// c — the explicit continue to DNS cutover (only once the roster settled
|
|
330
|
+
// with at least one success; cutover moves live traffic).
|
|
331
|
+
if (name === "c" && job.step === "clone") {
|
|
332
|
+
const settled = sel.length > 0 && sel.every((s) => s.step === "done" || s.step === "error")
|
|
333
|
+
if (settled && sel.some((s) => s.step === "done")) return cloneContinueToCutover()
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (job.step === "cutover") {
|
|
338
|
+
const recs = job.sites.filter((s) => s.selected && s.step === "done").flatMap((s) => (s.cutover?.records ?? []).map((r) => ({ r, siteId: s.sourceSiteId })))
|
|
339
|
+
const n = recs.length
|
|
340
|
+
if (n > 0 && (name === "up" || name === "k")) return setIdx((i) => (i - 1 + n) % n)
|
|
341
|
+
if (n > 0 && (name === "down" || name === "j")) return setIdx((i) => (i + 1) % n)
|
|
342
|
+
const cur = recs[Math.min(idx, Math.max(0, n - 1))]
|
|
343
|
+
// space — include/exclude a ready record from the batch flip.
|
|
344
|
+
if (name === "space" && cur && cur.r.status === "ready") return toggleCutoverExclude(cur.siteId, cur.r.name)
|
|
345
|
+
// ⏎ — open the zone's registrar/web console (zone copied for the
|
|
346
|
+
// delegate-access paste), for the manual rows the API can't flip.
|
|
347
|
+
if (name === "return" && cur) {
|
|
348
|
+
const con = cur.r.hostKey ? consoleForHost(cur.r.hostKey) : null
|
|
349
|
+
if (con) {
|
|
350
|
+
copyToClipboard(cur.r.apex ?? cur.r.name)
|
|
351
|
+
openUrl(con.url)
|
|
352
|
+
}
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
// c — flip every included "ready" record to the new server (the live-traffic write).
|
|
356
|
+
if (name === "c") return startCutover()
|
|
357
|
+
if (name === "r") return void cutoverCheck()
|
|
358
|
+
if (name === "f") return cloneCutoverFinish()
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
if (!server || !job) return null
|
|
363
|
+
|
|
364
|
+
const selected = job.sites.filter((s) => s.selected)
|
|
365
|
+
const sudoOn = isSudoConnected(server.id)
|
|
366
|
+
const steps = stepsFor(cloneNeedsGitAccess(job))
|
|
367
|
+
const curStepIdx = job.step === "done" ? steps.length : steps.findIndex((s) => s.step === job.step)
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 218 }}>
|
|
371
|
+
{/* header */}
|
|
372
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
373
|
+
<text content="✦ Clone server " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
374
|
+
<text content={`${server.name} (${selected.length} of ${job.sites.length} sites) → ${job.destServerName || "destination"}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
375
|
+
</box>
|
|
376
|
+
|
|
377
|
+
{/* two panes */}
|
|
378
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
379
|
+
{railPane()}
|
|
380
|
+
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>{rightPane()}</box>
|
|
381
|
+
</box>
|
|
382
|
+
|
|
383
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
384
|
+
</box>
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
function railPane() {
|
|
388
|
+
return (
|
|
389
|
+
<box style={{ width: RAIL_W, flexShrink: 0, flexDirection: "column", borderStyle: "single", borderColor: theme.border, paddingLeft: 1, paddingRight: 1 }}>
|
|
390
|
+
<text content="JOURNEY" fg={theme.textFaint} wrapMode="none" />
|
|
391
|
+
<box style={{ height: 1 }} />
|
|
392
|
+
{steps.map((s, i) => {
|
|
393
|
+
const state = i < curStepIdx ? "done" : i === curStepIdx ? "active" : "pending"
|
|
394
|
+
const glyph = state === "done" ? "✓" : state === "active" ? "❯" : "○"
|
|
395
|
+
const color = state === "done" ? theme.good : state === "active" ? theme.brand : theme.textFaint
|
|
396
|
+
// N-fraction badge on the two roster steps.
|
|
397
|
+
const badge = s.step === "clone" || s.step === "cutover" ? ` ${countDone(s.step)}/${selected.length}` : ""
|
|
398
|
+
return (
|
|
399
|
+
<box key={s.step} style={{ flexDirection: "row", height: 1 }}>
|
|
400
|
+
<text content={`${glyph} `} fg={color} style={{ flexShrink: 0 }} />
|
|
401
|
+
<text content={s.label} fg={state === "pending" ? theme.textFaint : theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
402
|
+
{badge ? <text content={badge} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
|
|
403
|
+
</box>
|
|
404
|
+
)
|
|
405
|
+
})}
|
|
406
|
+
<box style={{ flexGrow: 1 }} />
|
|
407
|
+
<text content={"─".repeat(RAIL_W - 2)} fg={theme.border} wrapMode="none" />
|
|
408
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
409
|
+
<text content="src " fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
410
|
+
<text content={truncate(server!.name, RAIL_W - 6)} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
411
|
+
</box>
|
|
412
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
413
|
+
<text content="dst " fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
414
|
+
<text content={job!.destServerName || job!.destServerIp || "—"} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
415
|
+
</box>
|
|
416
|
+
</box>
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function rightPane() {
|
|
421
|
+
if (job!.step === "plan") return planPane()
|
|
422
|
+
if (job!.step === "server") return serverPane()
|
|
423
|
+
if (job!.step === "trust") return trustPane()
|
|
424
|
+
if (job!.step === "gitaccess") return gitAccessPane()
|
|
425
|
+
if ((job!.step === "clone" || job!.step === "done") && verifyOpen != null) {
|
|
426
|
+
const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
|
|
427
|
+
return drilled?.step === "error" ? failurePane(verifyOpen) : verifyPane(verifyOpen)
|
|
428
|
+
}
|
|
429
|
+
if (job!.step === "clone" || job!.step === "done") return clonePane()
|
|
430
|
+
if (job!.step === "cutover") return cutoverPane()
|
|
431
|
+
// error — scaffolding for now.
|
|
432
|
+
return (
|
|
433
|
+
<Panel title={` ${steps[curStepIdx]?.label ?? job!.step} `} active>
|
|
434
|
+
<box style={{ flexDirection: "column", paddingTop: 1, paddingBottom: 1 }}>
|
|
435
|
+
<text content="This step is built in a later slice of the wizard." fg={theme.textDim} wrapMode="none" />
|
|
436
|
+
<text content="The Plan is captured; the rail shows where we are." fg={theme.textFaint} wrapMode="none" />
|
|
437
|
+
</box>
|
|
438
|
+
</Panel>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function serverPane() {
|
|
443
|
+
if (pickExisting) return pickExistingPane()
|
|
444
|
+
const sp = job!.specs
|
|
445
|
+
const canPick = eligibleDests.length > 0
|
|
446
|
+
return (
|
|
447
|
+
<Panel title=" Destination — provision new or pick existing " active>
|
|
448
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
449
|
+
<text content="Where should the cloned sites land?" fg={theme.textDim} wrapMode="none" />
|
|
450
|
+
<box style={{ height: 1 }} />
|
|
451
|
+
<text content="❯ Enter — provision a NEW server" fg={theme.brand} wrapMode="none" />
|
|
452
|
+
<text content={` matched to source: ${sp.providerName || "—"} · ${sp.region || "—"} · ${sp.size || "—"}`} fg={theme.textFaint} wrapMode="none" />
|
|
453
|
+
<box style={{ height: 1 }} />
|
|
454
|
+
{canPick ? (
|
|
455
|
+
<text content={`❯ d — use an EXISTING server (${eligibleDests.length} available)`} fg={theme.brand} wrapMode="none" />
|
|
456
|
+
) : (
|
|
457
|
+
<text content="d — use an existing server (none available — provision one)" fg={theme.textFaint} wrapMode="none" />
|
|
458
|
+
)}
|
|
459
|
+
<text content=" e.g. a pre-built box, or consolidate onto an existing server" fg={theme.textFaint} wrapMode="none" />
|
|
460
|
+
<box style={{ flexGrow: 1 }} />
|
|
461
|
+
<text content="Provisioning runs in the background and the wizard waits for it." fg={theme.textFaint} wrapMode="none" />
|
|
462
|
+
</box>
|
|
463
|
+
</Panel>
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Existing-server picker (the promoted "use an existing destination" feature).
|
|
468
|
+
function pickExistingPane() {
|
|
469
|
+
return (
|
|
470
|
+
<Panel title=" Destination — pick an existing server " active>
|
|
471
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
472
|
+
<text content="Clone INTO one of your existing servers:" fg={theme.textDim} wrapMode="none" />
|
|
473
|
+
<box style={{ height: 1 }} />
|
|
474
|
+
{eligibleDests.map((s, i) => {
|
|
475
|
+
const sel = i === destIdx
|
|
476
|
+
const meta = `${s.provider_name || "—"} · ${s.region || "—"}${s.ip_address ? " " + s.ip_address : ""}`
|
|
477
|
+
return (
|
|
478
|
+
<box key={s.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.bgAlt : undefined }}>
|
|
479
|
+
<text content={sel ? "❯ " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
480
|
+
<text content={s.name} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
481
|
+
<box style={{ flexGrow: 1 }} />
|
|
482
|
+
<text content={meta} fg={sel ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
483
|
+
</box>
|
|
484
|
+
)
|
|
485
|
+
})}
|
|
486
|
+
<box style={{ flexGrow: 1 }} />
|
|
487
|
+
<text content="The dest needs a sudo connection next (and a deploy key for Bedrock) — the wizard walks you through it." fg={theme.textFaint} wrapMode="none" />
|
|
488
|
+
<text content="↑↓ select · ⏎ use this server · esc back" fg={theme.textFaint} wrapMode="none" />
|
|
489
|
+
</box>
|
|
490
|
+
</Panel>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function trustPane() {
|
|
495
|
+
const destServer = job!.destServerId != null ? servers.find((s) => s.id === job!.destServerId) ?? null : null
|
|
496
|
+
const srcOn = isSudoConnected(server!.id)
|
|
497
|
+
const destOn = destServer != null && isSudoConnected(destServer.id)
|
|
498
|
+
const both = srcOn && destOn
|
|
499
|
+
const row = (ok: boolean, label: string) => (
|
|
500
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
501
|
+
<text content={ok ? "✓ " : "○ "} fg={ok ? theme.good : theme.warn} style={{ flexShrink: 0 }} />
|
|
502
|
+
<text content={label} fg={ok ? theme.textDim : theme.warn} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
503
|
+
</box>
|
|
504
|
+
)
|
|
505
|
+
return (
|
|
506
|
+
<Panel title=" Connect dest — the privileged foothold " active>
|
|
507
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
508
|
+
<text content={`Dest: ${job!.destServerName || "—"} ${job!.destServerIp || ""}`} fg={theme.text} wrapMode="none" />
|
|
509
|
+
<box style={{ height: 1 }} />
|
|
510
|
+
<text content="SpinupTUI needs a sudo connection on BOTH ends: root on the source" fg={theme.textFaint} wrapMode="none" />
|
|
511
|
+
<text content="to read each site, root on the dest to write + import." fg={theme.textFaint} wrapMode="none" />
|
|
512
|
+
<box style={{ height: 1 }} />
|
|
513
|
+
{row(srcOn, srcOn ? "Source sudo connected" : "Source sudo not connected — press G")}
|
|
514
|
+
{row(destOn, destOn ? "Dest sudo connected" : "Dest sudo not connected — create a sudo user (w), then press S")}
|
|
515
|
+
<box style={{ flexGrow: 1 }} />
|
|
516
|
+
{both ? (
|
|
517
|
+
<text content="❯ Enter — both ends connected, continue to clone" fg={theme.brand} wrapMode="none" />
|
|
518
|
+
) : (
|
|
519
|
+
<text content="Connect sudo on both ends to continue." fg={theme.textFaint} wrapMode="none" />
|
|
520
|
+
)}
|
|
521
|
+
</box>
|
|
522
|
+
</Panel>
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Git-access step — deploy-key onboarding for Bedrock dests (hybrid: gh auto-add
|
|
527
|
+
// when possible, manual key + settings link otherwise). Each repo gets its OWN
|
|
528
|
+
// generated keypair — a GitHub deploy key fits exactly one repo, so the server-wide
|
|
529
|
+
// key can never cover a second repo on the same server.
|
|
530
|
+
function gitAccessPane() {
|
|
531
|
+
const keys = job!.repoKeys ?? []
|
|
532
|
+
const anyManual = keys.some((k) => !k.auto)
|
|
533
|
+
const blocked = keys.some((k) => k.status === "checking" || k.status === "adding" || (k.auto && k.status === "missing"))
|
|
534
|
+
const glyph = (k: RepoKeyState): { g: string; c: string } => {
|
|
535
|
+
switch (k.status) {
|
|
536
|
+
case "present":
|
|
537
|
+
case "added":
|
|
538
|
+
return { g: "✓", c: theme.good }
|
|
539
|
+
case "missing":
|
|
540
|
+
return { g: "○", c: theme.warn }
|
|
541
|
+
case "checking":
|
|
542
|
+
case "adding":
|
|
543
|
+
return { g: "⠹", c: theme.brand }
|
|
544
|
+
case "error":
|
|
545
|
+
return { g: "✕", c: theme.bad }
|
|
546
|
+
default:
|
|
547
|
+
return { g: "•", c: theme.textFaint }
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const label = (k: RepoKeyState): string => {
|
|
551
|
+
switch (k.status) {
|
|
552
|
+
case "present":
|
|
553
|
+
return "deploy key present"
|
|
554
|
+
case "added":
|
|
555
|
+
return "deploy key added"
|
|
556
|
+
case "missing":
|
|
557
|
+
return "deploy key missing — press a to add"
|
|
558
|
+
case "checking":
|
|
559
|
+
return "checking…"
|
|
560
|
+
case "adding":
|
|
561
|
+
return "adding…"
|
|
562
|
+
case "error":
|
|
563
|
+
return truncate(k.error ?? "failed", 36)
|
|
564
|
+
default:
|
|
565
|
+
return "add the key by hand, then r to re-check"
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return (
|
|
569
|
+
<Panel title=" Git access — authorize the new server to pull your repos " active>
|
|
570
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
571
|
+
<text content="A Bedrock dest is cloned from git at create time. Each repo gets its" fg={theme.textFaint} wrapMode="none" />
|
|
572
|
+
<text content="own read-only deploy key (generated here; installed on the new site)." fg={theme.textFaint} wrapMode="none" />
|
|
573
|
+
<box style={{ height: 1 }} />
|
|
574
|
+
{keys.length === 0 ? <text content="Checking repositories…" fg={theme.textDim} wrapMode="none" /> : null}
|
|
575
|
+
{keys.map((k) => {
|
|
576
|
+
const { g, c } = glyph(k)
|
|
577
|
+
return (
|
|
578
|
+
<box key={k.repo} style={{ flexDirection: "row", height: 1 }}>
|
|
579
|
+
<text content={`${g} `} fg={c} style={{ flexShrink: 0 }} />
|
|
580
|
+
<text content={`${k.owner}/${k.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
581
|
+
<box style={{ flexGrow: 1 }} />
|
|
582
|
+
<text content={label(k)} fg={k.status === "error" ? theme.bad : k.status === "present" || k.status === "added" ? theme.good : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
583
|
+
</box>
|
|
584
|
+
)
|
|
585
|
+
})}
|
|
586
|
+
{anyManual ? (
|
|
587
|
+
<>
|
|
588
|
+
<box style={{ height: 1 }} />
|
|
589
|
+
<text content="Add each repo's generated key in its settings (o opens it, y copies it):" fg={theme.textFaint} wrapMode="none" />
|
|
590
|
+
{keys
|
|
591
|
+
.filter((k) => !k.auto && k.publicKey)
|
|
592
|
+
.map((k) => (
|
|
593
|
+
<box key={`pub-${k.repo}`} style={{ flexDirection: "row", height: 1 }}>
|
|
594
|
+
<text content={`${k.owner}/${k.name} `} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
595
|
+
<text content={k.publicKey!} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
596
|
+
</box>
|
|
597
|
+
))}
|
|
598
|
+
</>
|
|
599
|
+
) : null}
|
|
600
|
+
<box style={{ flexGrow: 1 }} />
|
|
601
|
+
{blocked ? (
|
|
602
|
+
<text content="Add the missing deploy keys (a), then continue." fg={theme.textFaint} wrapMode="none" />
|
|
603
|
+
) : (
|
|
604
|
+
<text content="❯ Enter — repos authorized, continue to clone" fg={theme.brand} wrapMode="none" />
|
|
605
|
+
)}
|
|
606
|
+
</box>
|
|
607
|
+
</Panel>
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function planPane() {
|
|
612
|
+
return (
|
|
613
|
+
<Panel title=" Plan — choose sites to clone " active>
|
|
614
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
615
|
+
<box style={{ flexDirection: "row" }}>
|
|
616
|
+
<text content="Bring which sites?" fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
617
|
+
<box style={{ flexGrow: 1 }} />
|
|
618
|
+
<text content={`${selected.length} of ${job!.sites.length} selected`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
619
|
+
</box>
|
|
620
|
+
<box style={{ height: 1 }} />
|
|
621
|
+
{job!.sites.map((s, i) => {
|
|
622
|
+
const sel = i === idx
|
|
623
|
+
const mark = s.selected ? "◉" : "◯"
|
|
624
|
+
const markColor = s.selected ? theme.good : theme.textFaint
|
|
625
|
+
const size = s.sizeBytes != null ? formatBytes(s.sizeBytes) : "—"
|
|
626
|
+
const tag = s.stack === "bedrock" ? "bedrock" : s.stack === "files" ? "files only" : ""
|
|
627
|
+
const note = !s.selected ? " (skipped)" : s.excludeUploads ? " (no uploads)" : ""
|
|
628
|
+
return (
|
|
629
|
+
<box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.bgAlt : undefined }}>
|
|
630
|
+
<text content={` ${mark} `} fg={markColor} style={{ flexShrink: 0 }} />
|
|
631
|
+
<text content={s.domain} fg={sel ? theme.text : s.selected ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
632
|
+
<box style={{ flexGrow: 1 }} />
|
|
633
|
+
{tag ? <text content={`${tag} `} fg={sel ? theme.text : theme.accent} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
|
|
634
|
+
<text content={size} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
635
|
+
{note ? <text content={note} fg={sel ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
|
|
636
|
+
</box>
|
|
637
|
+
)
|
|
638
|
+
})}
|
|
639
|
+
<box style={{ flexGrow: 1 }} />
|
|
640
|
+
<text content={payloadLine()} fg={theme.textFaint} wrapMode="none" />
|
|
641
|
+
<box style={{ height: 1 }} />
|
|
642
|
+
{/* pre-flight */}
|
|
643
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
644
|
+
<text content={sudoOn ? "✓ " : "○ "} fg={sudoOn ? theme.good : theme.warn} style={{ flexShrink: 0 }} />
|
|
645
|
+
<text content={sudoOn ? "Sudo connected on the source" : "Sudo not connected on the source — press S"} fg={sudoOn ? theme.textDim : theme.warn} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
646
|
+
</box>
|
|
647
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
648
|
+
<text content={job!.lowerTtlEarly ? "◉ " : "◯ "} fg={job!.lowerTtlEarly ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
649
|
+
<text content="Lower DNS TTL now (t) — makes cutover near-instant later" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
650
|
+
</box>
|
|
651
|
+
</box>
|
|
652
|
+
</Panel>
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// DNS cutover roster (slice 6) — one row per A record across the cloned sites'
|
|
657
|
+
// domains (primary + additional). www CNAMEs that follow the apex aren't listed.
|
|
658
|
+
function cutoverPane() {
|
|
659
|
+
const cloned = selected.filter((s) => s.step === "done")
|
|
660
|
+
const recs = cloned.flatMap((s) => (s.cutover?.records ?? []).map((r) => ({ r, siteId: s.sourceSiteId })))
|
|
661
|
+
const count = (st: string) => recs.filter((x) => x.r.status === st).length
|
|
662
|
+
const ready = recs.filter((x) => x.r.status === "ready" && !x.r.excluded).length
|
|
663
|
+
const skipped = recs.filter((x) => x.r.status === "ready" && x.r.excluded).length
|
|
664
|
+
const working = count("checking") + count("flipping")
|
|
665
|
+
const cutDone = count("done")
|
|
666
|
+
const manual = count("manual")
|
|
667
|
+
const errored = count("error")
|
|
668
|
+
const targetIp = job!.destServerIp || (job!.destServerId != null ? servers.find((s) => s.id === job!.destServerId)?.ip_address ?? "" : "") || "the new server"
|
|
669
|
+
const rowFor = ({ r, siteId }: (typeof recs)[number], i: number) => {
|
|
670
|
+
const st = r.status
|
|
671
|
+
const cur = i === idx
|
|
672
|
+
const busy = st === "checking" || st === "flipping"
|
|
673
|
+
// Ready rows read as include/exclude toggles (◉/◯, like Plan); the rest keep
|
|
674
|
+
// their status glyphs.
|
|
675
|
+
const glyph = st === "done" ? "✓" : st === "manual" ? "!" : st === "error" ? "✕" : st === "ready" ? (r.excluded ? "◯" : "◉") : "·"
|
|
676
|
+
const gcolor = st === "done" ? theme.good : st === "manual" ? theme.warn : st === "error" ? theme.bad : st === "ready" ? (r.excluded ? theme.textFaint : theme.brand) : theme.textFaint
|
|
677
|
+
const detail =
|
|
678
|
+
st === "checking" ? "reading DNS…"
|
|
679
|
+
: st === "flipping" ? "updating…"
|
|
680
|
+
: st === "done" ? `now → ${r.targetValue ?? targetIp}`
|
|
681
|
+
: st === "ready" ? `${r.currentValue ?? "?"} → ${r.targetValue ?? targetIp}${r.excluded ? " (skipped)" : ""}`
|
|
682
|
+
: st === "manual" ? `manual: ${r.reason ?? "edit by hand"}`
|
|
683
|
+
: st === "error" ? truncate(r.error ?? "failed", 28)
|
|
684
|
+
: "pending"
|
|
685
|
+
const dcolor = st === "error" ? theme.bad : st === "done" ? theme.good : st === "manual" ? theme.warn : cur ? theme.text : theme.textFaint
|
|
686
|
+
return (
|
|
687
|
+
<box key={`${siteId}|${r.name}`} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
|
|
688
|
+
{busy ? <Spinner color={theme.brand} /> : <text content={glyph} fg={gcolor} style={{ flexShrink: 0 }} />}
|
|
689
|
+
<text content={` ${r.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
690
|
+
<box style={{ flexGrow: 1 }} />
|
|
691
|
+
<text content={detail} fg={dcolor} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
692
|
+
</box>
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
return (
|
|
696
|
+
<Panel title=" DNS cutover — point traffic at the new server " active>
|
|
697
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
698
|
+
<text content={`Repoint each A record → ${targetIp}. This moves LIVE traffic (www CNAMEs follow the apex).`} fg={theme.textDim} wrapMode="none" />
|
|
699
|
+
<box style={{ height: 1 }} />
|
|
700
|
+
{recs.length === 0 ? <text content="Reading DNS…" fg={theme.textDim} wrapMode="none" /> : recs.map(rowFor)}
|
|
701
|
+
<box style={{ flexGrow: 1 }} />
|
|
702
|
+
<text content={`✓ ${cutDone} cut over · ◉ ${ready} ready${skipped ? ` · ◯ ${skipped} skipped` : ""} · ⠹ ${working} working${manual ? ` · ! ${manual} manual` : ""}${errored ? ` · ✕ ${errored} failed` : ""}`} fg={theme.textDim} wrapMode="none" />
|
|
703
|
+
{manual > 0 ? <text content="Manual records can't be API-edited — ⏎ opens that zone's registrar (zone name copied for the delegate-access paste)." fg={theme.textFaint} wrapMode="none" /> : null}
|
|
704
|
+
{ready > 0 ? (
|
|
705
|
+
<text content={`❯ c — cut over ${ready} record${ready === 1 ? "" : "s"} to the new server (live traffic)`} fg={theme.brand} wrapMode="none" />
|
|
706
|
+
) : (
|
|
707
|
+
<text content="f — finish" fg={theme.textFaint} wrapMode="none" />
|
|
708
|
+
)}
|
|
709
|
+
</box>
|
|
710
|
+
</Panel>
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Live clone roster — one row per selected site, sub-step + spinner/✓/✕.
|
|
715
|
+
function clonePane() {
|
|
716
|
+
const done = selected.filter((s) => s.step === "done").length
|
|
717
|
+
const settled = selected.length > 0 && selected.every((s) => s.step === "done" || s.step === "error")
|
|
718
|
+
const running = selected.filter((s) => !["queued", "done", "error"].includes(s.step)).length
|
|
719
|
+
const errored = selected.filter((s) => s.step === "error").length
|
|
720
|
+
const queued = selected.filter((s) => s.step === "queued").length
|
|
721
|
+
// "· 3 at a time" only earns its place when there's actually a queue.
|
|
722
|
+
const title = selected.length === 1 ? " Clone site " : selected.length > job!.concurrency ? ` Clone sites · up to ${job!.concurrency} at a time ` : " Clone sites "
|
|
723
|
+
return (
|
|
724
|
+
<Panel title={title} active>
|
|
725
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
726
|
+
{selected.map((s, i) => {
|
|
727
|
+
const active = !["queued", "done", "error"].includes(s.step)
|
|
728
|
+
const cur = i === idx
|
|
729
|
+
const vmark = s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : ""
|
|
730
|
+
return (
|
|
731
|
+
<box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
|
|
732
|
+
{active ? <Spinner color={theme.brand} /> : <text content={s.step === "done" ? "✓" : s.step === "error" ? "✕" : "○"} fg={s.step === "done" ? theme.good : s.step === "error" ? theme.bad : theme.textFaint} style={{ flexShrink: 0 }} />}
|
|
733
|
+
<text content={` ${s.domain}`} fg={cur || s.step === "error" || s.step === "done" ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
734
|
+
<box style={{ flexGrow: 1 }} />
|
|
735
|
+
<text
|
|
736
|
+
content={(s.step === "error" ? truncate(s.error ?? "failed", 24) : s.step === "done" ? "done" : s.step === "queued" ? "queued" : `${s.step}${s.detail ? " · " + s.detail : ""}${s.transferBytes != null ? ` · ${formatBytes(s.transferBytes)}${s.transferTarget ? ` of ${s.transferExact ? "" : "~"}${formatBytes(s.transferTarget)}` : ""}${s.transferExact && s.transferTarget ? ` (${Math.min(100, Math.round((s.transferBytes / s.transferTarget) * 100))}%)` : ""}${s.transferRate ? ` · ${formatBytes(s.transferRate)}/s` : ""}` : ""}${s.stageStartedAt ? " · " + fmtElapsed(now - s.stageStartedAt) : ""}`) + vmark}
|
|
737
|
+
fg={s.step === "error" || (s.verify && !s.verify.ok) ? theme.bad : s.verify?.ok ? theme.good : s.step === "done" ? theme.good : theme.textFaint}
|
|
738
|
+
wrapMode="none"
|
|
739
|
+
style={{ flexShrink: 0 }}
|
|
740
|
+
/>
|
|
741
|
+
</box>
|
|
742
|
+
)
|
|
743
|
+
})}
|
|
744
|
+
<box style={{ flexGrow: 1 }} />
|
|
745
|
+
<text content={`✓ ${done} done · ⠹ ${running} running · ${queued} queued${errored ? ` · ✕ ${errored} failed` : ""}`} fg={theme.textDim} wrapMode="none" />
|
|
746
|
+
{done > 0 ? <text content="↑↓ select · v verify a done site" fg={theme.textFaint} wrapMode="none" /> : null}
|
|
747
|
+
{errored > 0 ? <text content={`⏎ on a failed site — full error${job!.logPath ? ` · log: ${job!.logPath}` : ""}`} fg={theme.textFaint} wrapMode="none" /> : null}
|
|
748
|
+
{settled && done > 0 && job!.step === "clone" ? (
|
|
749
|
+
<text content={`❯ c — continue to DNS cutover (${done} of ${selected.length} cloned; cutover moves LIVE traffic)`} fg={theme.brand} wrapMode="none" />
|
|
750
|
+
) : null}
|
|
751
|
+
{job!.step === "clone" ? (
|
|
752
|
+
<text content={errored > 0 ? "r retry failed · esc — clone keeps running in the background" : "esc — clone keeps running in the background (reopen with C)"} fg={theme.textFaint} wrapMode="none" />
|
|
753
|
+
) : (
|
|
754
|
+
<text content={errored > 0 ? "r retry failed · esc close" : "esc — close"} fg={theme.textFaint} wrapMode="none" />
|
|
755
|
+
)}
|
|
756
|
+
</box>
|
|
757
|
+
</Panel>
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Verify drill-down (slice 5) — source-vs-clone facts + HTTP for one done site.
|
|
762
|
+
function verifyPane(siteId: number) {
|
|
763
|
+
const site = job!.sites.find((s) => s.sourceSiteId === siteId)
|
|
764
|
+
if (!site) return null
|
|
765
|
+
const v = site.verify
|
|
766
|
+
const row = (c: VerifyCheck) => (
|
|
767
|
+
<box key={c.key} style={{ flexDirection: "row", height: 1 }}>
|
|
768
|
+
<text content={c.ok ? "✓ " : "✕ "} fg={c.ok ? theme.good : theme.bad} style={{ flexShrink: 0 }} />
|
|
769
|
+
<text content={c.label} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
770
|
+
<box style={{ flexGrow: 1 }} />
|
|
771
|
+
<text content={truncate(c.source, 22)} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
|
|
772
|
+
<text content={truncate(c.clone, 22)} fg={c.ok ? theme.textDim : theme.bad} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
|
|
773
|
+
</box>
|
|
774
|
+
)
|
|
775
|
+
return (
|
|
776
|
+
<Panel title={` Verify · ${site.domain} `} active>
|
|
777
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
778
|
+
{site.verifying ? (
|
|
779
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
780
|
+
<Spinner color={theme.brand} />
|
|
781
|
+
<text content=" Comparing source vs clone…" fg={theme.textDim} wrapMode="none" />
|
|
782
|
+
</box>
|
|
783
|
+
) : site.verifyError ? (
|
|
784
|
+
<text content={`Verify failed: ${site.verifyError}`} fg={theme.bad} wrapMode="none" />
|
|
785
|
+
) : v ? (
|
|
786
|
+
<>
|
|
787
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
788
|
+
<text content=" " style={{ flexShrink: 0 }} />
|
|
789
|
+
<text content="check" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
790
|
+
<box style={{ flexGrow: 1 }} />
|
|
791
|
+
<text content="source" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
|
|
792
|
+
<text content="clone" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
|
|
793
|
+
</box>
|
|
794
|
+
{v.checks.map(row)}
|
|
795
|
+
<box style={{ flexGrow: 1 }} />
|
|
796
|
+
<text content={v.ok ? "✓ Clone matches the source." : "✕ Differences found — review the ✕ rows above."} fg={v.ok ? theme.good : theme.bad} wrapMode="none" />
|
|
797
|
+
<text content="v re-run · ← back to roster" fg={theme.textFaint} wrapMode="none" />
|
|
798
|
+
</>
|
|
799
|
+
) : (
|
|
800
|
+
<text content="Press v to compare this clone against its source." fg={theme.textDim} wrapMode="none" />
|
|
801
|
+
)}
|
|
802
|
+
</box>
|
|
803
|
+
</Panel>
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Failure drill-down — the FULL error for one failed site (the roster truncates it),
|
|
808
|
+
// plus where the complete stage-by-stage log lives on disk.
|
|
809
|
+
function failurePane(siteId: number) {
|
|
810
|
+
const site = job!.sites.find((s) => s.sourceSiteId === siteId)
|
|
811
|
+
if (!site) return null
|
|
812
|
+
return (
|
|
813
|
+
<Panel title={` Failed · ${site.domain} `} active>
|
|
814
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
815
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
816
|
+
<text content="✕ failed at: " fg={theme.bad} style={{ flexShrink: 0 }} />
|
|
817
|
+
<text content={site.failedStep ?? "?"} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
818
|
+
</box>
|
|
819
|
+
<box style={{ height: 1 }} />
|
|
820
|
+
<text content={site.error ?? "no error captured"} fg={theme.text} />
|
|
821
|
+
<box style={{ flexGrow: 1 }} />
|
|
822
|
+
{job!.logPath ? <text content={`Full stage-by-stage output: ${job!.logPath}`} fg={theme.textFaint} /> : null}
|
|
823
|
+
<text content="r retry this site · ← back to roster" fg={theme.textFaint} wrapMode="none" />
|
|
824
|
+
</box>
|
|
825
|
+
</Panel>
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function hints() {
|
|
830
|
+
if (job!.step === "plan") {
|
|
831
|
+
return [
|
|
832
|
+
{ key: "space", label: "toggle" },
|
|
833
|
+
{ key: "u", label: "uploads" },
|
|
834
|
+
{ key: "t", label: "lower TTL" },
|
|
835
|
+
...(sudoOn ? [] : [{ key: "S", label: "connect sudo" }]),
|
|
836
|
+
...(selected.length > 0 ? [{ key: "⏎", label: "continue" }] : []),
|
|
837
|
+
{ key: "esc", label: "close" },
|
|
838
|
+
]
|
|
839
|
+
}
|
|
840
|
+
if (job!.step === "server") {
|
|
841
|
+
if (pickExisting) return [{ key: "↑↓", label: "select" }, { key: "⏎", label: "use server" }, { key: "esc", label: "back" }]
|
|
842
|
+
return [{ key: "⏎", label: "provision new" }, ...(eligibleDests.length > 0 ? [{ key: "d", label: "use existing" }] : []), { key: "←", label: "back" }, { key: "esc", label: "close" }]
|
|
843
|
+
}
|
|
844
|
+
if (job!.step === "trust") {
|
|
845
|
+
const destServer = job!.destServerId != null ? servers.find((s) => s.id === job!.destServerId) ?? null : null
|
|
846
|
+
const both = isSudoConnected(server!.id) && destServer != null && isSudoConnected(destServer.id)
|
|
847
|
+
return [
|
|
848
|
+
{ key: "S", label: "sudo dest" },
|
|
849
|
+
{ key: "G", label: "sudo source" },
|
|
850
|
+
{ key: "w", label: "SpinupWP" },
|
|
851
|
+
...(both ? [{ key: "⏎", label: "continue" }] : []),
|
|
852
|
+
{ key: "←", label: "back" },
|
|
853
|
+
{ key: "esc", label: "close" },
|
|
854
|
+
]
|
|
855
|
+
}
|
|
856
|
+
if (job!.step === "clone" || job!.step === "done") {
|
|
857
|
+
if (verifyOpen != null) {
|
|
858
|
+
const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
|
|
859
|
+
if (drilled?.step === "error") return [{ key: "r", label: "retry site" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
|
|
860
|
+
return [{ key: "v", label: "re-run" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
|
|
861
|
+
}
|
|
862
|
+
const done = selected.filter((s) => s.step === "done").length
|
|
863
|
+
const errored = selected.filter((s) => s.step === "error").length
|
|
864
|
+
const settled = selected.length > 0 && selected.every((s) => s.step === "done" || s.step === "error")
|
|
865
|
+
return [
|
|
866
|
+
...(settled && done > 0 && job!.step === "clone" ? [{ key: "c", label: "DNS cutover" }] : []),
|
|
867
|
+
...(done > 0 ? [{ key: "↑↓", label: "select" }, { key: "v", label: "verify" }] : []),
|
|
868
|
+
...(errored > 0 ? [{ key: "r", label: "retry failed" }] : []),
|
|
869
|
+
{ key: "esc", label: job!.step === "clone" ? "background" : "close" },
|
|
870
|
+
]
|
|
871
|
+
}
|
|
872
|
+
if (job!.step === "cutover") {
|
|
873
|
+
const recs = selected.filter((s) => s.step === "done").flatMap((s) => s.cutover?.records ?? [])
|
|
874
|
+
const ready = recs.filter((r) => r.status === "ready" && !r.excluded).length
|
|
875
|
+
const anyConsole = recs.some((r) => r.hostKey && consoleForHost(r.hostKey))
|
|
876
|
+
return [
|
|
877
|
+
{ key: "↑↓", label: "select" },
|
|
878
|
+
...(recs.some((r) => r.status === "ready") ? [{ key: "space", label: "skip/include" }] : []),
|
|
879
|
+
...(anyConsole ? [{ key: "⏎", label: "registrar" }] : []),
|
|
880
|
+
...(ready > 0 ? [{ key: "c", label: "cut over" }] : []),
|
|
881
|
+
{ key: "r", label: "re-check" },
|
|
882
|
+
{ key: "←", label: "clone roster" },
|
|
883
|
+
{ key: "f", label: "finish" },
|
|
884
|
+
{ key: "esc", label: "background" },
|
|
885
|
+
]
|
|
886
|
+
}
|
|
887
|
+
if (job!.step === "gitaccess") {
|
|
888
|
+
const keys = job!.repoKeys ?? []
|
|
889
|
+
const canAdd = keys.some((k) => k.auto && (k.status === "missing" || k.status === "error"))
|
|
890
|
+
const hasManual = keys.some((k) => k.settingsUrl)
|
|
891
|
+
const blocked = keys.some((k) => k.status === "checking" || k.status === "adding" || (k.auto && k.status === "missing"))
|
|
892
|
+
return [
|
|
893
|
+
...(canAdd ? [{ key: "a", label: "add key" }] : []),
|
|
894
|
+
...(hasManual ? [{ key: "o", label: "open settings" }] : []),
|
|
895
|
+
{ key: "r", label: "re-check" },
|
|
896
|
+
...(!blocked ? [{ key: "⏎", label: "continue" }] : []),
|
|
897
|
+
{ key: "←", label: "back" },
|
|
898
|
+
{ key: "esc", label: "close" },
|
|
899
|
+
]
|
|
900
|
+
}
|
|
901
|
+
return [{ key: "esc", label: "close" }]
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function countDone(step: "clone" | "cutover") {
|
|
905
|
+
if (step === "clone") return job!.sites.filter((s) => s.selected && s.step === "done").length
|
|
906
|
+
// cutover badge counts records repointed (or already on the new IP).
|
|
907
|
+
return job!.sites.filter((s) => s.selected && s.cutover?.status === "done").length
|
|
908
|
+
}
|
|
909
|
+
function totalSize() {
|
|
910
|
+
const known = job!.sites.filter((s) => s.selected && s.sizeBytes != null).reduce((a, s) => a + (s.sizeBytes ?? 0), 0)
|
|
911
|
+
return known > 0 ? formatBytes(known) : "—"
|
|
912
|
+
}
|
|
913
|
+
function payloadLine() {
|
|
914
|
+
const anySized = job!.sites.some((s) => s.sizeBytes != null)
|
|
915
|
+
if (!sudoOn) return "Payload size — connect sudo (S) to measure webroot + DB"
|
|
916
|
+
if (!anySized) return "Measuring webroot + DB sizes over sudo…"
|
|
917
|
+
return `Payload ~${totalSize()} (selected sites' webroot + DB)`
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function fmtElapsed(ms: number): string {
|
|
922
|
+
const sec = Math.max(0, Math.floor(ms / 1000))
|
|
923
|
+
return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${String(sec % 60).padStart(2, "0")}s`
|
|
924
|
+
}
|
|
925
|
+
function truncate(s: string, n: number): string {
|
|
926
|
+
return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + "…"
|
|
927
|
+
}
|
|
928
|
+
function formatBytes(b: number): string {
|
|
929
|
+
if (b >= 1e9) return `${(b / 1e9).toFixed(1)} GB`
|
|
930
|
+
if (b >= 1e6) return `${(b / 1e6).toFixed(0)} MB`
|
|
931
|
+
return `${(b / 1e3).toFixed(0)} KB`
|
|
932
|
+
}
|