@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.
Files changed (24) hide show
  1. package/dist/screens/network-viewer/network-viewer-section.d.ts +9 -0
  2. package/dist/screens/network-viewer/network-viewer-section.js +11 -0
  3. package/dist/screens/network-viewer/network-viewer.d.ts +6 -0
  4. package/dist/screens/network-viewer/network-viewer.js +19 -1
  5. package/dist/screens/network-viewer/network-viewer.section.test.d.ts +1 -0
  6. package/dist/screens/network-viewer/network-viewer.section.test.js +63 -0
  7. package/dist/screens/network-viewer/sync-recovery.d.ts +8 -0
  8. package/dist/screens/network-viewer/sync-recovery.js +182 -0
  9. package/dist/screens/network-viewer/sync-recovery.test.d.ts +1 -0
  10. package/dist/screens/network-viewer/sync-recovery.test.js +104 -0
  11. package/dist/screens/settings/settings-page.js +13 -3
  12. package/dist/tabs-layout/network-status-indicator.js +39 -11
  13. package/dist/tabs-layout/network-status-indicator.test.js +109 -0
  14. package/dist/tabs-layout/use-network-status.test.js +1 -0
  15. package/package.json +3 -3
  16. package/src/screens/network-viewer/network-viewer-section.ts +12 -0
  17. package/src/screens/network-viewer/network-viewer.section.test.tsx +68 -0
  18. package/src/screens/network-viewer/network-viewer.tsx +27 -0
  19. package/src/screens/network-viewer/sync-recovery.test.tsx +114 -0
  20. package/src/screens/network-viewer/sync-recovery.tsx +461 -0
  21. package/src/screens/settings/settings-page.tsx +15 -5
  22. package/src/tabs-layout/network-status-indicator.test.tsx +131 -0
  23. package/src/tabs-layout/network-status-indicator.tsx +121 -43
  24. 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 rpcServerCalls.resetAllDeviceSyncInfo();
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 all change history for the current group and rebuilds it from your current database
366
- rows. Device sync cursors are cleared; a full resync with peers may follow. Use when
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 rpcServerCalls.resetChangeTracking();
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
+ }