spinuptui 1.2.0-alpha.1 → 1.2.1
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 +10 -10
- package/README.md +5 -10
- package/package.json +1 -1
- package/src/api/client.ts +15 -0
- package/src/api/types.ts +3 -0
- package/src/config.ts +0 -23
- package/src/dev/mockClient.ts +30 -7
- package/src/index.tsx +17 -16
- package/src/lib/appUpdate.ts +7 -33
- package/src/lib/cliOffline.ts +418 -0
- package/src/lib/offline.test.ts +181 -0
- package/src/lib/offline.ts +422 -0
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/ui/App.tsx +12 -10
- package/src/ui/Details.tsx +8 -2
- package/src/ui/Explain.tsx +2 -15
- package/src/ui/Header.tsx +0 -2
- package/src/ui/Help.tsx +3 -2
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/components.tsx +2 -1
- package/src/ui/offlineRun.tsx +82 -0
- package/src/ui/store.tsx +76 -10
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +13 -5
- package/src/ui/views/Dashboard.tsx +3 -1
- package/src/ui/views/Search.tsx +8 -6
- package/src/ui/views/ServerActions.tsx +108 -11
- package/src/ui/views/SiteOffline.tsx +171 -0
- package/src/ui/views/Stacks.tsx +26 -9
- package/src/lib/appUpdate.test.ts +0 -26
- package/src/lib/beacon.test.ts +0 -140
- package/src/lib/beacon.ts +0 -349
- package/src/lib/beaconSetup.test.ts +0 -65
- package/src/lib/beaconSetup.ts +0 -143
- package/src/lib/cliBeacon.ts +0 -290
- package/src/lib/prompt.ts +0 -47
- package/src/modules/beacon.ts +0 -51
- package/src/modules/contract.ts +0 -61
- package/src/modules/index.ts +0 -34
- package/src/ui/views/BeaconSetup.tsx +0 -268
- package/src/ui/views/Modules.tsx +0 -196
package/src/ui/Details.tsx
CHANGED
|
@@ -110,7 +110,7 @@ export const SERVER_CONTROL: ActionGroup[] = [
|
|
|
110
110
|
{
|
|
111
111
|
title: "Manage",
|
|
112
112
|
items: [
|
|
113
|
-
["a", "
|
|
113
|
+
["a", "Restart / offline"],
|
|
114
114
|
["h", "Health"],
|
|
115
115
|
["N", "DNS"],
|
|
116
116
|
],
|
|
@@ -180,7 +180,13 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
|
|
|
180
180
|
<Field label="Stack" value={stack} valueColor={stackColor(stack)} />
|
|
181
181
|
<Field
|
|
182
182
|
label="Detected"
|
|
183
|
-
value={
|
|
183
|
+
value={
|
|
184
|
+
probing
|
|
185
|
+
? "identifying…"
|
|
186
|
+
: probe
|
|
187
|
+
? `${probe.result.label}${isProbeStale(site) ? " (stale)" : ""} · ${timeAgo(new Date(probe.detectedAt).toISOString())}`
|
|
188
|
+
: "not identified · d"
|
|
189
|
+
}
|
|
184
190
|
valueColor={probing ? theme.textDim : probe ? probeKindColor(probe.result.kind) : theme.textFaint}
|
|
185
191
|
/>
|
|
186
192
|
<Field label="Type" value={isWordPress ? "WordPress" : "Generic"} valueColor={isWordPress ? theme.brand : theme.textDim} />
|
package/src/ui/Explain.tsx
CHANGED
|
@@ -27,7 +27,7 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
27
27
|
["Recent activity", "The latest deploys and operations"],
|
|
28
28
|
],
|
|
29
29
|
actions: [
|
|
30
|
-
["1–
|
|
30
|
+
["1–5", "Switch tabs"],
|
|
31
31
|
["r", "Refresh from the API"],
|
|
32
32
|
],
|
|
33
33
|
},
|
|
@@ -91,23 +91,10 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
91
91
|
title: "Events",
|
|
92
92
|
summary: "A live feed of deploys, reboots, and operations across your account.",
|
|
93
93
|
actions: [
|
|
94
|
-
["1–
|
|
94
|
+
["1–5", "Switch tabs"],
|
|
95
95
|
["r", "Refresh"],
|
|
96
96
|
],
|
|
97
97
|
},
|
|
98
|
-
modules: {
|
|
99
|
-
title: "Modules",
|
|
100
|
-
summary: "Modules are the parts of SpinupTUI that keep working after you close it: paging you, running follow-ups, watching the fleet. Each card says what it does and whether it's set up on this machine.",
|
|
101
|
-
panes: [
|
|
102
|
-
["Modules", "Every module, with its status here: ● set up · ○ not set up · coming soon"],
|
|
103
|
-
["Detail", "What the selected module does, its health checks, and what to do next"],
|
|
104
|
-
],
|
|
105
|
-
actions: [
|
|
106
|
-
["⏎ / s", "Set up the selected module (Beacon: connect a Telegram bot)"],
|
|
107
|
-
["t", "Send a test page through Beacon"],
|
|
108
|
-
["1–6", "Switch tabs"],
|
|
109
|
-
],
|
|
110
|
-
},
|
|
111
98
|
}
|
|
112
99
|
|
|
113
100
|
export function ExplainOverlay({ route }: { route: Route }) {
|
package/src/ui/Header.tsx
CHANGED
|
@@ -15,7 +15,6 @@ const TABS: { route: Route; key: string; label: string }[] = [
|
|
|
15
15
|
{ route: "stacks", key: "3", label: "Stacks" },
|
|
16
16
|
{ route: "search", key: "4", label: "Search" },
|
|
17
17
|
{ route: "events", key: "5", label: "Events" },
|
|
18
|
-
{ route: "modules", key: "6", label: "Modules" },
|
|
19
18
|
]
|
|
20
19
|
|
|
21
20
|
// A one-line "what is this view, and what can I do here" subtitle per tab —
|
|
@@ -27,7 +26,6 @@ const SUBTITLES: Record<Route, string> = {
|
|
|
27
26
|
stacks: "Your fleet grouped by app type · d identify an app (SSH) · S find local copies on your disk",
|
|
28
27
|
search: "Jump to any server or site by name · Tab hands focus to the result's actions",
|
|
29
28
|
events: "Recent deploys, reboots, and operations across your account",
|
|
30
|
-
modules: "What keeps working after you close SpinupTUI · ⏎ set up the selected module",
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
// SpinupWP allows 60 API requests a minute. Show what's left of this minute so
|
package/src/ui/Help.tsx
CHANGED
|
@@ -15,7 +15,7 @@ type Section = { title: string; keys: [string, string][] }
|
|
|
15
15
|
const GLOBAL: Section = {
|
|
16
16
|
title: "Global",
|
|
17
17
|
keys: [
|
|
18
|
-
["1 …
|
|
18
|
+
["1 … 5", "Switch tabs"],
|
|
19
19
|
["r", "Refresh from the API"],
|
|
20
20
|
["/", "Global search"],
|
|
21
21
|
["A", "Switch SpinupWP accounts"],
|
|
@@ -37,13 +37,14 @@ const NAV: Section = {
|
|
|
37
37
|
["s", "SSH into the site"],
|
|
38
38
|
["h", "Server health (SSH)"],
|
|
39
39
|
["w", "Open in SpinupWP web app"],
|
|
40
|
-
["a", "Server actions (
|
|
40
|
+
["a", "Server actions (restart, take offline)"],
|
|
41
41
|
["c", "Create a new server"],
|
|
42
42
|
["C", "Clone this server to a new one"],
|
|
43
43
|
["S", "Connect sudo on the server"],
|
|
44
44
|
["u", "Update PHP version or WordPress core"],
|
|
45
45
|
["H", "Enable/disable HTTPS"],
|
|
46
46
|
["P", "Purge page + object cache"],
|
|
47
|
+
["O", "Take a site offline / bring it back"],
|
|
47
48
|
["K", "Grant SpinupTUI's SSH key (sudo)"],
|
|
48
49
|
["d", "Identify a site's app (SSH)"],
|
|
49
50
|
],
|
package/src/ui/Onboarding.tsx
CHANGED
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Shown when no token is found in the environment or config file. Collects an
|
|
4
4
|
// API token, validates it against the live API, persists it to ~/.config, then
|
|
5
|
-
//
|
|
5
|
+
// asks (optionally) for the account slug that `w` deep links need — the API
|
|
6
|
+
// can't tell us — and hands control back to the app.
|
|
6
7
|
|
|
7
8
|
import { useState } from "react"
|
|
8
9
|
import { theme } from "../lib/theme.ts"
|
|
9
10
|
import { Spinner, SecretInput } from "./components.tsx"
|
|
10
11
|
import { SpinupWPClient } from "../api/client.ts"
|
|
11
12
|
import { DEFAULT_BASE_URL, saveConfig, configPath } from "../config.ts"
|
|
13
|
+
import { parseAccountSlug } from "../lib/spinupweb.ts"
|
|
12
14
|
|
|
13
|
-
type Phase = "input" | "validating" | "error"
|
|
15
|
+
type Phase = "input" | "validating" | "error" | "slug"
|
|
16
|
+
|
|
17
|
+
const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
14
18
|
|
|
15
19
|
export function Onboarding({ onComplete }: { onComplete: () => void }) {
|
|
16
20
|
const [value, setValue] = useState("")
|
|
17
21
|
const [phase, setPhase] = useState<Phase>("input")
|
|
18
22
|
const [error, setError] = useState<string | null>(null)
|
|
23
|
+
const [slug, setSlug] = useState("")
|
|
19
24
|
|
|
20
25
|
async function submit(token: string) {
|
|
21
26
|
const trimmed = token.trim()
|
|
@@ -26,13 +31,76 @@ export function Onboarding({ onComplete }: { onComplete: () => void }) {
|
|
|
26
31
|
const result = await client.validateToken()
|
|
27
32
|
if (result.ok) {
|
|
28
33
|
await saveConfig({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
|
|
29
|
-
|
|
34
|
+
setPhase("slug")
|
|
30
35
|
} else {
|
|
31
36
|
setError(result.reason)
|
|
32
37
|
setPhase("error")
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// Optional: empty skips (an existing slug, e.g. on `spinuptui login`, is kept).
|
|
42
|
+
async function submitSlug() {
|
|
43
|
+
if (!slug.trim()) return onComplete()
|
|
44
|
+
const parsed = parseAccountSlug(slug)
|
|
45
|
+
if (!parsed) {
|
|
46
|
+
setError("That doesn't look like a slug — paste the part after spinupwp.app/, or the whole URL.")
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
await saveConfig({ accountSlug: parsed })
|
|
50
|
+
onComplete()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (phase === "slug") {
|
|
54
|
+
return (
|
|
55
|
+
<box
|
|
56
|
+
style={{
|
|
57
|
+
width: "100%",
|
|
58
|
+
height: "100%",
|
|
59
|
+
flexDirection: "column",
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
alignItems: "center",
|
|
62
|
+
backgroundColor: theme.bg,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
|
|
66
|
+
<box style={{ height: 1 }} />
|
|
67
|
+
<box
|
|
68
|
+
title=" Almost done "
|
|
69
|
+
titleColor={theme.brand}
|
|
70
|
+
border
|
|
71
|
+
borderColor={theme.border}
|
|
72
|
+
style={{ flexDirection: "column", width: 70, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
|
|
73
|
+
>
|
|
74
|
+
<text content="✓ Token works." fg={theme.good} />
|
|
75
|
+
<box style={{ height: 1 }} />
|
|
76
|
+
<text content="One more (optional): your SpinupWP account slug, the part" fg={theme.text} wrapMode="none" />
|
|
77
|
+
<text content="after spinupwp.app/ in your dashboard's address. With it, w" fg={theme.text} wrapMode="none" />
|
|
78
|
+
<text content="opens a site or server straight in SpinupWP." fg={theme.text} wrapMode="none" />
|
|
79
|
+
<text content="Pasting any dashboard URL works too." fg={theme.textDim} wrapMode="none" />
|
|
80
|
+
<box style={{ height: 1 }} />
|
|
81
|
+
<text content="Account slug" fg={theme.accent} />
|
|
82
|
+
<input
|
|
83
|
+
focused
|
|
84
|
+
value={slug}
|
|
85
|
+
placeholder="acme-agency"
|
|
86
|
+
onInput={(v: string) => {
|
|
87
|
+
setSlug(v)
|
|
88
|
+
setError(null)
|
|
89
|
+
}}
|
|
90
|
+
onSubmit={() => void submitSlug()}
|
|
91
|
+
style={INPUT_STYLE}
|
|
92
|
+
/>
|
|
93
|
+
<box style={{ height: 1 }} />
|
|
94
|
+
{error ? (
|
|
95
|
+
<text content={"✕ " + error} fg={theme.bad} wrapMode="none" />
|
|
96
|
+
) : (
|
|
97
|
+
<text content="Press Enter to finish. Leave it empty to skip; w will ask later." fg={theme.textFaint} wrapMode="none" />
|
|
98
|
+
)}
|
|
99
|
+
</box>
|
|
100
|
+
</box>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
36
104
|
return (
|
|
37
105
|
<box
|
|
38
106
|
style={{
|
package/src/ui/components.tsx
CHANGED
|
@@ -463,11 +463,12 @@ export function siteGroups(isWordpress: boolean, isVanity: boolean): ActionGroup
|
|
|
463
463
|
remote.push(["H", "Toggle HTTPS"])
|
|
464
464
|
remote.push(["P", "Purge cache"])
|
|
465
465
|
remote.push(["M", "Monitoring"])
|
|
466
|
+
remote.push(["O", "Take offline"])
|
|
466
467
|
return [
|
|
467
468
|
{ title: "Open", items: [["o", "In browser"], ["w", "In SpinupWP"]] },
|
|
468
469
|
{ title: "Remote", items: remote },
|
|
469
470
|
{ title: "Local", items: local },
|
|
470
471
|
...(isVanity ? [{ title: "Vanity", items: [["R", "Refresh"]] as [string, string][] }] : []),
|
|
471
|
-
{ title: "Server", items: [["a", "
|
|
472
|
+
{ title: "Server", items: [["a", "Restart / offline"], ["h", "Health"]] },
|
|
472
473
|
]
|
|
473
474
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Shared by the two places that take sites offline: the server actions menu
|
|
2
|
+
// (`a`, the whole server) and the site overlay (`O`, one site). The work itself
|
|
3
|
+
// is lib/offline.ts; this is the progress state and panel around it.
|
|
4
|
+
//
|
|
5
|
+
// It runs in the overlay rather than the store: what it changes is recorded on
|
|
6
|
+
// disk as it goes, so closing the overlay mid-run loses only the progress
|
|
7
|
+
// lines, never the ability to undo it.
|
|
8
|
+
|
|
9
|
+
import { useState } from "react"
|
|
10
|
+
import { tmpdir } from "node:os"
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
import { theme } from "../lib/theme.ts"
|
|
13
|
+
import { isDevMode } from "../dev/devMode.ts"
|
|
14
|
+
import { Panel, Spinner } from "./components.tsx"
|
|
15
|
+
import { toast } from "./toast.ts"
|
|
16
|
+
|
|
17
|
+
// Dev Mode's fake servers must never land in the real account's offline record.
|
|
18
|
+
export const offlineDir = () => (isDevMode() ? join(tmpdir(), "spinuptui-dev-offline") : undefined)
|
|
19
|
+
|
|
20
|
+
export interface OfflineRun {
|
|
21
|
+
mode: "offline" | "online"
|
|
22
|
+
lines: string[]
|
|
23
|
+
finished: boolean
|
|
24
|
+
summary?: string
|
|
25
|
+
failed?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type OfflineJob = (onProgress: (line: string) => void) => Promise<{ summary: string; failed: boolean }>
|
|
29
|
+
|
|
30
|
+
// `label` prefixes the completion toast (a server or site name), since the
|
|
31
|
+
// overlay may be closed by the time it finishes.
|
|
32
|
+
export function useOfflineRun(refresh: () => Promise<void>) {
|
|
33
|
+
const [run, setRun] = useState<OfflineRun | null>(null)
|
|
34
|
+
const start = (mode: OfflineRun["mode"], label: string, job: OfflineJob) => {
|
|
35
|
+
const push = (line: string) => setRun((r) => (r ? { ...r, lines: [...r.lines, line] } : r))
|
|
36
|
+
setRun({ mode, lines: [], finished: false })
|
|
37
|
+
void (async () => {
|
|
38
|
+
let summary: string
|
|
39
|
+
let failed: boolean
|
|
40
|
+
try {
|
|
41
|
+
;({ summary, failed } = await job(push))
|
|
42
|
+
} catch (err) {
|
|
43
|
+
summary = (err as Error).message
|
|
44
|
+
failed = true
|
|
45
|
+
}
|
|
46
|
+
setRun((r) => (r ? { ...r, finished: true, summary, failed } : r))
|
|
47
|
+
if (failed) toast.error(`${label}: ${summary}`)
|
|
48
|
+
else toast.success(`${label}: ${summary}`)
|
|
49
|
+
await refresh()
|
|
50
|
+
})()
|
|
51
|
+
}
|
|
52
|
+
return { run, start }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function OfflineProgress({ run, plural }: { run: OfflineRun; plural: boolean }) {
|
|
56
|
+
const shown = run.lines.slice(-10)
|
|
57
|
+
const what = plural ? "sites" : "site"
|
|
58
|
+
return (
|
|
59
|
+
<Panel title={run.mode === "offline" ? ` Taking ${what} offline ` : ` Bringing ${what} back online `} active>
|
|
60
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
61
|
+
{shown.map((l, i) => (
|
|
62
|
+
<text key={i} content={l} fg={l.includes("✕") ? theme.bad : l.includes("✓") ? theme.good : theme.textDim} wrapMode="none" />
|
|
63
|
+
))}
|
|
64
|
+
<box style={{ height: 1 }} />
|
|
65
|
+
{run.finished ? (
|
|
66
|
+
<text content={`${run.failed ? "✕" : "✓"} ${run.summary}`} fg={run.failed ? theme.bad : theme.good} />
|
|
67
|
+
) : (
|
|
68
|
+
<box style={{ flexDirection: "row" }}>
|
|
69
|
+
<Spinner />
|
|
70
|
+
<text content={plural ? " Working — SpinupWP runs a server's sites one at a time." : " Working — usually a few seconds."} fg={theme.textDim} wrapMode="none" />
|
|
71
|
+
</box>
|
|
72
|
+
)}
|
|
73
|
+
<box style={{ height: 1 }} />
|
|
74
|
+
<text
|
|
75
|
+
content={run.finished ? "Esc to close" : "Esc to close — it keeps running, and each change is recorded."}
|
|
76
|
+
fg={theme.textFaint}
|
|
77
|
+
wrapMode="none"
|
|
78
|
+
/>
|
|
79
|
+
</box>
|
|
80
|
+
</Panel>
|
|
81
|
+
)
|
|
82
|
+
}
|
package/src/ui/store.tsx
CHANGED
|
@@ -51,7 +51,7 @@ import { carrySiteSettings, type SettingsCarryResult } from "../lib/cloneSetting
|
|
|
51
51
|
import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
|
|
52
52
|
import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
|
|
53
53
|
import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword } from "../lib/keychain.ts"
|
|
54
|
-
import { StackCache, siteSignature, type CachedProbe } from "../lib/stackCache.ts"
|
|
54
|
+
import { StackCache, siteSignature, isRecheckDue, type CachedProbe } from "../lib/stackCache.ts"
|
|
55
55
|
import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offeredPhpVersions as offeredPhpVersionsWith, type PhpEolDates } from "../lib/phpEol.ts"
|
|
56
56
|
import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
|
|
57
57
|
import { wpCoreBusy } from "./wpCoreJobs.tsx"
|
|
@@ -60,7 +60,7 @@ import { planDbSync, runDbSync, isDbSyncInFlight, type DbSyncProgress, type Sync
|
|
|
60
60
|
import { planLocalSetup, runLocalSetup, isLocalSetupInFlight, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
|
|
61
61
|
import { planMediaFallback, type MediaFallbackResult } from "../lib/mediaFallback.ts"
|
|
62
62
|
|
|
63
|
-
export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
|
|
63
|
+
export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
|
|
64
64
|
|
|
65
65
|
// Progress of a PHP-version upgrade, tracked in the store so it survives the
|
|
66
66
|
// modal being closed. `status` mirrors the SpinupWP event status
|
|
@@ -569,6 +569,9 @@ interface StoreValue extends DataState {
|
|
|
569
569
|
// poll it to completion in the background.
|
|
570
570
|
startHttpsToggle: (site: Site) => void
|
|
571
571
|
clearHttpsToggle: (siteId: number) => void
|
|
572
|
+
// The site whose take-offline overlay (O) is open, or null.
|
|
573
|
+
siteOfflineSite: Site | null
|
|
574
|
+
setSiteOfflineSite: (s: Site | null) => void
|
|
572
575
|
// The site whose purge-cache overlay is open, or null.
|
|
573
576
|
purgeCacheSite: Site | null
|
|
574
577
|
setPurgeCacheSite: (s: Site | null) => void
|
|
@@ -742,9 +745,6 @@ interface StoreValue extends DataState {
|
|
|
742
745
|
// Whether the local-copy discovery overlay is open.
|
|
743
746
|
discoverOpen: boolean
|
|
744
747
|
setDiscoverOpen: (v: boolean) => void
|
|
745
|
-
// Beacon's guided setup overlay, opened from the Modules tab.
|
|
746
|
-
beaconSetupOpen: boolean
|
|
747
|
-
setBeaconSetupOpen: (v: boolean) => void
|
|
748
748
|
// Whether the "needs a local copy" (forgotten) report overlay is open, and an
|
|
749
749
|
// optional stack filter (set from the selected Stacks group when opened).
|
|
750
750
|
forgottenOpen: boolean
|
|
@@ -819,6 +819,14 @@ interface StoreValue extends DataState {
|
|
|
819
819
|
enableLocalSync: () => void
|
|
820
820
|
// SpinupWP account slug (from env/config) for building web deep links.
|
|
821
821
|
accountSlug: string | null
|
|
822
|
+
// Open a page in the SpinupWP web app. Deep links need the account slug, which
|
|
823
|
+
// the API doesn't expose; with none saved this asks for it (slugPrompt) and
|
|
824
|
+
// opens the page once it's given, rather than landing on the dashboard root.
|
|
825
|
+
// Returns true when the page opened straight away.
|
|
826
|
+
openInSpinupWP: (build: (slug: string) => string) => boolean
|
|
827
|
+
slugPrompt: { build: (slug: string) => string } | null
|
|
828
|
+
setSlugPrompt: (p: { build: (slug: string) => string } | null) => void
|
|
829
|
+
saveAccountSlug: (slug: string) => void
|
|
822
830
|
// The account (profile) this store is mounted for — see config.ts "Accounts".
|
|
823
831
|
// Switching remounts the whole store, so these never change under a view.
|
|
824
832
|
profileId: string
|
|
@@ -1032,6 +1040,24 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1032
1040
|
})
|
|
1033
1041
|
}, [])
|
|
1034
1042
|
const [profileLabel, setProfileLabel] = useState(cfgRef.current.profileLabel)
|
|
1043
|
+
const [accountSlug, setAccountSlug] = useState(cfgRef.current.accountSlug)
|
|
1044
|
+
const [slugPrompt, setSlugPrompt] = useState<{ build: (slug: string) => string } | null>(null)
|
|
1045
|
+
const saveAccountSlug = useCallback((slug: string) => {
|
|
1046
|
+
cfgRef.current.accountSlug = slug
|
|
1047
|
+
setAccountSlug(slug)
|
|
1048
|
+
void saveProfileConfig({ accountSlug: slug })
|
|
1049
|
+
}, [])
|
|
1050
|
+
const openInSpinupWP = useCallback(
|
|
1051
|
+
(build: (slug: string) => string) => {
|
|
1052
|
+
if (accountSlug) {
|
|
1053
|
+
openUrl(build(accountSlug))
|
|
1054
|
+
return true
|
|
1055
|
+
}
|
|
1056
|
+
setSlugPrompt({ build })
|
|
1057
|
+
return false
|
|
1058
|
+
},
|
|
1059
|
+
[accountSlug],
|
|
1060
|
+
)
|
|
1035
1061
|
const [route, setRoute] = useState<Route>("dashboard")
|
|
1036
1062
|
const [inputMode, setInputMode] = useState(false)
|
|
1037
1063
|
const [overlayOpen, setOverlayOpen] = useState(false)
|
|
@@ -1043,6 +1069,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1043
1069
|
const [httpsToggleSite, setHttpsToggleSite] = useState<Site | null>(null)
|
|
1044
1070
|
const [httpsToggles, setHttpsToggles] = useState<Map<number, HttpsToggleProgress>>(new Map())
|
|
1045
1071
|
const [purgeCacheSite, setPurgeCacheSite] = useState<Site | null>(null)
|
|
1072
|
+
const [siteOfflineSite, setSiteOfflineSite] = useState<Site | null>(null)
|
|
1046
1073
|
const [purgeCacheProgress, setPurgeCacheProgress] = useState<Map<number, PurgeCacheProgress>>(new Map())
|
|
1047
1074
|
const [serverActionsServer, setServerActionsServer] = useState<Server | null>(null)
|
|
1048
1075
|
const [serverOps, setServerOps] = useState<Map<number, ServerOpProgress>>(new Map())
|
|
@@ -1113,7 +1140,6 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1113
1140
|
const [mediaFallbackSite, setMediaFallbackSite] = useState<Site | null>(null)
|
|
1114
1141
|
const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
|
|
1115
1142
|
const [discoverOpen, setDiscoverOpen] = useState(false)
|
|
1116
|
-
const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
|
|
1117
1143
|
const [forgottenOpen, setForgottenOpen] = useState(false)
|
|
1118
1144
|
const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
|
|
1119
1145
|
const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
|
|
@@ -1250,7 +1276,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1250
1276
|
}, [])
|
|
1251
1277
|
|
|
1252
1278
|
const probeOne = useCallback(
|
|
1253
|
-
async (site: Site) => {
|
|
1279
|
+
async (site: Site): Promise<boolean> => {
|
|
1254
1280
|
const cache = cacheRef.current!
|
|
1255
1281
|
setProbingIds((prev) => new Set(prev).add(site.id))
|
|
1256
1282
|
setProbeErrors((prev) => {
|
|
@@ -1272,6 +1298,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1272
1298
|
next.delete(site.id)
|
|
1273
1299
|
return next
|
|
1274
1300
|
})
|
|
1301
|
+
return outcome.ok
|
|
1275
1302
|
},
|
|
1276
1303
|
[servers],
|
|
1277
1304
|
)
|
|
@@ -1305,6 +1332,41 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1305
1332
|
|
|
1306
1333
|
const isProbeStale = useCallback((site: Site) => cacheRef.current!.isStale(site.id, siteSignature(site)), [])
|
|
1307
1334
|
|
|
1335
|
+
// Re-check WordPress versions that have gone old, whenever the site list loads
|
|
1336
|
+
// (launch, and every `r`). Only sites already probed successfully: an unprobed
|
|
1337
|
+
// site's stack is the user's call (`d`/`D`), and it may not be reachable at all.
|
|
1338
|
+
// Each site gets at most one automatic attempt per max-age window, recorded in
|
|
1339
|
+
// the cache so it holds across launches: a site whose probe now fails keeps its
|
|
1340
|
+
// old reading (shown with its age) instead of being retried on every launch or
|
|
1341
|
+
// refresh. Dev Mode's servers aren't real.
|
|
1342
|
+
//
|
|
1343
|
+
// Unattended SSH must not trip fail2ban, which bans the source IP after a few
|
|
1344
|
+
// failed logins on one server. So each server's sites go one at a time, and the
|
|
1345
|
+
// first failure on a server ends that server's run for this window: at most one
|
|
1346
|
+
// failed login per server per day. Servers run in parallel, a few at a time.
|
|
1347
|
+
useEffect(() => {
|
|
1348
|
+
if (isDevMode() || servers.length === 0) return
|
|
1349
|
+
const cache = cacheRef.current!
|
|
1350
|
+
const now = Date.now()
|
|
1351
|
+
const due = sites.filter((s) => isRecheckDue(cache.get(s.id), now))
|
|
1352
|
+
if (due.length === 0) return
|
|
1353
|
+
// Marked up front, all of them: a reload mid-run must not queue these again,
|
|
1354
|
+
// and a server's sites after its first failure are skipped for the window too.
|
|
1355
|
+
void cache.noteRecheck(due.map((s) => s.id))
|
|
1356
|
+
const byServer = new Map<number, Site[]>()
|
|
1357
|
+
for (const s of due) byServer.set(s.server_id, [...(byServer.get(s.server_id) ?? []), s])
|
|
1358
|
+
const queues = [...byServer.values()]
|
|
1359
|
+
let cursor = 0
|
|
1360
|
+
const worker = async () => {
|
|
1361
|
+
while (cursor < queues.length) {
|
|
1362
|
+
for (const site of queues[cursor++]) if (!(await probeOne(site))) break
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
for (let i = 0; i < Math.min(3, queues.length); i++) void worker()
|
|
1366
|
+
// probeOne changes identity with `servers`, which reloads together with
|
|
1367
|
+
// `sites` — this should only re-run when the site list itself is reloaded.
|
|
1368
|
+
}, [sites, servers])
|
|
1369
|
+
|
|
1308
1370
|
const isPhpEol = useCallback((version: string | null | undefined) => isPhpEolWith(version, phpEolDates), [phpEolDates])
|
|
1309
1371
|
const offeredPhpVersions = useCallback(
|
|
1310
1372
|
(current?: string | null) => offeredPhpVersionsWith(phpEolDates, current),
|
|
@@ -4418,6 +4480,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4418
4480
|
clearHttpsToggle,
|
|
4419
4481
|
purgeCacheSite,
|
|
4420
4482
|
setPurgeCacheSite,
|
|
4483
|
+
siteOfflineSite,
|
|
4484
|
+
setSiteOfflineSite,
|
|
4421
4485
|
purgeCacheProgress,
|
|
4422
4486
|
startPurgeCache,
|
|
4423
4487
|
clearPurgeCache,
|
|
@@ -4532,8 +4596,6 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4532
4596
|
addLocalRoot,
|
|
4533
4597
|
discoverOpen,
|
|
4534
4598
|
setDiscoverOpen,
|
|
4535
|
-
beaconSetupOpen,
|
|
4536
|
-
setBeaconSetupOpen,
|
|
4537
4599
|
forgottenOpen,
|
|
4538
4600
|
setForgottenOpen,
|
|
4539
4601
|
forgottenStack,
|
|
@@ -4575,7 +4637,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4575
4637
|
enableLocalSyncSite,
|
|
4576
4638
|
setEnableLocalSyncSite,
|
|
4577
4639
|
enableLocalSync,
|
|
4578
|
-
accountSlug
|
|
4640
|
+
accountSlug,
|
|
4641
|
+
openInSpinupWP,
|
|
4642
|
+
slugPrompt,
|
|
4643
|
+
setSlugPrompt,
|
|
4644
|
+
saveAccountSlug,
|
|
4579
4645
|
profileId: cfgRef.current.profileId,
|
|
4580
4646
|
profileLabel,
|
|
4581
4647
|
setProfileLabel,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Ask for the SpinupWP account slug the first time `w` needs it.
|
|
2
|
+
//
|
|
3
|
+
// Deep links into the web app are account-scoped (spinupwp.app/{slug}/sites/{id})
|
|
4
|
+
// and the API doesn't expose the slug, so without one `w` could only open the
|
|
5
|
+
// dashboard root. Instead of that, `w` opens this prompt: the user pastes the
|
|
6
|
+
// slug (or any dashboard URL), it's saved to the current account, and the page
|
|
7
|
+
// they asked for opens. Every later `w` goes straight there.
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState } from "react"
|
|
10
|
+
import { useKeyboard } from "@opentui/react"
|
|
11
|
+
import { theme } from "../../lib/theme.ts"
|
|
12
|
+
import { openUrl } from "../../lib/open.ts"
|
|
13
|
+
import { parseAccountSlug } from "../../lib/spinupweb.ts"
|
|
14
|
+
import { Panel, Centered } from "../components.tsx"
|
|
15
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
16
|
+
import { useStore } from "../store.tsx"
|
|
17
|
+
import { toast } from "../toast.ts"
|
|
18
|
+
|
|
19
|
+
const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
20
|
+
|
|
21
|
+
export function AccountSlugPrompt() {
|
|
22
|
+
const { slugPrompt, setSlugPrompt, saveAccountSlug, setInputMode } = useStore()
|
|
23
|
+
const [value, setValue] = useState("")
|
|
24
|
+
const [error, setError] = useState<string | null>(null)
|
|
25
|
+
|
|
26
|
+
// The input owns the keyboard while this is open.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setInputMode(true)
|
|
29
|
+
return () => setInputMode(false)
|
|
30
|
+
}, [setInputMode])
|
|
31
|
+
|
|
32
|
+
const close = () => setSlugPrompt(null)
|
|
33
|
+
|
|
34
|
+
const submit = () => {
|
|
35
|
+
if (!slugPrompt) return
|
|
36
|
+
const slug = parseAccountSlug(value)
|
|
37
|
+
if (!slug) {
|
|
38
|
+
setError(value.trim() ? "That doesn't look like a slug — paste the part after spinupwp.app/, or the whole URL." : "Paste your account slug, or Esc to cancel.")
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
saveAccountSlug(slug)
|
|
42
|
+
openUrl(slugPrompt.build(slug))
|
|
43
|
+
toast.success(`Saved “${slug}” — opening in SpinupWP…`)
|
|
44
|
+
close()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Esc reaches us even with the input focused (it doesn't consume it).
|
|
48
|
+
useKeyboard((key) => {
|
|
49
|
+
if (key.name === "escape") close()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (!slugPrompt) return null
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<box
|
|
56
|
+
style={{
|
|
57
|
+
position: "absolute",
|
|
58
|
+
top: 0,
|
|
59
|
+
left: 0,
|
|
60
|
+
width: "100%",
|
|
61
|
+
height: "100%",
|
|
62
|
+
flexDirection: "column",
|
|
63
|
+
backgroundColor: theme.bg,
|
|
64
|
+
zIndex: 300,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<Centered>
|
|
68
|
+
<Panel title=" Open in SpinupWP " active>
|
|
69
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
70
|
+
<text content="To open the right page, SpinupTUI needs your SpinupWP account slug:" fg={theme.text} wrapMode="none" />
|
|
71
|
+
<text content="the part after spinupwp.app/ in your dashboard's address." fg={theme.text} wrapMode="none" />
|
|
72
|
+
<box style={{ height: 1 }} />
|
|
73
|
+
<text content="SpinupWP's API doesn't provide it, so this asks once and saves it." fg={theme.textDim} wrapMode="none" />
|
|
74
|
+
<text content="Pasting any dashboard URL works too." fg={theme.textDim} wrapMode="none" />
|
|
75
|
+
<box style={{ height: 1 }} />
|
|
76
|
+
<text content="Account slug" fg={theme.accent} />
|
|
77
|
+
<input
|
|
78
|
+
focused
|
|
79
|
+
value={value}
|
|
80
|
+
placeholder="acme-agency"
|
|
81
|
+
onInput={(v: string) => {
|
|
82
|
+
setValue(v)
|
|
83
|
+
setError(null)
|
|
84
|
+
}}
|
|
85
|
+
onSubmit={submit}
|
|
86
|
+
style={INPUT_STYLE}
|
|
87
|
+
/>
|
|
88
|
+
<box style={{ height: 1 }} />
|
|
89
|
+
{error ? <text content={"✕ " + error} fg={theme.bad} wrapMode="none" /> : <text content="Saved to this account in config.json." fg={theme.textFaint} wrapMode="none" />}
|
|
90
|
+
</box>
|
|
91
|
+
</Panel>
|
|
92
|
+
</Centered>
|
|
93
|
+
<StatusBar
|
|
94
|
+
hints={[
|
|
95
|
+
{ key: "⏎", label: "save & open" },
|
|
96
|
+
{ key: "esc", label: "cancel" },
|
|
97
|
+
]}
|
|
98
|
+
showGlobal={false}
|
|
99
|
+
/>
|
|
100
|
+
</box>
|
|
101
|
+
)
|
|
102
|
+
}
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -24,7 +24,7 @@ type Focus = "servers" | "sites"
|
|
|
24
24
|
|
|
25
25
|
export function Browser({ rows }: { rows: number }) {
|
|
26
26
|
const store = useStore()
|
|
27
|
-
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes,
|
|
27
|
+
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, openInSpinupWP, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
|
|
28
28
|
|
|
29
29
|
const [serverIndex, setServerIndex] = useState(0)
|
|
30
30
|
const [siteIndex, setSiteIndex] = useState(0)
|
|
@@ -128,6 +128,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
128
128
|
// Purge page cache + object cache on the selected site.
|
|
129
129
|
if (focus === "sites" && sites[siteIndex]) setPurgeCacheSite(sites[siteIndex])
|
|
130
130
|
return
|
|
131
|
+
case "O":
|
|
132
|
+
// Take the selected site offline, or bring it back. The whole server's
|
|
133
|
+
// version is in the server actions menu (a).
|
|
134
|
+
if (focus === "sites" && sites[siteIndex]) setSiteOfflineSite(sites[siteIndex])
|
|
135
|
+
return
|
|
131
136
|
case "M":
|
|
132
137
|
// Uptime Kuma monitoring for the selected site (connect on first use).
|
|
133
138
|
// Capital because lowercase m is media fallback here, matching Search.
|
|
@@ -278,11 +283,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
278
283
|
// Open the focused server/site in the SpinupWP web app — useful for
|
|
279
284
|
// actions the API can't do (e.g. running a pending server upgrade).
|
|
280
285
|
if (focus === "sites" && sites[siteIndex]) {
|
|
281
|
-
|
|
282
|
-
|
|
286
|
+
const id = sites[siteIndex].id
|
|
287
|
+
if (openInSpinupWP((slug) => siteWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
|
|
283
288
|
} else if (server) {
|
|
284
|
-
|
|
285
|
-
|
|
289
|
+
const id = server.id
|
|
290
|
+
if (openInSpinupWP((slug) => serverWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
|
|
286
291
|
}
|
|
287
292
|
setTimeout(() => setFlash(null), 2000)
|
|
288
293
|
return
|
|
@@ -324,6 +329,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
324
329
|
emptyText="No servers yet — press c to create one"
|
|
325
330
|
renderRow={(s, selected) => {
|
|
326
331
|
const count = sitesForServer(s.id).length
|
|
332
|
+
const offline = sitesForServer(s.id).filter((x) => x.disabled_at).length
|
|
327
333
|
const op = serverOps.get(s.id)
|
|
328
334
|
return (
|
|
329
335
|
<>
|
|
@@ -340,6 +346,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
340
346
|
) : (
|
|
341
347
|
s.reboot_required && <text content=" ↻ rbt" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />
|
|
342
348
|
)}
|
|
349
|
+
{offline > 0 && <text content={offline === count ? " ⏻ off" : ` ⏻ ${offline} off`} fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
343
350
|
{s.upgrade_required && <text content=" ⬆ upg" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
|
|
344
351
|
{isServerOsEol(s) && <text content=" ⚠ os" fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
345
352
|
{isSudoConnected(s.id) && <text content=" ● sudo" fg={selected ? theme.text : theme.good} style={{ flexShrink: 0 }} />}
|
|
@@ -388,6 +395,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
388
395
|
linked/key/HTTPS/cache/backup/updates badges moved to the Details
|
|
389
396
|
pane (already shown there for the selected site) to give the
|
|
390
397
|
domain name room on narrower terminals. */}
|
|
398
|
+
{s.disabled_at && <text content="⏻ off " fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
391
399
|
<WpCoreRowMark siteId={s.id} selected={selected} compact />
|
|
392
400
|
<text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
393
401
|
<PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
|