mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-14 20:20:12 +00:00
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.
This commit is contained in:
1 parent
fc5f02ca3b
commit
cf527fbf86
22 files changed
+978
-88
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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<string>(extractP2PRoomSuffix(initialSettings?.P2P_roomID ?? ""));
|
||||
let useDiagRTC = $state<boolean>(initialSettings?.P2P_useDiagRTC ?? false);
|
||||
let diagnosticRows = $state<readonly P2PDiagnosticRow[]>([]);
|
||||
|
||||
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")}
|
||||
</button>
|
||||
@@ -190,12 +202,48 @@
|
||||
<span>{serverInfo.diag.totalClosedConnections}</span>
|
||||
</div>
|
||||
</div>
|
||||
<details class="diag-details">
|
||||
<summary>{translateMessage("Diagnostic log")}</summary>
|
||||
<div class="diag-summary">
|
||||
{#each diagnosticRows as row (row.category)}
|
||||
<div title={row.title}>
|
||||
<span>{row.label}: {row.text}</span>{#if row.time}<span> · {row.time}</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if core}
|
||||
<button class="diag-open-log" onclick={openLog}>{translateMessage("Open log")}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.diag-details {
|
||||
margin-top: var(--size-4-2);
|
||||
}
|
||||
|
||||
.diag-details > summary {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diag-summary {
|
||||
margin-top: var(--size-4-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.diag-open-log {
|
||||
margin-top: var(--size-4-1);
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
@@ -211,6 +259,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -498,37 +498,39 @@
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating...")
|
||||
: translateMessage("Replicate now")}
|
||||
aria-label={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating")
|
||||
: translateMessage("Replicate now")}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
{translateMessage("Revoke")}
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
|
||||
aria-label={translateMessage("More actions for ${DEVICE}", {
|
||||
DEVICE: peer.name,
|
||||
})}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={(event) => openPeerMenu(event, peer)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
<div class="decision-buttons">
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating...")
|
||||
: translateMessage("Replicate now")}
|
||||
aria-label={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating")
|
||||
: translateMessage("Replicate now")}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
{translateMessage("Revoke")}
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
|
||||
aria-label={translateMessage("More actions for ${DEVICE}", {
|
||||
DEVICE: peer.name,
|
||||
})}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={(event) => openPeerMenu(event, peer)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">{translateMessage("Follow changes")}</span>
|
||||
@@ -554,45 +556,49 @@
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">{translateMessage("PERMANENT")}</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow permanently")}
|
||||
aria-label={translateMessage("Allow permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, false)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny permanently")}
|
||||
aria-label={translateMessage("Deny permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, false)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
<div class="decision-buttons">
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow permanently")}
|
||||
aria-label={translateMessage("Allow permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, false)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny permanently")}
|
||||
aria-label={translateMessage("Deny permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, false)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">{translateMessage("SESSION")}</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow in session")}
|
||||
aria-label={translateMessage("Allow in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, true)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny in session")}
|
||||
aria-label={translateMessage("Deny in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, true)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
<div class="decision-buttons">
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow in session")}
|
||||
aria-label={translateMessage("Allow in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, true)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny in session")}
|
||||
aria-label={translateMessage("Deny in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, true)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
|
||||
@@ -626,11 +632,17 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.p2p-container {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.peers-section button {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.peers-section {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
@@ -647,6 +659,8 @@
|
||||
|
||||
.pane-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
@@ -654,6 +668,8 @@
|
||||
|
||||
.remote-picker-wrap {
|
||||
display: inline-flex;
|
||||
flex: 1 1 10rem;
|
||||
max-width: 100%;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
@@ -661,8 +677,8 @@
|
||||
|
||||
.remote-picker {
|
||||
max-width: 10rem;
|
||||
min-width: 1em;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
@@ -681,10 +697,11 @@
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
display: inline-flex;
|
||||
@@ -720,8 +737,12 @@
|
||||
}
|
||||
|
||||
.refresh {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
@@ -756,6 +777,8 @@
|
||||
}
|
||||
|
||||
.peer-name {
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -772,6 +795,9 @@
|
||||
}
|
||||
|
||||
.badge {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
@@ -837,17 +863,25 @@
|
||||
}
|
||||
|
||||
.decision-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accepted-row {
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
.decision-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
@@ -855,22 +889,25 @@
|
||||
}
|
||||
|
||||
.action-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
width: 2rem;
|
||||
height: 1.7rem;
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
font-size: 1.125rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -923,7 +960,7 @@
|
||||
}
|
||||
|
||||
.revoke-inline {
|
||||
justify-self: start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.no-peers {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
};
|
||||
let { close }: Props = $props();
|
||||
// export let close: () => void;
|
||||
function updateLog(logs: ReactiveInstance<string[]>) {
|
||||
function updateLog(logs: Pick<ReactiveInstance<string[]>, "value">) {
|
||||
const e = logs.value;
|
||||
if (!suspended) {
|
||||
messages = [...e];
|
||||
@@ -28,6 +28,7 @@
|
||||
}
|
||||
onMount(async () => {
|
||||
const _logMessages = reactive(() => logMessages.value);
|
||||
updateLog(_logMessages);
|
||||
_logMessages.onChanged(updateLog);
|
||||
Logger(msg("logPane.logWindowOpened", {}, lang));
|
||||
unsubscribe = () => _logMessages.offChanged(updateLog);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
debounce: (callback: () => void) => callback,
|
||||
normalizePath: (path: string) => path,
|
||||
Notice: class {},
|
||||
stringifyYaml: vi.fn(),
|
||||
}));
|
||||
vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" }));
|
||||
vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() }));
|
||||
vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true }));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_FILE_RENAMED: "renamed",
|
||||
EVENT_LAYOUT_READY: "ready",
|
||||
EVENT_LEAF_ACTIVE_CHANGED: "active",
|
||||
EVENT_ON_UNRESOLVED_ERROR: "error",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector", () => ({
|
||||
P2PLogCollector: class {},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/p2p", () => ({ EVENT_P2P_DIAGNOSTIC: "p2p-diagnostic" }));
|
||||
|
||||
async function harness(diag = true, verbose = false, lessInformation = false) {
|
||||
const { ModuleLog } = await import("./ModuleLog.ts");
|
||||
const { reactiveSource } = await import("octagonal-wheels/dataobject/reactive");
|
||||
const { logMessages } = await import("@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores");
|
||||
logMessages.value = [];
|
||||
const notices = { show: vi.fn() };
|
||||
const module = Object.assign(Object.create(ModuleLog.prototype), {
|
||||
core: {
|
||||
settings: { P2P_useDiagRTC: diag, showVerboseLog: verbose, lessInformationInLog: lessInformation },
|
||||
services: { vault: { getVaultName: () => "fixture" }, context: { notices } },
|
||||
},
|
||||
statusLog: reactiveSource("existing status"),
|
||||
logLines: [],
|
||||
notifies: {},
|
||||
}) as InstanceType<typeof ModuleLog>;
|
||||
const { LOG_LEVEL_VERBOSE, LOG_LEVEL_INFO } = await import("@vrtmrz/livesync-commonlib/compat/common/types");
|
||||
return { module, notices, logMessages, LOG_LEVEL_VERBOSE, LOG_LEVEL_INFO } as const;
|
||||
}
|
||||
|
||||
describe("P2P diagnostic log routing", () => {
|
||||
beforeEach(() => vi.resetModules());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("shows enabled diagnostics in Log Pane despite normal log visibility settings", async () => {
|
||||
const { module, notices, logMessages, LOG_LEVEL_VERBOSE } = await harness(true, false, true);
|
||||
module.__addLog("[P2P Diag] Peer: Authentication timed out", LOG_LEVEL_VERBOSE, "p2p-diagnostic");
|
||||
expect(logMessages.value).toEqual([expect.stringContaining("[P2P Diag] Peer: Authentication timed out")]);
|
||||
expect(module.statusLog.value).toBe("existing status");
|
||||
expect(module.logLines).toEqual([]);
|
||||
expect(notices.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps diagnostic detail out of editor status and notification surfaces", async () => {
|
||||
const { module, notices, logMessages, LOG_LEVEL_INFO } = await harness(true, true);
|
||||
module.__addLog("[P2P Diag] Sync: Pull completed", LOG_LEVEL_INFO, "p2p-diagnostic");
|
||||
expect(logMessages.value).toEqual([expect.stringContaining("[P2P Diag] Sync: Pull completed")]);
|
||||
expect(module.statusLog.value).toBe("existing status");
|
||||
expect(module.logLines).toEqual([]);
|
||||
expect(notices.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps disabled diagnostic detail out of Log Pane", async () => {
|
||||
const { module, logMessages, LOG_LEVEL_INFO } = await harness(false, true);
|
||||
module.__addLog("[P2P Diag] Relay: Room opened", LOG_LEVEL_INFO, "p2p-diagnostic");
|
||||
expect(logMessages.value).toEqual([]);
|
||||
expect(module.statusLog.value).toBe("existing status");
|
||||
});
|
||||
|
||||
it("preserves normal log routing", async () => {
|
||||
const { module, logMessages, LOG_LEVEL_INFO } = await harness();
|
||||
module.__addLog("Normal operation", LOG_LEVEL_INFO);
|
||||
expect(logMessages.value).toEqual([expect.stringContaining("Normal operation")]);
|
||||
expect(module.statusLog.value).toBe("Normal operation");
|
||||
expect(module.logLines).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { withLock } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import { EVENT_P2P_DIAGNOSTIC, type P2PDiagnosticEvent } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { formatP2PDiagnosticLog, P2P_DIAGNOSTIC_LOG_KEY } from "./P2PDiagnosticDisplay.ts";
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts";
|
||||
import {
|
||||
@@ -597,6 +599,10 @@ ${stringifyYaml(info)}
|
||||
this.writeLogToTheFile(now, vaultName, newMessage);
|
||||
}
|
||||
addLog(newMessage);
|
||||
if (key === P2P_DIAGNOSTIC_LOG_KEY) {
|
||||
if (this.settings?.P2P_useDiagRTC) addDisplayLog(newMessage);
|
||||
return;
|
||||
}
|
||||
if (memoOnly) {
|
||||
return;
|
||||
}
|
||||
@@ -638,6 +644,21 @@ ${stringifyYaml(info)}
|
||||
let logTarget: LogFunction = bufferedLog;
|
||||
const log: LogFunction = (message, level, key) => logTarget(message, level, key);
|
||||
services.API.addLog.setHandler(log);
|
||||
const lastDiagnosticState = new Map<P2PDiagnosticEvent["category"], P2PDiagnosticEvent>();
|
||||
const unsubscribeDiagnostics = services.context.events.onEvent(EVENT_P2P_DIAGNOSTIC, (event) => {
|
||||
try {
|
||||
const previous = lastDiagnosticState.get(event.category);
|
||||
const repeated =
|
||||
previous?.code === event.code &&
|
||||
previous.peerId === event.peerId &&
|
||||
previous.reason === event.reason;
|
||||
lastDiagnosticState.set(event.category, event);
|
||||
if (repeated && !/(failed|error|timeout|refused|cancelled)$/.test(event.code)) return;
|
||||
services.API.addLog(formatP2PDiagnosticLog(event), LOG_LEVEL_VERBOSE, P2P_DIAGNOSTIC_LOG_KEY);
|
||||
} catch {
|
||||
// Diagnostic output must not interrupt a transport operation.
|
||||
}
|
||||
});
|
||||
const collectGlobalLogs = () => {
|
||||
const entries = recentGlobalLogEntries.value;
|
||||
if (entries.length === 0) return;
|
||||
@@ -645,6 +666,8 @@ ${stringifyYaml(info)}
|
||||
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||
};
|
||||
this.logResource = createOwnedResource(log, () => {
|
||||
unsubscribeDiagnostics();
|
||||
lastDiagnosticState.clear();
|
||||
logTarget = logToConsole;
|
||||
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||
|
||||
@@ -22,7 +22,10 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"
|
||||
P2PLogCollector: class {},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/p2p", () => ({ EVENT_P2P_DIAGNOSTIC: "p2p-diagnostic" }));
|
||||
|
||||
async function createHarness() {
|
||||
const { createLiveSyncEventHub } = await import("@vrtmrz/livesync-commonlib/context");
|
||||
const { ModuleLog } = await import("./ModuleLog.ts");
|
||||
const { bindableFunction, allFunction } =
|
||||
await import("@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils");
|
||||
@@ -34,7 +37,7 @@ async function createHarness() {
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
context: { events: {}, notices: { hide: vi.fn() } },
|
||||
context: { events: createLiveSyncEventHub(), notices: { hide: vi.fn() } },
|
||||
appLifecycle: {
|
||||
onInitialise: allFunction<() => Promise<boolean>>(),
|
||||
onSettingLoaded: allFunction<() => Promise<boolean>>(),
|
||||
@@ -61,6 +64,57 @@ describe("ModuleLog service connection", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("routes accepted P2P diagnostics only through their owning context and disposes the subscription", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
first.services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "peer",
|
||||
code: "auth-timeout",
|
||||
timestamp: 123,
|
||||
});
|
||||
expect(first.sink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[P2P Diag] Peer: Authentication timed out"),
|
||||
expect.any(Number),
|
||||
"p2p-diagnostic"
|
||||
);
|
||||
expect(second.sink).not.toHaveBeenCalled();
|
||||
await first.module.logResource.dispose();
|
||||
first.sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
first.services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "peer",
|
||||
code: "peer-left",
|
||||
timestamp: 124,
|
||||
});
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(consoleLog).not.toHaveBeenCalled();
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
|
||||
it("does not repeat unchanged diagnostic state but retains repeated failure observations", async () => {
|
||||
const { services, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "sync",
|
||||
code: "watch-enabled",
|
||||
timestamp: 100,
|
||||
});
|
||||
services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "sync",
|
||||
code: "watch-enabled",
|
||||
timestamp: 200,
|
||||
});
|
||||
expect(sink).toHaveBeenCalledTimes(1);
|
||||
services.context.events.emitEvent("p2p-diagnostic", { category: "peer", code: "auth-timeout", timestamp: 300 });
|
||||
services.context.events.emitEvent("p2p-diagnostic", { category: "peer", code: "auth-timeout", timestamp: 400 });
|
||||
expect(sink).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("buffers startup logs and preserves global and service call order", async () => {
|
||||
const { services, sink, Logger } = await createHarness();
|
||||
Logger("before-global");
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
P2PDiagnostics,
|
||||
P2PDiagnosticCode,
|
||||
P2PDiagnosticEvent,
|
||||
P2PDiagnosticReason,
|
||||
P2PDiagnosticSnapshot,
|
||||
} from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
export const P2P_DIAGNOSTIC_LOG_KEY = "p2p-diagnostic";
|
||||
|
||||
const labels = {
|
||||
"room-opening": "Room opening",
|
||||
"room-opened": "Room opened",
|
||||
"room-closed": "Room closed",
|
||||
"room-open-failed": "Room could not open",
|
||||
"room-close-failed": "Room could not close",
|
||||
"room-open-cancelled": "Room opening cancelled",
|
||||
"room-join-error": "Peer could not join the room",
|
||||
"peer-advertised": "Peer advertised",
|
||||
"peer-left": "Peer left",
|
||||
"rtc-failed": "RTC connection failed",
|
||||
"auth-accepted": "Authentication accepted",
|
||||
"auth-refused": "Authentication refused",
|
||||
"auth-timeout": "Authentication timed out",
|
||||
"auth-cancelled": "Authentication cancelled",
|
||||
"auth-error": "Authentication failed",
|
||||
"pull-started": "Pull started",
|
||||
"pull-completed": "Pull completed",
|
||||
"pull-failed": "Pull failed",
|
||||
"pull-cancelled": "Pull cancelled",
|
||||
"watch-enabled": "Watching peer changes",
|
||||
"watch-disabled": "Stopped watching peer changes",
|
||||
"watch-skipped": "Automatic watch skipped",
|
||||
"broadcast-enabled": "Announcing changes",
|
||||
"broadcast-disabled": "Stopped announcing changes",
|
||||
"change-notification-received": "Change notification received",
|
||||
"change-notification-ignored": "Change notification ignored",
|
||||
"change-notification-sent": "Change notification sent",
|
||||
"change-notification-timeout": "Change notification timed out",
|
||||
"change-notification-cancelled": "Change notification cancelled",
|
||||
"change-notification-error": "Change notification failed",
|
||||
} satisfies Record<P2PDiagnosticCode, string>;
|
||||
|
||||
const reasons: Partial<Record<P2PDiagnosticReason, string>> = {
|
||||
"relay-details-unobserved": "relay replies not observed",
|
||||
"transport-error": "transport error",
|
||||
"replication-error": "replication error",
|
||||
"replication-not-ready": "replication not ready",
|
||||
"already-running": "replication already running",
|
||||
"not-watching": "peer is not watched",
|
||||
"not-accepted": "peer is not accepted",
|
||||
"setup-in-progress": "setup in progress",
|
||||
"peer-not-broadcasting": "peer is not announcing changes",
|
||||
"peer-broadcast-unobserved": "peer announcement state not observed",
|
||||
"authentication-unobserved": "authentication response not observed",
|
||||
ICE_GATHERING_NOT_COMPLETED: "ICE gathering did not complete",
|
||||
ICE_CONNECTIVITY_FAILED: "ICE connectivity failed",
|
||||
STUN_REQUEST_TIMEOUT: "STUN request timed out",
|
||||
SIGNALING_NOT_STABLE: "signalling did not stabilise",
|
||||
CONNECTION_DROPPED_AFTER_ESTABLISHED: "established connection dropped",
|
||||
NETWORK_INTERRUPTED: "network interrupted",
|
||||
UNKNOWN: "reason not observed",
|
||||
};
|
||||
|
||||
const categories = ["relay", "peer", "sync"] as const;
|
||||
const categoryLabels = { relay: "Relay", peer: "Peer", sync: "Sync" } as const;
|
||||
|
||||
function describeEvent(event: P2PDiagnosticEvent): string {
|
||||
const label = labels[event.code] ?? "Unknown diagnostic event";
|
||||
const reason = event.reason ? reasons[event.reason] : undefined;
|
||||
const peer = event.peerId ? ` [${event.peerId.replace(/[^A-Za-z0-9_-]/g, "?").slice(0, 12)}]` : "";
|
||||
return label + (reason ? `; ${reason}` : "") + peer;
|
||||
}
|
||||
|
||||
function eventDate(event: P2PDiagnosticEvent): Date | undefined {
|
||||
const date = new Date(event.timestamp);
|
||||
return Number.isFinite(date.getTime()) ? date : undefined;
|
||||
}
|
||||
|
||||
export function formatP2PDiagnosticLog(event: P2PDiagnosticEvent): string {
|
||||
const time = eventDate(event)?.toISOString() ?? "unknown time";
|
||||
return `[P2P Diag] ${categoryLabels[event.category]}: ${describeEvent(event)} (observed ${time})`;
|
||||
}
|
||||
|
||||
export type P2PDiagnosticRow = {
|
||||
category: (typeof categories)[number];
|
||||
label: string;
|
||||
text: string;
|
||||
time: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function projectP2PDiagnosticRows(snapshot: P2PDiagnosticSnapshot, available = true): P2PDiagnosticRow[] {
|
||||
return categories.map((category) => {
|
||||
const event = snapshot[category];
|
||||
const date = event ? eventDate(event) : undefined;
|
||||
return {
|
||||
category,
|
||||
label: categoryLabels[category],
|
||||
text: event ? describeEvent(event) : available ? "Not observed" : "Unavailable",
|
||||
time: date?.toLocaleTimeString() ?? "",
|
||||
title: date?.toLocaleString() ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Replays the latest three observations, then follows the same accepted events written to the log. */
|
||||
export function observeP2PDiagnosticRows(
|
||||
diagnostics: P2PDiagnostics,
|
||||
listener: (rows: readonly P2PDiagnosticRow[]) => void
|
||||
): () => void {
|
||||
const available = !!(diagnostics.getDiagnosticSnapshot || diagnostics.onDiagnosticEvent);
|
||||
let snapshot = diagnostics.getDiagnosticSnapshot?.() ?? {};
|
||||
let disposed = false;
|
||||
const unsubscribe = diagnostics.onDiagnosticEvent?.((event) => {
|
||||
if (disposed) return;
|
||||
snapshot = { ...snapshot, [event.category]: event };
|
||||
listener(projectP2PDiagnosticRows(snapshot, available));
|
||||
});
|
||||
listener(projectP2PDiagnosticRows(snapshot, available));
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { P2PDiagnosticEvent, P2PDiagnostics, P2PDiagnosticSnapshot } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { formatP2PDiagnosticLog, observeP2PDiagnosticRows, projectP2PDiagnosticRows } from "./P2PDiagnosticDisplay.ts";
|
||||
|
||||
const peerLeft: P2PDiagnosticEvent = {
|
||||
category: "peer",
|
||||
code: "peer-left",
|
||||
timestamp: 1_700_000_000_000,
|
||||
peerId: "fixture-peer-id",
|
||||
};
|
||||
|
||||
function diagnostics(snapshot: P2PDiagnosticSnapshot = {}) {
|
||||
let listener: ((event: P2PDiagnosticEvent) => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
const service: P2PDiagnostics = {
|
||||
requestStatus: vi.fn(),
|
||||
getPeerConnectionMetrics: vi.fn(),
|
||||
getDiagnosticSnapshot: () => snapshot,
|
||||
onDiagnosticEvent: (callback) => {
|
||||
listener = callback;
|
||||
return unsubscribe;
|
||||
},
|
||||
};
|
||||
return { service, unsubscribe, emit: (event: P2PDiagnosticEvent) => listener?.(event) };
|
||||
}
|
||||
|
||||
describe("P2P diagnostic presentation", () => {
|
||||
it("shows unobserved categories rather than claiming zero or success", () => {
|
||||
const rows = projectP2PDiagnosticRows({});
|
||||
expect(rows.map((row) => [row.label, row.text, row.time])).toEqual([
|
||||
["Relay", "Not observed", ""],
|
||||
["Peer", "Not observed", ""],
|
||||
["Sync", "Not observed", ""],
|
||||
]);
|
||||
expect(projectP2PDiagnosticRows({}, false).every((row) => row.text === "Unavailable")).toBe(true);
|
||||
});
|
||||
|
||||
it("states that room activation does not measure relay acceptance", () => {
|
||||
const event: P2PDiagnosticEvent = {
|
||||
category: "relay",
|
||||
code: "room-opened",
|
||||
timestamp: peerLeft.timestamp,
|
||||
reason: "relay-details-unobserved",
|
||||
};
|
||||
expect(projectP2PDiagnosticRows({ relay: event })[0].text).toBe("Room opened; relay replies not observed");
|
||||
expect(formatP2PDiagnosticLog(event)).toContain("[P2P Diag] Relay: Room opened; relay replies not observed");
|
||||
expect(formatP2PDiagnosticLog(event)).toContain(new Date(event.timestamp).toISOString());
|
||||
});
|
||||
|
||||
it("distinguishes explicit refusal, timeout, and unobserved watch policy", () => {
|
||||
const refused: P2PDiagnosticEvent = { category: "peer", code: "auth-refused", timestamp: peerLeft.timestamp };
|
||||
const timedOut = { ...refused, code: "auth-timeout" } as const;
|
||||
expect(formatP2PDiagnosticLog(refused)).toContain("Authentication refused");
|
||||
expect(formatP2PDiagnosticLog(timedOut)).toContain("Authentication timed out");
|
||||
expect(
|
||||
formatP2PDiagnosticLog({
|
||||
category: "sync",
|
||||
code: "watch-skipped",
|
||||
reason: "peer-broadcast-unobserved",
|
||||
timestamp: peerLeft.timestamp,
|
||||
})
|
||||
).toContain("peer announcement state not observed");
|
||||
});
|
||||
|
||||
it("replays a lost peer's latest observation and retains it while other categories change", () => {
|
||||
const fixture = diagnostics({ peer: peerLeft });
|
||||
const listener = vi.fn();
|
||||
const dispose = observeP2PDiagnosticRows(fixture.service, listener);
|
||||
expect(listener.mock.lastCall?.[0][1].text).toContain("Peer left");
|
||||
fixture.emit({ category: "relay", code: "room-closed", timestamp: peerLeft.timestamp + 100 });
|
||||
const latest = listener.mock.lastCall?.[0];
|
||||
expect(latest?.[0].text).toBe("Room closed");
|
||||
expect(latest?.[1].text).toContain("Peer left");
|
||||
expect(latest?.[1].title).toBe(new Date(peerLeft.timestamp).toLocaleString());
|
||||
dispose();
|
||||
dispose();
|
||||
fixture.emit({ ...peerLeft, code: "peer-advertised" });
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts an older diagnostics view without extending its contract", () => {
|
||||
const listener = vi.fn();
|
||||
const dispose = observeP2PDiagnosticRows(
|
||||
{ requestStatus: vi.fn(), getPeerConnectionMetrics: vi.fn() },
|
||||
listener
|
||||
);
|
||||
expect(listener.mock.lastCall?.[0].map((row: { text: string }) => row.text)).toEqual([
|
||||
"Unavailable",
|
||||
"Unavailable",
|
||||
"Unavailable",
|
||||
]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("does not put peer control characters into a log line and tolerates missing timestamps", () => {
|
||||
expect(formatP2PDiagnosticLog({ ...peerLeft, peerId: "bad\npeer\tvalue" })).not.toMatch(/[\n\t]/);
|
||||
const rows = projectP2PDiagnosticRows({ peer: { ...peerLeft, timestamp: NaN } });
|
||||
expect(rows[1].time).toBe("");
|
||||
expect(formatP2PDiagnosticLog({ ...peerLeft, timestamp: NaN })).toContain("unknown time");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
# P2P diagnostic UI checks
|
||||
|
||||
These checks use Node.js 24 or newer, the existing Playwright dependency, and a
|
||||
Chromium installation.
|
||||
The Log Pane fixture starts with a buffered diagnostic message and suppresses
|
||||
ordinary logger output, modelling a report opened while ordinary INFO messages
|
||||
are hidden. It checks initial display, later events, and unmounting. The status-card fixture
|
||||
also checks three summary rows at a narrow viewport and the existing Log Pane
|
||||
link. The full status-pane fixture exercises accepted, session-accepted, new,
|
||||
and denied peers at 320, 390, and 768 px, including long names. It checks bounds,
|
||||
complete action labels, and existing sync/admission callbacks. The card check
|
||||
also verifies initially collapsed diagnostic details, keyboard toggling, and
|
||||
updates received while collapsed. Host view, Menu, and SetupManager capabilities
|
||||
are stubbed; the Svelte components and shared log store are real, while P2P and
|
||||
Obsidian capabilities are fixtures. Add `?preview` to the status-pane URL for a
|
||||
shorter illustrative peer list.
|
||||
|
||||
From the repository root, start the fixture server:
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 node node_modules/vite/bin/vite.js --config test/p2p-diagnostics/vite.config.mjs
|
||||
```
|
||||
|
||||
In another terminal, run the focused browser check:
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 node --test --test-concurrency=1 test/p2p-diagnostics/log-pane.test.ts test/p2p-diagnostics/status-pane.test.ts
|
||||
```
|
||||
|
||||
The default endpoint is `http://127.0.0.1:5157`. Set `DIAG_UI_BASE_URL` when using
|
||||
another endpoint. Stop the fixture server after the check. This browser fixture
|
||||
does not replace verification in Obsidian.
|
||||
@@ -0,0 +1,3 @@
|
||||
// Host-only controls are not exercised by the browser layout fixture.
|
||||
export class Menu {}
|
||||
export class SetupManager {}
|
||||
@@ -0,0 +1 @@
|
||||
export const VIEW_TYPE_LOG = "log-log";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
test("Log Pane shows diagnostics recorded before it opens and follows subsequent events", async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
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/log-pane/index.html`);
|
||||
await page.waitForSelector(".logpane");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".log")?.textContent?.includes("Earlier connection failure"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
await page.click("#append");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".log")?.textContent?.includes("Later connection recovery"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
assert.match((await page.locator(".log").textContent()) ?? "", /Earlier connection failure/);
|
||||
await page.click("#unmount");
|
||||
await page.waitForSelector(".logpane", { state: "detached" });
|
||||
await page.click("#append");
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("P2P Stats has three diagnostic rows and opens the existing Log Pane", async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
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-card/index.html`);
|
||||
await page.waitForSelector(".diag-details", { state: "attached", timeout: 3_000 });
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true, "Stats remains visible");
|
||||
assert.equal(await page.getByRole("button", { name: "Open log", exact: true }).isVisible(), false);
|
||||
await page.click("#complete");
|
||||
assert.equal(await page.locator(".diag-summary").isVisible(), false, "New events do not expand the log");
|
||||
await page.locator(".diag-details > summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(".diag-summary");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
const rows = page.locator(".diag-summary > div");
|
||||
assert.equal(await rows.count(), 3);
|
||||
assert.match((await rows.nth(0).textContent()) ?? "", /Room opened; relay replies not observed/);
|
||||
assert.match((await rows.nth(1).textContent()) ?? "", /Peer left/);
|
||||
assert.match((await rows.nth(2).textContent()) ?? "", /Pull completed/);
|
||||
assert.equal(
|
||||
await page.locator(".diag-summary").evaluate((element) => getComputedStyle(element).fontSize),
|
||||
"12px"
|
||||
);
|
||||
if (process.env.DIAG_UI_SCREENSHOT) {
|
||||
await page.screenshot({ path: process.env.DIAG_UI_SCREENSHOT, fullPage: true });
|
||||
}
|
||||
await page.click("#complete");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".diag-summary")?.textContent?.includes("Pull completed"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
await page.locator(".diag-details > summary").click();
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true);
|
||||
assert.equal(await page.locator(".diag-summary").isVisible(), false);
|
||||
await page.locator(".diag-details > summary").focus();
|
||||
await page.keyboard.press("Space");
|
||||
await page.getByRole("button", { name: "Open log", exact: true }).click();
|
||||
await page.waitForSelector(".logpane");
|
||||
assert.equal(await page.locator("#opened").textContent(), "log-log");
|
||||
assert.match((await page.locator(".log").textContent()) ?? "", /Earlier connection failure/);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Log Pane diagnostic fixture</title></head>
|
||||
<body><button id="append">Append event</button><button id="unmount">Unmount</button><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import LogPane from "../../../src/modules/features/Log/LogPane.svelte";
|
||||
import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
|
||||
// Simulate ordinary INFO messages being hidden while a prior diagnostic is visible.
|
||||
setGlobalLogFunction(() => undefined);
|
||||
logMessages.value = ["[P2P Diag] Earlier connection failure"];
|
||||
const component = mount(LogPane, { target: document.querySelector("#app")!, props: { close: () => undefined } });
|
||||
document.querySelector<HTMLButtonElement>("#append")!.onclick = () => {
|
||||
logMessages.value = [...logMessages.value, "[P2P Diag] Later connection recovery"];
|
||||
};
|
||||
document.querySelector<HTMLButtonElement>("#unmount")!.onclick = () => {
|
||||
void unmount(component);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>P2P diagnostic summary fixture</title>
|
||||
<style>:root { --font-ui-small: 12px; --text-muted: #666; --size-4-1: 4px; --size-4-2: 8px; } body { margin: 8px; font: 14px sans-serif; }</style></head>
|
||||
<body><button id="complete">Complete pull</button><div id="opened"></div><div id="app"></div><div id="logapp"></div><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -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<HTMLButtonElement>("#complete")!.onclick = () => {
|
||||
for (const listener of listeners)
|
||||
listener({ category: "sync", code: "pull-completed", timestamp: timestamp + 1_000 });
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>P2P status pane layout fixture</title>
|
||||
<style>
|
||||
:root { --font-ui-small: 12px; --text-muted: #666; --text-normal: #222; --divider-color: #bbb; --size-4-1: 4px; --size-4-2: 8px; --background-secondary: #f4f4f4; --background-tertiary: #ddd; --interactive-normal: #eee; --interactive-hover: #ddd; --background-modifier-success: #bdb; --background-modifier-border: #ddd; --background-modifier-error: #ecc; }
|
||||
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; --text-muted: #aaa; --text-normal: #ddd; --divider-color: #555; --background-secondary: #262626; --background-tertiary: #444; --interactive-normal: #333; --interactive-hover: #444; --background-modifier-success: #253f25; --background-modifier-border: #444; --background-modifier-error: #522; } }
|
||||
body { margin: 8px; font: 16px sans-serif; color: var(--text-normal); }
|
||||
#app { margin: 16px; }
|
||||
button, select { font: inherit; color: var(--text-normal); background: var(--interactive-normal); }
|
||||
button { padding: 4px 12px; white-space: nowrap; }
|
||||
</style></head>
|
||||
<body><div id="app"></div><output id="action"></output><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -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 } });
|
||||
@@ -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;
|
||||
+11
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user