spinuptui 0.24.4 → 0.26.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 +112 -1
- package/README.md +65 -10
- package/package.json +1 -1
- package/src/index.tsx +131 -11
- package/src/lib/cliPull.ts +382 -0
- package/src/lib/cliSsh.ts +74 -49
- package/src/lib/dbBackup.ts +7 -4
- package/src/lib/dbSync.ts +15 -1
- package/src/lib/links.ts +23 -0
- package/src/lib/localSetup.ts +164 -0
- package/src/lib/sshExec.ts +2 -1
- package/src/ui/App.tsx +6 -0
- package/src/ui/Splash.tsx +3 -0
- package/src/ui/store.tsx +70 -0
- package/src/ui/views/LocalLink.tsx +62 -11
- package/src/ui/views/LocalSetup.tsx +256 -0
- package/src/ui/views/Search.tsx +38 -66
- package/src/ui/views/ServerActions.tsx +30 -4
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Clone a brand-new local working copy from production — the guided
|
|
2
|
+
// alternative to `L`'s manual "enter a path" form, offered when a site has no
|
|
3
|
+
// local link yet. Opened from LocalLink.tsx's choose screen.
|
|
4
|
+
//
|
|
5
|
+
// Gets the code down (git clone, or an rsync pull over SSH excluding uploads/
|
|
6
|
+
// for non-git sites), runs `composer install` for Bedrock/Radicle, then links
|
|
7
|
+
// the result exactly like the manual form would. It deliberately stops there —
|
|
8
|
+
// the local database and webserver config are NOT touched here. On success it
|
|
9
|
+
// hands off to the existing dbSync (`p`) flow unchanged (which itself gates on
|
|
10
|
+
// `localSync` being enabled), then that flow's own done screen already nudges
|
|
11
|
+
// into mediaFallback (`m`). Three existing, already-reviewed screens chained
|
|
12
|
+
// by keypresses, not one new mega state machine.
|
|
13
|
+
|
|
14
|
+
import { useState } from "react"
|
|
15
|
+
import { useKeyboard } from "@opentui/react"
|
|
16
|
+
import { theme } from "../../lib/theme.ts"
|
|
17
|
+
import { truncate, middleTruncate } from "../../lib/format.ts"
|
|
18
|
+
import { Panel, Centered, DestPath, Steps, type StepRow } from "../components.tsx"
|
|
19
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
20
|
+
import { useStore } from "../store.tsx"
|
|
21
|
+
import type { LocalSetupStage } from "../../lib/localSetup.ts"
|
|
22
|
+
|
|
23
|
+
const SETUP_STEPS: { stage: LocalSetupStage; label: string }[] = [
|
|
24
|
+
{ stage: "fetch", label: "Get the code (clone / pull)" },
|
|
25
|
+
{ stage: "build", label: "composer install" },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export function LocalSetupOverlay() {
|
|
29
|
+
const { localSetupSite: site, setLocalSetupSite, serverById, planLocalSetupFor, localSetups, startLocalSetup, clearLocalSetup, localSync, setEnableLocalSyncSite, setDbSyncSite } = useStore()
|
|
30
|
+
|
|
31
|
+
const progress = site ? localSetups.get(site.id) : undefined
|
|
32
|
+
const [path, setPath] = useState("")
|
|
33
|
+
const [url, setUrl] = useState("")
|
|
34
|
+
const [field, setField] = useState<"path" | "url">("path")
|
|
35
|
+
const [formError, setFormError] = useState<string | null>(null)
|
|
36
|
+
|
|
37
|
+
const dp: "form" | "running" | "done" | "error" =
|
|
38
|
+
!progress ? "form" : progress.stage === "done" ? "done" : progress.stage === "error" ? "error" : "running"
|
|
39
|
+
|
|
40
|
+
const close = () => {
|
|
41
|
+
if (site && (progress?.stage === "done" || progress?.stage === "error")) clearLocalSetup(site.id)
|
|
42
|
+
setLocalSetupSite(null)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const submit = () => {
|
|
46
|
+
if (!site) return
|
|
47
|
+
const res = planLocalSetupFor(site, path, url)
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
setFormError(res.error)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
setFormError(null)
|
|
53
|
+
startLocalSetup(site, path, url)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pullDb = () => {
|
|
57
|
+
if (!site) return
|
|
58
|
+
close()
|
|
59
|
+
if (!localSync) setEnableLocalSyncSite(site)
|
|
60
|
+
else setDbSyncSite(site)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
useKeyboard((key) => {
|
|
64
|
+
const name = key.name ?? ""
|
|
65
|
+
if (name === "escape" || name === "q") return close()
|
|
66
|
+
|
|
67
|
+
if (dp === "form") {
|
|
68
|
+
if (name === "up") return setField("path")
|
|
69
|
+
if (name === "down") return setField("url")
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (dp === "error") {
|
|
73
|
+
if (name === "r" && site) {
|
|
74
|
+
clearLocalSetup(site.id)
|
|
75
|
+
setFormError(null)
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (dp === "done") {
|
|
80
|
+
if (name === "return" || name === "p") return pullDb()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (!site) return null
|
|
86
|
+
const server = serverById(site.server_id)
|
|
87
|
+
const isGit = !!site.git?.repo
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
|
|
91
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
92
|
+
<text content="⇩ Clone fullsite (DB + files) from production " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
93
|
+
<text content={truncate(site.domain, 38)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
94
|
+
</box>
|
|
95
|
+
|
|
96
|
+
<Centered>{renderBody()}</Centered>
|
|
97
|
+
|
|
98
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
99
|
+
</box>
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
function renderBody() {
|
|
103
|
+
if (dp === "form") {
|
|
104
|
+
return (
|
|
105
|
+
<Panel title=" Clone from production " active>
|
|
106
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
107
|
+
<box style={{ flexDirection: "row" }}>
|
|
108
|
+
<text content="from " fg={theme.textFaint} />
|
|
109
|
+
<text content={truncate(site!.domain, 30)} fg={theme.accent} wrapMode="none" />
|
|
110
|
+
<text content=" on " fg={theme.textDim} />
|
|
111
|
+
<text content={truncate(server?.name ?? "—", 22)} fg={theme.textDim} wrapMode="none" />
|
|
112
|
+
</box>
|
|
113
|
+
<text content={isGit ? "source: git clone (this site's repo)" : "source: file pull over SSH (excludes uploads/)"} fg={theme.textFaint} wrapMode="none" />
|
|
114
|
+
<box style={{ height: 1 }} />
|
|
115
|
+
<text content="Local path for the new working copy" fg={field === "path" ? theme.accent : theme.textDim} />
|
|
116
|
+
<input
|
|
117
|
+
focused={field === "path"}
|
|
118
|
+
value={path}
|
|
119
|
+
placeholder={`~/code/${site!.domain}`}
|
|
120
|
+
onInput={setPath}
|
|
121
|
+
onSubmit={() => setField("url")}
|
|
122
|
+
style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
123
|
+
/>
|
|
124
|
+
<box style={{ height: 1 }} />
|
|
125
|
+
<text content="Local URL (optional) — Valet, Cove, LocalWP, Herd, DDEV…" fg={field === "url" ? theme.accent : theme.textDim} />
|
|
126
|
+
<input
|
|
127
|
+
focused={field === "url"}
|
|
128
|
+
value={url}
|
|
129
|
+
placeholder="https://example.test"
|
|
130
|
+
onInput={setUrl}
|
|
131
|
+
onSubmit={submit}
|
|
132
|
+
style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
133
|
+
/>
|
|
134
|
+
{formError ? (
|
|
135
|
+
<>
|
|
136
|
+
<box style={{ height: 1 }} />
|
|
137
|
+
<text content={`✕ ${formError}`} fg={theme.bad} wrapMode="none" />
|
|
138
|
+
</>
|
|
139
|
+
) : null}
|
|
140
|
+
<box style={{ height: 1 }} />
|
|
141
|
+
<text content="This only pulls code — your local DB/webserver aren't touched." fg={theme.textFaint} wrapMode="none" />
|
|
142
|
+
<text content="↑↓ switch field · Enter on the URL starts · Esc cancels" fg={theme.textFaint} wrapMode="none" />
|
|
143
|
+
</box>
|
|
144
|
+
</Panel>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// "build" (composer install) only applies to Bedrock/Radicle — unknown
|
|
149
|
+
// until the fetch stage finishes, so it stays out of the list entirely
|
|
150
|
+
// (like DbSync's optional hook row) until we actually know, rather than
|
|
151
|
+
// rendering a row that never ran as if it had.
|
|
152
|
+
const buildApplies =
|
|
153
|
+
progress?.stage === "build" ||
|
|
154
|
+
(progress?.stage === "done" && !!progress.ranComposer) ||
|
|
155
|
+
(progress?.stage === "error" && progress.failedStage === "build")
|
|
156
|
+
const steps = SETUP_STEPS.filter((s) => s.stage !== "build" || buildApplies)
|
|
157
|
+
const order = steps.map((s) => s.stage)
|
|
158
|
+
const curIdx = order.indexOf((progress?.stage ?? "fetch") as LocalSetupStage)
|
|
159
|
+
const failedIdx = progress?.failedStage ? order.indexOf(progress.failedStage) : order.length - 1
|
|
160
|
+
const rows: StepRow[] = steps.map(({ label }, i) => {
|
|
161
|
+
const state: StepRow["state"] =
|
|
162
|
+
dp === "done"
|
|
163
|
+
? "done"
|
|
164
|
+
: dp === "error"
|
|
165
|
+
? i < failedIdx
|
|
166
|
+
? "done"
|
|
167
|
+
: i === failedIdx
|
|
168
|
+
? "failed"
|
|
169
|
+
: "pending"
|
|
170
|
+
: i < curIdx
|
|
171
|
+
? "done"
|
|
172
|
+
: i === curIdx
|
|
173
|
+
? "active"
|
|
174
|
+
: "pending"
|
|
175
|
+
return { label, state }
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Panel title=" Cloning from production " active>
|
|
180
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
181
|
+
<Steps rows={rows} />
|
|
182
|
+
<box style={{ height: 1 }} />
|
|
183
|
+
{dp === "done" ? (
|
|
184
|
+
<>
|
|
185
|
+
<box style={{ flexDirection: "row" }}>
|
|
186
|
+
<text content="✓ " fg={theme.good} />
|
|
187
|
+
<text content="Files are down and linked." fg={theme.text} wrapMode="none" />
|
|
188
|
+
</box>
|
|
189
|
+
<box style={{ height: 1 }} />
|
|
190
|
+
<DestPath path={middleTruncate(progress?.destPath ?? "", 62)} fileColor={theme.good} width={62} />
|
|
191
|
+
{progress?.ranComposer ? <text content="composer install ran" fg={theme.textFaint} wrapMode="none" /> : null}
|
|
192
|
+
<box style={{ height: 1 }} />
|
|
193
|
+
<text content="Next: create a local database and point your local tool at it." fg={theme.textFaint} wrapMode="none" />
|
|
194
|
+
{/* The file pull copies the site's real wp-config.php, production
|
|
195
|
+
DB credentials and all. Nothing local can reach production's
|
|
196
|
+
database through them (its DB_HOST is localhost), but local
|
|
197
|
+
wp-cli authenticates as the production user, so the DB pull
|
|
198
|
+
below is denied until they're localized. Say so here rather
|
|
199
|
+
than letting it surface as a bare "Access denied" later. */}
|
|
200
|
+
{isGit ? null : (
|
|
201
|
+
<>
|
|
202
|
+
<text content="wp-config.php came down with production's DB credentials —" fg={theme.warn} wrapMode="none" />
|
|
203
|
+
<text content="update DB_NAME / DB_USER / DB_PASSWORD / DB_HOST to match," fg={theme.warn} wrapMode="none" />
|
|
204
|
+
<text content="or the DB pull is denied as production's user." fg={theme.warn} wrapMode="none" />
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
{/* The mirror image on the git path: Bedrock/Radicle keep DB
|
|
208
|
+
credentials and WP_HOME in .env, which is deliberately not in
|
|
209
|
+
the repo — so a fresh clone has none and wp-cli runs with
|
|
210
|
+
empty settings. composer install having run is the signal. */}
|
|
211
|
+
{progress?.ranComposer ? (
|
|
212
|
+
<>
|
|
213
|
+
<text content=".env isn't in the repo, so this checkout has none — copy" fg={theme.warn} wrapMode="none" />
|
|
214
|
+
<text content=".env.example to .env and set DB_* + WP_HOME before pulling." fg={theme.warn} wrapMode="none" />
|
|
215
|
+
</>
|
|
216
|
+
) : null}
|
|
217
|
+
<text content="Press ⏎ / p to pull production's database now, once that's ready." fg={theme.purple} wrapMode="none" />
|
|
218
|
+
<text content="Esc to finish here" fg={theme.textFaint} wrapMode="none" />
|
|
219
|
+
</>
|
|
220
|
+
) : dp === "error" ? (
|
|
221
|
+
<>
|
|
222
|
+
<text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
223
|
+
<box style={{ height: 1 }} />
|
|
224
|
+
<text content="Press r to try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
225
|
+
</>
|
|
226
|
+
) : (
|
|
227
|
+
<text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
228
|
+
)}
|
|
229
|
+
</box>
|
|
230
|
+
</Panel>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function hints() {
|
|
235
|
+
switch (dp) {
|
|
236
|
+
case "form":
|
|
237
|
+
return [
|
|
238
|
+
{ key: "↑↓", label: "field" },
|
|
239
|
+
{ key: "⏎", label: "start" },
|
|
240
|
+
{ key: "esc", label: "cancel" },
|
|
241
|
+
]
|
|
242
|
+
case "error":
|
|
243
|
+
return [
|
|
244
|
+
{ key: "r", label: "retry" },
|
|
245
|
+
{ key: "esc", label: "close" },
|
|
246
|
+
]
|
|
247
|
+
case "done":
|
|
248
|
+
return [
|
|
249
|
+
{ key: "⏎/p", label: "pull DB" },
|
|
250
|
+
{ key: "esc", label: "close" },
|
|
251
|
+
]
|
|
252
|
+
default:
|
|
253
|
+
return [{ key: "esc", label: "close" }]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/ui/views/Search.tsx
CHANGED
|
@@ -9,11 +9,11 @@ import { useKeyboard } from "@opentui/react"
|
|
|
9
9
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
10
10
|
import { classifyStack, stackColor, stackTag } from "../../lib/stack.ts"
|
|
11
11
|
import { truncate } from "../../lib/format.ts"
|
|
12
|
-
import { Panel,
|
|
12
|
+
import { Panel, SiteMetaCell, Spinner } from "../components.tsx"
|
|
13
13
|
import { isDbBackupInFlight } from "../../lib/dbBackup.ts"
|
|
14
14
|
import { isDbSyncInFlight } from "../../lib/dbSync.ts"
|
|
15
15
|
import { List, moveSelection } from "../List.tsx"
|
|
16
|
-
import { ServerDetail, SiteDetail,
|
|
16
|
+
import { ServerDetail, SiteDetail, ControlStrip, CONTROL_STRIP_HEIGHT } from "../Details.tsx"
|
|
17
17
|
import { StatusBar } from "../StatusBar.tsx"
|
|
18
18
|
import { openUrl } from "../../lib/open.ts"
|
|
19
19
|
import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
|
|
@@ -35,11 +35,12 @@ function score(haystack: string, q: string): number | null {
|
|
|
35
35
|
|
|
36
36
|
export function Search({ rows }: { rows: number }) {
|
|
37
37
|
const store = useStore()
|
|
38
|
-
const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite } = store
|
|
38
|
+
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
|
|
39
39
|
const [query, setQuery] = useState("")
|
|
40
40
|
const [selected, setSelected] = useState(0)
|
|
41
41
|
// "query" = typing/filtering (input focused); "actions" = input blurred so the
|
|
42
|
-
// selected result's single-key actions
|
|
42
|
+
// selected result's single-key actions fire — listed in the Site/Server
|
|
43
|
+
// Control drawer below, same as the Servers tab. Tab/→ enters, ←/Esc exits.
|
|
43
44
|
const [focus, setFocus] = useState<"query" | "actions">("query")
|
|
44
45
|
const [flash, setFlash] = useState<string | null>(null)
|
|
45
46
|
const flashMsg = (m: string) => {
|
|
@@ -160,11 +161,15 @@ export function Search({ rows }: { rows: number }) {
|
|
|
160
161
|
case "L":
|
|
161
162
|
if (current?.kind === "site") setLocalLinkSite(current.site)
|
|
162
163
|
return
|
|
163
|
-
case "K":
|
|
164
|
-
// Grant Spinup's machine key
|
|
165
|
-
//
|
|
166
|
-
|
|
164
|
+
case "K": {
|
|
165
|
+
// Grant Spinup's machine key over SSH — matches Browser's K binding,
|
|
166
|
+
// which is site-anchored but works on a server result too (falls back
|
|
167
|
+
// to the server's first key-eligible site, mirroring Browser.tsx).
|
|
168
|
+
const anchor =
|
|
169
|
+
current?.kind === "site" ? current.site : current?.kind === "server" ? sitesForServer(current.server.id).find((s) => s.site_user) : undefined
|
|
170
|
+
if (anchor) setGrantKeySite(anchor)
|
|
167
171
|
return
|
|
172
|
+
}
|
|
168
173
|
case "s":
|
|
169
174
|
if (current?.kind === "site") flashMsg(sshSite(current.site.id))
|
|
170
175
|
return
|
|
@@ -262,6 +267,17 @@ export function Search({ rows }: { rows: number }) {
|
|
|
262
267
|
case "N":
|
|
263
268
|
if (current?.kind === "server") setDnsInventoryServer(current.server)
|
|
264
269
|
return
|
|
270
|
+
case "V": {
|
|
271
|
+
// Create the vanity site at the server's own hostname, or resume an
|
|
272
|
+
// unfinished build — same gating as Browser's V.
|
|
273
|
+
if (current?.kind === "server") {
|
|
274
|
+
const s = current.server
|
|
275
|
+
const resumable = vanityJob && vanityJob.step !== "done" && vanityJob.serverId === s.id
|
|
276
|
+
const hasVanity = sitesForServer(s.id).some((site) => site.domain.toLowerCase() === s.name.toLowerCase())
|
|
277
|
+
if (resumable || !hasVanity) setVanityServer(s)
|
|
278
|
+
}
|
|
279
|
+
return
|
|
280
|
+
}
|
|
265
281
|
}
|
|
266
282
|
})
|
|
267
283
|
|
|
@@ -273,22 +289,14 @@ export function Search({ rows }: { rows: number }) {
|
|
|
273
289
|
{ key: "Tab/→", label: "actions" },
|
|
274
290
|
{ key: "esc", label: "dashboard" },
|
|
275
291
|
]
|
|
276
|
-
:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
{ key: "←/esc", label: "back" },
|
|
283
|
-
]
|
|
284
|
-
: [
|
|
285
|
-
// The Actions panel (Details pane) already lists every per-site key —
|
|
286
|
-
// this footer is nav-only now, so the two can't drift out of sync.
|
|
287
|
-
{ key: "↑↓", label: "select" },
|
|
288
|
-
{ key: "←/esc", label: "back" },
|
|
289
|
-
]
|
|
292
|
+
: [
|
|
293
|
+
// The Control drawer below already lists every key for the selected
|
|
294
|
+
// result — this footer stays nav-only so the two can't drift out of sync.
|
|
295
|
+
{ key: "↑↓", label: "select" },
|
|
296
|
+
{ key: "←/esc", label: "back" },
|
|
297
|
+
]
|
|
290
298
|
|
|
291
|
-
const listRows = Math.max(3, rows -
|
|
299
|
+
const listRows = Math.max(3, rows - 9 - CONTROL_STRIP_HEIGHT) // input box (3) + status bar (1) + chrome + control drawer
|
|
292
300
|
|
|
293
301
|
return (
|
|
294
302
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
@@ -374,14 +382,9 @@ export function Search({ rows }: { rows: number }) {
|
|
|
374
382
|
/>
|
|
375
383
|
</Panel>
|
|
376
384
|
|
|
377
|
-
<Panel title=
|
|
385
|
+
<Panel title=" Details " width={64}>
|
|
378
386
|
{!current ? (
|
|
379
387
|
<text content="No selection" fg={theme.textFaint} />
|
|
380
|
-
) : focus === "actions" ? (
|
|
381
|
-
<ActionsCard
|
|
382
|
-
result={current}
|
|
383
|
-
serverName={current.kind === "site" ? (serverById(current.site.server_id)?.name ?? "—") : current.server.name}
|
|
384
|
-
/>
|
|
385
388
|
) : current.kind === "server" ? (
|
|
386
389
|
<ServerDetail server={current.server} siteCount={store.sitesForServer(current.server.id).length} />
|
|
387
390
|
) : (
|
|
@@ -390,44 +393,13 @@ export function Search({ rows }: { rows: number }) {
|
|
|
390
393
|
</Panel>
|
|
391
394
|
</box>
|
|
392
395
|
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
396
|
+
<ControlStrip
|
|
397
|
+
site={current?.kind === "site" ? current.site : null}
|
|
398
|
+
server={current?.kind === "server" ? current.server : null}
|
|
399
|
+
serverName={current?.kind === "site" ? (serverById(current.site.server_id)?.name ?? "—") : current?.kind === "server" ? current.server.name : "—"}
|
|
400
|
+
/>
|
|
397
401
|
|
|
398
|
-
|
|
399
|
-
const isSite = result.kind === "site"
|
|
400
|
-
const name = isSite ? result.site.domain : result.server.name
|
|
401
|
-
const status = isSite ? result.site.status : result.server.connection_status
|
|
402
|
-
// Server results reuse the SAME Server Control suite as the Browser detail pane.
|
|
403
|
-
const groups = isSite ? siteGroups(result.site.is_wordpress, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
|
|
404
|
-
return (
|
|
405
|
-
<box style={{ flexDirection: "column" }}>
|
|
406
|
-
<box style={{ flexDirection: "row" }}>
|
|
407
|
-
<text content={truncate(name, 30)} fg={theme.text} attributes={1} wrapMode="none" />
|
|
408
|
-
<box style={{ flexGrow: 1 }} />
|
|
409
|
-
<StatusBadge status={status} />
|
|
410
|
-
</box>
|
|
411
|
-
{isSite ? (
|
|
412
|
-
<>
|
|
413
|
-
<text content={(result.site.https?.enabled ? "https://" : "http://") + result.site.domain} fg={theme.accent} wrapMode="none" />
|
|
414
|
-
<box style={{ height: 1 }} />
|
|
415
|
-
<Field label="Server" value={truncate(serverName, 28)} labelWidth={8} />
|
|
416
|
-
<Field label="PHP" value={result.site.php_version ?? "—"} labelWidth={8} />
|
|
417
|
-
<Field label="Stack" value={classifyStack(result.site)} valueColor={stackColor(classifyStack(result.site))} labelWidth={8} />
|
|
418
|
-
</>
|
|
419
|
-
) : (
|
|
420
|
-
<>
|
|
421
|
-
<text content={result.server.ip_address ?? "—"} fg={theme.accent} wrapMode="none" />
|
|
422
|
-
<box style={{ height: 1 }} />
|
|
423
|
-
<Field label="Provider" value={result.server.provider_name ?? "—"} labelWidth={9} />
|
|
424
|
-
<Field label="Region" value={result.server.region ?? "—"} labelWidth={9} />
|
|
425
|
-
</>
|
|
426
|
-
)}
|
|
427
|
-
<box style={{ height: 1 }} />
|
|
428
|
-
<ControlPanel heading={isSite ? "Site Control" : "Server Control"} groups={groups} />
|
|
429
|
-
<box style={{ height: 1 }} />
|
|
430
|
-
<text content="↑↓ select · ←/Esc back to search" fg={theme.textFaint} wrapMode="none" />
|
|
402
|
+
<StatusBar hints={hints} message={flash ?? undefined} showGlobal={false} />
|
|
431
403
|
</box>
|
|
432
404
|
)
|
|
433
405
|
}
|
|
@@ -15,6 +15,9 @@ import { Panel, Spinner, Centered } from "../components.tsx"
|
|
|
15
15
|
import { StatusBar } from "../StatusBar.tsx"
|
|
16
16
|
import { useStore } from "../store.tsx"
|
|
17
17
|
import { moveSelection } from "../List.tsx"
|
|
18
|
+
import { openUrl } from "../../lib/open.ts"
|
|
19
|
+
import { serverWebUrl } from "../../lib/spinupweb.ts"
|
|
20
|
+
import { toast } from "../toast.ts"
|
|
18
21
|
import type { RebootInfo } from "../../lib/ssh.ts"
|
|
19
22
|
import type { ServerOpKind } from "../store.tsx"
|
|
20
23
|
|
|
@@ -56,9 +59,11 @@ export function ServerActions() {
|
|
|
56
59
|
rebootInfoLoading,
|
|
57
60
|
rebootInfoErrors,
|
|
58
61
|
loadRebootInfo,
|
|
62
|
+
accountSlug,
|
|
59
63
|
} = store
|
|
60
64
|
|
|
61
65
|
const rebootRequired = !!server?.reboot_required
|
|
66
|
+
const upgradeRequired = !!server?.upgrade_required
|
|
62
67
|
const [phase, setPhase] = useState<Phase>("pick")
|
|
63
68
|
// Cursor starts on Reboot when one is pending, else on the first restart.
|
|
64
69
|
const [index, setIndex] = useState(() => (server?.reboot_required ? 0 : 1))
|
|
@@ -108,6 +113,12 @@ export function ServerActions() {
|
|
|
108
113
|
setTarget(ACTIONS[index])
|
|
109
114
|
setPhase("confirm")
|
|
110
115
|
return
|
|
116
|
+
case "w":
|
|
117
|
+
if (upgradeRequired && server) {
|
|
118
|
+
openUrl(serverWebUrl(server.id, accountSlug))
|
|
119
|
+
toast.success(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
|
|
120
|
+
}
|
|
121
|
+
return
|
|
111
122
|
}
|
|
112
123
|
return
|
|
113
124
|
}
|
|
@@ -179,17 +190,17 @@ export function ServerActions() {
|
|
|
179
190
|
{/* Reboot "why" context */}
|
|
180
191
|
{rebootRequired && (
|
|
181
192
|
<>
|
|
182
|
-
<box style={{ flexDirection: "
|
|
183
|
-
<text content="⚠ reboot pending
|
|
193
|
+
<box style={{ flexDirection: "column" }}>
|
|
194
|
+
<text content="⚠ reboot pending" fg={theme.warn} wrapMode="none" />
|
|
184
195
|
{infoLoading ? (
|
|
185
196
|
<box style={{ flexDirection: "row" }}>
|
|
186
197
|
<Spinner interval={120} />
|
|
187
198
|
<text content=" checking what's pending…" fg={theme.textDim} wrapMode="none" />
|
|
188
199
|
</box>
|
|
189
200
|
) : info ? (
|
|
190
|
-
<text content={rebootSummary(info)} fg={theme.textDim}
|
|
201
|
+
<text content={rebootSummary(info)} fg={theme.textDim} />
|
|
191
202
|
) : infoError ? (
|
|
192
|
-
<text content=
|
|
203
|
+
<text content={infoError} fg={theme.textFaint} />
|
|
193
204
|
) : (
|
|
194
205
|
<text content="reboot required" fg={theme.textDim} wrapMode="none" />
|
|
195
206
|
)}
|
|
@@ -197,6 +208,20 @@ export function ServerActions() {
|
|
|
197
208
|
<box style={{ height: 1 }} />
|
|
198
209
|
</>
|
|
199
210
|
)}
|
|
211
|
+
{/* SpinupWP platform upgrade — the API only exposes this as a flag,
|
|
212
|
+
no detail; the only actionable path is the web app. Two lines
|
|
213
|
+
(not a height:1 row) so the explanation can wrap instead of
|
|
214
|
+
overflowing the panel — see the flexGrow/flexShrink gotcha in
|
|
215
|
+
CLAUDE.md. */}
|
|
216
|
+
{upgradeRequired && (
|
|
217
|
+
<>
|
|
218
|
+
<box style={{ flexDirection: "column" }}>
|
|
219
|
+
<text content="⬆ SpinupWP upgrade pending" fg={theme.warn} wrapMode="none" />
|
|
220
|
+
<text content="Not doable from here — press w to open it in SpinupWP." fg={theme.textDim} />
|
|
221
|
+
</box>
|
|
222
|
+
<box style={{ height: 1 }} />
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
200
225
|
{ACTIONS.map((a, i) => {
|
|
201
226
|
const selected = i === index
|
|
202
227
|
const recommended = a.kind === "reboot" && rebootRequired
|
|
@@ -308,6 +333,7 @@ export function ServerActions() {
|
|
|
308
333
|
return [
|
|
309
334
|
{ key: "↑↓/jk", label: "action" },
|
|
310
335
|
{ key: "⏎", label: "choose" },
|
|
336
|
+
...(upgradeRequired ? [{ key: "w", label: "open in SpinupWP" }] : []),
|
|
311
337
|
{ key: "esc", label: "cancel" },
|
|
312
338
|
]
|
|
313
339
|
case "confirm":
|