spinuptui 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/App.tsx CHANGED
@@ -39,6 +39,7 @@ import { WpInventory } from "./views/WpInventory.tsx"
39
39
  import { ProviderConnect } from "./views/ProviderConnect.tsx"
40
40
  import { DnsRecords } from "./views/DnsRecords.tsx"
41
41
  import { KumaSite } from "./views/KumaSite.tsx"
42
+ import { Accounts } from "./views/Accounts.tsx"
42
43
 
43
44
  const MIN_SPLASH_MS = 1200
44
45
 
@@ -49,6 +50,7 @@ export function App() {
49
50
  const [splashDone, setSplashDone] = useState(false)
50
51
  const [showHelp, setShowHelp] = useState(false)
51
52
  const [showExplain, setShowExplain] = useState(false)
53
+ const [showAccounts, setShowAccounts] = useState(false)
52
54
 
53
55
  // Enforce a minimum splash duration so the intro is visible even on fast loads.
54
56
  useEffect(() => {
@@ -60,6 +62,7 @@ export function App() {
60
62
  const overlayActive =
61
63
  showHelp ||
62
64
  showExplain ||
65
+ showAccounts ||
63
66
  store.releaseNotesInfo !== null ||
64
67
  store.healthServer !== null ||
65
68
  store.wpInventorySite !== null ||
@@ -119,6 +122,9 @@ export function App() {
119
122
  // While a text field is focused, let it consume everything else.
120
123
  if (store.inputMode) return
121
124
 
125
+ // The Accounts overlay owns the keyboard while open.
126
+ if (showAccounts) return
127
+
122
128
  // The release-notes overlay owns the keyboard while open (any key dismisses).
123
129
  if (store.releaseNotesInfo) return
124
130
 
@@ -194,6 +200,9 @@ export function App() {
194
200
  // The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
195
201
  if (store.kumaSite) return
196
202
 
203
+ // A (Shift+a): the Accounts overlay — the header advertises it from every tab.
204
+ if (key.shift && key.name === "a") return setShowAccounts(true)
205
+
197
206
  switch (key.name) {
198
207
  case "q":
199
208
  return quit()
@@ -274,6 +283,7 @@ export function App() {
274
283
  {store.connectZoneTarget && <ProviderConnect />}
275
284
  {store.dnsRecordsTarget && <DnsRecords />}
276
285
  {store.kumaSite && <KumaSite />}
286
+ {showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
277
287
  <ToastStack />
278
288
  </box>
279
289
  )
package/src/ui/Header.tsx CHANGED
@@ -27,7 +27,7 @@ const SUBTITLES: Record<Route, string> = {
27
27
  }
28
28
 
29
29
  export function Header() {
30
- const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus } = useStore()
30
+ const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus, profileLabel } = useStore()
31
31
  // Name the domain(s) when there are few enough to fit the header row; fall
32
32
  // back to a bare count once there are too many to list safely. Deliberately
33
33
  // still just the plain up/down signal (kumaStatus.up) — not expanding what
@@ -119,6 +119,10 @@ export function Header() {
119
119
  <box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
120
120
  <text content="💡 " fg={theme.accent} style={{ flexShrink: 0 }} />
121
121
  <text content={SUBTITLES[route]} fg={theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
122
+ {/* Always visible, so it's never a guess which client's fleet this is. */}
123
+ <text content=" Account: " fg={theme.textFaint} style={{ flexShrink: 0 }} />
124
+ <text content={truncate(profileLabel, 32)} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
125
+ <text content=" A switch" fg={theme.textFaint} style={{ flexShrink: 0 }} />
122
126
  </box>
123
127
  </box>
124
128
  )
package/src/ui/Help.tsx CHANGED
@@ -18,6 +18,7 @@ const GLOBAL: Section = {
18
18
  ["1 … 5", "Switch tabs"],
19
19
  ["r", "Refresh from the API"],
20
20
  ["/", "Global search"],
21
+ ["A", "Switch SpinupWP accounts"],
21
22
  ["i", "Explain the current screen"],
22
23
  ["?", "This help"],
23
24
  ["q · Ctrl+C", "Quit · force quit"],
@@ -0,0 +1,21 @@
1
+ // Account switching lives ABOVE the store: switching remounts <StoreProvider>
2
+ // keyed by the account, which is what guarantees a clean slate — every effect,
3
+ // poller and interval of the outgoing account is torn down by React, and every
4
+ // id-keyed cache (servers, sites, probes, jobs…) starts empty for the new one.
5
+ // See config.ts "Accounts" for how the profiles are stored.
6
+
7
+ import { createContext, useContext } from "react"
8
+
9
+ export interface AccountsApi {
10
+ // Make `id` the active account and remount the app on it. The caller checks
11
+ // the store's switchBlocker() first — nothing here waits for running work.
12
+ switchAccount: (id: string) => Promise<void>
13
+ }
14
+
15
+ export const AccountsContext = createContext<AccountsApi | null>(null)
16
+
17
+ export function useAccounts(): AccountsApi {
18
+ const ctx = useContext(AccountsContext)
19
+ if (!ctx) throw new Error("useAccounts must be used within <AccountsContext.Provider>")
20
+ return ctx
21
+ }
package/src/ui/store.tsx CHANGED
@@ -11,7 +11,7 @@ import { SpinupWPClient, ApiError, type ServerService, type SpinupWPClientLike }
11
11
  import { isDevMode } from "../dev/devMode.ts"
12
12
  import { createMockClient } from "../dev/mockClient.ts"
13
13
  import type { Server, Site, Event, ProviderMetadata, CreateServerPayload, CreateSitePayload, AdditionalDomain } from "../api/types.ts"
14
- import { loadConfig, saveConfig, type ServerProviderRef, type KumaMonitorRef, type UptimeKumaConn } from "../config.ts"
14
+ import { loadConfig, saveConfig, type StoredConfig, type ServerProviderRef, type KumaMonitorRef, type UptimeKumaConn } from "../config.ts"
15
15
  import { saveJob, removeJob } from "../lib/jobs.ts"
16
16
  import { APP_VERSION } from "../version.ts"
17
17
  import { cachedUpdateInfo, refreshUpdateInfo, type UpdateInfo } from "../lib/appUpdate.ts"
@@ -53,9 +53,10 @@ import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword
53
53
  import { StackCache, siteSignature, type CachedProbe } from "../lib/stackCache.ts"
54
54
  import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offeredPhpVersions as offeredPhpVersionsWith, type PhpEolDates } from "../lib/phpEol.ts"
55
55
  import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
56
- import { planDbBackup, runDbBackup, type DbBackupProgress, type PlanResult } from "../lib/dbBackup.ts"
57
- import { planDbSync, runDbSync, type DbSyncProgress, type SyncPlanResult } from "../lib/dbSync.ts"
58
- import { planLocalSetup, runLocalSetup, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
56
+ import { wpCoreBusy } from "./wpCoreJobs.tsx"
57
+ import { planDbBackup, runDbBackup, isDbBackupInFlight, type DbBackupProgress, type PlanResult } from "../lib/dbBackup.ts"
58
+ import { planDbSync, runDbSync, isDbSyncInFlight, type DbSyncProgress, type SyncPlanResult } from "../lib/dbSync.ts"
59
+ import { planLocalSetup, runLocalSetup, isLocalSetupInFlight, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
59
60
  import { planMediaFallback, type MediaFallbackResult } from "../lib/mediaFallback.ts"
60
61
 
61
62
  export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
@@ -754,6 +755,18 @@ interface StoreValue extends DataState {
754
755
  enableLocalSync: () => void
755
756
  // SpinupWP account slug (from env/config) for building web deep links.
756
757
  accountSlug: string | null
758
+ // The account (profile) this store is mounted for — see config.ts "Accounts".
759
+ // Switching remounts the whole store, so these never change under a view.
760
+ profileId: string
761
+ profileLabel: string
762
+ setProfileLabel: (label: string) => void // after a rename in the Accounts overlay
763
+ // The token came from SPINUPWP_ACCESS_TOKEN, which pins the account: switching
764
+ // would change nothing until it's unset.
765
+ tokenFromEnv: boolean
766
+ // What's still running that an account switch would orphan, in words — or
767
+ // null when it's safe to switch. Only work that writes (to SpinupWP, a server,
768
+ // DNS, or a local database) counts; reads and probes are simply dropped.
769
+ switchBlocker: () => string | null
757
770
  sitesForServer: (serverId: number) => Site[]
758
771
  serverById: (id: number | null | undefined) => Server | undefined
759
772
  // Tier-2 stack probes (on-demand SSH), hydrated from disk at startup.
@@ -895,6 +908,9 @@ export function useStore(): StoreValue {
895
908
 
896
909
  export function StoreProvider({ children }: { children: ReactNode }) {
897
910
  const cfgRef = useRef(loadConfig())
911
+ // Every config write from this store targets the account it was mounted for —
912
+ // a late write from an async flow can't land in an account switched to since.
913
+ const saveProfileConfig = (partial: StoredConfig) => saveConfig(partial, cfgRef.current.profileId)
898
914
  const clientRef = useRef<SpinupWPClientLike | null>(null)
899
915
  if (!clientRef.current) {
900
916
  clientRef.current = isDevMode() ? createMockClient() : new SpinupWPClient(cfgRef.current)
@@ -927,7 +943,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
927
943
  // Fresh install, or upgrading from a pre-this-feature version — nothing
928
944
  // meaningful to announce. Seed silently so future bumps trigger correctly.
929
945
  cfgRef.current.lastSeenVersion = APP_VERSION
930
- void saveConfig({ lastSeenVersion: APP_VERSION })
946
+ void saveProfileConfig({ lastSeenVersion: APP_VERSION })
931
947
  return
932
948
  }
933
949
  void fetchReleaseNotes(APP_VERSION).then((notes) => {
@@ -939,7 +955,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
939
955
  const dismissReleaseNotes = useCallback(() => {
940
956
  setReleaseNotesInfo(null)
941
957
  cfgRef.current.lastSeenVersion = APP_VERSION
942
- void saveConfig({ lastSeenVersion: APP_VERSION })
958
+ void saveProfileConfig({ lastSeenVersion: APP_VERSION })
943
959
  }, [])
944
960
  // On-demand replay (Help overlay's `n`) — bypasses the "seen" gate entirely,
945
961
  // since viewing the current version's notes again is always valid regardless
@@ -951,6 +967,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
951
967
  if (notes) setReleaseNotesInfo(notes)
952
968
  })
953
969
  }, [])
970
+ const [profileLabel, setProfileLabel] = useState(cfgRef.current.profileLabel)
954
971
  const [route, setRoute] = useState<Route>("dashboard")
955
972
  const [inputMode, setInputMode] = useState(false)
956
973
  const [overlayOpen, setOverlayOpen] = useState(false)
@@ -1640,7 +1657,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1640
1657
  const next = { ...prev, [providerKey]: { ...(prev[providerKey] ?? {}), id } }
1641
1658
  cfgRef.current.serverProviders = next
1642
1659
  setServerProviders(next)
1643
- void saveConfig({ serverProviders: next })
1660
+ void saveProfileConfig({ serverProviders: next })
1644
1661
  }, [])
1645
1662
 
1646
1663
  // Poll a server-create event to completion, mirroring its status into the job
@@ -1734,7 +1751,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1734
1751
  const record: Record<string, LocalLink> = {}
1735
1752
  for (const [id, link] of next) record[String(id)] = link
1736
1753
  cfgRef.current.localSites = record
1737
- void saveConfig({ localSites: record })
1754
+ void saveProfileConfig({ localSites: record })
1738
1755
  }, [])
1739
1756
 
1740
1757
  const linkSite = useCallback(
@@ -1766,14 +1783,14 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1766
1783
  if (prev.includes(trimmed)) return prev
1767
1784
  const next = [...prev, trimmed]
1768
1785
  cfgRef.current.localRoots = next
1769
- void saveConfig({ localRoots: next })
1786
+ void saveProfileConfig({ localRoots: next })
1770
1787
  return next
1771
1788
  })
1772
1789
  }, [])
1773
1790
 
1774
1791
  const enableLocalSync = useCallback(() => {
1775
1792
  cfgRef.current.localSync = true
1776
- void saveConfig({ localSync: true })
1793
+ void saveProfileConfig({ localSync: true })
1777
1794
  setLocalSyncState(true)
1778
1795
  }, [])
1779
1796
 
@@ -1823,7 +1840,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1823
1840
  const record: Record<string, { user: string; keychain?: boolean }> = {}
1824
1841
  for (const [id, u] of users) record[String(id)] = saved.has(id) ? { user: u, keychain: true } : { user: u }
1825
1842
  cfgRef.current.sudoUsers = record
1826
- void saveConfig({ sudoUsers: record })
1843
+ void saveProfileConfig({ sudoUsers: record })
1827
1844
  }, [])
1828
1845
  const isSudoConnected = useCallback((serverId: number) => sudoConnected.has(serverId), [sudoConnected])
1829
1846
  const sudoSavedFor = useCallback((serverId: number) => sudoSaved.has(serverId), [sudoSaved])
@@ -1920,7 +1937,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1920
1937
  const setPreferredGrantKeys = useCallback((ids: string[]) => {
1921
1938
  cfgRef.current.preferredGrantKeys = ids
1922
1939
  setPreferredGrantKeysState(ids)
1923
- void saveConfig({ preferredGrantKeys: ids })
1940
+ void saveProfileConfig({ preferredGrantKeys: ids })
1924
1941
  }, [])
1925
1942
 
1926
1943
  // Persist the granted-keys map (only non-empty entries) to config, write-through.
@@ -1928,7 +1945,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1928
1945
  const rec: Record<string, string[]> = {}
1929
1946
  for (const [id, set] of next) if (set.size) rec[String(id)] = [...set]
1930
1947
  cfgRef.current.grantedKeys = rec
1931
- void saveConfig({ grantedKeys: rec })
1948
+ void saveProfileConfig({ grantedKeys: rec })
1932
1949
  }, [])
1933
1950
 
1934
1951
  // Record / forget which keys (by body) Spinup has granted on a site.
@@ -1970,7 +1987,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1970
1987
  else next.delete(apex)
1971
1988
  const rec = Object.fromEntries(next)
1972
1989
  cfgRef.current.zoneAccessNotes = rec
1973
- void saveConfig({ zoneAccessNotes: rec })
1990
+ void saveProfileConfig({ zoneAccessNotes: rec })
1974
1991
  return next
1975
1992
  })
1976
1993
  }, [])
@@ -2447,7 +2464,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2447
2464
  for (const p of ALL_PROVIDERS) {
2448
2465
  providers[p] = next[p].filter((c) => !c.env).map((c) => ({ id: c.id, label: c.label, creds: c.creds }))
2449
2466
  }
2450
- void saveConfig({ providers })
2467
+ void saveProfileConfig({ providers })
2451
2468
  }, [])
2452
2469
 
2453
2470
  // Verify a connection and write the result through to the cache.
@@ -2682,7 +2699,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2682
2699
  if (keys[domain] !== key) {
2683
2700
  keys[domain] = key
2684
2701
  cfgRef.current.vanityHealthKeys = keys
2685
- void saveConfig({ vanityHealthKeys: keys })
2702
+ void saveProfileConfig({ vanityHealthKeys: keys })
2686
2703
  }
2687
2704
  return key
2688
2705
  }, [])
@@ -2696,7 +2713,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2696
2713
  const newKey = crypto.randomUUID().replace(/-/g, "")
2697
2714
  keys[domain] = newKey
2698
2715
  cfgRef.current.vanityHealthKeys = keys
2699
- void saveConfig({ vanityHealthKeys: keys })
2716
+ void saveProfileConfig({ vanityHealthKeys: keys })
2700
2717
  return { oldKey, newKey }
2701
2718
  }, [])
2702
2719
 
@@ -2705,13 +2722,13 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2705
2722
  const persistKumaAuth = useCallback((conn: UptimeKumaConn, jwt: string) => {
2706
2723
  if (conn.env || jwt === conn.jwt) return
2707
2724
  cfgRef.current.uptimeKuma = { ...conn, jwt }
2708
- void saveConfig({ uptimeKuma: { ...conn, jwt } })
2725
+ void saveProfileConfig({ uptimeKuma: { ...conn, jwt } })
2709
2726
  }, [])
2710
2727
 
2711
2728
  const persistKumaMonitors = useCallback((domain: string, ref: KumaMonitorRef) => {
2712
2729
  const map = { ...cfgRef.current.kumaMonitors, [domain]: ref }
2713
2730
  cfgRef.current.kumaMonitors = map
2714
- void saveConfig({ kumaMonitors: map })
2731
+ void saveProfileConfig({ kumaMonitors: map })
2715
2732
  }, [])
2716
2733
 
2717
2734
  // The step machine. Each step performs its work then hands off to the next; it
@@ -3127,7 +3144,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3127
3144
  if (!login.ok) return { ok: false, error: login.error, tokenRequired: login.tokenRequired }
3128
3145
  const stored = { url, username: conn.username, password: conn.password, jwt: login.jwt }
3129
3146
  cfgRef.current.uptimeKuma = stored
3130
- void saveConfig({ uptimeKuma: stored })
3147
+ void saveProfileConfig({ uptimeKuma: stored })
3131
3148
  setKumaEpoch((e) => e + 1) // start the status poll loop now
3132
3149
  await kuma.waitForVersion() // `info` can land after the login ack
3133
3150
  return { ok: true, version: kuma.version }
@@ -4245,6 +4262,29 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4245
4262
  }
4246
4263
  }, [trackServerEvent, trackPhpUpgradeEvent, trackHttpsToggleEvent, driveVanity])
4247
4264
 
4265
+ const switchBlocker = (): string | null => {
4266
+ const count = <T,>(m: Map<unknown, T>, busy: (p: T) => boolean) => [...m.values()].filter(busy).length
4267
+ const running: [number, string][] = [
4268
+ [count(phpUpgrades, isUpgradeInFlight), "PHP upgrade"],
4269
+ [count(httpsToggles, isHttpsToggleInFlight), "HTTPS change"],
4270
+ [count(purgeCacheProgress, isPurgeCacheInFlight), "cache purge"],
4271
+ [count(serverOps, isServerOpInFlight), "server reboot/restart"],
4272
+ [count(keyGrants, isKeyGrantInFlight), "SSH key grant"],
4273
+ [count(dbBackups, isDbBackupInFlight), "database backup"],
4274
+ [count(dbSyncs, isDbSyncInFlight), "database sync"],
4275
+ [count(localSetups, isLocalSetupInFlight), "local copy setup"],
4276
+ [count(recordWrites, isRecordWriteInFlight), "DNS change"],
4277
+ [count(kumaOps, (o) => o.status === "running"), "monitoring change"],
4278
+ [isNewServerInFlight(newServerJob) ? 1 : 0, "server build"],
4279
+ [isVanityInFlight(vanityJob) ? 1 : 0, "vanity-site build"],
4280
+ // A clone still being planned hasn't touched anything yet — only a launched one counts.
4281
+ [isCloneInFlight(cloneJob) && cloneJob!.step !== "plan" && cloneJob!.step !== "server" ? 1 : 0, "server clone"],
4282
+ [wpCoreBusy() ? 1 : 0, "WordPress update"],
4283
+ ]
4284
+ const busy = running.filter(([n]) => n > 0).map(([n, what]) => (n > 1 ? `${n} ${what}s` : `a ${what}`))
4285
+ return busy.length ? `Still running: ${busy.join(", ")}. Switch accounts once ${busy.length > 1 ? "they finish" : "it finishes"}.` : null
4286
+ }
4287
+
4248
4288
  const value: StoreValue = {
4249
4289
  servers,
4250
4290
  sites,
@@ -4437,6 +4477,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4437
4477
  setEnableLocalSyncSite,
4438
4478
  enableLocalSync,
4439
4479
  accountSlug: cfgRef.current.accountSlug,
4480
+ profileId: cfgRef.current.profileId,
4481
+ profileLabel,
4482
+ setProfileLabel,
4483
+ tokenFromEnv: cfgRef.current.tokenSource === "env",
4484
+ switchBlocker,
4440
4485
  sitesForServer,
4441
4486
  serverById,
4442
4487
  probes,