@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.
- 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/packages/local-folder-grants-panel.d.ts +15 -0
- package/dist/screens/packages/local-folder-grants-panel.js +131 -0
- package/dist/screens/packages/local-folders-ipc.d.ts +24 -0
- package/dist/screens/packages/local-folders-ipc.js +10 -0
- package/dist/screens/packages/package-details.js +5 -0
- package/dist/screens/packages/package-list.d.ts +1 -0
- package/dist/screens/packages/package-list.js +3 -1
- 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/packages/local-folder-grants-panel.tsx +290 -0
- package/src/screens/packages/local-folders-ipc.ts +28 -0
- package/src/screens/packages/package-details.tsx +9 -0
- package/src/screens/packages/package-list.tsx +4 -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
|
@@ -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;
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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.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.
|
|
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.
|
|
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"}
|