spinuptui 1.3.0-alpha.2 → 1.3.0-alpha.3
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 +20 -1
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/index.tsx +9 -0
- package/src/lib/cliHome.ts +107 -0
- package/src/lib/cliScheduler.ts +99 -3
- package/src/lib/heartbeat.test.ts +86 -0
- package/src/lib/heartbeat.ts +245 -0
- package/src/lib/home.test.ts +102 -0
- package/src/lib/home.ts +442 -0
- package/src/lib/schedulerJobs.ts +2 -0
- package/src/lib/schedulerRun.ts +3 -2
- package/src/lib/schedulerTick.ts +17 -2
- 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/modules/scheduler.ts +13 -0
- package/src/ui/App.tsx +15 -1
- package/src/ui/Details.tsx +7 -1
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/store.tsx +81 -3
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +5 -5
- package/src/ui/views/HeartbeatSetup.tsx +284 -0
- package/src/ui/views/HomeSync.tsx +299 -0
- package/src/ui/views/Modules.tsx +34 -6
- package/src/ui/views/Search.tsx +5 -6
- package/src/ui/views/ServerActions.tsx +6 -4
- package/src/ui/views/Stacks.tsx +22 -9
package/src/modules/scheduler.ts
CHANGED
|
@@ -104,6 +104,19 @@ export const schedulerModule: ModuleDefinition = {
|
|
|
104
104
|
checks.push({ name: "jobs", ok: failed.length === 0, detail: `${active} active${failed.length ? `; not ok in the last day: ${failed.slice(0, 3).join(", ")}` : ""}` })
|
|
105
105
|
const beacon = beaconFromConfig(cfg)
|
|
106
106
|
checks.push({ name: "digests", ok: beacon.channels.length > 0, detail: beacon.channels.length ? `via Beacon (${beacon.defaultChannel ?? "no default"})` : "Beacon isn't set up, so runs aren't reported" })
|
|
107
|
+
const hb = cfg.scheduler?.heartbeat
|
|
108
|
+
const lastHb = last?.heartbeat
|
|
109
|
+
checks.push({
|
|
110
|
+
name: "heartbeat",
|
|
111
|
+
ok: !hb || !lastHb || lastHb.ok,
|
|
112
|
+
detail: !hb
|
|
113
|
+
? "not set: nothing outside this machine notices if it stops (spinuptui scheduler heartbeat set <kuma-push-url>)"
|
|
114
|
+
: !lastHb
|
|
115
|
+
? `${new URL(hb.url).host}: first ping on the next dispatcher tick`
|
|
116
|
+
: lastHb.ok
|
|
117
|
+
? `pinging ${new URL(hb.url).host} every tick (last: ${lastHb.status})`
|
|
118
|
+
: `last ping to ${new URL(hb.url).host} failed: ${lastHb.error}`,
|
|
119
|
+
})
|
|
107
120
|
|
|
108
121
|
if (!installed && !d) {
|
|
109
122
|
return {
|
package/src/ui/App.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { SiteUpdate } from "./views/SiteUpdate.tsx"
|
|
|
19
19
|
import { BulkWpUpdate } from "./views/BulkWpUpdate.tsx"
|
|
20
20
|
import { HttpsToggle } from "./views/HttpsToggle.tsx"
|
|
21
21
|
import { PurgeCache } from "./views/PurgeCache.tsx"
|
|
22
|
+
import { AccountSlugPrompt } from "./views/AccountSlugPrompt.tsx"
|
|
22
23
|
import { SiteOffline } from "./views/SiteOffline.tsx"
|
|
23
24
|
import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
|
|
24
25
|
import { GrantKey } from "./views/GrantKey.tsx"
|
|
@@ -44,6 +45,8 @@ import { Accounts } from "./views/Accounts.tsx"
|
|
|
44
45
|
import { Modules } from "./views/Modules.tsx"
|
|
45
46
|
import { BeaconSetup } from "./views/BeaconSetup.tsx"
|
|
46
47
|
import { SchedulerInstall } from "./views/SchedulerInstall.tsx"
|
|
48
|
+
import { HomeSync } from "./views/HomeSync.tsx"
|
|
49
|
+
import { HeartbeatSetup } from "./views/HeartbeatSetup.tsx"
|
|
47
50
|
|
|
48
51
|
const MIN_SPLASH_MS = 1200
|
|
49
52
|
|
|
@@ -94,7 +97,10 @@ export function App() {
|
|
|
94
97
|
store.dnsRecordsTarget !== null ||
|
|
95
98
|
store.kumaSite !== null ||
|
|
96
99
|
store.beaconSetupOpen ||
|
|
97
|
-
store.schedulerInstallOpen
|
|
100
|
+
store.schedulerInstallOpen ||
|
|
101
|
+
store.homeSyncOpen ||
|
|
102
|
+
store.heartbeatSetupOpen ||
|
|
103
|
+
store.slugPrompt !== null
|
|
98
104
|
useEffect(() => {
|
|
99
105
|
store.setOverlayOpen(overlayActive)
|
|
100
106
|
}, [overlayActive, store])
|
|
@@ -132,6 +138,9 @@ export function App() {
|
|
|
132
138
|
// The Accounts overlay owns the keyboard while open.
|
|
133
139
|
if (showAccounts) return
|
|
134
140
|
|
|
141
|
+
// The account-slug prompt (from `w`) owns the keyboard while open.
|
|
142
|
+
if (store.slugPrompt) return
|
|
143
|
+
|
|
135
144
|
// The release-notes overlay owns the keyboard while open (any key dismisses).
|
|
136
145
|
if (store.releaseNotesInfo) return
|
|
137
146
|
|
|
@@ -213,6 +222,8 @@ export function App() {
|
|
|
213
222
|
// Beacon's guided setup (Modules tab) owns the keyboard while open.
|
|
214
223
|
if (store.beaconSetupOpen) return
|
|
215
224
|
if (store.schedulerInstallOpen) return
|
|
225
|
+
if (store.homeSyncOpen) return
|
|
226
|
+
if (store.heartbeatSetupOpen) return
|
|
216
227
|
|
|
217
228
|
// A (Shift+a): the Accounts overlay — the header advertises it from every tab.
|
|
218
229
|
if (key.shift && key.name === "a") return setShowAccounts(true)
|
|
@@ -303,7 +314,10 @@ export function App() {
|
|
|
303
314
|
{store.kumaSite && <KumaSite />}
|
|
304
315
|
{store.beaconSetupOpen && <BeaconSetup />}
|
|
305
316
|
{store.schedulerInstallOpen && <SchedulerInstall />}
|
|
317
|
+
{store.homeSyncOpen && <HomeSync />}
|
|
318
|
+
{store.heartbeatSetupOpen && <HeartbeatSetup />}
|
|
306
319
|
{showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
|
|
320
|
+
{store.slugPrompt && <AccountSlugPrompt />}
|
|
307
321
|
<ToastStack />
|
|
308
322
|
</box>
|
|
309
323
|
)
|
package/src/ui/Details.tsx
CHANGED
|
@@ -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/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/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"
|
|
@@ -751,6 +751,12 @@ interface StoreValue extends DataState {
|
|
|
751
751
|
// Scheduler's install plan + confirm overlay, opened from the Modules tab.
|
|
752
752
|
schedulerInstallOpen: boolean
|
|
753
753
|
setSchedulerInstallOpen: (v: boolean) => void
|
|
754
|
+
// The SpinupTUI folder: where it is, and sharing it across machines (Modules tab).
|
|
755
|
+
homeSyncOpen: boolean
|
|
756
|
+
setHomeSyncOpen: (v: boolean) => void
|
|
757
|
+
// The station heartbeat (Kuma push monitor) overlay, from the Modules tab.
|
|
758
|
+
heartbeatSetupOpen: boolean
|
|
759
|
+
setHeartbeatSetupOpen: (v: boolean) => void
|
|
754
760
|
// Whether the "needs a local copy" (forgotten) report overlay is open, and an
|
|
755
761
|
// optional stack filter (set from the selected Stacks group when opened).
|
|
756
762
|
forgottenOpen: boolean
|
|
@@ -825,6 +831,14 @@ interface StoreValue extends DataState {
|
|
|
825
831
|
enableLocalSync: () => void
|
|
826
832
|
// SpinupWP account slug (from env/config) for building web deep links.
|
|
827
833
|
accountSlug: string | null
|
|
834
|
+
// Open a page in the SpinupWP web app. Deep links need the account slug, which
|
|
835
|
+
// the API doesn't expose; with none saved this asks for it (slugPrompt) and
|
|
836
|
+
// opens the page once it's given, rather than landing on the dashboard root.
|
|
837
|
+
// Returns true when the page opened straight away.
|
|
838
|
+
openInSpinupWP: (build: (slug: string) => string) => boolean
|
|
839
|
+
slugPrompt: { build: (slug: string) => string } | null
|
|
840
|
+
setSlugPrompt: (p: { build: (slug: string) => string } | null) => void
|
|
841
|
+
saveAccountSlug: (slug: string) => void
|
|
828
842
|
// The account (profile) this store is mounted for — see config.ts "Accounts".
|
|
829
843
|
// Switching remounts the whole store, so these never change under a view.
|
|
830
844
|
profileId: string
|
|
@@ -1038,6 +1052,24 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1038
1052
|
})
|
|
1039
1053
|
}, [])
|
|
1040
1054
|
const [profileLabel, setProfileLabel] = useState(cfgRef.current.profileLabel)
|
|
1055
|
+
const [accountSlug, setAccountSlug] = useState(cfgRef.current.accountSlug)
|
|
1056
|
+
const [slugPrompt, setSlugPrompt] = useState<{ build: (slug: string) => string } | null>(null)
|
|
1057
|
+
const saveAccountSlug = useCallback((slug: string) => {
|
|
1058
|
+
cfgRef.current.accountSlug = slug
|
|
1059
|
+
setAccountSlug(slug)
|
|
1060
|
+
void saveProfileConfig({ accountSlug: slug })
|
|
1061
|
+
}, [])
|
|
1062
|
+
const openInSpinupWP = useCallback(
|
|
1063
|
+
(build: (slug: string) => string) => {
|
|
1064
|
+
if (accountSlug) {
|
|
1065
|
+
openUrl(build(accountSlug))
|
|
1066
|
+
return true
|
|
1067
|
+
}
|
|
1068
|
+
setSlugPrompt({ build })
|
|
1069
|
+
return false
|
|
1070
|
+
},
|
|
1071
|
+
[accountSlug],
|
|
1072
|
+
)
|
|
1041
1073
|
const [route, setRoute] = useState<Route>("dashboard")
|
|
1042
1074
|
const [inputMode, setInputMode] = useState(false)
|
|
1043
1075
|
const [overlayOpen, setOverlayOpen] = useState(false)
|
|
@@ -1122,6 +1154,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1122
1154
|
const [discoverOpen, setDiscoverOpen] = useState(false)
|
|
1123
1155
|
const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
|
|
1124
1156
|
const [schedulerInstallOpen, setSchedulerInstallOpen] = useState(false)
|
|
1157
|
+
const [homeSyncOpen, setHomeSyncOpen] = useState(false)
|
|
1158
|
+
const [heartbeatSetupOpen, setHeartbeatSetupOpen] = useState(false)
|
|
1125
1159
|
const [forgottenOpen, setForgottenOpen] = useState(false)
|
|
1126
1160
|
const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
|
|
1127
1161
|
const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
|
|
@@ -1258,7 +1292,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1258
1292
|
}, [])
|
|
1259
1293
|
|
|
1260
1294
|
const probeOne = useCallback(
|
|
1261
|
-
async (site: Site) => {
|
|
1295
|
+
async (site: Site): Promise<boolean> => {
|
|
1262
1296
|
const cache = cacheRef.current!
|
|
1263
1297
|
setProbingIds((prev) => new Set(prev).add(site.id))
|
|
1264
1298
|
setProbeErrors((prev) => {
|
|
@@ -1280,6 +1314,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1280
1314
|
next.delete(site.id)
|
|
1281
1315
|
return next
|
|
1282
1316
|
})
|
|
1317
|
+
return outcome.ok
|
|
1283
1318
|
},
|
|
1284
1319
|
[servers],
|
|
1285
1320
|
)
|
|
@@ -1313,6 +1348,41 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1313
1348
|
|
|
1314
1349
|
const isProbeStale = useCallback((site: Site) => cacheRef.current!.isStale(site.id, siteSignature(site)), [])
|
|
1315
1350
|
|
|
1351
|
+
// Re-check WordPress versions that have gone old, whenever the site list loads
|
|
1352
|
+
// (launch, and every `r`). Only sites already probed successfully: an unprobed
|
|
1353
|
+
// site's stack is the user's call (`d`/`D`), and it may not be reachable at all.
|
|
1354
|
+
// Each site gets at most one automatic attempt per max-age window, recorded in
|
|
1355
|
+
// the cache so it holds across launches: a site whose probe now fails keeps its
|
|
1356
|
+
// old reading (shown with its age) instead of being retried on every launch or
|
|
1357
|
+
// refresh. Dev Mode's servers aren't real.
|
|
1358
|
+
//
|
|
1359
|
+
// Unattended SSH must not trip fail2ban, which bans the source IP after a few
|
|
1360
|
+
// failed logins on one server. So each server's sites go one at a time, and the
|
|
1361
|
+
// first failure on a server ends that server's run for this window: at most one
|
|
1362
|
+
// failed login per server per day. Servers run in parallel, a few at a time.
|
|
1363
|
+
useEffect(() => {
|
|
1364
|
+
if (isDevMode() || servers.length === 0) return
|
|
1365
|
+
const cache = cacheRef.current!
|
|
1366
|
+
const now = Date.now()
|
|
1367
|
+
const due = sites.filter((s) => isRecheckDue(cache.get(s.id), now))
|
|
1368
|
+
if (due.length === 0) return
|
|
1369
|
+
// Marked up front, all of them: a reload mid-run must not queue these again,
|
|
1370
|
+
// and a server's sites after its first failure are skipped for the window too.
|
|
1371
|
+
void cache.noteRecheck(due.map((s) => s.id))
|
|
1372
|
+
const byServer = new Map<number, Site[]>()
|
|
1373
|
+
for (const s of due) byServer.set(s.server_id, [...(byServer.get(s.server_id) ?? []), s])
|
|
1374
|
+
const queues = [...byServer.values()]
|
|
1375
|
+
let cursor = 0
|
|
1376
|
+
const worker = async () => {
|
|
1377
|
+
while (cursor < queues.length) {
|
|
1378
|
+
for (const site of queues[cursor++]) if (!(await probeOne(site))) break
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
for (let i = 0; i < Math.min(3, queues.length); i++) void worker()
|
|
1382
|
+
// probeOne changes identity with `servers`, which reloads together with
|
|
1383
|
+
// `sites` — this should only re-run when the site list itself is reloaded.
|
|
1384
|
+
}, [sites, servers])
|
|
1385
|
+
|
|
1316
1386
|
const isPhpEol = useCallback((version: string | null | undefined) => isPhpEolWith(version, phpEolDates), [phpEolDates])
|
|
1317
1387
|
const offeredPhpVersions = useCallback(
|
|
1318
1388
|
(current?: string | null) => offeredPhpVersionsWith(phpEolDates, current),
|
|
@@ -4546,6 +4616,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4546
4616
|
setBeaconSetupOpen,
|
|
4547
4617
|
schedulerInstallOpen,
|
|
4548
4618
|
setSchedulerInstallOpen,
|
|
4619
|
+
homeSyncOpen,
|
|
4620
|
+
setHomeSyncOpen,
|
|
4621
|
+
heartbeatSetupOpen,
|
|
4622
|
+
setHeartbeatSetupOpen,
|
|
4549
4623
|
forgottenOpen,
|
|
4550
4624
|
setForgottenOpen,
|
|
4551
4625
|
forgottenStack,
|
|
@@ -4587,7 +4661,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4587
4661
|
enableLocalSyncSite,
|
|
4588
4662
|
setEnableLocalSyncSite,
|
|
4589
4663
|
enableLocalSync,
|
|
4590
|
-
accountSlug
|
|
4664
|
+
accountSlug,
|
|
4665
|
+
openInSpinupWP,
|
|
4666
|
+
slugPrompt,
|
|
4667
|
+
setSlugPrompt,
|
|
4668
|
+
saveAccountSlug,
|
|
4591
4669
|
profileId: cfgRef.current.profileId,
|
|
4592
4670
|
profileLabel,
|
|
4593
4671
|
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)
|
|
@@ -283,11 +283,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
283
283
|
// Open the focused server/site in the SpinupWP web app — useful for
|
|
284
284
|
// actions the API can't do (e.g. running a pending server upgrade).
|
|
285
285
|
if (focus === "sites" && sites[siteIndex]) {
|
|
286
|
-
|
|
287
|
-
|
|
286
|
+
const id = sites[siteIndex].id
|
|
287
|
+
if (openInSpinupWP((slug) => siteWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
|
|
288
288
|
} else if (server) {
|
|
289
|
-
|
|
290
|
-
|
|
289
|
+
const id = server.id
|
|
290
|
+
if (openInSpinupWP((slug) => serverWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
|
|
291
291
|
}
|
|
292
292
|
setTimeout(() => setFlash(null), 2000)
|
|
293
293
|
return
|