spinuptui 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -0,0 +1,322 @@
1
+ // Live server health view (full-screen overlay).
2
+ //
3
+ // Opened from the Browser with `h`. Polls the selected server over SSH every
4
+ // few seconds and renders a custom dashboard: CPU (aggregate gauge + sparkline
5
+ // + per-core), memory/swap, disk mounts, and top processes. Esc/q/h closes it.
6
+
7
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
8
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
9
+ import { theme, diskColor } from "../../lib/theme.ts"
10
+ import { formatBytes, bar, formatUptime, sparkline, timeAgo, truncate } from "../../lib/format.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore, type KumaDomainStatus } from "../store.tsx"
14
+ import { fetchServerHealth, type HealthSnapshot } from "../../lib/ssh.ts"
15
+
16
+ const POLL_MS = 2500
17
+
18
+ // Color a 0..100 load/usage figure greenโ†’amberโ†’red.
19
+ function loadColor(load: number, cores: number): string {
20
+ if (load >= cores) return theme.bad
21
+ if (load >= cores * 0.7) return theme.warn
22
+ return theme.good
23
+ }
24
+
25
+ // A labeled horizontal gauge: [label] [bar] [pct] [trailing].
26
+ function Gauge({
27
+ label,
28
+ pct,
29
+ color,
30
+ width = 20,
31
+ trailing,
32
+ labelWidth = 8,
33
+ }: {
34
+ label: string
35
+ pct: number
36
+ color?: string
37
+ width?: number
38
+ trailing?: string
39
+ labelWidth?: number
40
+ }) {
41
+ const c = color ?? diskColor(pct)
42
+ return (
43
+ <box style={{ flexDirection: "row", height: 1 }}>
44
+ <text content={label.padEnd(labelWidth)} fg={theme.textDim} style={{ flexShrink: 0 }} />
45
+ <text content={bar(pct / 100, width)} fg={c} style={{ flexShrink: 0 }} />
46
+ <text content={` ${pct.toFixed(0)}%`.padStart(5)} fg={c} style={{ flexShrink: 0 }} />
47
+ {trailing && <text content={` ${trailing}`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />}
48
+ </box>
49
+ )
50
+ }
51
+
52
+ export function Health() {
53
+ const { healthServer, setHealthServer, sitesForServer, sshUser, kumaStatus } = useStore()
54
+ const { height } = useTerminalDimensions()
55
+ const server = healthServer
56
+
57
+ const sites = useMemo(() => (server ? sitesForServer(server.id) : []), [server, sitesForServer])
58
+
59
+ const [snapshot, setSnapshot] = useState<HealthSnapshot | null>(null)
60
+ const [error, setError] = useState<string | null>(null)
61
+ const [target, setTarget] = useState("")
62
+ const [loading, setLoading] = useState(true)
63
+ const [history, setHistory] = useState<number[]>([])
64
+ const busy = useRef(false)
65
+
66
+ const poll = useCallback(async () => {
67
+ if (!server || busy.current) return
68
+ busy.current = true
69
+ const res = await fetchServerHealth(server, sites, sshUser)
70
+ busy.current = false
71
+ setTarget(res.target)
72
+ setLoading(false)
73
+ if (res.ok) {
74
+ setSnapshot(res.snapshot)
75
+ setError(null)
76
+ setHistory((h) => [...h, res.snapshot.cpuPct].slice(-120))
77
+ } else {
78
+ setError(res.error)
79
+ }
80
+ }, [server, sites, sshUser])
81
+
82
+ // Poll on open and on an interval; reset when the target server changes.
83
+ useEffect(() => {
84
+ setLoading(true)
85
+ setSnapshot(null)
86
+ setError(null)
87
+ setHistory([])
88
+ void poll()
89
+ const id = setInterval(() => void poll(), POLL_MS)
90
+ return () => clearInterval(id)
91
+ }, [poll])
92
+
93
+ useKeyboard((key) => {
94
+ if (key.name === "escape" || key.name === "q" || key.name === "h") return setHealthServer(null)
95
+ if (key.name === "r") return void poll()
96
+ })
97
+
98
+ if (!server) return null
99
+
100
+ // The conditional Kuma Monitor panel takes up to 5 rows (2 border + 3 content)
101
+ // out of the same right column โ€” shrink the process budget when it renders.
102
+ const kumaRows = kumaStatus.get(server.name) ? 5 : 0
103
+ const procRows = Math.max(3, Math.min(snapshot?.processes.length ?? 0, height - 18 - kumaRows))
104
+
105
+ return (
106
+ <box
107
+ style={{
108
+ position: "absolute",
109
+ top: 0,
110
+ left: 0,
111
+ width: "100%",
112
+ height: "100%",
113
+ flexDirection: "column",
114
+ backgroundColor: theme.bg,
115
+ zIndex: 200,
116
+ }}
117
+ >
118
+ {/* Title bar */}
119
+ <box
120
+ style={{
121
+ flexDirection: "row",
122
+ height: 1,
123
+ backgroundColor: theme.bgAlt,
124
+ paddingLeft: 1,
125
+ paddingRight: 1,
126
+ alignItems: "center",
127
+ }}
128
+ >
129
+ <text content="โฌข Server Health " fg={theme.brand} style={{ flexShrink: 0 }} />
130
+ <text content={truncate(server.name, 36)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
131
+ <text content={` ${target}`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
132
+ <box style={{ flexGrow: 1 }} />
133
+ {loading && <Spinner interval={120} />}
134
+ <text
135
+ content={snapshot ? ` updated ${timeAgo(new Date(snapshot.takenAt).toISOString())}` : " connectingโ€ฆ"}
136
+ fg={theme.textFaint}
137
+ style={{ flexShrink: 0 }}
138
+ />
139
+ </box>
140
+
141
+ {loading && !snapshot && !error ? (
142
+ <Centered>
143
+ <box style={{ flexDirection: "row" }}>
144
+ <Spinner />
145
+ <text content={` Connecting to ${target || server.ip_address}โ€ฆ`} fg={theme.textDim} />
146
+ </box>
147
+ </Centered>
148
+ ) : error && !snapshot ? (
149
+ <Centered>
150
+ <box
151
+ title=" Couldn't reach this server "
152
+ titleColor={theme.bad}
153
+ border
154
+ borderColor={theme.bad}
155
+ style={{ flexDirection: "column", width: 70, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
156
+ >
157
+ <text content={`โœ• ${error}`} fg={theme.bad} wrapMode="none" />
158
+ <box style={{ height: 1 }} />
159
+ <text content={`Tried: ssh ${target}`} fg={theme.textDim} />
160
+ <text content="The health view uses your local SSH keys (no password prompts)." fg={theme.textFaint} />
161
+ <text content={`Verify it works first: ssh ${target} uptime`} fg={theme.textFaint} />
162
+ <box style={{ height: 1 }} />
163
+ <text content="Press r to retry ยท Esc to close" fg={theme.textDim} />
164
+ </box>
165
+ </Centered>
166
+ ) : snapshot ? (
167
+ <HealthBody snapshot={snapshot} history={history} server={server} procRows={procRows} staleError={error} kuma={kumaStatus.get(server.name)} />
168
+ ) : null}
169
+
170
+ <StatusBar
171
+ hints={[
172
+ { key: "r", label: "refresh now" },
173
+ { key: "esc/q", label: "close" },
174
+ ]}
175
+ message={target ? `live ยท every ${(POLL_MS / 1000).toFixed(0)}s ยท ${target}` : undefined}
176
+ showGlobal={false}
177
+ />
178
+ </box>
179
+ )
180
+ }
181
+
182
+ function HealthBody({
183
+ snapshot,
184
+ history,
185
+ server,
186
+ procRows,
187
+ staleError,
188
+ kuma,
189
+ }: {
190
+ snapshot: HealthSnapshot
191
+ history: number[]
192
+ server: { name: string }
193
+ procRows: number
194
+ staleError: string | null
195
+ kuma?: KumaDomainStatus
196
+ }) {
197
+ const s = snapshot
198
+ const memPct = s.memTotal > 0 ? (s.memUsed / s.memTotal) * 100 : 0
199
+ const swapPct = s.swapTotal > 0 ? (s.swapUsed / s.swapTotal) * 100 : 0
200
+
201
+ return (
202
+ <box style={{ flexGrow: 1, flexDirection: "column", padding: 1, gap: 1 }}>
203
+ {/* Top summary row */}
204
+ <box style={{ flexDirection: "row", gap: 1 }}>
205
+ <Summary label="Uptime" value={formatUptime(s.uptimeSecs)} color={theme.text} />
206
+ <Summary label="Load 1m" value={s.load[0].toFixed(2)} color={loadColor(s.load[0], s.cores)} sub={`5m ${s.load[1].toFixed(2)} ยท 15m ${s.load[2].toFixed(2)}`} />
207
+ <Summary label="CPU" value={`${s.cpuPct.toFixed(0)}%`} color={diskColor(s.cpuPct)} sub={`${s.cores} cores`} />
208
+ <Summary label="Memory" value={`${memPct.toFixed(0)}%`} color={diskColor(memPct)} sub={`${formatBytes(s.memUsed)} / ${formatBytes(s.memTotal)}`} />
209
+ </box>
210
+
211
+ <box style={{ flexGrow: 1, flexDirection: "row", gap: 1 }}>
212
+ {/* Left: CPU + memory */}
213
+ <box style={{ flexDirection: "column", flexGrow: 1, gap: 1 }}>
214
+ <Panel title=" CPU " flexGrow={1}>
215
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
216
+ <Gauge label="all" pct={s.cpuPct} color={diskColor(s.cpuPct)} width={20} labelWidth={6} />
217
+ <box style={{ height: 1 }} />
218
+ <box style={{ flexDirection: "row" }}>
219
+ <text content="history " fg={theme.textDim} style={{ flexShrink: 0 }} />
220
+ <text content={sparkline(history, 40)} fg={theme.brand} wrapMode="none" style={{ flexShrink: 1 }} />
221
+ </box>
222
+ <box style={{ height: 1 }} />
223
+ {s.perCore.map((c) => (
224
+ <Gauge key={c.idx} label={`cpu${c.idx}`} pct={c.pct} color={diskColor(c.pct)} width={16} labelWidth={6} />
225
+ ))}
226
+ </box>
227
+ </Panel>
228
+ <Panel title=" Memory " flexGrow={1}>
229
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
230
+ <Gauge label="RAM" pct={memPct} color={diskColor(memPct)} width={18} trailing={`${formatBytes(s.memUsed)} / ${formatBytes(s.memTotal)}`} />
231
+ {s.swapTotal > 0 ? (
232
+ <Gauge label="Swap" pct={swapPct} color={diskColor(swapPct)} width={18} trailing={`${formatBytes(s.swapUsed)} / ${formatBytes(s.swapTotal)}`} />
233
+ ) : (
234
+ <box style={{ flexDirection: "row", height: 1 }}>
235
+ <text content={"Swap".padEnd(8)} fg={theme.textDim} />
236
+ <text content="none" fg={theme.textFaint} />
237
+ </box>
238
+ )}
239
+ <box style={{ height: 1 }} />
240
+ <text content={`Available ${formatBytes(s.memAvailable)}`} fg={theme.textFaint} />
241
+ </box>
242
+ </Panel>
243
+ </box>
244
+
245
+ {/* Right: disks + processes */}
246
+ <box style={{ flexDirection: "column", flexGrow: 1, gap: 1 }}>
247
+ <Panel title=" Disk ">
248
+ <box style={{ flexDirection: "column" }}>
249
+ {s.disks.length === 0 ? (
250
+ <text content="No mounts reported" fg={theme.textFaint} />
251
+ ) : (
252
+ s.disks.slice(0, 5).map((d) => (
253
+ <Gauge
254
+ key={d.mount}
255
+ label={truncate(d.mount, 10)}
256
+ pct={d.pct}
257
+ color={diskColor(d.pct)}
258
+ width={10}
259
+ labelWidth={11}
260
+ trailing={`${formatBytes(d.used)}/${formatBytes(d.total)}`}
261
+ />
262
+ ))
263
+ )}
264
+ </box>
265
+ </Panel>
266
+ {kuma && (
267
+ <Panel title=" Monitor (Uptime Kuma) ">
268
+ <box style={{ flexDirection: "column" }}>
269
+ <box style={{ flexDirection: "row", height: 1 }}>
270
+ <text content={kuma.up === false ? "โ—‹ down" : kuma.up ? "โ— up" : "ยท no beats yet"} fg={kuma.up === false ? theme.bad : kuma.up ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
271
+ {kuma.uptime24 != null && <text content={` ${(kuma.uptime24 * 100).toFixed(2)}% (24h)`} fg={theme.textDim} wrapMode="none" />}
272
+ </box>
273
+ {kuma.responseBeats.length > 1 && (
274
+ <box style={{ flexDirection: "row", height: 1 }}>
275
+ <text content="response " fg={theme.textDim} style={{ flexShrink: 0 }} />
276
+ <text content={sparkline(kuma.responseBeats.slice(-40), 40)} fg={theme.brand} wrapMode="none" style={{ flexShrink: 1 }} />
277
+ </box>
278
+ )}
279
+ {kuma.loadBeats.length > 1 && (
280
+ <box style={{ flexDirection: "row", height: 1 }}>
281
+ <text content="load " fg={theme.textDim} style={{ flexShrink: 0 }} />
282
+ <text content={sparkline(kuma.loadBeats.slice(-40), 40)} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
283
+ {kuma.lastLoad != null && <text content={` ${kuma.lastLoad.toFixed(2)}`} fg={theme.textDim} style={{ flexShrink: 0 }} />}
284
+ </box>
285
+ )}
286
+ </box>
287
+ </Panel>
288
+ )}
289
+ <Panel title=" Top processes " flexGrow={1}>
290
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
291
+ <box style={{ flexDirection: "row", height: 1 }}>
292
+ <text content={"PID".padEnd(8)} fg={theme.textFaint} style={{ flexShrink: 0 }} />
293
+ <text content="COMMAND" fg={theme.textFaint} style={{ flexGrow: 1, flexShrink: 1 }} />
294
+ <text content=" CPU% MEM%" fg={theme.textFaint} style={{ flexShrink: 0 }} />
295
+ </box>
296
+ {s.processes.slice(0, procRows).map((p) => (
297
+ <box key={p.pid} style={{ flexDirection: "row", height: 1 }}>
298
+ <text content={(p.pid + " ").padEnd(8)} fg={theme.textDim} style={{ flexShrink: 0 }} />
299
+ <text content={truncate(p.comm, 18)} fg={theme.text} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
300
+ <text content={`${p.cpu.toFixed(1)}`.padStart(6)} fg={p.cpu > 50 ? theme.warn : theme.textDim} style={{ flexShrink: 0 }} />
301
+ <text content={`${p.mem.toFixed(1)}`.padStart(6)} fg={theme.textDim} style={{ flexShrink: 0 }} />
302
+ </box>
303
+ ))}
304
+ </box>
305
+ </Panel>
306
+ </box>
307
+ </box>
308
+
309
+ {staleError && <text content={`โš  last poll failed: ${staleError} โ€” showing previous reading`} fg={theme.warn} wrapMode="none" />}
310
+ </box>
311
+ )
312
+ }
313
+
314
+ function Summary({ label, value, color, sub }: { label: string; value: string; color: string; sub?: string }): ReactNode {
315
+ return (
316
+ <box border borderColor={theme.border} style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, height: 5 }}>
317
+ <text content={label} fg={theme.textDim} />
318
+ <text content={value} fg={color} attributes={1} />
319
+ <text content={sub ?? ""} fg={theme.textFaint} wrapMode="none" />
320
+ </box>
321
+ )
322
+ }
@@ -0,0 +1,195 @@
1
+ // Enable/disable HTTPS overlay โ€” mirrors PhpUpgrade.tsx's confirmโ†’trackingโ†’
2
+ // done/error shape, minus the picker phase: the direction (enable vs disable)
3
+ // is fully determined by the site's current https.enabled, so there's nothing
4
+ // to choose, just confirm. Opened with `H` on a selected site.
5
+ //
6
+ // The write + event polling live in the store (startHttpsToggle), so closing
7
+ // this overlay (Esc/q) doesn't abandon it โ€” the site's row keeps a marker
8
+ // until it settles.
9
+
10
+ import { useState } from "react"
11
+ import { useKeyboard } from "@opentui/react"
12
+ import { theme } from "../../lib/theme.ts"
13
+ import { truncate } from "../../lib/format.ts"
14
+ import { Panel, Spinner, Centered } from "../components.tsx"
15
+ import { StatusBar } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+
18
+ type Phase = "confirm" | "tracking" | "done" | "error"
19
+
20
+ export function HttpsToggle() {
21
+ const store = useStore()
22
+ const { httpsToggleSite: site, setHttpsToggleSite, httpsToggles, startHttpsToggle, clearHttpsToggle } = store
23
+
24
+ const action: "enable" | "disable" = site?.https?.enabled ? "disable" : "enable"
25
+ const [phase, setPhase] = useState<Phase>("confirm")
26
+
27
+ // Background progress for this site, owned by the store. Once fired
28
+ // ("tracking"), the screen follows the store's event status.
29
+ const progress = site ? httpsToggles.get(site.id) : undefined
30
+ const dp: Phase =
31
+ phase !== "tracking"
32
+ ? phase
33
+ : !progress
34
+ ? "done"
35
+ : progress.status === "failed"
36
+ ? "error"
37
+ : "tracking"
38
+
39
+ const close = () => {
40
+ // Leave an in-flight toggle running (its row keeps the marker); only drop
41
+ // a settled failure so it doesn't linger after the user has seen it.
42
+ if (site && progress?.status === "failed") clearHttpsToggle(site.id)
43
+ setHttpsToggleSite(null)
44
+ }
45
+
46
+ useKeyboard((key) => {
47
+ const name = key.name ?? ""
48
+ if (name === "escape" || name === "q") return close()
49
+
50
+ if (dp === "confirm") {
51
+ if (name === "y") {
52
+ if (site) {
53
+ startHttpsToggle(site)
54
+ setPhase("tracking")
55
+ }
56
+ return
57
+ }
58
+ return
59
+ }
60
+
61
+ if (dp === "error") {
62
+ if (name === "r") {
63
+ if (site) clearHttpsToggle(site.id)
64
+ setPhase("confirm")
65
+ }
66
+ return
67
+ }
68
+ })
69
+
70
+ if (!site) return null
71
+
72
+ return (
73
+ <box
74
+ style={{
75
+ position: "absolute",
76
+ top: 0,
77
+ left: 0,
78
+ width: "100%",
79
+ height: "100%",
80
+ flexDirection: "column",
81
+ backgroundColor: theme.bg,
82
+ zIndex: 210,
83
+ }}
84
+ >
85
+ <box
86
+ style={{
87
+ flexDirection: "row",
88
+ height: 1,
89
+ backgroundColor: theme.bgAlt,
90
+ paddingLeft: 1,
91
+ paddingRight: 1,
92
+ alignItems: "center",
93
+ }}
94
+ >
95
+ <text content={`${action === "enable" ? "๐Ÿ”’" : "๐Ÿ”“"} ${action === "enable" ? "Enable" : "Disable"} HTTPS `} fg={theme.brand} style={{ flexShrink: 0 }} />
96
+ <text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
97
+ </box>
98
+
99
+ <Centered>{renderBody()}</Centered>
100
+
101
+ <StatusBar hints={hints()} showGlobal={false} />
102
+ </box>
103
+ )
104
+
105
+ function renderBody() {
106
+ if (dp === "confirm") {
107
+ return action === "enable" ? (
108
+ <Panel title=" Confirm: enable HTTPS " active>
109
+ <box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
110
+ <box style={{ flexDirection: "row" }}>
111
+ <text content="Enable HTTPS for " fg={theme.text} />
112
+ <text content={truncate(site!.domain, 32)} fg={theme.accent} wrapMode="none" />
113
+ </box>
114
+ <box style={{ height: 1 }} />
115
+ <text content="Issues a free Let's Encrypt certificate. The domain must" fg={theme.textDim} wrapMode="none" />
116
+ <text content="already resolve to this server, or issuance will fail." fg={theme.textDim} wrapMode="none" />
117
+ <box style={{ height: 1 }} />
118
+ <text content="Press y to confirm ยท Esc to cancel" fg={theme.textFaint} wrapMode="none" />
119
+ </box>
120
+ </Panel>
121
+ ) : (
122
+ <Panel title=" Confirm: disable HTTPS " active>
123
+ <box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
124
+ <box style={{ flexDirection: "row" }}>
125
+ <text content="Disable HTTPS for " fg={theme.text} />
126
+ <text content={truncate(site!.domain, 32)} fg={theme.accent} wrapMode="none" />
127
+ </box>
128
+ <box style={{ height: 1 }} />
129
+ <text content="Removes the certificate. Visitors on https:// will start" fg={theme.warn} wrapMode="none" />
130
+ <text content="seeing connection/certificate errors until re-enabled." fg={theme.warn} wrapMode="none" />
131
+ <box style={{ height: 1 }} />
132
+ <text content="Press y to confirm ยท Esc to cancel" fg={theme.textFaint} wrapMode="none" />
133
+ </box>
134
+ </Panel>
135
+ )
136
+ }
137
+
138
+ if (dp === "tracking") {
139
+ return (
140
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
141
+ <box style={{ flexDirection: "row" }}>
142
+ <Spinner />
143
+ <text content={` ${action === "enable" ? "Enabling" : "Disabling"} HTTPS โ€” ${progress?.status ?? "queued"}โ€ฆ`} fg={theme.textDim} />
144
+ </box>
145
+ <box style={{ height: 1 }} />
146
+ <text content="You can press Esc โ€” it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
147
+ </box>
148
+ )
149
+ }
150
+
151
+ if (dp === "done") {
152
+ return (
153
+ <Panel title=" Done " active>
154
+ <box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
155
+ <box style={{ flexDirection: "row" }}>
156
+ <text content="โœ“ " fg={theme.good} />
157
+ <text content={truncate(site!.domain, 32)} fg={theme.accent} wrapMode="none" />
158
+ <text content={` โ€” HTTPS ${action === "enable" ? "enabled" : "disabled"}`} fg={theme.text} wrapMode="none" />
159
+ </box>
160
+ <box style={{ height: 1 }} />
161
+ <text content="Esc to close" fg={theme.textFaint} />
162
+ </box>
163
+ </Panel>
164
+ )
165
+ }
166
+
167
+ // error
168
+ return (
169
+ <Panel title=" Failed " active>
170
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
171
+ <text content={`โœ• ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
172
+ <box style={{ height: 1 }} />
173
+ <text content="Press r to try again ยท Esc to close" fg={theme.textFaint} wrapMode="none" />
174
+ </box>
175
+ </Panel>
176
+ )
177
+ }
178
+
179
+ function hints() {
180
+ switch (dp) {
181
+ case "confirm":
182
+ return [
183
+ { key: "y", label: "confirm" },
184
+ { key: "esc", label: "cancel" },
185
+ ]
186
+ case "error":
187
+ return [
188
+ { key: "r", label: "retry" },
189
+ { key: "esc", label: "close" },
190
+ ]
191
+ default:
192
+ return [{ key: "esc", label: "close" }]
193
+ }
194
+ }
195
+ }