@peers-app/peers-ui 0.21.2 → 0.21.4

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 (31) hide show
  1. package/dist/screens/network-viewer/connection-troubleshooter.js +1 -1
  2. package/dist/screens/network-viewer/device-details-modal.js +11 -15
  3. package/dist/screens/network-viewer/group-details-modal.js +60 -17
  4. package/dist/screens/network-viewer/network-viewer-ipc.d.ts +9 -39
  5. package/dist/screens/network-viewer/network-viewer-ipc.js +71 -3
  6. package/dist/screens/network-viewer/network-viewer-ipc.test.d.ts +1 -0
  7. package/dist/screens/network-viewer/network-viewer-ipc.test.js +59 -0
  8. package/dist/screens/network-viewer/network-viewer.js +46 -60
  9. package/dist/screens/network-viewer/remote-logs-panel.js +11 -12
  10. package/dist/tabs-layout/network-status-indicator.d.ts +41 -0
  11. package/dist/tabs-layout/network-status-indicator.js +261 -0
  12. package/dist/tabs-layout/network-status-indicator.test.d.ts +1 -0
  13. package/dist/tabs-layout/network-status-indicator.test.js +333 -0
  14. package/dist/tabs-layout/tabs-layout.js +25 -24
  15. package/dist/tabs-layout/use-network-status.d.ts +8 -0
  16. package/dist/tabs-layout/use-network-status.js +57 -0
  17. package/dist/tabs-layout/use-network-status.test.d.ts +1 -0
  18. package/dist/tabs-layout/use-network-status.test.js +42 -0
  19. package/package.json +3 -3
  20. package/src/screens/network-viewer/connection-troubleshooter.tsx +2 -2
  21. package/src/screens/network-viewer/device-details-modal.tsx +19 -61
  22. package/src/screens/network-viewer/group-details-modal.tsx +211 -130
  23. package/src/screens/network-viewer/network-viewer-ipc.test.ts +70 -0
  24. package/src/screens/network-viewer/network-viewer-ipc.ts +102 -35
  25. package/src/screens/network-viewer/network-viewer.tsx +76 -148
  26. package/src/screens/network-viewer/remote-logs-panel.tsx +14 -16
  27. package/src/tabs-layout/network-status-indicator.test.tsx +391 -0
  28. package/src/tabs-layout/network-status-indicator.tsx +475 -0
  29. package/src/tabs-layout/tabs-layout.tsx +4 -1
  30. package/src/tabs-layout/use-network-status.test.tsx +55 -0
  31. package/src/tabs-layout/use-network-status.ts +68 -0
@@ -0,0 +1,391 @@
1
+ import type { INetworkConnectionInfo, INetworkStatusSummary } from "@peers-app/peers-sdk";
2
+ import { TrustLevel } from "@peers-app/peers-sdk";
3
+ import { act, fireEvent, render, waitFor } from "@testing-library/react";
4
+ import moment from "moment";
5
+ import {
6
+ connectionIdentityLabel,
7
+ formatRelativeSyncTime,
8
+ getNetworkStatusPresentation,
9
+ NetworkStatusIndicator,
10
+ SYNCED_STABLE_MS,
11
+ SYNCING_GREEN_MS,
12
+ } from "./network-status-indicator";
13
+ import type { INetworkStatusViewState } from "./use-network-status";
14
+
15
+ const goToTabPath = jest.fn();
16
+ const getConnections = jest.fn();
17
+ let mockViewState: INetworkStatusViewState = { availability: "loading" };
18
+
19
+ jest.mock("./tabs-state", () => ({
20
+ goToTabPath: (...args: unknown[]) => goToTabPath(...args),
21
+ }));
22
+
23
+ jest.mock("./use-network-status", () => ({
24
+ useNetworkStatus: () => mockViewState,
25
+ }));
26
+
27
+ jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
28
+ getNetworkDiagnosticsConsumer: () =>
29
+ Promise.resolve({
30
+ tools: {
31
+ getConnections: (...args: unknown[]) => getConnections(...args),
32
+ },
33
+ }),
34
+ }));
35
+
36
+ function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummary {
37
+ return {
38
+ state: "synced",
39
+ connectionCount: 2,
40
+ syncingContextCount: 0,
41
+ errorCount: 0,
42
+ errors: [],
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function connection(overrides: Partial<INetworkConnectionInfo> = {}): INetworkConnectionInfo {
48
+ return {
49
+ deviceId: "device-1",
50
+ userId: "user-1",
51
+ userName: "Ada",
52
+ deviceName: "Laptop",
53
+ userTrustLevel: TrustLevel.Trusted,
54
+ deviceTrustLevel: TrustLevel.Trusted,
55
+ connectionState: "connected",
56
+ sharedGroups: ["group-a"],
57
+ roles: {},
58
+ pathType: "direct",
59
+ latencyMs: 10,
60
+ errorRate: 0,
61
+ isServer: false,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ describe("formatRelativeSyncTime", () => {
67
+ it("returns now for very recent syncs", () => {
68
+ const now = Date.parse("2026-07-28T23:43:06.000");
69
+ expect(formatRelativeSyncTime(now - 10_000, now)).toBe("now");
70
+ });
71
+
72
+ it("returns yesterday for the previous calendar day", () => {
73
+ const now = moment("2026-07-28T23:43:06").valueOf();
74
+ const yesterdayAfternoon = moment("2026-07-28T23:43:06")
75
+ .subtract(1, "day")
76
+ .hour(18)
77
+ .minute(0)
78
+ .second(0)
79
+ .valueOf();
80
+ expect(formatRelativeSyncTime(yesterdayAfternoon, now)).toBe("yesterday");
81
+ });
82
+
83
+ it("returns a short relative phrase otherwise", () => {
84
+ const now = Date.parse("2026-07-28T23:43:06.000");
85
+ expect(formatRelativeSyncTime(now - 5 * 60_000, now)).toBe("5 minutes ago");
86
+ });
87
+ });
88
+
89
+ describe("connectionIdentityLabel", () => {
90
+ it("prefers a friendly name", () => {
91
+ expect(connectionIdentityLabel("Ada", "00mabcdefghijklmnopqrs")).toEqual({
92
+ text: "Ada",
93
+ isId: false,
94
+ title: "Ada",
95
+ });
96
+ });
97
+
98
+ it("middle-ellipsizes raw ids", () => {
99
+ expect(connectionIdentityLabel(undefined, "00mabcdefghijklmnopqrs")).toEqual({
100
+ text: "00ma…pqrs",
101
+ isId: true,
102
+ title: "00mabcdefghijklmnopqrs",
103
+ });
104
+ });
105
+ });
106
+
107
+ describe("getNetworkStatusPresentation", () => {
108
+ it("uses a compact green check-circle when synced", () => {
109
+ expect(
110
+ getNetworkStatusPresentation({ availability: "ready", status: status({ state: "synced" }) }),
111
+ ).toMatchObject({
112
+ shortLabel: "Synced",
113
+ colorClass: "text-success",
114
+ iconClass: "bi-check-circle-fill",
115
+ compactIcon: true,
116
+ });
117
+ });
118
+
119
+ it("uses a solid red dot when in error", () => {
120
+ expect(
121
+ getNetworkStatusPresentation({
122
+ availability: "ready",
123
+ status: status({
124
+ state: "error",
125
+ errorCount: 1,
126
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
127
+ }),
128
+ }),
129
+ ).toMatchObject({
130
+ shortLabel: "Error",
131
+ colorClass: "text-danger",
132
+ mark: "solid",
133
+ });
134
+ });
135
+
136
+ it("pulses green then switches to an indeterminate circle while syncing", () => {
137
+ const syncing: INetworkStatusViewState = {
138
+ availability: "ready",
139
+ status: status({ state: "syncing", syncingContextCount: 1 }),
140
+ };
141
+ expect(getNetworkStatusPresentation(syncing)).toMatchObject({
142
+ colorClass: "text-success",
143
+ mark: "pulse",
144
+ });
145
+ expect(getNetworkStatusPresentation(syncing, { syncingLongerThanGreenMs: true })).toMatchObject(
146
+ {
147
+ colorClass: "text-primary",
148
+ mark: "indeterminate",
149
+ },
150
+ );
151
+ });
152
+
153
+ it("holds a green pulse while waiting for stable synced", () => {
154
+ expect(
155
+ getNetworkStatusPresentation(
156
+ { availability: "ready", status: status({ state: "synced" }) },
157
+ { holdSyncingVisual: true, syncingLongerThanGreenMs: true },
158
+ ),
159
+ ).toMatchObject({
160
+ shortLabel: "Syncing",
161
+ colorClass: "text-success",
162
+ mark: "pulse",
163
+ });
164
+ });
165
+ });
166
+
167
+ describe("NetworkStatusIndicator", () => {
168
+ beforeEach(() => {
169
+ mockViewState = { availability: "loading" };
170
+ getConnections.mockReset();
171
+ getConnections.mockResolvedValue({ connections: [] });
172
+ jest.clearAllMocks();
173
+ });
174
+
175
+ afterEach(() => {
176
+ jest.useRealTimers();
177
+ });
178
+
179
+ it.each([
180
+ [{ availability: "loading" }, /Network status loading/],
181
+ [{ availability: "unavailable" }, /Network status unavailable/],
182
+ [
183
+ { availability: "ready", status: status({ state: "offline", connectionCount: 0 }) },
184
+ /Network offline/,
185
+ ],
186
+ [
187
+ {
188
+ availability: "ready",
189
+ status: status({ state: "syncing", syncingContextCount: 1 }),
190
+ },
191
+ /Network syncing/,
192
+ ],
193
+ [{ availability: "ready", status: status({ state: "synced" }) }, /Network synced/],
194
+ [
195
+ {
196
+ availability: "ready",
197
+ status: status({
198
+ state: "error",
199
+ errorCount: 1,
200
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
201
+ }),
202
+ },
203
+ /Network error/,
204
+ ],
205
+ ] satisfies Array<
206
+ [INetworkStatusViewState, RegExp]
207
+ >)("renders an accessible state for %#", (viewState, accessibleName) => {
208
+ mockViewState = viewState;
209
+ const view = render(<NetworkStatusIndicator />);
210
+ expect(view.getByRole("button", { name: accessibleName })).toBeTruthy();
211
+ });
212
+
213
+ it("renders a compact green check-circle when synced and a solid red error dot", () => {
214
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
215
+ const view = render(<NetworkStatusIndicator />);
216
+ const syncedIcon = view.container.querySelector("i.bi-check-circle-fill");
217
+ expect(syncedIcon).toBeTruthy();
218
+ expect(syncedIcon).toHaveStyle({ fontSize: "0.7rem" });
219
+ expect(view.container.querySelector(".spinner-grow")).toBeNull();
220
+
221
+ mockViewState = {
222
+ availability: "ready",
223
+ status: status({
224
+ state: "error",
225
+ errorCount: 1,
226
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
227
+ }),
228
+ };
229
+ view.rerender(<NetworkStatusIndicator />);
230
+ expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
231
+ });
232
+
233
+ it("uses a slower green pulse while syncing", () => {
234
+ mockViewState = {
235
+ availability: "ready",
236
+ status: status({ state: "syncing", syncingContextCount: 1 }),
237
+ };
238
+ const view = render(<NetworkStatusIndicator />);
239
+ const pulse = view.container.querySelector(".spinner-grow.text-success");
240
+ expect(pulse).toHaveStyle({ animationDuration: "1.0s" });
241
+ });
242
+
243
+ it("pulses green while syncing, then an indeterminate circle after the green window", () => {
244
+ jest.useFakeTimers();
245
+ mockViewState = {
246
+ availability: "ready",
247
+ status: status({ state: "syncing", syncingContextCount: 1 }),
248
+ };
249
+ const view = render(<NetworkStatusIndicator />);
250
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
251
+
252
+ act(() => {
253
+ jest.advanceTimersByTime(SYNCING_GREEN_MS);
254
+ });
255
+ expect(view.container.querySelector(".spinner-border.text-primary")).toBeTruthy();
256
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeNull();
257
+ });
258
+
259
+ it("keeps the green syncing pulse through brief synced flaps until synced is stable", () => {
260
+ jest.useFakeTimers();
261
+ mockViewState = {
262
+ availability: "ready",
263
+ status: status({ state: "syncing", syncingContextCount: 1 }),
264
+ };
265
+ const view = render(<NetworkStatusIndicator />);
266
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
267
+
268
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
269
+ view.rerender(<NetworkStatusIndicator />);
270
+ act(() => {
271
+ jest.advanceTimersByTime(SYNCED_STABLE_MS - 1);
272
+ });
273
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
274
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeNull();
275
+
276
+ mockViewState = {
277
+ availability: "ready",
278
+ status: status({ state: "syncing", syncingContextCount: 1 }),
279
+ };
280
+ view.rerender(<NetworkStatusIndicator />);
281
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
282
+
283
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
284
+ view.rerender(<NetworkStatusIndicator />);
285
+ act(() => {
286
+ jest.advanceTimersByTime(SYNCED_STABLE_MS);
287
+ });
288
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
289
+ expect(view.container.querySelector(".spinner-grow")).toBeNull();
290
+ });
291
+
292
+ it("lists connected devices in the details popover", async () => {
293
+ getConnections.mockResolvedValue({
294
+ connections: [
295
+ connection({
296
+ deviceId: "device-1",
297
+ userName: "Ada",
298
+ deviceName: "Laptop",
299
+ sharedGroups: ["g1", "g2"],
300
+ }),
301
+ ],
302
+ });
303
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
304
+ const view = render(<NetworkStatusIndicator />);
305
+ fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
306
+
307
+ await waitFor(() => {
308
+ expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent("Ada");
309
+ });
310
+ expect(view.getByRole("dialog")).toHaveTextContent("Laptop");
311
+ expect(view.getByRole("dialog")).toHaveTextContent("2g");
312
+ expect(getConnections).toHaveBeenCalled();
313
+ });
314
+
315
+ it("shows middle-ellipsized ids when names are missing", async () => {
316
+ getConnections.mockResolvedValue({
317
+ connections: [
318
+ connection({
319
+ deviceId: "00mdeviceidabcdefghijklm",
320
+ userId: "00museridabcdefghijklmn",
321
+ userName: undefined,
322
+ deviceName: undefined,
323
+ sharedGroups: [],
324
+ }),
325
+ ],
326
+ });
327
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
328
+ const view = render(<NetworkStatusIndicator />);
329
+ fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
330
+
331
+ await waitFor(() => {
332
+ expect(view.getByRole("dialog")).toHaveTextContent("00mu…klmn");
333
+ });
334
+ expect(view.getByRole("dialog")).toHaveTextContent("00md…jklm");
335
+ });
336
+
337
+ it("shows an empty connected-devices message when there are none", async () => {
338
+ getConnections.mockResolvedValue({ connections: [] });
339
+ mockViewState = {
340
+ availability: "ready",
341
+ status: status({ state: "offline", connectionCount: 0 }),
342
+ };
343
+ const view = render(<NetworkStatusIndicator />);
344
+ fireEvent.click(view.getByRole("button", { name: /Network offline/ }));
345
+
346
+ await waitFor(() => {
347
+ expect(view.getByRole("dialog")).toHaveTextContent("No connected devices");
348
+ });
349
+ });
350
+
351
+ it("shows an error when connected devices fail to load", async () => {
352
+ getConnections.mockRejectedValue(new Error("offline"));
353
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
354
+ const view = render(<NetworkStatusIndicator />);
355
+ fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
356
+
357
+ await waitFor(() => {
358
+ expect(view.getByRole("dialog")).toHaveTextContent("Couldn't load devices");
359
+ });
360
+ });
361
+
362
+ it("updates on rerender and opens details before navigating to Network Viewer", async () => {
363
+ const view = render(<NetworkStatusIndicator />);
364
+ expect(view.getByRole("button", { name: /loading/ })).toBeTruthy();
365
+
366
+ mockViewState = {
367
+ availability: "ready",
368
+ status: status({
369
+ state: "error",
370
+ connectionCount: 3,
371
+ errorCount: 1,
372
+ errors: [{ dataContextId: "context", message: "sync failed", occurredAt: 1 }],
373
+ }),
374
+ };
375
+ view.rerender(<NetworkStatusIndicator />);
376
+ fireEvent.click(view.getByRole("button", { name: /Network error/ }));
377
+
378
+ expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent(
379
+ "sync failed",
380
+ );
381
+ await waitFor(() => {
382
+ expect(getConnections).toHaveBeenCalled();
383
+ });
384
+ fireEvent.keyDown(document, { key: "Escape" });
385
+ expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
386
+
387
+ fireEvent.click(view.getByRole("button", { name: /Network error/ }));
388
+ fireEvent.click(view.getByRole("button", { name: "Open Network Viewer" }));
389
+ expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
390
+ });
391
+ });