mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30: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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user