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.