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,328 @@
|
|
|
1
|
+
// Server actions overlay — reboot the server or restart a single service.
|
|
2
|
+
//
|
|
3
|
+
// Opened with `a` on a selected server (Servers tab / Search). Like the PHP
|
|
4
|
+
// upgrade overlay: pick an action → confirm → POST → poll the event. The actual
|
|
5
|
+
// call + polling live in the store (`startServerOp`), so closing the overlay
|
|
6
|
+
// (Esc) leaves it running and the server's row keeps a spinner. When a reboot is
|
|
7
|
+
// pending, we SSH-probe the *why* (Ubuntu's pending-restart packages) and show
|
|
8
|
+
// it — labeled as OS context, not as SpinupWP's internal logic.
|
|
9
|
+
|
|
10
|
+
import { useEffect, 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
|
+
import { moveSelection } from "../List.tsx"
|
|
18
|
+
import type { RebootInfo } from "../../lib/ssh.ts"
|
|
19
|
+
import type { ServerOpKind } from "../store.tsx"
|
|
20
|
+
|
|
21
|
+
type Phase = "pick" | "confirm" | "tracking" | "done" | "error"
|
|
22
|
+
|
|
23
|
+
interface Action {
|
|
24
|
+
kind: ServerOpKind
|
|
25
|
+
label: string // menu label, e.g. "Restart Nginx"
|
|
26
|
+
verb: string // short progress verb, e.g. "nginx"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ACTIONS: Action[] = [
|
|
30
|
+
{ kind: "reboot", label: "Reboot server", verb: "rebooting" },
|
|
31
|
+
{ kind: "nginx", label: "Restart Nginx", verb: "nginx" },
|
|
32
|
+
{ kind: "php", label: "Restart PHP-FPM", verb: "php-fpm" },
|
|
33
|
+
{ kind: "mysql", label: "Restart MySQL", verb: "mysql" },
|
|
34
|
+
{ kind: "redis", label: "Restart Redis", verb: "redis" },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
// Short headline for the menu / detail; the full package list is shown on the
|
|
38
|
+
// (wider) confirm screen, one per line.
|
|
39
|
+
function rebootSummary(info: RebootInfo): string {
|
|
40
|
+
if (!info.present) return "no restart currently flagged on the server"
|
|
41
|
+
const head = info.kernel ? "kernel update" : "package updates"
|
|
42
|
+
const n = info.packages.length
|
|
43
|
+
return `${head} · ${n} package${n === 1 ? "" : "s"}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ServerActions() {
|
|
47
|
+
const store = useStore()
|
|
48
|
+
const {
|
|
49
|
+
serverActionsServer: server,
|
|
50
|
+
setServerActionsServer,
|
|
51
|
+
sitesForServer,
|
|
52
|
+
serverOps,
|
|
53
|
+
startServerOp,
|
|
54
|
+
clearServerOp,
|
|
55
|
+
rebootInfo,
|
|
56
|
+
rebootInfoLoading,
|
|
57
|
+
rebootInfoErrors,
|
|
58
|
+
loadRebootInfo,
|
|
59
|
+
} = store
|
|
60
|
+
|
|
61
|
+
const rebootRequired = !!server?.reboot_required
|
|
62
|
+
const [phase, setPhase] = useState<Phase>("pick")
|
|
63
|
+
// Cursor starts on Reboot when one is pending, else on the first restart.
|
|
64
|
+
const [index, setIndex] = useState(() => (server?.reboot_required ? 0 : 1))
|
|
65
|
+
const [target, setTarget] = useState<Action | null>(null)
|
|
66
|
+
|
|
67
|
+
const siteCount = server ? sitesForServer(server.id).length : 0
|
|
68
|
+
const info = server ? rebootInfo.get(server.id) : undefined
|
|
69
|
+
const infoLoading = server ? rebootInfoLoading.has(server.id) : false
|
|
70
|
+
const infoError = server ? rebootInfoErrors.get(server.id) : undefined
|
|
71
|
+
|
|
72
|
+
// Fetch the "why" once when opened on a reboot-required server.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (server && rebootRequired && !info && !infoLoading && !infoError) loadRebootInfo(server)
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [server?.id])
|
|
77
|
+
|
|
78
|
+
const progress = server ? serverOps.get(server.id) : undefined
|
|
79
|
+
const dp: Phase =
|
|
80
|
+
phase !== "tracking"
|
|
81
|
+
? phase
|
|
82
|
+
: !progress
|
|
83
|
+
? "done"
|
|
84
|
+
: progress.status === "failed"
|
|
85
|
+
? "error"
|
|
86
|
+
: "tracking"
|
|
87
|
+
|
|
88
|
+
const close = () => {
|
|
89
|
+
if (server && progress?.status === "failed") clearServerOp(server.id)
|
|
90
|
+
setServerActionsServer(null)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
useKeyboard((key) => {
|
|
94
|
+
const name = key.name ?? ""
|
|
95
|
+
if (name === "escape" || name === "q") return close()
|
|
96
|
+
|
|
97
|
+
if (dp === "pick") {
|
|
98
|
+
switch (name) {
|
|
99
|
+
case "up":
|
|
100
|
+
case "k":
|
|
101
|
+
return setIndex((i) => moveSelection(i, -1, ACTIONS.length))
|
|
102
|
+
case "down":
|
|
103
|
+
case "j":
|
|
104
|
+
return setIndex((i) => moveSelection(i, 1, ACTIONS.length))
|
|
105
|
+
case "return":
|
|
106
|
+
case "right":
|
|
107
|
+
case "l":
|
|
108
|
+
setTarget(ACTIONS[index])
|
|
109
|
+
setPhase("confirm")
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (dp === "confirm") {
|
|
116
|
+
if (name === "y") {
|
|
117
|
+
if (server && target) {
|
|
118
|
+
startServerOp(server, target.kind, target.verb)
|
|
119
|
+
setPhase("tracking")
|
|
120
|
+
}
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (name === "left" || name === "h") return setPhase("pick")
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (dp === "error") {
|
|
128
|
+
if (name === "r") {
|
|
129
|
+
if (server) clearServerOp(server.id)
|
|
130
|
+
setPhase("pick")
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (!server) return null
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<box
|
|
140
|
+
style={{
|
|
141
|
+
position: "absolute",
|
|
142
|
+
top: 0,
|
|
143
|
+
left: 0,
|
|
144
|
+
width: "100%",
|
|
145
|
+
height: "100%",
|
|
146
|
+
flexDirection: "column",
|
|
147
|
+
backgroundColor: theme.bg,
|
|
148
|
+
zIndex: 210,
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{/* Title bar */}
|
|
152
|
+
<box
|
|
153
|
+
style={{
|
|
154
|
+
flexDirection: "row",
|
|
155
|
+
height: 1,
|
|
156
|
+
backgroundColor: theme.bgAlt,
|
|
157
|
+
paddingLeft: 1,
|
|
158
|
+
paddingRight: 1,
|
|
159
|
+
alignItems: "center",
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<text content="⚙ Server actions " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
163
|
+
<text content={truncate(server.name, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
164
|
+
<box style={{ flexGrow: 1 }} />
|
|
165
|
+
<text content={`${siteCount} site${siteCount === 1 ? "" : "s"}`} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
166
|
+
</box>
|
|
167
|
+
|
|
168
|
+
<Centered>{renderBody()}</Centered>
|
|
169
|
+
|
|
170
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
171
|
+
</box>
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
function renderBody() {
|
|
175
|
+
if (dp === "pick") {
|
|
176
|
+
return (
|
|
177
|
+
<Panel title=" Choose an action " active>
|
|
178
|
+
<box style={{ flexDirection: "column", width: 52 }}>
|
|
179
|
+
{/* Reboot "why" context */}
|
|
180
|
+
{rebootRequired && (
|
|
181
|
+
<>
|
|
182
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
183
|
+
<text content="⚠ reboot pending — " fg={theme.warn} style={{ flexShrink: 0 }} />
|
|
184
|
+
{infoLoading ? (
|
|
185
|
+
<box style={{ flexDirection: "row" }}>
|
|
186
|
+
<Spinner interval={120} />
|
|
187
|
+
<text content=" checking what's pending…" fg={theme.textDim} wrapMode="none" />
|
|
188
|
+
</box>
|
|
189
|
+
) : info ? (
|
|
190
|
+
<text content={rebootSummary(info)} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
191
|
+
) : infoError ? (
|
|
192
|
+
<text content="couldn't read detail over SSH" fg={theme.textFaint} wrapMode="none" />
|
|
193
|
+
) : (
|
|
194
|
+
<text content="reboot required" fg={theme.textDim} wrapMode="none" />
|
|
195
|
+
)}
|
|
196
|
+
</box>
|
|
197
|
+
<box style={{ height: 1 }} />
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
{ACTIONS.map((a, i) => {
|
|
201
|
+
const selected = i === index
|
|
202
|
+
const recommended = a.kind === "reboot" && rebootRequired
|
|
203
|
+
const fg = selected ? theme.text : recommended ? theme.warn : theme.textDim
|
|
204
|
+
const icon = a.kind === "reboot" ? "↻ " : "⟳ "
|
|
205
|
+
return (
|
|
206
|
+
<box
|
|
207
|
+
key={a.kind}
|
|
208
|
+
style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}
|
|
209
|
+
>
|
|
210
|
+
<text content={(selected ? "❯ " : " ") + icon + a.label} fg={fg} style={{ flexGrow: 1 }} wrapMode="none" />
|
|
211
|
+
{recommended && <text content="(recommended)" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
|
|
212
|
+
</box>
|
|
213
|
+
)
|
|
214
|
+
})}
|
|
215
|
+
</box>
|
|
216
|
+
</Panel>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (dp === "confirm") {
|
|
221
|
+
const isReboot = target?.kind === "reboot"
|
|
222
|
+
return (
|
|
223
|
+
<Panel title=" Confirm " active>
|
|
224
|
+
<box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
|
|
225
|
+
<box style={{ flexDirection: "row" }}>
|
|
226
|
+
<text content={isReboot ? "Reboot " : `${target?.label} on `} fg={theme.text} wrapMode="none" />
|
|
227
|
+
<text content={truncate(server!.name, 30)} fg={theme.accent} wrapMode="none" />
|
|
228
|
+
<text content="?" fg={theme.text} />
|
|
229
|
+
</box>
|
|
230
|
+
<box style={{ height: 1 }} />
|
|
231
|
+
{isReboot ? (
|
|
232
|
+
<>
|
|
233
|
+
<text
|
|
234
|
+
content={`Reboots the whole server — downtime for all ${siteCount} site${siteCount === 1 ? "" : "s"}.`}
|
|
235
|
+
fg={theme.warn}
|
|
236
|
+
wrapMode="none"
|
|
237
|
+
/>
|
|
238
|
+
{info?.present && (
|
|
239
|
+
<>
|
|
240
|
+
<box style={{ height: 1 }} />
|
|
241
|
+
<text content={`Pending: ${rebootSummary(info)}`} fg={theme.textDim} wrapMode="none" />
|
|
242
|
+
{info.packages.slice(0, 6).map((p) => (
|
|
243
|
+
<text key={p} content={` • ${p}`} fg={theme.textFaint} wrapMode="none" />
|
|
244
|
+
))}
|
|
245
|
+
{info.packages.length > 6 && (
|
|
246
|
+
<text content={` • …${info.packages.length - 6} more`} fg={theme.textFaint} wrapMode="none" />
|
|
247
|
+
)}
|
|
248
|
+
</>
|
|
249
|
+
)}
|
|
250
|
+
{store.kumaConfigured && (
|
|
251
|
+
<text content="Uptime Kuma monitors enter a maintenance window — no false alerts." fg={theme.textFaint} wrapMode="none" />
|
|
252
|
+
)}
|
|
253
|
+
</>
|
|
254
|
+
) : (
|
|
255
|
+
<text content="Brief interruption to that one service; sites stay up otherwise." fg={theme.textDim} wrapMode="none" />
|
|
256
|
+
)}
|
|
257
|
+
<box style={{ height: 1 }} />
|
|
258
|
+
<text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
259
|
+
</box>
|
|
260
|
+
</Panel>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (dp === "tracking") {
|
|
265
|
+
return (
|
|
266
|
+
<box style={{ flexDirection: "column", alignItems: "center" }}>
|
|
267
|
+
<box style={{ flexDirection: "row" }}>
|
|
268
|
+
<Spinner />
|
|
269
|
+
<text content={` ${target?.label ?? "Working"} — ${progress?.status ?? "queued"}…`} fg={theme.textDim} />
|
|
270
|
+
</box>
|
|
271
|
+
<box style={{ height: 1 }} />
|
|
272
|
+
<text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
273
|
+
</box>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (dp === "done") {
|
|
278
|
+
return (
|
|
279
|
+
<Panel title=" Done " active>
|
|
280
|
+
<box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
|
|
281
|
+
<box style={{ flexDirection: "row" }}>
|
|
282
|
+
<text content="✓ " fg={theme.good} />
|
|
283
|
+
<text content={`${target?.label ?? "Action"} completed on ` } fg={theme.text} wrapMode="none" />
|
|
284
|
+
<text content={truncate(server!.name, 24)} fg={theme.accent} wrapMode="none" />
|
|
285
|
+
</box>
|
|
286
|
+
<box style={{ height: 1 }} />
|
|
287
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
288
|
+
</box>
|
|
289
|
+
</Panel>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// error
|
|
294
|
+
return (
|
|
295
|
+
<Panel title=" Action failed " active>
|
|
296
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
297
|
+
<text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
298
|
+
<box style={{ height: 1 }} />
|
|
299
|
+
<text content="Press r to choose another action · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
300
|
+
</box>
|
|
301
|
+
</Panel>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function hints() {
|
|
306
|
+
switch (dp) {
|
|
307
|
+
case "pick":
|
|
308
|
+
return [
|
|
309
|
+
{ key: "↑↓/jk", label: "action" },
|
|
310
|
+
{ key: "⏎", label: "choose" },
|
|
311
|
+
{ key: "esc", label: "cancel" },
|
|
312
|
+
]
|
|
313
|
+
case "confirm":
|
|
314
|
+
return [
|
|
315
|
+
{ key: "y", label: "confirm" },
|
|
316
|
+
{ key: "←", label: "back" },
|
|
317
|
+
{ key: "esc", label: "cancel" },
|
|
318
|
+
]
|
|
319
|
+
case "error":
|
|
320
|
+
return [
|
|
321
|
+
{ key: "r", label: "retry" },
|
|
322
|
+
{ key: "esc", label: "close" },
|
|
323
|
+
]
|
|
324
|
+
default:
|
|
325
|
+
return [{ key: "esc", label: "close" }]
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|