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.
@@ -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
  )
@@ -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={probing ? "identifying…" : probe ? probe.result.label + (isProbeStale(site) ? " (stale)" : "") : "not identified · d"}
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} />
@@ -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
- // hands control back to the app.
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
- onComplete()
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: cfgRef.current.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
+ }
@@ -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, accountSlug, 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
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
- openUrl(siteWebUrl(sites[siteIndex].id, accountSlug))
287
- setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
286
+ const id = sites[siteIndex].id
287
+ if (openInSpinupWP((slug) => siteWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
288
288
  } else if (server) {
289
- openUrl(serverWebUrl(server.id, accountSlug))
290
- setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
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