@peers-app/peers-ui 0.25.8 → 0.25.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/screens/network-viewer/network-viewer-section.d.ts +9 -0
- package/dist/screens/network-viewer/network-viewer-section.js +11 -0
- package/dist/screens/network-viewer/network-viewer.d.ts +6 -0
- package/dist/screens/network-viewer/network-viewer.js +19 -1
- package/dist/screens/network-viewer/network-viewer.section.test.d.ts +1 -0
- package/dist/screens/network-viewer/network-viewer.section.test.js +63 -0
- package/dist/screens/network-viewer/sync-recovery.d.ts +8 -0
- package/dist/screens/network-viewer/sync-recovery.js +182 -0
- package/dist/screens/network-viewer/sync-recovery.test.d.ts +1 -0
- package/dist/screens/network-viewer/sync-recovery.test.js +104 -0
- package/dist/screens/settings/settings-page.js +13 -3
- package/dist/tabs-layout/network-status-indicator.js +39 -11
- package/dist/tabs-layout/network-status-indicator.test.js +109 -0
- package/dist/tabs-layout/use-network-status.test.js +1 -0
- package/package.json +3 -3
- package/src/screens/network-viewer/network-viewer-section.ts +12 -0
- package/src/screens/network-viewer/network-viewer.section.test.tsx +68 -0
- package/src/screens/network-viewer/network-viewer.tsx +27 -0
- package/src/screens/network-viewer/sync-recovery.test.tsx +114 -0
- package/src/screens/network-viewer/sync-recovery.tsx +461 -0
- package/src/screens/settings/settings-page.tsx +15 -5
- package/src/tabs-layout/network-status-indicator.test.tsx +131 -0
- package/src/tabs-layout/network-status-indicator.tsx +121 -43
- package/src/tabs-layout/use-network-status.test.tsx +1 -0
|
@@ -14,6 +14,7 @@ const moment_1 = __importDefault(require("moment"));
|
|
|
14
14
|
const react_1 = require("react");
|
|
15
15
|
const identity_routes_1 = require("../screens/identity/identity-routes");
|
|
16
16
|
const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-ipc");
|
|
17
|
+
const network_viewer_section_1 = require("../screens/network-viewer/network-viewer-section");
|
|
17
18
|
const tabs_state_1 = require("./tabs-state");
|
|
18
19
|
const use_network_status_1 = require("./use-network-status");
|
|
19
20
|
/** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
|
|
@@ -32,6 +33,19 @@ function disconnectedLabelSuffix(disconnectedContextCount) {
|
|
|
32
33
|
function connectedLabelSuffix(connectedContextCount) {
|
|
33
34
|
return `; ${connectedContextCount} ${connectedContextCount === 1 ? "group" : "groups"} connected`;
|
|
34
35
|
}
|
|
36
|
+
function withStuckAttention(presentation, stuckRecoveryCount) {
|
|
37
|
+
if (stuckRecoveryCount <= 0) {
|
|
38
|
+
return presentation;
|
|
39
|
+
}
|
|
40
|
+
const attention = stuckRecoveryCount === 1
|
|
41
|
+
? "1 record needs attention"
|
|
42
|
+
: `${stuckRecoveryCount} records need attention`;
|
|
43
|
+
const labeled = { ...presentation, label: `${presentation.label}; ${attention}` };
|
|
44
|
+
if (presentation.colorClass === "text-danger") {
|
|
45
|
+
return labeled;
|
|
46
|
+
}
|
|
47
|
+
return { ...labeled, colorClass: "text-warning" };
|
|
48
|
+
}
|
|
35
49
|
function connectedGroupsOverlay(connectionCount, connectedContextCount) {
|
|
36
50
|
if (connectionCount <= 0) {
|
|
37
51
|
return undefined;
|
|
@@ -60,7 +74,7 @@ function getNetworkStatusPresentation(viewState, opts) {
|
|
|
60
74
|
colorClass: "text-secondary",
|
|
61
75
|
};
|
|
62
76
|
}
|
|
63
|
-
const { connectedContextCount, connectionCount, disconnectedContextCount, errorCount, state, syncingContextCount, } = viewState.status;
|
|
77
|
+
const { connectedContextCount, connectionCount, disconnectedContextCount, errorCount, state, stuckRecoveryCount, syncingContextCount, } = viewState.status;
|
|
64
78
|
const disconnectedOverlay = disconnectedContextCount > 0 && state !== "offline"
|
|
65
79
|
? { count: disconnectedContextCount }
|
|
66
80
|
: undefined;
|
|
@@ -70,38 +84,38 @@ function getNetworkStatusPresentation(viewState, opts) {
|
|
|
70
84
|
const groupsOverlay = connectedGroupsOverlay(connectionCount, connectedContextCount);
|
|
71
85
|
const connectedSuffix = groupsOverlay ? connectedLabelSuffix(connectedContextCount) : "";
|
|
72
86
|
if (state === "error") {
|
|
73
|
-
return {
|
|
87
|
+
return withStuckAttention({
|
|
74
88
|
label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
|
|
75
89
|
shortLabel: "Error",
|
|
76
90
|
colorClass: "text-danger",
|
|
77
91
|
mark: "solid",
|
|
78
92
|
disconnectedGroupsOverlay: disconnectedOverlay,
|
|
79
93
|
connectedGroupsOverlay: groupsOverlay,
|
|
80
|
-
};
|
|
94
|
+
}, stuckRecoveryCount);
|
|
81
95
|
}
|
|
82
96
|
if (state === "syncing" || opts?.holdSyncingVisual) {
|
|
83
97
|
const longSync = state === "syncing" && opts?.syncingLongerThanGreenMs === true;
|
|
84
98
|
const contextsLabel = state === "syncing"
|
|
85
99
|
? `${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}`
|
|
86
100
|
: "recent activity";
|
|
87
|
-
return {
|
|
101
|
+
return withStuckAttention({
|
|
88
102
|
label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
|
|
89
103
|
shortLabel: "Syncing",
|
|
90
104
|
colorClass: longSync ? "text-primary" : "text-success",
|
|
91
105
|
mark: longSync ? "indeterminate" : "pulse",
|
|
92
106
|
disconnectedGroupsOverlay: disconnectedOverlay,
|
|
93
107
|
connectedGroupsOverlay: groupsOverlay,
|
|
94
|
-
};
|
|
108
|
+
}, stuckRecoveryCount);
|
|
95
109
|
}
|
|
96
110
|
if (state === "offline") {
|
|
97
|
-
return {
|
|
111
|
+
return withStuckAttention({
|
|
98
112
|
label: "Network offline; no active connections",
|
|
99
113
|
shortLabel: "Offline",
|
|
100
114
|
iconClass: "bi-cloud-slash-fill",
|
|
101
115
|
colorClass: "text-secondary",
|
|
102
|
-
};
|
|
116
|
+
}, stuckRecoveryCount);
|
|
103
117
|
}
|
|
104
|
-
return {
|
|
118
|
+
return withStuckAttention({
|
|
105
119
|
label: `Network synced; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
|
|
106
120
|
shortLabel: "Synced",
|
|
107
121
|
iconClass: "bi-check-circle-fill",
|
|
@@ -109,7 +123,7 @@ function getNetworkStatusPresentation(viewState, opts) {
|
|
|
109
123
|
compactIcon: true,
|
|
110
124
|
disconnectedGroupsOverlay: disconnectedOverlay,
|
|
111
125
|
connectedGroupsOverlay: groupsOverlay,
|
|
112
|
-
};
|
|
126
|
+
}, stuckRecoveryCount);
|
|
113
127
|
}
|
|
114
128
|
function statusMarkBgClass(colorClass) {
|
|
115
129
|
if (colorClass === "text-danger") {
|
|
@@ -118,6 +132,9 @@ function statusMarkBgClass(colorClass) {
|
|
|
118
132
|
if (colorClass === "text-primary") {
|
|
119
133
|
return "bg-primary";
|
|
120
134
|
}
|
|
135
|
+
if (colorClass === "text-warning") {
|
|
136
|
+
return "bg-warning";
|
|
137
|
+
}
|
|
121
138
|
return "bg-success";
|
|
122
139
|
}
|
|
123
140
|
function StatusMark({ mode, colorClass }) {
|
|
@@ -190,8 +207,14 @@ function ConnectedDevicesList({ loadState }) {
|
|
|
190
207
|
}
|
|
191
208
|
return ((0, jsx_runtime_1.jsx)("ul", { className: "small mb-0 ps-0", style: { maxHeight: "180px", overflowY: "auto" }, children: loadState.connections.map((connection) => ((0, jsx_runtime_1.jsx)(ConnectedDeviceRow, { connection: connection }, connection.deviceId))) }));
|
|
192
209
|
}
|
|
193
|
-
function
|
|
194
|
-
|
|
210
|
+
function StatusCountRow({ label, value, className, }) {
|
|
211
|
+
const tone = className ? ` ${className}` : "";
|
|
212
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: `col-7${tone}`, children: label }), (0, jsx_runtime_1.jsx)("dd", { className: `col-5 text-end${tone}`, children: value })] }));
|
|
213
|
+
}
|
|
214
|
+
function NetworkStatusDetails({ detailsId, status, unavailable, connectionsLoadState, onUseInvite, onOpenIdentity, onOpenViewer, onOpenRecovery, }) {
|
|
215
|
+
return ((0, jsx_runtime_1.jsx)("div", { id: detailsId, role: "dialog", "aria-label": "Network status details", className: "card shadow position-absolute end-0 mt-1", style: { top: "100%", width: "320px", zIndex: 1100 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 mb-3", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onUseInvite, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-qr-code me-1", "aria-hidden": "true" }), "Use Invite"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onOpenIdentity, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-badge me-1", "aria-hidden": "true" }), "Identity"] })] }), (0, jsx_runtime_1.jsx)("h6", { className: "card-title mb-2", children: "Network status" }), unavailable || !status ? ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-3", children: "Network diagnostics are temporarily unavailable. The local host may still be starting or reconnecting." })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-2", children: [(0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Connections", value: status.connectionCount }), (0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups connected", value: status.connectedContextCount }), status.syncingContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Syncing contexts", value: status.syncingContextCount })), status.disconnectedContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups offline", value: status.disconnectedContextCount, className: "text-warning" })), status.errorCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Sync errors", value: status.errorCount })), status.lastSuccessfulSyncAt != null && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Last sync" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end text-truncate", title: new Date(status.lastSuccessfulSyncAt).toLocaleString(), children: formatRelativeSyncTime(status.lastSuccessfulSyncAt) })] }))] }), status.errors.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger py-2 px-2 small mb-2", children: [status.errors[0].message, status.errors.length > 1 && ` (+${status.errors.length - 1} more)`] })), status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning py-2 px-2 small mb-2", children: status.stuckRecoveryCount === 1
|
|
216
|
+
? "1 record needs attention"
|
|
217
|
+
: `${status.stuckRecoveryCount} records need attention` })), (0, jsx_runtime_1.jsxs)("div", { className: "border-top pt-2 mt-1 mb-2", children: [(0, jsx_runtime_1.jsx)("div", { className: "small text-muted mb-1", children: "Connected Devices" }), (0, jsx_runtime_1.jsx)(ConnectedDevicesList, { loadState: connectionsLoadState })] })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: `btn btn-sm btn-outline-primary w-100${status && status.stuckRecoveryCount > 0 ? " mb-2" : ""}`, onClick: onOpenViewer, children: "Open Network Viewer" }), status && status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-warning w-100", onClick: onOpenRecovery, children: "Sync recovery" }))] }) }));
|
|
195
218
|
}
|
|
196
219
|
/** Accessible reactive network status button and details popover. */
|
|
197
220
|
function NetworkStatusIndicator({ compact = false }) {
|
|
@@ -314,6 +337,11 @@ function NetworkStatusIndicator({ compact = false }) {
|
|
|
314
337
|
(0, tabs_state_1.goToTabPath)(identity_routes_1.IDENTITY_ACCOUNT_PATH);
|
|
315
338
|
}, onOpenViewer: () => {
|
|
316
339
|
setOpen(false);
|
|
340
|
+
(0, network_viewer_section_1.networkViewerSection)("main");
|
|
341
|
+
(0, tabs_state_1.goToTabPath)("network-viewer");
|
|
342
|
+
}, onOpenRecovery: () => {
|
|
343
|
+
setOpen(false);
|
|
344
|
+
(0, network_viewer_section_1.networkViewerSection)("sync-recovery");
|
|
317
345
|
(0, tabs_state_1.goToTabPath)("network-viewer");
|
|
318
346
|
} }))] }));
|
|
319
347
|
}
|
|
@@ -8,6 +8,7 @@ const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
|
8
8
|
const react_1 = require("@testing-library/react");
|
|
9
9
|
const moment_1 = __importDefault(require("moment"));
|
|
10
10
|
const identity_routes_1 = require("../screens/identity/identity-routes");
|
|
11
|
+
const network_viewer_section_1 = require("../screens/network-viewer/network-viewer-section");
|
|
11
12
|
const network_status_indicator_1 = require("./network-status-indicator");
|
|
12
13
|
const goToTabPath = jest.fn();
|
|
13
14
|
const getConnections = jest.fn();
|
|
@@ -33,6 +34,7 @@ function status(overrides) {
|
|
|
33
34
|
errorCount: 0,
|
|
34
35
|
disconnectedContextCount: 0,
|
|
35
36
|
connectedContextCount: 0,
|
|
37
|
+
stuckRecoveryCount: 0,
|
|
36
38
|
errors: [],
|
|
37
39
|
...overrides,
|
|
38
40
|
};
|
|
@@ -128,6 +130,28 @@ describe("getNetworkStatusPresentation", () => {
|
|
|
128
130
|
mark: "indeterminate",
|
|
129
131
|
});
|
|
130
132
|
});
|
|
133
|
+
it("turns yellow for stuck recovery without hiding sync activity or errors", () => {
|
|
134
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
135
|
+
availability: "ready",
|
|
136
|
+
status: status({ state: "synced", stuckRecoveryCount: 2 }),
|
|
137
|
+
})).toMatchObject({
|
|
138
|
+
colorClass: "text-warning",
|
|
139
|
+
iconClass: "bi-check-circle-fill",
|
|
140
|
+
label: expect.stringContaining("2 records need attention"),
|
|
141
|
+
});
|
|
142
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
143
|
+
availability: "ready",
|
|
144
|
+
status: status({ state: "syncing", syncingContextCount: 1, stuckRecoveryCount: 1 }),
|
|
145
|
+
}, { syncingLongerThanGreenMs: true })).toMatchObject({
|
|
146
|
+
colorClass: "text-warning",
|
|
147
|
+
mark: "indeterminate",
|
|
148
|
+
label: expect.stringContaining("1 record needs attention"),
|
|
149
|
+
});
|
|
150
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
151
|
+
availability: "ready",
|
|
152
|
+
status: status({ state: "error", errorCount: 1, stuckRecoveryCount: 4 }),
|
|
153
|
+
}).colorClass).toBe("text-danger");
|
|
154
|
+
});
|
|
131
155
|
it("holds a green pulse while waiting for stable synced", () => {
|
|
132
156
|
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({ availability: "ready", status: status({ state: "synced" }) }, { holdSyncingVisual: true, syncingLongerThanGreenMs: true })).toMatchObject({
|
|
133
157
|
shortLabel: "Syncing",
|
|
@@ -186,10 +210,25 @@ describe("getNetworkStatusPresentation", () => {
|
|
|
186
210
|
}),
|
|
187
211
|
}).connectedGroupsOverlay).toBeUndefined();
|
|
188
212
|
});
|
|
213
|
+
it("turns an offline indicator yellow when recovery is stuck and keeps the offline icon", () => {
|
|
214
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
215
|
+
availability: "ready",
|
|
216
|
+
status: status({ state: "offline", connectionCount: 0, stuckRecoveryCount: 2 }),
|
|
217
|
+
})).toMatchObject({
|
|
218
|
+
iconClass: "bi-cloud-slash-fill",
|
|
219
|
+
colorClass: "text-warning",
|
|
220
|
+
label: expect.stringContaining("2 records need attention"),
|
|
221
|
+
});
|
|
222
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
223
|
+
availability: "ready",
|
|
224
|
+
status: status({ state: "error", errorCount: 1, stuckRecoveryCount: 2 }),
|
|
225
|
+
}).colorClass).toBe("text-danger");
|
|
226
|
+
});
|
|
189
227
|
});
|
|
190
228
|
describe("NetworkStatusIndicator", () => {
|
|
191
229
|
beforeEach(() => {
|
|
192
230
|
mockViewState = { availability: "loading" };
|
|
231
|
+
(0, network_viewer_section_1.networkViewerSection)("main");
|
|
193
232
|
getConnections.mockReset();
|
|
194
233
|
getConnections.mockResolvedValue({ connections: [] });
|
|
195
234
|
jest.clearAllMocks();
|
|
@@ -462,5 +501,75 @@ describe("NetworkStatusIndicator", () => {
|
|
|
462
501
|
react_1.fireEvent.click(view.getByRole("button", { name: /Network error/ }));
|
|
463
502
|
react_1.fireEvent.click(view.getByRole("button", { name: "Open Network Viewer" }));
|
|
464
503
|
expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
|
|
504
|
+
expect((0, network_viewer_section_1.networkViewerSection)()).toBe("main");
|
|
505
|
+
});
|
|
506
|
+
it("hides zero problem counts and Sync recovery when nothing needs attention", async () => {
|
|
507
|
+
mockViewState = {
|
|
508
|
+
availability: "ready",
|
|
509
|
+
status: status({
|
|
510
|
+
state: "synced",
|
|
511
|
+
connectionCount: 4,
|
|
512
|
+
connectedContextCount: 5,
|
|
513
|
+
lastSuccessfulSyncAt: Date.parse("2026-09-27T16:00:00.000Z"),
|
|
514
|
+
}),
|
|
515
|
+
};
|
|
516
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
517
|
+
await openNetworkDetails(view, /Network synced/);
|
|
518
|
+
const labels = [...view.getByRole("dialog").querySelectorAll("dt")].map((el) => el.textContent);
|
|
519
|
+
expect(labels).toEqual(["Connections", "Groups connected", "Last sync"]);
|
|
520
|
+
expect(view.queryByRole("button", { name: "Sync recovery" })).toBeNull();
|
|
521
|
+
});
|
|
522
|
+
it("orders non-zero status rows and offers Sync recovery when records are stuck", async () => {
|
|
523
|
+
mockViewState = {
|
|
524
|
+
availability: "ready",
|
|
525
|
+
status: status({
|
|
526
|
+
state: "error",
|
|
527
|
+
connectionCount: 4,
|
|
528
|
+
connectedContextCount: 5,
|
|
529
|
+
disconnectedContextCount: 1,
|
|
530
|
+
syncingContextCount: 2,
|
|
531
|
+
errorCount: 3,
|
|
532
|
+
stuckRecoveryCount: 1,
|
|
533
|
+
lastSuccessfulSyncAt: Date.parse("2026-09-27T16:00:00.000Z"),
|
|
534
|
+
errors: [{ dataContextId: "context", message: "sync failed", occurredAt: 1 }],
|
|
535
|
+
}),
|
|
536
|
+
};
|
|
537
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
538
|
+
await openNetworkDetails(view, /Network error/);
|
|
539
|
+
const labels = [...view.getByRole("dialog").querySelectorAll("dt")].map((el) => el.textContent);
|
|
540
|
+
expect(labels).toEqual([
|
|
541
|
+
"Connections",
|
|
542
|
+
"Groups connected",
|
|
543
|
+
"Syncing contexts",
|
|
544
|
+
"Groups offline",
|
|
545
|
+
"Sync errors",
|
|
546
|
+
"Last sync",
|
|
547
|
+
]);
|
|
548
|
+
const groupsOffline = view.getByText("Groups offline");
|
|
549
|
+
expect(groupsOffline.className).toContain("text-warning");
|
|
550
|
+
expect(groupsOffline.nextElementSibling?.textContent).toBe("1");
|
|
551
|
+
expect(groupsOffline.nextElementSibling?.className).toContain("text-warning");
|
|
552
|
+
expect(view.getByRole("button", { name: "Sync recovery" }).className).toContain("btn-warning");
|
|
553
|
+
});
|
|
554
|
+
it("opens Sync recovery inside the Network tab", async () => {
|
|
555
|
+
(0, network_viewer_section_1.networkViewerSection)("main");
|
|
556
|
+
mockViewState = {
|
|
557
|
+
availability: "ready",
|
|
558
|
+
status: status({ state: "synced", stuckRecoveryCount: 2 }),
|
|
559
|
+
};
|
|
560
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
561
|
+
await openNetworkDetails(view, /Network synced/);
|
|
562
|
+
react_1.fireEvent.click(view.getByRole("button", { name: "Sync recovery" }));
|
|
563
|
+
expect((0, network_viewer_section_1.networkViewerSection)()).toBe("sync-recovery");
|
|
564
|
+
expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
|
|
565
|
+
expect(goToTabPath).not.toHaveBeenCalledWith("sync-recovery");
|
|
566
|
+
expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
|
|
465
567
|
});
|
|
466
568
|
});
|
|
569
|
+
async function openNetworkDetails(view, name) {
|
|
570
|
+
react_1.fireEvent.click(view.getByRole("button", { name }));
|
|
571
|
+
await (0, react_1.waitFor)(() => expect(getConnections).toHaveBeenCalled());
|
|
572
|
+
await (0, react_1.act)(async () => {
|
|
573
|
+
await Promise.resolve();
|
|
574
|
+
});
|
|
575
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.9",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"lint:fix": "biome check --write ."
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@peers-app/peers-sdk": "^0.25.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.25.9",
|
|
32
32
|
"bootstrap": "^5.3.3",
|
|
33
33
|
"react": "^18.0.0",
|
|
34
34
|
"react-dom": "^18.0.0"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@babel/preset-react": "^7.24.1",
|
|
40
40
|
"@babel/preset-typescript": "^7.27.1",
|
|
41
41
|
"@biomejs/biome": "2.3.11",
|
|
42
|
-
"@peers-app/peers-sdk": "0.25.
|
|
42
|
+
"@peers-app/peers-sdk": "0.25.9",
|
|
43
43
|
"@testing-library/dom": "^10.4.0",
|
|
44
44
|
"@testing-library/jest-dom": "^6.6.3",
|
|
45
45
|
"@testing-library/react": "^16.3.0",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { observable } from "@peers-app/peers-sdk";
|
|
2
|
+
|
|
3
|
+
/** Pane shown inside the Network Viewer tab. */
|
|
4
|
+
export type NetworkViewerSection = "main" | "sync-recovery";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which Network Viewer pane is showing.
|
|
8
|
+
*
|
|
9
|
+
* The status overlay sets this before focusing the Network tab, so Sync recovery
|
|
10
|
+
* opens inside that tab instead of replacing whatever screen is current.
|
|
11
|
+
*/
|
|
12
|
+
export const networkViewerSection = observable<NetworkViewerSection>("main");
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
2
|
+
import { NetworkViewerList } from "./network-viewer";
|
|
3
|
+
import { networkViewerSection } from "./network-viewer-section";
|
|
4
|
+
|
|
5
|
+
jest.mock("../../ui-router/ui-loader", () => ({
|
|
6
|
+
registerInternalPeersUI: jest.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock("./network-viewer-ipc", () => ({
|
|
10
|
+
getNetworkDiagnosticsConsumer: async () => ({
|
|
11
|
+
tools: {
|
|
12
|
+
getOverview: async () => ({
|
|
13
|
+
overview: {
|
|
14
|
+
localDeviceId: "device",
|
|
15
|
+
localUserId: "user",
|
|
16
|
+
totalConnections: 0,
|
|
17
|
+
availableSlots: 8,
|
|
18
|
+
maxConnections: 8,
|
|
19
|
+
cpuPercent: 0,
|
|
20
|
+
memPercent: 0,
|
|
21
|
+
groups: [],
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
getConnections: async () => ({ connections: [] }),
|
|
25
|
+
getGroupSyncStatus: async () => ({ groups: [] }),
|
|
26
|
+
getDisconnectedDevices: async () => ({ devices: [] }),
|
|
27
|
+
getSyncRecoveryEntries: async () => ({ entries: [] }),
|
|
28
|
+
},
|
|
29
|
+
observables: {
|
|
30
|
+
status: {
|
|
31
|
+
subscribe: () => ({ dispose() {} }),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
getNetworkControlConsumer: async () => ({ tools: {} }),
|
|
36
|
+
getNetworkViewerPlatformApi: () => undefined,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
describe("Network Viewer sections", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
networkViewerSection("main");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("shows Sync recovery in place and returns to the overview", async () => {
|
|
45
|
+
const view = render(<NetworkViewerList />);
|
|
46
|
+
await waitFor(() => {
|
|
47
|
+
expect(view.getByRole("heading", { name: "Network Viewer" })).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
fireEvent.click(view.getByRole("button", { name: "Sync recovery" }));
|
|
51
|
+
expect(await view.findByRole("heading", { name: "Sync recovery" })).toBeTruthy();
|
|
52
|
+
expect(view.queryByRole("heading", { name: "Network Viewer" })).toBeNull();
|
|
53
|
+
|
|
54
|
+
fireEvent.click(view.getByRole("button", { name: "Back" }));
|
|
55
|
+
expect(view.getByRole("heading", { name: "Network Viewer" })).toBeTruthy();
|
|
56
|
+
view.unmount();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("opens on Sync recovery when the overlay selects that pane", async () => {
|
|
60
|
+
networkViewerSection("sync-recovery");
|
|
61
|
+
const view = render(<NetworkViewerList />);
|
|
62
|
+
|
|
63
|
+
expect(await view.findByRole("heading", { name: "Sync recovery" })).toBeTruthy();
|
|
64
|
+
expect(await view.findByText("No records are waiting in the recovery queue.")).toBeTruthy();
|
|
65
|
+
expect(view.queryByRole("heading", { name: "Network Viewer" })).toBeNull();
|
|
66
|
+
view.unmount();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { useCallback, useEffect, useState } from "react";
|
|
9
9
|
import { LoadingIndicator } from "../../components/loading-indicator";
|
|
10
10
|
import { TrustLevelBadge } from "../../components/trust-level-badge";
|
|
11
|
+
import { useObservable } from "../../hooks";
|
|
11
12
|
import { registerInternalPeersUI } from "../../ui-router/ui-loader";
|
|
12
13
|
import { ConnectionTroubleshooter } from "./connection-troubleshooter";
|
|
13
14
|
import { DeviceDetailsModal } from "./device-details-modal";
|
|
@@ -17,6 +18,8 @@ import {
|
|
|
17
18
|
getNetworkDiagnosticsConsumer,
|
|
18
19
|
getNetworkViewerPlatformApi,
|
|
19
20
|
} from "./network-viewer-ipc";
|
|
21
|
+
import { networkViewerSection } from "./network-viewer-section";
|
|
22
|
+
import { SyncRecoveryScreen } from "./sync-recovery";
|
|
20
23
|
import { UsageGraph } from "./usage-graph";
|
|
21
24
|
|
|
22
25
|
/** Format bytes to human-readable string (KB, MB, GB) */
|
|
@@ -38,6 +41,12 @@ function formatRate(mbps: number | undefined): string {
|
|
|
38
41
|
return `${mbps.toFixed(2)} MB/s`;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Network Viewer screen.
|
|
46
|
+
*
|
|
47
|
+
* Shows connections, group sync, and disconnected devices, and hosts the
|
|
48
|
+
* Sync Recovery pane selected from the network status indicator.
|
|
49
|
+
*/
|
|
41
50
|
export function NetworkViewerList() {
|
|
42
51
|
const [overview, setOverview] = useState<INetworkOverview | null>(null);
|
|
43
52
|
const [connections, setConnections] = useState<INetworkConnectionInfo[]>([]);
|
|
@@ -49,6 +58,7 @@ export function NetworkViewerList() {
|
|
|
49
58
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
|
50
59
|
const [connecting, setConnecting] = useState<Set<string>>(new Set());
|
|
51
60
|
const [showTroubleshooter, setShowTroubleshooter] = useState(false);
|
|
61
|
+
const [viewerSection] = useObservable(networkViewerSection);
|
|
52
62
|
|
|
53
63
|
const loadData = useCallback(async () => {
|
|
54
64
|
try {
|
|
@@ -159,6 +169,16 @@ export function NetworkViewerList() {
|
|
|
159
169
|
return () => clearInterval(interval);
|
|
160
170
|
}, [loadData]);
|
|
161
171
|
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (viewerSection === "sync-recovery") {
|
|
174
|
+
setShowTroubleshooter(false);
|
|
175
|
+
}
|
|
176
|
+
}, [viewerSection]);
|
|
177
|
+
|
|
178
|
+
if (viewerSection === "sync-recovery") {
|
|
179
|
+
return <SyncRecoveryScreen onBack={() => networkViewerSection("main")} />;
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
if (loading) {
|
|
163
183
|
return <LoadingIndicator />;
|
|
164
184
|
}
|
|
@@ -190,6 +210,13 @@ export function NetworkViewerList() {
|
|
|
190
210
|
Troubleshoot
|
|
191
211
|
</button>
|
|
192
212
|
)}
|
|
213
|
+
<button
|
|
214
|
+
className="btn btn-sm btn-outline-secondary"
|
|
215
|
+
onClick={() => networkViewerSection("sync-recovery")}
|
|
216
|
+
title="Records that could not be materialized"
|
|
217
|
+
>
|
|
218
|
+
Sync recovery
|
|
219
|
+
</button>
|
|
193
220
|
<button className="btn btn-sm btn-primary" onClick={handleRefresh} disabled={refreshing}>
|
|
194
221
|
<i className={`bi bi-arrow-clockwise ${refreshing ? "spin" : ""}`}></i>
|
|
195
222
|
{refreshing ? " Refreshing..." : " Refresh"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { SyncRecoveryScreen } from "./sync-recovery";
|
|
3
|
+
|
|
4
|
+
const mockResolveSyncRecovery = jest.fn(async () => ({
|
|
5
|
+
success: false,
|
|
6
|
+
message: "Permission denied for Shared Group: requires Owner",
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock("./network-viewer-ipc", () => ({
|
|
10
|
+
getNetworkDiagnosticsConsumer: async () => ({
|
|
11
|
+
tools: {
|
|
12
|
+
getSyncRecoveryEntries: async () => ({
|
|
13
|
+
entries: [
|
|
14
|
+
{
|
|
15
|
+
dataContextId: "personal",
|
|
16
|
+
dataContextName: "Personal",
|
|
17
|
+
syncRecoveryId: "queue-1",
|
|
18
|
+
tableName: "Messages",
|
|
19
|
+
recordId: "00mflddy2sbk393s47smn4tgl",
|
|
20
|
+
firstFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
21
|
+
lastFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
22
|
+
attemptCount: 3,
|
|
23
|
+
latestError:
|
|
24
|
+
'Validation on insert failed for Messages: [{"code":"custom","message":"assistant id was empty","path":["assistantId"]}]',
|
|
25
|
+
status: "parked",
|
|
26
|
+
parkedReason: "schema",
|
|
27
|
+
stuck: true,
|
|
28
|
+
localRowExists: false,
|
|
29
|
+
recordSummary: "hello from the stored message",
|
|
30
|
+
queuedRecordPreview: '{\n "message": "hello from the stored message"\n}',
|
|
31
|
+
recordPreview: '{\n "message": "hello from the stored message"\n}',
|
|
32
|
+
changeCreatedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
dataContextId: "personal",
|
|
36
|
+
dataContextName: "Personal",
|
|
37
|
+
syncRecoveryId: "queue-2",
|
|
38
|
+
tableName: "UniqueNames",
|
|
39
|
+
recordId: "00mflddy2sbk393s47smn4tgm",
|
|
40
|
+
firstFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
41
|
+
lastFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
42
|
+
attemptCount: 1,
|
|
43
|
+
latestError: "UNIQUE constraint failed: UniqueNames.name",
|
|
44
|
+
status: "parked",
|
|
45
|
+
parkedReason: "unique-conflict",
|
|
46
|
+
stuck: true,
|
|
47
|
+
localRowExists: true,
|
|
48
|
+
conflictingRecordId: "00mflddy2sbk393s47smn4tgn",
|
|
49
|
+
tombstoneRecordId: "00mflddy2sbk393s47smn4tgm",
|
|
50
|
+
recordSummary: "shared",
|
|
51
|
+
keptRecordPreview: '{\n "name": "shared",\n "value": "local"\n}',
|
|
52
|
+
queuedRecordPreview: '{\n "name": "shared",\n "value": "rejected"\n}',
|
|
53
|
+
recordPreview: '{\n "name": "shared",\n "value": "local"\n}',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
observables: {
|
|
59
|
+
status: {
|
|
60
|
+
subscribe: () => ({ dispose() {} }),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
getNetworkControlConsumer: async () => ({
|
|
65
|
+
tools: {
|
|
66
|
+
resolveSyncRecovery: () => mockResolveSyncRecovery(),
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
describe("SyncRecoveryScreen", () => {
|
|
72
|
+
it("shows the full error, status help, action help, and stored record", async () => {
|
|
73
|
+
render(<SyncRecoveryScreen onBack={() => undefined} />);
|
|
74
|
+
|
|
75
|
+
expect(await screen.findByText("hello from the stored message")).toBeTruthy();
|
|
76
|
+
expect(screen.getByText(/Validation on insert failed/)).toBeTruthy();
|
|
77
|
+
expect(screen.queryByText(/assistant id was empty/)).toBeNull();
|
|
78
|
+
|
|
79
|
+
fireEvent.click(screen.getAllByRole("button", { name: "Show error details" })[0]);
|
|
80
|
+
const error = screen.getByText(/assistant id was empty/);
|
|
81
|
+
expect(error.tagName).toBe("PRE");
|
|
82
|
+
|
|
83
|
+
fireEvent.click(screen.getByRole("button", { name: "About status Parked, schema" }));
|
|
84
|
+
expect(screen.getByText(/does not match this device's rules/)).toBeTruthy();
|
|
85
|
+
|
|
86
|
+
fireEvent.click(screen.getByRole("button", { name: "About these actions" }));
|
|
87
|
+
expect(screen.getByText(/only the other row is stored/)).toBeTruthy();
|
|
88
|
+
|
|
89
|
+
fireEvent.click(screen.getAllByRole("button", { name: "Show record details" })[0]);
|
|
90
|
+
expect(screen.getByText(/Change written/)).toBeTruthy();
|
|
91
|
+
expect(screen.getByText("Queued change")).toBeTruthy();
|
|
92
|
+
expect(screen.getByText(/"message": "hello from the stored message"/)).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("labels the row Keep will snapshot separately from a different queued change", async () => {
|
|
96
|
+
render(<SyncRecoveryScreen onBack={() => undefined} />);
|
|
97
|
+
fireEvent.click((await screen.findAllByRole("button", { name: "Show record details" }))[1]);
|
|
98
|
+
expect(screen.getByText("This device's version")).toBeTruthy();
|
|
99
|
+
expect(screen.getByText(/"value": "local"/)).toBeTruthy();
|
|
100
|
+
expect(screen.getByText("Queued change")).toBeTruthy();
|
|
101
|
+
expect(screen.getByText(/"value": "rejected"/)).toBeTruthy();
|
|
102
|
+
expect(screen.getByText(/tombstones this queued id/)).toBeTruthy();
|
|
103
|
+
expect(screen.queryByText(/Keep deletes/)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps a failed action message after the list reloads", async () => {
|
|
107
|
+
render(<SyncRecoveryScreen onBack={() => undefined} />);
|
|
108
|
+
fireEvent.click((await screen.findAllByRole("button", { name: "Ignore on this device" }))[0]);
|
|
109
|
+
expect(
|
|
110
|
+
await screen.findByText("Permission denied for Shared Group: requires Owner"),
|
|
111
|
+
).toBeTruthy();
|
|
112
|
+
expect(screen.getByText("hello from the stored message")).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
});
|