spinuptui 0.26.1 → 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/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"
@@ -20,7 +20,7 @@ import { resolveLocalLink, expandPath, normalizeLink, type LocalLink } from "../
20
20
  import { cloneStackFor, type Stack } from "../lib/stack.ts"
21
21
  import { openTerminalAt, openUrl, openSshSession } from "../lib/open.ts"
22
22
  import { gitDrift, type Drift } from "../lib/gitStatus.ts"
23
- import { probeSite } from "../lib/probe.ts"
23
+ import { probeSite, withWpVersion } from "../lib/probe.ts"
24
24
  import { resolveZone, normalizeDomain, candidateHostnames, type ZoneHost } from "../lib/dns.ts"
25
25
  import { queryAuthoritative } from "../lib/dnsQuery.ts"
26
26
  import { DnsCache, type CachedDns } from "../lib/dnsCache.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"
@@ -484,6 +485,11 @@ interface StoreValue extends DataState {
484
485
  // The site whose PHP-upgrade overlay is open, or null. Set by site views.
485
486
  phpUpgradeSite: Site | null
486
487
  setPhpUpgradeSite: (s: Site | null) => void
488
+ // The Stacks version group whose bulk WordPress-update overlay is open, or null.
489
+ // The sites are a snapshot taken when it opened (the group itself reshuffles as
490
+ // updates land and versions change).
491
+ bulkWpGroup: { title: string; sites: Site[] } | null
492
+ setBulkWpGroup: (g: { title: string; sites: Site[] } | null) => void
487
493
  // In-flight (and just-failed) PHP upgrades, keyed by site id. Tracked in the
488
494
  // store — not the overlay — so progress survives closing the modal; site rows
489
495
  // and detail panels read this to show a spinner/marker.
@@ -749,6 +755,18 @@ interface StoreValue extends DataState {
749
755
  enableLocalSync: () => void
750
756
  // SpinupWP account slug (from env/config) for building web deep links.
751
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
752
770
  sitesForServer: (serverId: number) => Site[]
753
771
  serverById: (id: number | null | undefined) => Server | undefined
754
772
  // Tier-2 stack probes (on-demand SSH), hydrated from disk at startup.
@@ -757,6 +775,9 @@ interface StoreValue extends DataState {
757
775
  probeErrors: Map<number, string> // last error per site id
758
776
  // Probe a single site over SSH (fire-and-forget); write-through to the cache.
759
777
  runProbe: (site: Site) => void
778
+ // Fold a WP core version read elsewhere (the `u` → WordPress check/update) into
779
+ // the cached probe, so rows showing "WordPress x.y.z" don't lag behind it.
780
+ noteWpVersion: (site: Site, version: string) => void
760
781
  // Probe many sites with a bounded concurrency pool (skips in-flight sites).
761
782
  runProbeMany: (sites: Site[]) => void
762
783
  // Whether a cached probe for this site is stale (site shape changed since).
@@ -887,6 +908,9 @@ export function useStore(): StoreValue {
887
908
 
888
909
  export function StoreProvider({ children }: { children: ReactNode }) {
889
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)
890
914
  const clientRef = useRef<SpinupWPClientLike | null>(null)
891
915
  if (!clientRef.current) {
892
916
  clientRef.current = isDevMode() ? createMockClient() : new SpinupWPClient(cfgRef.current)
@@ -919,7 +943,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
919
943
  // Fresh install, or upgrading from a pre-this-feature version — nothing
920
944
  // meaningful to announce. Seed silently so future bumps trigger correctly.
921
945
  cfgRef.current.lastSeenVersion = APP_VERSION
922
- void saveConfig({ lastSeenVersion: APP_VERSION })
946
+ void saveProfileConfig({ lastSeenVersion: APP_VERSION })
923
947
  return
924
948
  }
925
949
  void fetchReleaseNotes(APP_VERSION).then((notes) => {
@@ -931,7 +955,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
931
955
  const dismissReleaseNotes = useCallback(() => {
932
956
  setReleaseNotesInfo(null)
933
957
  cfgRef.current.lastSeenVersion = APP_VERSION
934
- void saveConfig({ lastSeenVersion: APP_VERSION })
958
+ void saveProfileConfig({ lastSeenVersion: APP_VERSION })
935
959
  }, [])
936
960
  // On-demand replay (Help overlay's `n`) — bypasses the "seen" gate entirely,
937
961
  // since viewing the current version's notes again is always valid regardless
@@ -943,12 +967,14 @@ export function StoreProvider({ children }: { children: ReactNode }) {
943
967
  if (notes) setReleaseNotesInfo(notes)
944
968
  })
945
969
  }, [])
970
+ const [profileLabel, setProfileLabel] = useState(cfgRef.current.profileLabel)
946
971
  const [route, setRoute] = useState<Route>("dashboard")
947
972
  const [inputMode, setInputMode] = useState(false)
948
973
  const [overlayOpen, setOverlayOpen] = useState(false)
949
974
  const [healthServer, setHealthServer] = useState<Server | null>(null)
950
975
  const [wpInventorySite, setWpInventorySite] = useState<Site | null>(null)
951
976
  const [phpUpgradeSite, setPhpUpgradeSite] = useState<Site | null>(null)
977
+ const [bulkWpGroup, setBulkWpGroup] = useState<{ title: string; sites: Site[] } | null>(null)
952
978
  const [phpUpgrades, setPhpUpgrades] = useState<Map<number, PhpUpgradeProgress>>(new Map())
953
979
  const [httpsToggleSite, setHttpsToggleSite] = useState<Site | null>(null)
954
980
  const [httpsToggles, setHttpsToggles] = useState<Map<number, HttpsToggleProgress>>(new Map())
@@ -1147,6 +1173,17 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1147
1173
 
1148
1174
  // Probe one site and reconcile state + cache. Concurrency-safe (all state
1149
1175
  // updates are functional), so the batch runner can pool several at once.
1176
+ const noteWpVersion = useCallback((site: Site, version: string) => {
1177
+ const cache = cacheRef.current!
1178
+ const entry = cache.get(site.id)
1179
+ // Only refresh an existing WP-family probe: with no probe we don't know the
1180
+ // stack (Standard vs Bedrock vs Radicle), and that's the probe's job.
1181
+ if (!entry || entry.result.version === version) return
1182
+ const next = withWpVersion(entry.result, version)
1183
+ if (!next) return
1184
+ void cache.set(site.id, next, siteSignature(site)).then(() => setProbes(cache.snapshot()))
1185
+ }, [])
1186
+
1150
1187
  const probeOne = useCallback(
1151
1188
  async (site: Site) => {
1152
1189
  const cache = cacheRef.current!
@@ -1620,7 +1657,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1620
1657
  const next = { ...prev, [providerKey]: { ...(prev[providerKey] ?? {}), id } }
1621
1658
  cfgRef.current.serverProviders = next
1622
1659
  setServerProviders(next)
1623
- void saveConfig({ serverProviders: next })
1660
+ void saveProfileConfig({ serverProviders: next })
1624
1661
  }, [])
1625
1662
 
1626
1663
  // Poll a server-create event to completion, mirroring its status into the job
@@ -1714,7 +1751,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1714
1751
  const record: Record<string, LocalLink> = {}
1715
1752
  for (const [id, link] of next) record[String(id)] = link
1716
1753
  cfgRef.current.localSites = record
1717
- void saveConfig({ localSites: record })
1754
+ void saveProfileConfig({ localSites: record })
1718
1755
  }, [])
1719
1756
 
1720
1757
  const linkSite = useCallback(
@@ -1746,14 +1783,14 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1746
1783
  if (prev.includes(trimmed)) return prev
1747
1784
  const next = [...prev, trimmed]
1748
1785
  cfgRef.current.localRoots = next
1749
- void saveConfig({ localRoots: next })
1786
+ void saveProfileConfig({ localRoots: next })
1750
1787
  return next
1751
1788
  })
1752
1789
  }, [])
1753
1790
 
1754
1791
  const enableLocalSync = useCallback(() => {
1755
1792
  cfgRef.current.localSync = true
1756
- void saveConfig({ localSync: true })
1793
+ void saveProfileConfig({ localSync: true })
1757
1794
  setLocalSyncState(true)
1758
1795
  }, [])
1759
1796
 
@@ -1803,7 +1840,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1803
1840
  const record: Record<string, { user: string; keychain?: boolean }> = {}
1804
1841
  for (const [id, u] of users) record[String(id)] = saved.has(id) ? { user: u, keychain: true } : { user: u }
1805
1842
  cfgRef.current.sudoUsers = record
1806
- void saveConfig({ sudoUsers: record })
1843
+ void saveProfileConfig({ sudoUsers: record })
1807
1844
  }, [])
1808
1845
  const isSudoConnected = useCallback((serverId: number) => sudoConnected.has(serverId), [sudoConnected])
1809
1846
  const sudoSavedFor = useCallback((serverId: number) => sudoSaved.has(serverId), [sudoSaved])
@@ -1900,7 +1937,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1900
1937
  const setPreferredGrantKeys = useCallback((ids: string[]) => {
1901
1938
  cfgRef.current.preferredGrantKeys = ids
1902
1939
  setPreferredGrantKeysState(ids)
1903
- void saveConfig({ preferredGrantKeys: ids })
1940
+ void saveProfileConfig({ preferredGrantKeys: ids })
1904
1941
  }, [])
1905
1942
 
1906
1943
  // Persist the granted-keys map (only non-empty entries) to config, write-through.
@@ -1908,7 +1945,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1908
1945
  const rec: Record<string, string[]> = {}
1909
1946
  for (const [id, set] of next) if (set.size) rec[String(id)] = [...set]
1910
1947
  cfgRef.current.grantedKeys = rec
1911
- void saveConfig({ grantedKeys: rec })
1948
+ void saveProfileConfig({ grantedKeys: rec })
1912
1949
  }, [])
1913
1950
 
1914
1951
  // Record / forget which keys (by body) Spinup has granted on a site.
@@ -1950,7 +1987,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1950
1987
  else next.delete(apex)
1951
1988
  const rec = Object.fromEntries(next)
1952
1989
  cfgRef.current.zoneAccessNotes = rec
1953
- void saveConfig({ zoneAccessNotes: rec })
1990
+ void saveProfileConfig({ zoneAccessNotes: rec })
1954
1991
  return next
1955
1992
  })
1956
1993
  }, [])
@@ -2427,7 +2464,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2427
2464
  for (const p of ALL_PROVIDERS) {
2428
2465
  providers[p] = next[p].filter((c) => !c.env).map((c) => ({ id: c.id, label: c.label, creds: c.creds }))
2429
2466
  }
2430
- void saveConfig({ providers })
2467
+ void saveProfileConfig({ providers })
2431
2468
  }, [])
2432
2469
 
2433
2470
  // Verify a connection and write the result through to the cache.
@@ -2662,7 +2699,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2662
2699
  if (keys[domain] !== key) {
2663
2700
  keys[domain] = key
2664
2701
  cfgRef.current.vanityHealthKeys = keys
2665
- void saveConfig({ vanityHealthKeys: keys })
2702
+ void saveProfileConfig({ vanityHealthKeys: keys })
2666
2703
  }
2667
2704
  return key
2668
2705
  }, [])
@@ -2676,7 +2713,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2676
2713
  const newKey = crypto.randomUUID().replace(/-/g, "")
2677
2714
  keys[domain] = newKey
2678
2715
  cfgRef.current.vanityHealthKeys = keys
2679
- void saveConfig({ vanityHealthKeys: keys })
2716
+ void saveProfileConfig({ vanityHealthKeys: keys })
2680
2717
  return { oldKey, newKey }
2681
2718
  }, [])
2682
2719
 
@@ -2685,13 +2722,13 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2685
2722
  const persistKumaAuth = useCallback((conn: UptimeKumaConn, jwt: string) => {
2686
2723
  if (conn.env || jwt === conn.jwt) return
2687
2724
  cfgRef.current.uptimeKuma = { ...conn, jwt }
2688
- void saveConfig({ uptimeKuma: { ...conn, jwt } })
2725
+ void saveProfileConfig({ uptimeKuma: { ...conn, jwt } })
2689
2726
  }, [])
2690
2727
 
2691
2728
  const persistKumaMonitors = useCallback((domain: string, ref: KumaMonitorRef) => {
2692
2729
  const map = { ...cfgRef.current.kumaMonitors, [domain]: ref }
2693
2730
  cfgRef.current.kumaMonitors = map
2694
- void saveConfig({ kumaMonitors: map })
2731
+ void saveProfileConfig({ kumaMonitors: map })
2695
2732
  }, [])
2696
2733
 
2697
2734
  // The step machine. Each step performs its work then hands off to the next; it
@@ -3107,7 +3144,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3107
3144
  if (!login.ok) return { ok: false, error: login.error, tokenRequired: login.tokenRequired }
3108
3145
  const stored = { url, username: conn.username, password: conn.password, jwt: login.jwt }
3109
3146
  cfgRef.current.uptimeKuma = stored
3110
- void saveConfig({ uptimeKuma: stored })
3147
+ void saveProfileConfig({ uptimeKuma: stored })
3111
3148
  setKumaEpoch((e) => e + 1) // start the status poll loop now
3112
3149
  await kuma.waitForVersion() // `info` can land after the login ack
3113
3150
  return { ok: true, version: kuma.version }
@@ -4225,6 +4262,29 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4225
4262
  }
4226
4263
  }, [trackServerEvent, trackPhpUpgradeEvent, trackHttpsToggleEvent, driveVanity])
4227
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
+
4228
4288
  const value: StoreValue = {
4229
4289
  servers,
4230
4290
  sites,
@@ -4249,6 +4309,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4249
4309
  setWpInventorySite,
4250
4310
  phpUpgradeSite,
4251
4311
  setPhpUpgradeSite,
4312
+ bulkWpGroup,
4313
+ setBulkWpGroup,
4252
4314
  phpUpgrades,
4253
4315
  startPhpUpgrade,
4254
4316
  clearPhpUpgrade,
@@ -4415,12 +4477,18 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4415
4477
  setEnableLocalSyncSite,
4416
4478
  enableLocalSync,
4417
4479
  accountSlug: cfgRef.current.accountSlug,
4480
+ profileId: cfgRef.current.profileId,
4481
+ profileLabel,
4482
+ setProfileLabel,
4483
+ tokenFromEnv: cfgRef.current.tokenSource === "env",
4484
+ switchBlocker,
4418
4485
  sitesForServer,
4419
4486
  serverById,
4420
4487
  probes,
4421
4488
  probingIds,
4422
4489
  probeErrors,
4423
4490
  runProbe,
4491
+ noteWpVersion,
4424
4492
  runProbeMany,
4425
4493
  isProbeStale,
4426
4494
  dnsZones,
package/src/ui/toast.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Replaces the @opentui-ui/toast package, which is stuck on a peer-dependency
4
4
  // range from opentui's 0.1.x days and breaks against the @opentui/core 0.4.x
5
5
  // line we're on (see CHANGELOG v0.24.2/v0.24.3). Every call site in this app
6
- // only ever fires a single-line success message, so a small first-party
6
+ // only ever fires a single-line success or error message, so a small first-party
7
7
  // implementation is simpler — and immune to upstream drift — than depending
8
8
  // on a package built for a much larger feature set we don't use.
9
9
  //
@@ -15,6 +15,7 @@
15
15
  export interface ToastItem {
16
16
  id: number
17
17
  message: string
18
+ kind: "success" | "error"
18
19
  }
19
20
 
20
21
  const TOAST_DURATION_MS = 4000
@@ -36,14 +37,19 @@ export function getToasts(): ToastItem[] {
36
37
  return toasts
37
38
  }
38
39
 
39
- export const toast = {
40
- success(message: string) {
41
- const id = nextId++
42
- toasts = [...toasts, { id, message }]
40
+ function push(message: string, kind: ToastItem["kind"]) {
41
+ const id = nextId++
42
+ toasts = [...toasts, { id, message, kind }]
43
+ notify()
44
+ setTimeout(() => {
45
+ toasts = toasts.filter((t) => t.id !== id)
43
46
  notify()
44
- setTimeout(() => {
45
- toasts = toasts.filter((t) => t.id !== id)
46
- notify()
47
- }, TOAST_DURATION_MS)
48
- },
47
+ }, TOAST_DURATION_MS)
48
+ }
49
+
50
+ export const toast = {
51
+ success: (message: string) => push(message, "success"),
52
+ // For a background job that failed after its overlay was closed — points the
53
+ // user back at where the details are.
54
+ error: (message: string) => push(message, "error"),
49
55
  }