@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.
Files changed (35) 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/packages/local-folder-grants-panel.d.ts +15 -0
  12. package/dist/screens/packages/local-folder-grants-panel.js +131 -0
  13. package/dist/screens/packages/local-folders-ipc.d.ts +24 -0
  14. package/dist/screens/packages/local-folders-ipc.js +10 -0
  15. package/dist/screens/packages/package-details.js +5 -0
  16. package/dist/screens/packages/package-list.d.ts +1 -0
  17. package/dist/screens/packages/package-list.js +3 -1
  18. package/dist/screens/settings/settings-page.js +13 -3
  19. package/dist/tabs-layout/network-status-indicator.js +39 -11
  20. package/dist/tabs-layout/network-status-indicator.test.js +109 -0
  21. package/dist/tabs-layout/use-network-status.test.js +1 -0
  22. package/package.json +3 -3
  23. package/src/screens/network-viewer/network-viewer-section.ts +12 -0
  24. package/src/screens/network-viewer/network-viewer.section.test.tsx +68 -0
  25. package/src/screens/network-viewer/network-viewer.tsx +27 -0
  26. package/src/screens/network-viewer/sync-recovery.test.tsx +114 -0
  27. package/src/screens/network-viewer/sync-recovery.tsx +461 -0
  28. package/src/screens/packages/local-folder-grants-panel.tsx +290 -0
  29. package/src/screens/packages/local-folders-ipc.ts +28 -0
  30. package/src/screens/packages/package-details.tsx +9 -0
  31. package/src/screens/packages/package-list.tsx +4 -0
  32. package/src/screens/settings/settings-page.tsx +15 -5
  33. package/src/tabs-layout/network-status-indicator.test.tsx +131 -0
  34. package/src/tabs-layout/network-status-indicator.tsx +121 -43
  35. package/src/tabs-layout/use-network-status.test.tsx +1 -0
@@ -0,0 +1,290 @@
1
+ import type { ILocalFolderGrantInfo, LocalFolderMode } from "@peers-app/peers-sdk";
2
+ import { useEffect, useState } from "react";
3
+ import { groups } from "../../globals";
4
+ import { useObservable } from "../../hooks";
5
+ import { useHostContract } from "../../hooks/use-host-contract";
6
+ import { localFoldersConsumer } from "./local-folders-ipc";
7
+
8
+ /** Props for {@link LocalFolderGrantsPanel}. */
9
+ export interface ILocalFolderGrantsPanelProps {
10
+ /**
11
+ * When set, the list and new grants are limited to this package.
12
+ * Omit it for the device-wide operator list.
13
+ */
14
+ packageId?: string;
15
+ }
16
+
17
+ /**
18
+ * Folder grants on this device, with add and revoke.
19
+ *
20
+ * Add opens the host folder picker. A host that does not provide System Local Folders
21
+ * (the PWA) shows the contract as unavailable.
22
+ */
23
+ export function LocalFolderGrantsPanel(props: ILocalFolderGrantsPanelProps) {
24
+ const { consumer, error: transportError } = useHostContract(localFoldersConsumer);
25
+ const [groupList] = useObservable(groups);
26
+ const [grants, setGrants] = useState<ILocalFolderGrantInfo[] | undefined>();
27
+ const [loadError, setLoadError] = useState<Error | undefined>();
28
+ const [actionError, setActionError] = useState<string | undefined>();
29
+ const [busy, setBusy] = useState(false);
30
+ const [groupId, setGroupId] = useState("");
31
+ const [mode, setMode] = useState<LocalFolderMode>("readwrite");
32
+ const [refresh, setRefresh] = useState(0);
33
+
34
+ useEffect(() => {
35
+ if (!consumer) {
36
+ return;
37
+ }
38
+ let cancelled = false;
39
+ consumer.tools.listGrants().then(
40
+ (result) => {
41
+ if (!cancelled) {
42
+ setGrants(result.grants);
43
+ setLoadError(undefined);
44
+ }
45
+ },
46
+ (cause: unknown) => {
47
+ if (!cancelled) {
48
+ setLoadError(cause instanceof Error ? cause : new Error(String(cause)));
49
+ }
50
+ },
51
+ );
52
+ return () => {
53
+ cancelled = true;
54
+ };
55
+ }, [consumer, refresh]);
56
+
57
+ const unavailable = isUnavailable(transportError) || isUnavailable(loadError);
58
+ const visible = (grants ?? []).filter((grant) =>
59
+ props.packageId ? grant.packageId === props.packageId : true,
60
+ );
61
+ const names = new Map(groupList.map((group) => [group.groupId, group.name || group.groupId]));
62
+
63
+ async function addFolder(): Promise<void> {
64
+ if (!consumer || !groupId) {
65
+ return;
66
+ }
67
+ setBusy(true);
68
+ setActionError(undefined);
69
+ try {
70
+ await consumer.tools.requestGrant({
71
+ groupId,
72
+ mode,
73
+ ...(props.packageId ? { packageId: props.packageId } : {}),
74
+ });
75
+ setRefresh((value) => value + 1);
76
+ } catch (cause) {
77
+ const message = cause instanceof Error ? cause.message : String(cause);
78
+ if (!/cancelled/i.test(message)) {
79
+ setActionError(message);
80
+ }
81
+ } finally {
82
+ setBusy(false);
83
+ }
84
+ }
85
+
86
+ async function revoke(grantId: string): Promise<void> {
87
+ if (!consumer) {
88
+ return;
89
+ }
90
+ if (!window.confirm("Revoke this folder grant?")) {
91
+ return;
92
+ }
93
+ setBusy(true);
94
+ setActionError(undefined);
95
+ try {
96
+ await consumer.tools.revokeGrant({ grantId });
97
+ setRefresh((value) => value + 1);
98
+ } catch (cause) {
99
+ setActionError(cause instanceof Error ? cause.message : String(cause));
100
+ } finally {
101
+ setBusy(false);
102
+ }
103
+ }
104
+
105
+ return (
106
+ <section className="mb-4">
107
+ <h5>{props.packageId ? "Folders" : "Local folders"}</h5>
108
+ <FolderStatus
109
+ unavailable={unavailable}
110
+ transportError={transportError}
111
+ loadError={loadError}
112
+ loading={!!consumer && grants === undefined && !loadError}
113
+ />
114
+ {consumer && !unavailable && (
115
+ <>
116
+ <GrantTable grants={visible} names={names} busy={busy} onRevoke={revoke} />
117
+ <AddFolderForm
118
+ fieldScope={props.packageId ?? "device"}
119
+ groupId={groupId}
120
+ groups={groupList.map((group) => ({
121
+ groupId: group.groupId,
122
+ name: group.name || group.groupId,
123
+ }))}
124
+ mode={mode}
125
+ busy={busy}
126
+ onGroupId={setGroupId}
127
+ onMode={setMode}
128
+ onAdd={addFolder}
129
+ />
130
+ {actionError && <div className="alert alert-danger mt-2 mb-0">{actionError}</div>}
131
+ </>
132
+ )}
133
+ </section>
134
+ );
135
+ }
136
+
137
+ function FolderStatus(props: {
138
+ unavailable: boolean;
139
+ transportError: Error | undefined;
140
+ loadError: Error | undefined;
141
+ loading: boolean;
142
+ }) {
143
+ if (props.unavailable) {
144
+ return (
145
+ <div className="alert alert-secondary mb-0">
146
+ System Local Folders is not available on this host.
147
+ </div>
148
+ );
149
+ }
150
+ const error = props.loadError ?? props.transportError;
151
+ if (error) {
152
+ return <div className="alert alert-danger mb-0">{error.message}</div>;
153
+ }
154
+ if (props.loading) {
155
+ return <div className="text-muted">Loading folder grants…</div>;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function GrantTable(props: {
161
+ grants: ILocalFolderGrantInfo[];
162
+ names: Map<string, string>;
163
+ busy: boolean;
164
+ onRevoke: (grantId: string) => void;
165
+ }) {
166
+ if (props.grants.length === 0) {
167
+ return <p className="text-muted">No folder grants.</p>;
168
+ }
169
+ return (
170
+ <div className="table-responsive">
171
+ <table className="table table-sm align-middle">
172
+ <thead>
173
+ <tr>
174
+ <th>Path</th>
175
+ <th>Group</th>
176
+ <th>Package</th>
177
+ <th>Mode</th>
178
+ <th>Created</th>
179
+ <th />
180
+ </tr>
181
+ </thead>
182
+ <tbody>
183
+ {props.grants.map((grant) => (
184
+ <GrantRow
185
+ key={grant.grantId}
186
+ grant={grant}
187
+ groupName={props.names.get(grant.groupId) ?? grant.groupId}
188
+ busy={props.busy}
189
+ onRevoke={props.onRevoke}
190
+ />
191
+ ))}
192
+ </tbody>
193
+ </table>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ function GrantRow(props: {
199
+ grant: ILocalFolderGrantInfo;
200
+ groupName: string;
201
+ busy: boolean;
202
+ onRevoke: (grantId: string) => void;
203
+ }) {
204
+ const { grant } = props;
205
+ return (
206
+ <tr>
207
+ <td className="text-break">{grant.absolutePath}</td>
208
+ <td>{props.groupName}</td>
209
+ <td>{grant.packageId ?? "operator"}</td>
210
+ <td>{grant.mode}</td>
211
+ <td>{new Date(grant.createdAt).toLocaleString()}</td>
212
+ <td>
213
+ <button
214
+ type="button"
215
+ className="btn btn-sm btn-outline-danger"
216
+ disabled={props.busy}
217
+ onClick={() => props.onRevoke(grant.grantId)}
218
+ >
219
+ Revoke
220
+ </button>
221
+ </td>
222
+ </tr>
223
+ );
224
+ }
225
+
226
+ function AddFolderForm(props: {
227
+ fieldScope: string;
228
+ groupId: string;
229
+ groups: { groupId: string; name: string }[];
230
+ mode: LocalFolderMode;
231
+ busy: boolean;
232
+ onGroupId: (groupId: string) => void;
233
+ onMode: (mode: LocalFolderMode) => void;
234
+ onAdd: () => void;
235
+ }) {
236
+ return (
237
+ <form
238
+ className="row g-2 align-items-end"
239
+ onSubmit={(event) => {
240
+ event.preventDefault();
241
+ props.onAdd();
242
+ }}
243
+ >
244
+ <div className="col-md-5">
245
+ <label className="form-label" htmlFor={`local-folder-group-${props.fieldScope}`}>
246
+ Group
247
+ </label>
248
+ <select
249
+ id={`local-folder-group-${props.fieldScope}`}
250
+ className="form-select"
251
+ value={props.groupId}
252
+ onChange={(event) => props.onGroupId(event.target.value)}
253
+ >
254
+ <option value="">Select a group</option>
255
+ {props.groups.map((group) => (
256
+ <option key={group.groupId} value={group.groupId}>
257
+ {group.name}
258
+ </option>
259
+ ))}
260
+ </select>
261
+ </div>
262
+ <div className="col-md-3">
263
+ <label className="form-label" htmlFor={`local-folder-mode-${props.fieldScope}`}>
264
+ Mode
265
+ </label>
266
+ <select
267
+ id={`local-folder-mode-${props.fieldScope}`}
268
+ className="form-select"
269
+ value={props.mode}
270
+ onChange={(event) => props.onMode(event.target.value as LocalFolderMode)}
271
+ >
272
+ <option value="readwrite">readwrite</option>
273
+ <option value="read">read</option>
274
+ </select>
275
+ </div>
276
+ <div className="col-md-4">
277
+ <button type="submit" className="btn btn-primary" disabled={props.busy || !props.groupId}>
278
+ {props.busy ? "Working…" : "Add folder"}
279
+ </button>
280
+ </div>
281
+ </form>
282
+ );
283
+ }
284
+
285
+ function isUnavailable(error: Error | undefined): boolean {
286
+ if (!error) {
287
+ return false;
288
+ }
289
+ return /No contract provider registered|not available locally/i.test(error.message);
290
+ }
@@ -0,0 +1,28 @@
1
+ import {
2
+ getLocalFoldersContractDefinition,
3
+ hostContractConsumer,
4
+ type IContractConsumerBase,
5
+ type IHostContractConsumer,
6
+ type ILocalFolderGrantInfo,
7
+ type ILocalFolderRequestGrantInput,
8
+ } from "@peers-app/peers-sdk";
9
+
10
+ /** Tools the Packages screens use on System Local Folders. */
11
+ export interface ILocalFoldersUiConsumer extends IContractConsumerBase {
12
+ tools: {
13
+ /** Create a grant. Omitting `absolutePath` opens the desktop folder picker. */
14
+ requestGrant(input: ILocalFolderRequestGrantInput): Promise<ILocalFolderGrantInfo>;
15
+ /** Active grants on this device. */
16
+ listGrants(): Promise<{ grants: ILocalFolderGrantInfo[] }>;
17
+ /** Revoke one grant. `revoked` is false when it was already gone. */
18
+ revokeGrant(input: { grantId: string }): Promise<{ revoked: boolean }>;
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Shared System Local Folders consumer for the Packages screens.
24
+ *
25
+ * The PWA host does not register this contract. Callers treat a missing provider as unavailable.
26
+ */
27
+ export const localFoldersConsumer: IHostContractConsumer<ILocalFoldersUiConsumer> =
28
+ hostContractConsumer<ILocalFoldersUiConsumer>(getLocalFoldersContractDefinition());
@@ -5,6 +5,7 @@ import { type ISaveButtonProps, SaveButton } from "../../components/save-button"
5
5
  import { ScreenTabBody, Tabs } from "../../components/tabs";
6
6
  import { useObservableState, usePromise } from "../../hooks";
7
7
  import { updateActiveTabTitle } from "../../tabs-layout/tabs-state";
8
+ import { LocalFolderGrantsPanel } from "./local-folder-grants-panel";
8
9
  import { PackageInfo } from "./package-info";
9
10
  import { PackageVersionsList } from "./package-versions";
10
11
 
@@ -127,6 +128,14 @@ export const PackageDetails = (props: IProps) => {
127
128
  <ScreenTabBody>TODO - show all of the dependencies of the package</ScreenTabBody>
128
129
  ),
129
130
  },
131
+ {
132
+ name: "Folders",
133
+ content: (
134
+ <ScreenTabBody>
135
+ <LocalFolderGrantsPanel packageId={pkg.packageId} />
136
+ </ScreenTabBody>
137
+ ),
138
+ },
130
139
  ]}
131
140
  />
132
141
  </div>
@@ -21,6 +21,7 @@ import { Tooltip } from "../../components/tooltip";
21
21
  import { isDesktop, mainContentPath } from "../../globals";
22
22
  import { useObservable, useObservableState, usePromise } from "../../hooks";
23
23
  import { registerInternalPeersUI } from "../../ui-router/ui-loader";
24
+ import { LocalFolderGrantsPanel } from "./local-folder-grants-panel";
24
25
  import {
25
26
  activatePackageVersion,
26
27
  checkVersionStatus,
@@ -236,6 +237,7 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
236
237
  };
237
238
 
238
239
  /** Searchable list of installed packages, plus create/import from the search box. */
240
+ /** Packages screen: search, local folder grants, and the installed package list. */
239
241
  export const PackageList = () => {
240
242
  const [searchTextObs] = useState(() => observable(""));
241
243
  const [searchText] = useObservable(searchTextObs);
@@ -437,6 +439,8 @@ export const PackageList = () => {
437
439
  </ul>
438
440
  </div>
439
441
 
442
+ <LocalFolderGrantsPanel />
443
+
440
444
  <div className="peers-list-container">
441
445
  <LazyList
442
446
  resetTrigger={`${searchText}_${refreshKey()}`}
@@ -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
+ }