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
package/src/ui/App.tsx
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Application shell: splash gating, global key routing, header/content/status layout.
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
5
|
+
import { Toaster } from "@opentui-ui/toast/react"
|
|
6
|
+
import { theme } from "../lib/theme.ts"
|
|
7
|
+
import { useStore } from "./store.tsx"
|
|
8
|
+
import { Splash } from "./Splash.tsx"
|
|
9
|
+
import { Header } from "./Header.tsx"
|
|
10
|
+
import { HelpOverlay } from "./Help.tsx"
|
|
11
|
+
import { ExplainOverlay } from "./Explain.tsx"
|
|
12
|
+
import { Dashboard } from "./views/Dashboard.tsx"
|
|
13
|
+
import { Browser } from "./views/Browser.tsx"
|
|
14
|
+
import { Stacks } from "./views/Stacks.tsx"
|
|
15
|
+
import { Search } from "./views/Search.tsx"
|
|
16
|
+
import { Events } from "./views/Events.tsx"
|
|
17
|
+
import { Health } from "./views/Health.tsx"
|
|
18
|
+
import { PhpUpgrade } from "./views/PhpUpgrade.tsx"
|
|
19
|
+
import { HttpsToggle } from "./views/HttpsToggle.tsx"
|
|
20
|
+
import { PurgeCache } from "./views/PurgeCache.tsx"
|
|
21
|
+
import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
|
|
22
|
+
import { GrantKey } from "./views/GrantKey.tsx"
|
|
23
|
+
import { SudoConnect } from "./views/SudoConnect.tsx"
|
|
24
|
+
import { DbBackup } from "./views/DbBackup.tsx"
|
|
25
|
+
import { DbSync } from "./views/DbSync.tsx"
|
|
26
|
+
import { MediaFallback } from "./views/MediaFallback.tsx"
|
|
27
|
+
import { ServerActions } from "./views/ServerActions.tsx"
|
|
28
|
+
import { NewServer } from "./views/NewServer.tsx"
|
|
29
|
+
import { VanityNewSite } from "./views/VanityNewSite.tsx"
|
|
30
|
+
import { CloneWizard } from "./views/CloneWizard.tsx"
|
|
31
|
+
import { LocalLinkOverlay } from "./views/LocalLink.tsx"
|
|
32
|
+
import { Discover } from "./views/Discover.tsx"
|
|
33
|
+
import { Forgotten } from "./views/Forgotten.tsx"
|
|
34
|
+
import { DnsInventory } from "./views/DnsInventory.tsx"
|
|
35
|
+
import { WpInventory } from "./views/WpInventory.tsx"
|
|
36
|
+
import { ProviderConnect } from "./views/ProviderConnect.tsx"
|
|
37
|
+
import { DnsRecords } from "./views/DnsRecords.tsx"
|
|
38
|
+
import { KumaSite } from "./views/KumaSite.tsx"
|
|
39
|
+
|
|
40
|
+
const MIN_SPLASH_MS = 1200
|
|
41
|
+
|
|
42
|
+
export function App() {
|
|
43
|
+
const store = useStore()
|
|
44
|
+
const renderer = useRenderer()
|
|
45
|
+
const { height } = useTerminalDimensions()
|
|
46
|
+
const [splashDone, setSplashDone] = useState(false)
|
|
47
|
+
const [showHelp, setShowHelp] = useState(false)
|
|
48
|
+
const [showExplain, setShowExplain] = useState(false)
|
|
49
|
+
|
|
50
|
+
// Enforce a minimum splash duration so the intro is visible even on fast loads.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const id = setTimeout(() => setSplashDone(true), MIN_SPLASH_MS)
|
|
53
|
+
return () => clearTimeout(id)
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
// Keep views aware that a modal is open so they pause their own key handling.
|
|
57
|
+
const overlayActive =
|
|
58
|
+
showHelp ||
|
|
59
|
+
showExplain ||
|
|
60
|
+
store.releaseNotesInfo !== null ||
|
|
61
|
+
store.healthServer !== null ||
|
|
62
|
+
store.wpInventorySite !== null ||
|
|
63
|
+
store.phpUpgradeSite !== null ||
|
|
64
|
+
store.httpsToggleSite !== null ||
|
|
65
|
+
store.purgeCacheSite !== null ||
|
|
66
|
+
store.grantKeySite !== null ||
|
|
67
|
+
store.sudoConnectServer !== null ||
|
|
68
|
+
store.dbBackupSite !== null ||
|
|
69
|
+
store.dbSyncSite !== null ||
|
|
70
|
+
store.mediaFallbackSite !== null ||
|
|
71
|
+
store.serverActionsServer !== null ||
|
|
72
|
+
store.newServerOpen ||
|
|
73
|
+
store.vanityServer !== null ||
|
|
74
|
+
store.cloneServer !== null ||
|
|
75
|
+
store.localLinkSite !== null ||
|
|
76
|
+
store.discoverOpen ||
|
|
77
|
+
store.forgottenOpen ||
|
|
78
|
+
store.dnsInventoryServer !== null ||
|
|
79
|
+
store.connectZoneTarget !== null ||
|
|
80
|
+
store.dnsRecordsTarget !== null ||
|
|
81
|
+
store.kumaSite !== null
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
store.setOverlayOpen(overlayActive)
|
|
84
|
+
}, [overlayActive, store])
|
|
85
|
+
|
|
86
|
+
function quit() {
|
|
87
|
+
try {
|
|
88
|
+
renderer.destroy?.()
|
|
89
|
+
} catch {
|
|
90
|
+
// ignore — we're exiting anyway
|
|
91
|
+
}
|
|
92
|
+
process.exit(0)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
useKeyboard((key) => {
|
|
96
|
+
// Ctrl+C always quits, even while typing.
|
|
97
|
+
if (key.ctrl && key.name === "c") return quit()
|
|
98
|
+
|
|
99
|
+
// Help/Explain must close even while a text field is focused (they can open
|
|
100
|
+
// a beat BEFORE a view's input grabs focus — e.g. `4` then typing a name
|
|
101
|
+
// containing `i` faster than Search mounts — and the inputMode gate below
|
|
102
|
+
// would otherwise deadlock them open, since the overlay also deactivates the
|
|
103
|
+
// view's own handler).
|
|
104
|
+
if (showHelp) {
|
|
105
|
+
if (key.name === "escape" || key.name === "q" || key.name === "?") setShowHelp(false)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (showExplain) {
|
|
109
|
+
if (key.name === "escape" || key.name === "q" || key.name === "i") setShowExplain(false)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// While a text field is focused, let it consume everything else.
|
|
114
|
+
if (store.inputMode) return
|
|
115
|
+
|
|
116
|
+
// The release-notes overlay owns the keyboard while open (any key dismisses).
|
|
117
|
+
if (store.releaseNotesInfo) return
|
|
118
|
+
|
|
119
|
+
// The health overlay owns the keyboard while open (it handles Esc/q/r/h).
|
|
120
|
+
if (store.healthServer) return
|
|
121
|
+
|
|
122
|
+
// The plugins/themes overlay owns the keyboard while open.
|
|
123
|
+
if (store.wpInventorySite) return
|
|
124
|
+
|
|
125
|
+
// The PHP-upgrade overlay owns the keyboard while open.
|
|
126
|
+
if (store.phpUpgradeSite) return
|
|
127
|
+
|
|
128
|
+
// The HTTPS-toggle overlay owns the keyboard while open.
|
|
129
|
+
if (store.httpsToggleSite) return
|
|
130
|
+
|
|
131
|
+
// The purge-cache overlay owns the keyboard while open.
|
|
132
|
+
if (store.purgeCacheSite) return
|
|
133
|
+
|
|
134
|
+
// The grant-SSH-key overlay owns the keyboard while open.
|
|
135
|
+
if (store.grantKeySite) return
|
|
136
|
+
|
|
137
|
+
// The connect-sudo overlay owns the keyboard while open.
|
|
138
|
+
if (store.sudoConnectServer) return
|
|
139
|
+
|
|
140
|
+
// The DB-backup overlay owns the keyboard while open.
|
|
141
|
+
if (store.dbBackupSite) return
|
|
142
|
+
|
|
143
|
+
// The DB-sync overlay owns the keyboard while open.
|
|
144
|
+
if (store.dbSyncSite) return
|
|
145
|
+
|
|
146
|
+
// The media-fallback overlay owns the keyboard while open.
|
|
147
|
+
if (store.mediaFallbackSite) return
|
|
148
|
+
|
|
149
|
+
// The server-actions overlay owns the keyboard while open.
|
|
150
|
+
if (store.serverActionsServer) return
|
|
151
|
+
|
|
152
|
+
// The new-server overlay owns the keyboard while open.
|
|
153
|
+
if (store.newServerOpen) return
|
|
154
|
+
|
|
155
|
+
// The vanity-site overlay owns the keyboard while open.
|
|
156
|
+
if (store.vanityServer) return
|
|
157
|
+
|
|
158
|
+
// The clone wizard owns the keyboard while open.
|
|
159
|
+
if (store.cloneServer) return
|
|
160
|
+
|
|
161
|
+
// The local-link overlay owns the keyboard while open.
|
|
162
|
+
if (store.localLinkSite) return
|
|
163
|
+
|
|
164
|
+
// The discovery overlay owns the keyboard while open.
|
|
165
|
+
if (store.discoverOpen) return
|
|
166
|
+
|
|
167
|
+
// The "needs a local copy" report owns the keyboard while open.
|
|
168
|
+
if (store.forgottenOpen) return
|
|
169
|
+
|
|
170
|
+
// The DNS inventory overlay owns the keyboard while open.
|
|
171
|
+
if (store.dnsInventoryServer) return
|
|
172
|
+
|
|
173
|
+
// The provider-connect overlay owns the keyboard while open.
|
|
174
|
+
if (store.connectZoneTarget) return
|
|
175
|
+
|
|
176
|
+
// The DNS-records overlay owns the keyboard while open.
|
|
177
|
+
if (store.dnsRecordsTarget) return
|
|
178
|
+
|
|
179
|
+
// The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
|
|
180
|
+
if (store.kumaSite) return
|
|
181
|
+
|
|
182
|
+
switch (key.name) {
|
|
183
|
+
case "q":
|
|
184
|
+
return quit()
|
|
185
|
+
case "1":
|
|
186
|
+
return store.setRoute("dashboard")
|
|
187
|
+
case "2":
|
|
188
|
+
return store.setRoute("servers")
|
|
189
|
+
case "3":
|
|
190
|
+
return store.setRoute("stacks")
|
|
191
|
+
case "4":
|
|
192
|
+
return store.setRoute("search")
|
|
193
|
+
case "5":
|
|
194
|
+
return store.setRoute("events")
|
|
195
|
+
case "r":
|
|
196
|
+
return void store.refresh()
|
|
197
|
+
case "?":
|
|
198
|
+
return setShowHelp(true)
|
|
199
|
+
case "i":
|
|
200
|
+
return setShowExplain(true)
|
|
201
|
+
case "/":
|
|
202
|
+
return store.setRoute("search")
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// Splash until the minimum time has elapsed AND the first data load resolved.
|
|
207
|
+
if (!splashDone || !store.ready) {
|
|
208
|
+
const status = store.error
|
|
209
|
+
? "Trouble reaching SpinupWP — entering anyway…"
|
|
210
|
+
: store.loading
|
|
211
|
+
? "Fetching your servers and sites…"
|
|
212
|
+
: "Ready."
|
|
213
|
+
return <Splash status={status} />
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Rows available to the content area: total minus the header (nav + subtitle =
|
|
217
|
+
// 2 rows) and the error banner, when present. Each view renders its own status bar.
|
|
218
|
+
const contentRows = Math.max(3, height - 2 - (store.error ? 1 : 0))
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<box style={{ width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg }}>
|
|
222
|
+
<Header />
|
|
223
|
+
{store.error && (
|
|
224
|
+
<box style={{ height: 1, backgroundColor: theme.bad, paddingLeft: 1, paddingRight: 1, flexDirection: "row" }}>
|
|
225
|
+
<text content={`⚠ ${store.error} — press r to retry`} fg={theme.bg} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
226
|
+
</box>
|
|
227
|
+
)}
|
|
228
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
229
|
+
{store.route === "dashboard" && <Dashboard rows={contentRows} />}
|
|
230
|
+
{store.route === "servers" && <Browser rows={contentRows} />}
|
|
231
|
+
{store.route === "stacks" && <Stacks rows={contentRows} />}
|
|
232
|
+
{store.route === "search" && <Search rows={contentRows} />}
|
|
233
|
+
{store.route === "events" && <Events rows={contentRows} />}
|
|
234
|
+
</box>
|
|
235
|
+
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
|
236
|
+
{showExplain && <ExplainOverlay route={store.route} />}
|
|
237
|
+
{store.releaseNotesInfo && <ReleaseNotes />}
|
|
238
|
+
{store.healthServer && <Health />}
|
|
239
|
+
{store.wpInventorySite && <WpInventory />}
|
|
240
|
+
{store.phpUpgradeSite && <PhpUpgrade />}
|
|
241
|
+
{store.httpsToggleSite && <HttpsToggle />}
|
|
242
|
+
{store.purgeCacheSite && <PurgeCache />}
|
|
243
|
+
{store.grantKeySite && <GrantKey />}
|
|
244
|
+
{store.sudoConnectServer && <SudoConnect />}
|
|
245
|
+
{store.dbBackupSite && <DbBackup />}
|
|
246
|
+
{store.dbSyncSite && <DbSync />}
|
|
247
|
+
{store.mediaFallbackSite && <MediaFallback />}
|
|
248
|
+
{store.serverActionsServer && <ServerActions />}
|
|
249
|
+
{store.newServerOpen && <NewServer />}
|
|
250
|
+
{store.vanityServer && <VanityNewSite />}
|
|
251
|
+
{store.cloneServer && <CloneWizard />}
|
|
252
|
+
{store.localLinkSite && <LocalLinkOverlay />}
|
|
253
|
+
{store.discoverOpen && <Discover />}
|
|
254
|
+
{store.forgottenOpen && <Forgotten />}
|
|
255
|
+
{store.dnsInventoryServer && <DnsInventory />}
|
|
256
|
+
{store.connectZoneTarget && <ProviderConnect />}
|
|
257
|
+
{store.dnsRecordsTarget && <DnsRecords />}
|
|
258
|
+
{store.kumaSite && <KumaSite />}
|
|
259
|
+
{/* Async-completion toasts (PHP upgrade, server reboot/restart). Mounted last
|
|
260
|
+
so it draws over every view + overlay; top-right, nudged clear of the
|
|
261
|
+
2-row Header. It never takes keyboard focus. */}
|
|
262
|
+
<Toaster position="top-right" offset={{ top: 2, right: 2 }} />
|
|
263
|
+
</box>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// Detail panels for a server and a site. Shared by the Browser and Search views.
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
import { theme, statusColor, diskColor } from "../lib/theme.ts"
|
|
5
|
+
import { formatBytes, diskUsedPct, bar, formatDate, timeAgo, truncate } from "../lib/format.ts"
|
|
6
|
+
import { Field, StatusBadge, ControlPanel, type ActionGroup } from "./components.tsx"
|
|
7
|
+
import { effectiveStack, stackColor } from "../lib/stack.ts"
|
|
8
|
+
import { probeKindColor } from "../lib/probe.ts"
|
|
9
|
+
import { resolveLocalLink } from "../lib/local.ts"
|
|
10
|
+
import { normalizeDomain } from "../lib/dns.ts"
|
|
11
|
+
import type { Drift } from "../lib/gitStatus.ts"
|
|
12
|
+
import { useStore, isUpgradeInFlight, isServerOpInFlight, isHttpsToggleInFlight, isPurgeCacheInFlight, purgeCacheFailed } from "./store.tsx"
|
|
13
|
+
import type { Server, Site } from "../api/types.ts"
|
|
14
|
+
|
|
15
|
+
export function ServerDetail({ server, siteCount, showControl = false }: { server: Server; siteCount: number; showControl?: boolean }) {
|
|
16
|
+
const { rebootInfo, serverOps, isServerOsEol, sitesForServer, vanityJob } = useStore()
|
|
17
|
+
const ds = server.disk_space
|
|
18
|
+
const pct = diskUsedPct(ds?.used, ds?.total)
|
|
19
|
+
const op = serverOps.get(server.id)
|
|
20
|
+
const rb = rebootInfo.get(server.id)
|
|
21
|
+
const osEol = isServerOsEol(server)
|
|
22
|
+
const rebootValue =
|
|
23
|
+
op && isServerOpInFlight(op)
|
|
24
|
+
? `${op.label}…`
|
|
25
|
+
: op?.status === "failed"
|
|
26
|
+
? "action failed"
|
|
27
|
+
: server.reboot_required
|
|
28
|
+
? rb?.present
|
|
29
|
+
? `required · ${rb.kernel ? "kernel update" : "pkg updates"} (${rb.packages.length})`
|
|
30
|
+
: "required"
|
|
31
|
+
: "not needed"
|
|
32
|
+
const rebootColor =
|
|
33
|
+
op?.status === "failed" ? theme.bad : server.reboot_required || (op && isServerOpInFlight(op)) ? theme.warn : theme.good
|
|
34
|
+
return (
|
|
35
|
+
<box style={{ flexDirection: "column" }}>
|
|
36
|
+
<box style={{ flexDirection: "row" }}>
|
|
37
|
+
<text content={truncate(server.name, 34)} fg={theme.text} attributes={1} />
|
|
38
|
+
<box style={{ flexGrow: 1 }} />
|
|
39
|
+
<StatusBadge status={server.connection_status} />
|
|
40
|
+
</box>
|
|
41
|
+
<text content={server.ip_address ?? "—"} fg={theme.accent} />
|
|
42
|
+
<box style={{ height: 1 }} />
|
|
43
|
+
|
|
44
|
+
<Field label="Provider" value={server.provider_name ?? "—"} />
|
|
45
|
+
<Field label="Region" value={server.region ?? "—"} />
|
|
46
|
+
<Field label="Size" value={server.size ?? "—"} />
|
|
47
|
+
<Field
|
|
48
|
+
label="Ubuntu"
|
|
49
|
+
value={osEol ? `${server.ubuntu_version} — EOL, clone to newer (C)` : (server.ubuntu_version ?? "—")}
|
|
50
|
+
valueColor={osEol ? theme.bad : undefined}
|
|
51
|
+
/>
|
|
52
|
+
<Field label="SSH" value={`${server.ip_address ?? "—"}:${server.ssh_port ?? 22}`} />
|
|
53
|
+
<Field label="Database" value={server.database?.server ?? "—"} />
|
|
54
|
+
<Field label="Sites" value={String(siteCount)} valueColor={theme.accent} />
|
|
55
|
+
<box style={{ height: 1 }} />
|
|
56
|
+
|
|
57
|
+
<Field
|
|
58
|
+
label="Disk"
|
|
59
|
+
value={
|
|
60
|
+
<box style={{ flexDirection: "row" }}>
|
|
61
|
+
<text content={bar(pct / 100, 16)} fg={diskColor(pct)} />
|
|
62
|
+
<text content={` ${pct.toFixed(0)}%`} fg={diskColor(pct)} />
|
|
63
|
+
</box>
|
|
64
|
+
}
|
|
65
|
+
/>
|
|
66
|
+
<Field label="Used / Total" value={`${formatBytes(ds?.used)} / ${formatBytes(ds?.total)}`} />
|
|
67
|
+
<Field label="Available" value={formatBytes(ds?.available)} />
|
|
68
|
+
<box style={{ height: 1 }} />
|
|
69
|
+
|
|
70
|
+
<Field label="Reboot" value={rebootValue} valueColor={rebootColor} />
|
|
71
|
+
<Field
|
|
72
|
+
label="SpinupWP upgrade"
|
|
73
|
+
value={server.upgrade_required ? "required — press w to open" : "up to date"}
|
|
74
|
+
valueColor={server.upgrade_required ? theme.warn : theme.good}
|
|
75
|
+
/>
|
|
76
|
+
<Field label="Created" value={formatDate(server.created_at)} />
|
|
77
|
+
|
|
78
|
+
{/* Server Control — mirrors Search's Site Control. Only rendered where the keys
|
|
79
|
+
actually fire (the Browser's Servers pane), via showControl; Search's query
|
|
80
|
+
mode passes it off and surfaces the pressable list in its actions focus. */}
|
|
81
|
+
{showControl ? (
|
|
82
|
+
<>
|
|
83
|
+
<box style={{ height: 1 }} />
|
|
84
|
+
<ControlPanel heading="Server Control" groups={serverControlGroups()} />
|
|
85
|
+
</>
|
|
86
|
+
) : null}
|
|
87
|
+
</box>
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// The static groups, plus a Manage "V" row whenever the V key would actually do
|
|
91
|
+
// something: the server has no site at its own hostname yet (busy servers
|
|
92
|
+
// benefit from a vanity site just like empty ones), or an unfinished build for
|
|
93
|
+
// this server can be reopened.
|
|
94
|
+
function serverControlGroups(): ActionGroup[] {
|
|
95
|
+
const resumable = vanityJob != null && vanityJob.step !== "done" && vanityJob.serverId === server.id
|
|
96
|
+
const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
|
|
97
|
+
if (!resumable && hasVanity) return SERVER_CONTROL
|
|
98
|
+
const vanityItem: [string, string] = ["V", resumable ? "Resume vanity-site build" : "Vanity site at hostname"]
|
|
99
|
+
return SERVER_CONTROL.map((g) => (g.title === "Manage" ? { ...g, items: [...g.items, vanityItem] } : g))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const SERVER_CONTROL: ActionGroup[] = [
|
|
104
|
+
{ title: "Clone", items: [["C", "Clone this server →"]] },
|
|
105
|
+
{ title: "Access", items: [["S", "Connect sudo"]] },
|
|
106
|
+
{
|
|
107
|
+
title: "Manage",
|
|
108
|
+
items: [
|
|
109
|
+
["a", "Reboot / restart services"],
|
|
110
|
+
["h", "Server health"],
|
|
111
|
+
["N", "DNS records (migrate lens)"],
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{ title: "Open", items: [["w", "Open in SpinupWP"]] },
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
export function SiteDetail({ site, serverName }: { site: Site; serverName: string }) {
|
|
118
|
+
const { probes, probingIds, isProbeStale, phpUpgrades, httpsToggles, purgeCacheProgress, localLinks, grantedKeyKinds, kumaStatus, kumaMonitorFor } = useStore()
|
|
119
|
+
const kuma = kumaStatus.get(site.domain)
|
|
120
|
+
// The fingerprint check failing while HTTP is up means the site is answering
|
|
121
|
+
// 200 with the WRONG page (stale/corrupt page cache) — a distinct, worse state
|
|
122
|
+
// than down, so it outranks the plain up/down wording.
|
|
123
|
+
const kumaValue = kuma
|
|
124
|
+
? kuma.up === false
|
|
125
|
+
? "DOWN"
|
|
126
|
+
: kuma.fingerprintUp === false
|
|
127
|
+
? "up · WRONG PAGE SERVED (M)"
|
|
128
|
+
: kuma.redisUp === false
|
|
129
|
+
? "up · REDIS DOWN (M)"
|
|
130
|
+
: kuma.up
|
|
131
|
+
? `up${kuma.uptime24 != null ? ` · ${(kuma.uptime24 * 100).toFixed(2)}% (24h)` : ""}`
|
|
132
|
+
: "no beats yet"
|
|
133
|
+
: kumaMonitorFor(site.domain)
|
|
134
|
+
? "registered · awaiting poll"
|
|
135
|
+
: "not monitored · M"
|
|
136
|
+
const kumaColor = kuma ? (kuma.up === false || kuma.fingerprintUp === false || kuma.redisUp === false ? theme.bad : kuma.up ? theme.good : theme.textDim) : theme.textFaint
|
|
137
|
+
const httpsProgress = httpsToggles.get(site.id)
|
|
138
|
+
const purgeProgress = purgeCacheProgress.get(site.id)
|
|
139
|
+
const updates = (site.wp_plugin_updates || 0) + (site.wp_theme_updates || 0) + (site.wp_core_update ? 1 : 0)
|
|
140
|
+
const probe = probes.get(site.id)
|
|
141
|
+
// Prefer the DETECTED stack over SpinupWP's is_wordpress/git shape, which
|
|
142
|
+
// misclassifies some sites (a /public/ WP install shows as "Generic", Bedrock
|
|
143
|
+
// git sites report is_wordpress:false). Falls back to the API when unprobed.
|
|
144
|
+
const stack = effectiveStack(site, probe?.result.kind)
|
|
145
|
+
const isWordPress = stack !== "Non-WP"
|
|
146
|
+
const probing = probingIds.has(site.id)
|
|
147
|
+
const upgrade = phpUpgrades.get(site.id)
|
|
148
|
+
const link = localLinks.get(site.id)
|
|
149
|
+
const linkState = link ? resolveLocalLink(link) : null
|
|
150
|
+
const linkValue = !link
|
|
151
|
+
? "not linked · L"
|
|
152
|
+
: !linkState!.exists
|
|
153
|
+
? "missing — path not found"
|
|
154
|
+
: `${linkState!.label} · ${link.path}`
|
|
155
|
+
const linkColor = !link ? theme.textFaint : !linkState!.exists ? theme.bad : theme.good
|
|
156
|
+
// SSH keys Spinup has granted into this site user's authorized_keys, split into
|
|
157
|
+
// your personal key(s) vs the spinup-tui machine key (so it's never ambiguous).
|
|
158
|
+
const keyKinds = grantedKeyKinds(site.id)
|
|
159
|
+
const keyParts: string[] = []
|
|
160
|
+
if (keyKinds.personal > 0) keyParts.push(keyKinds.personal > 1 ? `your keys (${keyKinds.personal})` : "your key")
|
|
161
|
+
if (keyKinds.machine > 0) keyParts.push("spinup-tui")
|
|
162
|
+
const keyValue = keyParts.length ? `${keyParts.join(" + ")} · K` : "not granted · K"
|
|
163
|
+
return (
|
|
164
|
+
<box style={{ flexDirection: "column" }}>
|
|
165
|
+
<box style={{ flexDirection: "row" }}>
|
|
166
|
+
<text content={truncate(site.domain, 34)} fg={theme.text} attributes={1} />
|
|
167
|
+
<box style={{ flexGrow: 1 }} />
|
|
168
|
+
<StatusBadge status={site.status} />
|
|
169
|
+
</box>
|
|
170
|
+
<text content={(site.https?.enabled ? "https://" : "http://") + site.domain} fg={theme.accent} />
|
|
171
|
+
<box style={{ height: 1 }} />
|
|
172
|
+
|
|
173
|
+
<Field label="Server" value={truncate(serverName, 30)} />
|
|
174
|
+
<Field label="Stack" value={stack} valueColor={stackColor(stack)} />
|
|
175
|
+
<Field
|
|
176
|
+
label="Detected"
|
|
177
|
+
value={probing ? "identifying…" : probe ? probe.result.label + (isProbeStale(site) ? " (stale)" : "") : "not identified · d"}
|
|
178
|
+
valueColor={probing ? theme.textDim : probe ? probeKindColor(probe.result.kind) : theme.textFaint}
|
|
179
|
+
/>
|
|
180
|
+
<Field label="Type" value={isWordPress ? "WordPress" : "Generic"} valueColor={isWordPress ? theme.brand : theme.textDim} />
|
|
181
|
+
<Field
|
|
182
|
+
label="PHP"
|
|
183
|
+
value={
|
|
184
|
+
upgrade && isUpgradeInFlight(upgrade)
|
|
185
|
+
? `${site.php_version ?? "—"} → ${upgrade.target} (${upgrade.status}…)`
|
|
186
|
+
: upgrade?.status === "failed"
|
|
187
|
+
? `${site.php_version ?? "—"} (upgrade failed)`
|
|
188
|
+
: (site.php_version ?? "—")
|
|
189
|
+
}
|
|
190
|
+
valueColor={
|
|
191
|
+
upgrade && isUpgradeInFlight(upgrade) ? theme.warn : upgrade?.status === "failed" ? theme.bad : theme.text
|
|
192
|
+
}
|
|
193
|
+
/>
|
|
194
|
+
<Field label="User" value={site.site_user ?? "—"} />
|
|
195
|
+
<Field label="Granted" value={keyValue} valueColor={keyParts.length ? theme.good : theme.textFaint} />
|
|
196
|
+
<Field label="Public dir" value={site.public_folder ?? "/"} />
|
|
197
|
+
<Field label="Monitor" value={kumaValue} valueColor={kumaColor} />
|
|
198
|
+
<Field label="Local" value={linkValue} valueColor={linkColor} />
|
|
199
|
+
<SiteDnsSection site={site} />
|
|
200
|
+
<Field
|
|
201
|
+
label="HTTPS"
|
|
202
|
+
value={
|
|
203
|
+
httpsProgress && isHttpsToggleInFlight(httpsProgress)
|
|
204
|
+
? `${httpsProgress.action === "enable" ? "enabling" : "disabling"}… (${httpsProgress.status})`
|
|
205
|
+
: httpsProgress?.status === "failed"
|
|
206
|
+
? `${site.https?.enabled ? "enabled" : "disabled"} (toggle failed)`
|
|
207
|
+
: `${site.https?.enabled ? "enabled" : "disabled"} · H`
|
|
208
|
+
}
|
|
209
|
+
valueColor={
|
|
210
|
+
httpsProgress && isHttpsToggleInFlight(httpsProgress)
|
|
211
|
+
? theme.warn
|
|
212
|
+
: httpsProgress?.status === "failed"
|
|
213
|
+
? theme.bad
|
|
214
|
+
: site.https?.enabled
|
|
215
|
+
? theme.good
|
|
216
|
+
: theme.warn
|
|
217
|
+
}
|
|
218
|
+
/>
|
|
219
|
+
<Field
|
|
220
|
+
label="Page cache"
|
|
221
|
+
value={
|
|
222
|
+
purgeProgress && isPurgeCacheInFlight(purgeProgress)
|
|
223
|
+
? "purging…"
|
|
224
|
+
: purgeProgress && purgeCacheFailed(purgeProgress)
|
|
225
|
+
? `${site.page_cache?.enabled ? "enabled" : "disabled"} (purge failed)`
|
|
226
|
+
: `${site.page_cache?.enabled ? "enabled" : "disabled"} · P purge`
|
|
227
|
+
}
|
|
228
|
+
valueColor={
|
|
229
|
+
purgeProgress && isPurgeCacheInFlight(purgeProgress)
|
|
230
|
+
? theme.warn
|
|
231
|
+
: purgeProgress && purgeCacheFailed(purgeProgress)
|
|
232
|
+
? theme.bad
|
|
233
|
+
: site.page_cache?.enabled
|
|
234
|
+
? theme.good
|
|
235
|
+
: theme.textDim
|
|
236
|
+
}
|
|
237
|
+
/>
|
|
238
|
+
<box style={{ height: 1 }} />
|
|
239
|
+
|
|
240
|
+
{site.is_wordpress && (
|
|
241
|
+
<Field
|
|
242
|
+
label="WP updates"
|
|
243
|
+
value={
|
|
244
|
+
updates === 0
|
|
245
|
+
? "all up to date"
|
|
246
|
+
: `${site.wp_plugin_updates || 0} plugin · ${site.wp_theme_updates || 0} theme${site.wp_core_update ? " · core" : ""}`
|
|
247
|
+
}
|
|
248
|
+
valueColor={updates === 0 ? theme.good : theme.warn}
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
251
|
+
<Field
|
|
252
|
+
label="Backups"
|
|
253
|
+
value={
|
|
254
|
+
site.backups?.files || site.backups?.database
|
|
255
|
+
? `${site.backups?.files ? "files" : ""}${site.backups?.files && site.backups?.database ? " + " : ""}${site.backups?.database ? "db" : ""}`
|
|
256
|
+
: "none"
|
|
257
|
+
}
|
|
258
|
+
valueColor={site.backups?.files || site.backups?.database ? theme.good : theme.textDim}
|
|
259
|
+
/>
|
|
260
|
+
{site.backups?.next_run_time && <Field label="Next backup" value={timeAgo(site.backups.next_run_time)} />}
|
|
261
|
+
{site.git?.repo && <Field label="Git" value={`${truncate(site.git.repo, 26)} (${site.git.branch ?? "?"})`} />}
|
|
262
|
+
{site.basic_auth?.enabled && <Field label="Basic auth" value="enabled" valueColor={theme.warn} />}
|
|
263
|
+
<Field label="Created" value={formatDate(site.created_at)} />
|
|
264
|
+
</box>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// DNS zone-host lines for a site's domains, populated on demand (key `n`). Each
|
|
269
|
+
// distinct zone (www + apex collapsed) shows its resolved host; a separate-TLD
|
|
270
|
+
// additional domain surfaces as its own line with its own host. Read-only.
|
|
271
|
+
function SiteDnsSection({ site }: { site: Site }) {
|
|
272
|
+
const { zoneForDomain, isDnsResolving } = useStore()
|
|
273
|
+
const seen = new Set<string>()
|
|
274
|
+
const domains = [site.domain, ...(site.additional_domains?.map((a) => a.domain) ?? [])].filter((d) => {
|
|
275
|
+
const k = normalizeDomain(d)
|
|
276
|
+
if (!k || seen.has(k)) return false
|
|
277
|
+
seen.add(k)
|
|
278
|
+
return true
|
|
279
|
+
})
|
|
280
|
+
const anyResolved = domains.some((d) => zoneForDomain(d))
|
|
281
|
+
const anyResolving = domains.some((d) => isDnsResolving(d))
|
|
282
|
+
if (!anyResolved && !anyResolving) {
|
|
283
|
+
return <Field label="DNS" value="not checked · n" valueColor={theme.textFaint} />
|
|
284
|
+
}
|
|
285
|
+
return (
|
|
286
|
+
<box style={{ flexDirection: "column" }}>
|
|
287
|
+
<text content="DNS" fg={theme.textDim} />
|
|
288
|
+
{domains.map((d) => {
|
|
289
|
+
const c = zoneForDomain(d)
|
|
290
|
+
const resolving = isDnsResolving(d) && !c
|
|
291
|
+
const host = resolving ? "looking up…" : !c ? "—" : c.zone === null ? "no host found" : c.zone.providerLabel
|
|
292
|
+
const color = resolving ? theme.textDim : !c ? theme.textFaint : c.zone === null ? theme.warn : theme.accent
|
|
293
|
+
return (
|
|
294
|
+
<box key={d} style={{ flexDirection: "row" }}>
|
|
295
|
+
<text content={" " + truncate(d, 22)} fg={theme.text} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
296
|
+
<text content={host} fg={color} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
297
|
+
</box>
|
|
298
|
+
)
|
|
299
|
+
})}
|
|
300
|
+
</box>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Color for a resolved local stack label.
|
|
305
|
+
function localLabelColor(kind: "bedrock" | "wp" | "unknown"): string {
|
|
306
|
+
return kind === "bedrock" ? theme.good : kind === "wp" ? theme.accent : theme.warn
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Compact, self-explaining git-drift label for a linked copy's line, e.g.
|
|
310
|
+
// "⇡2 unpushed ● uncommitted". Empty when in sync.
|
|
311
|
+
function driftLabel(d: Drift): string {
|
|
312
|
+
const parts: string[] = []
|
|
313
|
+
if (d.ahead > 0) parts.push(`⇡${d.ahead} unpushed`)
|
|
314
|
+
if (d.dirty) parts.push("● uncommitted")
|
|
315
|
+
return parts.join(" ")
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A single inline action token, e.g. "t terminal". Dimmed when not applicable.
|
|
319
|
+
function Act({ keyName, label, on }: { keyName: string; label: string; on: boolean }) {
|
|
320
|
+
return (
|
|
321
|
+
<box style={{ flexDirection: "row", flexShrink: 0, marginRight: 3 }}>
|
|
322
|
+
<text content={keyName + " "} fg={on ? theme.brand : theme.textFaint} />
|
|
323
|
+
<text content={label} fg={on ? theme.textDim : theme.textFaint} wrapMode="none" />
|
|
324
|
+
</box>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Height (incl. border) of the contextual strip — views subtract this from their
|
|
329
|
+
// list viewport so the panes shrink to make room. Border eats 2 rows → 2 content
|
|
330
|
+
// lines (status + actions).
|
|
331
|
+
export const SITE_CONTEXT_STRIP_HEIGHT = 4
|
|
332
|
+
|
|
333
|
+
// A full-width context strip for the selected site, sitting between the content
|
|
334
|
+
// panes and the command drawer. Shows the local-link status and the inline
|
|
335
|
+
// open-locally actions (which the host view's keyboard fires on the selection).
|
|
336
|
+
// The space is always reserved (a placeholder when nothing is selected) so the
|
|
337
|
+
// layout doesn't jump as the cursor moves.
|
|
338
|
+
export function SiteContextStrip({ site }: { site: Site | null }) {
|
|
339
|
+
const { localLinks, drift, ensureDrift } = useStore()
|
|
340
|
+
const link = site ? localLinks.get(site.id) : undefined
|
|
341
|
+
const state = link ? resolveLocalLink(link) : null
|
|
342
|
+
const canOpen = !!state?.exists
|
|
343
|
+
const hasLocalUrl = !!link?.localUrl
|
|
344
|
+
const d = site ? drift.get(site.id) : undefined
|
|
345
|
+
|
|
346
|
+
// Auto-compute (and cache) git drift when a linked, on-disk copy is shown.
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (site && link && state?.exists) ensureDrift(site.id, link.path)
|
|
349
|
+
}, [site?.id, link?.path, state?.exists, ensureDrift])
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<box
|
|
353
|
+
title=" Local "
|
|
354
|
+
titleColor={theme.brand}
|
|
355
|
+
border
|
|
356
|
+
borderColor={link && !state!.exists ? theme.bad : theme.border}
|
|
357
|
+
style={{ height: SITE_CONTEXT_STRIP_HEIGHT, flexDirection: "column", paddingLeft: 1, paddingRight: 1 }}
|
|
358
|
+
>
|
|
359
|
+
{/* Line 1 — status (or, with nothing selected, a legend for the row markers) */}
|
|
360
|
+
{!site ? (
|
|
361
|
+
<box style={{ flexDirection: "row" }}>
|
|
362
|
+
<text content="◆ " fg={theme.good} style={{ flexShrink: 0 }} />
|
|
363
|
+
<text content="linked locally " fg={theme.textDim} style={{ flexShrink: 0 }} />
|
|
364
|
+
<text content="↑N " fg={theme.warn} style={{ flexShrink: 0 }} />
|
|
365
|
+
<text content="pending WordPress updates" fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
366
|
+
</box>
|
|
367
|
+
) : !link ? (
|
|
368
|
+
<box style={{ flexDirection: "row" }}>
|
|
369
|
+
<text content={truncate(site.domain, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
370
|
+
<text content=" not linked" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
371
|
+
</box>
|
|
372
|
+
) : !state!.exists ? (
|
|
373
|
+
<box style={{ flexDirection: "row" }}>
|
|
374
|
+
<text content="missing " fg={theme.bad} style={{ flexShrink: 0 }} />
|
|
375
|
+
<text content={link.path} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
376
|
+
</box>
|
|
377
|
+
) : (
|
|
378
|
+
<box style={{ flexDirection: "row" }}>
|
|
379
|
+
<text content={state!.label} fg={localLabelColor(state!.kind)} style={{ flexShrink: 0 }} />
|
|
380
|
+
<text content=" · " fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
381
|
+
<text content={link.path} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
382
|
+
{link.localUrl ? (
|
|
383
|
+
<text content={" " + link.localUrl} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
384
|
+
) : null}
|
|
385
|
+
{d && (d.ahead > 0 || d.dirty) ? (
|
|
386
|
+
<text content={" " + driftLabel(d)} fg={theme.warn} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
387
|
+
) : null}
|
|
388
|
+
</box>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{/* Line 2 — inline actions (fired by the host view on the selection) */}
|
|
392
|
+
{site ? (
|
|
393
|
+
<box style={{ flexDirection: "row" }}>
|
|
394
|
+
<Act keyName="t" label="terminal" on={canOpen} />
|
|
395
|
+
<Act keyName="v" label="local URL" on={canOpen && hasLocalUrl} />
|
|
396
|
+
<Act keyName="L" label={link ? "edit / unlink" : "link a local copy"} on />
|
|
397
|
+
</box>
|
|
398
|
+
) : (
|
|
399
|
+
<text content="Select a site to link a local copy and open it locally (t · v · L)" fg={theme.textFaint} wrapMode="none" />
|
|
400
|
+
)}
|
|
401
|
+
</box>
|
|
402
|
+
)
|
|
403
|
+
}
|