From cf527fbf86abbe633a698cc45cba9c990d991151 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 6 Nov 2026 12:17:31 +0000 Subject: [PATCH] feat: show P2P diagnostics in the status pane and log Show expandable relay, peer, and sync observations below Stats and route Diag events to the existing Log Pane without notification or editor-status churn. Retain departed-peer evidence and replay logs recorded before the pane opens. Keep narrow panes usable with wrapping peer controls, readable device names, and larger buttons. Add browser regressions for disclosure, control bounds, button targets, and existing actions. --- .../messages/LiveSyncProvisionalMessages.ts | 6 + .../P2PReplicator/P2PServerStatusCard.svelte | 53 ++++- .../P2PReplicator/P2PServerStatusPane.svelte | 205 +++++++++++------- src/modules/features/Log/LogPane.svelte | 3 +- .../ModuleLog.diagnostics.unit.spec.ts | 81 +++++++ src/modules/features/ModuleLog.ts | 23 ++ src/modules/features/ModuleLog.unit.spec.ts | 56 ++++- src/modules/features/P2PDiagnosticDisplay.ts | 126 +++++++++++ .../P2PDiagnosticDisplay.unit.spec.ts | 102 +++++++++ test/p2p-diagnostics/README.md | 32 +++ test/p2p-diagnostics/host-ui.ts | 3 + test/p2p-diagnostics/log-pane-view.ts | 1 + test/p2p-diagnostics/log-pane.test.ts | 84 +++++++ test/p2p-diagnostics/log-pane/index.html | 3 + test/p2p-diagnostics/log-pane/main.ts | 15 ++ test/p2p-diagnostics/status-card/index.html | 4 + test/p2p-diagnostics/status-card/main.ts | 58 +++++ test/p2p-diagnostics/status-pane.test.ts | 101 +++++++++ test/p2p-diagnostics/status-pane/index.html | 11 + test/p2p-diagnostics/status-pane/main.ts | 67 ++++++ test/p2p-diagnostics/vite.config.mjs | 21 ++ updates.md | 11 + 22 files changed, 978 insertions(+), 88 deletions(-) create mode 100644 src/modules/features/ModuleLog.diagnostics.unit.spec.ts create mode 100644 src/modules/features/P2PDiagnosticDisplay.ts create mode 100644 src/modules/features/P2PDiagnosticDisplay.unit.spec.ts create mode 100644 test/p2p-diagnostics/README.md create mode 100644 test/p2p-diagnostics/host-ui.ts create mode 100644 test/p2p-diagnostics/log-pane-view.ts create mode 100644 test/p2p-diagnostics/log-pane.test.ts create mode 100644 test/p2p-diagnostics/log-pane/index.html create mode 100644 test/p2p-diagnostics/log-pane/main.ts create mode 100644 test/p2p-diagnostics/status-card/index.html create mode 100644 test/p2p-diagnostics/status-card/main.ts create mode 100644 test/p2p-diagnostics/status-pane.test.ts create mode 100644 test/p2p-diagnostics/status-pane/index.html create mode 100644 test/p2p-diagnostics/status-pane/main.ts create mode 100644 test/p2p-diagnostics/vite.config.mjs diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index ae63c87c..e52abf8f 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -9,6 +9,12 @@ * the same change. */ export const liveSyncProvisionalEnglishMessages = { + "Diagnostic log": "Diagnostic log", + "Open log": "Open log", + "Detailed P2P diagnostics and Log Pane output are enabled": + "Detailed P2P diagnostics and Log Pane output are enabled", + "Enable detailed P2P diagnostics and Log Pane output": + "Enable detailed P2P diagnostics and Log Pane output", "Enable SyncGuard": "Enable SyncGuard", "Enable device-local SyncGuard protection during synchronisation.": "Enable device-local SyncGuard protection during synchronisation.", diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte index 13e7a244..111fda26 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte @@ -14,6 +14,8 @@ import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; import { $msg as translateMessage } from "@/common/translation"; + import { observeP2PDiagnosticRows, type P2PDiagnosticRow } from "@/modules/features/P2PDiagnosticDisplay"; + import { VIEW_TYPE_LOG } from "@/modules/features/Log/LogPaneView"; interface Props { p2p: P2PServiceViews; @@ -29,6 +31,12 @@ const initialSettings = readCurrentSettings(); let roomSuffix = $state(extractP2PRoomSuffix(initialSettings?.P2P_roomID ?? "")); let useDiagRTC = $state(initialSettings?.P2P_useDiagRTC ?? false); + let diagnosticRows = $state([]); + + function openLog() { + const currentCore = core; + if (currentCore) fireAndForget(() => currentCore.services.API.showWindow(VIEW_TYPE_LOG)); + } async function requestServerStatus() { p2p.diagnostics.requestStatus(); @@ -66,6 +74,9 @@ } onMount(() => { + const unsubscribeDiagnostics = observeP2PDiagnosticRows(p2p.diagnostics, (rows) => { + diagnosticRows = rows; + }); const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => { serverInfo = status; roomSuffix = extractP2PRoomSuffix(status?.roomId ?? ""); @@ -84,6 +95,7 @@ }); return () => { + unsubscribeDiagnostics(); unsubscribe(); unsubscribeStatus(); unsubscribeSettings(); @@ -161,8 +173,8 @@ class="broadcast-button {useDiagRTC ? 'is-on' : 'is-off'}" onclick={toggleDiagRTC} title={useDiagRTC - ? translateMessage("Diagnostic RTCPeerConnection is enabled") - : translateMessage("Use Diagnostic RTCPeerConnection for statistics")} + ? translateMessage("Detailed P2P diagnostics and Log Pane output are enabled") + : translateMessage("Enable detailed P2P diagnostics and Log Pane output")} > {useDiagRTC ? translateMessage("On") : translateMessage("Off")} @@ -190,12 +202,48 @@ {serverInfo.diag.totalClosedConnections} +
+ {translateMessage("Diagnostic log")} +
+ {#each diagnosticRows as row (row.category)} +
+ {row.label}: {row.text}{#if row.time} ยท {row.time}{/if} +
+ {/each} + {#if core} + + {/if} +
+
{/if} +
diff --git a/test/p2p-diagnostics/status-card/main.ts b/test/p2p-diagnostics/status-card/main.ts new file mode 100644 index 00000000..08fc854a --- /dev/null +++ b/test/p2p-diagnostics/status-card/main.ts @@ -0,0 +1,58 @@ +import { mount } from "svelte"; +import Card from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte"; +import LogPane from "../../../src/modules/features/Log/LogPane.svelte"; +import { eventHub } from "../../../src/common/events"; +import { EVENT_SERVER_STATUS } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; +import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores"; +import { setGlobalLogFunction } from "octagonal-wheels/common/logger"; +import type { P2PDiagnosticEvent } from "@vrtmrz/livesync-commonlib/p2p"; + +setGlobalLogFunction(() => undefined); +const timestamp = 1_700_000_000_000; +const listeners = new Set<(event: P2PDiagnosticEvent) => void>(); +const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room" }; +const p2p = { + transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined }, + changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} }, + diagnostics: { + getDiagnosticSnapshot: () => ({ + relay: { category: "relay", code: "room-opened", reason: "relay-details-unobserved", timestamp }, + peer: { category: "peer", code: "peer-left", peerId: "departed-peer", timestamp }, + }), + onDiagnosticEvent: (listener: (event: P2PDiagnosticEvent) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + requestStatus: () => + eventHub.emitEvent(EVENT_SERVER_STATUS, { + isConnected: true, + knownAdvertisements: [], + serverPeerId: "fixture-self", + roomId: "fixture-room", + diag: { + totalNewConnections: 0, + totalSuccessfulConnections: 0, + totalFailedConnections: 0, + totalClosedConnections: 0, + details: {}, + }, + }), + }, +}; +const core = { + services: { + setting: { currentSettings: () => settings }, + API: { + showWindow: async (view: string) => { + document.querySelector("#opened")!.textContent = view; + logMessages.value = ["[P2P Diag] Earlier connection failure"]; + mount(LogPane, { target: document.querySelector("#logapp")!, props: { close() {} } }); + }, + }, + }, +}; +mount(Card, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } }); +document.querySelector("#complete")!.onclick = () => { + for (const listener of listeners) + listener({ category: "sync", code: "pull-completed", timestamp: timestamp + 1_000 }); +}; diff --git a/test/p2p-diagnostics/status-pane.test.ts b/test/p2p-diagnostics/status-pane.test.ts new file mode 100644 index 00000000..a27ca664 --- /dev/null +++ b/test/p2p-diagnostics/status-pane.test.ts @@ -0,0 +1,101 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +for (const width of [320, 390, 768]) { + test(`P2P pane controls fit a ${width}px viewport`, async () => { + const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + try { + const page = await browser.newPage({ + viewport: { width, height: 844 }, + colorScheme: width === 320 ? "dark" : "light", + }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157"; + await page.goto(`${base}/test/p2p-diagnostics/status-pane/index.html`); + await page.waitForSelector(".peer-item"); + assert.deepEqual(errors, [], "Fixture must load without browser errors"); + const undersized = await page.locator(".peers-section button").evaluateAll((buttons) => + buttons + .filter((button) => { + const rect = button.getBoundingClientRect(); + return rect.width < 43.5 || rect.height < 43.5; + }) + .map((button) => ({ + label: button.textContent?.trim(), + width: button.getBoundingClientRect().width, + height: button.getBoundingClientRect().height, + })) + ); + assert.deepEqual(undersized, [], "Peer buttons must have at least a 44px target at the default font size"); + const overflow = await page.locator(".peer-item").evaluateAll((items) => + items.flatMap((item, index) => { + const rect = item.getBoundingClientRect(); + const style = getComputedStyle(item); + const left = rect.left + parseFloat(style.paddingLeft) + parseFloat(style.borderLeftWidth); + const right = rect.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth); + return Array.from(item.querySelectorAll("button")).flatMap((button) => { + const bounds = button.getBoundingClientRect(); + return bounds.left < left - 1 || bounds.right > right + 1 + ? [ + { + peer: index, + button: button.textContent?.trim(), + left: bounds.left, + right: bounds.right, + container: [left, right], + }, + ] + : []; + }); + }) + ); + assert.deepEqual(overflow, [], "Every peer action must stay inside its card content"); + const clipped = await page + .locator(".peer-actions .action-button") + .evaluateAll((buttons) => + buttons + .filter((button) => button.scrollWidth > button.clientWidth + 1) + .map((button) => button.textContent?.trim()) + ); + assert.deepEqual(clipped, [], "Peer action labels must remain readable in full"); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + "Long names must not cause horizontal page overflow" + ); + assert.equal(await page.locator(".diag-grid").isVisible(), true); + assert.deepEqual(await page.locator(".diag-item > span:last-child").allTextContents(), [ + "4", + "2", + "1", + "1", + ]); + if (process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX) { + await page.screenshot({ + path: `${process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX}-${width}.png`, + fullPage: true, + }); + } + await page.locator(".peer-item").nth(0).getByRole("button", { name: "Replicate now", exact: true }).click(); + assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), { + sync: "permanent-peer", + }); + await page + .locator(".peer-item") + .nth(2) + .getByRole("button", { name: "Allow in session", exact: true }) + .click(); + assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), { + peerId: "new-peer", + name: "New device", + decision: true, + isTemporary: true, + }); + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } + }); +} diff --git a/test/p2p-diagnostics/status-pane/index.html b/test/p2p-diagnostics/status-pane/index.html new file mode 100644 index 00000000..c363aacb --- /dev/null +++ b/test/p2p-diagnostics/status-pane/index.html @@ -0,0 +1,11 @@ + +P2P status pane layout fixture + +
diff --git a/test/p2p-diagnostics/status-pane/main.ts b/test/p2p-diagnostics/status-pane/main.ts new file mode 100644 index 00000000..de05e236 --- /dev/null +++ b/test/p2p-diagnostics/status-pane/main.ts @@ -0,0 +1,67 @@ +import { mount } from "svelte"; +import Pane from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte"; +import { eventHub } from "../../../src/common/events"; +import { + EVENT_SERVER_STATUS, + EVENT_P2P_REPLICATOR_STATUS, +} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; +import { setGlobalLogFunction } from "octagonal-wheels/common/logger"; + +setGlobalLogFunction(() => undefined); +const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room", remoteConfigurations: {} }; +const knownAdvertisements = [ + { peerId: "permanent-peer", name: "Phone", platform: "mobile", isAccepted: true }, + { + peerId: "session-peer", + name: "LongDeviceNameWithoutWordBreaks".repeat(4), + platform: "desktop", + isTemporaryAccepted: true, + }, + { peerId: "new-peer", name: "New device", platform: "mobile" }, + { peerId: "denied-peer", name: "Denied device", platform: "mobile", isTemporaryAccepted: false }, +]; +if (new URLSearchParams(location.search).has("preview")) { + knownAdvertisements.splice(1, 1); + knownAdvertisements.splice(2, 1); + knownAdvertisements[0].name = "Android phone"; + knownAdvertisements[1].name = "MacBook Pro (Living room)"; +} +const record = (action: unknown) => { + document.querySelector("#action")!.textContent = JSON.stringify(action); +}; +const p2p = { + transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined }, + changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} }, + peerAdmission: { + makeDecision: async (decision: unknown) => record(decision), + revokeDecision: async (decision: unknown) => record(decision), + }, + targetedTransfer: { synchroniseWithPeer: async (peerId: string) => record({ sync: peerId }) }, + diagnostics: { + getDiagnosticSnapshot: () => ({ peer: { category: "peer", code: "peer-left", timestamp: 1_700_000_000_000 } }), + onDiagnosticEvent: () => () => undefined, + requestStatus: () => { + eventHub.emitEvent(EVENT_SERVER_STATUS, { + isConnected: true, + knownAdvertisements, + serverPeerId: "fixture-self", + roomId: "fixture-room", + diag: { + totalNewConnections: 4, + totalSuccessfulConnections: 2, + totalFailedConnections: 1, + totalClosedConnections: 1, + details: {}, + }, + } as never); + eventHub.emitEvent(EVENT_P2P_REPLICATOR_STATUS, { + replicatingFrom: [], + replicatingTo: [], + watchingPeers: [], + isBroadcasting: false, + } as never); + }, + }, +}; +const core = { services: { setting: { currentSettings: () => settings }, API: { showWindow: async () => undefined } } }; +mount(Pane, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } }); diff --git a/test/p2p-diagnostics/vite.config.mjs b/test/p2p-diagnostics/vite.config.mjs new file mode 100644 index 00000000..85d1b866 --- /dev/null +++ b/test/p2p-diagnostics/vite.config.mjs @@ -0,0 +1,21 @@ +import { mergeConfig } from "vite"; +import { fileURLToPath } from "node:url"; +import common from "../../vitest.config.common.ts"; + +const config = mergeConfig(common, { + optimizeDeps: { + entries: [ + "test/p2p-diagnostics/log-pane/index.html", + "test/p2p-diagnostics/status-card/index.html", + "test/p2p-diagnostics/status-pane/index.html", + ], + }, + server: { host: "127.0.0.1", port: 5157, strictPort: true }, +}); +config.resolve.alias = { + "@/modules/features/Log/LogPaneView": fileURLToPath(new URL("./log-pane-view.ts", import.meta.url)), + "@/deps": fileURLToPath(new URL("./host-ui.ts", import.meta.url)), + "@/modules/features/SetupManager": fileURLToPath(new URL("./host-ui.ts", import.meta.url)), + ...common.resolve.alias, +}; +export default config; diff --git a/updates.md b/updates.md index f24a9b73..ac668045 100644 --- a/updates.md +++ b/updates.md @@ -16,6 +16,17 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### P2P synchronisation +- P2P status shows Stats with diagnostic details collapsed by default. Expand + 'Diagnostic log' to see the latest relay, peer, and synchronisation observations. + With Diag enabled, their events also appear in the Log Pane without updating + the editor status or producing notifications. + - Unobserved information is labelled explicitly. Relay rejection messages are + unavailable through the current transport API; an open room does not confirm + that a relay accepted its announcements. + +- Larger peer controls wrap onto additional lines in narrow panes, keeping action labels + readable alongside long device names. + - P2P progress shows sent (โ†‘) and received (โ†“) document revision counts, including Metadata and Chunks. Directional โฉ labels show comparison progress even when no transfer is needed, and disappear three seconds after their last count change.