/** * [Gitea #124] Homeserver status from an Uptime Kuma status page. * * config.json maps a homeserver to a Kuma status page: * "statusPages": { "matrix.lotusguild.org": { "url": "https://isitup.lotusguild.org", * "slug": "matrix", "groups": { "homeserver": "Homeserver", "calls": "Voice calls", * "login": "Login" } } } * Users of any other homeserver never contact Kuma. * * Kuma 2.x public JSON: * - GET /api/status-page/: publicGroupList[{name, monitorList[{id}]}], * maintenanceList (only windows UNDER maintenance right now), incidents * (announcements posted on the page). * - GET /api/status-page/heartbeat/: heartbeatList{: [{status, time}]}, * status 0 down, 1 up, 2 pending, 3 maintenance. * * Everything here is pure and defensive: anything unexpected reads as * "unknown", and unknown never shows a banner. */ export type StatusGroups = { homeserver?: string; calls?: string; login?: string }; export type StatusPageConfig = { url: string; slug: string; groups: StatusGroups }; export type ServiceState = 'up' | 'down' | 'unknown'; export type KumaMaintenance = { id: string; title: string; description: string; end?: number }; export type KumaIncident = { id: string; title: string; content: string; style: string; updated: string; }; export type ServerStatus = { homeserver: ServiceState; /** * When Kuma last checked every homeserver monitor (the OLDEST of their * latest beats, ms). Lets the client tell "Kuma has looked since my * connection dropped and the server was fine" from a stale "up". */ homeserverCheckedAt?: number; calls: ServiceState; login: ServiceState; maintenance: KumaMaintenance[]; incidents: KumaIncident[]; }; export const DEFAULT_GROUPS: Required = { homeserver: 'Homeserver', calls: 'Voice calls', login: 'Login', }; const obj = (v: unknown): Record | undefined => v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined; const str = (v: unknown): string => (typeof v === 'string' ? v : ''); /** The status page for `serverName`, if config.json names a valid one. */ export const resolveStatusPage = ( statusPages: unknown, serverName: string | undefined, ): StatusPageConfig | undefined => { if (!serverName) return undefined; const entry = obj(obj(statusPages)?.[serverName]); if (!entry) return undefined; const slug = str(entry.slug); if (!/^[a-z0-9-]{1,64}$/i.test(slug)) return undefined; try { const url = new URL(str(entry.url)); const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined; const g = obj(entry.groups) ?? {}; return { url: url.origin, slug, groups: { homeserver: str(g.homeserver) || DEFAULT_GROUPS.homeserver, calls: str(g.calls) || DEFAULT_GROUPS.calls, login: str(g.login) || DEFAULT_GROUPS.login, }, }; } catch { return undefined; } }; export const kumaUrls = (page: StatusPageConfig) => ({ page: `${page.url}/api/status-page/${page.slug}`, heartbeat: `${page.url}/api/status-page/heartbeat/${page.slug}`, }); const DOWN = 0; const PENDING = 2; /** Kuma beat time ("2026-09-29 14:16:11.804", UTC) → ms. */ export const kumaBeatTime = (time: unknown): number | undefined => { const t = Date.parse(`${str(time).trim().replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : undefined; }; const sortedBeats = (list: unknown): Record[] => (Array.isArray(list) ? list : []) .map((b) => obj(b)) .filter((b): b is Record => !!b && typeof b.status === 'number') .sort((a, b) => str(a.time).localeCompare(str(b.time))); /** * A group is down when ANY of its monitors is failing across two checks in a * row: its latest beat is down and the one before is down or pending (Kuma * marks a first failure "pending" when the monitor has a retry). One piece * down, say the call token service, already breaks the feature; two checks so * a single blip doesn't flash a banner. Up when every monitor we have beats * for is fine; unknown when the group or its beats are missing. */ const groupState = ( groupName: string | undefined, groups: Map, beats: Record, ): ServiceState => { if (!groupName) return 'unknown'; const ids = groups.get(groupName); if (!ids || ids.length === 0) return 'unknown'; let known = 0; let down = false; ids.forEach((id) => { const statuses = sortedBeats(beats[id]).map((b) => b.status as number); if (statuses.length === 0) return; known += 1; const [prev, last] = statuses.slice(-2); if (statuses.length >= 2 && last === DOWN && (prev === DOWN || prev === PENDING)) down = true; }); if (down) return 'down'; return known > 0 ? 'up' : 'unknown'; }; export const parseKumaStatus = ( pageJson: unknown, heartbeatJson: unknown, groupNames: StatusGroups, ): ServerStatus => { const page = obj(pageJson) ?? {}; const groups = new Map(); (Array.isArray(page.publicGroupList) ? page.publicGroupList : []).forEach((g) => { const group = obj(g); if (!group) return; const ids = (Array.isArray(group.monitorList) ? group.monitorList : []) .map((m) => obj(m)?.id) .filter((id): id is number | string => typeof id === 'number' || typeof id === 'string') .map(String); groups.set(str(group.name), ids); }); const beats = obj(obj(heartbeatJson)?.heartbeatList) ?? {}; const maintenance: KumaMaintenance[] = ( Array.isArray(page.maintenanceList) ? page.maintenanceList : [] ) .map((m) => obj(m)) .filter((m): m is Record => !!m && str(m.title) !== '') .filter((m) => !m.status || m.status === 'under-maintenance') .map((m) => { const slot = obj((Array.isArray(m.timeslotList) ? m.timeslotList : [])[0]); const end = Date.parse(str(slot?.endDate)); return { id: String(m.id ?? str(m.title)), title: str(m.title), description: str(m.description), ...(Number.isFinite(end) ? { end } : {}), }; }); const incidents: KumaIncident[] = (Array.isArray(page.incidents) ? page.incidents : []) .map((i) => obj(i)) .filter((i): i is Record => !!i && str(i.title) !== '' && i.active !== false) .map((i) => ({ id: String(i.id ?? str(i.title)), title: str(i.title), content: str(i.content), style: str(i.style) || 'info', updated: str(i.lastUpdatedDate) || str(i.createdDate), })); const hsIds = (groupNames.homeserver && groups.get(groupNames.homeserver)) || []; const hsLatest = hsIds.map((id) => kumaBeatTime(sortedBeats(beats[id]).slice(-1)[0]?.time)); const homeserverCheckedAt = hsLatest.length > 0 && hsLatest.every((t): t is number => t !== undefined) ? Math.min(...hsLatest) : undefined; return { homeserver: groupState(groupNames.homeserver, groups, beats), ...(homeserverCheckedAt !== undefined ? { homeserverCheckedAt } : {}), calls: groupState(groupNames.calls, groups, beats), login: groupState(groupNames.login, groups, beats), maintenance, incidents, }; }; /** * Kuma checked the homeserver AFTER this client lost its connection, and it * was fine: the problem is more likely on the user's side. A stale "up" from * before the drop proves nothing (Kuma takes a minute or two to notice an * outage), so it doesn't count. */ export const serverConfirmedUpSince = ( status: ServerStatus | null | undefined, lostAt: number | null | undefined, ): boolean => !!status && !!lostAt && status.homeserver === 'up' && status.homeserverCheckedAt !== undefined && status.homeserverCheckedAt > lostAt; export type BannerTone = 'Critical' | 'Warning' | 'Primary'; export type BannerKind = | 'server-down' | 'server-problems' | 'maintenance' | 'calls-down' | 'announcement' | 'login-down'; export type StatusBanner = { kind: BannerKind; /** Stable id for dismissing (announcements come back when edited). */ key: string; tone: BannerTone; text: string; detail?: string; dismissable: boolean; }; const formatTime = (ms: number): string => new Date(ms).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); const incidentTone = (style: string): BannerTone => { if (style === 'danger') return 'Critical'; if (style === 'warning') return 'Warning'; return 'Primary'; }; /** Markdown-ish incident text as plain text for the Details line. */ const plain = (s: string): string => s .replace(/!\[[^\]]*\]\([^)]*\)/g, '') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/[*_`#>]/g, '') .replace(/\s+\n/g, '\n') .trim(); /** * The one strip to show, highest priority first. `syncLost`: this client's * connection is reconnecting/erroring. `where`: the logged-in app or the * login screen. */ export const pickBanner = ( status: ServerStatus | null | undefined, opts: { syncLost: boolean; where: 'client' | 'login'; dismissed?: ReadonlySet }, ): StatusBanner | undefined => { if (!status) return undefined; const dismissed = opts.dismissed ?? new Set(); const candidates: StatusBanner[] = []; if (status.homeserver === 'down') { if (opts.syncLost || opts.where === 'login') { candidates.push({ kind: 'server-down', key: 'server-down', tone: 'Critical', text: opts.where === 'login' ? "Lotus Chat's server is down. We're on it." : "Lotus Chat's server is down. We're on it, reconnecting…", dismissable: false, }); } else { candidates.push({ kind: 'server-problems', key: 'server-problems', tone: 'Warning', text: "Lotus Chat's server is having problems. Messages may be slow to send or arrive.", dismissable: false, }); } } const maint = status.maintenance[0]; if (maint) { const until = maint.end ? ` until ${formatTime(maint.end)}` : ''; candidates.push({ kind: 'maintenance', key: `maintenance:${maint.id}`, tone: 'Warning', text: `Maintenance in progress${until}: ${maint.title}. Messages and calls may be interrupted.`, detail: plain(maint.description) || undefined, dismissable: false, }); } if (opts.where === 'login' && status.login === 'down') { candidates.push({ kind: 'login-down', key: 'login-down', tone: 'Warning', text: "Sign-in is having problems right now. If it doesn't work, try again in a few minutes.", dismissable: false, }); } if (opts.where === 'client' && status.calls === 'down' && status.homeserver !== 'down') { candidates.push({ kind: 'calls-down', key: 'calls-down', tone: 'Warning', text: 'Voice calls are down right now. Messages still work.', dismissable: true, }); } status.incidents.forEach((i) => { candidates.push({ kind: 'announcement', key: `announcement:${i.id}:${i.updated}`, tone: incidentTone(i.style), text: i.title, detail: plain(i.content) || undefined, dismissable: true, }); }); return candidates.find((b) => !(b.dismissable && dismissed.has(b.key))); };