spinuptui 0.19.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/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Pure helpers for the "create a server" flow (backlog item 5, build 1).
|
|
2
|
+
// No I/O — the API calls live in the client; the orchestration in the store.
|
|
3
|
+
// Here: map a source server's provider/specs onto the provider metadata catalog,
|
|
4
|
+
// format specs + cost, and suggest a server name from the existing fleet.
|
|
5
|
+
|
|
6
|
+
import type { Server, ProviderMetadata, ProviderSize, ProviderRegion } from "../api/types.ts"
|
|
7
|
+
|
|
8
|
+
// The provider keys the API recognizes for metadata + create.
|
|
9
|
+
export type ProviderKey = "digitalocean" | "vultr" | "linode" | "hetzner"
|
|
10
|
+
export const PROVIDER_KEYS: ProviderKey[] = ["digitalocean", "vultr", "linode", "hetzner"]
|
|
11
|
+
|
|
12
|
+
// All regions across continents, flattened in catalog order (for the picker).
|
|
13
|
+
export function allRegions(md: ProviderMetadata | undefined): ProviderRegion[] {
|
|
14
|
+
if (!md) return []
|
|
15
|
+
return Object.values(md.regions).flat()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A sensible default region (first available, else first overall).
|
|
19
|
+
export function firstRegion(md: ProviderMetadata | undefined): ProviderRegion | undefined {
|
|
20
|
+
const all = allRegions(md)
|
|
21
|
+
return all.find((r) => r.available !== false) ?? all[0]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Normalize the API's free-form provider_name ("DigitalOcean", "Akamai/Linode",
|
|
25
|
+
// "Hetzner Cloud") to the key the metadata + create endpoints use.
|
|
26
|
+
export function providerKeyFromName(name: string | null | undefined): ProviderKey | null {
|
|
27
|
+
const n = (name ?? "").toLowerCase()
|
|
28
|
+
if (n.includes("digitalocean") || n.includes("digital ocean")) return "digitalocean"
|
|
29
|
+
if (n.includes("vultr")) return "vultr"
|
|
30
|
+
if (n.includes("linode") || n.includes("akamai")) return "linode"
|
|
31
|
+
if (n.includes("hetzner")) return "hetzner"
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A human label for a provider key (for the form + confirm screens).
|
|
36
|
+
export function providerLabel(key: ProviderKey): string {
|
|
37
|
+
return { digitalocean: "DigitalOcean", vultr: "Vultr", linode: "Linode", hetzner: "Hetzner" }[key]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sizeBySlug(md: ProviderMetadata | undefined, slug: string | null | undefined): ProviderSize | undefined {
|
|
41
|
+
if (!md || !slug) return undefined
|
|
42
|
+
return md.sizes.find((s) => s.slug === slug)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Flatten the continent-grouped regions to find one by slug. Case-insensitive
|
|
46
|
+
// because a Server's `region` comes back as a code ("HIL") while the metadata
|
|
47
|
+
// slugs are lowercase ("hil").
|
|
48
|
+
export function regionBySlug(md: ProviderMetadata | undefined, slug: string | null | undefined): ProviderRegion | undefined {
|
|
49
|
+
if (!md || !slug) return undefined
|
|
50
|
+
const target = slug.toLowerCase()
|
|
51
|
+
for (const group of Object.values(md.regions)) {
|
|
52
|
+
const hit = group.find((r) => r.slug.toLowerCase() === target)
|
|
53
|
+
if (hit) return hit
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Parse a Server's human-readable `size` ("8 GB / 4 vCPUs") into specs. The API
|
|
59
|
+
// returns this display string, NOT a size slug, so matching to the catalog is by
|
|
60
|
+
// vCPU + memory rather than by slug.
|
|
61
|
+
export function parseSizeSpec(display: string | null | undefined): { vcpus: number | null; memoryMb: number | null } {
|
|
62
|
+
const d = display ?? ""
|
|
63
|
+
const mem = /(\d+(?:\.\d+)?)\s*(gb|mb)/i.exec(d)
|
|
64
|
+
const memoryMb = mem ? Math.round(parseFloat(mem[1]) * (/gb/i.test(mem[2]) ? 1024 : 1)) : null
|
|
65
|
+
const cpu = /(\d+)\s*v?cpu/i.exec(d)
|
|
66
|
+
const vcpus = cpu ? parseInt(cpu[1], 10) : null
|
|
67
|
+
return { vcpus, memoryMb }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Match a vCPU+memory spec to the closest size slug from a CANDIDATE list. Pass
|
|
71
|
+
// the region's available sizes (sizesForRegion) — NOT the whole catalog — so we
|
|
72
|
+
// never match a size that isn't offered in the chosen region. (A provider can have
|
|
73
|
+
// several sizes with identical specs across region-specific lines, e.g. Hetzner's
|
|
74
|
+
// EU-only `cx33` vs the global `cpx31`, both 4 vCPU / 8 GB; matching globally can
|
|
75
|
+
// pick one the region doesn't sell, which then gets dropped.) This is the basis
|
|
76
|
+
// for carrying a chosen spec across providers when switching. Returns null only
|
|
77
|
+
// when the candidate list is empty.
|
|
78
|
+
export function matchSizeBySpec(sizes: ProviderSize[], vcpus: number | null, memoryMb: number | null): string | null {
|
|
79
|
+
if (sizes.length === 0) return null
|
|
80
|
+
const byCpu = vcpus != null ? sizes.filter((s) => s.vcpus === vcpus) : []
|
|
81
|
+
const pool = byCpu.length ? byCpu : sizes
|
|
82
|
+
if (memoryMb == null) return pool[0].slug
|
|
83
|
+
let best = pool[0]
|
|
84
|
+
let bestDiff = Infinity
|
|
85
|
+
for (const s of pool) {
|
|
86
|
+
const diff = Math.abs(s.memory - memoryMb)
|
|
87
|
+
if (diff < bestDiff) {
|
|
88
|
+
bestDiff = diff
|
|
89
|
+
best = s
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return best.slug
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Convenience: match a Server's display `size` string ("8 GB / 4 vCPUs") against a
|
|
96
|
+
// candidate list.
|
|
97
|
+
export function matchSizeSlug(sizes: ProviderSize[], display: string | null | undefined): string | null {
|
|
98
|
+
const { vcpus, memoryMb } = parseSizeSpec(display)
|
|
99
|
+
return matchSizeBySpec(sizes, vcpus, memoryMb)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The sizes a given region offers, as full size objects, in catalog order.
|
|
103
|
+
// Falls back to all sizes if the region isn't found (older/unknown region slug).
|
|
104
|
+
export function sizesForRegion(md: ProviderMetadata | undefined, regionSlug: string | null | undefined): ProviderSize[] {
|
|
105
|
+
if (!md) return []
|
|
106
|
+
const region = regionBySlug(md, regionSlug)
|
|
107
|
+
if (!region) return md.sizes
|
|
108
|
+
const allowed = new Set(region.sizes)
|
|
109
|
+
const inRegion = md.sizes.filter((s) => allowed.has(s.slug))
|
|
110
|
+
return inRegion.length ? inRegion : md.sizes
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// "2 vCPU / 4 GB / 80 GB" — memory comes back in MB.
|
|
114
|
+
export function formatSize(s: ProviderSize): string {
|
|
115
|
+
const mem = s.memory >= 1024 ? `${Math.round(s.memory / 1024)} GB` : `${s.memory} MB`
|
|
116
|
+
return `${s.vcpus} vCPU / ${mem} / ${s.disk} GB`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// "$8.49/mo" (+ backups when enabled and priced).
|
|
120
|
+
export function formatCost(s: ProviderSize | undefined, backups = false): string {
|
|
121
|
+
if (!s || typeof s.priceMonthly !== "number") return "—"
|
|
122
|
+
let total = s.priceMonthly
|
|
123
|
+
if (backups && typeof s.backupPriceMonthly === "number") total += s.backupPriceMonthly
|
|
124
|
+
return `$${total.toFixed(2)}/mo`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse a name into (prefix, number, suffix) when it has a numbered component,
|
|
128
|
+
// e.g. "web11.example.com" → ["web", 11, ".example.com"]. Null when unnumbered.
|
|
129
|
+
function parseNumbered(name: string): { prefix: string; num: number; suffix: string } | null {
|
|
130
|
+
const m = /^(.*?)(\d+)(\D.*)?$/.exec(name ?? "")
|
|
131
|
+
if (!m) return null
|
|
132
|
+
return { prefix: m[1], num: Number(m[2]), suffix: m[3] ?? "" }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Suggest the next server name from the fleet's naming convention. Detects a
|
|
136
|
+
// numbered pattern (prefix + number + optional suffix, e.g. "web11.example.com")
|
|
137
|
+
// and proposes the next in the dominant series ("web12.example.com"). Returns ""
|
|
138
|
+
// when no numbered convention is detectable — the user names it themselves.
|
|
139
|
+
export function suggestServerName(servers: Server[]): string {
|
|
140
|
+
// Group numbered names by (prefix, suffix); track the count + the max number.
|
|
141
|
+
const groups = new Map<string, { prefix: string; suffix: string; max: number; count: number }>()
|
|
142
|
+
for (const s of servers) {
|
|
143
|
+
const p = parseNumbered(s.name ?? "")
|
|
144
|
+
if (!p) continue
|
|
145
|
+
const key = JSON.stringify([p.prefix, p.suffix]) // unambiguous composite key
|
|
146
|
+
const g = groups.get(key)
|
|
147
|
+
if (!g) groups.set(key, { prefix: p.prefix, suffix: p.suffix, max: p.num, count: 1 })
|
|
148
|
+
else {
|
|
149
|
+
g.max = Math.max(g.max, p.num)
|
|
150
|
+
g.count += 1
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (groups.size === 0) return ""
|
|
154
|
+
// Prefer the convention used by the most servers; break ties by higher number.
|
|
155
|
+
let best: { prefix: string; suffix: string; max: number; count: number } | null = null
|
|
156
|
+
for (const g of groups.values()) {
|
|
157
|
+
if (!best || g.count > best.count || (g.count === best.count && g.max > best.max)) best = g
|
|
158
|
+
}
|
|
159
|
+
if (!best) return ""
|
|
160
|
+
return `${best.prefix}${best.max + 1}${best.suffix}`
|
|
161
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The site doctor (site monitoring Phase 2) — an on-demand, READ-ONLY diagnosis
|
|
2
|
+
// of the "200 OK but wrong page" failure class. Pure HTTP: no SSH, no Kuma, no
|
|
3
|
+
// writes, so it works on any site with zero setup.
|
|
4
|
+
//
|
|
5
|
+
// The centerpiece is the cache differential. SpinupWP's nginx page cache
|
|
6
|
+
// answers with `fastcgi-cache: HIT|MISS|BYPASS` and honors the default
|
|
7
|
+
// `wordpress_no_cache` cookie exclusion (both verified live on production
|
|
8
|
+
// boxes, and the header set is what the spinupwp plugin registers with WP
|
|
9
|
+
// core's Site Health page-cache test). So the doctor fetches the page twice —
|
|
10
|
+
// once normally (cache-eligible) and once with the bypass cookie (same URL,
|
|
11
|
+
// only the cache layer differs) — and compares WP's body_class() template
|
|
12
|
+
// tokens between the two. A mismatch is PROOF the cache is serving a different
|
|
13
|
+
// template than PHP renders right now: the purge-fixable condition, diagnosed
|
|
14
|
+
// rather than guessed. The headers tell us which layer answered each request,
|
|
15
|
+
// so the differential asserts its own validity instead of assuming it.
|
|
16
|
+
//
|
|
17
|
+
// Verdict precedence: down > partial-outage > stale-cache > recalibrate >
|
|
18
|
+
// healthy, with "inconclusive" when the evidence doesn't support any of them.
|
|
19
|
+
// The doctor ends at diagnosis + a copyable runbook — deliberately no auto-heal
|
|
20
|
+
// (the operator does the surgery; builder-specific lines appear only on
|
|
21
|
+
// positive evidence in the served markup).
|
|
22
|
+
|
|
23
|
+
import { bodyClassOf } from "./siteFingerprint.ts"
|
|
24
|
+
|
|
25
|
+
export interface DoctorCheck {
|
|
26
|
+
label: string
|
|
27
|
+
status: "ok" | "warn" | "bad" | "info"
|
|
28
|
+
detail: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// "partial-outage" is the nastiest verdict: cached pages serve 200 while fresh
|
|
32
|
+
// renders (or the wp-admin door) throw 5xx — anonymous visitors look fine, but
|
|
33
|
+
// admins, logged-in users and everything uncached is failing. Verified real:
|
|
34
|
+
// SpinupWP's default object-cache drop-in makes a dead Redis FATAL on every
|
|
35
|
+
// page-cache miss, and a plugin/theme fatal produces the same signature.
|
|
36
|
+
// Page-watching monitors sleep straight through it; this is where the doctor
|
|
37
|
+
// catches it.
|
|
38
|
+
export type DoctorVerdict = "healthy" | "stale-cache" | "recalibrate" | "partial-outage" | "down" | "inconclusive"
|
|
39
|
+
|
|
40
|
+
export interface DoctorReport {
|
|
41
|
+
verdict: DoctorVerdict
|
|
42
|
+
summary: string
|
|
43
|
+
checks: DoctorCheck[]
|
|
44
|
+
runbook: string[] // shell lines (plus comments) when something is fixable; empty otherwise
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Probe {
|
|
48
|
+
ok: boolean
|
|
49
|
+
status: number
|
|
50
|
+
ms: number
|
|
51
|
+
html: string
|
|
52
|
+
cache: string | null // fastcgi-cache header, uppercased
|
|
53
|
+
bypassReason: string | null
|
|
54
|
+
error?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FETCH_TIMEOUT_MS = 20_000
|
|
58
|
+
const MAX_HTML_BYTES = 512 * 1024
|
|
59
|
+
|
|
60
|
+
// The body_class() tokens that identify WHICH template won — the set the
|
|
61
|
+
// differential compares. Cosmetic/theme classes are deliberately excluded so a
|
|
62
|
+
// plugin adding a class doesn't read as a template change.
|
|
63
|
+
const TEMPLATE_TOKEN =
|
|
64
|
+
/^(home|blog|front-page|search|search-results|search-no-results|error404|single|singular|page|archive|category|tag|author|date|attachment|page-id-\d+|postid-\d+|single-[a-z0-9_-]+)$/
|
|
65
|
+
|
|
66
|
+
async function probe(url: string, cookie?: string): Promise<Probe> {
|
|
67
|
+
const started = performance.now()
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(url, {
|
|
70
|
+
redirect: "follow",
|
|
71
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
72
|
+
headers: {
|
|
73
|
+
"user-agent": "spinup-tui (site doctor)",
|
|
74
|
+
"cache-control": "no-store",
|
|
75
|
+
...(cookie ? { cookie } : {}),
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
const html = (await res.text()).slice(0, MAX_HTML_BYTES)
|
|
79
|
+
return {
|
|
80
|
+
ok: res.ok,
|
|
81
|
+
status: res.status,
|
|
82
|
+
ms: Math.round(performance.now() - started),
|
|
83
|
+
html,
|
|
84
|
+
cache: res.headers.get("fastcgi-cache")?.toUpperCase() ?? null,
|
|
85
|
+
bypassReason: res.headers.get("fastcgi-cache-bypass-reason"),
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return { ok: false, status: 0, ms: Math.round(performance.now() - started), html: "", cache: null, bypassReason: null, error: (err as Error).message }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function templateTokens(html: string): string[] | null {
|
|
93
|
+
const cls = bodyClassOf(html)
|
|
94
|
+
if (!cls) return null
|
|
95
|
+
const tokens = cls.tokens.filter((t) => TEMPLATE_TOKEN.test(t)).sort()
|
|
96
|
+
return tokens.length > 0 ? tokens : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function runSiteDoctor(opts: {
|
|
100
|
+
url: string
|
|
101
|
+
expectedKeyword?: string | null // the calibrated fingerprint, when one exists
|
|
102
|
+
pageCacheEnabled?: boolean | null // from the SpinupWP API; null = unknown
|
|
103
|
+
sshTarget?: string | null // "site_user@host" for the runbook's ssh line
|
|
104
|
+
isWordPress?: boolean // enables the wp-login.php door probe
|
|
105
|
+
loginPath?: string // where the login lives (Bedrock relocates it to /wp/wp-login.php)
|
|
106
|
+
}): Promise<DoctorReport> {
|
|
107
|
+
const checks: DoctorCheck[] = []
|
|
108
|
+
const runbook: string[] = []
|
|
109
|
+
|
|
110
|
+
const partialOutageRunbook = (why: string) => {
|
|
111
|
+
if (opts.sshTarget) runbook.push(`ssh ${opts.sshTarget}`)
|
|
112
|
+
runbook.push("tail -n 50 ~/logs/*error*.log # the fatal will be here")
|
|
113
|
+
runbook.push(`# ${why}`)
|
|
114
|
+
runbook.push("# front pages are cached, so visitors look fine — admins and logged-in users are not")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const cached = await probe(opts.url)
|
|
118
|
+
if (!cached.ok) {
|
|
119
|
+
checks.push({ label: "reachability", status: "bad", detail: cached.error ?? `answered ${cached.status}` })
|
|
120
|
+
return { verdict: "down", summary: "The site isn't answering — this isn't a cache problem.", checks, runbook }
|
|
121
|
+
}
|
|
122
|
+
checks.push({ label: "reachability", status: "ok", detail: `200 in ${cached.ms}ms` })
|
|
123
|
+
|
|
124
|
+
const fresh = await probe(opts.url, "wordpress_no_cache=1")
|
|
125
|
+
|
|
126
|
+
// What layer served the plain request?
|
|
127
|
+
if (cached.cache === "HIT") {
|
|
128
|
+
checks.push({ label: "page cache", status: "ok", detail: "served from the page cache (HIT)" })
|
|
129
|
+
} else if (cached.cache) {
|
|
130
|
+
checks.push({ label: "page cache", status: "info", detail: `${cached.cache} — freshly rendered, nothing stale to compare` })
|
|
131
|
+
} else if (opts.pageCacheEnabled === false) {
|
|
132
|
+
checks.push({ label: "page cache", status: "info", detail: "disabled for this site (per SpinupWP)" })
|
|
133
|
+
} else {
|
|
134
|
+
checks.push({ label: "page cache", status: "warn", detail: "no fastcgi-cache header — cache layer not verifiable here" })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Did the bypass actually bypass? The header proves it either way. With the
|
|
138
|
+
// page cache off, every render is fresh and there's nothing to bypass — that
|
|
139
|
+
// is expected state, not a warning.
|
|
140
|
+
const cacheOff = opts.pageCacheEnabled === false && !cached.cache
|
|
141
|
+
const bypassConfirmed = fresh.ok && fresh.cache === "BYPASS"
|
|
142
|
+
if (bypassConfirmed) {
|
|
143
|
+
checks.push({ label: "bypass", status: "ok", detail: `fresh render confirmed (BYPASS: ${fresh.bypassReason ?? "cookie"}) in ${fresh.ms}ms` })
|
|
144
|
+
} else if (cacheOff && fresh.ok) {
|
|
145
|
+
checks.push({ label: "bypass", status: "info", detail: "page cache off — every render is already fresh" })
|
|
146
|
+
} else if (fresh.ok) {
|
|
147
|
+
checks.push({ label: "bypass", status: "warn", detail: `couldn't confirm a fresh render (${fresh.cache ?? "no cache header"}) — differential unverified` })
|
|
148
|
+
} else if (fresh.status >= 500 || fresh.status === 0) {
|
|
149
|
+
// Cached 200 + fresh 5xx IS the partial outage (verified live: a dead Redis
|
|
150
|
+
// is fatal on cache misses with SpinupWP's default drop-in; a plugin/theme
|
|
151
|
+
// fatal looks identical). Nothing further is measurable — diagnose and stop.
|
|
152
|
+
checks.push({ label: "fresh render", status: "bad", detail: fresh.error ?? `PHP fails when the cache is bypassed (HTTP ${fresh.status})` })
|
|
153
|
+
partialOutageRunbook("if the server's redis monitor is red, Redis is the cause; otherwise it's a PHP fatal")
|
|
154
|
+
return {
|
|
155
|
+
verdict: "partial-outage",
|
|
156
|
+
summary: "Cached pages serve fine, but fresh renders are failing — a partial outage the cached-page monitors can't see.",
|
|
157
|
+
checks,
|
|
158
|
+
runbook,
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
checks.push({ label: "bypass", status: "warn", detail: `bypass request answered ${fresh.status} — differential unverified` })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The differential: cached template identity vs freshly rendered identity.
|
|
165
|
+
const cachedTokens = templateTokens(cached.html)
|
|
166
|
+
const freshTokens = fresh.ok ? templateTokens(fresh.html) : null
|
|
167
|
+
let staleCache = false
|
|
168
|
+
if (cached.cache === "HIT" && bypassConfirmed && cachedTokens && freshTokens) {
|
|
169
|
+
if (cachedTokens.join(" ") === freshTokens.join(" ")) {
|
|
170
|
+
checks.push({ label: "template", status: "ok", detail: `cached and fresh renders agree (${freshTokens.slice(0, 3).join(" ")})` })
|
|
171
|
+
} else {
|
|
172
|
+
staleCache = true
|
|
173
|
+
checks.push({ label: "template", status: "bad", detail: `cached says “${cachedTokens.join(" ")}” but PHP renders “${freshTokens.join(" ")}”` })
|
|
174
|
+
}
|
|
175
|
+
} else if (cachedTokens && freshTokens) {
|
|
176
|
+
checks.push({ label: "template", status: "info", detail: `both renders show “${freshTokens.slice(0, 3).join(" ")}” (differential not provable this pass)` })
|
|
177
|
+
} else {
|
|
178
|
+
checks.push({ label: "template", status: "info", detail: "no body_class() tokens to compare — theme doesn't emit them" })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The calibrated fingerprint, when the site has one.
|
|
182
|
+
let recalibrate = false
|
|
183
|
+
if (opts.expectedKeyword) {
|
|
184
|
+
const inCached = cached.html.includes(opts.expectedKeyword)
|
|
185
|
+
const inFresh = fresh.ok ? fresh.html.includes(opts.expectedKeyword) : null
|
|
186
|
+
if (inCached) {
|
|
187
|
+
checks.push({ label: "fingerprint", status: "ok", detail: "calibrated fingerprint present on the live page" })
|
|
188
|
+
} else if (inFresh) {
|
|
189
|
+
staleCache = true
|
|
190
|
+
checks.push({ label: "fingerprint", status: "bad", detail: "missing from the cached page but present in a fresh render" })
|
|
191
|
+
} else {
|
|
192
|
+
recalibrate = true
|
|
193
|
+
checks.push({ label: "fingerprint", status: "warn", detail: "gone from the live site entirely — the page changed, not the cache" })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The wp-admin door. A fatal confined to admin-facing code produces "site
|
|
198
|
+
// serves fine, wp-admin shows the critical-error screen" — a real incident
|
|
199
|
+
// shape. /wp-login.php is never page-cached (default `wp-.*.php` path
|
|
200
|
+
// exclusion) and renders through a full WP load, so a 5xx here is unambiguous.
|
|
201
|
+
// Anything else non-200 (basic auth, relocated login, hardening) is normal
|
|
202
|
+
// protection — report it, judge nothing.
|
|
203
|
+
let loginFatal = false
|
|
204
|
+
if (opts.isWordPress) {
|
|
205
|
+
let loginUrl: string | null = null
|
|
206
|
+
try {
|
|
207
|
+
loginUrl = new URL(opts.loginPath ?? "/wp-login.php", opts.url).toString()
|
|
208
|
+
} catch {
|
|
209
|
+
// Unparseable base URL — skip the probe.
|
|
210
|
+
}
|
|
211
|
+
if (loginUrl) {
|
|
212
|
+
const login = await probe(loginUrl)
|
|
213
|
+
if (login.ok) {
|
|
214
|
+
checks.push({ label: "wp-admin door", status: "ok", detail: `login page renders (${login.ms}ms)` })
|
|
215
|
+
} else if (login.status >= 500) {
|
|
216
|
+
loginFatal = true
|
|
217
|
+
checks.push({ label: "wp-admin door", status: "bad", detail: `login page throws HTTP ${login.status} — admin-facing fatal` })
|
|
218
|
+
} else if (login.status > 0) {
|
|
219
|
+
checks.push({ label: "wp-admin door", status: "info", detail: `answered ${login.status} — protected or relocated; can't judge` })
|
|
220
|
+
} else {
|
|
221
|
+
checks.push({ label: "wp-admin door", status: "warn", detail: login.error ?? "login probe failed" })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (fresh.ok) {
|
|
227
|
+
checks.push({
|
|
228
|
+
label: "timing",
|
|
229
|
+
status: "info",
|
|
230
|
+
detail: cached.cache === "HIT" ? `cached ${cached.ms}ms · uncached ${fresh.ms}ms` : `renders: ${cached.ms}ms · ${fresh.ms}ms`,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (loginFatal) {
|
|
235
|
+
partialOutageRunbook("the front end renders fine — the fatal is in admin-facing code (a plugin's admin hooks, most likely)")
|
|
236
|
+
return {
|
|
237
|
+
verdict: "partial-outage",
|
|
238
|
+
summary: "The site serves fine, but wp-admin is throwing a fatal — visitors won't notice; you can't log in.",
|
|
239
|
+
checks,
|
|
240
|
+
runbook,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (staleCache) {
|
|
244
|
+
// Builder-specific runbook lines only on positive evidence in the markup.
|
|
245
|
+
const elementor = /wp-content\/plugins\/elementor\/|elementor-kit-\d/.test(fresh.ok ? fresh.html : cached.html)
|
|
246
|
+
if (opts.sshTarget) runbook.push(`ssh ${opts.sshTarget}`)
|
|
247
|
+
if (elementor) runbook.push("wp elementor flush_css # elementor detected — this also purges SpinupWP's caches")
|
|
248
|
+
else runbook.push("wp spinupwp cache purge-site")
|
|
249
|
+
runbook.push("# or: press P on this site (purge via API), then re-run d")
|
|
250
|
+
return { verdict: "stale-cache", summary: "The page cache is serving a different page than PHP renders — purge it.", checks, runbook }
|
|
251
|
+
}
|
|
252
|
+
if (recalibrate) {
|
|
253
|
+
return { verdict: "recalibrate", summary: "The site renders fine but no longer matches its calibrated fingerprint — press f to recalibrate.", checks, runbook }
|
|
254
|
+
}
|
|
255
|
+
if (cachedTokens || opts.expectedKeyword) {
|
|
256
|
+
const proved = cached.cache === "HIT" && bypassConfirmed
|
|
257
|
+
return { verdict: "healthy", summary: proved ? "Serving the right page, and the cache agrees with a fresh render." : "Serving the right page.", checks, runbook }
|
|
258
|
+
}
|
|
259
|
+
return { verdict: "inconclusive", summary: "Reachable, but there's no template identity or fingerprint to judge against.", checks, runbook }
|
|
260
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Derive a template-identity fingerprint from a site's LIVE front page — the
|
|
2
|
+
// calibration step behind the front-page Kuma monitor (site monitoring Phase 1).
|
|
3
|
+
//
|
|
4
|
+
// Why not a hand-picked headline keyword: content is the one thing a site owner
|
|
5
|
+
// is allowed to change, so a headline monitor breaks the day someone edits the
|
|
6
|
+
// copy. WordPress instead stamps the winning template into body_class() on every
|
|
7
|
+
// request (`home` / `page-id-N` / `front-page` vs `search` / `error404`), so a
|
|
8
|
+
// template-derived token catches "the page cache is serving the wrong page" —
|
|
9
|
+
// verified live 2026-07-04: a page-cache incident served `class="search …"`
|
|
10
|
+
// where `class="home …"` belonged, with HTTP 200 the whole time.
|
|
11
|
+
//
|
|
12
|
+
// The candidate ladder (most → least distinctive) is read from what the page
|
|
13
|
+
// ACTUALLY serves right now — the page is presumed healthy at calibration time:
|
|
14
|
+
// 1. body class starting with `home`/`blog` → `class="home` (prefix-anchored,
|
|
15
|
+
// can't false-match in prose)
|
|
16
|
+
// 2. a `page-id-N` token → `page-id-N`
|
|
17
|
+
// 3. a `front-page` token → `front-page`
|
|
18
|
+
// 4. the canonical <link> tag → its exact text
|
|
19
|
+
// Non-WP sites and body_class-less themes fall down the ladder naturally
|
|
20
|
+
// (canonical), and a page with none of these reports WHY instead of registering
|
|
21
|
+
// a junk monitor.
|
|
22
|
+
//
|
|
23
|
+
// Validation: a throwaway search URL (`?s=…` — bypasses the page cache and, on
|
|
24
|
+
// WP, renders a different template) is fetched and the chosen keyword must be
|
|
25
|
+
// ABSENT there; a keyword that also matches the search template couldn't
|
|
26
|
+
// discriminate. Sites that ignore `?s=` (serve the same page back) make the
|
|
27
|
+
// probe inconclusive — the fingerprint is then accepted unvalidated, with a note.
|
|
28
|
+
|
|
29
|
+
export interface Fingerprint {
|
|
30
|
+
kind: "body-class" | "canonical"
|
|
31
|
+
keyword: string // the literal substring the Kuma keyword monitor asserts
|
|
32
|
+
detail: string // human-readable description for the overlay / done message
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DeriveResult = { ok: true; fingerprint: Fingerprint; validated: boolean; note?: string } | { ok: false; error: string }
|
|
36
|
+
|
|
37
|
+
const FETCH_TIMEOUT_MS = 20_000
|
|
38
|
+
// The body tag and canonical link live near the top of the document; capping the
|
|
39
|
+
// read keeps a pathological page from ballooning memory.
|
|
40
|
+
const MAX_HTML_BYTES = 512 * 1024
|
|
41
|
+
|
|
42
|
+
async function fetchHtml(url: string): Promise<{ ok: true; html: string } | { ok: false; error: string }> {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
redirect: "follow",
|
|
46
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
47
|
+
headers: { "user-agent": "spinup-tui (front-page fingerprint calibration)" },
|
|
48
|
+
})
|
|
49
|
+
if (!res.ok) return { ok: false, error: `The page answered ${res.status} — calibrate while it's healthy.` }
|
|
50
|
+
return { ok: true, html: (await res.text()).slice(0, MAX_HTML_BYTES) }
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { ok: false, error: `Couldn't fetch ${url}: ${(err as Error).message}` }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function bodyClassOf(html: string): { quote: string; tokens: string[] } | null {
|
|
57
|
+
const body = html.match(/<body[^>]*>/i)?.[0]
|
|
58
|
+
if (!body) return null
|
|
59
|
+
const cls = body.match(/class\s*=\s*(?:"([^"]*)"|'([^']*)')/i)
|
|
60
|
+
if (!cls) return null
|
|
61
|
+
const value = (cls[1] ?? cls[2] ?? "").trim()
|
|
62
|
+
if (!value) return null
|
|
63
|
+
return { quote: cls[1] != null ? '"' : "'", tokens: value.split(/\s+/) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function candidatesFrom(html: string): Fingerprint[] {
|
|
67
|
+
const out: Fingerprint[] = []
|
|
68
|
+
const cls = bodyClassOf(html)
|
|
69
|
+
if (cls) {
|
|
70
|
+
const first = cls.tokens[0]
|
|
71
|
+
if (first === "home" || first === "blog") {
|
|
72
|
+
out.push({ kind: "body-class", keyword: `class=${cls.quote}${first}`, detail: `body class “${first}”` })
|
|
73
|
+
}
|
|
74
|
+
const pageId = cls.tokens.find((t) => /^page-id-\d+$/.test(t))
|
|
75
|
+
if (pageId) out.push({ kind: "body-class", keyword: pageId, detail: `body class “${pageId}”` })
|
|
76
|
+
if (cls.tokens.includes("front-page")) out.push({ kind: "body-class", keyword: "front-page", detail: "body class “front-page”" })
|
|
77
|
+
}
|
|
78
|
+
const canonical = html.match(/<link[^>]*rel=["']canonical["'][^>]*>/i)?.[0]
|
|
79
|
+
// A canonical tag is only a usable keyword when it's a sane length (some SEO
|
|
80
|
+
// plugins emit data-bloated tags that would make a brittle, unreadable match).
|
|
81
|
+
if (canonical && canonical.length <= 300) out.push({ kind: "canonical", keyword: canonical, detail: "canonical link tag" })
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function deriveFingerprint(homeUrl: string): Promise<DeriveResult> {
|
|
86
|
+
const home = await fetchHtml(homeUrl)
|
|
87
|
+
if (!home.ok) return { ok: false, error: home.error }
|
|
88
|
+
const candidates = candidatesFrom(home.html)
|
|
89
|
+
if (candidates.length === 0) {
|
|
90
|
+
return { ok: false, error: "No template fingerprint on the front page (no WP body classes or canonical link) — this site would need a hand-made keyword monitor in Kuma." }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sep = homeUrl.includes("?") ? "&" : "?"
|
|
94
|
+
const probe = await fetchHtml(`${homeUrl}${sep}s=spinup-fingerprint-probe`)
|
|
95
|
+
if (!probe.ok) return { ok: true, fingerprint: candidates[0]!, validated: false, note: "comparison probe unreachable — fingerprint unvalidated" }
|
|
96
|
+
|
|
97
|
+
const homeCls = bodyClassOf(home.html)
|
|
98
|
+
const probeCls = bodyClassOf(probe.html)
|
|
99
|
+
if (homeCls && probeCls && homeCls.tokens.join(" ") === probeCls.tokens.join(" ")) {
|
|
100
|
+
// The site serves the same page for `?s=` (no search feature) — the probe
|
|
101
|
+
// can't tell a distinctive keyword from a useless one.
|
|
102
|
+
return { ok: true, fingerprint: candidates[0]!, validated: false, note: "site ignores search URLs — fingerprint unvalidated" }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const distinct = candidates.find((c) => !probe.html.includes(c.keyword))
|
|
106
|
+
if (distinct) return { ok: true, fingerprint: distinct, validated: true }
|
|
107
|
+
return { ok: false, error: "Every candidate fingerprint also appears on a non-front page — nothing distinctive to monitor." }
|
|
108
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Deep links into the SpinupWP web app.
|
|
2
|
+
//
|
|
3
|
+
// A recurring pattern: when the API can't *act* on something (e.g. it exposes
|
|
4
|
+
// `upgrade_required` but has no upgrade endpoint), give the user a one-keystroke
|
|
5
|
+
// jump to the exact page in SpinupWP where they can. URLs are account-scoped:
|
|
6
|
+
//
|
|
7
|
+
// https://spinupwp.app/{accountSlug}/servers/{id}
|
|
8
|
+
// https://spinupwp.app/{accountSlug}/sites/{id}
|
|
9
|
+
//
|
|
10
|
+
// The API doesn't expose the account slug, so it comes from config. Without it
|
|
11
|
+
// we fall back to the dashboard root rather than guessing a broken URL.
|
|
12
|
+
|
|
13
|
+
const WEB_BASE = "https://spinupwp.app"
|
|
14
|
+
|
|
15
|
+
export function serverWebUrl(id: number, accountSlug: string | null): string {
|
|
16
|
+
return accountSlug ? `${WEB_BASE}/${accountSlug}/servers/${id}` : WEB_BASE
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function siteWebUrl(id: number, accountSlug: string | null): string {
|
|
20
|
+
return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}` : WEB_BASE
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Account Settings → Server Providers (where the server_provider ids live).
|
|
24
|
+
export function serverProvidersSettingsUrl(accountSlug: string | null): string {
|
|
25
|
+
return accountSlug ? `${WEB_BASE}/${accountSlug}/settings#server-providers` : WEB_BASE
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A site's SFTP & SSH section — where you add an SSH key to the SITE USER so
|
|
29
|
+
// Spinup can connect to that site (the vanity flow deep-links here for the manual
|
|
30
|
+
// key step). The key is per-site, not a server sudo user.
|
|
31
|
+
export function siteSftpUrl(id: number, accountSlug: string | null): string {
|
|
32
|
+
return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#sftp` : WEB_BASE
|
|
33
|
+
}
|