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,551 @@
|
|
|
1
|
+
// Thin Uptime Kuma client over its socket.io management API (Kuma exposes no REST
|
|
2
|
+
// for management — only the push/badge/status-page endpoints). Event names and
|
|
3
|
+
// payloads verified against the Kuma 2.x source; everything used here exists in
|
|
4
|
+
// 1.21+ as well. Connections are short-lived: connect → login → work → disconnect.
|
|
5
|
+
//
|
|
6
|
+
// Auth: password login returns a JWT; we hand it back so callers can persist it
|
|
7
|
+
// (config.json, 0600) and reconnect via loginByToken — the JWT stays valid until
|
|
8
|
+
// the Kuma password changes, and it's what lets a 2FA-protected account work
|
|
9
|
+
// beyond its first (token-prompted) login.
|
|
10
|
+
|
|
11
|
+
import { io, type Socket } from "socket.io-client"
|
|
12
|
+
import type { KumaMonitorRef } from "../config.ts"
|
|
13
|
+
|
|
14
|
+
// The push cron sends 1-min load ×this as an INTEGER (some Kuma builds validate
|
|
15
|
+
// `ping` as an int and silently store null for floats — verified live). Every
|
|
16
|
+
// encoder (the cron line) and decoder (the store's status poll) shares this one
|
|
17
|
+
// constant so the unit contract can't drift.
|
|
18
|
+
export const LOAD_PUSH_SCALE = 100
|
|
19
|
+
|
|
20
|
+
export interface KumaCreds {
|
|
21
|
+
url: string
|
|
22
|
+
username: string
|
|
23
|
+
password: string
|
|
24
|
+
jwt?: string // from a prior successful login; preferred when present
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A monitor row as Kuma broadcasts it (monitorList / getMonitor). Only the fields
|
|
28
|
+
// we read are typed; the rest ride along untyped.
|
|
29
|
+
export interface KumaMonitor {
|
|
30
|
+
id: number
|
|
31
|
+
name: string
|
|
32
|
+
type: string
|
|
33
|
+
url?: string
|
|
34
|
+
active?: boolean | number
|
|
35
|
+
pushToken?: string
|
|
36
|
+
[k: string]: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface KumaBeat {
|
|
40
|
+
status: number // 0 down, 1 up, 2 pending, 3 maintenance
|
|
41
|
+
time: string
|
|
42
|
+
msg?: string
|
|
43
|
+
ping?: number | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A notification provider as configured in Kuma (Settings → Notifications) —
|
|
47
|
+
// e.g. a Telegram bot. Kuma keeps the display name inside the config JSON.
|
|
48
|
+
export interface KumaNotificationInfo {
|
|
49
|
+
id: number
|
|
50
|
+
name: string
|
|
51
|
+
active: boolean
|
|
52
|
+
isDefault: boolean // Kuma auto-attaches these to monitors its UI creates (we mirror that in addMonitor)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Ack {
|
|
56
|
+
ok: boolean
|
|
57
|
+
msg?: string
|
|
58
|
+
monitorID?: number
|
|
59
|
+
maintenanceID?: number
|
|
60
|
+
token?: string
|
|
61
|
+
tokenRequired?: boolean
|
|
62
|
+
monitor?: KumaMonitor // getMonitor's payload
|
|
63
|
+
data?: unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ACK_TIMEOUT_MS = 15_000
|
|
67
|
+
const CONNECT_TIMEOUT_MS = 10_000
|
|
68
|
+
|
|
69
|
+
// 32 chars, [A-Za-z0-9] — same shape the Kuma UI generates for push monitors.
|
|
70
|
+
// (Kuma stores whatever the client sends; the token is chosen client-side.)
|
|
71
|
+
export function genPushToken(): string {
|
|
72
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
73
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
|
74
|
+
return [...bytes].map((b) => chars[b % chars.length]).join("")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function pushUrl(baseUrl: string, token: string): string {
|
|
78
|
+
return `${baseUrl.replace(/\/+$/, "")}/api/push/${token}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class KumaError extends Error {}
|
|
82
|
+
|
|
83
|
+
export class KumaClient {
|
|
84
|
+
private socket: Socket
|
|
85
|
+
version: string | null = null
|
|
86
|
+
// Kuma pushes these as events (not ack payloads) right after login and on change.
|
|
87
|
+
private monitorList: Record<string, KumaMonitor> | null = null
|
|
88
|
+
private notificationList: { id: number; isDefault?: boolean; active?: boolean; config?: string }[] = []
|
|
89
|
+
// notificationList is pushed in the post-login burst (there's no request event
|
|
90
|
+
// for it) — track arrival so getNotifications can tell "none configured" apart
|
|
91
|
+
// from "hasn't landed yet".
|
|
92
|
+
private notificationsArrived = false
|
|
93
|
+
|
|
94
|
+
// Kuma pushes each monitor's recent beats + uptime percentages as events right
|
|
95
|
+
// after login (it's how its own dashboard populates) — capture them so one
|
|
96
|
+
// short-lived connection yields a full status snapshot with no extra queries.
|
|
97
|
+
private heartbeats = new Map<number, KumaBeat[]>()
|
|
98
|
+
private uptimes = new Map<string, number>() // "<monitorId>:<period>" → percent
|
|
99
|
+
|
|
100
|
+
private constructor(socket: Socket) {
|
|
101
|
+
this.socket = socket
|
|
102
|
+
socket.on("info", (info: { version?: string }) => {
|
|
103
|
+
this.version = info?.version ?? null
|
|
104
|
+
})
|
|
105
|
+
socket.on("monitorList", (list: Record<string, KumaMonitor>) => {
|
|
106
|
+
this.monitorList = list
|
|
107
|
+
})
|
|
108
|
+
socket.on("notificationList", (list: typeof this.notificationList) => {
|
|
109
|
+
this.notificationList = list ?? []
|
|
110
|
+
this.notificationsArrived = true
|
|
111
|
+
})
|
|
112
|
+
socket.on("heartbeatList", (monitorID: number | string, beats: KumaBeat[], overwrite?: boolean) => {
|
|
113
|
+
const id = Number(monitorID)
|
|
114
|
+
const prev = overwrite ? [] : (this.heartbeats.get(id) ?? [])
|
|
115
|
+
// Cap the retained history: the UI reads the last ~40 beats, and an
|
|
116
|
+
// uncapped append would grow without bound on a long-lived connection.
|
|
117
|
+
this.heartbeats.set(id, [...prev, ...(beats ?? [])].slice(-100))
|
|
118
|
+
})
|
|
119
|
+
socket.on("uptime", (monitorID: number | string, period: number | string, percent: number) => {
|
|
120
|
+
this.uptimes.set(`${Number(monitorID)}:${period}`, percent)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
beatsFor(monitorId: number): KumaBeat[] {
|
|
125
|
+
return this.heartbeats.get(monitorId) ?? []
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
uptimeFor(monitorId: number, period: number): number | null {
|
|
129
|
+
return this.uptimes.get(`${monitorId}:${period}`) ?? null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The post-login event burst (one heartbeatList per monitor) arrives over ~a
|
|
133
|
+
// second. Wait until we've heard from `expected` monitors (not just one — a
|
|
134
|
+
// single early arrival is a partial snapshot) or the deadline passes.
|
|
135
|
+
async waitForSnapshot(expected: number, ms = 1500): Promise<void> {
|
|
136
|
+
const deadline = Date.now() + ms
|
|
137
|
+
while (this.heartbeats.size < Math.max(1, expected) && Date.now() < deadline) {
|
|
138
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `info` (with the version) can land after the login ack; wait briefly so the
|
|
143
|
+
// connect flow can report a real version instead of racing it.
|
|
144
|
+
async waitForVersion(ms = 1200): Promise<void> {
|
|
145
|
+
if (this.version) return
|
|
146
|
+
await new Promise<void>((resolve) => {
|
|
147
|
+
const t = setTimeout(resolve, ms)
|
|
148
|
+
this.socket.once("info", () => {
|
|
149
|
+
clearTimeout(t)
|
|
150
|
+
resolve()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
static async connect(url: string): Promise<KumaClient> {
|
|
156
|
+
const base = url.replace(/\/+$/, "")
|
|
157
|
+
const socket = io(base, { transports: ["websocket"], reconnection: false, timeout: CONNECT_TIMEOUT_MS })
|
|
158
|
+
const client = new KumaClient(socket)
|
|
159
|
+
await new Promise<void>((resolve, reject) => {
|
|
160
|
+
const t = setTimeout(() => {
|
|
161
|
+
socket.disconnect()
|
|
162
|
+
reject(new KumaError(`Timed out reaching ${base} — is that the right URL?`))
|
|
163
|
+
}, CONNECT_TIMEOUT_MS)
|
|
164
|
+
socket.once("connect", () => {
|
|
165
|
+
clearTimeout(t)
|
|
166
|
+
resolve()
|
|
167
|
+
})
|
|
168
|
+
socket.once("connect_error", (e: Error) => {
|
|
169
|
+
clearTimeout(t)
|
|
170
|
+
socket.disconnect()
|
|
171
|
+
reject(new KumaError(`Couldn't reach Uptime Kuma at ${base}: ${e.message}`))
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
return client
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
disconnect(): void {
|
|
178
|
+
this.socket.disconnect()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private emitAck(event: string, ...args: unknown[]): Promise<Ack> {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const t = setTimeout(() => reject(new KumaError(`Kuma didn't answer "${event}" in time.`)), ACK_TIMEOUT_MS)
|
|
184
|
+
this.socket.emit(event, ...args, (res: Ack) => {
|
|
185
|
+
clearTimeout(t)
|
|
186
|
+
resolve(res ?? { ok: false, msg: "Empty response" })
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async must(event: string, ...args: unknown[]): Promise<Ack> {
|
|
192
|
+
const res = await this.emitAck(event, ...args)
|
|
193
|
+
if (!res.ok) throw new KumaError(res.msg || `Kuma rejected "${event}".`)
|
|
194
|
+
return res
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Login, preferring a saved JWT. Returns the JWT to persist on success (the
|
|
198
|
+
// password path mints one; the JWT path echoes it back). `tokenRequired` means
|
|
199
|
+
// the account has 2FA — retry with a current TOTP in `twofa`.
|
|
200
|
+
async login(creds: KumaCreds, twofa?: string): Promise<{ ok: true; jwt: string } | { ok: false; error: string; tokenRequired?: boolean }> {
|
|
201
|
+
if (creds.jwt) {
|
|
202
|
+
const byToken = await this.emitAck("loginByToken", creds.jwt)
|
|
203
|
+
if (byToken.ok) return { ok: true, jwt: creds.jwt }
|
|
204
|
+
// Stale JWT (password changed / server reset) — fall through to password.
|
|
205
|
+
}
|
|
206
|
+
const res = await this.emitAck("login", { username: creds.username, password: creds.password, token: twofa ?? "" })
|
|
207
|
+
if (res.ok && res.token) return { ok: true, jwt: res.token }
|
|
208
|
+
if (res.tokenRequired) return { ok: false, error: "This account has 2FA — enter a current code to log in.", tokenRequired: true }
|
|
209
|
+
return { ok: false, error: res.msg || "Uptime Kuma rejected the login." }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The monitor list arrives as a broadcast event (login triggers one); asking via
|
|
213
|
+
// getMonitorList re-triggers it. Await whichever lands first.
|
|
214
|
+
async getMonitors(): Promise<KumaMonitor[]> {
|
|
215
|
+
if (!this.monitorList) {
|
|
216
|
+
const wait = new Promise<void>((resolve, reject) => {
|
|
217
|
+
const t = setTimeout(() => reject(new KumaError("Kuma didn't send the monitor list in time.")), ACK_TIMEOUT_MS)
|
|
218
|
+
this.socket.once("monitorList", () => {
|
|
219
|
+
clearTimeout(t)
|
|
220
|
+
resolve()
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
// If the ack below rejects first, `wait` is never awaited — pre-attach a
|
|
224
|
+
// no-op handler so its own later timeout can't become an unhandled
|
|
225
|
+
// rejection (the `await wait` path still observes the real error).
|
|
226
|
+
wait.catch(() => {})
|
|
227
|
+
await this.emitAck("getMonitorList")
|
|
228
|
+
if (!this.monitorList) await wait
|
|
229
|
+
}
|
|
230
|
+
return Object.values(this.monitorList ?? {})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The notification providers configured in Kuma, with display names parsed out
|
|
234
|
+
// of the config JSON. Waits briefly for the post-login burst so an immediate
|
|
235
|
+
// call after login doesn't misread "not arrived yet" as "none configured".
|
|
236
|
+
async getNotifications(ms = 2000): Promise<KumaNotificationInfo[]> {
|
|
237
|
+
const deadline = Date.now() + ms
|
|
238
|
+
while (!this.notificationsArrived && Date.now() < deadline) {
|
|
239
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
240
|
+
}
|
|
241
|
+
return this.notificationList.map((n) => {
|
|
242
|
+
let name = `notification #${n.id}`
|
|
243
|
+
let isDefault = !!n.isDefault
|
|
244
|
+
try {
|
|
245
|
+
const cfg = n.config ? (JSON.parse(n.config) as { name?: string; isDefault?: boolean }) : {}
|
|
246
|
+
if (cfg.name) name = cfg.name
|
|
247
|
+
if (cfg.isDefault) isDefault = true
|
|
248
|
+
} catch {
|
|
249
|
+
// Unparseable config — keep the fallback name.
|
|
250
|
+
}
|
|
251
|
+
return { id: n.id, name, active: n.active !== false, isDefault }
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Notifications flagged "Default enabled" in Kuma — the UI auto-attaches these
|
|
256
|
+
// to new monitors, so we do the same for monitors we create.
|
|
257
|
+
private defaultNotificationIds(): Record<string, boolean> {
|
|
258
|
+
const ids: Record<string, boolean> = {}
|
|
259
|
+
for (const n of this.notificationList) {
|
|
260
|
+
try {
|
|
261
|
+
const cfg = n.config ? (JSON.parse(n.config) as { isDefault?: boolean }) : {}
|
|
262
|
+
if (n.isDefault || cfg.isDefault) ids[String(n.id)] = true
|
|
263
|
+
} catch {
|
|
264
|
+
// Unparseable config — skip.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return ids
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Kuma 2.x monitors carry a `conditions` array its UI always sends; 1.x has no
|
|
271
|
+
// such column and the insert fails if we send one. Try the 2.x shape first and
|
|
272
|
+
// fall back, so one client speaks to either generation.
|
|
273
|
+
async addMonitor(monitor: Record<string, unknown>): Promise<number> {
|
|
274
|
+
const payload = { notificationIDList: this.defaultNotificationIds(), conditions: [], ...monitor }
|
|
275
|
+
let res = await this.emitAck("add", payload)
|
|
276
|
+
if (!res.ok && /conditions/i.test(res.msg ?? "")) {
|
|
277
|
+
const { conditions: _legacy, ...withoutConditions } = payload
|
|
278
|
+
res = await this.emitAck("add", withoutConditions)
|
|
279
|
+
}
|
|
280
|
+
if (!res.ok) throw new KumaError(res.msg || 'Kuma rejected "add".')
|
|
281
|
+
if (typeof res.monitorID !== "number") throw new KumaError("Kuma didn't return the new monitor's id.")
|
|
282
|
+
return res.monitorID
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The full monitor row (getMonitor returns more fields than the monitorList
|
|
286
|
+
// broadcast) — the shape editMonitor expects back, so rotate flows round-trip it.
|
|
287
|
+
async getMonitor(id: number): Promise<KumaMonitor> {
|
|
288
|
+
const res = await this.must("getMonitor", id)
|
|
289
|
+
if (!res.monitor) throw new KumaError("Kuma didn't return the monitor.")
|
|
290
|
+
return res.monitor
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Save changes to an existing monitor IN PLACE — same id, so heartbeat history,
|
|
294
|
+
// uptime stats and notification wiring all survive. Callers pass a full monitor
|
|
295
|
+
// object from getMonitor (Kuma's own edit dialog does the same round-trip).
|
|
296
|
+
async editMonitor(monitor: Record<string, unknown>): Promise<void> {
|
|
297
|
+
await this.must("editMonitor", monitor)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async pauseMonitor(id: number): Promise<void> {
|
|
301
|
+
await this.must("pauseMonitor", id)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async resumeMonitor(id: number): Promise<void> {
|
|
305
|
+
await this.must("resumeMonitor", id)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async deleteMonitor(id: number): Promise<void> {
|
|
309
|
+
await this.must("deleteMonitor", id)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async getMonitorBeats(id: number, periodHours: number): Promise<KumaBeat[]> {
|
|
313
|
+
const res = await this.must("getMonitorBeats", id, periodHours)
|
|
314
|
+
return (res.data as KumaBeat[]) ?? []
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Manual-strategy maintenance: active immediately, until deleted/paused.
|
|
318
|
+
async addManualMaintenance(title: string, description: string): Promise<number> {
|
|
319
|
+
const res = await this.must("addMaintenance", {
|
|
320
|
+
title,
|
|
321
|
+
description,
|
|
322
|
+
strategy: "manual",
|
|
323
|
+
active: true,
|
|
324
|
+
intervalDay: 1,
|
|
325
|
+
dateRange: [null, null],
|
|
326
|
+
timeRange: [
|
|
327
|
+
{ hours: 0, minutes: 0 },
|
|
328
|
+
{ hours: 0, minutes: 0 },
|
|
329
|
+
],
|
|
330
|
+
weekdays: [],
|
|
331
|
+
daysOfMonth: [],
|
|
332
|
+
})
|
|
333
|
+
if (typeof res.maintenanceID !== "number") throw new KumaError("Kuma didn't return the maintenance id.")
|
|
334
|
+
return res.maintenanceID
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async setMaintenanceMonitors(maintenanceID: number, monitorIds: number[]): Promise<void> {
|
|
338
|
+
await this.must(
|
|
339
|
+
"addMonitorMaintenance",
|
|
340
|
+
maintenanceID,
|
|
341
|
+
monitorIds.map((id) => ({ id })),
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async deleteMaintenance(maintenanceID: number): Promise<void> {
|
|
346
|
+
await this.must("deleteMaintenance", maintenanceID)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Standard payloads for the two monitors Spinup creates per vanity site. Kuma's
|
|
351
|
+
// `add` handler tolerates missing columns, but we send what its own UI sends so
|
|
352
|
+
// the rows are indistinguishable from hand-made ones.
|
|
353
|
+
export function healthMonitorPayload(name: string, url: string): Record<string, unknown> {
|
|
354
|
+
return {
|
|
355
|
+
type: "http",
|
|
356
|
+
name,
|
|
357
|
+
url,
|
|
358
|
+
method: "GET",
|
|
359
|
+
interval: 60,
|
|
360
|
+
retryInterval: 60,
|
|
361
|
+
resendInterval: 0,
|
|
362
|
+
maxretries: 2, // ride out a brief load spike before alerting
|
|
363
|
+
timeout: 48,
|
|
364
|
+
accepted_statuscodes: ["200-299"],
|
|
365
|
+
expiryNotification: true, // LE-renewal canary for the whole server
|
|
366
|
+
ignoreTls: false,
|
|
367
|
+
upsideDown: false,
|
|
368
|
+
maxredirects: 10,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function loadPushMonitorPayload(name: string, pushToken: string): Record<string, unknown> {
|
|
373
|
+
return {
|
|
374
|
+
type: "push",
|
|
375
|
+
name,
|
|
376
|
+
pushToken,
|
|
377
|
+
description: "1-min load average ×100 (integer), pushed by SpinupTUI's heartbeat cron. 164 = load 1.64.",
|
|
378
|
+
// The cron beats once a minute; alert after ~2 missed beats.
|
|
379
|
+
interval: 60,
|
|
380
|
+
retryInterval: 60,
|
|
381
|
+
maxretries: 2,
|
|
382
|
+
upsideDown: false,
|
|
383
|
+
// Kuma validates status-code arrays on every monitor type, push included.
|
|
384
|
+
accepted_statuscodes: ["200-299"],
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The server-wide Redis sentinel: the same heartbeat cron runs `redis-cli ping`
|
|
389
|
+
// once a minute and pushes status=up/down here. One per SERVER (Redis is one
|
|
390
|
+
// daemon per box), not per site — the per-site silent-fallback story is the
|
|
391
|
+
// Phase-3b health endpoint's job.
|
|
392
|
+
export function redisPushMonitorPayload(name: string, pushToken: string): Record<string, unknown> {
|
|
393
|
+
return {
|
|
394
|
+
type: "push",
|
|
395
|
+
name,
|
|
396
|
+
pushToken,
|
|
397
|
+
description: "Server-wide Redis sentinel: SpinupTUI's heartbeat cron pings Redis every minute and reports up/down. Silence = the server (not Redis) is the problem.",
|
|
398
|
+
interval: 60,
|
|
399
|
+
retryInterval: 60,
|
|
400
|
+
maxretries: 2,
|
|
401
|
+
upsideDown: false,
|
|
402
|
+
accepted_statuscodes: ["200-299"],
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The front-page fingerprint monitor (site monitoring Phase 1): a `keyword`
|
|
407
|
+
// monitor asserting the front page still carries the template-identity string
|
|
408
|
+
// calibration derived (lib/siteFingerprint.ts). Catches "the page cache is
|
|
409
|
+
// serving the wrong template" — a failure plain up/down monitors sleep through
|
|
410
|
+
// (the real incidents answered 200 throughout). `interval` is the user-chosen
|
|
411
|
+
// check window; the fetch is served straight from nginx's page cache, so even
|
|
412
|
+
// the tightest window costs the site nothing.
|
|
413
|
+
export function fingerprintMonitorPayload(name: string, url: string, keyword: string, interval: number): Record<string, unknown> {
|
|
414
|
+
return {
|
|
415
|
+
type: "keyword",
|
|
416
|
+
name,
|
|
417
|
+
url,
|
|
418
|
+
keyword,
|
|
419
|
+
method: "GET",
|
|
420
|
+
interval,
|
|
421
|
+
retryInterval: 60, // once suspect, confirm quickly regardless of the window
|
|
422
|
+
resendInterval: 0,
|
|
423
|
+
maxretries: 2, // two confirming failures before alerting — ride out one flaky fetch
|
|
424
|
+
timeout: 48,
|
|
425
|
+
accepted_statuscodes: ["200-299"],
|
|
426
|
+
expiryNotification: false, // the plain health monitor already owns cert expiry
|
|
427
|
+
ignoreTls: false,
|
|
428
|
+
upsideDown: false,
|
|
429
|
+
maxredirects: 10,
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Adopt-or-EDIT-or-create the fingerprint monitor for a domain. Unlike
|
|
434
|
+
// registerMonitors (adopt-or-create only), an existing row is edited in place:
|
|
435
|
+
// recalibrating after a redesign updates keyword/interval while the monitor id,
|
|
436
|
+
// heartbeat history and notification wiring all survive (same philosophy as
|
|
437
|
+
// rotatePushToken).
|
|
438
|
+
export async function registerFingerprintMonitor(
|
|
439
|
+
kuma: KumaClient,
|
|
440
|
+
domain: string,
|
|
441
|
+
opts: { proto: "http" | "https"; keyword: string; interval: number; knownId?: number | null },
|
|
442
|
+
): Promise<number> {
|
|
443
|
+
const name = `${domain} front page`
|
|
444
|
+
const url = `${opts.proto}://${domain}/`
|
|
445
|
+
const monitors = await kuma.getMonitors()
|
|
446
|
+
const byId = new Map(monitors.map((m) => [m.id, m]))
|
|
447
|
+
const existingId = (opts.knownId != null && byId.has(opts.knownId) ? opts.knownId : undefined) ?? monitors.find((m) => m.name === name)?.id
|
|
448
|
+
if (existingId != null) {
|
|
449
|
+
const full = await kuma.getMonitor(existingId)
|
|
450
|
+
await kuma.editMonitor({ ...full, type: "keyword", url, keyword: opts.keyword, interval: opts.interval })
|
|
451
|
+
return existingId
|
|
452
|
+
}
|
|
453
|
+
return kuma.addMonitor(fingerprintMonitorPayload(name, url, opts.keyword, opts.interval))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Adopt-or-create the monitors for one domain — THE single implementation shared
|
|
457
|
+
// by the vanity wizard's monitor step and the `m` overlay's add/repair, so the
|
|
458
|
+
// two paths cannot drift. Rules:
|
|
459
|
+
// - A recorded id is trusted only if the monitor still EXISTS in Kuma (deleted
|
|
460
|
+
// over there ⇒ re-create, don't silently no-op).
|
|
461
|
+
// - Same-named monitors are adopted rather than duplicated; an adopted push
|
|
462
|
+
// monitor keeps its own token so the cron feeds the right one.
|
|
463
|
+
// - The two creates are independent acks — run them in parallel.
|
|
464
|
+
export async function registerMonitors(
|
|
465
|
+
kuma: KumaClient,
|
|
466
|
+
domain: string,
|
|
467
|
+
opts: { proto: "http" | "https"; healthzPath: string; wantPush: boolean; wantRedis?: boolean; known?: KumaMonitorRef | null; pushToken?: string | null },
|
|
468
|
+
): Promise<{ healthId: number; pushId?: number; pushToken?: string; redisId?: number; redisToken?: string }> {
|
|
469
|
+
const monitors = await kuma.getMonitors()
|
|
470
|
+
const byId = new Map(monitors.map((m) => [m.id, m]))
|
|
471
|
+
const byName = new Map(monitors.map((m) => [m.name, m]))
|
|
472
|
+
const known = opts.known ?? {}
|
|
473
|
+
const live = (id?: number) => (id != null && byId.has(id) ? id : undefined)
|
|
474
|
+
|
|
475
|
+
let pushToken = opts.pushToken ?? known.pushToken ?? genPushToken()
|
|
476
|
+
const existingPush = opts.wantPush && live(known.pushId) == null ? byName.get(`${domain} load`) : undefined
|
|
477
|
+
if (existingPush?.pushToken) pushToken = existingPush.pushToken
|
|
478
|
+
|
|
479
|
+
// The Redis sentinel gets its OWN token — the cron feeds two distinct push
|
|
480
|
+
// URLs so the monitors alert independently.
|
|
481
|
+
let redisToken = known.redisToken ?? genPushToken()
|
|
482
|
+
const existingRedis = opts.wantRedis && live(known.redisId) == null ? byName.get(`${domain} redis`) : undefined
|
|
483
|
+
if (existingRedis?.pushToken) redisToken = existingRedis.pushToken
|
|
484
|
+
|
|
485
|
+
const [healthId, pushId, redisId] = await Promise.all([
|
|
486
|
+
(async () => live(known.healthId) ?? byName.get(domain)?.id ?? (await kuma.addMonitor(healthMonitorPayload(domain, `${opts.proto}://${domain}${opts.healthzPath}`))))(),
|
|
487
|
+
(async () => {
|
|
488
|
+
if (!opts.wantPush) return undefined
|
|
489
|
+
return live(known.pushId) ?? existingPush?.id ?? (await kuma.addMonitor(loadPushMonitorPayload(`${domain} load`, pushToken)))
|
|
490
|
+
})(),
|
|
491
|
+
(async () => {
|
|
492
|
+
if (!opts.wantRedis) return undefined
|
|
493
|
+
return live(known.redisId) ?? existingRedis?.id ?? (await kuma.addMonitor(redisPushMonitorPayload(`${domain} redis`, redisToken)))
|
|
494
|
+
})(),
|
|
495
|
+
])
|
|
496
|
+
return {
|
|
497
|
+
healthId,
|
|
498
|
+
pushId,
|
|
499
|
+
pushToken: opts.wantPush ? pushToken : undefined,
|
|
500
|
+
redisId,
|
|
501
|
+
redisToken: opts.wantRedis ? redisToken : undefined,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Attach/detach one notification provider on a set of monitors, editing each
|
|
506
|
+
// row in place (getMonitor → editMonitor, Kuma's own edit-dialog round-trip) so
|
|
507
|
+
// history, tags and the rest of the notification wiring survive untouched.
|
|
508
|
+
export async function setMonitorNotifications(kuma: KumaClient, monitorIds: number[], providerId: number, on: boolean): Promise<void> {
|
|
509
|
+
for (const id of monitorIds) {
|
|
510
|
+
const full = await kuma.getMonitor(id)
|
|
511
|
+
const list = { ...((full.notificationIDList as Record<string, boolean> | undefined) ?? {}), [String(providerId)]: on }
|
|
512
|
+
await kuma.editMonitor({ ...full, notificationIDList: list })
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Swap a push monitor's token in place — the old push URL stops accepting beats
|
|
517
|
+
// the moment this lands, but the monitor row (and its history) is untouched.
|
|
518
|
+
export async function rotatePushToken(kuma: KumaClient, pushId: number, newToken: string): Promise<void> {
|
|
519
|
+
const mon = await kuma.getMonitor(pushId)
|
|
520
|
+
await kuma.editMonitor({ ...mon, pushToken: newToken })
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Repoint any monitor whose URL embeds the old health key (the JSON-query recipe
|
|
524
|
+
// in docs/uptime-kuma.md bakes it into the query string) at the new key, so a
|
|
525
|
+
// key rotation doesn't silently kill hand-made threshold monitors. Returns how
|
|
526
|
+
// many were updated. Keys shorter than 8 chars are refused — too much risk of
|
|
527
|
+
// matching an unrelated URL fragment.
|
|
528
|
+
export async function retargetHealthKeyMonitors(kuma: KumaClient, oldKey: string, newKey: string): Promise<number> {
|
|
529
|
+
if (oldKey.length < 8) return 0
|
|
530
|
+
const monitors = await kuma.getMonitors()
|
|
531
|
+
const hits = monitors.filter((m) => typeof m.url === "string" && m.url.includes(oldKey))
|
|
532
|
+
for (const m of hits) {
|
|
533
|
+
const full = await kuma.getMonitor(m.id)
|
|
534
|
+
await kuma.editMonitor({ ...full, url: String(full.url).split(oldKey).join(newKey) })
|
|
535
|
+
}
|
|
536
|
+
return hits.length
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Connect → login → run → always disconnect. Returns the (possibly refreshed) JWT
|
|
540
|
+
// alongside the result so callers can persist it.
|
|
541
|
+
export async function withKuma<T>(creds: KumaCreds, fn: (kuma: KumaClient) => Promise<T>): Promise<{ result: T; jwt: string; version: string | null }> {
|
|
542
|
+
const kuma = await KumaClient.connect(creds.url)
|
|
543
|
+
try {
|
|
544
|
+
const login = await kuma.login(creds)
|
|
545
|
+
if (!login.ok) throw new KumaError(login.error)
|
|
546
|
+
const result = await fn(kuma)
|
|
547
|
+
return { result, jwt: login.jwt, version: kuma.version }
|
|
548
|
+
} finally {
|
|
549
|
+
kuma.disconnect()
|
|
550
|
+
}
|
|
551
|
+
}
|