spinuptui 0.27.0 → 0.28.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 +11 -1
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/config.ts +232 -8
- package/src/index.tsx +25 -5
- package/src/lib/dnsCache.ts +9 -6
- package/src/lib/keychain.ts +11 -5
- package/src/lib/providersCache.ts +9 -6
- package/src/lib/stackCache.ts +9 -6
- package/src/lib/wpCli.ts +2 -2
- package/src/ui/App.tsx +10 -0
- package/src/ui/Header.tsx +5 -1
- package/src/ui/Help.tsx +1 -0
- package/src/ui/accounts.tsx +21 -0
- package/src/ui/store.tsx +65 -20
- package/src/ui/views/Accounts.tsx +370 -0
- package/src/ui/views/Stacks.tsx +31 -6
- package/src/ui/wpCoreJobs.tsx +18 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Accounts overlay (`A`, from anywhere): the SpinupWP accounts this machine
|
|
2
|
+
// knows, which one is active, and switching between them — deliberately one
|
|
3
|
+
// account at a time, never a merged fleet, so an action can't land on the wrong
|
|
4
|
+
// client's server. Also adds (token validated live, like onboarding), renames,
|
|
5
|
+
// and removes accounts. See config.ts "Accounts" for storage and ui/accounts.tsx
|
|
6
|
+
// for how a switch remounts the app.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from "react"
|
|
9
|
+
import { useKeyboard } from "@opentui/react"
|
|
10
|
+
import { rm } from "node:fs/promises"
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
import { theme } from "../../lib/theme.ts"
|
|
13
|
+
import { truncate } from "../../lib/format.ts"
|
|
14
|
+
import { Panel, Centered, Spinner, SecretInput } from "../components.tsx"
|
|
15
|
+
import { StatusBar, type KeyHint } from "../StatusBar.tsx"
|
|
16
|
+
import { useStore } from "../store.tsx"
|
|
17
|
+
import { useAccounts } from "../accounts.tsx"
|
|
18
|
+
import { SpinupWPClient } from "../../api/client.ts"
|
|
19
|
+
import { deleteSudoPassword } from "../../lib/keychain.ts"
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_BASE_URL,
|
|
22
|
+
DEFAULT_PROFILE,
|
|
23
|
+
PROFILE_CACHE_FILES,
|
|
24
|
+
addProfile,
|
|
25
|
+
listProfiles,
|
|
26
|
+
profileDataDir,
|
|
27
|
+
removeProfile,
|
|
28
|
+
renameProfile,
|
|
29
|
+
type ProfileSummary,
|
|
30
|
+
} from "../../config.ts"
|
|
31
|
+
|
|
32
|
+
type Phase =
|
|
33
|
+
| { kind: "list" }
|
|
34
|
+
| { kind: "add-label" }
|
|
35
|
+
| { kind: "add-token" }
|
|
36
|
+
| { kind: "add-slug" }
|
|
37
|
+
| { kind: "validating" }
|
|
38
|
+
| { kind: "added"; id: string; label: string }
|
|
39
|
+
| { kind: "rename"; id: string }
|
|
40
|
+
| { kind: "confirm-remove"; profile: ProfileSummary }
|
|
41
|
+
| { kind: "switching"; label: string }
|
|
42
|
+
|
|
43
|
+
// A SpinupWP account slug is the team name, lowercased and dashed — a good first
|
|
44
|
+
// guess the user can correct ("Acme Agency" → acme-agency).
|
|
45
|
+
const slugGuess = (label: string) =>
|
|
46
|
+
label
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
49
|
+
.replace(/^-+|-+$/g, "")
|
|
50
|
+
|
|
51
|
+
const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
52
|
+
|
|
53
|
+
export function Accounts({ onClose }: { onClose: () => void }) {
|
|
54
|
+
const { profileId, tokenFromEnv, switchBlocker, setInputMode, setProfileLabel } = useStore()
|
|
55
|
+
const { switchAccount } = useAccounts()
|
|
56
|
+
const [profiles, setProfiles] = useState<ProfileSummary[]>(() => listProfiles())
|
|
57
|
+
const [index, setIndex] = useState(() => Math.max(0, profiles.findIndex((p) => p.id === profileId)))
|
|
58
|
+
const [phase, setPhase] = useState<Phase>({ kind: "list" })
|
|
59
|
+
const [message, setMessage] = useState<{ text: string; color: string } | null>(null)
|
|
60
|
+
const [label, setLabel] = useState("")
|
|
61
|
+
const [token, setToken] = useState("")
|
|
62
|
+
const [slug, setSlug] = useState("")
|
|
63
|
+
|
|
64
|
+
// The last row is "+ Add an account".
|
|
65
|
+
const rowCount = profiles.length + 1
|
|
66
|
+
const selected = index < profiles.length ? profiles[index] : null
|
|
67
|
+
const typing = phase.kind === "add-label" || phase.kind === "add-token" || phase.kind === "add-slug" || phase.kind === "rename"
|
|
68
|
+
|
|
69
|
+
// Text fields own the keyboard (global shortcuts off) while they're focused.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
setInputMode(typing)
|
|
72
|
+
return () => setInputMode(false)
|
|
73
|
+
}, [typing, setInputMode])
|
|
74
|
+
|
|
75
|
+
const reload = () => setProfiles(listProfiles())
|
|
76
|
+
const say = (text: string, color: string = theme.textDim) => setMessage({ text, color })
|
|
77
|
+
|
|
78
|
+
async function doSwitch(p: ProfileSummary) {
|
|
79
|
+
if (p.active) return say(`Already on ${p.label}.`)
|
|
80
|
+
if (tokenFromEnv) return say("SPINUPWP_ACCESS_TOKEN is set, which pins the account — unset it to switch.", theme.warn)
|
|
81
|
+
const blocker = switchBlocker()
|
|
82
|
+
if (blocker) return say(blocker, theme.warn)
|
|
83
|
+
setPhase({ kind: "switching", label: p.label })
|
|
84
|
+
// Remounts the whole app on the new account — this overlay goes with it.
|
|
85
|
+
await switchAccount(p.id)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function submitToken() {
|
|
89
|
+
const t = token.trim()
|
|
90
|
+
if (!t) return
|
|
91
|
+
setPhase({ kind: "validating" })
|
|
92
|
+
const result = await new SpinupWPClient({ token: t, baseUrl: DEFAULT_BASE_URL }).validateToken()
|
|
93
|
+
if (!result.ok) {
|
|
94
|
+
say(result.reason, theme.bad)
|
|
95
|
+
setPhase({ kind: "add-token" })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
setMessage(null)
|
|
99
|
+
setSlug(slugGuess(label))
|
|
100
|
+
setPhase({ kind: "add-slug" })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function finishAdd() {
|
|
104
|
+
const id = await addProfile({ label: label.trim(), token: token.trim(), accountSlug: slug.trim() || undefined })
|
|
105
|
+
reload()
|
|
106
|
+
setIndex(listProfiles().findIndex((p) => p.id === id))
|
|
107
|
+
setToken("")
|
|
108
|
+
setPhase({ kind: "added", id, label: label.trim() })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Remove = forget the account on this machine: its profile (token, DNS
|
|
112
|
+
// connections, local-copy links…), its saved sudo passwords, and its caches.
|
|
113
|
+
// Files in local working copies are never touched.
|
|
114
|
+
async function doRemove(p: ProfileSummary) {
|
|
115
|
+
for (const sid of p.keychainServers) await deleteSudoPassword(sid, p.id)
|
|
116
|
+
// The default account's caches sit beside machine-level files in the config
|
|
117
|
+
// dir itself, so only its named files go; any other account owns its folder.
|
|
118
|
+
const dir = profileDataDir(p.id)
|
|
119
|
+
if (p.id === DEFAULT_PROFILE) {
|
|
120
|
+
for (const f of PROFILE_CACHE_FILES) await rm(join(dir, f), { force: true }).catch(() => {})
|
|
121
|
+
} else {
|
|
122
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
123
|
+
}
|
|
124
|
+
await removeProfile(p.id)
|
|
125
|
+
reload()
|
|
126
|
+
setIndex(0)
|
|
127
|
+
setPhase({ kind: "list" })
|
|
128
|
+
say(`Removed ${p.label} from this machine.`, theme.good)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
useKeyboard((key) => {
|
|
132
|
+
const name = key.name ?? ""
|
|
133
|
+
switch (phase.kind) {
|
|
134
|
+
case "list": {
|
|
135
|
+
if (name === "escape" || name === "q") return onClose()
|
|
136
|
+
if (name === "up" || name === "k") return setIndex((i) => (i - 1 + rowCount) % rowCount)
|
|
137
|
+
if (name === "down" || name === "j") return setIndex((i) => (i + 1) % rowCount)
|
|
138
|
+
setMessage(null)
|
|
139
|
+
if (name === "return" || name === "enter") {
|
|
140
|
+
if (!selected) {
|
|
141
|
+
setLabel("")
|
|
142
|
+
setToken("")
|
|
143
|
+
return setPhase({ kind: "add-label" })
|
|
144
|
+
}
|
|
145
|
+
return void doSwitch(selected)
|
|
146
|
+
}
|
|
147
|
+
if (name === "e" && selected) {
|
|
148
|
+
setLabel(selected.label)
|
|
149
|
+
return setPhase({ kind: "rename", id: selected.id })
|
|
150
|
+
}
|
|
151
|
+
if (name === "x" && selected) {
|
|
152
|
+
if (selected.active) return say("That's the account you're on — switch to another before removing it.", theme.warn)
|
|
153
|
+
return setPhase({ kind: "confirm-remove", profile: selected })
|
|
154
|
+
}
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
case "add-label":
|
|
158
|
+
case "add-token":
|
|
159
|
+
case "add-slug":
|
|
160
|
+
case "rename":
|
|
161
|
+
if (name === "escape") {
|
|
162
|
+
setMessage(null)
|
|
163
|
+
return setPhase({ kind: "list" })
|
|
164
|
+
}
|
|
165
|
+
return
|
|
166
|
+
case "added":
|
|
167
|
+
if (name === "return" || name === "enter") {
|
|
168
|
+
const p = listProfiles().find((x) => x.id === phase.id)
|
|
169
|
+
if (p) return void doSwitch(p)
|
|
170
|
+
}
|
|
171
|
+
if (name === "escape" || name === "q") return setPhase({ kind: "list" })
|
|
172
|
+
return
|
|
173
|
+
case "confirm-remove":
|
|
174
|
+
if (name === "y") return void doRemove(phase.profile)
|
|
175
|
+
if (name === "escape" || name === "n" || name === "q") return setPhase({ kind: "list" })
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const hints: KeyHint[] =
|
|
181
|
+
phase.kind === "list"
|
|
182
|
+
? [
|
|
183
|
+
{ key: "↑↓/jk", label: "select" },
|
|
184
|
+
...(selected?.active ? [] : [{ key: "⏎", label: selected ? "switch" : "add" }]),
|
|
185
|
+
...(selected ? [{ key: "e", label: "rename" }] : []),
|
|
186
|
+
// The active account can't be removed (switch away first), so don't offer it.
|
|
187
|
+
...(selected && !selected.active ? [{ key: "x", label: "remove" }] : []),
|
|
188
|
+
{ key: "esc", label: "close" },
|
|
189
|
+
]
|
|
190
|
+
: phase.kind === "confirm-remove"
|
|
191
|
+
? [
|
|
192
|
+
{ key: "y", label: "remove" },
|
|
193
|
+
{ key: "esc", label: "cancel" },
|
|
194
|
+
]
|
|
195
|
+
: phase.kind === "added"
|
|
196
|
+
? [
|
|
197
|
+
{ key: "⏎", label: "switch now" },
|
|
198
|
+
{ key: "esc", label: "stay here" },
|
|
199
|
+
]
|
|
200
|
+
: typing
|
|
201
|
+
? [
|
|
202
|
+
{ key: "⏎", label: "next" },
|
|
203
|
+
{ key: "esc", label: "cancel" },
|
|
204
|
+
]
|
|
205
|
+
: []
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
|
|
209
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
210
|
+
<text content="◆ Accounts" fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
211
|
+
<text content=" one SpinupWP account at a time — switching reloads everything" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
212
|
+
</box>
|
|
213
|
+
<Centered>{renderBody()}</Centered>
|
|
214
|
+
<StatusBar hints={hints} message={message?.text} messageColor={message?.color} showGlobal={false} />
|
|
215
|
+
</box>
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
function rowHelp(): [string, string][] {
|
|
219
|
+
if (!selected) return [["⏎", "add another SpinupWP account"]]
|
|
220
|
+
const name = truncate(selected.label, 24)
|
|
221
|
+
if (selected.active) return [["⏎", "you're on this one"], ["e", "rename it"]]
|
|
222
|
+
return [
|
|
223
|
+
["⏎", `switch to ${name}`],
|
|
224
|
+
["e", "rename"],
|
|
225
|
+
["x", "remove"],
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function renderBody() {
|
|
230
|
+
switch (phase.kind) {
|
|
231
|
+
case "list":
|
|
232
|
+
return (
|
|
233
|
+
<Panel title=" Accounts " active>
|
|
234
|
+
<box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
|
|
235
|
+
{profiles.map((p, i) => {
|
|
236
|
+
const sel = i === index
|
|
237
|
+
return (
|
|
238
|
+
<box key={p.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
|
|
239
|
+
<text content={p.active ? " ● " : " "} fg={sel ? theme.text : theme.brand} style={{ flexShrink: 0 }} />
|
|
240
|
+
<text content={truncate(p.label, 36)} fg={sel ? theme.text : p.active ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
241
|
+
<text content={truncate(p.accountSlug ?? "", 26)} fg={sel ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0, marginLeft: 1 }} />
|
|
242
|
+
<text content={p.active ? " active " : " "} fg={sel ? theme.text : theme.good} style={{ flexShrink: 0 }} />
|
|
243
|
+
</box>
|
|
244
|
+
)
|
|
245
|
+
})}
|
|
246
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: index === profiles.length ? theme.selectedBg : undefined }}>
|
|
247
|
+
<text content=" + Add an account" fg={index === profiles.length ? theme.text : theme.accent} wrapMode="none" />
|
|
248
|
+
</box>
|
|
249
|
+
<box style={{ height: 1 }} />
|
|
250
|
+
{/* What the keys do to THIS row — the bottom bar lists them, this says what they'll mean. */}
|
|
251
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
252
|
+
{rowHelp().map(([k, what], i) => (
|
|
253
|
+
<box key={i} style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
254
|
+
<text content={i ? " " : ""} />
|
|
255
|
+
<text content={k} fg={theme.accent} />
|
|
256
|
+
<text content={` ${what}`} fg={theme.textDim} wrapMode="none" />
|
|
257
|
+
</box>
|
|
258
|
+
))}
|
|
259
|
+
</box>
|
|
260
|
+
<box style={{ height: 1 }} />
|
|
261
|
+
<text content="Each account keeps its own token, DNS connections, local copies and" fg={theme.textFaint} wrapMode="none" />
|
|
262
|
+
<text content="saved sudo passwords. The Uptime Kuma login is shared by all of them." fg={theme.textFaint} wrapMode="none" />
|
|
263
|
+
</box>
|
|
264
|
+
</Panel>
|
|
265
|
+
)
|
|
266
|
+
case "add-label":
|
|
267
|
+
return (
|
|
268
|
+
<Panel title=" Add an account · 1 of 3 " active>
|
|
269
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
270
|
+
<text content="A name for this account — shown in the header while you're on it" fg={theme.accent} />
|
|
271
|
+
<input
|
|
272
|
+
focused
|
|
273
|
+
value={label}
|
|
274
|
+
placeholder="e.g. Acme Agency"
|
|
275
|
+
onInput={setLabel}
|
|
276
|
+
onSubmit={() => label.trim() && setPhase({ kind: "add-token" })}
|
|
277
|
+
style={INPUT_STYLE}
|
|
278
|
+
/>
|
|
279
|
+
</box>
|
|
280
|
+
</Panel>
|
|
281
|
+
)
|
|
282
|
+
case "add-token":
|
|
283
|
+
case "validating":
|
|
284
|
+
return (
|
|
285
|
+
<Panel title=" Add an account · 2 of 3 " active>
|
|
286
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
287
|
+
<text content={`API token for ${truncate(label.trim(), 40)}`} fg={theme.accent} />
|
|
288
|
+
<text content="SpinupWP → Settings → API Tokens (Read/Write for changes)" fg={theme.textFaint} wrapMode="none" />
|
|
289
|
+
<SecretInput focused={phase.kind === "add-token"} value={token} placeholder="paste the token here…" onChange={setToken} onSubmit={() => void submitToken()} />
|
|
290
|
+
{phase.kind === "validating" && (
|
|
291
|
+
<box style={{ flexDirection: "row", marginTop: 1 }}>
|
|
292
|
+
<Spinner />
|
|
293
|
+
<text content=" Checking the token with SpinupWP…" fg={theme.textDim} />
|
|
294
|
+
</box>
|
|
295
|
+
)}
|
|
296
|
+
</box>
|
|
297
|
+
</Panel>
|
|
298
|
+
)
|
|
299
|
+
case "add-slug":
|
|
300
|
+
return (
|
|
301
|
+
<Panel title=" Add an account · 3 of 3 " active>
|
|
302
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
303
|
+
<text content="✓ Token works." fg={theme.good} />
|
|
304
|
+
<box style={{ height: 1 }} />
|
|
305
|
+
<text content="Account slug — the part after spinupwp.app/ in the dashboard URL." fg={theme.accent} wrapMode="none" />
|
|
306
|
+
<text content="Used for w (open in SpinupWP). Leave empty to skip." fg={theme.textFaint} wrapMode="none" />
|
|
307
|
+
<input focused value={slug} placeholder="acme-agency" onInput={setSlug} onSubmit={() => void finishAdd()} style={INPUT_STYLE} />
|
|
308
|
+
</box>
|
|
309
|
+
</Panel>
|
|
310
|
+
)
|
|
311
|
+
case "added":
|
|
312
|
+
return (
|
|
313
|
+
<Panel title=" Account added " active>
|
|
314
|
+
<box style={{ flexDirection: "column", width: 60, paddingTop: 1, paddingBottom: 1 }}>
|
|
315
|
+
<text content={`✓ ${truncate(phase.label, 50)} is ready.`} fg={theme.good} wrapMode="none" />
|
|
316
|
+
<box style={{ height: 1 }} />
|
|
317
|
+
<text content="Press Enter to switch to it now, or Esc to stay where you are." fg={theme.textDim} wrapMode="none" />
|
|
318
|
+
</box>
|
|
319
|
+
</Panel>
|
|
320
|
+
)
|
|
321
|
+
case "rename":
|
|
322
|
+
return (
|
|
323
|
+
<Panel title=" Rename account " active>
|
|
324
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
325
|
+
<text content="Name shown in the header" fg={theme.accent} />
|
|
326
|
+
<input
|
|
327
|
+
focused
|
|
328
|
+
value={label}
|
|
329
|
+
onInput={setLabel}
|
|
330
|
+
onSubmit={() => {
|
|
331
|
+
const id = phase.id
|
|
332
|
+
void renameProfile(id, label).then(() => {
|
|
333
|
+
reload()
|
|
334
|
+
setPhase({ kind: "list" })
|
|
335
|
+
if (id === profileId) setProfileLabel(label.trim())
|
|
336
|
+
})
|
|
337
|
+
}}
|
|
338
|
+
style={INPUT_STYLE}
|
|
339
|
+
/>
|
|
340
|
+
</box>
|
|
341
|
+
</Panel>
|
|
342
|
+
)
|
|
343
|
+
case "confirm-remove": {
|
|
344
|
+
const p = phase.profile
|
|
345
|
+
const n = p.keychainServers.length
|
|
346
|
+
return (
|
|
347
|
+
<Panel title=" Remove account? " active>
|
|
348
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
349
|
+
<text content={`Forget ${truncate(p.label, 44)} on this machine:`} fg={theme.text} wrapMode="none" />
|
|
350
|
+
<text content=" · its API token, DNS connections and local-copy links" fg={theme.textDim} wrapMode="none" />
|
|
351
|
+
<text content={` · ${n} saved sudo password${n === 1 ? "" : "s"} in the Keychain`} fg={theme.textDim} wrapMode="none" />
|
|
352
|
+
<text content=" · its cached app probes and DNS lookups" fg={theme.textDim} wrapMode="none" />
|
|
353
|
+
<box style={{ height: 1 }} />
|
|
354
|
+
<text content="Nothing changes in SpinupWP, and your local working-copy files stay." fg={theme.textFaint} wrapMode="none" />
|
|
355
|
+
<box style={{ height: 1 }} />
|
|
356
|
+
<text content="Press y to remove · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
357
|
+
</box>
|
|
358
|
+
</Panel>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
case "switching":
|
|
362
|
+
return (
|
|
363
|
+
<box style={{ flexDirection: "row" }}>
|
|
364
|
+
<Spinner />
|
|
365
|
+
<text content={` Switching to ${truncate(phase.label, 40)}…`} fg={theme.textDim} />
|
|
366
|
+
</box>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
package/src/ui/views/Stacks.tsx
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// probe. The WordPress buckets (Standard WP / Bedrock / Radicle) expand into one
|
|
7
7
|
// sub-row per probed core version, newest first — the "who still needs the
|
|
8
8
|
// security release" view; Non-WP expands into named sub-rows (WHMCS / Laravel /
|
|
9
|
-
// Static HTML / Unknown / unprobed). Both come from cached probes
|
|
9
|
+
// Static HTML / Vanity Site / Unknown / unprobed). Both come from cached probes,
|
|
10
|
+
// except Vanity Site, which is derived with no SSH (see `nonWpSub`).
|
|
10
11
|
//
|
|
11
12
|
// Middle pane: the sites in the selected group. `d` probes the selected site;
|
|
12
13
|
// `D` probes the whole selected group (bounded concurrency). Right pane: the
|
|
@@ -27,6 +28,7 @@ import { StatusBar } from "../StatusBar.tsx"
|
|
|
27
28
|
import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
|
|
28
29
|
import { openUrl } from "../../lib/open.ts"
|
|
29
30
|
import { siteWebUrl } from "../../lib/spinupweb.ts"
|
|
31
|
+
import { isVanityPair } from "../../lib/vanitySite.ts"
|
|
30
32
|
import { useStore } from "../store.tsx"
|
|
31
33
|
import type { Site } from "../../api/types.ts"
|
|
32
34
|
|
|
@@ -47,14 +49,28 @@ interface Group {
|
|
|
47
49
|
const WP_STACKS: Stack[] = ["Standard WP", "Bedrock", "Radicle"]
|
|
48
50
|
|
|
49
51
|
|
|
50
|
-
// Non-WP sub-categories, in display order. `kind: null` = not yet probed
|
|
51
|
-
|
|
52
|
+
// Non-WP sub-categories, in display order. `kind: null` = not yet probed;
|
|
53
|
+
// "vanity" is the site at its server's own hostname (see `nonWpSub`).
|
|
54
|
+
type NonWpSub = ProbeKind | "vanity" | null
|
|
55
|
+
const NONWP_SUBS: { kind: NonWpSub; label: string }[] = [
|
|
52
56
|
{ kind: "whmcs", label: "WHMCS" },
|
|
53
57
|
{ kind: "laravel", label: "Laravel" },
|
|
54
58
|
{ kind: "static", label: "Static HTML" },
|
|
59
|
+
{ kind: "vanity", label: "Vanity Site" },
|
|
55
60
|
{ kind: "unknown", label: "Unknown" },
|
|
56
61
|
{ kind: null, label: "unprobed" },
|
|
57
62
|
]
|
|
63
|
+
const VANITY_COLOR = theme.info
|
|
64
|
+
|
|
65
|
+
// Which Non-WP sub-row a site belongs in. A vanity site is known from the API
|
|
66
|
+
// alone (domain === its server's hostname, the same `isVanityPair` every other
|
|
67
|
+
// vanity feature keys off), so it claims the site whether it's unprobed or the
|
|
68
|
+
// probe came back Unknown/Static — the placeholder page reads as neither. A
|
|
69
|
+
// positively identified app (WHMCS/Laravel) living at the hostname still wins.
|
|
70
|
+
function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
|
|
71
|
+
if (vanity && (kind === null || kind === "unknown" || kind === "static")) return "vanity"
|
|
72
|
+
return kind
|
|
73
|
+
}
|
|
58
74
|
|
|
59
75
|
export function Stacks({ rows }: { rows: number }) {
|
|
60
76
|
const store = useStore()
|
|
@@ -125,13 +141,15 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
125
141
|
}
|
|
126
142
|
if (st === "Non-WP") {
|
|
127
143
|
for (const sub of NONWP_SUBS) {
|
|
128
|
-
const subSites = bucket.filter(
|
|
144
|
+
const subSites = bucket.filter(
|
|
145
|
+
(site) => nonWpSub(probes.get(site.id)?.result.kind ?? null, isVanityPair(site.domain, serverById(site.server_id)?.name ?? "")) === sub.kind,
|
|
146
|
+
)
|
|
129
147
|
if (subSites.length === 0) continue
|
|
130
148
|
groups.push({
|
|
131
149
|
id: `nonwp:${sub.label}`,
|
|
132
150
|
label: sub.label,
|
|
133
151
|
level: 1,
|
|
134
|
-
color: sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
|
|
152
|
+
color: sub.kind === "vanity" ? VANITY_COLOR : sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
|
|
135
153
|
sites: subSites,
|
|
136
154
|
})
|
|
137
155
|
}
|
|
@@ -139,7 +157,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
139
157
|
}
|
|
140
158
|
const php = [...phpCounts.entries()].sort((a, b) => phpSortKey(b[0]) - phpSortKey(a[0]))
|
|
141
159
|
return { groups, php }
|
|
142
|
-
}, [sites, probes])
|
|
160
|
+
}, [sites, probes, serverById])
|
|
143
161
|
|
|
144
162
|
// Keep selection in range as groups appear/disappear with probing.
|
|
145
163
|
const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
|
|
@@ -372,6 +390,11 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
372
390
|
const errored = probeErrors.has(s.id)
|
|
373
391
|
const wpRunning = wpCoreJobs.get(s.id)?.result === null
|
|
374
392
|
const faint = selected ? theme.text : theme.textFaint
|
|
393
|
+
// Vanity rows say so instead of the probe's "Unknown"/"Static HTML"
|
|
394
|
+
// (or "· press d" — there's nothing to identify).
|
|
395
|
+
const vanity =
|
|
396
|
+
effectiveStack(s, cached?.result.kind) === "Non-WP" &&
|
|
397
|
+
nonWpSub(cached?.result.kind ?? null, isVanityPair(s.domain, serverById(s.server_id)?.name ?? "")) === "vanity"
|
|
375
398
|
const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
|
|
376
399
|
return (
|
|
377
400
|
<>
|
|
@@ -391,6 +414,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
391
414
|
<WpCoreRowMark siteId={s.id} selected={selected} />
|
|
392
415
|
) : probing ? (
|
|
393
416
|
<Spinner color={selected ? theme.text : theme.brand} />
|
|
417
|
+
) : vanity ? (
|
|
418
|
+
<text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
|
|
394
419
|
) : cached ? (
|
|
395
420
|
<text
|
|
396
421
|
content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
|
package/src/ui/wpCoreJobs.tsx
CHANGED
|
@@ -160,6 +160,24 @@ export function clearBulk() {
|
|
|
160
160
|
emitBulk()
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// Is any core update still running? Switching accounts waits for these — the
|
|
164
|
+
// job maps are keyed by site id, which only means something within one account.
|
|
165
|
+
export function wpCoreBusy(): boolean {
|
|
166
|
+
if (bulk && bulk.phase !== "done") return true
|
|
167
|
+
for (const j of jobs.values()) if (j.result === null) return true
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Forget every job and finished bulk run — on an account switch, once nothing
|
|
172
|
+
// is running (wpCoreBusy), so the new account's rows don't inherit markers.
|
|
173
|
+
export function resetWpCoreJobs() {
|
|
174
|
+
jobs = new Map()
|
|
175
|
+
bulk = null
|
|
176
|
+
stopRequested = false
|
|
177
|
+
for (const l of jobListeners) l()
|
|
178
|
+
emitBulk()
|
|
179
|
+
}
|
|
180
|
+
|
|
163
181
|
export function startBulkCoreUpdate(
|
|
164
182
|
title: string,
|
|
165
183
|
plan: Omit<BulkItem, "state" | "error">[],
|