diff --git a/config.json b/config.json index a31a83e69..b7bb2d3b1 100644 --- a/config.json +++ b/config.json @@ -12,5 +12,16 @@ "enabled": false, "basename": "/" }, - "gifApiKey": "" + "gifApiKey": "", + "statusPages": { + "matrix.lotusguild.org": { + "url": "https://isitup.lotusguild.org", + "slug": "matrix", + "groups": { + "homeserver": "Homeserver", + "calls": "Voice calls", + "login": "Login" + } + } + } } diff --git a/e2e/server-status.spec.ts b/e2e/server-status.spec.ts new file mode 100644 index 000000000..2cc29becf --- /dev/null +++ b/e2e/server-status.spec.ts @@ -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); + }); +}); diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx index c6ac4afb1..b9c3be05c 100644 --- a/src/app/features/call/PrescreenControls.tsx +++ b/src/app/features/call/PrescreenControls.tsx @@ -8,6 +8,7 @@ import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEm import { useCallPreferences } from '../../state/hooks/callPreferences'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; +import { useCallsDown } from '../server-status/ServerStatusBanner'; type MediaPermState = 'granted' | 'denied' | 'prompt' | 'unknown'; @@ -61,6 +62,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) { const micPermission = useMediaPermissions(); const micDenied = micPermission === 'denied'; + const callsDown = useCallsDown(); const disabled = inOtherCall || !canJoin || micDenied; @@ -91,6 +93,11 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) { + {callsDown && ( + + Voice calls are down right now, so joining may fail. + + )} {micDenied && ( Microphone access is blocked. Enable it in your browser settings to join. diff --git a/src/app/features/server-status/ServerStatusBanner.tsx b/src/app/features/server-status/ServerStatusBanner.tsx new file mode 100644 index 000000000..62d8b689b --- /dev/null +++ b/src/app/features/server-status/ServerStatusBanner.tsx @@ -0,0 +1,168 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { MatrixClient, SyncState } from 'matrix-js-sdk'; +import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { useSyncState } from '../../hooks/useSyncState'; +import { useServerStatus } from '../../hooks/useServerStatus'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { serverStatusAtom } from '../../state/serverStatus'; +import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus'; + +const DISMISS_KEY = 'lotus-status-banner-dismissed'; +const DISMISS_MAX = 50; + +const readDismissed = (): string[] => { + try { + const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]'); + return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : []; + } catch { + return []; + } +}; + +const bannerIcon = (b: StatusBanner): IconSrc => { + if (b.kind === 'maintenance') return Icons.Setting; + if (b.tone === 'Primary') return Icons.Info; + return Icons.Warning; +}; + +/** + * [Gitea #124] One status strip, in the same slot and style as the sync and + * clock banners. Presentational: the caller picks the banner. + */ +export function StatusStrip({ + banner, + onDismiss, +}: { + banner: StatusBanner; + onDismiss?: () => void; +}) { + const [open, setOpen] = useState(false); + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + useEffect(() => setOpen(false), [banner.key]); + const critical = banner.tone === 'Critical'; + return ( + + + + + + {banner.text} + + {banner.detail && ( + + )} + {banner.dismissable && onDismiss && ( + + + + )} + + {banner.detail && open && ( + + + {banner.detail} + + + )} + + + + ); +} + +/** Runs the Kuma poller for the logged-in homeserver and publishes it. */ +export function ServerStatusFeature({ mx }: { mx: MatrixClient }) { + const { statusPages } = useClientConfig(); + const page = useMemo( + () => resolveStatusPage(statusPages, mx.getDomain() ?? undefined), + [statusPages, mx], + ); + const [lostAt, setLostAt] = useState(null); + useSyncState( + mx, + useCallback((state: SyncState | null) => { + const lost = state === SyncState.Reconnecting || state === SyncState.Error; + setLostAt((prev) => { + if (!lost) return null; + return prev ?? Date.now(); + }); + }, []), + ); + const syncLost = lostAt !== null; + const status = useServerStatus(page, syncLost); + const setAtom = useSetAtom(serverStatusAtom); + useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]); + return null; +} + +/** The logged-in app's status strip. */ +export function ServerStatusBanner() { + const { status, syncLost } = useAtomValue(serverStatusAtom); + const [dismissed, setDismissed] = useState(readDismissed); + const banner = pickBanner(status, { + syncLost, + where: 'client', + dismissed: new Set(dismissed), + }); + if (!banner) return null; + const dismiss = () => { + const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX); + try { + localStorage.setItem(DISMISS_KEY, JSON.stringify(next)); + } catch { + // storage unavailable — dismissed for this session only + } + setDismissed(next); + }; + return ; +} + +/** The sign-in screen's strip, for the homeserver being signed in to. */ +export function LoginStatusBanner({ serverName }: { serverName: string }) { + const { statusPages } = useClientConfig(); + const page = useMemo(() => resolveStatusPage(statusPages, serverName), [statusPages, serverName]); + const status = useServerStatus(page); + const banner = pickBanner(status, { syncLost: false, where: 'login' }); + return banner ? : null; +} + +/** Calls-down note for the call Join controls (null when calls are fine). */ +export function useCallsDown(): boolean { + const { status } = useAtomValue(serverStatusAtom); + return status?.calls === 'down' && status.homeserver !== 'down'; +} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index c6e0b0acb..91e68f5ea 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -35,6 +35,13 @@ export type ClientConfig = { */ desktopCallOrigin?: string; + /** + * [Gitea #124] Uptime Kuma status page per homeserver, for the status banner: + * { "": { url, slug, groups?: { homeserver, calls, login } } }. + * Homeservers not listed never contact Kuma. + */ + statusPages?: Record; + /** * Absolute https URL of the public web app (e.g. https://chat.lotusguild.org). * The desktop app sets it so it can hand calls it can't make to the browser. diff --git a/src/app/hooks/useServerStatus.ts b/src/app/hooks/useServerStatus.ts new file mode 100644 index 000000000..b4996ab39 --- /dev/null +++ b/src/app/hooks/useServerStatus.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { kumaUrls, parseKumaStatus, ServerStatus, StatusPageConfig } from '../utils/kumaStatus'; + +/** Normal poll, and while a problem is showing (so recovery clears quickly). */ +export const STATUS_POLL_MS = 5 * 60 * 1000; +export const STATUS_POLL_PROBLEM_MS = 60 * 1000; +const STATUS_FETCH_TIMEOUT_MS = 10 * 1000; +const STATUS_BACKOFF_MAX_MS = 15 * 60 * 1000; + +const fetchJson = async (url: string, signal: AbortSignal): Promise => { + const res = await fetch(url, { cache: 'no-store', credentials: 'omit', signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +}; + +export const hasProblem = (s: ServerStatus | null): boolean => + !!s && + (s.homeserver === 'down' || s.calls === 'down' || s.login === 'down' || s.maintenance.length > 0); + +/** + * [Gitea #124] Poll the homeserver's Kuma status page (none configured → no + * requests, null). Only while the page is visible; every 5 min, every 60 s + * while something is wrong, and at once when `urgent` turns true (this + * client's connection dropped). Any failure → null ("no banner"), with + * backoff: Kuma being unreachable must never look like Matrix being down. + */ +export const useServerStatus = ( + page: StatusPageConfig | undefined, + urgent = false, +): ServerStatus | null => { + const [status, setStatus] = useState(null); + const pageKey = page ? `${page.url}/${page.slug}` : ''; + + useEffect(() => { + if (!page) { + setStatus(null); + return undefined; + } + const urls = kumaUrls(page); + let timer: ReturnType | undefined; + let controller: AbortController | undefined; + let failures = 0; + let stopped = false; + let last: ServerStatus | null = null; + + const schedule = (ms: number) => { + if (timer) clearTimeout(timer); + timer = setTimeout(poll, ms); + }; + + async function poll() { + if (stopped) return; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + controller?.abort(); + controller = new AbortController(); + const abort = controller; + const timeout = setTimeout(() => abort.abort(), STATUS_FETCH_TIMEOUT_MS); + try { + const [pageJson, heartbeatJson] = await Promise.all([ + fetchJson(urls.page, abort.signal), + fetchJson(urls.heartbeat, abort.signal), + ]); + if (stopped) return; + failures = 0; + last = parseKumaStatus(pageJson, heartbeatJson, page!.groups); + setStatus(last); + // While this client's connection is lost, keep asking every minute: that + // is how it learns Kuma has checked the server since the drop. + schedule(hasProblem(last) || urgent ? STATUS_POLL_PROBLEM_MS : STATUS_POLL_MS); + } catch { + if (stopped) return; + failures += 1; + last = null; + setStatus(null); + schedule(Math.min(STATUS_POLL_PROBLEM_MS * 2 ** failures, STATUS_BACKOFF_MAX_MS)); + } finally { + clearTimeout(timeout); + } + } + + const onVisible = () => { + if (document.visibilityState === 'visible') poll(); + }; + document.addEventListener('visibilitychange', onVisible); + poll(); + + return () => { + stopped = true; + if (timer) clearTimeout(timer); + controller?.abort(); + document.removeEventListener('visibilitychange', onVisible); + }; + // pageKey identifies the page; the object itself is rebuilt from config. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageKey, urgent]); + + return status; +}; diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 6ce1674fb..228444ead 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -30,6 +30,7 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthServerProvider } from '../../hooks/useAuthServer'; import { tryDecodeURIComponent } from '../../utils/dom'; +import { LoginStatusBanner } from '../../features/server-status/ServerStatusBanner'; const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png'); @@ -125,89 +126,92 @@ export function AuthLayout() { discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : []; return ( - - - -
- - Lotus Chat Logo - - Lotus Chat - + + + + + +
+ + Lotus Chat Logo + + Lotus Chat + + +
+ + + + Homeserver + + + + {discoveryState.status === AsyncStatus.Loading && ( + + )} + {discoveryState.status === AsyncStatus.Error && ( + + )} + {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && ( + + )} + {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && ( + + )} + {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && ( + + + ( + + )} + error={() => ( + + )} + > + {(specVersions) => ( + + ( + + )} + error={() => ( + + )} + > + {(authFlows) => ( + + + + )} + + + )} + + + + )} -
- - - - Homeserver - - - - {discoveryState.status === AsyncStatus.Loading && ( - - )} - {discoveryState.status === AsyncStatus.Error && ( - - )} - {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && ( - - )} - {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && ( - - )} - {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && ( - - - ( - - )} - error={() => ( - - )} - > - {(specVersions) => ( - - ( - - )} - error={() => ( - - )} - > - {(authFlows) => ( - - - - )} - - - )} - - - - )} +
- -
-
+ +
); } diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index d74a83b94..304813674 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -44,6 +44,10 @@ import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { ClockSkewBanner } from './ClockSkewBanner'; +import { + ServerStatusBanner, + ServerStatusFeature, +} from '../../features/server-status/ServerStatusBanner'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; import { pushSessionToSW } from '../../../sw-session'; @@ -245,6 +249,8 @@ export function ClientRoot({ children }: ClientRootProps) { return ( + {mx && } + {mx && !syncError && } {mx && !syncError && } {mx && !syncError && } {loading && } diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 197225698..25d1042af 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -1,8 +1,11 @@ import { MatrixClient, SyncState } from 'matrix-js-sdk'; import React, { useCallback, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Box, config, Line, Text } from 'folds'; import { useSyncState } from '../../hooks/useSyncState'; import { ContainerColor } from '../../styles/ContainerColor.css'; +import { serverStatusAtom } from '../../state/serverStatus'; +import { serverConfirmedUpSince } from '../../utils/kumaStatus'; type StateData = { current: SyncState | null; @@ -13,6 +16,7 @@ type SyncStatusProps = { mx: MatrixClient; }; export function SyncStatus({ mx }: SyncStatusProps) { + const serverStatus = useAtomValue(serverStatusAtom); const [stateData, setStateData] = useState({ current: null, previous: undefined, @@ -53,7 +57,14 @@ export function SyncStatus({ mx }: SyncStatusProps) { ); } - if (stateData.current === SyncState.Reconnecting) { + // [Gitea #124] When Kuma confirms the homeserver is down, the status banner + // says so ("Lotus Chat's server is down…") instead of "Connection Lost!". + const serverDown = serverStatus.status?.homeserver === 'down'; + // Only blame the user's connection when Kuma has checked the server since + // the drop and it was fine (a stale "up" from before proves nothing). + const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt); + + if (stateData.current === SyncState.Reconnecting && !serverDown) { return ( - Connection Lost! Reconnecting... + + {serverUp + ? "Connection lost. Our status checks say Lotus Chat's server is up, so it may be your internet connection. Reconnecting…" + : 'Connection Lost! Reconnecting...'} + ); } - if (stateData.current === SyncState.Error) { + if (stateData.current === SyncState.Error && !serverDown) { return ( - Connection Lost! + + {serverUp + ? "Connection lost. Our status checks say Lotus Chat's server is up, so check your internet connection." + : 'Connection Lost!'} + diff --git a/src/app/state/serverStatus.ts b/src/app/state/serverStatus.ts new file mode 100644 index 000000000..9d4e94250 --- /dev/null +++ b/src/app/state/serverStatus.ts @@ -0,0 +1,18 @@ +import { atom } from 'jotai'; +import { ServerStatus } from '../utils/kumaStatus'; + +/** + * [Gitea #124] The homeserver's status from its Kuma status page (null: none + * configured, not fetched yet, or Kuma unreachable — all mean "no banner"), + * plus whether this client's own connection is currently lost. + */ +export const serverStatusAtom = atom<{ + status: ServerStatus | null; + syncLost: boolean; + /** When this client's connection dropped (ms), null while connected. */ + lostAt: number | null; +}>({ + status: null, + syncLost: false, + lostAt: null, +}); diff --git a/src/app/utils/kumaStatus.test.ts b/src/app/utils/kumaStatus.test.ts new file mode 100644 index 000000000..ad08f850c --- /dev/null +++ b/src/app/utils/kumaStatus.test.ts @@ -0,0 +1,262 @@ +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 = {}) => ({ + 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], + ['', 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', + ); +}); diff --git a/src/app/utils/kumaStatus.ts b/src/app/utils/kumaStatus.ts new file mode 100644 index 000000000..4b4b71837 --- /dev/null +++ b/src/app/utils/kumaStatus.ts @@ -0,0 +1,332 @@ +/** + * [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))); +};