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,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
|
+
}
|