@peers-app/peers-ui 0.25.7 → 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.
Files changed (24) hide show
  1. package/dist/screens/network-viewer/network-viewer-section.d.ts +9 -0
  2. package/dist/screens/network-viewer/network-viewer-section.js +11 -0
  3. package/dist/screens/network-viewer/network-viewer.d.ts +6 -0
  4. package/dist/screens/network-viewer/network-viewer.js +19 -1
  5. package/dist/screens/network-viewer/network-viewer.section.test.d.ts +1 -0
  6. package/dist/screens/network-viewer/network-viewer.section.test.js +63 -0
  7. package/dist/screens/network-viewer/sync-recovery.d.ts +8 -0
  8. package/dist/screens/network-viewer/sync-recovery.js +182 -0
  9. package/dist/screens/network-viewer/sync-recovery.test.d.ts +1 -0
  10. package/dist/screens/network-viewer/sync-recovery.test.js +104 -0
  11. package/dist/screens/settings/settings-page.js +13 -3
  12. package/dist/tabs-layout/network-status-indicator.js +39 -11
  13. package/dist/tabs-layout/network-status-indicator.test.js +109 -0
  14. package/dist/tabs-layout/use-network-status.test.js +1 -0
  15. package/package.json +3 -3
  16. package/src/screens/network-viewer/network-viewer-section.ts +12 -0
  17. package/src/screens/network-viewer/network-viewer.section.test.tsx +68 -0
  18. package/src/screens/network-viewer/network-viewer.tsx +27 -0
  19. package/src/screens/network-viewer/sync-recovery.test.tsx +114 -0
  20. package/src/screens/network-viewer/sync-recovery.tsx +461 -0
  21. package/src/screens/settings/settings-page.tsx +15 -5
  22. package/src/tabs-layout/network-status-indicator.test.tsx +131 -0
  23. package/src/tabs-layout/network-status-indicator.tsx +121 -43
  24. 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 NetworkStatusDetails({ detailsId, status, unavailable, connectionsLoadState, onUseInvite, onOpenIdentity, onOpenViewer, }) {
194
- 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)("dt", { className: "col-7", children: "Connections" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.connectionCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Syncing contexts" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.syncingContextCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Sync errors" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.errorCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Groups connected" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.connectedContextCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Groups offline" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.disconnectedContextCount }), 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)`] })), (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: "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", onClick: onOpenViewer, children: "Open Network Viewer" })] }) }));
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
+ }
@@ -16,6 +16,7 @@ function makeStatus(overrides = {}) {
16
16
  errorCount: 0,
17
17
  disconnectedContextCount: 0,
18
18
  connectedContextCount: 0,
19
+ stuckRecoveryCount: 0,
19
20
  errors: [],
20
21
  ...overrides,
21
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.25.7",
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.7",
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.7",
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
+ });