@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,114 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { SyncRecoveryScreen } from "./sync-recovery";
|
|
3
|
+
|
|
4
|
+
const mockResolveSyncRecovery = jest.fn(async () => ({
|
|
5
|
+
success: false,
|
|
6
|
+
message: "Permission denied for Shared Group: requires Owner",
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock("./network-viewer-ipc", () => ({
|
|
10
|
+
getNetworkDiagnosticsConsumer: async () => ({
|
|
11
|
+
tools: {
|
|
12
|
+
getSyncRecoveryEntries: async () => ({
|
|
13
|
+
entries: [
|
|
14
|
+
{
|
|
15
|
+
dataContextId: "personal",
|
|
16
|
+
dataContextName: "Personal",
|
|
17
|
+
syncRecoveryId: "queue-1",
|
|
18
|
+
tableName: "Messages",
|
|
19
|
+
recordId: "00mflddy2sbk393s47smn4tgl",
|
|
20
|
+
firstFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
21
|
+
lastFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
22
|
+
attemptCount: 3,
|
|
23
|
+
latestError:
|
|
24
|
+
'Validation on insert failed for Messages: [{"code":"custom","message":"assistant id was empty","path":["assistantId"]}]',
|
|
25
|
+
status: "parked",
|
|
26
|
+
parkedReason: "schema",
|
|
27
|
+
stuck: true,
|
|
28
|
+
localRowExists: false,
|
|
29
|
+
recordSummary: "hello from the stored message",
|
|
30
|
+
queuedRecordPreview: '{\n "message": "hello from the stored message"\n}',
|
|
31
|
+
recordPreview: '{\n "message": "hello from the stored message"\n}',
|
|
32
|
+
changeCreatedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
dataContextId: "personal",
|
|
36
|
+
dataContextName: "Personal",
|
|
37
|
+
syncRecoveryId: "queue-2",
|
|
38
|
+
tableName: "UniqueNames",
|
|
39
|
+
recordId: "00mflddy2sbk393s47smn4tgm",
|
|
40
|
+
firstFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
41
|
+
lastFailedAt: Date.parse("2026-09-10T17:16:02Z"),
|
|
42
|
+
attemptCount: 1,
|
|
43
|
+
latestError: "UNIQUE constraint failed: UniqueNames.name",
|
|
44
|
+
status: "parked",
|
|
45
|
+
parkedReason: "unique-conflict",
|
|
46
|
+
stuck: true,
|
|
47
|
+
localRowExists: true,
|
|
48
|
+
conflictingRecordId: "00mflddy2sbk393s47smn4tgn",
|
|
49
|
+
tombstoneRecordId: "00mflddy2sbk393s47smn4tgm",
|
|
50
|
+
recordSummary: "shared",
|
|
51
|
+
keptRecordPreview: '{\n "name": "shared",\n "value": "local"\n}',
|
|
52
|
+
queuedRecordPreview: '{\n "name": "shared",\n "value": "rejected"\n}',
|
|
53
|
+
recordPreview: '{\n "name": "shared",\n "value": "local"\n}',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
observables: {
|
|
59
|
+
status: {
|
|
60
|
+
subscribe: () => ({ dispose() {} }),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
getNetworkControlConsumer: async () => ({
|
|
65
|
+
tools: {
|
|
66
|
+
resolveSyncRecovery: () => mockResolveSyncRecovery(),
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
describe("SyncRecoveryScreen", () => {
|
|
72
|
+
it("shows the full error, status help, action help, and stored record", async () => {
|
|
73
|
+
render(<SyncRecoveryScreen onBack={() => undefined} />);
|
|
74
|
+
|
|
75
|
+
expect(await screen.findByText("hello from the stored message")).toBeTruthy();
|
|
76
|
+
expect(screen.getByText(/Validation on insert failed/)).toBeTruthy();
|
|
77
|
+
expect(screen.queryByText(/assistant id was empty/)).toBeNull();
|
|
78
|
+
|
|
79
|
+
fireEvent.click(screen.getAllByRole("button", { name: "Show error details" })[0]);
|
|
80
|
+
const error = screen.getByText(/assistant id was empty/);
|
|
81
|
+
expect(error.tagName).toBe("PRE");
|
|
82
|
+
|
|
83
|
+
fireEvent.click(screen.getByRole("button", { name: "About status Parked, schema" }));
|
|
84
|
+
expect(screen.getByText(/does not match this device's rules/)).toBeTruthy();
|
|
85
|
+
|
|
86
|
+
fireEvent.click(screen.getByRole("button", { name: "About these actions" }));
|
|
87
|
+
expect(screen.getByText(/only the other row is stored/)).toBeTruthy();
|
|
88
|
+
|
|
89
|
+
fireEvent.click(screen.getAllByRole("button", { name: "Show record details" })[0]);
|
|
90
|
+
expect(screen.getByText(/Change written/)).toBeTruthy();
|
|
91
|
+
expect(screen.getByText("Queued change")).toBeTruthy();
|
|
92
|
+
expect(screen.getByText(/"message": "hello from the stored message"/)).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("labels the row Keep will snapshot separately from a different queued change", async () => {
|
|
96
|
+
render(<SyncRecoveryScreen onBack={() => undefined} />);
|
|
97
|
+
fireEvent.click((await screen.findAllByRole("button", { name: "Show record details" }))[1]);
|
|
98
|
+
expect(screen.getByText("This device's version")).toBeTruthy();
|
|
99
|
+
expect(screen.getByText(/"value": "local"/)).toBeTruthy();
|
|
100
|
+
expect(screen.getByText("Queued change")).toBeTruthy();
|
|
101
|
+
expect(screen.getByText(/"value": "rejected"/)).toBeTruthy();
|
|
102
|
+
expect(screen.getByText(/tombstones this queued id/)).toBeTruthy();
|
|
103
|
+
expect(screen.queryByText(/Keep deletes/)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps a failed action message after the list reloads", async () => {
|
|
107
|
+
render(<SyncRecoveryScreen onBack={() => undefined} />);
|
|
108
|
+
fireEvent.click((await screen.findAllByRole("button", { name: "Ignore on this device" }))[0]);
|
|
109
|
+
expect(
|
|
110
|
+
await screen.findByText("Permission denied for Shared Group: requires Owner"),
|
|
111
|
+
).toBeTruthy();
|
|
112
|
+
expect(screen.getByText("hello from the stored message")).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import type { INetworkSyncRecoveryEntry, SyncRecoveryResolution } from "@peers-app/peers-sdk";
|
|
2
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Popover } from "react-tiny-popover";
|
|
4
|
+
import { LoadingIndicator } from "../../components/loading-indicator";
|
|
5
|
+
import { getNetworkControlConsumer, getNetworkDiagnosticsConsumer } from "./network-viewer-ipc";
|
|
6
|
+
|
|
7
|
+
/** Props for {@link SyncRecoveryScreen}. */
|
|
8
|
+
interface ISyncRecoveryScreenProps {
|
|
9
|
+
/** Return to the Network Viewer overview. */
|
|
10
|
+
onBack: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatWhen(timestamp: number): string {
|
|
14
|
+
return new Date(timestamp).toLocaleString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function statusLabel(entry: INetworkSyncRecoveryEntry): string {
|
|
18
|
+
if (entry.status === "retrying") {
|
|
19
|
+
return "Retrying";
|
|
20
|
+
}
|
|
21
|
+
if (entry.parkedReason === "schema") {
|
|
22
|
+
return "Parked, schema";
|
|
23
|
+
}
|
|
24
|
+
if (entry.parkedReason === "unique-conflict") {
|
|
25
|
+
return "Parked, conflict";
|
|
26
|
+
}
|
|
27
|
+
if (entry.parkedReason === "unresolvable") {
|
|
28
|
+
return "Parked, needs a decision";
|
|
29
|
+
}
|
|
30
|
+
return "Parked";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function statusExplanation(entry: INetworkSyncRecoveryEntry): string {
|
|
34
|
+
if (entry.status === "retrying") {
|
|
35
|
+
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.";
|
|
36
|
+
}
|
|
37
|
+
if (entry.parkedReason === "schema") {
|
|
38
|
+
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.";
|
|
39
|
+
}
|
|
40
|
+
if (entry.parkedReason === "unique-conflict") {
|
|
41
|
+
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.";
|
|
42
|
+
}
|
|
43
|
+
if (entry.parkedReason === "unresolvable") {
|
|
44
|
+
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.";
|
|
45
|
+
}
|
|
46
|
+
return "This entry is waiting and is not being sent to other devices. Retry tries it again. The other actions clear it.";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Show a validation payload as indented JSON when the error ends with one. */
|
|
50
|
+
function formatError(error: string): string {
|
|
51
|
+
const marker = error.search(/[[{]/);
|
|
52
|
+
if (marker < 0) {
|
|
53
|
+
return error;
|
|
54
|
+
}
|
|
55
|
+
const head = error.slice(0, marker).trimEnd();
|
|
56
|
+
try {
|
|
57
|
+
return `${head}\n${JSON.stringify(JSON.parse(error.slice(marker)), null, 2)}`;
|
|
58
|
+
} catch {
|
|
59
|
+
return error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const detailsBlockStyle = {
|
|
64
|
+
maxHeight: "16.8em",
|
|
65
|
+
lineHeight: 1.4,
|
|
66
|
+
overflow: "auto",
|
|
67
|
+
whiteSpace: "pre-wrap",
|
|
68
|
+
wordBreak: "break-word",
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
function errorSummary(error: string): string {
|
|
72
|
+
const line = error.split("\n")[0] ?? error;
|
|
73
|
+
return line.length > 80 ? `${line.slice(0, 77)}…` : line;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function InfoTip({ label, children }: { label: string; children: ReactNode }) {
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
return (
|
|
79
|
+
<Popover
|
|
80
|
+
isOpen={open}
|
|
81
|
+
positions={["bottom", "top", "left", "right"]}
|
|
82
|
+
align="start"
|
|
83
|
+
onClickOutside={() => setOpen(false)}
|
|
84
|
+
content={() => (
|
|
85
|
+
<div
|
|
86
|
+
className="card p-2 shadow small"
|
|
87
|
+
style={{ maxWidth: 420, zIndex: 1000001, textAlign: "left" }}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-info"
|
|
96
|
+
aria-label={label}
|
|
97
|
+
onClick={() => setOpen((current) => !current)}
|
|
98
|
+
>
|
|
99
|
+
<i className="bi bi-info-circle" />
|
|
100
|
+
</button>
|
|
101
|
+
</Popover>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function RecoveryRows({
|
|
106
|
+
entry,
|
|
107
|
+
busy,
|
|
108
|
+
onRun,
|
|
109
|
+
}: {
|
|
110
|
+
entry: INetworkSyncRecoveryEntry;
|
|
111
|
+
busy: boolean;
|
|
112
|
+
onRun: (entry: INetworkSyncRecoveryEntry, action: "retry" | SyncRecoveryResolution) => void;
|
|
113
|
+
}) {
|
|
114
|
+
const [recordOpen, setRecordOpen] = useState(false);
|
|
115
|
+
const [errorOpen, setErrorOpen] = useState(false);
|
|
116
|
+
return (
|
|
117
|
+
<>
|
|
118
|
+
<tr>
|
|
119
|
+
<td>{entry.tableName}</td>
|
|
120
|
+
<td>
|
|
121
|
+
<code title={entry.recordId}>{entry.recordId}</code>
|
|
122
|
+
{entry.recordSummary && (
|
|
123
|
+
<div className="small text-muted" style={{ maxWidth: 220 }}>
|
|
124
|
+
{entry.recordSummary}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="btn btn-link btn-sm p-0"
|
|
130
|
+
aria-label={recordOpen ? "Hide record details" : "Show record details"}
|
|
131
|
+
onClick={() => setRecordOpen((current) => !current)}
|
|
132
|
+
>
|
|
133
|
+
{recordOpen ? "Hide details" : "Details"}
|
|
134
|
+
</button>
|
|
135
|
+
</td>
|
|
136
|
+
<td className="text-nowrap small">{formatWhen(entry.firstFailedAt)}</td>
|
|
137
|
+
<td>{entry.attemptCount}</td>
|
|
138
|
+
<td className="text-nowrap">
|
|
139
|
+
{statusLabel(entry)}
|
|
140
|
+
<InfoTip label={`About status ${statusLabel(entry)}`}>{statusExplanation(entry)}</InfoTip>
|
|
141
|
+
</td>
|
|
142
|
+
<td>
|
|
143
|
+
<div className="small text-muted" style={{ maxWidth: 220 }}>
|
|
144
|
+
{errorSummary(entry.latestError)}
|
|
145
|
+
</div>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
className="btn btn-link btn-sm p-0"
|
|
149
|
+
aria-label={errorOpen ? "Hide error details" : "Show error details"}
|
|
150
|
+
onClick={() => setErrorOpen((current) => !current)}
|
|
151
|
+
>
|
|
152
|
+
{errorOpen ? "Hide details" : "Details"}
|
|
153
|
+
</button>
|
|
154
|
+
</td>
|
|
155
|
+
<td>
|
|
156
|
+
<div className="d-flex flex-column gap-1">
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
className="btn btn-sm btn-outline-primary"
|
|
160
|
+
disabled={busy}
|
|
161
|
+
onClick={() => onRun(entry, "retry")}
|
|
162
|
+
>
|
|
163
|
+
Retry
|
|
164
|
+
</button>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="btn btn-sm btn-outline-primary"
|
|
168
|
+
disabled={busy || !entry.localRowExists}
|
|
169
|
+
title={
|
|
170
|
+
entry.localRowExists
|
|
171
|
+
? "Write this device's row as a snapshot every device will adopt"
|
|
172
|
+
: "This device has no row to keep"
|
|
173
|
+
}
|
|
174
|
+
onClick={() => onRun(entry, "keepLocal")}
|
|
175
|
+
>
|
|
176
|
+
Keep this device's version
|
|
177
|
+
</button>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
className="btn btn-sm btn-outline-danger"
|
|
181
|
+
disabled={busy}
|
|
182
|
+
onClick={() => onRun(entry, "deleteEverywhere")}
|
|
183
|
+
>
|
|
184
|
+
Delete everywhere
|
|
185
|
+
</button>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className="btn btn-sm btn-outline-secondary"
|
|
189
|
+
disabled={busy}
|
|
190
|
+
title="Removes the entry on this device only. Other devices keep theirs."
|
|
191
|
+
onClick={() => onRun(entry, "ignoreLocal")}
|
|
192
|
+
>
|
|
193
|
+
Ignore on this device
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
</td>
|
|
197
|
+
</tr>
|
|
198
|
+
{(errorOpen || recordOpen) && (
|
|
199
|
+
<tr>
|
|
200
|
+
<td colSpan={7} className="bg-body-tertiary">
|
|
201
|
+
{errorOpen && (
|
|
202
|
+
<pre className="small mb-0 font-monospace" style={detailsBlockStyle}>
|
|
203
|
+
{formatError(entry.latestError)}
|
|
204
|
+
</pre>
|
|
205
|
+
)}
|
|
206
|
+
{recordOpen && (
|
|
207
|
+
<div className={errorOpen ? "mt-3" : undefined}>
|
|
208
|
+
{entry.changeCreatedAt != null && (
|
|
209
|
+
<p className="small mb-2">Change written {formatWhen(entry.changeCreatedAt)}</p>
|
|
210
|
+
)}
|
|
211
|
+
{entry.conflictingRecordId && (
|
|
212
|
+
<p className="small mb-2">
|
|
213
|
+
Another record holds the same unique value:{" "}
|
|
214
|
+
<code>{entry.conflictingRecordId}</code>
|
|
215
|
+
</p>
|
|
216
|
+
)}
|
|
217
|
+
{entry.tombstoneRecordId &&
|
|
218
|
+
entry.tombstoneRecordId === entry.conflictingRecordId && (
|
|
219
|
+
<p className="small mb-2">
|
|
220
|
+
Keep deletes <code>{entry.tombstoneRecordId}</code> on every device.
|
|
221
|
+
</p>
|
|
222
|
+
)}
|
|
223
|
+
{entry.tombstoneRecordId && entry.tombstoneRecordId === entry.recordId && (
|
|
224
|
+
<p className="small mb-2">
|
|
225
|
+
Keep snapshots the other record and tombstones this queued id.
|
|
226
|
+
</p>
|
|
227
|
+
)}
|
|
228
|
+
{entry.keptRecordPreview && (
|
|
229
|
+
<>
|
|
230
|
+
<p className="small mb-1">This device's version</p>
|
|
231
|
+
<pre className="small mb-2 font-monospace" style={detailsBlockStyle}>
|
|
232
|
+
{entry.keptRecordPreview}
|
|
233
|
+
</pre>
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
{entry.queuedRecordPreview && (
|
|
237
|
+
<>
|
|
238
|
+
<p className="small mb-1">Queued change</p>
|
|
239
|
+
<pre className="small mb-0 font-monospace" style={detailsBlockStyle}>
|
|
240
|
+
{entry.queuedRecordPreview}
|
|
241
|
+
</pre>
|
|
242
|
+
</>
|
|
243
|
+
)}
|
|
244
|
+
{!entry.keptRecordPreview && !entry.queuedRecordPreview && entry.recordPreview && (
|
|
245
|
+
<pre className="small mb-0 font-monospace" style={detailsBlockStyle}>
|
|
246
|
+
{entry.recordPreview}
|
|
247
|
+
</pre>
|
|
248
|
+
)}
|
|
249
|
+
{!entry.keptRecordPreview && !entry.queuedRecordPreview && !entry.recordPreview && (
|
|
250
|
+
<p className="small text-muted mb-0">
|
|
251
|
+
This device has no stored copy of the record. The latest error may still quote
|
|
252
|
+
the rejected value.
|
|
253
|
+
</p>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</td>
|
|
258
|
+
</tr>
|
|
259
|
+
)}
|
|
260
|
+
</>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Lists recovery-queue entries and the actions that resolve them. */
|
|
265
|
+
export function SyncRecoveryScreen({ onBack }: ISyncRecoveryScreenProps) {
|
|
266
|
+
const [entries, setEntries] = useState<INetworkSyncRecoveryEntry[]>([]);
|
|
267
|
+
const [loading, setLoading] = useState(true);
|
|
268
|
+
const [busyKey, setBusyKey] = useState<string | null>(null);
|
|
269
|
+
const [error, setError] = useState<string | null>(null);
|
|
270
|
+
const loadGeneration = useRef(0);
|
|
271
|
+
|
|
272
|
+
const load = useCallback(async (preserveError = false) => {
|
|
273
|
+
const generation = ++loadGeneration.current;
|
|
274
|
+
try {
|
|
275
|
+
const diagnostics = await getNetworkDiagnosticsConsumer();
|
|
276
|
+
const result = await diagnostics.tools.getSyncRecoveryEntries();
|
|
277
|
+
if (generation !== loadGeneration.current) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
setEntries(result.entries);
|
|
281
|
+
if (!preserveError) {
|
|
282
|
+
setError(null);
|
|
283
|
+
}
|
|
284
|
+
} catch (loadError) {
|
|
285
|
+
if (generation !== loadGeneration.current) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
|
289
|
+
} finally {
|
|
290
|
+
if (generation === loadGeneration.current) {
|
|
291
|
+
setLoading(false);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}, []);
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
void load();
|
|
298
|
+
}, [load]);
|
|
299
|
+
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
let active = true;
|
|
302
|
+
let subscription: { dispose(): void } | undefined;
|
|
303
|
+
void getNetworkDiagnosticsConsumer().then((diagnostics) => {
|
|
304
|
+
if (!active) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
subscription = diagnostics.observables?.status.subscribe(() => {
|
|
308
|
+
void load();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
return () => {
|
|
312
|
+
active = false;
|
|
313
|
+
subscription?.dispose();
|
|
314
|
+
};
|
|
315
|
+
}, [load]);
|
|
316
|
+
|
|
317
|
+
const run = async (
|
|
318
|
+
entry: INetworkSyncRecoveryEntry,
|
|
319
|
+
action: "retry" | SyncRecoveryResolution,
|
|
320
|
+
) => {
|
|
321
|
+
if (action === "deleteEverywhere") {
|
|
322
|
+
const confirmed = window.confirm(
|
|
323
|
+
`Delete ${entry.tableName}/${entry.recordId} on every device? This writes a synced delete.`,
|
|
324
|
+
);
|
|
325
|
+
if (!confirmed) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const key = `${entry.dataContextId}:${entry.syncRecoveryId}:${action}`;
|
|
330
|
+
setBusyKey(key);
|
|
331
|
+
setError(null);
|
|
332
|
+
try {
|
|
333
|
+
const control = await getNetworkControlConsumer();
|
|
334
|
+
const result =
|
|
335
|
+
action === "retry"
|
|
336
|
+
? await control.tools.retrySyncRecovery({
|
|
337
|
+
dataContextId: entry.dataContextId,
|
|
338
|
+
tableName: entry.tableName,
|
|
339
|
+
recordId: entry.recordId,
|
|
340
|
+
syncRecoveryId: entry.syncRecoveryId,
|
|
341
|
+
})
|
|
342
|
+
: await control.tools.resolveSyncRecovery({
|
|
343
|
+
dataContextId: entry.dataContextId,
|
|
344
|
+
tableName: entry.tableName,
|
|
345
|
+
recordId: entry.recordId,
|
|
346
|
+
resolution: action,
|
|
347
|
+
syncRecoveryId: entry.syncRecoveryId,
|
|
348
|
+
});
|
|
349
|
+
if (!result.success) {
|
|
350
|
+
setError(result.message);
|
|
351
|
+
await load(true);
|
|
352
|
+
} else {
|
|
353
|
+
setError(null);
|
|
354
|
+
await load();
|
|
355
|
+
}
|
|
356
|
+
} catch (actionError) {
|
|
357
|
+
setError(actionError instanceof Error ? actionError.message : String(actionError));
|
|
358
|
+
} finally {
|
|
359
|
+
setBusyKey(null);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
if (loading) {
|
|
364
|
+
return <LoadingIndicator />;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const groups = new Map<string, INetworkSyncRecoveryEntry[]>();
|
|
368
|
+
for (const entry of entries) {
|
|
369
|
+
const group = groups.get(entry.dataContextId) ?? [];
|
|
370
|
+
group.push(entry);
|
|
371
|
+
groups.set(entry.dataContextId, group);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div className="container-fluid p-4">
|
|
376
|
+
<div className="d-flex justify-content-between align-items-center mb-3">
|
|
377
|
+
<div className="d-flex align-items-center gap-3">
|
|
378
|
+
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={onBack}>
|
|
379
|
+
<i className="bi bi-arrow-left me-1" />
|
|
380
|
+
Back
|
|
381
|
+
</button>
|
|
382
|
+
<div>
|
|
383
|
+
<h2 className="mb-1">Sync recovery</h2>
|
|
384
|
+
<p className="text-muted small mb-0">
|
|
385
|
+
These records have change history, but this device could not write the row. Parked
|
|
386
|
+
entries are not asked of other devices until you retry them.
|
|
387
|
+
</p>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
<button type="button" className="btn btn-sm btn-primary" onClick={() => void load()}>
|
|
391
|
+
Refresh
|
|
392
|
+
</button>
|
|
393
|
+
</div>
|
|
394
|
+
{error && <div className="alert alert-danger py-2 small">{error}</div>}
|
|
395
|
+
{entries.length === 0 ? (
|
|
396
|
+
<div className="alert alert-success">No records are waiting in the recovery queue.</div>
|
|
397
|
+
) : (
|
|
398
|
+
[...groups.entries()].map(([dataContextId, groupEntries]) => (
|
|
399
|
+
<div key={dataContextId} className="card mb-3">
|
|
400
|
+
<div className="card-header">{groupEntries[0]?.dataContextName || dataContextId}</div>
|
|
401
|
+
<div className="table-responsive">
|
|
402
|
+
<table className="table table-sm align-middle mb-0">
|
|
403
|
+
<thead>
|
|
404
|
+
<tr>
|
|
405
|
+
<th>Table</th>
|
|
406
|
+
<th>Record</th>
|
|
407
|
+
<th>First failed</th>
|
|
408
|
+
<th>Attempts</th>
|
|
409
|
+
<th>Status</th>
|
|
410
|
+
<th>Latest error</th>
|
|
411
|
+
<th>
|
|
412
|
+
Actions
|
|
413
|
+
<InfoTip label="About these actions">
|
|
414
|
+
<ul className="mb-0 ps-3">
|
|
415
|
+
<li>
|
|
416
|
+
<strong>Retry</strong> writes the record again from this device's
|
|
417
|
+
history, then asks another device once. If a schema or unique-key entry
|
|
418
|
+
is still queued after that, it is parked again. Use it after an upgrade
|
|
419
|
+
that might accept a row this device rejected before.
|
|
420
|
+
</li>
|
|
421
|
+
<li>
|
|
422
|
+
<strong>Keep this device's version</strong> snapshots the row this
|
|
423
|
+
device will keep. That is the queued row when it is already stored here,
|
|
424
|
+
and otherwise the other row that holds the same unique value. When both
|
|
425
|
+
rows are stored, the other record is deleted everywhere. When only the
|
|
426
|
+
other row is stored, that row is kept and this queued id is tombstoned.
|
|
427
|
+
Disabled when this device has no row to keep.
|
|
428
|
+
</li>
|
|
429
|
+
<li>
|
|
430
|
+
<strong>Delete everywhere</strong> deletes this record on every device,
|
|
431
|
+
including devices that already have it.
|
|
432
|
+
</li>
|
|
433
|
+
<li>
|
|
434
|
+
<strong>Ignore on this device</strong> removes the entry here only.
|
|
435
|
+
Other devices keep their own copy of the problem.
|
|
436
|
+
</li>
|
|
437
|
+
</ul>
|
|
438
|
+
</InfoTip>
|
|
439
|
+
</th>
|
|
440
|
+
</tr>
|
|
441
|
+
</thead>
|
|
442
|
+
<tbody>
|
|
443
|
+
{groupEntries.map((entry) => (
|
|
444
|
+
<RecoveryRows
|
|
445
|
+
key={entry.syncRecoveryId}
|
|
446
|
+
entry={entry}
|
|
447
|
+
busy={Boolean(
|
|
448
|
+
busyKey?.startsWith(`${entry.dataContextId}:${entry.syncRecoveryId}:`),
|
|
449
|
+
)}
|
|
450
|
+
onRun={(item, action) => void run(item, action)}
|
|
451
|
+
/>
|
|
452
|
+
))}
|
|
453
|
+
</tbody>
|
|
454
|
+
</table>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
))
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|