feat: homeserver status banner from Uptime Kuma (#124)
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
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d6548c56fd
commit
6ae0087213
@@ -0,0 +1,162 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
HS,
|
||||
createRoom,
|
||||
ensureUser,
|
||||
hsReachable,
|
||||
loginUI,
|
||||
openRoom,
|
||||
uniq,
|
||||
TestUser,
|
||||
} from './localHs';
|
||||
|
||||
// [Gitea #124] Status banner from the homeserver's Uptime Kuma page. Kuma is
|
||||
// answered by fixtures here; nothing contacts a real Kuma.
|
||||
const KUMA = 'https://isitup.lotusguild.org';
|
||||
const PAGE_CFG = { url: KUMA, slug: 'matrix' };
|
||||
const UP = 1;
|
||||
const DOWN = 0;
|
||||
|
||||
type Fixture = { hs?: number; calls?: number; staleMinutes?: number; fail?: boolean };
|
||||
|
||||
const kumaTime = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
|
||||
/** Serve config.json with a status page for `serverName` and answer Kuma from `fx()`. */
|
||||
async function mockKuma(page: Page, serverName: string | null, fx: () => Fixture) {
|
||||
const kumaRequests: string[] = [];
|
||||
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const cfg = await res.json();
|
||||
cfg.statusPages = serverName ? { [serverName]: PAGE_CFG } : {};
|
||||
await route.fulfill({ response: res, json: cfg });
|
||||
});
|
||||
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
|
||||
const url = route.request().url();
|
||||
kumaRequests.push(url);
|
||||
const f = fx();
|
||||
if (f.fail) {
|
||||
await route.fulfill({ status: 500, body: 'oops' });
|
||||
return;
|
||||
}
|
||||
const cors = { 'access-control-allow-origin': '*' };
|
||||
if (url.includes('/heartbeat/')) {
|
||||
// Two checks, the latest `staleMinutes` ago (0 = just now).
|
||||
const last = Date.now() - (f.staleMinutes ?? 0) * 60_000;
|
||||
const beats = (s: number) => [
|
||||
{ status: s, time: kumaTime(last - 60_000) },
|
||||
{ status: s, time: kumaTime(last) },
|
||||
];
|
||||
await route.fulfill({
|
||||
headers: cors,
|
||||
json: { heartbeatList: { 1: beats(f.hs ?? UP), 2: beats(f.calls ?? UP) }, uptimeList: {} },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
headers: cors,
|
||||
json: {
|
||||
config: { slug: 'matrix' },
|
||||
incidents: [],
|
||||
maintenanceList: [],
|
||||
publicGroupList: [
|
||||
{ name: 'Homeserver', monitorList: [{ id: 1 }] },
|
||||
{ name: 'Voice calls', monitorList: [{ id: 2 }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
return kumaRequests;
|
||||
}
|
||||
|
||||
const serverOf = (u: TestUser) => u.userId.split(':').slice(1).join(':');
|
||||
const strip = (page: Page, text: RegExp) =>
|
||||
page.locator('[role="status"], [role="alert"]').filter({ hasText: text });
|
||||
const cutSync = (page: Page) =>
|
||||
page.route(/\/_matrix\/client\/v3\/sync/, (route) => route.abort('connectionfailed'));
|
||||
|
||||
test.describe('server status banner (#124)', () => {
|
||||
let alice: TestUser;
|
||||
let room: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
|
||||
alice = await ensureUser(uniq('e2e_status_'));
|
||||
room = await createRoom(alice, 'Status Room');
|
||||
});
|
||||
|
||||
test('calls down: amber strip, dismiss sticks across a reload', async ({ page }) => {
|
||||
await mockKuma(page, serverOf(alice), () => ({ calls: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
const calls = strip(page, /Voice calls are down right now\. Messages still work\./);
|
||||
await expect(calls).toBeVisible();
|
||||
await calls.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await expect(calls).toHaveCount(0);
|
||||
await page.reload();
|
||||
await openRoom(page, room);
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(calls).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('other homeservers never contact Kuma', async ({ page }) => {
|
||||
const requests = await mockKuma(page, 'some.other.server', () => ({ hs: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await page.waitForTimeout(3000);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
test('Kuma failing shows nothing (fail quiet)', async ({ page }) => {
|
||||
const requests = await mockKuma(page, serverOf(alice), () => ({ fail: true }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await expect.poll(() => requests.length).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(strip(page, /server|Voice calls|Maintenance/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('connection lost + Kuma says the server is down: critical strip, not "Connection Lost"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
// Connected: the server is "having problems" for others, not for us.
|
||||
await expect(strip(page, /server is having problems/)).toBeVisible();
|
||||
await cutSync(page);
|
||||
const down = page.getByRole('alert').filter({ hasText: /server is down\. We're on it/ });
|
||||
await expect(down).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByText('Connection Lost! Reconnecting...')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('connection lost + Kuma checked since and the server is up: "may be your connection"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: UP }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await cutSync(page);
|
||||
await expect(page.getByText(/server is up, so it may be your internet connection/)).toBeVisible(
|
||||
{
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('connection lost + only a stale "up" from Kuma: plain "Connection Lost"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: UP, staleMinutes: 10 }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await cutSync(page);
|
||||
await expect(page.getByText('Connection Lost! Reconnecting...')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
await expect(page.getByText(/may be your internet connection/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user