@peers-app/peers-ui 0.25.8 → 0.25.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/screens/network-viewer/network-viewer-section.d.ts +9 -0
- package/dist/screens/network-viewer/network-viewer-section.js +11 -0
- package/dist/screens/network-viewer/network-viewer.d.ts +6 -0
- package/dist/screens/network-viewer/network-viewer.js +19 -1
- package/dist/screens/network-viewer/network-viewer.section.test.d.ts +1 -0
- package/dist/screens/network-viewer/network-viewer.section.test.js +63 -0
- package/dist/screens/network-viewer/sync-recovery.d.ts +8 -0
- package/dist/screens/network-viewer/sync-recovery.js +182 -0
- package/dist/screens/network-viewer/sync-recovery.test.d.ts +1 -0
- package/dist/screens/network-viewer/sync-recovery.test.js +104 -0
- package/dist/screens/settings/settings-page.js +13 -3
- package/dist/tabs-layout/network-status-indicator.js +39 -11
- package/dist/tabs-layout/network-status-indicator.test.js +109 -0
- package/dist/tabs-layout/use-network-status.test.js +1 -0
- package/package.json +3 -3
- package/src/screens/network-viewer/network-viewer-section.ts +12 -0
- package/src/screens/network-viewer/network-viewer.section.test.tsx +68 -0
- package/src/screens/network-viewer/network-viewer.tsx +27 -0
- package/src/screens/network-viewer/sync-recovery.test.tsx +114 -0
- package/src/screens/network-viewer/sync-recovery.tsx +461 -0
- package/src/screens/settings/settings-page.tsx +15 -5
- package/src/tabs-layout/network-status-indicator.test.tsx +131 -0
- package/src/tabs-layout/network-status-indicator.tsx +121 -43
- package/src/tabs-layout/use-network-status.test.tsx +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Pane shown inside the Network Viewer tab. */
|
|
2
|
+
export type NetworkViewerSection = "main" | "sync-recovery";
|
|
3
|
+
/**
|
|
4
|
+
* Which Network Viewer pane is showing.
|
|
5
|
+
*
|
|
6
|
+
* The status overlay sets this before focusing the Network tab, so Sync recovery
|
|
7
|
+
* opens inside that tab instead of replacing whatever screen is current.
|
|
8
|
+
*/
|
|
9
|
+
export declare const networkViewerSection: import("@peers-app/peers-sdk").Observable<NetworkViewerSection>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.networkViewerSection = void 0;
|
|
4
|
+
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
5
|
+
/**
|
|
6
|
+
* Which Network Viewer pane is showing.
|
|
7
|
+
*
|
|
8
|
+
* The status overlay sets this before focusing the Network tab, so Sync recovery
|
|
9
|
+
* opens inside that tab instead of replacing whatever screen is current.
|
|
10
|
+
*/
|
|
11
|
+
exports.networkViewerSection = (0, peers_sdk_1.observable)("main");
|
|
@@ -6,11 +6,14 @@ const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
|
6
6
|
const react_1 = require("react");
|
|
7
7
|
const loading_indicator_1 = require("../../components/loading-indicator");
|
|
8
8
|
const trust_level_badge_1 = require("../../components/trust-level-badge");
|
|
9
|
+
const hooks_1 = require("../../hooks");
|
|
9
10
|
const ui_loader_1 = require("../../ui-router/ui-loader");
|
|
10
11
|
const connection_troubleshooter_1 = require("./connection-troubleshooter");
|
|
11
12
|
const device_details_modal_1 = require("./device-details-modal");
|
|
12
13
|
const group_details_modal_1 = require("./group-details-modal");
|
|
13
14
|
const network_viewer_ipc_1 = require("./network-viewer-ipc");
|
|
15
|
+
const network_viewer_section_1 = require("./network-viewer-section");
|
|
16
|
+
const sync_recovery_1 = require("./sync-recovery");
|
|
14
17
|
const usage_graph_1 = require("./usage-graph");
|
|
15
18
|
/** Format bytes to human-readable string (KB, MB, GB) */
|
|
16
19
|
function formatBytes(bytes) {
|
|
@@ -33,6 +36,12 @@ function formatRate(mbps) {
|
|
|
33
36
|
}
|
|
34
37
|
return `${mbps.toFixed(2)} MB/s`;
|
|
35
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Network Viewer screen.
|
|
41
|
+
*
|
|
42
|
+
* Shows connections, group sync, and disconnected devices, and hosts the
|
|
43
|
+
* Sync Recovery pane selected from the network status indicator.
|
|
44
|
+
*/
|
|
36
45
|
function NetworkViewerList() {
|
|
37
46
|
const [overview, setOverview] = (0, react_1.useState)(null);
|
|
38
47
|
const [connections, setConnections] = (0, react_1.useState)([]);
|
|
@@ -44,6 +53,7 @@ function NetworkViewerList() {
|
|
|
44
53
|
const [selectedGroupId, setSelectedGroupId] = (0, react_1.useState)(null);
|
|
45
54
|
const [connecting, setConnecting] = (0, react_1.useState)(new Set());
|
|
46
55
|
const [showTroubleshooter, setShowTroubleshooter] = (0, react_1.useState)(false);
|
|
56
|
+
const [viewerSection] = (0, hooks_1.useObservable)(network_viewer_section_1.networkViewerSection);
|
|
47
57
|
const loadData = (0, react_1.useCallback)(async () => {
|
|
48
58
|
try {
|
|
49
59
|
const diagnostics = await (0, network_viewer_ipc_1.getNetworkDiagnosticsConsumer)();
|
|
@@ -151,6 +161,14 @@ function NetworkViewerList() {
|
|
|
151
161
|
const interval = setInterval(loadData, 5000);
|
|
152
162
|
return () => clearInterval(interval);
|
|
153
163
|
}, [loadData]);
|
|
164
|
+
(0, react_1.useEffect)(() => {
|
|
165
|
+
if (viewerSection === "sync-recovery") {
|
|
166
|
+
setShowTroubleshooter(false);
|
|
167
|
+
}
|
|
168
|
+
}, [viewerSection]);
|
|
169
|
+
if (viewerSection === "sync-recovery") {
|
|
170
|
+
return (0, jsx_runtime_1.jsx)(sync_recovery_1.SyncRecoveryScreen, { onBack: () => (0, network_viewer_section_1.networkViewerSection)("main") });
|
|
171
|
+
}
|
|
154
172
|
if (loading) {
|
|
155
173
|
return (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {});
|
|
156
174
|
}
|
|
@@ -160,7 +178,7 @@ function NetworkViewerList() {
|
|
|
160
178
|
if (showTroubleshooter) {
|
|
161
179
|
return (0, jsx_runtime_1.jsx)(connection_troubleshooter_1.ConnectionTroubleshooter, { onBack: () => setShowTroubleshooter(false) });
|
|
162
180
|
}
|
|
163
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center mb-4", children: [(0, jsx_runtime_1.jsx)("h2", { children: "Network Viewer" }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2", children: [(0, network_viewer_ipc_1.getNetworkViewerPlatformApi)()?.runDiagnostics && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-warning", onClick: () => setShowTroubleshooter(true), title: "Run connection diagnostics", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-stethoscope me-1" }), "Troubleshoot"] })), (0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-primary", onClick: handleRefresh, disabled: refreshing, children: [(0, jsx_runtime_1.jsx)("i", { className: `bi bi-arrow-clockwise ${refreshing ? "spin" : ""}` }), refreshing ? " Refreshing..." : " Refresh"] })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "card mb-2", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Local Device" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device ID:" }), " ", (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: overview.localDeviceId })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6", children: [(0, jsx_runtime_1.jsx)("strong", { children: "User ID:" }), " ", (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: overview.localUserId })] })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "row mb-4", children: [(0, jsx_runtime_1.jsx)("div", { className: "col-md-4", children: (0, jsx_runtime_1.jsx)("div", { className: "card", style: { height: "100%" }, children: (0, jsx_runtime_1.jsx)("div", { className: "card-body p-2", children: (0, jsx_runtime_1.jsx)(usage_graph_1.UsageGraph, { label: "Connections", fetchUsage: async () => {
|
|
181
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center mb-4", children: [(0, jsx_runtime_1.jsx)("h2", { children: "Network Viewer" }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2", children: [(0, network_viewer_ipc_1.getNetworkViewerPlatformApi)()?.runDiagnostics && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-outline-warning", onClick: () => setShowTroubleshooter(true), title: "Run connection diagnostics", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-stethoscope me-1" }), "Troubleshoot"] })), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-sm btn-outline-secondary", onClick: () => (0, network_viewer_section_1.networkViewerSection)("sync-recovery"), title: "Records that could not be materialized", children: "Sync recovery" }), (0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-primary", onClick: handleRefresh, disabled: refreshing, children: [(0, jsx_runtime_1.jsx)("i", { className: `bi bi-arrow-clockwise ${refreshing ? "spin" : ""}` }), refreshing ? " Refreshing..." : " Refresh"] })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "card mb-2", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Local Device" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device ID:" }), " ", (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: overview.localDeviceId })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6", children: [(0, jsx_runtime_1.jsx)("strong", { children: "User ID:" }), " ", (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: overview.localUserId })] })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "row mb-4", children: [(0, jsx_runtime_1.jsx)("div", { className: "col-md-4", children: (0, jsx_runtime_1.jsx)("div", { className: "card", style: { height: "100%" }, children: (0, jsx_runtime_1.jsx)("div", { className: "card-body p-2", children: (0, jsx_runtime_1.jsx)(usage_graph_1.UsageGraph, { label: "Connections", fetchUsage: async () => {
|
|
164
182
|
const diagnostics = await (0, network_viewer_ipc_1.getNetworkDiagnosticsConsumer)();
|
|
165
183
|
const { overview: currentOverview } = await diagnostics.tools.getOverview();
|
|
166
184
|
return currentOverview.totalConnections;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_1 = require("@testing-library/react");
|
|
5
|
+
const network_viewer_1 = require("./network-viewer");
|
|
6
|
+
const network_viewer_section_1 = require("./network-viewer-section");
|
|
7
|
+
jest.mock("../../ui-router/ui-loader", () => ({
|
|
8
|
+
registerInternalPeersUI: jest.fn(),
|
|
9
|
+
}));
|
|
10
|
+
jest.mock("./network-viewer-ipc", () => ({
|
|
11
|
+
getNetworkDiagnosticsConsumer: async () => ({
|
|
12
|
+
tools: {
|
|
13
|
+
getOverview: async () => ({
|
|
14
|
+
overview: {
|
|
15
|
+
localDeviceId: "device",
|
|
16
|
+
localUserId: "user",
|
|
17
|
+
totalConnections: 0,
|
|
18
|
+
availableSlots: 8,
|
|
19
|
+
maxConnections: 8,
|
|
20
|
+
cpuPercent: 0,
|
|
21
|
+
memPercent: 0,
|
|
22
|
+
groups: [],
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
getConnections: async () => ({ connections: [] }),
|
|
26
|
+
getGroupSyncStatus: async () => ({ groups: [] }),
|
|
27
|
+
getDisconnectedDevices: async () => ({ devices: [] }),
|
|
28
|
+
getSyncRecoveryEntries: async () => ({ entries: [] }),
|
|
29
|
+
},
|
|
30
|
+
observables: {
|
|
31
|
+
status: {
|
|
32
|
+
subscribe: () => ({ dispose() { } }),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
getNetworkControlConsumer: async () => ({ tools: {} }),
|
|
37
|
+
getNetworkViewerPlatformApi: () => undefined,
|
|
38
|
+
}));
|
|
39
|
+
describe("Network Viewer sections", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
(0, network_viewer_section_1.networkViewerSection)("main");
|
|
42
|
+
});
|
|
43
|
+
it("shows Sync recovery in place and returns to the overview", async () => {
|
|
44
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_viewer_1.NetworkViewerList, {}));
|
|
45
|
+
await (0, react_1.waitFor)(() => {
|
|
46
|
+
expect(view.getByRole("heading", { name: "Network Viewer" })).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
react_1.fireEvent.click(view.getByRole("button", { name: "Sync recovery" }));
|
|
49
|
+
expect(await view.findByRole("heading", { name: "Sync recovery" })).toBeTruthy();
|
|
50
|
+
expect(view.queryByRole("heading", { name: "Network Viewer" })).toBeNull();
|
|
51
|
+
react_1.fireEvent.click(view.getByRole("button", { name: "Back" }));
|
|
52
|
+
expect(view.getByRole("heading", { name: "Network Viewer" })).toBeTruthy();
|
|
53
|
+
view.unmount();
|
|
54
|
+
});
|
|
55
|
+
it("opens on Sync recovery when the overlay selects that pane", async () => {
|
|
56
|
+
(0, network_viewer_section_1.networkViewerSection)("sync-recovery");
|
|
57
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_viewer_1.NetworkViewerList, {}));
|
|
58
|
+
expect(await view.findByRole("heading", { name: "Sync recovery" })).toBeTruthy();
|
|
59
|
+
expect(await view.findByText("No records are waiting in the recovery queue.")).toBeTruthy();
|
|
60
|
+
expect(view.queryByRole("heading", { name: "Network Viewer" })).toBeNull();
|
|
61
|
+
view.unmount();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Props for {@link SyncRecoveryScreen}. */
|
|
2
|
+
interface ISyncRecoveryScreenProps {
|
|
3
|
+
/** Return to the Network Viewer overview. */
|
|
4
|
+
onBack: () => void;
|
|
5
|
+
}
|
|
6
|
+
/** Lists recovery-queue entries and the actions that resolve them. */
|
|
7
|
+
export declare function SyncRecoveryScreen({ onBack }: ISyncRecoveryScreenProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SyncRecoveryScreen = SyncRecoveryScreen;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const react_tiny_popover_1 = require("react-tiny-popover");
|
|
7
|
+
const loading_indicator_1 = require("../../components/loading-indicator");
|
|
8
|
+
const network_viewer_ipc_1 = require("./network-viewer-ipc");
|
|
9
|
+
function formatWhen(timestamp) {
|
|
10
|
+
return new Date(timestamp).toLocaleString();
|
|
11
|
+
}
|
|
12
|
+
function statusLabel(entry) {
|
|
13
|
+
if (entry.status === "retrying") {
|
|
14
|
+
return "Retrying";
|
|
15
|
+
}
|
|
16
|
+
if (entry.parkedReason === "schema") {
|
|
17
|
+
return "Parked, schema";
|
|
18
|
+
}
|
|
19
|
+
if (entry.parkedReason === "unique-conflict") {
|
|
20
|
+
return "Parked, conflict";
|
|
21
|
+
}
|
|
22
|
+
if (entry.parkedReason === "unresolvable") {
|
|
23
|
+
return "Parked, needs a decision";
|
|
24
|
+
}
|
|
25
|
+
return "Parked";
|
|
26
|
+
}
|
|
27
|
+
function statusExplanation(entry) {
|
|
28
|
+
if (entry.status === "retrying") {
|
|
29
|
+
return "This device is still asking others for a copy it can write. The wait starts at one minute and doubles after each try, up to one hour. It is marked as needing attention after 5 tries spanning at least 10 minutes.";
|
|
30
|
+
}
|
|
31
|
+
if (entry.parkedReason === "schema") {
|
|
32
|
+
return "The saved change does not match this device's rules for the table — a missing field, an empty id, or an id in the wrong format. Every device has the same change, so asking them again will not help. It is tried again from history once each time this device starts, when a newer change arrives, and when you press Retry. After an upgrade that accepts the row, the next startup or Retry can write it.";
|
|
33
|
+
}
|
|
34
|
+
if (entry.parkedReason === "unique-conflict") {
|
|
35
|
+
return "Two records want the same unique value, such as one variable name. This table has no automatic way to choose a winner, so nothing is deleted until you pick an action.";
|
|
36
|
+
}
|
|
37
|
+
if (entry.parkedReason === "unresolvable") {
|
|
38
|
+
return "An automatic fix could not be applied safely — the other record was missing, more than one row matched, or the write failed. The rows were left as they are. Choose an action when you know which version should remain.";
|
|
39
|
+
}
|
|
40
|
+
return "This entry is waiting and is not being sent to other devices. Retry tries it again. The other actions clear it.";
|
|
41
|
+
}
|
|
42
|
+
/** Show a validation payload as indented JSON when the error ends with one. */
|
|
43
|
+
function formatError(error) {
|
|
44
|
+
const marker = error.search(/[[{]/);
|
|
45
|
+
if (marker < 0) {
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
const head = error.slice(0, marker).trimEnd();
|
|
49
|
+
try {
|
|
50
|
+
return `${head}\n${JSON.stringify(JSON.parse(error.slice(marker)), null, 2)}`;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const detailsBlockStyle = {
|
|
57
|
+
maxHeight: "16.8em",
|
|
58
|
+
lineHeight: 1.4,
|
|
59
|
+
overflow: "auto",
|
|
60
|
+
whiteSpace: "pre-wrap",
|
|
61
|
+
wordBreak: "break-word",
|
|
62
|
+
};
|
|
63
|
+
function errorSummary(error) {
|
|
64
|
+
const line = error.split("\n")[0] ?? error;
|
|
65
|
+
return line.length > 80 ? `${line.slice(0, 77)}…` : line;
|
|
66
|
+
}
|
|
67
|
+
function InfoTip({ label, children }) {
|
|
68
|
+
const [open, setOpen] = (0, react_1.useState)(false);
|
|
69
|
+
return ((0, jsx_runtime_1.jsx)(react_tiny_popover_1.Popover, { isOpen: open, positions: ["bottom", "top", "left", "right"], align: "start", onClickOutside: () => setOpen(false), content: () => ((0, jsx_runtime_1.jsx)("div", { className: "card p-2 shadow small", style: { maxWidth: 420, zIndex: 1000001, textAlign: "left" }, children: children })), children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-link btn-sm p-0 ms-1 align-baseline text-info", "aria-label": label, onClick: () => setOpen((current) => !current), children: (0, jsx_runtime_1.jsx)("i", { className: "bi bi-info-circle" }) }) }));
|
|
70
|
+
}
|
|
71
|
+
function RecoveryRows({ entry, busy, onRun, }) {
|
|
72
|
+
const [recordOpen, setRecordOpen] = (0, react_1.useState)(false);
|
|
73
|
+
const [errorOpen, setErrorOpen] = (0, react_1.useState)(false);
|
|
74
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("td", { children: entry.tableName }), (0, jsx_runtime_1.jsxs)("td", { children: [(0, jsx_runtime_1.jsx)("code", { title: entry.recordId, children: entry.recordId }), entry.recordSummary && ((0, jsx_runtime_1.jsx)("div", { className: "small text-muted", style: { maxWidth: 220 }, children: entry.recordSummary })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-link btn-sm p-0", "aria-label": recordOpen ? "Hide record details" : "Show record details", onClick: () => setRecordOpen((current) => !current), children: recordOpen ? "Hide details" : "Details" })] }), (0, jsx_runtime_1.jsx)("td", { className: "text-nowrap small", children: formatWhen(entry.firstFailedAt) }), (0, jsx_runtime_1.jsx)("td", { children: entry.attemptCount }), (0, jsx_runtime_1.jsxs)("td", { className: "text-nowrap", children: [statusLabel(entry), (0, jsx_runtime_1.jsx)(InfoTip, { label: `About status ${statusLabel(entry)}`, children: statusExplanation(entry) })] }), (0, jsx_runtime_1.jsxs)("td", { children: [(0, jsx_runtime_1.jsx)("div", { className: "small text-muted", style: { maxWidth: 220 }, children: errorSummary(entry.latestError) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-link btn-sm p-0", "aria-label": errorOpen ? "Hide error details" : "Show error details", onClick: () => setErrorOpen((current) => !current), children: errorOpen ? "Hide details" : "Details" })] }), (0, jsx_runtime_1.jsx)("td", { children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex flex-column gap-1", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-primary", disabled: busy, onClick: () => onRun(entry, "retry"), children: "Retry" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-primary", disabled: busy || !entry.localRowExists, title: entry.localRowExists
|
|
75
|
+
? "Write this device's row as a snapshot every device will adopt"
|
|
76
|
+
: "This device has no row to keep", onClick: () => onRun(entry, "keepLocal"), children: "Keep this device's version" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-danger", disabled: busy, onClick: () => onRun(entry, "deleteEverywhere"), children: "Delete everywhere" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-secondary", disabled: busy, title: "Removes the entry on this device only. Other devices keep theirs.", onClick: () => onRun(entry, "ignoreLocal"), children: "Ignore on this device" })] }) })] }), (errorOpen || recordOpen) && ((0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsxs)("td", { colSpan: 7, className: "bg-body-tertiary", children: [errorOpen && ((0, jsx_runtime_1.jsx)("pre", { className: "small mb-0 font-monospace", style: detailsBlockStyle, children: formatError(entry.latestError) })), recordOpen && ((0, jsx_runtime_1.jsxs)("div", { className: errorOpen ? "mt-3" : undefined, children: [entry.changeCreatedAt != null && ((0, jsx_runtime_1.jsxs)("p", { className: "small mb-2", children: ["Change written ", formatWhen(entry.changeCreatedAt)] })), entry.conflictingRecordId && ((0, jsx_runtime_1.jsxs)("p", { className: "small mb-2", children: ["Another record holds the same unique value:", " ", (0, jsx_runtime_1.jsx)("code", { children: entry.conflictingRecordId })] })), entry.tombstoneRecordId &&
|
|
77
|
+
entry.tombstoneRecordId === entry.conflictingRecordId && ((0, jsx_runtime_1.jsxs)("p", { className: "small mb-2", children: ["Keep deletes ", (0, jsx_runtime_1.jsx)("code", { children: entry.tombstoneRecordId }), " on every device."] })), entry.tombstoneRecordId && entry.tombstoneRecordId === entry.recordId && ((0, jsx_runtime_1.jsx)("p", { className: "small mb-2", children: "Keep snapshots the other record and tombstones this queued id." })), entry.keptRecordPreview && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { className: "small mb-1", children: "This device's version" }), (0, jsx_runtime_1.jsx)("pre", { className: "small mb-2 font-monospace", style: detailsBlockStyle, children: entry.keptRecordPreview })] })), entry.queuedRecordPreview && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("p", { className: "small mb-1", children: "Queued change" }), (0, jsx_runtime_1.jsx)("pre", { className: "small mb-0 font-monospace", style: detailsBlockStyle, children: entry.queuedRecordPreview })] })), !entry.keptRecordPreview && !entry.queuedRecordPreview && entry.recordPreview && ((0, jsx_runtime_1.jsx)("pre", { className: "small mb-0 font-monospace", style: detailsBlockStyle, children: entry.recordPreview })), !entry.keptRecordPreview && !entry.queuedRecordPreview && !entry.recordPreview && ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-0", children: "This device has no stored copy of the record. The latest error may still quote the rejected value." }))] }))] }) }))] }));
|
|
78
|
+
}
|
|
79
|
+
/** Lists recovery-queue entries and the actions that resolve them. */
|
|
80
|
+
function SyncRecoveryScreen({ onBack }) {
|
|
81
|
+
const [entries, setEntries] = (0, react_1.useState)([]);
|
|
82
|
+
const [loading, setLoading] = (0, react_1.useState)(true);
|
|
83
|
+
const [busyKey, setBusyKey] = (0, react_1.useState)(null);
|
|
84
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
85
|
+
const loadGeneration = (0, react_1.useRef)(0);
|
|
86
|
+
const load = (0, react_1.useCallback)(async (preserveError = false) => {
|
|
87
|
+
const generation = ++loadGeneration.current;
|
|
88
|
+
try {
|
|
89
|
+
const diagnostics = await (0, network_viewer_ipc_1.getNetworkDiagnosticsConsumer)();
|
|
90
|
+
const result = await diagnostics.tools.getSyncRecoveryEntries();
|
|
91
|
+
if (generation !== loadGeneration.current) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setEntries(result.entries);
|
|
95
|
+
if (!preserveError) {
|
|
96
|
+
setError(null);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (loadError) {
|
|
100
|
+
if (generation !== loadGeneration.current) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (generation === loadGeneration.current) {
|
|
107
|
+
setLoading(false);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}, []);
|
|
111
|
+
(0, react_1.useEffect)(() => {
|
|
112
|
+
void load();
|
|
113
|
+
}, [load]);
|
|
114
|
+
(0, react_1.useEffect)(() => {
|
|
115
|
+
let active = true;
|
|
116
|
+
let subscription;
|
|
117
|
+
void (0, network_viewer_ipc_1.getNetworkDiagnosticsConsumer)().then((diagnostics) => {
|
|
118
|
+
if (!active) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
subscription = diagnostics.observables?.status.subscribe(() => {
|
|
122
|
+
void load();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return () => {
|
|
126
|
+
active = false;
|
|
127
|
+
subscription?.dispose();
|
|
128
|
+
};
|
|
129
|
+
}, [load]);
|
|
130
|
+
const run = async (entry, action) => {
|
|
131
|
+
if (action === "deleteEverywhere") {
|
|
132
|
+
const confirmed = window.confirm(`Delete ${entry.tableName}/${entry.recordId} on every device? This writes a synced delete.`);
|
|
133
|
+
if (!confirmed) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const key = `${entry.dataContextId}:${entry.syncRecoveryId}:${action}`;
|
|
138
|
+
setBusyKey(key);
|
|
139
|
+
setError(null);
|
|
140
|
+
try {
|
|
141
|
+
const control = await (0, network_viewer_ipc_1.getNetworkControlConsumer)();
|
|
142
|
+
const result = action === "retry"
|
|
143
|
+
? await control.tools.retrySyncRecovery({
|
|
144
|
+
dataContextId: entry.dataContextId,
|
|
145
|
+
tableName: entry.tableName,
|
|
146
|
+
recordId: entry.recordId,
|
|
147
|
+
syncRecoveryId: entry.syncRecoveryId,
|
|
148
|
+
})
|
|
149
|
+
: await control.tools.resolveSyncRecovery({
|
|
150
|
+
dataContextId: entry.dataContextId,
|
|
151
|
+
tableName: entry.tableName,
|
|
152
|
+
recordId: entry.recordId,
|
|
153
|
+
resolution: action,
|
|
154
|
+
syncRecoveryId: entry.syncRecoveryId,
|
|
155
|
+
});
|
|
156
|
+
if (!result.success) {
|
|
157
|
+
setError(result.message);
|
|
158
|
+
await load(true);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
setError(null);
|
|
162
|
+
await load();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (actionError) {
|
|
166
|
+
setError(actionError instanceof Error ? actionError.message : String(actionError));
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
setBusyKey(null);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
if (loading) {
|
|
173
|
+
return (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {});
|
|
174
|
+
}
|
|
175
|
+
const groups = new Map();
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const group = groups.get(entry.dataContextId) ?? [];
|
|
178
|
+
group.push(entry);
|
|
179
|
+
groups.set(entry.dataContextId, group);
|
|
180
|
+
}
|
|
181
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center mb-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-3", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-secondary", onClick: onBack, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-arrow-left me-1" }), "Back"] }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h2", { className: "mb-1", children: "Sync recovery" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted small mb-0", children: "These records have change history, but this device could not write the row. Parked entries are not asked of other devices until you retry them." })] })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-primary", onClick: () => void load(), children: "Refresh" })] }), error && (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger py-2 small", children: error }), entries.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-success", children: "No records are waiting in the recovery queue." })) : ([...groups.entries()].map(([dataContextId, groupEntries]) => ((0, jsx_runtime_1.jsxs)("div", { className: "card mb-3", children: [(0, jsx_runtime_1.jsx)("div", { className: "card-header", children: groupEntries[0]?.dataContextName || dataContextId }), (0, jsx_runtime_1.jsx)("div", { className: "table-responsive", children: (0, jsx_runtime_1.jsxs)("table", { className: "table table-sm align-middle mb-0", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("th", { children: "Table" }), (0, jsx_runtime_1.jsx)("th", { children: "Record" }), (0, jsx_runtime_1.jsx)("th", { children: "First failed" }), (0, jsx_runtime_1.jsx)("th", { children: "Attempts" }), (0, jsx_runtime_1.jsx)("th", { children: "Status" }), (0, jsx_runtime_1.jsx)("th", { children: "Latest error" }), (0, jsx_runtime_1.jsxs)("th", { children: ["Actions", (0, jsx_runtime_1.jsx)(InfoTip, { label: "About these actions", children: (0, jsx_runtime_1.jsxs)("ul", { className: "mb-0 ps-3", children: [(0, jsx_runtime_1.jsxs)("li", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Retry" }), " writes the record again from this device's history, then asks another device once. If a schema or unique-key entry is still queued after that, it is parked again. Use it after an upgrade that might accept a row this device rejected before."] }), (0, jsx_runtime_1.jsxs)("li", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Keep this device's version" }), " snapshots the row this device will keep. That is the queued row when it is already stored here, and otherwise the other row that holds the same unique value. When both rows are stored, the other record is deleted everywhere. When only the other row is stored, that row is kept and this queued id is tombstoned. Disabled when this device has no row to keep."] }), (0, jsx_runtime_1.jsxs)("li", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Delete everywhere" }), " deletes this record on every device, including devices that already have it."] }), (0, jsx_runtime_1.jsxs)("li", { children: [(0, jsx_runtime_1.jsx)("strong", { children: "Ignore on this device" }), " removes the entry here only. Other devices keep their own copy of the problem."] })] }) })] })] }) }), (0, jsx_runtime_1.jsx)("tbody", { children: groupEntries.map((entry) => ((0, jsx_runtime_1.jsx)(RecoveryRows, { entry: entry, busy: Boolean(busyKey?.startsWith(`${entry.dataContextId}:${entry.syncRecoveryId}:`)), onRun: (item, action) => void run(item, action) }, entry.syncRecoveryId))) })] }) })] }, dataContextId))))] }));
|
|
182
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_1 = require("@testing-library/react");
|
|
5
|
+
const sync_recovery_1 = require("./sync-recovery");
|
|
6
|
+
const mockResolveSyncRecovery = jest.fn(async () => ({
|
|
7
|
+
success: false,
|
|
8
|
+
message: "Permission denied for Shared Group: requires Owner",
|
|
9
|
+
}));
|
|
10
|
+
jest.mock("./network-viewer-ipc", () => ({
|
|
11
|
+
getNetworkDiagnosticsConsumer: async () => ({
|
|
12
|
+
tools: {
|
|
13
|
+
getSyncRecoveryEntries: async () => ({
|
|
14
|
+
entries: [
|
|
15
|
+
{
|
|
16
|
+
dataContextId: "personal",
|
|
17
|
+
dataContextName: "Personal",
|
|
18
|
+
syncRecoveryId: "queue-1",
|
|
19
|
+
tableName: "Messages",
|
|
20
|
+
recordId: "00mflddy2sbk393s47smn4tgl",
|
|
21
|
+
firstFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
22
|
+
lastFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
23
|
+
attemptCount: 3,
|
|
24
|
+
latestError: '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
|
+
describe("SyncRecoveryScreen", () => {
|
|
71
|
+
it("shows the full error, status help, action help, and stored record", async () => {
|
|
72
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(sync_recovery_1.SyncRecoveryScreen, { onBack: () => undefined }));
|
|
73
|
+
expect(await react_1.screen.findByText("hello from the stored message")).toBeTruthy();
|
|
74
|
+
expect(react_1.screen.getByText(/Validation on insert failed/)).toBeTruthy();
|
|
75
|
+
expect(react_1.screen.queryByText(/assistant id was empty/)).toBeNull();
|
|
76
|
+
react_1.fireEvent.click(react_1.screen.getAllByRole("button", { name: "Show error details" })[0]);
|
|
77
|
+
const error = react_1.screen.getByText(/assistant id was empty/);
|
|
78
|
+
expect(error.tagName).toBe("PRE");
|
|
79
|
+
react_1.fireEvent.click(react_1.screen.getByRole("button", { name: "About status Parked, schema" }));
|
|
80
|
+
expect(react_1.screen.getByText(/does not match this device's rules/)).toBeTruthy();
|
|
81
|
+
react_1.fireEvent.click(react_1.screen.getByRole("button", { name: "About these actions" }));
|
|
82
|
+
expect(react_1.screen.getByText(/only the other row is stored/)).toBeTruthy();
|
|
83
|
+
react_1.fireEvent.click(react_1.screen.getAllByRole("button", { name: "Show record details" })[0]);
|
|
84
|
+
expect(react_1.screen.getByText(/Change written/)).toBeTruthy();
|
|
85
|
+
expect(react_1.screen.getByText("Queued change")).toBeTruthy();
|
|
86
|
+
expect(react_1.screen.getByText(/"message": "hello from the stored message"/)).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
it("labels the row Keep will snapshot separately from a different queued change", async () => {
|
|
89
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(sync_recovery_1.SyncRecoveryScreen, { onBack: () => undefined }));
|
|
90
|
+
react_1.fireEvent.click((await react_1.screen.findAllByRole("button", { name: "Show record details" }))[1]);
|
|
91
|
+
expect(react_1.screen.getByText("This device's version")).toBeTruthy();
|
|
92
|
+
expect(react_1.screen.getByText(/"value": "local"/)).toBeTruthy();
|
|
93
|
+
expect(react_1.screen.getByText("Queued change")).toBeTruthy();
|
|
94
|
+
expect(react_1.screen.getByText(/"value": "rejected"/)).toBeTruthy();
|
|
95
|
+
expect(react_1.screen.getByText(/tombstones this queued id/)).toBeTruthy();
|
|
96
|
+
expect(react_1.screen.queryByText(/Keep deletes/)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
it("keeps a failed action message after the list reloads", async () => {
|
|
99
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(sync_recovery_1.SyncRecoveryScreen, { onBack: () => undefined }));
|
|
100
|
+
react_1.fireEvent.click((await react_1.screen.findAllByRole("button", { name: "Ignore on this device" }))[0]);
|
|
101
|
+
expect(await react_1.screen.findByText("Permission denied for Shared Group: requires Owner")).toBeTruthy();
|
|
102
|
+
expect(react_1.screen.getByText("hello from the stored message")).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -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
|
}
|