Files
cinny/src/app/utils/kumaStatus.test.ts
T
Lotus CIandClaude Opus 5.5 6ae0087213
CI / Build & Quality Checks (pull_request) Successful in 4m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m58s
feat: homeserver status banner from Uptime Kuma (#124)
When the homeserver, voice calls or sign-in break, or maintenance is under
way, say so in the app — driven by the Kuma status page
(isitup.lotusguild.org/status/matrix), managed from Kuma's UI. The client
asks Kuma directly (not via our servers) so it still hears "the server is
down" when our servers can't tell it.

- config.json `statusPages`, keyed by homeserver: users of other servers
  never contact Kuma.
- utils/kumaStatus.ts (pure, unit-tested): parse Kuma 2.x's public JSON;
  a group is down when any monitor fails two checks in a row (down+down or
  pending+down); maintenance = windows under way; announcements = incidents.
  One strip at a time: server down (connection lost AND Kuma confirms) >
  server having problems > maintenance > calls down > announcement;
  sign-in problems on the login screen only.
- Wording about the user's own connection: "Connection lost … our status
  checks say the server is up, so it may be your internet connection" ONLY
  when Kuma checked the server after this client's connection dropped and
  it passed; a stale "up" (Kuma needs a minute or two to notice an outage)
  keeps the plain "Connection Lost!".
- useServerStatus: polls only while visible; 5 min, 60 s while something is
  wrong or the connection is lost, at once when it drops; backoff; any
  failure = no banner (Kuma being unreachable never looks like Matrix
  being down); GET only, no cookies.
- UI in the existing banner slot and style (ContainerColor/Line like the
  sync and clock banners); Details expands; dismiss for calls-down and
  announcements (an edited announcement comes back); calls-down note above
  Join; phone: one line + Details.

Needs the CSP connect-src to allow https://isitup.lotusguild.org (matrix
repo) before it can fetch in production; until then it fails quiet.

Tests: 15 unit tests (live page layout, two-check rule, any-monitor rule,
unknown/garbage, UTC beat times, stale-vs-fresh "up", priorities, login vs
client, maintenance, announcements + dismiss/edit); e2e (fixtures for Kuma):
calls-down strip + dismiss across reload, other homeservers make no
requests, Kuma 500 → nothing, lost connection + Kuma down → critical strip
instead of "Connection Lost", + fresh "up" → "may be your connection",
+ stale "up" → plain "Connection Lost". Unit 1315, Playwright 26 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 12:08:08 -04:00

263 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_GROUPS,
kumaBeatTime,
kumaUrls,
parseKumaStatus,
pickBanner,
resolveStatusPage,
serverConfirmedUpSince,
ServerStatus,
} from './kumaStatus';
// The live `matrix` status page's groups (captured 2026-09-29).
const PAGE = {
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 30, name: 'Synapse HTTP' }] },
{
name: 'Voice calls',
monitorList: [
{ id: 37, name: 'Matrix: LiveKit (public /rtc)' },
{ id: 38, name: 'Matrix: call tokens (public /sfu/get)' },
{ id: 39, name: 'Matrix: call page (call.chat)' },
],
},
{ name: 'Login', monitorList: [{ id: 15, name: 'Authentication Server' }] },
],
maintenanceList: [],
incidents: [],
};
const UP = 1;
const DOWN = 0;
const PENDING = 2;
/** Kuma's beat format: "YYYY-MM-DD HH:MM:SS.mmm" in UTC, oldest first. */
const beats = (...statuses: number[]) =>
statuses.map((status, i) => ({
status,
time: `2026-09-29 14:${String(10 + i).padStart(2, '0')}:00.000`,
msg: '',
}));
const hb = (over: Record<number, number[]> = {}) => ({
heartbeatList: {
30: beats(...(over[30] ?? [UP, UP, UP])),
37: beats(...(over[37] ?? [UP, UP])),
38: beats(...(over[38] ?? [UP, UP])),
39: beats(...(over[39] ?? [UP, UP])),
15: beats(...(over[15] ?? [UP, UP])),
},
uptimeList: {},
});
const parse = (page: unknown, heartbeat: unknown) =>
parseKumaStatus(page, heartbeat, DEFAULT_GROUPS);
test('config: only a valid page for the listed homeserver', () => {
const cfg = {
'matrix.lotusguild.org': { url: 'https://isitup.lotusguild.org/', slug: 'matrix' },
};
const page = resolveStatusPage(cfg, 'matrix.lotusguild.org');
assert.deepEqual(page, {
url: 'https://isitup.lotusguild.org',
slug: 'matrix',
groups: DEFAULT_GROUPS,
});
assert.deepEqual(kumaUrls(page!), {
page: 'https://isitup.lotusguild.org/api/status-page/matrix',
heartbeat: 'https://isitup.lotusguild.org/api/status-page/heartbeat/matrix',
});
assert.equal(resolveStatusPage(cfg, 'matrix.org'), undefined, 'other homeservers: nothing');
assert.equal(resolveStatusPage(cfg, undefined), undefined);
assert.equal(resolveStatusPage(undefined, 'matrix.lotusguild.org'), undefined);
for (const bad of [
{ url: 'http://isitup.lotusguild.org', slug: 'matrix' },
{ url: ['javascript', 'alert(1)'].join(':'), slug: 'matrix' },
{ url: 'https://isitup.lotusguild.org', slug: '../admin' },
{ url: 'https://isitup.lotusguild.org', slug: '' },
{ url: 'not a url', slug: 'matrix' },
]) {
assert.equal(resolveStatusPage({ hs: bad }, 'hs'), undefined, JSON.stringify(bad));
}
assert.equal(
resolveStatusPage({ hs: { url: 'http://localhost:3001', slug: 'm' } }, 'hs')?.url,
'http://localhost:3001',
'http allowed for local development',
);
});
test('all green on the live page layout', () => {
const s = parse(PAGE, hb());
assert.equal(s.homeserver, 'up');
assert.equal(s.calls, 'up');
assert.equal(s.login, 'up');
assert.equal(pickBanner(s, { syncLost: false, where: 'client' }), undefined);
assert.equal(pickBanner(s, { syncLost: true, where: 'client' }), undefined);
});
test('down needs two failing checks: down+down or pending+down; one blip is not down', () => {
assert.equal(parse(PAGE, hb({ 30: [UP, UP, DOWN] })).homeserver, 'up', 'single failure');
assert.equal(parse(PAGE, hb({ 30: [UP, PENDING] })).homeserver, 'up', 'pending only');
assert.equal(parse(PAGE, hb({ 30: [UP, DOWN, DOWN] })).homeserver, 'down');
assert.equal(parse(PAGE, hb({ 30: [UP, PENDING, DOWN] })).homeserver, 'down');
assert.equal(parse(PAGE, hb({ 30: [DOWN, DOWN, UP] })).homeserver, 'up', 'recovered');
});
test('any failing monitor takes its group down (one piece breaks calls)', () => {
const s = parse(PAGE, hb({ 38: [DOWN, DOWN] }));
assert.equal(s.calls, 'down');
assert.equal(s.homeserver, 'up');
});
test('unknown when the group or its beats are missing, and unknown shows nothing', () => {
const s = parse({ publicGroupList: [] }, hb());
assert.deepEqual([s.homeserver, s.calls, s.login], ['unknown', 'unknown', 'unknown']);
assert.equal(parse(PAGE, { heartbeatList: {} }).homeserver, 'unknown');
assert.equal(pickBanner(s, { syncLost: true, where: 'client' }), undefined);
});
test('garbage from Kuma never throws and never shows a banner', () => {
for (const [page, heartbeat] of [
[null, null],
['<html>', 42],
[{ publicGroupList: 'x', maintenanceList: {}, incidents: 7 }, { heartbeatList: [] }],
[{ publicGroupList: [{ name: 'Homeserver', monitorList: [{ id: {} }] }] }, hb()],
]) {
const s = parse(page, heartbeat);
assert.equal(pickBanner(s, { syncLost: true, where: 'client' }), undefined);
}
assert.equal(pickBanner(null, { syncLost: true, where: 'client' }), undefined);
});
test('beat times are read as UTC; checkedAt is the oldest latest beat', () => {
assert.equal(kumaBeatTime('2026-09-29 14:16:11.804'), Date.UTC(2026, 8, 29, 14, 16, 11, 804));
assert.equal(kumaBeatTime('nope'), undefined);
const s = parse(PAGE, hb());
assert.equal(s.homeserverCheckedAt, Date.UTC(2026, 8, 29, 14, 12));
});
test('"your connection" only when Kuma checked AFTER the drop and the server was up', () => {
const s = parse(PAGE, hb());
const checked = s.homeserverCheckedAt!;
assert.equal(serverConfirmedUpSince(s, checked - 60_000), true, 'checked after the drop');
assert.equal(serverConfirmedUpSince(s, checked + 1), false, 'stale: checked before the drop');
assert.equal(serverConfirmedUpSince(s, null), false, 'not disconnected');
const down = parse(PAGE, hb({ 30: [UP, DOWN, DOWN] }));
assert.equal(serverConfirmedUpSince(down, checked - 60_000), false);
assert.equal(serverConfirmedUpSince(null, 1), false);
});
test('server down vs having problems depends on this client’s own connection', () => {
const s = parse(PAGE, hb({ 30: [DOWN, DOWN] }));
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.kind, 'server-down');
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.tone, 'Critical');
assert.equal(pickBanner(s, { syncLost: false, where: 'client' })?.kind, 'server-problems');
assert.equal(pickBanner(s, { syncLost: false, where: 'login' })?.kind, 'server-down');
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.dismissable, false);
});
test('priority: server > maintenance > calls > announcement; one strip only', () => {
const s: ServerStatus = {
...parse(PAGE, hb({ 30: [DOWN, DOWN], 37: [DOWN, DOWN] })),
maintenance: [{ id: '1', title: 'Upgrade', description: '' }],
incidents: [{ id: '2', title: 'Heads up', content: '', style: 'info', updated: 'x' }],
};
assert.equal(pickBanner(s, { syncLost: true, where: 'client' })?.kind, 'server-down');
assert.equal(
pickBanner({ ...s, homeserver: 'up' }, { syncLost: false, where: 'client' })?.kind,
'maintenance',
);
assert.equal(
pickBanner({ ...s, homeserver: 'up', maintenance: [] }, { syncLost: false, where: 'client' })
?.kind,
'calls-down',
);
assert.equal(
pickBanner(
{ ...s, homeserver: 'up', maintenance: [], calls: 'up' },
{ syncLost: false, where: 'client' },
)?.kind,
'announcement',
);
});
test('calls-down is not shown on the login screen or when the whole server is down', () => {
const calls = parse(PAGE, hb({ 37: [DOWN, DOWN] }));
assert.equal(pickBanner(calls, { syncLost: false, where: 'login' }), undefined);
const both = parse(PAGE, hb({ 30: [DOWN, DOWN], 37: [DOWN, DOWN] }));
assert.equal(pickBanner(both, { syncLost: false, where: 'client' })?.kind, 'server-problems');
});
test('login-down only on the login screen', () => {
const s = parse(PAGE, hb({ 15: [DOWN, DOWN] }));
assert.equal(pickBanner(s, { syncLost: false, where: 'login' })?.kind, 'login-down');
assert.equal(pickBanner(s, { syncLost: false, where: 'client' }), undefined);
});
test('maintenance: title, end time and plain-text description', () => {
const end = '2026-09-29T22:15:00.000Z';
const s = parse(
{
...PAGE,
maintenanceList: [
{
id: 7,
title: 'Homeserver upgrade',
description: '**Synapse 1.160**, see [notes](https://x)',
status: 'under-maintenance',
timeslotList: [{ startDate: '2026-09-29T22:00:00.000Z', endDate: end }],
},
{ id: 8, title: 'Later', status: 'scheduled' },
],
},
hb(),
);
assert.equal(s.maintenance.length, 1, 'only windows under maintenance');
assert.equal(s.maintenance[0].end, Date.parse(end));
const b = pickBanner(s, { syncLost: false, where: 'client' })!;
assert.equal(b.kind, 'maintenance');
assert.match(b.text, /^Maintenance in progress until .+: Homeserver upgrade\. /);
assert.equal(b.detail, 'Synapse 1.160, see notes');
assert.equal(b.dismissable, false);
});
test('announcements: tone from style, dismissable, a new edit comes back', () => {
const incident = {
id: 3,
style: 'danger',
title: 'Planned maintenance tonight',
content: 'Calls will drop briefly.',
active: true,
createdDate: '2026-09-29 15:00:00',
lastUpdatedDate: null,
};
const s = parse({ ...PAGE, incidents: [incident] }, hb());
const b = pickBanner(s, { syncLost: false, where: 'client' })!;
assert.equal(b.kind, 'announcement');
assert.equal(b.tone, 'Critical');
assert.equal(b.dismissable, true);
const dismissed = new Set([b.key]);
assert.equal(pickBanner(s, { syncLost: false, where: 'client', dismissed }), undefined);
const edited = parse(
{ ...PAGE, incidents: [{ ...incident, lastUpdatedDate: '2026-09-29 16:00:00' }] },
hb(),
);
assert.equal(
pickBanner(edited, { syncLost: false, where: 'client', dismissed })?.kind,
'announcement',
);
const inactive = parse({ ...PAGE, incidents: [{ ...incident, active: false }] }, hb());
assert.equal(pickBanner(inactive, { syncLost: false, where: 'client' }), undefined);
const info = parse({ ...PAGE, incidents: [{ ...incident, style: 'info' }] }, hb());
assert.equal(pickBanner(info, { syncLost: false, where: 'client' })?.tone, 'Primary');
});
test('a dismissed calls-down banner lets the next one through, never hides server-down', () => {
const s = parse(PAGE, hb({ 37: [DOWN, DOWN] }));
const dismissed = new Set(['calls-down', 'server-down', 'server-problems']);
assert.equal(pickBanner(s, { syncLost: false, where: 'client', dismissed }), undefined);
const down = parse(PAGE, hb({ 30: [DOWN, DOWN] }));
assert.equal(
pickBanner(down, { syncLost: true, where: 'client', dismissed })?.kind,
'server-down',
);
});