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,571 @@
|
|
|
1
|
+
// Site monitoring overlay — opened with `m` in the sites pane.
|
|
2
|
+
//
|
|
3
|
+
// The monitor screen is the default; the Kuma connect form is opt-in (`c`, or
|
|
4
|
+
// pressing `a` while unconnected), so the vanity-page refresh works with no
|
|
5
|
+
// Uptime Kuma at all:
|
|
6
|
+
// - `R` on a vanity site (domain = server name) re-publishes the embedded page
|
|
7
|
+
// — the upgrade path for pages seeded by older Spinup versions. Without a
|
|
8
|
+
// Kuma connection that's ALL it does; with one it also registers the healthz
|
|
9
|
+
// + load push monitors and installs the heartbeat cron.
|
|
10
|
+
// - `a` registers monitors for this site (vanity: healthz + load push + cron;
|
|
11
|
+
// regular site: homepage monitor only — client site files are never touched).
|
|
12
|
+
// - `f` (regular sites) calibrates the front-page check: reads the live front
|
|
13
|
+
// page, derives a template fingerprint (body class / canonical — survives
|
|
14
|
+
// copy edits), and registers a Kuma keyword monitor asserting it at a chosen
|
|
15
|
+
// window. Catches "the cache is serving the wrong page" (HTTP stays 200).
|
|
16
|
+
// Re-running recalibrates in place (monitor history survives a redesign).
|
|
17
|
+
// - `n` (alerts) lists Kuma's notification providers by name (Telegram, email,
|
|
18
|
+
// …) and toggles them per site across all of the site's Spinup monitors —
|
|
19
|
+
// detection is real (providers ride the post-login event burst), only the
|
|
20
|
+
// provider's own setup (bot token etc.) stays in Kuma's settings.
|
|
21
|
+
// - `r` (vanity, confirm-gated) rotates the monitoring secrets: new push token
|
|
22
|
+
// edited into the existing monitor (history kept), cron rewritten, new health
|
|
23
|
+
// key re-seeded — so secrets shown on a screencast can be killed right after.
|
|
24
|
+
// - The connect form verifies by actually logging in before anything persists;
|
|
25
|
+
// the minted JWT is stored so later sessions (and 2FA accounts) log in by
|
|
26
|
+
// token. Env-sourced connections (SPINUP_KUMA_*) never see the form.
|
|
27
|
+
|
|
28
|
+
import { useEffect, useState } from "react"
|
|
29
|
+
import { useKeyboard } from "@opentui/react"
|
|
30
|
+
import { theme } from "../../lib/theme.ts"
|
|
31
|
+
import { Panel, Centered, Field, SecretInput, Spinner } from "../components.tsx"
|
|
32
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
33
|
+
import { useStore, type KumaAlertProvider } from "../store.tsx"
|
|
34
|
+
import { isVanityPair } from "../../lib/vanitySite.ts"
|
|
35
|
+
import { runSiteDoctor, type DoctorReport } from "../../lib/siteDoctor.ts"
|
|
36
|
+
import { classifyStack } from "../../lib/stack.ts"
|
|
37
|
+
|
|
38
|
+
const BASE_FIELDS = ["url", "username", "password"] as const
|
|
39
|
+
// Fixed input width: the panel body is 66 wide, the label gutter is 12 ("❯ " +
|
|
40
|
+
// padEnd(10)), so 52 fills the row — roomy enough that long URLs stay visible.
|
|
41
|
+
const INPUT_W = 52
|
|
42
|
+
|
|
43
|
+
// Check windows for the front-page fingerprint monitor. The check is one GET
|
|
44
|
+
// served straight from nginx's page cache (no PHP), so even 5m costs the site
|
|
45
|
+
// nothing — the window is really "how long a wrong page may go unnoticed".
|
|
46
|
+
const FP_WINDOWS = [
|
|
47
|
+
{ label: "5m", sec: 300 },
|
|
48
|
+
{ label: "15m", sec: 900 },
|
|
49
|
+
{ label: "30m", sec: 1800 },
|
|
50
|
+
{ label: "1h", sec: 3600 },
|
|
51
|
+
] as const
|
|
52
|
+
|
|
53
|
+
// The `n` (alerts) step's state machine: load → list providers → toggle.
|
|
54
|
+
type AlertsState =
|
|
55
|
+
| null
|
|
56
|
+
| { kind: "loading" }
|
|
57
|
+
| { kind: "error"; error: string }
|
|
58
|
+
| { kind: "ready"; providers: KumaAlertProvider[]; monitorIds: number[]; idx: number; busy: boolean; flash: string | null }
|
|
59
|
+
|
|
60
|
+
export function KumaSite() {
|
|
61
|
+
const { kumaSite: site, setKumaSite, kumaConfigured, connectKuma, kumaMonitorFor, kumaOps, startKumaSetup, startVanityReseed, startKumaRotate, startFingerprintSetup, fetchKumaAlerts, toggleKumaAlert, clearKumaOp, kumaStatus, servers, setInputMode } = useStore()
|
|
62
|
+
|
|
63
|
+
const [draft, setDraft] = useState({ url: "", username: "", password: "" })
|
|
64
|
+
const [fieldIdx, setFieldIdx] = useState(0)
|
|
65
|
+
const [busy, setBusy] = useState(false)
|
|
66
|
+
const [error, setError] = useState<string | null>(null)
|
|
67
|
+
const [connectedVersion, setConnectedVersion] = useState<string | null>(null)
|
|
68
|
+
const [showConnect, setShowConnect] = useState(false)
|
|
69
|
+
// `r` arms a confirm (rotation kills the live push URL / health key the moment
|
|
70
|
+
// it fires — never do that on a stray keypress); y/⏎ fires, Esc disarms.
|
|
71
|
+
const [confirmRotate, setConfirmRotate] = useState(false)
|
|
72
|
+
// `f` opens the front-page check-window picker; ⏎ calibrates & registers.
|
|
73
|
+
const [fpPick, setFpPick] = useState(false)
|
|
74
|
+
const [fpIdx, setFpIdx] = useState(0)
|
|
75
|
+
// `n` opens the alerts step — a live read of Kuma's notification providers and
|
|
76
|
+
// which are attached to this site's monitors; ⏎ toggles the selected one.
|
|
77
|
+
const [alerts, setAlerts] = useState<AlertsState>(null)
|
|
78
|
+
// `d` runs the doctor — read-only HTTP diagnosis (works without Kuma).
|
|
79
|
+
const [doctor, setDoctor] = useState<null | { kind: "loading" } | { kind: "ready"; report: DoctorReport }>(null)
|
|
80
|
+
// 2FA: revealed only after Kuma answers `tokenRequired` to a correct password.
|
|
81
|
+
// The code is used once — the stored JWT covers every later login.
|
|
82
|
+
const [needsTwofa, setNeedsTwofa] = useState(false)
|
|
83
|
+
const [twofa, setTwofa] = useState("")
|
|
84
|
+
const fields: string[] = needsTwofa ? [...BASE_FIELDS, "2FA code"] : [...BASE_FIELDS]
|
|
85
|
+
|
|
86
|
+
const server = site ? servers.find((s) => s.id === site.server_id) : undefined
|
|
87
|
+
const isVanity = !!site && !!server && isVanityPair(site.domain, server.name)
|
|
88
|
+
const registered = site ? kumaMonitorFor(site.domain) : null
|
|
89
|
+
const op = site ? kumaOps.get(site.id) : undefined
|
|
90
|
+
// Not gated on kumaConfigured: `c` must also work as a RE-connect (e.g. the
|
|
91
|
+
// stored JWT went stale after a Kuma password change on a 2FA account —
|
|
92
|
+
// without this the only fix would be hand-editing config.json).
|
|
93
|
+
const connecting = showConnect
|
|
94
|
+
|
|
95
|
+
// While the connect form is up, its inputs own the keyboard (suppresses the
|
|
96
|
+
// global single-key shortcuts); cleared on connect/close.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
setInputMode(connecting)
|
|
99
|
+
return () => setInputMode(false)
|
|
100
|
+
}, [connecting, setInputMode])
|
|
101
|
+
|
|
102
|
+
const runDoctor = () => {
|
|
103
|
+
if (!site) return
|
|
104
|
+
setDoctor({ kind: "loading" })
|
|
105
|
+
const proto = site.https?.enabled ? "https" : "http"
|
|
106
|
+
void runSiteDoctor({
|
|
107
|
+
url: `${proto}://${site.domain}/`,
|
|
108
|
+
expectedKeyword: registered?.fingerprint?.keyword ?? null,
|
|
109
|
+
pageCacheEnabled: site.page_cache?.enabled ?? null,
|
|
110
|
+
sshTarget: site.site_user && server ? `${site.site_user}@${server.name}` : null,
|
|
111
|
+
isWordPress: !!site.is_wordpress,
|
|
112
|
+
// Bedrock relocates the login under /wp/ — aim the door probe there.
|
|
113
|
+
loginPath: classifyStack(site) === "Bedrock" ? "/wp/wp-login.php" : "/wp-login.php",
|
|
114
|
+
}).then((report) => setDoctor((prev) => (prev ? { kind: "ready", report } : prev)))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const close = () => {
|
|
118
|
+
// A settled result was seen — forget it so the overlay opens fresh next
|
|
119
|
+
// time (a RUNNING op stays: esc deliberately backgrounds it).
|
|
120
|
+
if (site && op && op.status !== "running") clearKumaOp(site.id)
|
|
121
|
+
setInputMode(false)
|
|
122
|
+
setKumaSite(null)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const verify = () => {
|
|
126
|
+
if (busy) return
|
|
127
|
+
if (!draft.url.trim() || !draft.username.trim() || !draft.password) {
|
|
128
|
+
setError("URL, username and password are all needed.")
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (needsTwofa && !twofa.trim()) {
|
|
132
|
+
setError("Enter the current 6-digit code from your authenticator.")
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
setBusy(true)
|
|
136
|
+
setError(null)
|
|
137
|
+
void connectKuma(draft, needsTwofa ? twofa.trim() : undefined).then((r) => {
|
|
138
|
+
setBusy(false)
|
|
139
|
+
if (r.ok) {
|
|
140
|
+
setConnectedVersion(r.version ?? "connected")
|
|
141
|
+
setShowConnect(false)
|
|
142
|
+
setInputMode(false)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (r.tokenRequired && !needsTwofa) {
|
|
146
|
+
// Password was right; the account wants a TOTP. Reveal the field and
|
|
147
|
+
// land the cursor on it.
|
|
148
|
+
setNeedsTwofa(true)
|
|
149
|
+
setFieldIdx(BASE_FIELDS.length)
|
|
150
|
+
setError("2FA is on — enter the current code (needed once; a token is stored after).")
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
setError(r.error)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
useKeyboard((key) => {
|
|
158
|
+
const raw = key.name ?? ""
|
|
159
|
+
const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
|
|
160
|
+
if (connecting) {
|
|
161
|
+
// The focused <input> consumes printable keys; only navigation reaches us.
|
|
162
|
+
if (name === "escape") {
|
|
163
|
+
if (busy) return
|
|
164
|
+
return setShowConnect(false) // back to the monitor screen, not out
|
|
165
|
+
}
|
|
166
|
+
if (name === "return") {
|
|
167
|
+
if (busy) return
|
|
168
|
+
if (fieldIdx < fields.length - 1) return setFieldIdx(fieldIdx + 1)
|
|
169
|
+
return verify()
|
|
170
|
+
}
|
|
171
|
+
if (name === "tab" || name === "down") return setFieldIdx((i) => Math.min(i + 1, fields.length - 1))
|
|
172
|
+
if (name === "up") return setFieldIdx((i) => Math.max(i - 1, 0))
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (confirmRotate) {
|
|
176
|
+
if ((name === "y" || name === "return") && site) {
|
|
177
|
+
setConfirmRotate(false)
|
|
178
|
+
return startKumaRotate(site)
|
|
179
|
+
}
|
|
180
|
+
if (name === "escape" || name === "n" || name === "q") return setConfirmRotate(false)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (doctor) {
|
|
184
|
+
if (name === "escape" || name === "q") return setDoctor(null)
|
|
185
|
+
if (doctor.kind !== "ready") return
|
|
186
|
+
if (name === "d") return runDoctor()
|
|
187
|
+
if (name === "f" && !isVanity && doctor.report.verdict === "recalibrate") {
|
|
188
|
+
// The doctor's own suggestion — jump straight into recalibration.
|
|
189
|
+
setDoctor(null)
|
|
190
|
+
const storedSec = registered?.fingerprint?.interval
|
|
191
|
+
const idx = FP_WINDOWS.findIndex((w) => w.sec === storedSec)
|
|
192
|
+
setFpIdx(idx >= 0 ? idx : 0)
|
|
193
|
+
return setFpPick(true)
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (alerts) {
|
|
198
|
+
if (alerts.kind === "ready" && alerts.busy) return // a toggle is mid-flight
|
|
199
|
+
if (name === "escape" || name === "q") return setAlerts(null)
|
|
200
|
+
if (alerts.kind !== "ready") return
|
|
201
|
+
if (name === "up" || name === "k") return setAlerts({ ...alerts, idx: Math.max(alerts.idx - 1, 0), flash: null })
|
|
202
|
+
if (name === "down" || name === "j") return setAlerts({ ...alerts, idx: Math.min(alerts.idx + 1, alerts.providers.length - 1), flash: null })
|
|
203
|
+
if ((name === "return" || name === "space" || name === " ") && site && alerts.providers.length > 0) {
|
|
204
|
+
const p = alerts.providers[alerts.idx]!
|
|
205
|
+
const on = !p.attachedAll
|
|
206
|
+
setAlerts({ ...alerts, busy: true, flash: null })
|
|
207
|
+
void toggleKumaAlert(site, p.id, on, alerts.monitorIds).then((r) => {
|
|
208
|
+
setAlerts((prev) => {
|
|
209
|
+
if (!prev || prev.kind !== "ready") return prev
|
|
210
|
+
if (!r.ok) return { ...prev, busy: false, flash: `✕ ${r.error}` }
|
|
211
|
+
const providers = prev.providers.map((q) => (q.id === p.id ? { ...q, attachedAll: on, attachedAny: on } : q))
|
|
212
|
+
const n = prev.monitorIds.length
|
|
213
|
+
return { ...prev, providers, busy: false, flash: `✓ ${p.name} ${on ? "now alerts" : "no longer alerts"} for ${n} monitor${n === 1 ? "" : "s"}` }
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (fpPick) {
|
|
221
|
+
if (name === "left" || name === "up" || name === "h" || name === "k") return setFpIdx((i) => Math.max(i - 1, 0))
|
|
222
|
+
if (name === "right" || name === "down" || name === "l" || name === "j" || name === "tab") return setFpIdx((i) => Math.min(i + 1, FP_WINDOWS.length - 1))
|
|
223
|
+
if (/^[1-4]$/.test(name)) return setFpIdx(Number(name) - 1)
|
|
224
|
+
if (name === "return" && site) {
|
|
225
|
+
setFpPick(false)
|
|
226
|
+
return startFingerprintSetup(site, FP_WINDOWS[fpIdx]!.sec)
|
|
227
|
+
}
|
|
228
|
+
if (name === "escape" || name === "q") return setFpPick(false)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (name === "c") return setShowConnect(true)
|
|
232
|
+
if (name === "f" && !isVanity && site && op?.status !== "running") {
|
|
233
|
+
if (!kumaConfigured) return setShowConnect(true) // the check lives in Kuma
|
|
234
|
+
// Preselect the stored window on recalibration so ⏎⏎ keeps it.
|
|
235
|
+
const storedSec = registered?.fingerprint?.interval
|
|
236
|
+
const idx = FP_WINDOWS.findIndex((w) => w.sec === storedSec)
|
|
237
|
+
setFpIdx(idx >= 0 ? idx : 0)
|
|
238
|
+
return setFpPick(true)
|
|
239
|
+
}
|
|
240
|
+
if (name === "d" && !isVanity && site && op?.status !== "running") {
|
|
241
|
+
// Pure HTTP — deliberately NOT gated on a Kuma connection.
|
|
242
|
+
return runDoctor()
|
|
243
|
+
}
|
|
244
|
+
if (name === "n" && site && op?.status !== "running") {
|
|
245
|
+
if (!kumaConfigured) return setShowConnect(true) // alert wiring lives in Kuma
|
|
246
|
+
setAlerts({ kind: "loading" })
|
|
247
|
+
void fetchKumaAlerts(site).then((r) => {
|
|
248
|
+
setAlerts(r.ok ? { kind: "ready", providers: r.providers, monitorIds: r.monitorIds, idx: 0, busy: false, flash: null } : { kind: "error", error: r.error })
|
|
249
|
+
})
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (name === "r" && isVanity && site && op?.status !== "running") return setConfirmRotate(true)
|
|
253
|
+
if (name === "a" && site && op?.status !== "running") {
|
|
254
|
+
if (!kumaConfigured) return setShowConnect(true) // monitors need a connection
|
|
255
|
+
return startKumaSetup(site)
|
|
256
|
+
}
|
|
257
|
+
if (name === "R" && isVanity && site && op?.status !== "running") {
|
|
258
|
+
// Connected: re-publish + monitors + cron. Unconnected: just the page.
|
|
259
|
+
return kumaConfigured ? startKumaSetup(site, { reseed: true }) : startVanityReseed(site)
|
|
260
|
+
}
|
|
261
|
+
if (name === "escape" || name === "q") return close()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
if (!site) return null
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 215 }}>
|
|
268
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
269
|
+
<text content="◉ Site monitoring " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
270
|
+
<text content={site.domain} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
271
|
+
</box>
|
|
272
|
+
|
|
273
|
+
<Centered>{connecting ? renderConnect() : renderMonitor()}</Centered>
|
|
274
|
+
|
|
275
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
276
|
+
</box>
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
function renderConnect() {
|
|
280
|
+
return (
|
|
281
|
+
<Panel title=" Connect Uptime Kuma " active>
|
|
282
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
283
|
+
<text content="One-time setup: your Uptime Kuma instance's URL and login." fg={theme.textDim} wrapMode="none" />
|
|
284
|
+
<text content="Verified by logging in before anything is saved (config, 0600)." fg={theme.textFaint} wrapMode="none" />
|
|
285
|
+
<box style={{ height: 1 }} />
|
|
286
|
+
{fields.map((f, i) => (
|
|
287
|
+
<box key={f} style={{ flexDirection: "column" }}>
|
|
288
|
+
<box style={{ flexDirection: "row" }}>
|
|
289
|
+
<text content={`${i === fieldIdx ? "❯" : " "} ${f.padEnd(10)}`} fg={i === fieldIdx ? theme.brand : theme.textDim} style={{ flexShrink: 0 }} />
|
|
290
|
+
{f === "password" ? (
|
|
291
|
+
<SecretInput focused={i === fieldIdx && !busy} value={draft.password} onChange={(v: string) => setDraft((d) => ({ ...d, password: v }))} onSubmit={verify} width={INPUT_W} />
|
|
292
|
+
) : f === "2FA code" ? (
|
|
293
|
+
<input
|
|
294
|
+
focused={i === fieldIdx && !busy}
|
|
295
|
+
value={twofa}
|
|
296
|
+
onInput={setTwofa}
|
|
297
|
+
placeholder="123456"
|
|
298
|
+
style={{ width: INPUT_W, backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
299
|
+
/>
|
|
300
|
+
) : (
|
|
301
|
+
<input
|
|
302
|
+
focused={i === fieldIdx && !busy}
|
|
303
|
+
value={draft[f as "url" | "username"]}
|
|
304
|
+
onInput={(v: string) => setDraft((d) => ({ ...d, [f]: v }))}
|
|
305
|
+
placeholder={f === "url" ? "https://kuma.example.com" : ""}
|
|
306
|
+
style={{ width: INPUT_W, backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
307
|
+
/>
|
|
308
|
+
)}
|
|
309
|
+
</box>
|
|
310
|
+
{i < fields.length - 1 && <box style={{ height: 1 }} />}
|
|
311
|
+
</box>
|
|
312
|
+
))}
|
|
313
|
+
<box style={{ height: 1 }} />
|
|
314
|
+
{busy ? (
|
|
315
|
+
<box style={{ flexDirection: "row" }}>
|
|
316
|
+
<Spinner />
|
|
317
|
+
<text content=" Logging in…" fg={theme.textDim} wrapMode="none" />
|
|
318
|
+
</box>
|
|
319
|
+
) : error ? (
|
|
320
|
+
<text content={`✕ ${error}`} fg={theme.bad} wrapMode="none" />
|
|
321
|
+
) : (
|
|
322
|
+
<text content="⏎ next field / connect · Esc back" fg={theme.textFaint} wrapMode="none" />
|
|
323
|
+
)}
|
|
324
|
+
</box>
|
|
325
|
+
</Panel>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderMonitor() {
|
|
330
|
+
const fp = registered?.fingerprint
|
|
331
|
+
const fpUp = site ? (kumaStatus.get(site.domain)?.fingerprintUp ?? null) : null
|
|
332
|
+
const win = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600}h` : `${Math.round(sec / 60)}m`)
|
|
333
|
+
const fpValue = fp
|
|
334
|
+
? `${fp.detail} · every ${win(fp.interval)}${fpUp === false ? " · WRONG PAGE SERVED" : fpUp === true ? " · ok" : ""}`
|
|
335
|
+
: registered?.fingerprintId
|
|
336
|
+
? "registered (details unknown) · f recalibrates"
|
|
337
|
+
: "not calibrated · f"
|
|
338
|
+
const fpColor = fpUp === false ? theme.bad : fp ? (fpUp === true ? theme.good : theme.text) : theme.textFaint
|
|
339
|
+
return (
|
|
340
|
+
<Panel title=" Site monitoring " active>
|
|
341
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
342
|
+
{connectedVersion && (
|
|
343
|
+
<>
|
|
344
|
+
<text content={`● Uptime Kuma connected (${connectedVersion}).`} fg={theme.good} wrapMode="none" />
|
|
345
|
+
<box style={{ height: 1 }} />
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
<Field label="Site" value={site!.domain} />
|
|
349
|
+
<Field label="Kind" value={isVanity ? "vanity page (full server monitoring)" : "site homepage (up/down + cert expiry)"} />
|
|
350
|
+
<Field label="Kuma" value={kumaConfigured ? "connected · c to reconnect" : "not connected · c"} valueColor={kumaConfigured ? theme.good : theme.textFaint} />
|
|
351
|
+
<Field
|
|
352
|
+
label="Monitors"
|
|
353
|
+
value={
|
|
354
|
+
registered?.healthId
|
|
355
|
+
? `registered${registered.pushId ? " (healthz + load push)" : ""}`
|
|
356
|
+
: registered?.fingerprintId
|
|
357
|
+
? "front-page check only · a adds the up/down monitor"
|
|
358
|
+
: "not registered yet"
|
|
359
|
+
}
|
|
360
|
+
/>
|
|
361
|
+
{!isVanity && <Field label="Front page" value={fpValue} valueColor={fpColor} />}
|
|
362
|
+
<box style={{ height: 1 }} />
|
|
363
|
+
{doctor ? (
|
|
364
|
+
renderDoctor()
|
|
365
|
+
) : alerts ? (
|
|
366
|
+
renderAlerts()
|
|
367
|
+
) : fpPick ? (
|
|
368
|
+
<>
|
|
369
|
+
<text content="Front-page check — alerts when the wrong page is served." fg={theme.text} wrapMode="none" />
|
|
370
|
+
<text content="Reads the live page now (while it's healthy), derives a template" fg={theme.textDim} wrapMode="none" />
|
|
371
|
+
<text content="fingerprint (body class / canonical — survives copy edits), and" fg={theme.textDim} wrapMode="none" />
|
|
372
|
+
<text content="registers a Kuma keyword monitor asserting it." fg={theme.textDim} wrapMode="none" />
|
|
373
|
+
<box style={{ height: 1 }} />
|
|
374
|
+
<box style={{ flexDirection: "row" }}>
|
|
375
|
+
<text content="Check window:" fg={theme.textDim} style={{ flexShrink: 0 }} />
|
|
376
|
+
{FP_WINDOWS.map((w, i) => (
|
|
377
|
+
<text key={w.label} content={i === fpIdx ? ` ▸${w.label}` : ` ${w.label}`} fg={i === fpIdx ? theme.brand : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
378
|
+
))}
|
|
379
|
+
</box>
|
|
380
|
+
<box style={{ height: 1 }} />
|
|
381
|
+
<text content="←/→ or 1-4 window · ⏎ calibrate & register · esc cancel" fg={theme.textFaint} wrapMode="none" />
|
|
382
|
+
</>
|
|
383
|
+
) : confirmRotate ? (
|
|
384
|
+
<>
|
|
385
|
+
<text content="Rotate this site's monitoring secrets?" fg={theme.warn} wrapMode="none" />
|
|
386
|
+
<text content="A new push URL and health key are minted; the old ones stop" fg={theme.textDim} wrapMode="none" />
|
|
387
|
+
<text content="working immediately. Kuma monitor history is kept." fg={theme.textDim} wrapMode="none" />
|
|
388
|
+
<box style={{ height: 1 }} />
|
|
389
|
+
<text content="y / ⏎ rotate · esc cancel" fg={theme.textFaint} wrapMode="none" />
|
|
390
|
+
</>
|
|
391
|
+
) : op?.status === "running" ? (
|
|
392
|
+
<box style={{ flexDirection: "row" }}>
|
|
393
|
+
<Spinner />
|
|
394
|
+
<text content={` ${op.detail}`} fg={theme.textDim} wrapMode="none" />
|
|
395
|
+
</box>
|
|
396
|
+
) : isVanity ? (
|
|
397
|
+
<>
|
|
398
|
+
{/* A settled result shows ABOVE the actions, never instead of them —
|
|
399
|
+
the overlay must always offer its next moves. */}
|
|
400
|
+
{op?.status === "error" && (
|
|
401
|
+
<>
|
|
402
|
+
<text content={`✕ ${op.error}`} fg={theme.bad} />
|
|
403
|
+
<box style={{ height: 1 }} />
|
|
404
|
+
</>
|
|
405
|
+
)}
|
|
406
|
+
{op?.status === "done" && (
|
|
407
|
+
<>
|
|
408
|
+
<text content={`✓ ${op.detail}`} fg={theme.good} />
|
|
409
|
+
<box style={{ height: 1 }} />
|
|
410
|
+
</>
|
|
411
|
+
)}
|
|
412
|
+
<text
|
|
413
|
+
content={kumaConfigured ? "R — re-publish the page, register monitors & install the cron" : "R — re-publish the page (current version, health endpoints)"}
|
|
414
|
+
fg={theme.textDim}
|
|
415
|
+
wrapMode="none"
|
|
416
|
+
/>
|
|
417
|
+
<text
|
|
418
|
+
content={kumaConfigured ? "a — register monitors & cron without touching the page" : "a — register Kuma monitors (connects Uptime Kuma first)"}
|
|
419
|
+
fg={theme.textDim}
|
|
420
|
+
wrapMode="none"
|
|
421
|
+
/>
|
|
422
|
+
<text content="r — rotate secrets: new push URL + health key (old ones die)" fg={theme.textDim} wrapMode="none" />
|
|
423
|
+
<text content="n — alerts: choose where this server's checks notify" fg={theme.textDim} wrapMode="none" />
|
|
424
|
+
</>
|
|
425
|
+
) : (
|
|
426
|
+
<>
|
|
427
|
+
{op?.status === "error" && (
|
|
428
|
+
<>
|
|
429
|
+
<text content={`✕ ${op.error}`} fg={theme.bad} />
|
|
430
|
+
<box style={{ height: 1 }} />
|
|
431
|
+
</>
|
|
432
|
+
)}
|
|
433
|
+
{op?.status === "done" && (
|
|
434
|
+
<>
|
|
435
|
+
<text content={`✓ ${op.detail}`} fg={theme.good} />
|
|
436
|
+
<box style={{ height: 1 }} />
|
|
437
|
+
</>
|
|
438
|
+
)}
|
|
439
|
+
<text content={kumaConfigured ? "a — register a homepage monitor in Uptime Kuma" : "a — register a homepage monitor (connects Uptime Kuma first)"} fg={theme.textDim} wrapMode="none" />
|
|
440
|
+
<text
|
|
441
|
+
content={registered?.fingerprintId ? "f — recalibrate the front-page check (e.g. after a redesign)" : "f — front-page check: alert when the wrong page is served"}
|
|
442
|
+
fg={theme.textDim}
|
|
443
|
+
wrapMode="none"
|
|
444
|
+
/>
|
|
445
|
+
<text content="n — alerts: choose where this site's checks notify" fg={theme.textDim} wrapMode="none" />
|
|
446
|
+
<text content="d — doctor: prove whether the cache is serving the wrong page" fg={theme.textDim} wrapMode="none" />
|
|
447
|
+
</>
|
|
448
|
+
)}
|
|
449
|
+
</box>
|
|
450
|
+
</Panel>
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function renderDoctor() {
|
|
455
|
+
if (!doctor) return null
|
|
456
|
+
if (doctor.kind === "loading") {
|
|
457
|
+
return (
|
|
458
|
+
<box style={{ flexDirection: "row" }}>
|
|
459
|
+
<Spinner />
|
|
460
|
+
<text content=" Examining the live site (cached vs fresh render)…" fg={theme.textDim} wrapMode="none" />
|
|
461
|
+
</box>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
const r = doctor.report
|
|
465
|
+
const glyph = { ok: "✓", warn: "!", bad: "✕", info: "·" } as const
|
|
466
|
+
const gColor = { ok: theme.good, warn: theme.warn, bad: theme.bad, info: theme.textFaint } as const
|
|
467
|
+
const vColor = r.verdict === "healthy" ? theme.good : r.verdict === "stale-cache" || r.verdict === "down" || r.verdict === "partial-outage" ? theme.bad : theme.warn
|
|
468
|
+
return (
|
|
469
|
+
<>
|
|
470
|
+
{r.checks.map((c, i) => (
|
|
471
|
+
<box key={i} style={{ flexDirection: "row" }}>
|
|
472
|
+
<text content={`${glyph[c.status]} ${c.label.padEnd(13)}`} fg={gColor[c.status]} style={{ flexShrink: 0 }} />
|
|
473
|
+
<text content={c.detail} fg={c.status === "bad" ? theme.bad : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
474
|
+
</box>
|
|
475
|
+
))}
|
|
476
|
+
<box style={{ height: 1 }} />
|
|
477
|
+
<text content={r.summary} fg={vColor} wrapMode="none" />
|
|
478
|
+
{r.runbook.length > 0 && (
|
|
479
|
+
<>
|
|
480
|
+
<box style={{ height: 1 }} />
|
|
481
|
+
{r.runbook.map((line, i) => (
|
|
482
|
+
<text key={i} content={` ${line}`} fg={line.startsWith("#") ? theme.textFaint : theme.text} wrapMode="none" />
|
|
483
|
+
))}
|
|
484
|
+
</>
|
|
485
|
+
)}
|
|
486
|
+
<box style={{ height: 1 }} />
|
|
487
|
+
<text content={`d re-run${r.verdict === "recalibrate" ? " · f recalibrate" : ""} · esc back`} fg={theme.textFaint} wrapMode="none" />
|
|
488
|
+
</>
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function renderAlerts() {
|
|
493
|
+
if (!alerts) return null
|
|
494
|
+
if (alerts.kind === "loading") {
|
|
495
|
+
return (
|
|
496
|
+
<box style={{ flexDirection: "row" }}>
|
|
497
|
+
<Spinner />
|
|
498
|
+
<text content=" Reading notification providers from Kuma…" fg={theme.textDim} wrapMode="none" />
|
|
499
|
+
</box>
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
if (alerts.kind === "error") {
|
|
503
|
+
return (
|
|
504
|
+
<>
|
|
505
|
+
<text content={`✕ ${alerts.error}`} fg={theme.bad} wrapMode="none" />
|
|
506
|
+
<box style={{ height: 1 }} />
|
|
507
|
+
<text content="esc back" fg={theme.textFaint} wrapMode="none" />
|
|
508
|
+
</>
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
if (alerts.providers.length === 0) {
|
|
512
|
+
return (
|
|
513
|
+
<>
|
|
514
|
+
<text content="No notification providers in Uptime Kuma yet." fg={theme.text} wrapMode="none" />
|
|
515
|
+
<text content="Add one in Kuma → Settings → Notifications (Telegram, email, …)," fg={theme.textDim} wrapMode="none" />
|
|
516
|
+
<text content="then press n again — SpinupTUI wires it to this site's checks." fg={theme.textDim} wrapMode="none" />
|
|
517
|
+
<box style={{ height: 1 }} />
|
|
518
|
+
<text content="esc back" fg={theme.textFaint} wrapMode="none" />
|
|
519
|
+
</>
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
const n = alerts.monitorIds.length
|
|
523
|
+
return (
|
|
524
|
+
<>
|
|
525
|
+
<text content={`Alerts — where this site's ${n} check${n === 1 ? "" : "s"} notify:`} fg={theme.text} wrapMode="none" />
|
|
526
|
+
<box style={{ height: 1 }} />
|
|
527
|
+
{alerts.providers.map((p, i) => {
|
|
528
|
+
const glyph = p.attachedAll ? "✓" : p.attachedAny ? "◐" : "○"
|
|
529
|
+
const glyphColor = p.attachedAll ? theme.good : p.attachedAny ? theme.warn : theme.textFaint
|
|
530
|
+
const suffix = p.attachedAny && !p.attachedAll ? " (some monitors only — ⏎ attaches to all)" : !p.active ? " (paused in Kuma)" : ""
|
|
531
|
+
return (
|
|
532
|
+
<box key={p.id} style={{ flexDirection: "row" }}>
|
|
533
|
+
<text content={i === alerts.idx ? "❯ " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
534
|
+
<text content={glyph} fg={glyphColor} style={{ flexShrink: 0 }} />
|
|
535
|
+
<text content={` ${p.name}${suffix}`} fg={i === alerts.idx ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
536
|
+
</box>
|
|
537
|
+
)
|
|
538
|
+
})}
|
|
539
|
+
<box style={{ height: 1 }} />
|
|
540
|
+
{alerts.busy ? (
|
|
541
|
+
<box style={{ flexDirection: "row" }}>
|
|
542
|
+
<Spinner />
|
|
543
|
+
<text content=" Rewiring…" fg={theme.textDim} wrapMode="none" />
|
|
544
|
+
</box>
|
|
545
|
+
) : alerts.flash ? (
|
|
546
|
+
<text content={alerts.flash} fg={alerts.flash.startsWith("✓") ? theme.good : theme.bad} wrapMode="none" />
|
|
547
|
+
) : (
|
|
548
|
+
<text content="↑↓ select · ⏎ toggle on/off · esc done" fg={theme.textFaint} wrapMode="none" />
|
|
549
|
+
)}
|
|
550
|
+
</>
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function hints() {
|
|
555
|
+
if (connecting) return [{ key: "⏎", label: "next / connect" }, { key: "esc", label: "back" }]
|
|
556
|
+
if (confirmRotate) return [{ key: "y/⏎", label: "rotate" }, { key: "esc", label: "cancel" }]
|
|
557
|
+
if (doctor) return doctor.kind === "ready" ? [{ key: "d", label: "re-run" }, { key: "esc", label: "back" }] : [{ key: "esc", label: "back" }]
|
|
558
|
+
if (alerts) return alerts.kind === "ready" && alerts.providers.length > 0 ? [{ key: "↑↓", label: "select" }, { key: "⏎", label: "toggle" }, { key: "esc", label: "done" }] : [{ key: "esc", label: "back" }]
|
|
559
|
+
if (fpPick) return [{ key: "←/→", label: "window" }, { key: "⏎", label: "calibrate" }, { key: "esc", label: "cancel" }]
|
|
560
|
+
if (op?.status === "running") return [{ key: "esc", label: "background" }]
|
|
561
|
+
const base: { key: string; label: string }[] = []
|
|
562
|
+
if (isVanity) base.push({ key: "R", label: kumaConfigured ? "refresh page + monitors" : "refresh page" })
|
|
563
|
+
if (isVanity) base.push({ key: "r", label: "rotate secrets" })
|
|
564
|
+
base.push({ key: "a", label: registered ? "repair monitors" : "add monitors" })
|
|
565
|
+
if (!isVanity) base.push({ key: "f", label: registered?.fingerprintId ? "recalibrate front page" : "front-page check" })
|
|
566
|
+
if (!isVanity) base.push({ key: "d", label: "doctor" })
|
|
567
|
+
base.push({ key: "n", label: "alerts" })
|
|
568
|
+
base.push({ key: "c", label: kumaConfigured ? "reconnect Kuma" : "connect Kuma" })
|
|
569
|
+
return [...base, { key: "esc", label: "close" }]
|
|
570
|
+
}
|
|
571
|
+
}
|