@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,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
|
+
}
|
|
@@ -17,6 +17,7 @@ import { useObservable, usePromise } from "../../hooks";
|
|
|
17
17
|
import { LayoutPicker } from "../../root-layout/layout-picker";
|
|
18
18
|
import { identityTools } from "../device-pairing/identity-ipc";
|
|
19
19
|
import { IDENTITY_ACCOUNT_PATH, IDENTITY_PAIR_PATH } from "../identity/identity-routes";
|
|
20
|
+
import { getNetworkControlConsumer } from "../network-viewer/network-viewer-ipc";
|
|
20
21
|
import { ColorModeDropdown } from "./color-mode-dropdown";
|
|
21
22
|
|
|
22
23
|
/** Electron file picker attaches a filesystem path on `File`. */
|
|
@@ -262,10 +263,15 @@ const ResetDeviceSyncInfos: React.FC = () => {
|
|
|
262
263
|
className="btn btn-secondary btn-sm ms-2"
|
|
263
264
|
onClick={async () => {
|
|
264
265
|
try {
|
|
265
|
-
await
|
|
266
|
+
const result = await (await getNetworkControlConsumer()).tools.resetDeviceSyncInfo();
|
|
267
|
+
if (!result.success) {
|
|
268
|
+
alert(result.message);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
266
271
|
alert("All device sync info has been reset. Syncing in progress...");
|
|
267
272
|
} catch (err) {
|
|
268
273
|
console.error("Error while resetting all device sync info", err);
|
|
274
|
+
alert(`Failed to reset device sync info: ${(err as Error).message || String(err)}`);
|
|
269
275
|
}
|
|
270
276
|
}}
|
|
271
277
|
>
|
|
@@ -362,9 +368,9 @@ const ResetChangeTracking: React.FC = () => {
|
|
|
362
368
|
<div className="mt-4 pt-3 border-top">
|
|
363
369
|
<h6 className="mb-2">Change tracking</h6>
|
|
364
370
|
<small className="text-muted d-block mb-2">
|
|
365
|
-
Clears
|
|
366
|
-
rows. Device sync cursors are cleared; a full resync with peers may follow. Use
|
|
367
|
-
debugging sync issues.
|
|
371
|
+
Clears change history for every personal and group context on this device and rebuilds it
|
|
372
|
+
from current rows. Device sync cursors are cleared; a full resync with peers may follow. Use
|
|
373
|
+
when debugging sync issues.
|
|
368
374
|
</small>
|
|
369
375
|
<button
|
|
370
376
|
className="btn btn-warning btn-sm"
|
|
@@ -376,7 +382,11 @@ const ResetChangeTracking: React.FC = () => {
|
|
|
376
382
|
if (!confirmed) return;
|
|
377
383
|
setBusy(true);
|
|
378
384
|
try {
|
|
379
|
-
await
|
|
385
|
+
const result = await (await getNetworkControlConsumer()).tools.resetChangeTracking();
|
|
386
|
+
if (!result.success) {
|
|
387
|
+
alert(result.message);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
380
390
|
await rpcServerCalls.flushDatabases();
|
|
381
391
|
alert("Change tracking reset successfully.");
|
|
382
392
|
} catch (err) {
|
|
@@ -3,6 +3,7 @@ import { TrustLevel } from "@peers-app/peers-sdk";
|
|
|
3
3
|
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
|
4
4
|
import moment from "moment";
|
|
5
5
|
import { IDENTITY_ACCEPT_PATH, IDENTITY_ACCOUNT_PATH } from "../screens/identity/identity-routes";
|
|
6
|
+
import { networkViewerSection } from "../screens/network-viewer/network-viewer-section";
|
|
6
7
|
import {
|
|
7
8
|
connectionIdentityLabel,
|
|
8
9
|
formatRelativeSyncTime,
|
|
@@ -42,6 +43,7 @@ function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummar
|
|
|
42
43
|
errorCount: 0,
|
|
43
44
|
disconnectedContextCount: 0,
|
|
44
45
|
connectedContextCount: 0,
|
|
46
|
+
stuckRecoveryCount: 0,
|
|
45
47
|
errors: [],
|
|
46
48
|
...overrides,
|
|
47
49
|
};
|
|
@@ -153,6 +155,38 @@ describe("getNetworkStatusPresentation", () => {
|
|
|
153
155
|
);
|
|
154
156
|
});
|
|
155
157
|
|
|
158
|
+
it("turns yellow for stuck recovery without hiding sync activity or errors", () => {
|
|
159
|
+
expect(
|
|
160
|
+
getNetworkStatusPresentation({
|
|
161
|
+
availability: "ready",
|
|
162
|
+
status: status({ state: "synced", stuckRecoveryCount: 2 }),
|
|
163
|
+
}),
|
|
164
|
+
).toMatchObject({
|
|
165
|
+
colorClass: "text-warning",
|
|
166
|
+
iconClass: "bi-check-circle-fill",
|
|
167
|
+
label: expect.stringContaining("2 records need attention"),
|
|
168
|
+
});
|
|
169
|
+
expect(
|
|
170
|
+
getNetworkStatusPresentation(
|
|
171
|
+
{
|
|
172
|
+
availability: "ready",
|
|
173
|
+
status: status({ state: "syncing", syncingContextCount: 1, stuckRecoveryCount: 1 }),
|
|
174
|
+
},
|
|
175
|
+
{ syncingLongerThanGreenMs: true },
|
|
176
|
+
),
|
|
177
|
+
).toMatchObject({
|
|
178
|
+
colorClass: "text-warning",
|
|
179
|
+
mark: "indeterminate",
|
|
180
|
+
label: expect.stringContaining("1 record needs attention"),
|
|
181
|
+
});
|
|
182
|
+
expect(
|
|
183
|
+
getNetworkStatusPresentation({
|
|
184
|
+
availability: "ready",
|
|
185
|
+
status: status({ state: "error", errorCount: 1, stuckRecoveryCount: 4 }),
|
|
186
|
+
}).colorClass,
|
|
187
|
+
).toBe("text-danger");
|
|
188
|
+
});
|
|
189
|
+
|
|
156
190
|
it("holds a green pulse while waiting for stable synced", () => {
|
|
157
191
|
expect(
|
|
158
192
|
getNetworkStatusPresentation(
|
|
@@ -230,11 +264,31 @@ describe("getNetworkStatusPresentation", () => {
|
|
|
230
264
|
}).connectedGroupsOverlay,
|
|
231
265
|
).toBeUndefined();
|
|
232
266
|
});
|
|
267
|
+
|
|
268
|
+
it("turns an offline indicator yellow when recovery is stuck and keeps the offline icon", () => {
|
|
269
|
+
expect(
|
|
270
|
+
getNetworkStatusPresentation({
|
|
271
|
+
availability: "ready",
|
|
272
|
+
status: status({ state: "offline", connectionCount: 0, stuckRecoveryCount: 2 }),
|
|
273
|
+
}),
|
|
274
|
+
).toMatchObject({
|
|
275
|
+
iconClass: "bi-cloud-slash-fill",
|
|
276
|
+
colorClass: "text-warning",
|
|
277
|
+
label: expect.stringContaining("2 records need attention"),
|
|
278
|
+
});
|
|
279
|
+
expect(
|
|
280
|
+
getNetworkStatusPresentation({
|
|
281
|
+
availability: "ready",
|
|
282
|
+
status: status({ state: "error", errorCount: 1, stuckRecoveryCount: 2 }),
|
|
283
|
+
}).colorClass,
|
|
284
|
+
).toBe("text-danger");
|
|
285
|
+
});
|
|
233
286
|
});
|
|
234
287
|
|
|
235
288
|
describe("NetworkStatusIndicator", () => {
|
|
236
289
|
beforeEach(() => {
|
|
237
290
|
mockViewState = { availability: "loading" };
|
|
291
|
+
networkViewerSection("main");
|
|
238
292
|
getConnections.mockReset();
|
|
239
293
|
getConnections.mockResolvedValue({ connections: [] });
|
|
240
294
|
jest.clearAllMocks();
|
|
@@ -546,5 +600,82 @@ describe("NetworkStatusIndicator", () => {
|
|
|
546
600
|
fireEvent.click(view.getByRole("button", { name: /Network error/ }));
|
|
547
601
|
fireEvent.click(view.getByRole("button", { name: "Open Network Viewer" }));
|
|
548
602
|
expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
|
|
603
|
+
expect(networkViewerSection()).toBe("main");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("hides zero problem counts and Sync recovery when nothing needs attention", async () => {
|
|
607
|
+
mockViewState = {
|
|
608
|
+
availability: "ready",
|
|
609
|
+
status: status({
|
|
610
|
+
state: "synced",
|
|
611
|
+
connectionCount: 4,
|
|
612
|
+
connectedContextCount: 5,
|
|
613
|
+
lastSuccessfulSyncAt: Date.parse("2026-09-27T16:00:00.000Z"),
|
|
614
|
+
}),
|
|
615
|
+
};
|
|
616
|
+
const view = render(<NetworkStatusIndicator />);
|
|
617
|
+
await openNetworkDetails(view, /Network synced/);
|
|
618
|
+
|
|
619
|
+
const labels = [...view.getByRole("dialog").querySelectorAll("dt")].map((el) => el.textContent);
|
|
620
|
+
expect(labels).toEqual(["Connections", "Groups connected", "Last sync"]);
|
|
621
|
+
expect(view.queryByRole("button", { name: "Sync recovery" })).toBeNull();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("orders non-zero status rows and offers Sync recovery when records are stuck", async () => {
|
|
625
|
+
mockViewState = {
|
|
626
|
+
availability: "ready",
|
|
627
|
+
status: status({
|
|
628
|
+
state: "error",
|
|
629
|
+
connectionCount: 4,
|
|
630
|
+
connectedContextCount: 5,
|
|
631
|
+
disconnectedContextCount: 1,
|
|
632
|
+
syncingContextCount: 2,
|
|
633
|
+
errorCount: 3,
|
|
634
|
+
stuckRecoveryCount: 1,
|
|
635
|
+
lastSuccessfulSyncAt: Date.parse("2026-09-27T16:00:00.000Z"),
|
|
636
|
+
errors: [{ dataContextId: "context", message: "sync failed", occurredAt: 1 }],
|
|
637
|
+
}),
|
|
638
|
+
};
|
|
639
|
+
const view = render(<NetworkStatusIndicator />);
|
|
640
|
+
await openNetworkDetails(view, /Network error/);
|
|
641
|
+
|
|
642
|
+
const labels = [...view.getByRole("dialog").querySelectorAll("dt")].map((el) => el.textContent);
|
|
643
|
+
expect(labels).toEqual([
|
|
644
|
+
"Connections",
|
|
645
|
+
"Groups connected",
|
|
646
|
+
"Syncing contexts",
|
|
647
|
+
"Groups offline",
|
|
648
|
+
"Sync errors",
|
|
649
|
+
"Last sync",
|
|
650
|
+
]);
|
|
651
|
+
const groupsOffline = view.getByText("Groups offline");
|
|
652
|
+
expect(groupsOffline.className).toContain("text-warning");
|
|
653
|
+
expect(groupsOffline.nextElementSibling?.textContent).toBe("1");
|
|
654
|
+
expect(groupsOffline.nextElementSibling?.className).toContain("text-warning");
|
|
655
|
+
expect(view.getByRole("button", { name: "Sync recovery" }).className).toContain("btn-warning");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("opens Sync recovery inside the Network tab", async () => {
|
|
659
|
+
networkViewerSection("main");
|
|
660
|
+
mockViewState = {
|
|
661
|
+
availability: "ready",
|
|
662
|
+
status: status({ state: "synced", stuckRecoveryCount: 2 }),
|
|
663
|
+
};
|
|
664
|
+
const view = render(<NetworkStatusIndicator />);
|
|
665
|
+
await openNetworkDetails(view, /Network synced/);
|
|
666
|
+
fireEvent.click(view.getByRole("button", { name: "Sync recovery" }));
|
|
667
|
+
|
|
668
|
+
expect(networkViewerSection()).toBe("sync-recovery");
|
|
669
|
+
expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
|
|
670
|
+
expect(goToTabPath).not.toHaveBeenCalledWith("sync-recovery");
|
|
671
|
+
expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
|
|
549
672
|
});
|
|
550
673
|
});
|
|
674
|
+
|
|
675
|
+
async function openNetworkDetails(view: ReturnType<typeof render>, name: RegExp): Promise<void> {
|
|
676
|
+
fireEvent.click(view.getByRole("button", { name }));
|
|
677
|
+
await waitFor(() => expect(getConnections).toHaveBeenCalled());
|
|
678
|
+
await act(async () => {
|
|
679
|
+
await Promise.resolve();
|
|
680
|
+
});
|
|
681
|
+
}
|