@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,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
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Props for {@link LocalFolderGrantsPanel}. */
|
|
2
|
+
export interface ILocalFolderGrantsPanelProps {
|
|
3
|
+
/**
|
|
4
|
+
* When set, the list and new grants are limited to this package.
|
|
5
|
+
* Omit it for the device-wide operator list.
|
|
6
|
+
*/
|
|
7
|
+
packageId?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Folder grants on this device, with add and revoke.
|
|
11
|
+
*
|
|
12
|
+
* Add opens the host folder picker. A host that does not provide System Local Folders
|
|
13
|
+
* (the PWA) shows the contract as unavailable.
|
|
14
|
+
*/
|
|
15
|
+
export declare function LocalFolderGrantsPanel(props: ILocalFolderGrantsPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalFolderGrantsPanel = LocalFolderGrantsPanel;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const globals_1 = require("../../globals");
|
|
7
|
+
const hooks_1 = require("../../hooks");
|
|
8
|
+
const use_host_contract_1 = require("../../hooks/use-host-contract");
|
|
9
|
+
const local_folders_ipc_1 = require("./local-folders-ipc");
|
|
10
|
+
/**
|
|
11
|
+
* Folder grants on this device, with add and revoke.
|
|
12
|
+
*
|
|
13
|
+
* Add opens the host folder picker. A host that does not provide System Local Folders
|
|
14
|
+
* (the PWA) shows the contract as unavailable.
|
|
15
|
+
*/
|
|
16
|
+
function LocalFolderGrantsPanel(props) {
|
|
17
|
+
const { consumer, error: transportError } = (0, use_host_contract_1.useHostContract)(local_folders_ipc_1.localFoldersConsumer);
|
|
18
|
+
const [groupList] = (0, hooks_1.useObservable)(globals_1.groups);
|
|
19
|
+
const [grants, setGrants] = (0, react_1.useState)();
|
|
20
|
+
const [loadError, setLoadError] = (0, react_1.useState)();
|
|
21
|
+
const [actionError, setActionError] = (0, react_1.useState)();
|
|
22
|
+
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
23
|
+
const [groupId, setGroupId] = (0, react_1.useState)("");
|
|
24
|
+
const [mode, setMode] = (0, react_1.useState)("readwrite");
|
|
25
|
+
const [refresh, setRefresh] = (0, react_1.useState)(0);
|
|
26
|
+
(0, react_1.useEffect)(() => {
|
|
27
|
+
if (!consumer) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
consumer.tools.listGrants().then((result) => {
|
|
32
|
+
if (!cancelled) {
|
|
33
|
+
setGrants(result.grants);
|
|
34
|
+
setLoadError(undefined);
|
|
35
|
+
}
|
|
36
|
+
}, (cause) => {
|
|
37
|
+
if (!cancelled) {
|
|
38
|
+
setLoadError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
};
|
|
44
|
+
}, [consumer, refresh]);
|
|
45
|
+
const unavailable = isUnavailable(transportError) || isUnavailable(loadError);
|
|
46
|
+
const visible = (grants ?? []).filter((grant) => props.packageId ? grant.packageId === props.packageId : true);
|
|
47
|
+
const names = new Map(groupList.map((group) => [group.groupId, group.name || group.groupId]));
|
|
48
|
+
async function addFolder() {
|
|
49
|
+
if (!consumer || !groupId) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setBusy(true);
|
|
53
|
+
setActionError(undefined);
|
|
54
|
+
try {
|
|
55
|
+
await consumer.tools.requestGrant({
|
|
56
|
+
groupId,
|
|
57
|
+
mode,
|
|
58
|
+
...(props.packageId ? { packageId: props.packageId } : {}),
|
|
59
|
+
});
|
|
60
|
+
setRefresh((value) => value + 1);
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
64
|
+
if (!/cancelled/i.test(message)) {
|
|
65
|
+
setActionError(message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
setBusy(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function revoke(grantId) {
|
|
73
|
+
if (!consumer) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!window.confirm("Revoke this folder grant?")) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setBusy(true);
|
|
80
|
+
setActionError(undefined);
|
|
81
|
+
try {
|
|
82
|
+
await consumer.tools.revokeGrant({ grantId });
|
|
83
|
+
setRefresh((value) => value + 1);
|
|
84
|
+
}
|
|
85
|
+
catch (cause) {
|
|
86
|
+
setActionError(cause instanceof Error ? cause.message : String(cause));
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
setBusy(false);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return ((0, jsx_runtime_1.jsxs)("section", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h5", { children: props.packageId ? "Folders" : "Local folders" }), (0, jsx_runtime_1.jsx)(FolderStatus, { unavailable: unavailable, transportError: transportError, loadError: loadError, loading: !!consumer && grants === undefined && !loadError }), consumer && !unavailable && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(GrantTable, { grants: visible, names: names, busy: busy, onRevoke: revoke }), (0, jsx_runtime_1.jsx)(AddFolderForm, { fieldScope: props.packageId ?? "device", groupId: groupId, groups: groupList.map((group) => ({
|
|
93
|
+
groupId: group.groupId,
|
|
94
|
+
name: group.name || group.groupId,
|
|
95
|
+
})), mode: mode, busy: busy, onGroupId: setGroupId, onMode: setMode, onAdd: addFolder }), actionError && (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger mt-2 mb-0", children: actionError })] }))] }));
|
|
96
|
+
}
|
|
97
|
+
function FolderStatus(props) {
|
|
98
|
+
if (props.unavailable) {
|
|
99
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-secondary mb-0", children: "System Local Folders is not available on this host." }));
|
|
100
|
+
}
|
|
101
|
+
const error = props.loadError ?? props.transportError;
|
|
102
|
+
if (error) {
|
|
103
|
+
return (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger mb-0", children: error.message });
|
|
104
|
+
}
|
|
105
|
+
if (props.loading) {
|
|
106
|
+
return (0, jsx_runtime_1.jsx)("div", { className: "text-muted", children: "Loading folder grants\u2026" });
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
function GrantTable(props) {
|
|
111
|
+
if (props.grants.length === 0) {
|
|
112
|
+
return (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "No folder grants." });
|
|
113
|
+
}
|
|
114
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "table-responsive", children: (0, jsx_runtime_1.jsxs)("table", { className: "table table-sm align-middle", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("th", { children: "Path" }), (0, jsx_runtime_1.jsx)("th", { children: "Group" }), (0, jsx_runtime_1.jsx)("th", { children: "Package" }), (0, jsx_runtime_1.jsx)("th", { children: "Mode" }), (0, jsx_runtime_1.jsx)("th", { children: "Created" }), (0, jsx_runtime_1.jsx)("th", {})] }) }), (0, jsx_runtime_1.jsx)("tbody", { children: props.grants.map((grant) => ((0, jsx_runtime_1.jsx)(GrantRow, { grant: grant, groupName: props.names.get(grant.groupId) ?? grant.groupId, busy: props.busy, onRevoke: props.onRevoke }, grant.grantId))) })] }) }));
|
|
115
|
+
}
|
|
116
|
+
function GrantRow(props) {
|
|
117
|
+
const { grant } = props;
|
|
118
|
+
return ((0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("td", { className: "text-break", children: grant.absolutePath }), (0, jsx_runtime_1.jsx)("td", { children: props.groupName }), (0, jsx_runtime_1.jsx)("td", { children: grant.packageId ?? "operator" }), (0, jsx_runtime_1.jsx)("td", { children: grant.mode }), (0, jsx_runtime_1.jsx)("td", { children: new Date(grant.createdAt).toLocaleString() }), (0, jsx_runtime_1.jsx)("td", { children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-danger", disabled: props.busy, onClick: () => props.onRevoke(grant.grantId), children: "Revoke" }) })] }));
|
|
119
|
+
}
|
|
120
|
+
function AddFolderForm(props) {
|
|
121
|
+
return ((0, jsx_runtime_1.jsxs)("form", { className: "row g-2 align-items-end", onSubmit: (event) => {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
props.onAdd();
|
|
124
|
+
}, children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-5", children: [(0, jsx_runtime_1.jsx)("label", { className: "form-label", htmlFor: `local-folder-group-${props.fieldScope}`, children: "Group" }), (0, jsx_runtime_1.jsxs)("select", { id: `local-folder-group-${props.fieldScope}`, className: "form-select", value: props.groupId, onChange: (event) => props.onGroupId(event.target.value), children: [(0, jsx_runtime_1.jsx)("option", { value: "", children: "Select a group" }), props.groups.map((group) => ((0, jsx_runtime_1.jsx)("option", { value: group.groupId, children: group.name }, group.groupId)))] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-3", children: [(0, jsx_runtime_1.jsx)("label", { className: "form-label", htmlFor: `local-folder-mode-${props.fieldScope}`, children: "Mode" }), (0, jsx_runtime_1.jsxs)("select", { id: `local-folder-mode-${props.fieldScope}`, className: "form-select", value: props.mode, onChange: (event) => props.onMode(event.target.value), children: [(0, jsx_runtime_1.jsx)("option", { value: "readwrite", children: "readwrite" }), (0, jsx_runtime_1.jsx)("option", { value: "read", children: "read" })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "col-md-4", children: (0, jsx_runtime_1.jsx)("button", { type: "submit", className: "btn btn-primary", disabled: props.busy || !props.groupId, children: props.busy ? "Working…" : "Add folder" }) })] }));
|
|
125
|
+
}
|
|
126
|
+
function isUnavailable(error) {
|
|
127
|
+
if (!error) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return /No contract provider registered|not available locally/i.test(error.message);
|
|
131
|
+
}
|