From 7a63ff2bfe2221b02c551b76d95c76f5d20eb235 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:24:38 +0100 Subject: [PATCH 01/12] feat(web-console): humanised activity view and connection health The connection panel shows what happened on a connection as sentences (connected, handshake, pairing established, relayed messages, peers seen, certificate changes) derived from the session's state changes and frames, with the raw frame log behind a toggle. Health is measured without a button press: a ping probes the node when the connection comes up and on an interval, each pong becoming a sample in an RTT sparkline and each unanswered probe a recorded loss; a node that never answers is left alone. A dropped connection shows when the next attempt is due and how many remain, from the same reconnect policy the session uses, and the directory shows when each peer was last seen. --- ts/packages/web-console/src/App.tsx | 25 +-- ts/packages/web-console/src/activity.ts | 205 +++++++++++++++++ .../src/components/ActivityLog.tsx | 95 ++++++++ .../src/components/CertificateTrustPanel.tsx | 5 +- .../src/components/ConnectionHealth.tsx | 76 +++++++ .../src/components/ConnectionPanel.tsx | 108 +++++---- .../src/components/RttSparkline.tsx | 81 +++++++ .../web-console/src/format-duration.ts | 23 ++ .../src/hooks/use-certificate-trust.ts | 21 +- .../src/hooks/use-connection-health.ts | 78 +++++++ .../src/hooks/use-mesh-session-events.ts | 45 +++- ts/packages/web-console/src/hooks/use-now.ts | 24 ++ .../web-console/src/reconnect-policy.ts | 17 ++ .../web-console/test/App.unit.test.tsx | 43 ++++ .../test/CertificateTrustPanel.unit.test.tsx | 19 ++ .../test/RttSparkline.unit.test.tsx | 63 ++++++ .../web-console/test/activity.unit.test.ts | 212 ++++++++++++++++++ .../test/format-duration.unit.test.ts | 42 ++++ .../test/use-connection-health.unit.test.tsx | 131 +++++++++++ 19 files changed, 1243 insertions(+), 70 deletions(-) create mode 100644 ts/packages/web-console/src/activity.ts create mode 100644 ts/packages/web-console/src/components/ActivityLog.tsx create mode 100644 ts/packages/web-console/src/components/ConnectionHealth.tsx create mode 100644 ts/packages/web-console/src/components/RttSparkline.tsx create mode 100644 ts/packages/web-console/src/format-duration.ts create mode 100644 ts/packages/web-console/src/hooks/use-connection-health.ts create mode 100644 ts/packages/web-console/src/hooks/use-now.ts create mode 100644 ts/packages/web-console/src/reconnect-policy.ts create mode 100644 ts/packages/web-console/test/RttSparkline.unit.test.tsx create mode 100644 ts/packages/web-console/test/activity.unit.test.ts create mode 100644 ts/packages/web-console/test/format-duration.unit.test.ts create mode 100644 ts/packages/web-console/test/use-connection-health.unit.test.tsx diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 20762c80..47768732 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -11,10 +11,7 @@ import { Title, } from "@mantine/core"; import { createMeshSession } from "wire-mesh-core/domain/mesh-session"; -import type { - MeshSession, - ReconnectPolicy, -} from "wire-mesh-core/domain/mesh-session"; +import type { MeshSession } from "wire-mesh-core/domain/mesh-session"; import { createGossipExpansion } from "wire-mesh-core/domain/gossip-expansion"; import type { GossipExpansion, @@ -26,6 +23,7 @@ import type { Connection } from "wire-mesh-core/ports/transport"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { dmRoomPath } from "wire-mesh-core/domain/room-path"; +import { reconnectPolicy } from "./reconnect-policy.js"; import { createDialTransport } from "./adapters/dial-transport.js"; import type { CertificateMemory } from "./certificate-memory.js"; import { DEFAULT_ICE_SERVERS } from "./ice-servers.js"; @@ -72,20 +70,6 @@ const DEFAULT_ADDRESS = "ws://localhost:8787"; const AVAILABLE_DOMAINS = ["core/management", "core/exec", "core/data"]; const DEFAULT_DOMAINS = ["core/management", "core/data"]; -// Exponential backoff, capped at 30s, giving up after 5 attempts -- reasonable defaults for a browser console reconnecting to a relay that may just be restarting, without retrying forever against one that is genuinely gone. -const RECONNECT_MAX_ATTEMPTS = 5; -const RECONNECT_BASE_DELAY_MS = 1000; -const RECONNECT_MAX_DELAY_MS = 30_000; -const RECONNECT_BACKOFF_BASE = 2; -const reconnectPolicy: ReconnectPolicy = { - maxAttempts: RECONNECT_MAX_ATTEMPTS, - delayMs: (attempt) => - Math.min( - RECONNECT_BASE_DELAY_MS * RECONNECT_BACKOFF_BASE ** (attempt - 1), - RECONNECT_MAX_DELAY_MS, - ), -}; - /** A direct connection attempt that did not open, kept on screen until dismissed so a refusal or a failure to negotiate is never silent. The conversation itself carries on through the hub. */ interface ConnectionFailure { key: string; @@ -121,7 +105,7 @@ export function App({ const [connections, setConnections] = useState([]); const [discovered, setDiscovered] = useState([]); const [failures, setFailures] = useState([]); - const trust = useCertificateTrust(certificateMemory); + const trust = useCertificateTrust(certificateMemory, clock); const roomMessaging = useRoomMessaging(identity, clock, messageStore); const [selectedPath, setSelectedPath] = useState(); // The conversation shown is the one the user picked, falling back to the first while nothing is picked or the picked one no longer exists. @@ -405,6 +389,9 @@ export function App({ key={entry.address} address={entry.address} session={entry.session} + clock={clock} + reconnectPolicy={reconnectPolicy} + certificateChanges={trust.changes} onClose={() => { handleClose(entry); }} diff --git a/ts/packages/web-console/src/activity.ts b/ts/packages/web-console/src/activity.ts new file mode 100644 index 00000000..df87c47f --- /dev/null +++ b/ts/packages/web-console/src/activity.ts @@ -0,0 +1,205 @@ +// The console's human reading of a session: what a person wants to know happened ("connected", "pairing established", "peer seen", "certificate changed"), derived from the session's state changes and the frames that crossed it. The raw frames stay available beside it for debugging. Pure, so the wording and the change detection can be tested without a session. + +import type { SessionEvent } from "wire-mesh-core/domain/mesh-session"; +import type { DeviceId, Frame } from "wire-mesh-core/generated/protocol"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; + +export type ActivityKind = + "connection" | "peer" | "pairing" | "relay" | "certificate"; + +export interface ActivityEntry { + key: string; + /** When the console observed it, from the injected clock. */ + at: number; + kind: ActivityKind; + /** The sentence, which ends where the peer's name goes when `peer` is set. */ + summary: string; + /** Hex device-id of the peer the entry is about, named by the console's naming convention when shown. */ + peer: string | undefined; +} + +/** What has already been turned into entries, so the next event yields only what is new. */ +export interface ActivityTracker { + /** How many frame-log entries have been read. The log only ever grows. */ + frames: number; + /** The connection stage last reported: the status, with the attempt number while reconnecting, so each failed retry is a change and a repeated event is not. */ + stage: string | undefined; + handshake: string | undefined; + /** Sequence for entry keys. */ + issued: number; +} + +export const initialTracker: ActivityTracker = { + frames: 0, + stage: undefined, + handshake: undefined, + issued: 0, +}; + +type Described = Pick; + +function hex(device: DeviceId): string { + return deviceIdToHex(device); +} + +/** What one frame means to a person, or nothing for plumbing (pings, handshakes and the like have their own views or are only for the raw log). */ +export function describeFrame( + direction: "sent" | "received", + frame: Readonly, +): Described[] { + if (frame.type === "gossip") { + return direction === "received" + ? frame.peers.map((advert) => ({ + kind: "peer", + summary: "Peer seen:", + peer: hex(advert.device), + })) + : [ + { + kind: "peer", + summary: "Advertised this console to the node", + peer: undefined, + }, + ]; + } + if (frame.type === "relay-connect") { + return [ + { + kind: "pairing", + summary: "Asked the node to pair this console with", + peer: hex(frame["target-device"]), + }, + ]; + } + if (frame.type === "relay-inbound") { + return [ + { + kind: "pairing", + summary: "Pairing established with", + peer: hex(frame["source-device"]), + }, + ]; + } + if (frame.type === "relay-data") { + const other = + direction === "sent" ? frame["to-device"] : frame["from-device"]; + return [ + { + kind: "relay", + summary: + direction === "sent" + ? "Relayed a message to" + : "Relayed message received from", + peer: other === undefined ? undefined : hex(other), + }, + ]; + } + if (frame.type === "close") { + return [ + { + kind: "connection", + summary: "The node closed the connection", + peer: undefined, + }, + ]; + } + return []; +} + +function connectionStage(state: Readonly): string { + return state.status === "reconnecting" + ? `reconnecting:${String(state.attempt)}` + : state.status; +} + +function describeState( + previous: Readonly, + state: Readonly, + handshake: string | undefined, +): Described[] { + const entries: Described[] = []; + const connection = (summary: string): void => { + entries.push({ kind: "connection", summary, peer: undefined }); + }; + if (connectionStage(state) !== previous.stage) { + switch (state.status) { + case "connecting": + connection(`Connecting to ${state.address}`); + break; + case "connected": + connection(`Connected to ${state.address}`); + break; + case "reconnecting": + connection( + `${previous.stage?.startsWith("reconnecting") === true ? "Retry failed" : "Connection lost"} (${state.reason}); retrying, attempt ${String(state.attempt)}`, + ); + break; + case "closed": + connection(`Closed (${state.reason})`); + break; + case "idle": + break; + } + } + if (state.status === "connected" && handshake !== previous.handshake) { + if (state.handshake.status === "negotiated") { + connection( + `Handshake negotiated: ${state.handshake.sharedDomains.join(", ")}`, + ); + } else if (state.handshake.status === "unanswered") { + connection("The node does not answer handshakes (a relay-only node)"); + } else if (state.handshake.status === "rejected") { + connection(`Handshake rejected (${state.handshake.reason})`); + } + } + return entries; +} + +/** The handshake stage of a state, or undefined when not connected. */ +function handshakeStage( + state: Readonly, +): string | undefined { + return state.status === "connected" ? state.handshake.status : undefined; +} + +/** The entries an event adds beyond what `tracker` has already seen, and the tracker to use for the next event. */ +export function advanceActivity( + tracker: Readonly, + event: Readonly, + at: number, +): { tracker: ActivityTracker; entries: ActivityEntry[] } { + const handshake = handshakeStage(event.state); + const described: Described[] = [ + ...describeState(tracker, event.state, handshake), + ...event.frameLog + .slice(tracker.frames) + .flatMap((entry) => describeFrame(entry.direction, entry.frame)), + ]; + const entries = described.map((entry, index): ActivityEntry => ({ + ...entry, + key: `activity-${String(tracker.issued + index)}`, + at, + })); + return { + tracker: { + frames: event.frameLog.length, + stage: connectionStage(event.state), + handshake, + issued: tracker.issued + entries.length, + }, + entries, + }; +} + +/** The activity entries for certificate changes announced by a node, for merging into that node's connection activity. */ +export function certificateActivity( + changes: readonly { key: string; at: number; node: string }[], +): ActivityEntry[] { + return changes.map((change) => ({ + key: `certificate-${change.key}`, + at: change.at, + kind: "certificate", + summary: `Certificate changed: ${change.node} announced certificates unlike the remembered ones`, + peer: undefined, + })); +} diff --git a/ts/packages/web-console/src/components/ActivityLog.tsx b/ts/packages/web-console/src/components/ActivityLog.tsx new file mode 100644 index 00000000..d931e6c1 --- /dev/null +++ b/ts/packages/web-console/src/components/ActivityLog.tsx @@ -0,0 +1,95 @@ +// The connection's activity as sentences with their times, and the raw frame log behind a toggle for debugging. + +import { useState } from "react"; +import { Switch, Table, Text } from "@mantine/core"; +import type { FrameLogEntry } from "wire-mesh-core/domain/mesh-session"; +import type { ActivityEntry } from "../activity.js"; +import { PeerName } from "./PeerName.js"; + +function describeRawFrame(frame: unknown): string { + return JSON.stringify(frame, (_key: string, value: unknown): unknown => + value instanceof Uint8Array ? `<${String(value.byteLength)} bytes>` : value, + ); +} + +export interface ActivityLogProps { + activity: readonly ActivityEntry[]; + frameLog: readonly FrameLogEntry[]; +} + +export function ActivityLog({ + activity, + frameLog, +}: Readonly): React.JSX.Element { + const [raw, setRaw] = useState(false); + return ( + <> + { + setRaw(event.currentTarget.checked); + }} + /> + {raw ? ( + + + + + direction + frame + + + + {frameLog.map((entry, index) => ( + + {entry.direction} + {describeRawFrame(entry.frame)} + + ))} + +
+
+ ) : ( + <> + + Activity + + {activity.length === 0 ? ( + + Nothing has happened yet. + + ) : ( + + + + {[...activity].reverse().map((entry) => ( + + + {new Date(entry.at).toLocaleTimeString()} + + + {entry.summary} + {entry.peer !== undefined && ( + <> + {" "} + + + )} + + + ))} + +
+
+ )} + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx index 5ebac0bc..fcd0f9f4 100644 --- a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx +++ b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx @@ -89,7 +89,8 @@ export function CertificateTrustPanel({ changes, onDismissChange, }: Readonly): React.JSX.Element | null { - if (prompts.length === 0 && changes.length === 0) { + const undismissed = changes.filter((change) => !change.dismissed); + if (prompts.length === 0 && undismissed.length === 0) { return null; } return ( @@ -97,7 +98,7 @@ export function CertificateTrustPanel({ {prompts.map((prompt) => ( ))} - {changes.map((change) => ( + {undismissed.map((change) => ( ; + /** When the connection's status last changed, which is when a reconnect's backoff started. */ + statusSince: number | undefined; + now: number; + policy: Readonly; + health: Readonly; +} + +function ReconnectStatus({ + state, + statusSince, + now, + policy, +}: Readonly< + Pick & { + state: Extract; + } +>): React.JSX.Element { + const retryAt = + statusSince === undefined + ? undefined + : statusSince + policy.delayMs(state.attempt); + const due = retryAt === undefined ? 0 : retryAt - now; + return ( + + {due > 0 ? `Retrying in ${formatRemaining(due)}` : "Retrying now"}{" "} + (attempt {state.attempt} of {policy.maxAttempts}). Last failure:{" "} + {state.reason} + + ); +} + +export function ConnectionHealth({ + state, + statusSince, + now, + policy, + health, +}: Readonly): React.JSX.Element | null { + if (state.status === "reconnecting") { + return ( + + ); + } + if (state.status !== "connected") { + return null; + } + return ( + + + {health.unresponsive && ( + + This node does not answer pings, so round trips are not measured + automatically. + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index 0e4c13e4..a56ba200 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,18 +1,30 @@ // One open relay/hub connection's own UI: status line, ping/disconnect controls, peer directory, and frame log. All behaviour lives in wire-mesh-core's own mesh-session domain module; this component only renders whatever useMeshSessionEvents last reported and forwards clicks back onto the session. -import { Button, Card, Group, Table, Text } from "@mantine/core"; +import { Button, Card, Group, Table, Text, Tooltip } from "@mantine/core"; import type { MeshSession, + ReconnectPolicy, SessionEvent, } from "wire-mesh-core/domain/mesh-session"; +import type { Clock } from "wire-mesh-core/ports/clock"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; import { selfNameExtension } from "../peer-names.js"; +import { useConnectionHealth } from "../hooks/use-connection-health.js"; +import { useNow } from "../hooks/use-now.js"; +import { certificateActivity } from "../activity.js"; +import { formatAgo } from "../format-duration.js"; +import { presentedCertificates } from "../certificate-trust.js"; +import { toDialAddress } from "../dial-address.js"; +import type { NodeCertificateChange } from "../hooks/use-certificate-trust.js"; +import { ActivityLog } from "./ActivityLog.js"; +import { ConnectionHealth } from "./ConnectionHealth.js"; import { PeerLabel } from "./PeerLabel.js"; const HEX_RADIX = 16; +const MS_PER_SECOND = 1000; export function deviceHex(device: Uint8Array): string { let hex = ""; @@ -56,26 +68,35 @@ function describeStatus(event: Readonly): string { return ""; } -function describeFrame(frame: unknown): string { - return JSON.stringify(frame, (_key: string, value: unknown): unknown => - value instanceof Uint8Array ? `<${String(value.byteLength)} bytes>` : value, - ); -} - export interface ConnectionPanelProps { address: string; session: Readonly; + clock: Readonly; + /** The backoff the session redials with, so the panel can say when the next attempt is due. */ + reconnectPolicy: Readonly; + /** Every certificate change seen for any node; the panel shows those for its own node in its activity. */ + certificateChanges: readonly NodeCertificateChange[]; onClose: () => void; onMessagePeer: (device: DeviceId) => void; } +/** How often elapsed times and the reconnect countdown refresh. */ +const CLOCK_TICK_MS = 1000; + export function ConnectionPanel({ address, session, + clock, + reconnectPolicy, + certificateChanges, onClose, onMessagePeer, }: Readonly): React.JSX.Element { - const event = useMeshSessionEvents(session); + const { + event, + activity: sessionActivity, + statusSince, + } = useMeshSessionEvents(session, clock); const { observeDirectory, selfName } = usePeerNames(); const connected = event?.state.status === "connected"; useEffect(() => { @@ -94,19 +115,31 @@ export function ConnectionPanel({ const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; const frameLog = event?.frameLog ?? []; + const health = useConnectionHealth(session, connected); + const reconnecting = event?.state.status === "reconnecting"; + const now = useNow( + clock, + reconnecting || directory.length > 0, + CLOCK_TICK_MS, + ); + const node = presentedCertificates(toDialAddress(address))?.node; + const activity = useMemo( + () => + [ + ...sessionActivity, + ...certificateActivity( + certificateChanges.filter((change) => change.node === node), + ), + ].sort((a, b) => a.at - b.at), + [sessionActivity, certificateChanges, node], + ); return ( {address} -