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