CI / Build & Quality Checks (pull_request) Successful in 3m1s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Failing after 11m28s
Incident 2026-09-29: the homeserver's host ran out of memory and stalled for ~2 minutes. The /sync that finally went out carried events whose `age` was computed ~30 s before it arrived, so every client showed "Your computer's clock is 30 seconds ahead of the server" while the real problem was the server (all host clocks were within 0.25 s the whole evening). The skew estimate was the median of the last 5 samples, and a sample is local skew + delivery delay, so one late /sync with a handful of events tripped it. - Estimate = the LOWEST sample of the last 5 minutes: delay only ever adds, so the fastest-delivered event is the truest. - "Behind" (which a delay can't cause) is reported as soon as there are 3 samples, like before. "Ahead" must hold across samples received at least a minute apart, so a single late burst never trips it. - Samples are aged on the monotonic clock, and a change of the local clock (someone fixing it) resets the measurement, so the warning clears at once. - Only events stamped by our own homeserver are sampled: a federated event's origin_server_ts is the other server's clock. - Wording: "This device's clock is … Voice calls and encrypted messages can fail until it's corrected." / call bar "Device clock … : calls may fail" (was "will fail"). Unit tests: the incident (late burst after normal traffic, and a fresh client whose first samples are all late), mixed slow/fast deliveries, ahead only after a minute, behind at once, hysteresis, clock fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
import React, { useCallback, useState } from 'react';
|
|
import { useAtomValue } from 'jotai';
|
|
import { Box, Button, config, Line, Text } from 'folds';
|
|
import { clockSkewAtom } from '../../state/clockSkew';
|
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
|
import { clockFixHint, describeSkewVsServer, detectClockFixPlatform } from '../../utils/clockSkew';
|
|
|
|
const DISMISS_KEY = 'lotus-clock-skew-dismissed-until';
|
|
const DISMISS_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const readDismissedUntil = (): number => {
|
|
try {
|
|
const raw = localStorage.getItem(DISMISS_KEY);
|
|
const n = raw ? Number(raw) : 0;
|
|
return Number.isFinite(n) ? n : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* [Gitea #158] "This device's clock is 14 minutes ahead of the server."
|
|
* Same slot and style as the sync banners. Shown while the skew monitor is
|
|
* over its threshold; the direction matters, so it is said. Dismissable for
|
|
* 24 h; never auto-corrects anything.
|
|
*/
|
|
export function ClockSkewBanner() {
|
|
const { skewMs, warning } = useAtomValue(clockSkewAtom);
|
|
const [dismissedUntil, setDismissedUntil] = useState(readDismissedUntil);
|
|
const [showHint, setShowHint] = useState(false);
|
|
|
|
const dismiss = useCallback(() => {
|
|
const until = Date.now() + DISMISS_MS;
|
|
try {
|
|
localStorage.setItem(DISMISS_KEY, String(until));
|
|
} catch {
|
|
// storage unavailable — dismiss for this session only
|
|
}
|
|
setDismissedUntil(until);
|
|
}, []);
|
|
|
|
if (!warning || skewMs === null || Date.now() < dismissedUntil) return null;
|
|
|
|
return (
|
|
<Box direction="Column" shrink="No">
|
|
<Box
|
|
className={ContainerColor({ variant: 'Warning' })}
|
|
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
|
|
direction="Column"
|
|
alignItems="Center"
|
|
gap="100"
|
|
role="alert"
|
|
>
|
|
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
|
|
<Text size="L400" align="Center">
|
|
This device's clock is <b>{describeSkewVsServer(skewMs)}</b>. Voice calls and
|
|
encrypted messages can fail until it's corrected.
|
|
</Text>
|
|
<Button
|
|
size="300"
|
|
variant="Warning"
|
|
fill="Soft"
|
|
radii="300"
|
|
onClick={() => setShowHint((v) => !v)}
|
|
aria-expanded={showHint}
|
|
>
|
|
<Text size="B300">How to fix</Text>
|
|
</Button>
|
|
<Button size="300" variant="Warning" fill="None" radii="300" onClick={dismiss}>
|
|
<Text size="B300">Dismiss for 24 h</Text>
|
|
</Button>
|
|
</Box>
|
|
{showHint && (
|
|
<Text size="T200" align="Center">
|
|
{clockFixHint(detectClockFixPlatform(navigator.userAgent))}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Line variant="Warning" size="300" />
|
|
</Box>
|
|
);
|
|
}
|