@peers-app/peers-ui 0.25.8 → 0.25.10

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 (35) 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/packages/local-folder-grants-panel.d.ts +15 -0
  12. package/dist/screens/packages/local-folder-grants-panel.js +131 -0
  13. package/dist/screens/packages/local-folders-ipc.d.ts +24 -0
  14. package/dist/screens/packages/local-folders-ipc.js +10 -0
  15. package/dist/screens/packages/package-details.js +5 -0
  16. package/dist/screens/packages/package-list.d.ts +1 -0
  17. package/dist/screens/packages/package-list.js +3 -1
  18. package/dist/screens/settings/settings-page.js +13 -3
  19. package/dist/tabs-layout/network-status-indicator.js +39 -11
  20. package/dist/tabs-layout/network-status-indicator.test.js +109 -0
  21. package/dist/tabs-layout/use-network-status.test.js +1 -0
  22. package/package.json +3 -3
  23. package/src/screens/network-viewer/network-viewer-section.ts +12 -0
  24. package/src/screens/network-viewer/network-viewer.section.test.tsx +68 -0
  25. package/src/screens/network-viewer/network-viewer.tsx +27 -0
  26. package/src/screens/network-viewer/sync-recovery.test.tsx +114 -0
  27. package/src/screens/network-viewer/sync-recovery.tsx +461 -0
  28. package/src/screens/packages/local-folder-grants-panel.tsx +290 -0
  29. package/src/screens/packages/local-folders-ipc.ts +28 -0
  30. package/src/screens/packages/package-details.tsx +9 -0
  31. package/src/screens/packages/package-list.tsx +4 -0
  32. package/src/screens/settings/settings-page.tsx +15 -5
  33. package/src/tabs-layout/network-status-indicator.test.tsx +131 -0
  34. package/src/tabs-layout/network-status-indicator.tsx +121 -43
  35. package/src/tabs-layout/use-network-status.test.tsx +1 -0
@@ -0,0 +1,24 @@
1
+ import { type IContractConsumerBase, type IHostContractConsumer, type ILocalFolderGrantInfo, type ILocalFolderRequestGrantInput } from "@peers-app/peers-sdk";
2
+ /** Tools the Packages screens use on System Local Folders. */
3
+ export interface ILocalFoldersUiConsumer extends IContractConsumerBase {
4
+ tools: {
5
+ /** Create a grant. Omitting `absolutePath` opens the desktop folder picker. */
6
+ requestGrant(input: ILocalFolderRequestGrantInput): Promise<ILocalFolderGrantInfo>;
7
+ /** Active grants on this device. */
8
+ listGrants(): Promise<{
9
+ grants: ILocalFolderGrantInfo[];
10
+ }>;
11
+ /** Revoke one grant. `revoked` is false when it was already gone. */
12
+ revokeGrant(input: {
13
+ grantId: string;
14
+ }): Promise<{
15
+ revoked: boolean;
16
+ }>;
17
+ };
18
+ }
19
+ /**
20
+ * Shared System Local Folders consumer for the Packages screens.
21
+ *
22
+ * The PWA host does not register this contract. Callers treat a missing provider as unavailable.
23
+ */
24
+ export declare const localFoldersConsumer: IHostContractConsumer<ILocalFoldersUiConsumer>;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.localFoldersConsumer = void 0;
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ /**
6
+ * Shared System Local Folders consumer for the Packages screens.
7
+ *
8
+ * The PWA host does not register this contract. Callers treat a missing provider as unavailable.
9
+ */
10
+ exports.localFoldersConsumer = (0, peers_sdk_1.hostContractConsumer)((0, peers_sdk_1.getLocalFoldersContractDefinition)());
@@ -9,6 +9,7 @@ const save_button_1 = require("../../components/save-button");
9
9
  const tabs_1 = require("../../components/tabs");
10
10
  const hooks_1 = require("../../hooks");
11
11
  const tabs_state_1 = require("../../tabs-layout/tabs-state");
12
+ const local_folder_grants_panel_1 = require("./local-folder-grants-panel");
12
13
  const package_info_1 = require("./package-info");
13
14
  const package_versions_1 = require("./package-versions");
14
15
  /** Detail screen for a single package with Info, Versions, Components, and Dependencies tabs. */
@@ -75,6 +76,10 @@ const PackageDetails = (props) => {
75
76
  name: "Dependencies",
76
77
  content: ((0, jsx_runtime_1.jsx)(tabs_1.ScreenTabBody, { children: "TODO - show all of the dependencies of the package" })),
77
78
  },
79
+ {
80
+ name: "Folders",
81
+ content: ((0, jsx_runtime_1.jsx)(tabs_1.ScreenTabBody, { children: (0, jsx_runtime_1.jsx)(local_folder_grants_panel_1.LocalFolderGrantsPanel, { packageId: pkg.packageId }) })),
82
+ },
78
83
  ] }, pkg.packageId)] }));
79
84
  };
80
85
  exports.PackageDetails = PackageDetails;
@@ -1,2 +1,3 @@
1
1
  /** Searchable list of installed packages, plus create/import from the search box. */
2
+ /** Packages screen: search, local folder grants, and the installed package list. */
2
3
  export declare const PackageList: () => import("react/jsx-runtime").JSX.Element;
@@ -11,6 +11,7 @@ const tooltip_1 = require("../../components/tooltip");
11
11
  const globals_1 = require("../../globals");
12
12
  const hooks_1 = require("../../hooks");
13
13
  const ui_loader_1 = require("../../ui-router/ui-loader");
14
+ const local_folder_grants_panel_1 = require("./local-folder-grants-panel");
14
15
  const package_helpers_1 = require("./package-helpers");
15
16
  function tagBadgeClass(tag) {
16
17
  if (tag === "dev")
@@ -120,6 +121,7 @@ const PackageRow = ({ pkg, onUpdated }) => {
120
121
  return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid pb-4 d-flex align-items-center", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1", style: { minWidth: 0 }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-fill" }), "\u00A0\u00A0", (0, jsx_runtime_1.jsx)("a", { href: `#packages/${pkg.packageId}`, children: pkg.name }), isDeviceOverridden && ((0, jsx_runtime_1.jsx)("span", { className: "text-warning ms-1", title: "This device is using a different version than the group", children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-pc-display" }) })), status && ((0, jsx_runtime_1.jsxs)("span", { className: "ms-2", children: [(0, jsx_runtime_1.jsxs)("small", { className: "text-muted", children: ["v", status.activePv.version] }), status.activePv.versionTag && ((0, jsx_runtime_1.jsx)("span", { className: `badge ms-1 ${tagBadgeClass(status.activePv.versionTag)}`, style: { fontSize: "0.65em" }, children: status.activePv.versionTag })), hasUpdate ? ((0, jsx_runtime_1.jsxs)("span", { className: `badge ms-1 ${updateBadgeClass(status.newerLevel)}`, style: { fontSize: "0.65em" }, children: ["v", status.newestPv?.version, " available"] })) : isOnDev && hasGroupRelease ? ((0, jsx_runtime_1.jsxs)("span", { className: "badge ms-1 text-bg-secondary", style: { fontSize: "0.65em" }, children: ["release: v", status.groupReleasePv?.version] })) : ((0, jsx_runtime_1.jsx)("span", { className: "badge ms-1 text-bg-success", style: { fontSize: "0.65em" }, children: "up to date" }))] })), (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: pkg.description, positions: ["bottom", "top", "right", "left"] })] }), hasUpdate && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-primary ms-2 text-nowrap", disabled: updating, onClick: handleUpdate, children: [updating ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-1" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-arrow-up-circle me-1" })), "Update"] })), isOnDev && hasGroupRelease && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-secondary ms-2 text-nowrap", disabled: updating, onClick: handleUseRelease, children: [updating ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-1" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-arrow-in-right me-1" })), "Use Release"] })), hasDevVersion && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-danger ms-2 text-nowrap", disabled: updating, onClick: handleUseDev, children: [updating ? ((0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-1" })) : ((0, jsx_runtime_1.jsx)("i", { className: "bi bi-tools me-1" })), "Use Dev"] }))] }));
121
122
  };
122
123
  /** Searchable list of installed packages, plus create/import from the search box. */
124
+ /** Packages screen: search, local folder grants, and the installed package list. */
123
125
  const PackageList = () => {
124
126
  const [searchTextObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)(""));
125
127
  const [searchText] = (0, hooks_1.useObservable)(searchTextObs);
@@ -243,7 +245,7 @@ const PackageList = () => {
243
245
  if (addingPackage()) {
244
246
  return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("div", { className: "container-fluid d-flex justify-content-center mt-5", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-seam" }), "\u00A0 Adding Package"] }), (0, jsx_runtime_1.jsx)("div", { className: "container-fluid d-flex justify-content-center mt-2", children: (0, jsx_runtime_1.jsx)("div", { className: "progress", role: "progressbar", "aria-label": "Animated striped example", "aria-valuenow": 100, "aria-valuemin": 0, "aria-valuemax": 100, children: (0, jsx_runtime_1.jsx)("div", { className: "progress-bar progress-bar-striped progress-bar-animated", style: { width: 150 } }) }) })] }));
245
247
  }
246
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid", children: [(0, jsx_runtime_1.jsxs)("div", { className: "input-group mt-3 mb-3", children: [(0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", placeholder: "Search, add, or create package", autoFocus: !!(0, globals_1.isDesktop)(), onKeyUp: (evt) => searchSubmit(evt) }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-outline-secondary dropdown-toggle sm", type: "button", "data-bs-toggle": "dropdown", "aria-expanded": "false", children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-plus-lg" }) }), (0, jsx_runtime_1.jsxs)("ul", { className: "dropdown-menu dropdown-menu-end", children: [(0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item", href: "#packages/newlocal", children: "New Local Package" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item disabled", href: "#", children: "Add Local Package" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item disabled", href: "#", children: "Add Remote Package" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("hr", { className: "dropdown-divider" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item", onClick: () => peers_sdk_1.rpcServerCalls.openPath(peers_sdk_1.packagesRootDir), style: { cursor: "pointer" }, children: "Open Packages Directory" }) })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "peers-list-container", children: (0, jsx_runtime_1.jsx)(lazy_list_1.LazyList, { resetTrigger: `${searchText}_${refreshKey()}`, loadMore: loadMore, scrollThreshold: 0.6, renderItems: (packages) => {
248
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid", children: [(0, jsx_runtime_1.jsxs)("div", { className: "input-group mt-3 mb-3", children: [(0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", placeholder: "Search, add, or create package", autoFocus: !!(0, globals_1.isDesktop)(), onKeyUp: (evt) => searchSubmit(evt) }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-outline-secondary dropdown-toggle sm", type: "button", "data-bs-toggle": "dropdown", "aria-expanded": "false", children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-plus-lg" }) }), (0, jsx_runtime_1.jsxs)("ul", { className: "dropdown-menu dropdown-menu-end", children: [(0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item", href: "#packages/newlocal", children: "New Local Package" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item disabled", href: "#", children: "Add Local Package" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item disabled", href: "#", children: "Add Remote Package" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("hr", { className: "dropdown-divider" }) }), (0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)("a", { className: "dropdown-item", onClick: () => peers_sdk_1.rpcServerCalls.openPath(peers_sdk_1.packagesRootDir), style: { cursor: "pointer" }, children: "Open Packages Directory" }) })] })] }), (0, jsx_runtime_1.jsx)(local_folder_grants_panel_1.LocalFolderGrantsPanel, {}), (0, jsx_runtime_1.jsx)("div", { className: "peers-list-container", children: (0, jsx_runtime_1.jsx)(lazy_list_1.LazyList, { resetTrigger: `${searchText}_${refreshKey()}`, loadMore: loadMore, scrollThreshold: 0.6, renderItems: (packages) => {
247
249
  return packages.map((pkg) => ((0, jsx_runtime_1.jsx)(PackageRow, { pkg: pkg, onUpdated: () => refreshKey(refreshKey() + 1) }, pkg.packageId)));
248
250
  }, loadingIndicator: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 }, children: (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}) }), endOfList: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 } }) }) })] }));
249
251
  };
@@ -45,6 +45,7 @@ const hooks_1 = require("../../hooks");
45
45
  const layout_picker_1 = require("../../root-layout/layout-picker");
46
46
  const identity_ipc_1 = require("../device-pairing/identity-ipc");
47
47
  const identity_routes_1 = require("../identity/identity-routes");
48
+ const network_viewer_ipc_1 = require("../network-viewer/network-viewer-ipc");
48
49
  const color_mode_dropdown_1 = require("./color-mode-dropdown");
49
50
  const isBrowserPwaHost = typeof window !== "undefined" &&
50
51
  !window.electronAPI &&
@@ -138,11 +139,16 @@ const AutoUpdatePeersCore = () => {
138
139
  const ResetDeviceSyncInfos = () => {
139
140
  return ((0, jsx_runtime_1.jsx)("div", { className: "mt-3", children: (0, jsx_runtime_1.jsx)("button", { className: "btn btn-secondary btn-sm ms-2", onClick: async () => {
140
141
  try {
141
- await peers_sdk_1.rpcServerCalls.resetAllDeviceSyncInfo();
142
+ const result = await (await (0, network_viewer_ipc_1.getNetworkControlConsumer)()).tools.resetDeviceSyncInfo();
143
+ if (!result.success) {
144
+ alert(result.message);
145
+ return;
146
+ }
142
147
  alert("All device sync info has been reset. Syncing in progress...");
143
148
  }
144
149
  catch (err) {
145
150
  console.error("Error while resetting all device sync info", err);
151
+ alert(`Failed to reset device sync info: ${err.message || String(err)}`);
146
152
  }
147
153
  }, children: "Reset All Device Sync Info" }) }));
148
154
  };
@@ -199,13 +205,17 @@ const CleanupOldDataDir = () => {
199
205
  };
200
206
  const ResetChangeTracking = () => {
201
207
  const [busy, setBusy] = (0, react_1.useState)(false);
202
- return ((0, jsx_runtime_1.jsxs)("div", { className: "mt-4 pt-3 border-top", children: [(0, jsx_runtime_1.jsx)("h6", { className: "mb-2", children: "Change tracking" }), (0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block mb-2", children: "Clears all change history for the current group and rebuilds it from your current database rows. Device sync cursors are cleared; a full resync with peers may follow. Use when debugging sync issues." }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-warning btn-sm", disabled: busy, onClick: async () => {
208
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "mt-4 pt-3 border-top", children: [(0, jsx_runtime_1.jsx)("h6", { className: "mb-2", children: "Change tracking" }), (0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block mb-2", children: "Clears change history for every personal and group context on this device and rebuilds it from current rows. Device sync cursors are cleared; a full resync with peers may follow. Use when debugging sync issues." }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-warning btn-sm", disabled: busy, onClick: async () => {
203
209
  const confirmed = window.confirm("Reset Change Tracking will clear all change history and rebuild it from scratch. This may take a while. Continue?");
204
210
  if (!confirmed)
205
211
  return;
206
212
  setBusy(true);
207
213
  try {
208
- await peers_sdk_1.rpcServerCalls.resetChangeTracking();
214
+ const result = await (await (0, network_viewer_ipc_1.getNetworkControlConsumer)()).tools.resetChangeTracking();
215
+ if (!result.success) {
216
+ alert(result.message);
217
+ return;
218
+ }
209
219
  await peers_sdk_1.rpcServerCalls.flushDatabases();
210
220
  alert("Change tracking reset successfully.");
211
221
  }
@@ -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.8",
3
+ "version": "0.25.10",
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.8",
31
+ "@peers-app/peers-sdk": "^0.25.10",
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.8",
42
+ "@peers-app/peers-sdk": "0.25.10",
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"}