spinuptui 1.2.0 → 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.
- package/CHANGELOG.md +8 -1
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/ui/App.tsx +7 -1
- package/src/ui/Details.tsx +7 -1
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/store.tsx +69 -3
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +5 -5
- package/src/ui/views/Search.tsx +5 -6
- package/src/ui/views/ServerActions.tsx +6 -4
- package/src/ui/views/Stacks.tsx +22 -9
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,12 @@ version; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.2.1] - 2026-10-27
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **WordPress versions no longer go stale.** The version shown for a site was read once, when the site was first identified, and never again, so sites updated outside SpinupTUI (auto-updates, wp-admin, deploys) kept showing the old version, and `r` didn't help. Versions older than a day are now re-checked in the background when the site list loads, one site at a time per server, stopping at a server's first failure so unattended SSH can't trip fail2ban. A reading that couldn't be refreshed shows its age in the Stacks list, and the Details pane always says when it was taken.
|
|
18
|
+
- **`w` opens the site or server you picked, not the SpinupWP dashboard.** Deep links need the account slug, which SpinupWP's API doesn't provide and first-run setup never asked for, so unless you had added `accountSlug` to `config.json` by hand, `w` quietly fell back to the dashboard. Now `w` asks for the slug the first time it needs it (pasting any dashboard URL works), saves it to the current account and opens the right page. First-run setup also asks for it, as an optional step after the token.
|
|
19
|
+
|
|
14
20
|
## [1.2.0] - 2026-10-21
|
|
15
21
|
|
|
16
22
|
### Added
|
|
@@ -1490,7 +1496,8 @@ Initial tagged release.
|
|
|
1490
1496
|
### Notes
|
|
1491
1497
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1492
1498
|
|
|
1493
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.
|
|
1499
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.1...HEAD
|
|
1500
|
+
[1.2.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.2.1
|
|
1494
1501
|
[1.2.0]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0
|
|
1495
1502
|
[1.1.2]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...v1.1.2
|
|
1496
1503
|
[1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
|
package/README.md
CHANGED
|
@@ -305,8 +305,9 @@ These can be set in `config.json` or via an environment variable:
|
|
|
305
305
|
- **`accountSlug`** / `SPINUPWP_ACCOUNT_SLUG` — your SpinupWP account/team slug
|
|
306
306
|
(the first path segment in a SpinupWP URL, e.g. `acme-agency` in
|
|
307
307
|
`https://spinupwp.app/acme-agency/servers/12345`). The API
|
|
308
|
-
doesn't expose it, so
|
|
309
|
-
|
|
308
|
+
doesn't expose it, so it's needed for the `w` deep links into the web app.
|
|
309
|
+
You don't have to set it by hand: first-run setup asks for it (optional), and
|
|
310
|
+
`w` asks the first time it's needed, then saves it to the current account.
|
|
310
311
|
- **`sshUser`** / `SPINUPWP_SSH_USER` — override the SSH user for the health view
|
|
311
312
|
and stack probes (see "Server health" below).
|
|
312
313
|
- **`localSync`** / `SPINUPWP_LOCAL_SYNC` — opt-in for the **Pull production →
|
package/package.json
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { parseAccountSlug, siteWebUrl } from "./spinupweb.ts"
|
|
3
|
+
|
|
4
|
+
describe("parseAccountSlug", () => {
|
|
5
|
+
test("a bare slug", () => {
|
|
6
|
+
expect(parseAccountSlug(" acme-agency ")).toBe("acme-agency")
|
|
7
|
+
})
|
|
8
|
+
test("a whole dashboard URL", () => {
|
|
9
|
+
expect(parseAccountSlug("https://spinupwp.app/acme-agency/sites/123#git")).toBe("acme-agency")
|
|
10
|
+
expect(parseAccountSlug("spinupwp.app/acme-agency")).toBe("acme-agency")
|
|
11
|
+
expect(parseAccountSlug("https://SpinupWP.app/acme-agency/?tab=servers")).toBe("acme-agency")
|
|
12
|
+
})
|
|
13
|
+
test("anything that isn't a slug", () => {
|
|
14
|
+
expect(parseAccountSlug("")).toBeNull()
|
|
15
|
+
expect(parseAccountSlug("https://spinupwp.app/")).toBeNull()
|
|
16
|
+
expect(parseAccountSlug("acme agency")).toBeNull()
|
|
17
|
+
expect(parseAccountSlug("../etc")).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
test("feeds the deep link", () => {
|
|
20
|
+
expect(siteWebUrl(42, parseAccountSlug("https://spinupwp.app/acme-agency/sites"))).toBe("https://spinupwp.app/acme-agency/sites/42")
|
|
21
|
+
})
|
|
22
|
+
})
|
package/src/lib/spinupweb.ts
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
const WEB_BASE = "https://spinupwp.app"
|
|
14
14
|
|
|
15
|
+
// Read an account slug from what a user types: the slug itself, or any dashboard
|
|
16
|
+
// URL pasted whole ("https://spinupwp.app/acme-agency/sites/123" → "acme-agency").
|
|
17
|
+
// Null when it doesn't look like a slug.
|
|
18
|
+
export function parseAccountSlug(input: string): string | null {
|
|
19
|
+
let s = input.trim()
|
|
20
|
+
const at = s.toLowerCase().indexOf("spinupwp.app/")
|
|
21
|
+
if (at >= 0) s = s.slice(at + "spinupwp.app/".length)
|
|
22
|
+
s = s.replace(/^\/+/, "").split(/[/?#]/)[0] ?? ""
|
|
23
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(s) ? s : null
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export function serverWebUrl(id: number, accountSlug: string | null): string {
|
|
16
27
|
return accountSlug ? `${WEB_BASE}/${accountSlug}/servers/${id}` : WEB_BASE
|
|
17
28
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { isRecheckDue, isVersionOld, VERSION_MAX_AGE_MS, type CachedProbe } from "./stackCache.ts"
|
|
3
|
+
|
|
4
|
+
const NOW = 1_800_000_000_000
|
|
5
|
+
|
|
6
|
+
function entry(version: string | null, ageMs: number): CachedProbe {
|
|
7
|
+
return {
|
|
8
|
+
result: { kind: version ? "wordpress" : "whmcs", app: "x", version, label: "x" },
|
|
9
|
+
detectedAt: NOW - ageMs,
|
|
10
|
+
signature: "true:",
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("isVersionOld", () => {
|
|
15
|
+
test("a fresh version reading isn't old", () => {
|
|
16
|
+
expect(isVersionOld(entry("7.1.3", 60_000), NOW)).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
test("a version reading past the max age is old", () => {
|
|
19
|
+
expect(isVersionOld(entry("7.0", VERSION_MAX_AGE_MS + 1), NOW)).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
test("an identity without a version never goes old", () => {
|
|
22
|
+
expect(isVersionOld(entry(null, 365 * VERSION_MAX_AGE_MS), NOW)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
test("no entry isn't old", () => {
|
|
25
|
+
expect(isVersionOld(undefined, NOW)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("isRecheckDue", () => {
|
|
30
|
+
const old = entry("7.0", 3 * VERSION_MAX_AGE_MS)
|
|
31
|
+
test("an old version with no attempt yet is due", () => {
|
|
32
|
+
expect(isRecheckDue(old, NOW)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
test("an attempt inside the window (even a failed one) holds it off", () => {
|
|
35
|
+
expect(isRecheckDue({ ...old, recheckedAt: NOW - 60_000 }, NOW)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
test("an attempt from a previous window doesn't", () => {
|
|
38
|
+
expect(isRecheckDue({ ...old, recheckedAt: NOW - VERSION_MAX_AGE_MS - 1 }, NOW)).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
test("a fresh reading is never due", () => {
|
|
41
|
+
expect(isRecheckDue(entry("7.1.3", 60_000), NOW)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
package/src/lib/stackCache.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// - set() is called after a successful `d` probe; it upserts the entry and
|
|
6
6
|
// writes the whole file back immediately (write-through), so a probe
|
|
7
7
|
// survives even if the TUI is killed with q / Ctrl+C.
|
|
8
|
-
// Probing
|
|
8
|
+
// Probing is on-demand and per-site, with one exception: a WP version older than
|
|
9
|
+
// VERSION_MAX_AGE_MS is re-probed in the background. This cache exists to avoid
|
|
9
10
|
// repeating the slow SSH round-trips across launches, not to save RAM. It's a
|
|
10
11
|
// deliberately small, reusable pattern for other expensive derived data later.
|
|
11
12
|
|
|
@@ -21,6 +22,10 @@ export interface CachedProbe {
|
|
|
21
22
|
result: ProbeResult
|
|
22
23
|
detectedAt: number // epoch ms
|
|
23
24
|
signature: string // invalidation key (see siteSignature)
|
|
25
|
+
// Last automatic re-check of an old version (epoch ms), kept even when it
|
|
26
|
+
// failed, so the once-per-window limit holds across launches. A successful
|
|
27
|
+
// probe replaces the whole entry, which clears it.
|
|
28
|
+
recheckedAt?: number
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
interface CacheFile {
|
|
@@ -32,6 +37,25 @@ export function stackCachePath(dir = profileDataDir()): string {
|
|
|
32
37
|
return join(dir, "stack-cache.json")
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
// A WordPress version is a fact about the server that changes without us: auto-
|
|
41
|
+
// updates, wp-admin and deploys all move it, and none of them touch the site's
|
|
42
|
+
// shape below. So a reading that carries a version is re-checked once it's older
|
|
43
|
+
// than this (in the background, when the site list loads), and its age is shown.
|
|
44
|
+
export const VERSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
// True when the entry carries a WP version read longer ago than the max age.
|
|
47
|
+
// Identities without a version (WHMCS, Laravel, static) don't drift this way.
|
|
48
|
+
export function isVersionOld(entry: CachedProbe | undefined, now = Date.now()): boolean {
|
|
49
|
+
return entry?.result.version != null && now - entry.detectedAt > VERSION_MAX_AGE_MS
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// True when an old version is due an automatic re-check: none was attempted in
|
|
53
|
+
// the current window. Unattended SSH that fails must not repeat on every launch.
|
|
54
|
+
export function isRecheckDue(entry: CachedProbe | undefined, now = Date.now()): boolean {
|
|
55
|
+
if (!entry || !isVersionOld(entry, now)) return false
|
|
56
|
+
return entry.recheckedAt == null || now - entry.recheckedAt > VERSION_MAX_AGE_MS
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
// A cached probe is considered stale when the site's relevant shape changes.
|
|
36
60
|
// Keying on is_wordpress + public_folder catches the cases that would alter the
|
|
37
61
|
// probe's target paths or expected result.
|
|
@@ -83,6 +107,16 @@ export class StackCache {
|
|
|
83
107
|
await this.persist()
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
// Record an automatic re-check attempt on existing entries, in one write.
|
|
111
|
+
async noteRecheck(siteIds: number[]): Promise<void> {
|
|
112
|
+
const now = Date.now()
|
|
113
|
+
for (const id of siteIds) {
|
|
114
|
+
const e = this.entries.get(id)
|
|
115
|
+
if (e) this.entries.set(id, { ...e, recheckedAt: now })
|
|
116
|
+
}
|
|
117
|
+
await this.persist()
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
// A copy of all entries (for rendering aggregates without exposing the map).
|
|
87
121
|
snapshot(): Map<number, CachedProbe> {
|
|
88
122
|
return new Map(this.entries)
|
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"
|
|
@@ -89,7 +90,8 @@ export function App() {
|
|
|
89
90
|
store.dnsInventoryServer !== null ||
|
|
90
91
|
store.connectZoneTarget !== null ||
|
|
91
92
|
store.dnsRecordsTarget !== null ||
|
|
92
|
-
store.kumaSite !== null
|
|
93
|
+
store.kumaSite !== null ||
|
|
94
|
+
store.slugPrompt !== null
|
|
93
95
|
useEffect(() => {
|
|
94
96
|
store.setOverlayOpen(overlayActive)
|
|
95
97
|
}, [overlayActive, store])
|
|
@@ -127,6 +129,9 @@ export function App() {
|
|
|
127
129
|
// The Accounts overlay owns the keyboard while open.
|
|
128
130
|
if (showAccounts) return
|
|
129
131
|
|
|
132
|
+
// The account-slug prompt (from `w`) owns the keyboard while open.
|
|
133
|
+
if (store.slugPrompt) return
|
|
134
|
+
|
|
130
135
|
// The release-notes overlay owns the keyboard while open (any key dismisses).
|
|
131
136
|
if (store.releaseNotesInfo) return
|
|
132
137
|
|
|
@@ -290,6 +295,7 @@ export function App() {
|
|
|
290
295
|
{store.dnsRecordsTarget && <DnsRecords />}
|
|
291
296
|
{store.kumaSite && <KumaSite />}
|
|
292
297
|
{showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
|
|
298
|
+
{store.slugPrompt && <AccountSlugPrompt />}
|
|
293
299
|
<ToastStack />
|
|
294
300
|
</box>
|
|
295
301
|
)
|
package/src/ui/Details.tsx
CHANGED
|
@@ -180,7 +180,13 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
|
|
|
180
180
|
<Field label="Stack" value={stack} valueColor={stackColor(stack)} />
|
|
181
181
|
<Field
|
|
182
182
|
label="Detected"
|
|
183
|
-
value={
|
|
183
|
+
value={
|
|
184
|
+
probing
|
|
185
|
+
? "identifying…"
|
|
186
|
+
: probe
|
|
187
|
+
? `${probe.result.label}${isProbeStale(site) ? " (stale)" : ""} · ${timeAgo(new Date(probe.detectedAt).toISOString())}`
|
|
188
|
+
: "not identified · d"
|
|
189
|
+
}
|
|
184
190
|
valueColor={probing ? theme.textDim : probe ? probeKindColor(probe.result.kind) : theme.textFaint}
|
|
185
191
|
/>
|
|
186
192
|
<Field label="Type" value={isWordPress ? "WordPress" : "Generic"} valueColor={isWordPress ? theme.brand : theme.textDim} />
|
package/src/ui/Onboarding.tsx
CHANGED
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Shown when no token is found in the environment or config file. Collects an
|
|
4
4
|
// API token, validates it against the live API, persists it to ~/.config, then
|
|
5
|
-
//
|
|
5
|
+
// asks (optionally) for the account slug that `w` deep links need — the API
|
|
6
|
+
// can't tell us — and hands control back to the app.
|
|
6
7
|
|
|
7
8
|
import { useState } from "react"
|
|
8
9
|
import { theme } from "../lib/theme.ts"
|
|
9
10
|
import { Spinner, SecretInput } from "./components.tsx"
|
|
10
11
|
import { SpinupWPClient } from "../api/client.ts"
|
|
11
12
|
import { DEFAULT_BASE_URL, saveConfig, configPath } from "../config.ts"
|
|
13
|
+
import { parseAccountSlug } from "../lib/spinupweb.ts"
|
|
12
14
|
|
|
13
|
-
type Phase = "input" | "validating" | "error"
|
|
15
|
+
type Phase = "input" | "validating" | "error" | "slug"
|
|
16
|
+
|
|
17
|
+
const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
14
18
|
|
|
15
19
|
export function Onboarding({ onComplete }: { onComplete: () => void }) {
|
|
16
20
|
const [value, setValue] = useState("")
|
|
17
21
|
const [phase, setPhase] = useState<Phase>("input")
|
|
18
22
|
const [error, setError] = useState<string | null>(null)
|
|
23
|
+
const [slug, setSlug] = useState("")
|
|
19
24
|
|
|
20
25
|
async function submit(token: string) {
|
|
21
26
|
const trimmed = token.trim()
|
|
@@ -26,13 +31,76 @@ export function Onboarding({ onComplete }: { onComplete: () => void }) {
|
|
|
26
31
|
const result = await client.validateToken()
|
|
27
32
|
if (result.ok) {
|
|
28
33
|
await saveConfig({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
|
|
29
|
-
|
|
34
|
+
setPhase("slug")
|
|
30
35
|
} else {
|
|
31
36
|
setError(result.reason)
|
|
32
37
|
setPhase("error")
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// Optional: empty skips (an existing slug, e.g. on `spinuptui login`, is kept).
|
|
42
|
+
async function submitSlug() {
|
|
43
|
+
if (!slug.trim()) return onComplete()
|
|
44
|
+
const parsed = parseAccountSlug(slug)
|
|
45
|
+
if (!parsed) {
|
|
46
|
+
setError("That doesn't look like a slug — paste the part after spinupwp.app/, or the whole URL.")
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
await saveConfig({ accountSlug: parsed })
|
|
50
|
+
onComplete()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (phase === "slug") {
|
|
54
|
+
return (
|
|
55
|
+
<box
|
|
56
|
+
style={{
|
|
57
|
+
width: "100%",
|
|
58
|
+
height: "100%",
|
|
59
|
+
flexDirection: "column",
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
alignItems: "center",
|
|
62
|
+
backgroundColor: theme.bg,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
|
|
66
|
+
<box style={{ height: 1 }} />
|
|
67
|
+
<box
|
|
68
|
+
title=" Almost done "
|
|
69
|
+
titleColor={theme.brand}
|
|
70
|
+
border
|
|
71
|
+
borderColor={theme.border}
|
|
72
|
+
style={{ flexDirection: "column", width: 70, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
|
|
73
|
+
>
|
|
74
|
+
<text content="✓ Token works." fg={theme.good} />
|
|
75
|
+
<box style={{ height: 1 }} />
|
|
76
|
+
<text content="One more (optional): your SpinupWP account slug, the part" fg={theme.text} wrapMode="none" />
|
|
77
|
+
<text content="after spinupwp.app/ in your dashboard's address. With it, w" fg={theme.text} wrapMode="none" />
|
|
78
|
+
<text content="opens a site or server straight in SpinupWP." fg={theme.text} wrapMode="none" />
|
|
79
|
+
<text content="Pasting any dashboard URL works too." fg={theme.textDim} wrapMode="none" />
|
|
80
|
+
<box style={{ height: 1 }} />
|
|
81
|
+
<text content="Account slug" fg={theme.accent} />
|
|
82
|
+
<input
|
|
83
|
+
focused
|
|
84
|
+
value={slug}
|
|
85
|
+
placeholder="acme-agency"
|
|
86
|
+
onInput={(v: string) => {
|
|
87
|
+
setSlug(v)
|
|
88
|
+
setError(null)
|
|
89
|
+
}}
|
|
90
|
+
onSubmit={() => void submitSlug()}
|
|
91
|
+
style={INPUT_STYLE}
|
|
92
|
+
/>
|
|
93
|
+
<box style={{ height: 1 }} />
|
|
94
|
+
{error ? (
|
|
95
|
+
<text content={"✕ " + error} fg={theme.bad} wrapMode="none" />
|
|
96
|
+
) : (
|
|
97
|
+
<text content="Press Enter to finish. Leave it empty to skip; w will ask later." fg={theme.textFaint} wrapMode="none" />
|
|
98
|
+
)}
|
|
99
|
+
</box>
|
|
100
|
+
</box>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
36
104
|
return (
|
|
37
105
|
<box
|
|
38
106
|
style={{
|
package/src/ui/store.tsx
CHANGED
|
@@ -51,7 +51,7 @@ import { carrySiteSettings, type SettingsCarryResult } from "../lib/cloneSetting
|
|
|
51
51
|
import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
|
|
52
52
|
import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
|
|
53
53
|
import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword } from "../lib/keychain.ts"
|
|
54
|
-
import { StackCache, siteSignature, type CachedProbe } from "../lib/stackCache.ts"
|
|
54
|
+
import { StackCache, siteSignature, isRecheckDue, type CachedProbe } from "../lib/stackCache.ts"
|
|
55
55
|
import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offeredPhpVersions as offeredPhpVersionsWith, type PhpEolDates } from "../lib/phpEol.ts"
|
|
56
56
|
import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
|
|
57
57
|
import { wpCoreBusy } from "./wpCoreJobs.tsx"
|
|
@@ -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)
|
|
@@ -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),
|
|
@@ -4575,7 +4637,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4575
4637
|
enableLocalSyncSite,
|
|
4576
4638
|
setEnableLocalSyncSite,
|
|
4577
4639
|
enableLocalSync,
|
|
4578
|
-
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
|
+
}
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -24,7 +24,7 @@ type Focus = "servers" | "sites"
|
|
|
24
24
|
|
|
25
25
|
export function Browser({ rows }: { rows: number }) {
|
|
26
26
|
const store = useStore()
|
|
27
|
-
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes,
|
|
27
|
+
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, openInSpinupWP, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
|
|
28
28
|
|
|
29
29
|
const [serverIndex, setServerIndex] = useState(0)
|
|
30
30
|
const [siteIndex, setSiteIndex] = useState(0)
|
|
@@ -283,11 +283,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
283
283
|
// Open the focused server/site in the SpinupWP web app — useful for
|
|
284
284
|
// actions the API can't do (e.g. running a pending server upgrade).
|
|
285
285
|
if (focus === "sites" && sites[siteIndex]) {
|
|
286
|
-
|
|
287
|
-
|
|
286
|
+
const id = sites[siteIndex].id
|
|
287
|
+
if (openInSpinupWP((slug) => siteWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
|
|
288
288
|
} else if (server) {
|
|
289
|
-
|
|
290
|
-
|
|
289
|
+
const id = server.id
|
|
290
|
+
if (openInSpinupWP((slug) => serverWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
|
|
291
291
|
}
|
|
292
292
|
setTimeout(() => setFlash(null), 2000)
|
|
293
293
|
return
|
package/src/ui/views/Search.tsx
CHANGED
|
@@ -36,7 +36,7 @@ function score(haystack: string, q: string): number | null {
|
|
|
36
36
|
|
|
37
37
|
export function Search({ rows }: { rows: number }) {
|
|
38
38
|
const store = useStore()
|
|
39
|
-
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer,
|
|
39
|
+
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, openInSpinupWP, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
|
|
40
40
|
const [query, setQuery] = useState("")
|
|
41
41
|
const [selected, setSelected] = useState(0)
|
|
42
42
|
// "query" = typing/filtering (input focused); "actions" = input blurred so the
|
|
@@ -101,9 +101,8 @@ export function Search({ rows }: { rows: number }) {
|
|
|
101
101
|
openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
|
|
102
102
|
flashMsg(`Opening ${s.domain}…`)
|
|
103
103
|
}
|
|
104
|
-
const openInSpinup = (
|
|
105
|
-
|
|
106
|
-
flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
|
|
104
|
+
const openInSpinup = (build: (slug: string) => string) => {
|
|
105
|
+
if (openInSpinupWP(build)) flashMsg("Opening in SpinupWP…")
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
useKeyboard((key) => {
|
|
@@ -141,8 +140,8 @@ export function Search({ rows }: { rows: number }) {
|
|
|
141
140
|
if (current?.kind === "site") openSite(current.site)
|
|
142
141
|
return
|
|
143
142
|
case "w":
|
|
144
|
-
if (current?.kind === "site") openInSpinup(siteWebUrl(current.site.id,
|
|
145
|
-
else if (current?.kind === "server") openInSpinup(serverWebUrl(current.server.id,
|
|
143
|
+
if (current?.kind === "site") openInSpinup((slug) => siteWebUrl(current.site.id, slug))
|
|
144
|
+
else if (current?.kind === "server") openInSpinup((slug) => serverWebUrl(current.server.id, slug))
|
|
146
145
|
return
|
|
147
146
|
case "u":
|
|
148
147
|
if (current?.kind === "site") setPhpUpgradeSite(current.site)
|
|
@@ -19,7 +19,6 @@ import { Panel, Spinner, Centered } from "../components.tsx"
|
|
|
19
19
|
import { StatusBar } from "../StatusBar.tsx"
|
|
20
20
|
import { useStore } from "../store.tsx"
|
|
21
21
|
import { moveSelection } from "../List.tsx"
|
|
22
|
-
import { openUrl } from "../../lib/open.ts"
|
|
23
22
|
import { serverWebUrl } from "../../lib/spinupweb.ts"
|
|
24
23
|
import { toast } from "../toast.ts"
|
|
25
24
|
import type { RebootInfo } from "../../lib/ssh.ts"
|
|
@@ -67,7 +66,7 @@ export function ServerActions() {
|
|
|
67
66
|
rebootInfoLoading,
|
|
68
67
|
rebootInfoErrors,
|
|
69
68
|
loadRebootInfo,
|
|
70
|
-
|
|
69
|
+
openInSpinupWP,
|
|
71
70
|
refresh,
|
|
72
71
|
} = store
|
|
73
72
|
|
|
@@ -161,8 +160,11 @@ export function ServerActions() {
|
|
|
161
160
|
return
|
|
162
161
|
case "w":
|
|
163
162
|
if (upgradeRequired && server) {
|
|
164
|
-
|
|
165
|
-
toast.success(
|
|
163
|
+
const id = server.id
|
|
164
|
+
if (openInSpinupWP((slug) => serverWebUrl(id, slug))) toast.success("Opening in SpinupWP…")
|
|
165
|
+
// No slug yet: the prompt takes over — close this overlay so its
|
|
166
|
+
// keys don't fire underneath.
|
|
167
|
+
else close()
|
|
166
168
|
}
|
|
167
169
|
return
|
|
168
170
|
}
|
package/src/ui/views/Stacks.tsx
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
17
17
|
import { useKeyboard } from "@opentui/react"
|
|
18
18
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
19
|
-
import { bar, truncate } from "../../lib/format.ts"
|
|
19
|
+
import { bar, timeAgo, truncate } from "../../lib/format.ts"
|
|
20
|
+
import { isVersionOld } from "../../lib/stackCache.ts"
|
|
20
21
|
import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
|
|
21
22
|
import { phpSortKey } from "../../lib/phpEol.ts"
|
|
22
23
|
import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
|
|
@@ -75,7 +76,7 @@ function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
|
|
|
75
76
|
export function Stacks({ rows }: { rows: number }) {
|
|
76
77
|
const store = useStore()
|
|
77
78
|
const wpCoreJobs = useWpCoreJobs()
|
|
78
|
-
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol,
|
|
79
|
+
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, openInSpinupWP, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
|
|
79
80
|
store
|
|
80
81
|
|
|
81
82
|
const [groupIndex, setGroupIndex] = useState(0)
|
|
@@ -218,8 +219,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
218
219
|
case "w":
|
|
219
220
|
// Open the selected site in the SpinupWP web app.
|
|
220
221
|
if (focus === "sites" && groupSites[siteIndex]) {
|
|
221
|
-
|
|
222
|
-
|
|
222
|
+
const id = groupSites[siteIndex].id
|
|
223
|
+
if (openInSpinupWP((slug) => siteWebUrl(id, slug))) flashMsg("Opening in SpinupWP…")
|
|
223
224
|
}
|
|
224
225
|
return
|
|
225
226
|
case "d":
|
|
@@ -421,11 +422,23 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
421
422
|
) : vanity ? (
|
|
422
423
|
<text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
|
|
423
424
|
) : cached ? (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
<>
|
|
426
|
+
<text
|
|
427
|
+
content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
|
|
428
|
+
fg={probeKindColor(cached.result.kind, selected)}
|
|
429
|
+
wrapMode="none"
|
|
430
|
+
/>
|
|
431
|
+
{/* A version past its re-check age (the background re-probe
|
|
432
|
+
failed or hasn't run) says how old it is, so it can't
|
|
433
|
+
pass for current. */}
|
|
434
|
+
{isVersionOld(cached) && (
|
|
435
|
+
<text
|
|
436
|
+
content={" " + timeAgo(new Date(cached.detectedAt).toISOString()).replace(/ ago$/, "")}
|
|
437
|
+
fg={selected ? theme.text : theme.warn}
|
|
438
|
+
wrapMode="none"
|
|
439
|
+
/>
|
|
440
|
+
)}
|
|
441
|
+
</>
|
|
429
442
|
) : errored ? (
|
|
430
443
|
<text content="probe failed" fg={selected ? theme.text : theme.bad} wrapMode="none" />
|
|
431
444
|
) : (
|