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.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -10
  2. package/README.md +5 -10
  3. package/package.json +1 -1
  4. package/src/api/client.ts +15 -0
  5. package/src/api/types.ts +3 -0
  6. package/src/config.ts +0 -23
  7. package/src/dev/mockClient.ts +30 -7
  8. package/src/index.tsx +17 -16
  9. package/src/lib/appUpdate.ts +7 -33
  10. package/src/lib/cliOffline.ts +418 -0
  11. package/src/lib/offline.test.ts +181 -0
  12. package/src/lib/offline.ts +422 -0
  13. package/src/lib/spinupweb.test.ts +22 -0
  14. package/src/lib/spinupweb.ts +11 -0
  15. package/src/lib/stackCache.test.ts +43 -0
  16. package/src/lib/stackCache.ts +35 -1
  17. package/src/ui/App.tsx +12 -10
  18. package/src/ui/Details.tsx +8 -2
  19. package/src/ui/Explain.tsx +2 -15
  20. package/src/ui/Header.tsx +0 -2
  21. package/src/ui/Help.tsx +3 -2
  22. package/src/ui/Onboarding.tsx +71 -3
  23. package/src/ui/components.tsx +2 -1
  24. package/src/ui/offlineRun.tsx +82 -0
  25. package/src/ui/store.tsx +76 -10
  26. package/src/ui/views/AccountSlugPrompt.tsx +102 -0
  27. package/src/ui/views/Browser.tsx +13 -5
  28. package/src/ui/views/Dashboard.tsx +3 -1
  29. package/src/ui/views/Search.tsx +8 -6
  30. package/src/ui/views/ServerActions.tsx +108 -11
  31. package/src/ui/views/SiteOffline.tsx +171 -0
  32. package/src/ui/views/Stacks.tsx +26 -9
  33. package/src/lib/appUpdate.test.ts +0 -26
  34. package/src/lib/beacon.test.ts +0 -140
  35. package/src/lib/beacon.ts +0 -349
  36. package/src/lib/beaconSetup.test.ts +0 -65
  37. package/src/lib/beaconSetup.ts +0 -143
  38. package/src/lib/cliBeacon.ts +0 -290
  39. package/src/lib/prompt.ts +0 -47
  40. package/src/modules/beacon.ts +0 -51
  41. package/src/modules/contract.ts +0 -61
  42. package/src/modules/index.ts +0 -34
  43. package/src/ui/views/BeaconSetup.tsx +0 -268
  44. package/src/ui/views/Modules.tsx +0 -196
@@ -110,7 +110,7 @@ export const SERVER_CONTROL: ActionGroup[] = [
110
110
  {
111
111
  title: "Manage",
112
112
  items: [
113
- ["a", "Reboot / restart"],
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={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} />
@@ -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–6", "Switch tabs"],
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–6", "Switch tabs"],
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 … 6", "Switch tabs"],
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 (reboot/restart)"],
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
  ],
@@ -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={{
@@ -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", "Reboot / restart"], ["h", "Health"]] },
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" | "modules"
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: cfgRef.current.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
+ }
@@ -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, 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)
@@ -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
- openUrl(siteWebUrl(sites[siteIndex].id, accountSlug))
282
- 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…")
283
288
  } else if (server) {
284
- openUrl(serverWebUrl(server.id, accountSlug))
285
- 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…")
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} />