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,750 @@
|
|
|
1
|
+
// "Create a new server" overlay (backlog item 5, build 1) — the standalone
|
|
2
|
+
// server-creation action the clone wizard will later reuse.
|
|
3
|
+
//
|
|
4
|
+
// Opened with `c` on a selected server (Servers tab). Seeds the form by MATCHING
|
|
5
|
+
// the selected server's provider/region/size, prices it from the provider
|
|
6
|
+
// metadata, suggests a hostname from the fleet's naming convention, then confirms
|
|
7
|
+
// before firing POST /servers. The actual create + ~10-min event poll live in the
|
|
8
|
+
// store (`startNewServer`), so closing this modal (Esc) doesn't abandon the build.
|
|
9
|
+
//
|
|
10
|
+
// You can switch to any supported provider (DigitalOcean / Vultr / Linode /
|
|
11
|
+
// Hetzner). The API exposes no endpoint to list an account's server providers, so
|
|
12
|
+
// each provider's numeric id is captured in-app the first time it's used and saved
|
|
13
|
+
// to config.json (the same way accountSlug is configured). Region + size are
|
|
14
|
+
// provider-specific, so switching provider re-defaults them from that provider's
|
|
15
|
+
// catalog.
|
|
16
|
+
|
|
17
|
+
import { useEffect, useState } from "react"
|
|
18
|
+
import { useKeyboard } from "@opentui/react"
|
|
19
|
+
import { theme } from "../../lib/theme.ts"
|
|
20
|
+
import { truncate } from "../../lib/format.ts"
|
|
21
|
+
import { Panel, Spinner, Centered, Field } from "../components.tsx"
|
|
22
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
23
|
+
import { useStore, isNewServerInFlight } from "../store.tsx"
|
|
24
|
+
import { moveSelection } from "../List.tsx"
|
|
25
|
+
import { openUrl } from "../../lib/open.ts"
|
|
26
|
+
import { serverProvidersSettingsUrl } from "../../lib/spinupweb.ts"
|
|
27
|
+
import {
|
|
28
|
+
providerKeyFromName,
|
|
29
|
+
providerLabel,
|
|
30
|
+
sizeBySlug,
|
|
31
|
+
regionBySlug,
|
|
32
|
+
sizesForRegion,
|
|
33
|
+
matchSizeBySpec,
|
|
34
|
+
parseSizeSpec,
|
|
35
|
+
allRegions,
|
|
36
|
+
firstRegion,
|
|
37
|
+
formatSize,
|
|
38
|
+
formatCost,
|
|
39
|
+
suggestServerName,
|
|
40
|
+
PROVIDER_KEYS,
|
|
41
|
+
type ProviderKey,
|
|
42
|
+
} from "../../lib/serverCreate.ts"
|
|
43
|
+
import type { CreateServerPayload } from "../../api/types.ts"
|
|
44
|
+
|
|
45
|
+
type Phase =
|
|
46
|
+
| "providers"
|
|
47
|
+
| "provider"
|
|
48
|
+
| "loading"
|
|
49
|
+
| "form"
|
|
50
|
+
| "regions"
|
|
51
|
+
| "sizes"
|
|
52
|
+
| "name"
|
|
53
|
+
| "confirm"
|
|
54
|
+
| "tracking"
|
|
55
|
+
| "done"
|
|
56
|
+
| "error"
|
|
57
|
+
|
|
58
|
+
// m:ss elapsed since a build was fired.
|
|
59
|
+
function fmtElapsed(ms: number): string {
|
|
60
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
61
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function NewServer() {
|
|
65
|
+
const store = useStore()
|
|
66
|
+
const {
|
|
67
|
+
newServerOpen,
|
|
68
|
+
setNewServerOpen,
|
|
69
|
+
newServerSource: source,
|
|
70
|
+
setNewServerSource,
|
|
71
|
+
servers,
|
|
72
|
+
serverProviders,
|
|
73
|
+
providerMetadata,
|
|
74
|
+
providerMetadataLoading,
|
|
75
|
+
providerMetadataError,
|
|
76
|
+
loadProviderMetadata,
|
|
77
|
+
newServerJob,
|
|
78
|
+
startNewServer,
|
|
79
|
+
clearNewServer,
|
|
80
|
+
saveServerProviderId,
|
|
81
|
+
accountSlug,
|
|
82
|
+
setInputMode,
|
|
83
|
+
} = store
|
|
84
|
+
|
|
85
|
+
const sourceProviderKey = providerKeyFromName(source?.provider_name)
|
|
86
|
+
|
|
87
|
+
// The active provider (may differ from the source). Null only when the source's
|
|
88
|
+
// provider can't be mapped — then we open the picker so the user chooses one.
|
|
89
|
+
const [selectedProviderKey, setSelectedProviderKey] = useState<ProviderKey | null>(sourceProviderKey)
|
|
90
|
+
// Which provider the id-capture form is collecting for (transient).
|
|
91
|
+
const [providerCaptureKey, setProviderCaptureKey] = useState<ProviderKey | null>(null)
|
|
92
|
+
|
|
93
|
+
const providerRef = selectedProviderKey ? serverProviders[selectedProviderKey] : undefined
|
|
94
|
+
const md = selectedProviderKey ? providerMetadata.get(selectedProviderKey) : undefined
|
|
95
|
+
const mdLoading = selectedProviderKey ? providerMetadataLoading.has(selectedProviderKey) : false
|
|
96
|
+
const mdError = selectedProviderKey ? providerMetadataError.get(selectedProviderKey) : undefined
|
|
97
|
+
|
|
98
|
+
const [phase, setPhase] = useState<Phase>(() => {
|
|
99
|
+
if (newServerJob && isNewServerInFlight(newServerJob)) return "tracking"
|
|
100
|
+
// Land on the form (defaulting to the source's provider) when it's already
|
|
101
|
+
// linked; otherwise open the picker so the user can choose or add a provider.
|
|
102
|
+
if (sourceProviderKey && serverProviders[sourceProviderKey]) return "loading"
|
|
103
|
+
return "providers"
|
|
104
|
+
})
|
|
105
|
+
// Region + size start empty; seeded with real catalog values once metadata loads
|
|
106
|
+
// (a Server's region/size are display codes, not slugs — see the seeding effect).
|
|
107
|
+
const [regionSlug, setRegionSlug] = useState<string>("")
|
|
108
|
+
const [sizeSlug, setSizeSlug] = useState<string>("")
|
|
109
|
+
// The desired specs to carry across provider/region switches. Seeded from the
|
|
110
|
+
// source server; updated whenever the user picks a size. So switching providers
|
|
111
|
+
// keeps "the same size" (closest match in that provider's catalog) rather than
|
|
112
|
+
// resetting to the cheapest, and a manual size choice sticks across switches.
|
|
113
|
+
const [desiredSpec, setDesiredSpec] = useState(() => parseSizeSpec(source?.size))
|
|
114
|
+
const [backups, setBackups] = useState(false)
|
|
115
|
+
const [hostname, setHostname] = useState<string>("")
|
|
116
|
+
const [providerIndex, setProviderIndex] = useState(() => {
|
|
117
|
+
const i = sourceProviderKey ? PROVIDER_KEYS.indexOf(sourceProviderKey) : 0
|
|
118
|
+
return i >= 0 ? i : 0
|
|
119
|
+
})
|
|
120
|
+
const [regionIndex, setRegionIndex] = useState(0)
|
|
121
|
+
const [sizeIndex, setSizeIndex] = useState(0)
|
|
122
|
+
const [providerIdInput, setProviderIdInput] = useState("")
|
|
123
|
+
|
|
124
|
+
// Seed a hostname suggestion from the fleet, but never clobber a value the user
|
|
125
|
+
// has already typed/submitted. The servers list refreshes mid-build, and an
|
|
126
|
+
// unguarded re-seed here would overwrite the in-flight name with a new suggestion.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
setHostname((h) => h || suggestServerName(servers))
|
|
129
|
+
}, [servers])
|
|
130
|
+
|
|
131
|
+
// Kick off the metadata fetch while in "loading"; flip to the form once it's in.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (phase !== "loading" || !selectedProviderKey) return
|
|
134
|
+
if (md) setPhase("form")
|
|
135
|
+
else if (!mdLoading && !mdError) loadProviderMetadata(selectedProviderKey)
|
|
136
|
+
}, [phase, selectedProviderKey, md, mdLoading, mdError, loadProviderMetadata])
|
|
137
|
+
|
|
138
|
+
// Text sub-forms own the keyboard (suppress global shortcuts while typing).
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
setInputMode(phase === "name" || phase === "provider")
|
|
141
|
+
return () => setInputMode(false)
|
|
142
|
+
}, [phase, setInputMode])
|
|
143
|
+
|
|
144
|
+
// Default/validate region + size against the active provider's catalog. Runs on
|
|
145
|
+
// load and whenever the provider or region changes. Source region/size are
|
|
146
|
+
// display codes, so for the same provider we match by code/specs; for a
|
|
147
|
+
// different provider we fall back to that catalog's first region + size.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!md) return
|
|
150
|
+
let region = regionBySlug(md, regionSlug)
|
|
151
|
+
if (!region) {
|
|
152
|
+
// Prefer the source server's region only when we're on its provider (a DO
|
|
153
|
+
// region slug won't exist in Hetzner's catalog, and vice versa).
|
|
154
|
+
const preferSource = selectedProviderKey === sourceProviderKey
|
|
155
|
+
region = (preferSource ? regionBySlug(md, source?.region) : undefined) ?? firstRegion(md)
|
|
156
|
+
}
|
|
157
|
+
const rSlug = region?.slug ?? ""
|
|
158
|
+
if (rSlug !== regionSlug) setRegionSlug(rSlug)
|
|
159
|
+
|
|
160
|
+
const sizes = sizesForRegion(md, rSlug)
|
|
161
|
+
if (!sizes.find((s) => s.slug === sizeSlug)) {
|
|
162
|
+
// Carry the desired spec into this region's sizes (closest match); only fall
|
|
163
|
+
// back to the cheapest when nothing matches. Region-scoped so we never pick a
|
|
164
|
+
// size the region doesn't offer.
|
|
165
|
+
const matched = matchSizeBySpec(sizes, desiredSpec.vcpus, desiredSpec.memoryMb)
|
|
166
|
+
const next = matched ?? sizes[0]?.slug ?? ""
|
|
167
|
+
if (next !== sizeSlug) setSizeSlug(next)
|
|
168
|
+
}
|
|
169
|
+
}, [md, selectedProviderKey, regionSlug, desiredSpec]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
170
|
+
|
|
171
|
+
// Follow the store job once we've fired (mirrors PhpUpgrade): a settled failure
|
|
172
|
+
// shows the error; "done" shows success; anything else is still in flight.
|
|
173
|
+
const dp: Phase =
|
|
174
|
+
phase !== "tracking"
|
|
175
|
+
? phase
|
|
176
|
+
: !newServerJob
|
|
177
|
+
? "tracking"
|
|
178
|
+
: newServerJob.status === "failed"
|
|
179
|
+
? "error"
|
|
180
|
+
: newServerJob.status === "done"
|
|
181
|
+
? "done"
|
|
182
|
+
: "tracking"
|
|
183
|
+
|
|
184
|
+
const region = regionBySlug(md, regionSlug)
|
|
185
|
+
const size = sizeBySlug(md, sizeSlug)
|
|
186
|
+
const regionSizes = sizesForRegion(md, regionSlug)
|
|
187
|
+
const regionList = allRegions(md)
|
|
188
|
+
|
|
189
|
+
// What to DISPLAY as the server name: once fired, the job holds the actual
|
|
190
|
+
// submitted name (source of truth); before that, it's the editable field.
|
|
191
|
+
const displayName = newServerJob?.hostname || hostname
|
|
192
|
+
|
|
193
|
+
// Tick once a second while a build is in flight so the elapsed readout advances.
|
|
194
|
+
const [now, setNow] = useState(() => Date.now())
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (dp !== "tracking") return
|
|
197
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
198
|
+
return () => clearInterval(id)
|
|
199
|
+
}, [dp])
|
|
200
|
+
|
|
201
|
+
const close = () => {
|
|
202
|
+
setInputMode(false)
|
|
203
|
+
if (newServerJob && !isNewServerInFlight(newServerJob)) clearNewServer()
|
|
204
|
+
setNewServerOpen(false)
|
|
205
|
+
setNewServerSource(null)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Switch to (or add) a provider from the picker.
|
|
209
|
+
const chooseProvider = (key: ProviderKey) => {
|
|
210
|
+
if (!serverProviders[key]) {
|
|
211
|
+
// Not linked yet → capture its id first.
|
|
212
|
+
setProviderCaptureKey(key)
|
|
213
|
+
setProviderIdInput("")
|
|
214
|
+
return setPhase("provider")
|
|
215
|
+
}
|
|
216
|
+
if (key !== selectedProviderKey) {
|
|
217
|
+
setSelectedProviderKey(key)
|
|
218
|
+
setRegionSlug("") // re-default region + size for the new provider's catalog
|
|
219
|
+
setSizeSlug("")
|
|
220
|
+
return setPhase(providerMetadata.has(key) ? "form" : "loading")
|
|
221
|
+
}
|
|
222
|
+
setPhase("form")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const saveProviderId = () => {
|
|
226
|
+
const id = parseInt(providerIdInput.trim(), 10)
|
|
227
|
+
const key = providerCaptureKey
|
|
228
|
+
if (!key || !Number.isFinite(id) || id <= 0) return
|
|
229
|
+
saveServerProviderId(key, id)
|
|
230
|
+
setSelectedProviderKey(key)
|
|
231
|
+
setRegionSlug("")
|
|
232
|
+
setSizeSlug("")
|
|
233
|
+
setPhase("loading") // metadata loads next, then the form
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const fire = () => {
|
|
237
|
+
if (!providerRef || !regionSlug || !sizeSlug) return
|
|
238
|
+
const name = hostname.trim()
|
|
239
|
+
if (!name) return setPhase("name")
|
|
240
|
+
const payload: CreateServerPayload = {
|
|
241
|
+
server_provider: {
|
|
242
|
+
id: providerRef.id,
|
|
243
|
+
region: region?.slug ?? regionSlug,
|
|
244
|
+
size: sizeSlug,
|
|
245
|
+
enable_backups: backups,
|
|
246
|
+
},
|
|
247
|
+
hostname: name,
|
|
248
|
+
...(source?.timezone ? { timezone: source.timezone } : {}),
|
|
249
|
+
...(providerRef.databaseProviderId ? { database_provider: { id: providerRef.databaseProviderId } } : {}),
|
|
250
|
+
}
|
|
251
|
+
startNewServer(payload, name)
|
|
252
|
+
setPhase("tracking")
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
useKeyboard((key) => {
|
|
256
|
+
const name = key.name ?? ""
|
|
257
|
+
|
|
258
|
+
// Text sub-forms: the <input> handles text + Enter (onSubmit); we only cancel.
|
|
259
|
+
if (dp === "provider") {
|
|
260
|
+
if (name === "escape") return setPhase("providers")
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (dp === "name") {
|
|
264
|
+
if (name === "escape") return setPhase("form")
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Esc steps back one level from the list sub-screens (q still abandons the
|
|
269
|
+
// whole overlay via the catch-all below). regions/sizes are only reachable
|
|
270
|
+
// from the form, so Esc always returns there; the providers screen doubles as
|
|
271
|
+
// the entry point when no provider is linked, so it only steps back when we
|
|
272
|
+
// came from a loaded form (same guard ←/h uses).
|
|
273
|
+
if (name === "escape") {
|
|
274
|
+
if (dp === "regions" || dp === "sizes" || dp === "confirm") return setPhase("form")
|
|
275
|
+
if (dp === "providers") return selectedProviderKey && md ? setPhase("form") : close()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (name === "escape" || name === "q") return close()
|
|
279
|
+
|
|
280
|
+
if (dp === "loading") {
|
|
281
|
+
if (name === "r" && selectedProviderKey && mdError) loadProviderMetadata(selectedProviderKey)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (dp === "providers") {
|
|
286
|
+
switch (name) {
|
|
287
|
+
case "up":
|
|
288
|
+
case "k":
|
|
289
|
+
return setProviderIndex((i) => moveSelection(i, -1, PROVIDER_KEYS.length))
|
|
290
|
+
case "down":
|
|
291
|
+
case "j":
|
|
292
|
+
return setProviderIndex((i) => moveSelection(i, 1, PROVIDER_KEYS.length))
|
|
293
|
+
case "return":
|
|
294
|
+
case "right":
|
|
295
|
+
case "l":
|
|
296
|
+
return chooseProvider(PROVIDER_KEYS[providerIndex])
|
|
297
|
+
case "w":
|
|
298
|
+
if (accountSlug) openUrl(serverProvidersSettingsUrl(accountSlug))
|
|
299
|
+
return
|
|
300
|
+
case "left":
|
|
301
|
+
case "h":
|
|
302
|
+
if (selectedProviderKey && md) return setPhase("form")
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (dp === "form") {
|
|
309
|
+
switch (name) {
|
|
310
|
+
case "p":
|
|
311
|
+
setProviderIndex(Math.max(0, PROVIDER_KEYS.indexOf(selectedProviderKey as ProviderKey)))
|
|
312
|
+
return setPhase("providers")
|
|
313
|
+
case "g":
|
|
314
|
+
if (!md) return
|
|
315
|
+
setRegionIndex(Math.max(0, regionList.findIndex((r) => r.slug === regionSlug)))
|
|
316
|
+
return setPhase("regions")
|
|
317
|
+
case "e":
|
|
318
|
+
if (!md) return
|
|
319
|
+
setSizeIndex(Math.max(0, regionSizes.findIndex((s) => s.slug === sizeSlug)))
|
|
320
|
+
return setPhase("sizes")
|
|
321
|
+
case "b":
|
|
322
|
+
return setBackups((v) => !v)
|
|
323
|
+
case "r":
|
|
324
|
+
return setPhase("name")
|
|
325
|
+
case "return":
|
|
326
|
+
case "right":
|
|
327
|
+
case "l":
|
|
328
|
+
if (regionSlug && sizeSlug) setPhase("confirm")
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (dp === "regions") {
|
|
335
|
+
switch (name) {
|
|
336
|
+
case "up":
|
|
337
|
+
case "k":
|
|
338
|
+
return setRegionIndex((i) => moveSelection(i, -1, regionList.length))
|
|
339
|
+
case "down":
|
|
340
|
+
case "j":
|
|
341
|
+
return setRegionIndex((i) => moveSelection(i, 1, regionList.length))
|
|
342
|
+
case "return":
|
|
343
|
+
case "right":
|
|
344
|
+
case "l": {
|
|
345
|
+
const picked = regionList[regionIndex]
|
|
346
|
+
if (picked) setRegionSlug(picked.slug)
|
|
347
|
+
return setPhase("form")
|
|
348
|
+
}
|
|
349
|
+
case "left":
|
|
350
|
+
case "h":
|
|
351
|
+
return setPhase("form")
|
|
352
|
+
}
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (dp === "sizes") {
|
|
357
|
+
switch (name) {
|
|
358
|
+
case "up":
|
|
359
|
+
case "k":
|
|
360
|
+
return setSizeIndex((i) => moveSelection(i, -1, regionSizes.length))
|
|
361
|
+
case "down":
|
|
362
|
+
case "j":
|
|
363
|
+
return setSizeIndex((i) => moveSelection(i, 1, regionSizes.length))
|
|
364
|
+
case "return":
|
|
365
|
+
case "right":
|
|
366
|
+
case "l": {
|
|
367
|
+
const picked = regionSizes[sizeIndex]
|
|
368
|
+
if (picked) {
|
|
369
|
+
setSizeSlug(picked.slug)
|
|
370
|
+
// Remember this choice so switching providers keeps the same size.
|
|
371
|
+
setDesiredSpec({ vcpus: picked.vcpus, memoryMb: picked.memory })
|
|
372
|
+
}
|
|
373
|
+
return setPhase("form")
|
|
374
|
+
}
|
|
375
|
+
case "left":
|
|
376
|
+
case "h":
|
|
377
|
+
return setPhase("form")
|
|
378
|
+
}
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (dp === "confirm") {
|
|
383
|
+
if (name === "y") return fire()
|
|
384
|
+
if (name === "left" || name === "h") return setPhase("form")
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (dp === "error") {
|
|
389
|
+
if (name === "r") {
|
|
390
|
+
clearNewServer()
|
|
391
|
+
setPhase("form")
|
|
392
|
+
}
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
if (!newServerOpen) return null
|
|
398
|
+
|
|
399
|
+
const titleProvider = selectedProviderKey ? providerLabel(selectedProviderKey) : "choose a provider"
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<box
|
|
403
|
+
style={{
|
|
404
|
+
position: "absolute",
|
|
405
|
+
top: 0,
|
|
406
|
+
left: 0,
|
|
407
|
+
width: "100%",
|
|
408
|
+
height: "100%",
|
|
409
|
+
flexDirection: "column",
|
|
410
|
+
backgroundColor: theme.bg,
|
|
411
|
+
// 240 so it layers ABOVE the clone wizard (218) when reused as that flow's
|
|
412
|
+
// "New server" step; standalone use is unaffected.
|
|
413
|
+
zIndex: 240,
|
|
414
|
+
}}
|
|
415
|
+
>
|
|
416
|
+
{/* Title bar */}
|
|
417
|
+
<box
|
|
418
|
+
style={{
|
|
419
|
+
flexDirection: "row",
|
|
420
|
+
height: 1,
|
|
421
|
+
backgroundColor: theme.bgAlt,
|
|
422
|
+
paddingLeft: 1,
|
|
423
|
+
paddingRight: 1,
|
|
424
|
+
alignItems: "center",
|
|
425
|
+
}}
|
|
426
|
+
>
|
|
427
|
+
<text content="✦ New server " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
428
|
+
<text content={displayName || "name it below"} fg={displayName ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
429
|
+
<box style={{ flexGrow: 1 }} />
|
|
430
|
+
<text content={truncate(titleProvider, 24)} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
431
|
+
</box>
|
|
432
|
+
|
|
433
|
+
<Centered>{renderBody()}</Centered>
|
|
434
|
+
|
|
435
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
436
|
+
</box>
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
function renderBody() {
|
|
440
|
+
if (dp === "providers") return renderProviders()
|
|
441
|
+
if (dp === "provider") return renderProvider()
|
|
442
|
+
|
|
443
|
+
if (dp === "loading") {
|
|
444
|
+
return (
|
|
445
|
+
<box style={{ flexDirection: "column", alignItems: "center" }}>
|
|
446
|
+
{mdError ? (
|
|
447
|
+
<>
|
|
448
|
+
<text content={`✕ ${mdError}`} fg={theme.bad} wrapMode="none" />
|
|
449
|
+
<box style={{ height: 1 }} />
|
|
450
|
+
<text content="r retry · Esc to close" fg={theme.textFaint} />
|
|
451
|
+
</>
|
|
452
|
+
) : (
|
|
453
|
+
<box style={{ flexDirection: "row" }}>
|
|
454
|
+
<Spinner />
|
|
455
|
+
<text content={` Loading ${selectedProviderKey ? providerLabel(selectedProviderKey) : ""} sizes & pricing…`} fg={theme.textDim} />
|
|
456
|
+
</box>
|
|
457
|
+
)}
|
|
458
|
+
</box>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (dp === "regions") return renderRegions()
|
|
463
|
+
if (dp === "sizes") return renderSizes()
|
|
464
|
+
if (dp === "name") return renderName()
|
|
465
|
+
if (dp === "confirm") return renderConfirm()
|
|
466
|
+
if (dp === "tracking") return renderTracking()
|
|
467
|
+
if (dp === "done") return renderDone()
|
|
468
|
+
if (dp === "error") return renderError()
|
|
469
|
+
return renderForm()
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function renderForm() {
|
|
473
|
+
return (
|
|
474
|
+
<Panel title=" New server " active>
|
|
475
|
+
<box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
|
|
476
|
+
<Field label="Provider" value={selectedProviderKey ? providerLabel(selectedProviderKey) : "—"} />
|
|
477
|
+
<Field label="Region" value={region ? `${region.slug} (${region.name})` : (regionSlug || "—")} />
|
|
478
|
+
<Field label="Size" value={size ? `${size.slug} — ${formatSize(size)}` : (sizeSlug || "—")} />
|
|
479
|
+
<Field label="Cost" value={formatCost(size, backups)} valueColor={theme.good} />
|
|
480
|
+
<Field label="Backups" value={backups ? "on" : "off"} valueColor={backups ? theme.good : theme.textFaint} />
|
|
481
|
+
<box style={{ height: 1 }} />
|
|
482
|
+
<box style={{ flexDirection: "row" }}>
|
|
483
|
+
<text content="Name " fg={theme.textDim} />
|
|
484
|
+
<text content={hostname || "—"} fg={hostname ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
485
|
+
</box>
|
|
486
|
+
<box style={{ height: 1 }} />
|
|
487
|
+
<text content="p provider · g region · e size" fg={theme.textFaint} wrapMode="none" />
|
|
488
|
+
<text content="b backups · r rename · ⏎ review" fg={theme.textFaint} wrapMode="none" />
|
|
489
|
+
</box>
|
|
490
|
+
</Panel>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function renderProviders() {
|
|
495
|
+
return (
|
|
496
|
+
<Panel title=" Choose a provider " active>
|
|
497
|
+
<box style={{ flexDirection: "column", width: 60 }}>
|
|
498
|
+
{PROVIDER_KEYS.map((key, i) => {
|
|
499
|
+
const selected = i === providerIndex
|
|
500
|
+
const ref = serverProviders[key]
|
|
501
|
+
const isCurrent = key === selectedProviderKey
|
|
502
|
+
const fg = selected ? theme.text : isCurrent ? theme.accent : theme.textDim
|
|
503
|
+
const status = ref ? `id ${ref.id}` : "needs id"
|
|
504
|
+
const statusFg = selected ? theme.text : ref ? theme.textFaint : theme.warn
|
|
505
|
+
return (
|
|
506
|
+
<box key={key} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
|
|
507
|
+
<text content={(selected ? "❯ " : " ") + providerLabel(key) + (isCurrent ? " ●" : "")} fg={fg} style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" />
|
|
508
|
+
<text content={status} fg={statusFg} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
509
|
+
</box>
|
|
510
|
+
)
|
|
511
|
+
})}
|
|
512
|
+
<box style={{ height: 1 }} />
|
|
513
|
+
<text content="Pick a linked provider, or one marked “needs id” to add it." fg={theme.textFaint} wrapMode="none" />
|
|
514
|
+
<text
|
|
515
|
+
content={accountSlug ? "↑↓ choose · ⏎ select / add · w find ids in SpinupWP" : "↑↓ choose · ⏎ select / add"}
|
|
516
|
+
fg={theme.textFaint}
|
|
517
|
+
wrapMode="none"
|
|
518
|
+
/>
|
|
519
|
+
</box>
|
|
520
|
+
</Panel>
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderRegions() {
|
|
525
|
+
return (
|
|
526
|
+
<Panel title=" Choose a region " active>
|
|
527
|
+
<box style={{ flexDirection: "column", width: 60 }}>
|
|
528
|
+
{regionList.map((rg, i) => {
|
|
529
|
+
const selected = i === regionIndex
|
|
530
|
+
const isCurrent = rg.slug === regionSlug
|
|
531
|
+
const unavailable = rg.available === false
|
|
532
|
+
const fg = selected ? theme.text : unavailable ? theme.textFaint : isCurrent ? theme.accent : theme.textDim
|
|
533
|
+
return (
|
|
534
|
+
<box key={rg.slug} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
|
|
535
|
+
<text content={(selected ? "❯ " : " ") + rg.slug} fg={fg} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
536
|
+
<text content={` ${rg.name}`} fg={selected ? theme.text : theme.textFaint} style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" />
|
|
537
|
+
{unavailable ? <text content="full" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} /> : null}
|
|
538
|
+
</box>
|
|
539
|
+
)
|
|
540
|
+
})}
|
|
541
|
+
<box style={{ height: 1 }} />
|
|
542
|
+
<text content="↑↓ choose · ⏎ select · ←/esc back" fg={theme.textFaint} wrapMode="none" />
|
|
543
|
+
</box>
|
|
544
|
+
</Panel>
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function renderSizes() {
|
|
549
|
+
return (
|
|
550
|
+
<Panel title=" Choose a size " active>
|
|
551
|
+
<box style={{ flexDirection: "column", width: 62 }}>
|
|
552
|
+
{regionSizes.map((s, i) => {
|
|
553
|
+
const selected = i === sizeIndex
|
|
554
|
+
const isCurrent = s.slug === sizeSlug
|
|
555
|
+
const fg = selected ? theme.text : isCurrent ? theme.accent : theme.textDim
|
|
556
|
+
return (
|
|
557
|
+
<box key={s.slug} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
|
|
558
|
+
<text content={(selected ? "❯ " : " ") + s.slug} fg={fg} style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" />
|
|
559
|
+
<text content={formatSize(s) + " "} fg={selected ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
560
|
+
<text content={formatCost(s, backups)} fg={selected ? theme.text : theme.good} style={{ flexShrink: 0 }} />
|
|
561
|
+
</box>
|
|
562
|
+
)
|
|
563
|
+
})}
|
|
564
|
+
<box style={{ height: 1 }} />
|
|
565
|
+
<text content="↑↓ choose · ⏎ select · ←/esc back" fg={theme.textFaint} wrapMode="none" />
|
|
566
|
+
</box>
|
|
567
|
+
</Panel>
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function renderName() {
|
|
572
|
+
return (
|
|
573
|
+
<Panel title=" Name the server " active>
|
|
574
|
+
<box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
|
|
575
|
+
<text content="Hostname (letters, numbers, dashes, periods)" fg={theme.accent} wrapMode="none" />
|
|
576
|
+
<input
|
|
577
|
+
focused
|
|
578
|
+
value={hostname}
|
|
579
|
+
placeholder="web1.example.com"
|
|
580
|
+
onInput={setHostname}
|
|
581
|
+
onSubmit={() => setPhase("form")}
|
|
582
|
+
style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
583
|
+
/>
|
|
584
|
+
<box style={{ height: 1 }} />
|
|
585
|
+
<text content="A conventional name like web12.example.com keeps your fleet tidy." fg={theme.textFaint} />
|
|
586
|
+
<text content="Enter to accept · Esc to go back" fg={theme.textFaint} wrapMode="none" />
|
|
587
|
+
</box>
|
|
588
|
+
</Panel>
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function renderConfirm() {
|
|
593
|
+
return (
|
|
594
|
+
<Panel title=" Confirm — this provisions a real server " active>
|
|
595
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
596
|
+
<box style={{ flexDirection: "row" }}>
|
|
597
|
+
<text content="Create " fg={theme.text} />
|
|
598
|
+
<text content={hostname} fg={theme.accent} wrapMode="none" />
|
|
599
|
+
</box>
|
|
600
|
+
<box style={{ height: 1 }} />
|
|
601
|
+
<Field label="Provider" value={selectedProviderKey ? providerLabel(selectedProviderKey) : "—"} />
|
|
602
|
+
<Field label="Region" value={region ? `${region.slug} (${region.name})` : regionSlug} />
|
|
603
|
+
<Field label="Size" value={size ? `${size.slug} — ${formatSize(size)}` : sizeSlug} />
|
|
604
|
+
<Field label="Backups" value={backups ? "on" : "off"} valueColor={backups ? theme.good : theme.textFaint} />
|
|
605
|
+
<box style={{ flexDirection: "row" }}>
|
|
606
|
+
<text content="Cost " fg={theme.textDim} />
|
|
607
|
+
<text content={formatCost(size, backups)} fg={theme.good} />
|
|
608
|
+
<text content=" — billed by your provider" fg={theme.textFaint} wrapMode="none" />
|
|
609
|
+
</box>
|
|
610
|
+
<box style={{ height: 1 }} />
|
|
611
|
+
<text content="Provisioning takes ~10 minutes." fg={theme.textDim} wrapMode="none" />
|
|
612
|
+
<text content="Press y to create · ←/esc to go back · q to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
613
|
+
</box>
|
|
614
|
+
</Panel>
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function renderTracking() {
|
|
619
|
+
const status = newServerJob?.status ?? "queued"
|
|
620
|
+
const elapsed = newServerJob?.startedAt ? fmtElapsed(now - newServerJob.startedAt) : null
|
|
621
|
+
return (
|
|
622
|
+
<box style={{ flexDirection: "column", alignItems: "center" }}>
|
|
623
|
+
<box style={{ flexDirection: "row" }}>
|
|
624
|
+
<Spinner />
|
|
625
|
+
<text content={` Provisioning ${displayName}…`} fg={theme.textDim} />
|
|
626
|
+
</box>
|
|
627
|
+
<box style={{ height: 1 }} />
|
|
628
|
+
<box style={{ flexDirection: "row" }}>
|
|
629
|
+
<text content="Status " fg={theme.textFaint} />
|
|
630
|
+
<text content={status} fg={theme.text} wrapMode="none" />
|
|
631
|
+
{elapsed ? <text content={` · elapsed ${elapsed}`} fg={theme.textFaint} wrapMode="none" /> : null}
|
|
632
|
+
</box>
|
|
633
|
+
<box style={{ height: 1 }} />
|
|
634
|
+
<text content="Typically ~10 min. Press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
635
|
+
</box>
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function renderDone() {
|
|
640
|
+
return (
|
|
641
|
+
<Panel title=" Server created " active>
|
|
642
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
643
|
+
<box style={{ flexDirection: "row" }}>
|
|
644
|
+
<text content="✓ " fg={theme.good} style={{ flexShrink: 0 }} />
|
|
645
|
+
<text content={displayName} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
646
|
+
</box>
|
|
647
|
+
<text content="It's up — and in your Servers list now." fg={theme.text} wrapMode="none" />
|
|
648
|
+
<box style={{ height: 1 }} />
|
|
649
|
+
<text content="Next: connect it with a vanity site" fg={theme.accent} wrapMode="none" />
|
|
650
|
+
<text content="A brand-new server has no site, so there's nothing to attach an" fg={theme.textDim} />
|
|
651
|
+
<text content="SSH key to and no way to reach it from SpinupTUI. Creating a vanity" fg={theme.textDim} />
|
|
652
|
+
<text content="site — a tiny placeholder at the server's own hostname — gives" fg={theme.textDim} />
|
|
653
|
+
<text content="you that foothold. Open the server in your Servers list to do it." fg={theme.textDim} />
|
|
654
|
+
<box style={{ height: 1 }} />
|
|
655
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
656
|
+
</box>
|
|
657
|
+
</Panel>
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function renderError() {
|
|
662
|
+
return (
|
|
663
|
+
<Panel title=" Create failed " active>
|
|
664
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
665
|
+
<text content={`✕ ${newServerJob?.error ?? "Something went wrong."}`} fg={theme.bad} wrapMode="none" />
|
|
666
|
+
<box style={{ height: 1 }} />
|
|
667
|
+
<text content="Press r to adjust and try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
668
|
+
</box>
|
|
669
|
+
</Panel>
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function renderProvider() {
|
|
674
|
+
const key = providerCaptureKey ?? selectedProviderKey
|
|
675
|
+
return (
|
|
676
|
+
<Panel title=" Link your server provider " active>
|
|
677
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
678
|
+
<text content={`${key ? providerLabel(key) : "This provider"} isn't linked in SpinupTUI yet.`} fg={theme.text} wrapMode="none" />
|
|
679
|
+
<text content="Paste its SpinupWP provider id (Account Settings →" fg={theme.textDim} wrapMode="none" />
|
|
680
|
+
<text content="Server Providers — the ID column):" fg={theme.textDim} wrapMode="none" />
|
|
681
|
+
<box style={{ height: 1 }} />
|
|
682
|
+
<input
|
|
683
|
+
focused
|
|
684
|
+
value={providerIdInput}
|
|
685
|
+
placeholder="e.g. 7577"
|
|
686
|
+
onInput={setProviderIdInput}
|
|
687
|
+
onSubmit={saveProviderId}
|
|
688
|
+
style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
689
|
+
/>
|
|
690
|
+
<box style={{ height: 1 }} />
|
|
691
|
+
<text content="It's saved to your config — you only do this once per provider." fg={theme.textFaint} wrapMode="none" />
|
|
692
|
+
<text content="⏎ save · Esc back to providers" fg={theme.textFaint} wrapMode="none" />
|
|
693
|
+
</box>
|
|
694
|
+
</Panel>
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function hints() {
|
|
699
|
+
switch (dp) {
|
|
700
|
+
case "providers": {
|
|
701
|
+
const canGoBack = !!(selectedProviderKey && md)
|
|
702
|
+
return [
|
|
703
|
+
{ key: "↑↓/jk", label: "provider" },
|
|
704
|
+
{ key: "⏎", label: "select / add" },
|
|
705
|
+
...(accountSlug ? [{ key: "w", label: "SpinupWP" }] : []),
|
|
706
|
+
canGoBack ? { key: "←/esc", label: "back" } : { key: "esc", label: "cancel" },
|
|
707
|
+
]
|
|
708
|
+
}
|
|
709
|
+
case "provider":
|
|
710
|
+
return [
|
|
711
|
+
{ key: "⏎", label: "save" },
|
|
712
|
+
{ key: "esc", label: "back" },
|
|
713
|
+
]
|
|
714
|
+
case "form":
|
|
715
|
+
return [
|
|
716
|
+
{ key: "p", label: "provider" },
|
|
717
|
+
{ key: "g", label: "region" },
|
|
718
|
+
{ key: "e", label: "size" },
|
|
719
|
+
{ key: "b", label: "backups" },
|
|
720
|
+
{ key: "r", label: "rename" },
|
|
721
|
+
{ key: "⏎", label: "review" },
|
|
722
|
+
]
|
|
723
|
+
case "regions":
|
|
724
|
+
case "sizes":
|
|
725
|
+
return [
|
|
726
|
+
{ key: "↑↓/jk", label: dp === "regions" ? "region" : "size" },
|
|
727
|
+
{ key: "⏎", label: "select" },
|
|
728
|
+
{ key: "←/esc", label: "back" },
|
|
729
|
+
]
|
|
730
|
+
case "name":
|
|
731
|
+
return [
|
|
732
|
+
{ key: "⏎", label: "accept" },
|
|
733
|
+
{ key: "esc", label: "back" },
|
|
734
|
+
]
|
|
735
|
+
case "confirm":
|
|
736
|
+
return [
|
|
737
|
+
{ key: "y", label: "create" },
|
|
738
|
+
{ key: "←/esc", label: "back" },
|
|
739
|
+
{ key: "q", label: "cancel" },
|
|
740
|
+
]
|
|
741
|
+
case "error":
|
|
742
|
+
return [
|
|
743
|
+
{ key: "r", label: "retry" },
|
|
744
|
+
{ key: "esc", label: "close" },
|
|
745
|
+
]
|
|
746
|
+
default:
|
|
747
|
+
return [{ key: "esc", label: "close" }]
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|