@peers-app/peers-ui 0.21.5 → 0.21.6

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.
@@ -24,7 +24,6 @@ const IOSchemaValue = (props) => {
24
24
  const { field, values, q } = props;
25
25
  const [valueObs] = (0, react_1.useState)(() => {
26
26
  // Field values are heterogeneous (string | number | boolean | Date | …) at runtime.
27
- // biome-ignore lint/suspicious/noExplicitAny: dynamic IO schema binding
28
27
  const obs = (0, peers_sdk_1.observable)(values[field.name] ?? "");
29
28
  return obs;
30
29
  });
@@ -74,7 +74,7 @@ const editorConfig = {
74
74
  list_1.ListNode,
75
75
  list_1.ListItemNode,
76
76
  mention_node_1.MentionNode,
77
- // biome-ignore lint/suspicious/noExplicitAny: Lexical node registry typing is narrower than runtime; AutoLinkNode is required for links.
77
+ // Lexical node registry typing is narrower than runtime; AutoLinkNode is required for links.
78
78
  link_1.AutoLinkNode,
79
79
  link_1.LinkNode,
80
80
  ],
@@ -1,6 +1,7 @@
1
1
  import type { IDoc } from "@peers-app/peers-sdk";
2
2
  import type React from "react";
3
3
  export interface ISaveButtonProps {
4
+ /** Used with many concrete table row shapes; IDoc stays generic at call sites. */
4
5
  doc: IDoc<any>;
5
6
  onClick?: () => void | Promise<void>;
6
7
  addActions?: ({
@@ -11,6 +11,8 @@ function setupHost(label) {
11
11
  connectionCount: 0,
12
12
  syncingContextCount: 0,
13
13
  errorCount: 0,
14
+ disconnectedContextCount: 0,
15
+ connectedContextCount: 0,
14
16
  errors: [],
15
17
  };
16
18
  }
@@ -1,6 +1,6 @@
1
1
  import { type INetworkStatusViewState } from "./use-network-status";
2
2
  /** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
3
- export declare const SYNCING_GREEN_MS = 2000;
3
+ export declare const SYNCING_GREEN_MS = 4000;
4
4
  /** How long status must stay fully synced before leaving the syncing visual. */
5
5
  export declare const SYNCED_STABLE_MS = 1000;
6
6
  type NetworkStatusMark = "solid" | "pulse" | "indeterminate";
@@ -15,6 +15,21 @@ interface INetworkStatusPresentation {
15
15
  compactIcon?: boolean;
16
16
  /** Solid / pulsing / indeterminate mark (syncing / error). */
17
17
  mark?: NetworkStatusMark;
18
+ /**
19
+ * Bottom-right count of sync contexts that have known peers but no live connection.
20
+ * Warning/yellow when shown; hidden when offline or count is 0.
21
+ */
22
+ disconnectedGroupsOverlay?: {
23
+ count: number;
24
+ };
25
+ /**
26
+ * Top-right connected-groups count when the device has active connections.
27
+ * Green when > 0; red zero when connected devices exist but no groups are live.
28
+ */
29
+ connectedGroupsOverlay?: {
30
+ count: number;
31
+ colorClass: "text-success" | "text-danger";
32
+ };
18
33
  }
19
34
  /** Convert host availability and network state into accessible indicator text and styling. */
20
35
  export declare function getNetworkStatusPresentation(viewState: INetworkStatusViewState, opts?: {
@@ -16,12 +16,30 @@ const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-i
16
16
  const tabs_state_1 = require("./tabs-state");
17
17
  const use_network_status_1 = require("./use-network-status");
18
18
  /** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
19
- exports.SYNCING_GREEN_MS = 2_000;
19
+ exports.SYNCING_GREEN_MS = 4_000;
20
20
  /** How long status must stay fully synced before leaving the syncing visual. */
21
21
  exports.SYNCED_STABLE_MS = 1_000;
22
- /** Slightly slower than Bootstrap's default spinner-grow (~0.75s). */
23
- const PULSE_ANIMATION_DURATION = "1.0s";
22
+ /**
23
+ * Calm opacity pulse (same feel as kube-tools WatchButton) — not Bootstrap
24
+ * spinner-grow, which scales the mark and reads as noisy in the tabs strip.
25
+ */
26
+ const PULSE_ANIMATION = "peers-network-pulse-dot 2s ease-in-out infinite";
24
27
  const DOT_SIZE = "0.7rem";
28
+ function disconnectedLabelSuffix(disconnectedContextCount) {
29
+ return `; ${disconnectedContextCount} ${disconnectedContextCount === 1 ? "group" : "groups"} without a connection`;
30
+ }
31
+ function connectedLabelSuffix(connectedContextCount) {
32
+ return `; ${connectedContextCount} ${connectedContextCount === 1 ? "group" : "groups"} connected`;
33
+ }
34
+ function connectedGroupsOverlay(connectionCount, connectedContextCount) {
35
+ if (connectionCount <= 0) {
36
+ return undefined;
37
+ }
38
+ return {
39
+ count: connectedContextCount,
40
+ colorClass: connectedContextCount > 0 ? "text-success" : "text-danger",
41
+ };
42
+ }
25
43
  /** Convert host availability and network state into accessible indicator text and styling. */
26
44
  function getNetworkStatusPresentation(viewState, opts) {
27
45
  if (viewState.availability === "loading") {
@@ -41,13 +59,23 @@ function getNetworkStatusPresentation(viewState, opts) {
41
59
  colorClass: "text-secondary",
42
60
  };
43
61
  }
44
- const { connectionCount, errorCount, state, syncingContextCount } = viewState.status;
62
+ const { connectedContextCount, connectionCount, disconnectedContextCount, errorCount, state, syncingContextCount, } = viewState.status;
63
+ const disconnectedOverlay = disconnectedContextCount > 0 && state !== "offline"
64
+ ? { count: disconnectedContextCount }
65
+ : undefined;
66
+ const disconnectedSuffix = disconnectedOverlay
67
+ ? disconnectedLabelSuffix(disconnectedContextCount)
68
+ : "";
69
+ const groupsOverlay = connectedGroupsOverlay(connectionCount, connectedContextCount);
70
+ const connectedSuffix = groupsOverlay ? connectedLabelSuffix(connectedContextCount) : "";
45
71
  if (state === "error") {
46
72
  return {
47
- label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
73
+ label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
48
74
  shortLabel: "Error",
49
75
  colorClass: "text-danger",
50
76
  mark: "solid",
77
+ disconnectedGroupsOverlay: disconnectedOverlay,
78
+ connectedGroupsOverlay: groupsOverlay,
51
79
  };
52
80
  }
53
81
  if (state === "syncing" || opts?.holdSyncingVisual) {
@@ -56,10 +84,12 @@ function getNetworkStatusPresentation(viewState, opts) {
56
84
  ? `${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}`
57
85
  : "recent activity";
58
86
  return {
59
- label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
87
+ label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
60
88
  shortLabel: "Syncing",
61
89
  colorClass: longSync ? "text-primary" : "text-success",
62
90
  mark: longSync ? "indeterminate" : "pulse",
91
+ disconnectedGroupsOverlay: disconnectedOverlay,
92
+ connectedGroupsOverlay: groupsOverlay,
63
93
  };
64
94
  }
65
95
  if (state === "offline") {
@@ -71,29 +101,42 @@ function getNetworkStatusPresentation(viewState, opts) {
71
101
  };
72
102
  }
73
103
  return {
74
- label: `Network synced; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
104
+ label: `Network synced; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
75
105
  shortLabel: "Synced",
76
106
  iconClass: "bi-check-circle-fill",
77
107
  colorClass: "text-success",
78
108
  compactIcon: true,
109
+ disconnectedGroupsOverlay: disconnectedOverlay,
110
+ connectedGroupsOverlay: groupsOverlay,
79
111
  };
80
112
  }
81
- function StatusMark({ mode, colorClass }) {
82
- if (mode === "pulse") {
83
- return ((0, jsx_runtime_1.jsx)("span", { className: `spinner-grow spinner-grow-sm ${colorClass}`, style: {
84
- width: DOT_SIZE,
85
- height: DOT_SIZE,
86
- animationDuration: PULSE_ANIMATION_DURATION,
87
- }, "aria-hidden": "true" }));
113
+ function statusMarkBgClass(colorClass) {
114
+ if (colorClass === "text-danger") {
115
+ return "bg-danger";
116
+ }
117
+ if (colorClass === "text-primary") {
118
+ return "bg-primary";
88
119
  }
120
+ return "bg-success";
121
+ }
122
+ function StatusMark({ mode, colorClass }) {
89
123
  if (mode === "indeterminate") {
90
124
  return ((0, jsx_runtime_1.jsx)("span", { className: `spinner-border spinner-border-sm ${colorClass}`, style: { width: DOT_SIZE, height: DOT_SIZE, borderWidth: "0.12em" }, "aria-hidden": "true" }));
91
125
  }
92
- const bgClass = colorClass === "text-danger"
93
- ? "bg-danger"
94
- : colorClass === "text-primary"
95
- ? "bg-primary"
96
- : "bg-success";
126
+ const bgClass = statusMarkBgClass(colorClass);
127
+ if (mode === "pulse") {
128
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: `network-status-pulse-dot rounded-circle ${bgClass}`, style: {
129
+ width: DOT_SIZE,
130
+ height: DOT_SIZE,
131
+ display: "inline-block",
132
+ animation: PULSE_ANIMATION,
133
+ }, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("style", { children: `
134
+ @keyframes peers-network-pulse-dot {
135
+ 0%, 100% { opacity: 1; }
136
+ 50% { opacity: 0.4; }
137
+ }
138
+ ` })] }));
139
+ }
97
140
  return ((0, jsx_runtime_1.jsx)("span", { className: `rounded-circle ${bgClass}`, style: { width: DOT_SIZE, height: DOT_SIZE, display: "inline-block" }, "aria-hidden": "true" }));
98
141
  }
99
142
  /** Compact relative time for the status popover (always short enough for one line). */
@@ -147,7 +190,7 @@ function ConnectedDevicesList({ loadState }) {
147
190
  return ((0, jsx_runtime_1.jsx)("ul", { className: "small mb-0 ps-0", style: { maxHeight: "180px", overflowY: "auto" }, children: loadState.connections.map((connection) => ((0, jsx_runtime_1.jsx)(ConnectedDeviceRow, { connection: connection }, connection.deviceId))) }));
148
191
  }
149
192
  function NetworkStatusDetails({ detailsId, status, unavailable, connectionsLoadState, onOpenViewer, }) {
150
- return ((0, jsx_runtime_1.jsx)("div", { id: detailsId, role: "dialog", "aria-label": "Network status details", className: "card shadow position-absolute end-0 mt-1", style: { top: "100%", width: "320px", zIndex: 1100 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-3", children: [(0, jsx_runtime_1.jsx)("h6", { className: "card-title mb-2", children: "Network status" }), unavailable || !status ? ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-3", children: "Network diagnostics are temporarily unavailable. The local host may still be starting or reconnecting." })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-2", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Connections" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.connectionCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Syncing contexts" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.syncingContextCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Sync errors" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.errorCount }), status.lastSuccessfulSyncAt != null && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Last sync" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end text-truncate", title: new Date(status.lastSuccessfulSyncAt).toLocaleString(), children: formatRelativeSyncTime(status.lastSuccessfulSyncAt) })] }))] }), status.errors.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger py-2 px-2 small mb-2", children: [status.errors[0].message, status.errors.length > 1 && ` (+${status.errors.length - 1} more)`] })), (0, jsx_runtime_1.jsxs)("div", { className: "border-top pt-2 mt-1 mb-2", children: [(0, jsx_runtime_1.jsx)("div", { className: "small text-muted mb-1", children: "Devices" }), (0, jsx_runtime_1.jsx)(ConnectedDevicesList, { loadState: connectionsLoadState })] })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-primary w-100", onClick: onOpenViewer, children: "Open Network Viewer" })] }) }));
193
+ return ((0, jsx_runtime_1.jsx)("div", { id: detailsId, role: "dialog", "aria-label": "Network status details", className: "card shadow position-absolute end-0 mt-1", style: { top: "100%", width: "320px", zIndex: 1100 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-3", children: [(0, jsx_runtime_1.jsx)("h6", { className: "card-title mb-2", children: "Network status" }), unavailable || !status ? ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-3", children: "Network diagnostics are temporarily unavailable. The local host may still be starting or reconnecting." })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-2", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Connections" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.connectionCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Syncing contexts" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.syncingContextCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Sync errors" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.errorCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Groups connected" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.connectedContextCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Groups offline" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.disconnectedContextCount }), status.lastSuccessfulSyncAt != null && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Last sync" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end text-truncate", title: new Date(status.lastSuccessfulSyncAt).toLocaleString(), children: formatRelativeSyncTime(status.lastSuccessfulSyncAt) })] }))] }), status.errors.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger py-2 px-2 small mb-2", children: [status.errors[0].message, status.errors.length > 1 && ` (+${status.errors.length - 1} more)`] })), (0, jsx_runtime_1.jsxs)("div", { className: "border-top pt-2 mt-1 mb-2", children: [(0, jsx_runtime_1.jsx)("div", { className: "small text-muted mb-1", children: "Devices" }), (0, jsx_runtime_1.jsx)(ConnectedDevicesList, { loadState: connectionsLoadState })] })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-primary w-100", onClick: onOpenViewer, children: "Open Network Viewer" })] }) }));
151
194
  }
152
195
  /** Accessible reactive network status button and details popover. */
153
196
  function NetworkStatusIndicator({ compact = false }) {
@@ -248,9 +291,21 @@ function NetworkStatusIndicator({ compact = false }) {
248
291
  border: "none",
249
292
  background: "transparent",
250
293
  minWidth: compact ? "30px" : undefined,
251
- }, "aria-label": presentation.label, "aria-expanded": open, "aria-controls": detailsId, title: presentation.label, onClick: () => setOpen((current) => !current), children: [presentation.mark ? ((0, jsx_runtime_1.jsx)(StatusMark, { mode: presentation.mark, colorClass: presentation.colorClass })) : ((0, jsx_runtime_1.jsx)("i", { className: `bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`, style: presentation.compactIcon
252
- ? { fontSize: DOT_SIZE, lineHeight: 1, display: "inline-block" }
253
- : undefined, "aria-hidden": "true" })), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onOpenViewer: () => {
294
+ }, "aria-label": presentation.label, "aria-expanded": open, "aria-controls": detailsId, title: presentation.label, onClick: () => setOpen((current) => !current), children: [(0, jsx_runtime_1.jsxs)("span", { className: "position-relative d-inline-flex align-items-center justify-content-center", children: [presentation.mark ? ((0, jsx_runtime_1.jsx)(StatusMark, { mode: presentation.mark, colorClass: presentation.colorClass })) : ((0, jsx_runtime_1.jsx)("i", { className: `bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`, style: presentation.compactIcon
295
+ ? { fontSize: DOT_SIZE, lineHeight: 1, display: "inline-block" }
296
+ : undefined, "aria-hidden": "true" })), presentation.connectedGroupsOverlay && ((0, jsx_runtime_1.jsx)("span", { className: `network-status-connected-count position-absolute ${presentation.connectedGroupsOverlay.colorClass}`, style: {
297
+ fontSize: "0.45rem",
298
+ lineHeight: 1,
299
+ fontWeight: 700,
300
+ right: "-0.22rem",
301
+ top: "-0.32rem",
302
+ }, "aria-hidden": "true", children: presentation.connectedGroupsOverlay.count })), presentation.disconnectedGroupsOverlay && ((0, jsx_runtime_1.jsx)("span", { className: "network-status-disconnected-count text-warning position-absolute", style: {
303
+ fontSize: "0.45rem",
304
+ lineHeight: 1,
305
+ fontWeight: 700,
306
+ right: "-0.28rem",
307
+ bottom: "-0.32rem",
308
+ }, "aria-hidden": "true", children: presentation.disconnectedGroupsOverlay.count }))] }), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onOpenViewer: () => {
254
309
  setOpen(false);
255
310
  (0, tabs_state_1.goToTabPath)("network-viewer");
256
311
  } }))] }));
@@ -30,6 +30,8 @@ function status(overrides) {
30
30
  connectionCount: 2,
31
31
  syncingContextCount: 0,
32
32
  errorCount: 0,
33
+ disconnectedContextCount: 0,
34
+ connectedContextCount: 0,
33
35
  errors: [],
34
36
  ...overrides,
35
37
  };
@@ -132,6 +134,57 @@ describe("getNetworkStatusPresentation", () => {
132
134
  mark: "pulse",
133
135
  });
134
136
  });
137
+ it("flags a disconnected-group count when some groups lack a connection", () => {
138
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
139
+ availability: "ready",
140
+ status: status({ state: "synced", disconnectedContextCount: 2 }),
141
+ })).toMatchObject({
142
+ disconnectedGroupsOverlay: { count: 2 },
143
+ label: expect.stringContaining("2 groups without a connection"),
144
+ });
145
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
146
+ availability: "ready",
147
+ status: status({ state: "offline", connectionCount: 0, disconnectedContextCount: 2 }),
148
+ })).toMatchObject({
149
+ iconClass: "bi-cloud-slash-fill",
150
+ });
151
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
152
+ availability: "ready",
153
+ status: status({ state: "offline", connectionCount: 0, disconnectedContextCount: 2 }),
154
+ }).disconnectedGroupsOverlay).toBeUndefined();
155
+ });
156
+ it("shows a green connected-groups count or red zero when the device is online", () => {
157
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
158
+ availability: "ready",
159
+ status: status({
160
+ state: "synced",
161
+ connectionCount: 2,
162
+ connectedContextCount: 3,
163
+ }),
164
+ })).toMatchObject({
165
+ connectedGroupsOverlay: { count: 3, colorClass: "text-success" },
166
+ label: expect.stringContaining("3 groups connected"),
167
+ });
168
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
169
+ availability: "ready",
170
+ status: status({
171
+ state: "synced",
172
+ connectionCount: 1,
173
+ connectedContextCount: 0,
174
+ }),
175
+ })).toMatchObject({
176
+ connectedGroupsOverlay: { count: 0, colorClass: "text-danger" },
177
+ label: expect.stringContaining("0 groups connected"),
178
+ });
179
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
180
+ availability: "ready",
181
+ status: status({
182
+ state: "offline",
183
+ connectionCount: 0,
184
+ connectedContextCount: 0,
185
+ }),
186
+ }).connectedGroupsOverlay).toBeUndefined();
187
+ });
135
188
  });
136
189
  describe("NetworkStatusIndicator", () => {
137
190
  beforeEach(() => {
@@ -180,7 +233,8 @@ describe("NetworkStatusIndicator", () => {
180
233
  const syncedIcon = view.container.querySelector("i.bi-check-circle-fill");
181
234
  expect(syncedIcon).toBeTruthy();
182
235
  expect(syncedIcon).toHaveStyle({ fontSize: "0.7rem" });
183
- expect(view.container.querySelector(".spinner-grow")).toBeNull();
236
+ expect(view.container.querySelector(".network-status-pulse-dot")).toBeNull();
237
+ expect(view.container.querySelector(".network-status-disconnected-count")).toBeNull();
184
238
  mockViewState = {
185
239
  availability: "ready",
186
240
  status: status({
@@ -192,14 +246,78 @@ describe("NetworkStatusIndicator", () => {
192
246
  view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
193
247
  expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
194
248
  });
195
- it("uses a slower green pulse while syncing", () => {
249
+ it("shows a yellow disconnected-groups count when synced with offline groups", () => {
250
+ mockViewState = {
251
+ availability: "ready",
252
+ status: status({ state: "synced", disconnectedContextCount: 1 }),
253
+ };
254
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
255
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
256
+ const overlay = view.container.querySelector(".network-status-disconnected-count.text-warning");
257
+ expect(overlay?.textContent).toBe("1");
258
+ expect(view.getByRole("button", { name: /1 group without a connection/ })).toBeTruthy();
259
+ });
260
+ it("shows a green connected-groups overlay when groups are live", () => {
261
+ mockViewState = {
262
+ availability: "ready",
263
+ status: status({
264
+ state: "synced",
265
+ connectionCount: 2,
266
+ connectedContextCount: 2,
267
+ }),
268
+ };
269
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
270
+ const overlay = view.container.querySelector(".network-status-connected-count.text-success");
271
+ expect(overlay?.textContent).toBe("2");
272
+ });
273
+ it("shows a red zero when the device is connected but no groups are live", () => {
274
+ mockViewState = {
275
+ availability: "ready",
276
+ status: status({
277
+ state: "synced",
278
+ connectionCount: 1,
279
+ connectedContextCount: 0,
280
+ }),
281
+ };
282
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
283
+ const overlay = view.container.querySelector(".network-status-connected-count.text-danger");
284
+ expect(overlay?.textContent).toBe("0");
285
+ });
286
+ it("hides the connected-groups overlay when fully offline", () => {
287
+ mockViewState = {
288
+ availability: "ready",
289
+ status: status({
290
+ state: "offline",
291
+ connectionCount: 0,
292
+ connectedContextCount: 0,
293
+ }),
294
+ };
295
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
296
+ expect(view.container.querySelector(".network-status-connected-count")).toBeNull();
297
+ });
298
+ it("does not show the disconnected-groups count when fully offline", () => {
299
+ mockViewState = {
300
+ availability: "ready",
301
+ status: status({
302
+ state: "offline",
303
+ connectionCount: 0,
304
+ disconnectedContextCount: 3,
305
+ }),
306
+ };
307
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
308
+ expect(view.container.querySelector("i.bi-cloud-slash-fill")).toBeTruthy();
309
+ expect(view.container.querySelector(".network-status-disconnected-count")).toBeNull();
310
+ });
311
+ it("uses a calm opacity pulse while syncing", () => {
196
312
  mockViewState = {
197
313
  availability: "ready",
198
314
  status: status({ state: "syncing", syncingContextCount: 1 }),
199
315
  };
200
316
  const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
201
- const pulse = view.container.querySelector(".spinner-grow.text-success");
202
- expect(pulse).toHaveStyle({ animationDuration: "1.0s" });
317
+ const pulse = view.container.querySelector(".network-status-pulse-dot.bg-success");
318
+ expect(pulse).toHaveStyle({
319
+ animation: "peers-network-pulse-dot 2s ease-in-out infinite",
320
+ });
203
321
  });
204
322
  it("pulses green while syncing, then an indeterminate circle after the green window", () => {
205
323
  jest.useFakeTimers();
@@ -208,12 +326,12 @@ describe("NetworkStatusIndicator", () => {
208
326
  status: status({ state: "syncing", syncingContextCount: 1 }),
209
327
  };
210
328
  const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
211
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
329
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
212
330
  (0, react_1.act)(() => {
213
331
  jest.advanceTimersByTime(network_status_indicator_1.SYNCING_GREEN_MS);
214
332
  });
215
333
  expect(view.container.querySelector(".spinner-border.text-primary")).toBeTruthy();
216
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeNull();
334
+ expect(view.container.querySelector(".network-status-pulse-dot")).toBeNull();
217
335
  });
218
336
  it("keeps the green syncing pulse through brief synced flaps until synced is stable", () => {
219
337
  jest.useFakeTimers();
@@ -222,27 +340,27 @@ describe("NetworkStatusIndicator", () => {
222
340
  status: status({ state: "syncing", syncingContextCount: 1 }),
223
341
  };
224
342
  const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
225
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
343
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
226
344
  mockViewState = { availability: "ready", status: status({ state: "synced" }) };
227
345
  view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
228
346
  (0, react_1.act)(() => {
229
347
  jest.advanceTimersByTime(network_status_indicator_1.SYNCED_STABLE_MS - 1);
230
348
  });
231
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
349
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
232
350
  expect(view.container.querySelector("i.bi-check-circle-fill")).toBeNull();
233
351
  mockViewState = {
234
352
  availability: "ready",
235
353
  status: status({ state: "syncing", syncingContextCount: 1 }),
236
354
  };
237
355
  view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
238
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
356
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
239
357
  mockViewState = { availability: "ready", status: status({ state: "synced" }) };
240
358
  view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
241
359
  (0, react_1.act)(() => {
242
360
  jest.advanceTimersByTime(network_status_indicator_1.SYNCED_STABLE_MS);
243
361
  });
244
362
  expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
245
- expect(view.container.querySelector(".spinner-grow")).toBeNull();
363
+ expect(view.container.querySelector(".network-status-pulse-dot")).toBeNull();
246
364
  });
247
365
  it("lists connected devices in the details popover", async () => {
248
366
  getConnections.mockResolvedValue({
@@ -14,6 +14,8 @@ function makeStatus(overrides = {}) {
14
14
  connectionCount: 0,
15
15
  syncingContextCount: 0,
16
16
  errorCount: 0,
17
+ disconnectedContextCount: 0,
18
+ connectedContextCount: 0,
17
19
  errors: [],
18
20
  ...overrides,
19
21
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.21.5",
3
+ "version": "0.21.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.21.5",
31
+ "@peers-app/peers-sdk": "^0.21.6",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-react": "^7.24.1",
40
40
  "@babel/preset-typescript": "^7.27.1",
41
41
  "@biomejs/biome": "2.3.11",
42
- "@peers-app/peers-sdk": "0.21.5",
42
+ "@peers-app/peers-sdk": "0.21.6",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -54,7 +54,6 @@ export const IOSchemaValue = (props: {
54
54
 
55
55
  const [valueObs] = useState(() => {
56
56
  // Field values are heterogeneous (string | number | boolean | Date | …) at runtime.
57
- // biome-ignore lint/suspicious/noExplicitAny: dynamic IO schema binding
58
57
  const obs = observable<any | undefined>(values[field.name] ?? "");
59
58
  return obs;
60
59
  });
@@ -35,7 +35,7 @@ const editorConfig = {
35
35
  ListNode,
36
36
  ListItemNode,
37
37
  MentionNode,
38
- // biome-ignore lint/suspicious/noExplicitAny: Lexical node registry typing is narrower than runtime; AutoLinkNode is required for links.
38
+ // Lexical node registry typing is narrower than runtime; AutoLinkNode is required for links.
39
39
  AutoLinkNode as any,
40
40
  LinkNode,
41
41
  ],
@@ -4,7 +4,7 @@ import { isDesktop, mainContentPath } from "../globals";
4
4
  import { useObservable } from "../hooks";
5
5
 
6
6
  export interface ISaveButtonProps {
7
- // biome-ignore lint/suspicious/noExplicitAny: SaveButton is used with many concrete table row shapes; IDoc stays generic at call sites.
7
+ /** Used with many concrete table row shapes; IDoc stays generic at call sites. */
8
8
  doc: IDoc<any>;
9
9
  onClick?: () => void | Promise<void>;
10
10
  addActions?: (
@@ -15,6 +15,8 @@ function setupHost(label: string) {
15
15
  connectionCount: 0,
16
16
  syncingContextCount: 0,
17
17
  errorCount: 0,
18
+ disconnectedContextCount: 0,
19
+ connectedContextCount: 0,
18
20
  errors: [],
19
21
  };
20
22
  }
@@ -39,6 +39,8 @@ function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummar
39
39
  connectionCount: 2,
40
40
  syncingContextCount: 0,
41
41
  errorCount: 0,
42
+ disconnectedContextCount: 0,
43
+ connectedContextCount: 0,
42
44
  errors: [],
43
45
  ...overrides,
44
46
  };
@@ -162,6 +164,71 @@ describe("getNetworkStatusPresentation", () => {
162
164
  mark: "pulse",
163
165
  });
164
166
  });
167
+
168
+ it("flags a disconnected-group count when some groups lack a connection", () => {
169
+ expect(
170
+ getNetworkStatusPresentation({
171
+ availability: "ready",
172
+ status: status({ state: "synced", disconnectedContextCount: 2 }),
173
+ }),
174
+ ).toMatchObject({
175
+ disconnectedGroupsOverlay: { count: 2 },
176
+ label: expect.stringContaining("2 groups without a connection"),
177
+ });
178
+ expect(
179
+ getNetworkStatusPresentation({
180
+ availability: "ready",
181
+ status: status({ state: "offline", connectionCount: 0, disconnectedContextCount: 2 }),
182
+ }),
183
+ ).toMatchObject({
184
+ iconClass: "bi-cloud-slash-fill",
185
+ });
186
+ expect(
187
+ getNetworkStatusPresentation({
188
+ availability: "ready",
189
+ status: status({ state: "offline", connectionCount: 0, disconnectedContextCount: 2 }),
190
+ }).disconnectedGroupsOverlay,
191
+ ).toBeUndefined();
192
+ });
193
+
194
+ it("shows a green connected-groups count or red zero when the device is online", () => {
195
+ expect(
196
+ getNetworkStatusPresentation({
197
+ availability: "ready",
198
+ status: status({
199
+ state: "synced",
200
+ connectionCount: 2,
201
+ connectedContextCount: 3,
202
+ }),
203
+ }),
204
+ ).toMatchObject({
205
+ connectedGroupsOverlay: { count: 3, colorClass: "text-success" },
206
+ label: expect.stringContaining("3 groups connected"),
207
+ });
208
+ expect(
209
+ getNetworkStatusPresentation({
210
+ availability: "ready",
211
+ status: status({
212
+ state: "synced",
213
+ connectionCount: 1,
214
+ connectedContextCount: 0,
215
+ }),
216
+ }),
217
+ ).toMatchObject({
218
+ connectedGroupsOverlay: { count: 0, colorClass: "text-danger" },
219
+ label: expect.stringContaining("0 groups connected"),
220
+ });
221
+ expect(
222
+ getNetworkStatusPresentation({
223
+ availability: "ready",
224
+ status: status({
225
+ state: "offline",
226
+ connectionCount: 0,
227
+ connectedContextCount: 0,
228
+ }),
229
+ }).connectedGroupsOverlay,
230
+ ).toBeUndefined();
231
+ });
165
232
  });
166
233
 
167
234
  describe("NetworkStatusIndicator", () => {
@@ -216,7 +283,8 @@ describe("NetworkStatusIndicator", () => {
216
283
  const syncedIcon = view.container.querySelector("i.bi-check-circle-fill");
217
284
  expect(syncedIcon).toBeTruthy();
218
285
  expect(syncedIcon).toHaveStyle({ fontSize: "0.7rem" });
219
- expect(view.container.querySelector(".spinner-grow")).toBeNull();
286
+ expect(view.container.querySelector(".network-status-pulse-dot")).toBeNull();
287
+ expect(view.container.querySelector(".network-status-disconnected-count")).toBeNull();
220
288
 
221
289
  mockViewState = {
222
290
  availability: "ready",
@@ -230,14 +298,83 @@ describe("NetworkStatusIndicator", () => {
230
298
  expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
231
299
  });
232
300
 
233
- it("uses a slower green pulse while syncing", () => {
301
+ it("shows a yellow disconnected-groups count when synced with offline groups", () => {
302
+ mockViewState = {
303
+ availability: "ready",
304
+ status: status({ state: "synced", disconnectedContextCount: 1 }),
305
+ };
306
+ const view = render(<NetworkStatusIndicator />);
307
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
308
+ const overlay = view.container.querySelector(".network-status-disconnected-count.text-warning");
309
+ expect(overlay?.textContent).toBe("1");
310
+ expect(view.getByRole("button", { name: /1 group without a connection/ })).toBeTruthy();
311
+ });
312
+
313
+ it("shows a green connected-groups overlay when groups are live", () => {
314
+ mockViewState = {
315
+ availability: "ready",
316
+ status: status({
317
+ state: "synced",
318
+ connectionCount: 2,
319
+ connectedContextCount: 2,
320
+ }),
321
+ };
322
+ const view = render(<NetworkStatusIndicator />);
323
+ const overlay = view.container.querySelector(".network-status-connected-count.text-success");
324
+ expect(overlay?.textContent).toBe("2");
325
+ });
326
+
327
+ it("shows a red zero when the device is connected but no groups are live", () => {
328
+ mockViewState = {
329
+ availability: "ready",
330
+ status: status({
331
+ state: "synced",
332
+ connectionCount: 1,
333
+ connectedContextCount: 0,
334
+ }),
335
+ };
336
+ const view = render(<NetworkStatusIndicator />);
337
+ const overlay = view.container.querySelector(".network-status-connected-count.text-danger");
338
+ expect(overlay?.textContent).toBe("0");
339
+ });
340
+
341
+ it("hides the connected-groups overlay when fully offline", () => {
342
+ mockViewState = {
343
+ availability: "ready",
344
+ status: status({
345
+ state: "offline",
346
+ connectionCount: 0,
347
+ connectedContextCount: 0,
348
+ }),
349
+ };
350
+ const view = render(<NetworkStatusIndicator />);
351
+ expect(view.container.querySelector(".network-status-connected-count")).toBeNull();
352
+ });
353
+
354
+ it("does not show the disconnected-groups count when fully offline", () => {
355
+ mockViewState = {
356
+ availability: "ready",
357
+ status: status({
358
+ state: "offline",
359
+ connectionCount: 0,
360
+ disconnectedContextCount: 3,
361
+ }),
362
+ };
363
+ const view = render(<NetworkStatusIndicator />);
364
+ expect(view.container.querySelector("i.bi-cloud-slash-fill")).toBeTruthy();
365
+ expect(view.container.querySelector(".network-status-disconnected-count")).toBeNull();
366
+ });
367
+
368
+ it("uses a calm opacity pulse while syncing", () => {
234
369
  mockViewState = {
235
370
  availability: "ready",
236
371
  status: status({ state: "syncing", syncingContextCount: 1 }),
237
372
  };
238
373
  const view = render(<NetworkStatusIndicator />);
239
- const pulse = view.container.querySelector(".spinner-grow.text-success");
240
- expect(pulse).toHaveStyle({ animationDuration: "1.0s" });
374
+ const pulse = view.container.querySelector(".network-status-pulse-dot.bg-success");
375
+ expect(pulse).toHaveStyle({
376
+ animation: "peers-network-pulse-dot 2s ease-in-out infinite",
377
+ });
241
378
  });
242
379
 
243
380
  it("pulses green while syncing, then an indeterminate circle after the green window", () => {
@@ -247,13 +384,13 @@ describe("NetworkStatusIndicator", () => {
247
384
  status: status({ state: "syncing", syncingContextCount: 1 }),
248
385
  };
249
386
  const view = render(<NetworkStatusIndicator />);
250
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
387
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
251
388
 
252
389
  act(() => {
253
390
  jest.advanceTimersByTime(SYNCING_GREEN_MS);
254
391
  });
255
392
  expect(view.container.querySelector(".spinner-border.text-primary")).toBeTruthy();
256
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeNull();
393
+ expect(view.container.querySelector(".network-status-pulse-dot")).toBeNull();
257
394
  });
258
395
 
259
396
  it("keeps the green syncing pulse through brief synced flaps until synced is stable", () => {
@@ -263,14 +400,14 @@ describe("NetworkStatusIndicator", () => {
263
400
  status: status({ state: "syncing", syncingContextCount: 1 }),
264
401
  };
265
402
  const view = render(<NetworkStatusIndicator />);
266
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
403
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
267
404
 
268
405
  mockViewState = { availability: "ready", status: status({ state: "synced" }) };
269
406
  view.rerender(<NetworkStatusIndicator />);
270
407
  act(() => {
271
408
  jest.advanceTimersByTime(SYNCED_STABLE_MS - 1);
272
409
  });
273
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
410
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
274
411
  expect(view.container.querySelector("i.bi-check-circle-fill")).toBeNull();
275
412
 
276
413
  mockViewState = {
@@ -278,7 +415,7 @@ describe("NetworkStatusIndicator", () => {
278
415
  status: status({ state: "syncing", syncingContextCount: 1 }),
279
416
  };
280
417
  view.rerender(<NetworkStatusIndicator />);
281
- expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
418
+ expect(view.container.querySelector(".network-status-pulse-dot.bg-success")).toBeTruthy();
282
419
 
283
420
  mockViewState = { availability: "ready", status: status({ state: "synced" }) };
284
421
  view.rerender(<NetworkStatusIndicator />);
@@ -286,7 +423,7 @@ describe("NetworkStatusIndicator", () => {
286
423
  jest.advanceTimersByTime(SYNCED_STABLE_MS);
287
424
  });
288
425
  expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
289
- expect(view.container.querySelector(".spinner-grow")).toBeNull();
426
+ expect(view.container.querySelector(".network-status-pulse-dot")).toBeNull();
290
427
  });
291
428
 
292
429
  it("lists connected devices in the details popover", async () => {
@@ -6,13 +6,16 @@ import { goToTabPath } from "./tabs-state";
6
6
  import { type INetworkStatusViewState, useNetworkStatus } from "./use-network-status";
7
7
 
8
8
  /** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
9
- export const SYNCING_GREEN_MS = 2_000;
9
+ export const SYNCING_GREEN_MS = 4_000;
10
10
 
11
11
  /** How long status must stay fully synced before leaving the syncing visual. */
12
12
  export const SYNCED_STABLE_MS = 1_000;
13
13
 
14
- /** Slightly slower than Bootstrap's default spinner-grow (~0.75s). */
15
- const PULSE_ANIMATION_DURATION = "1.0s";
14
+ /**
15
+ * Calm opacity pulse (same feel as kube-tools WatchButton) — not Bootstrap
16
+ * spinner-grow, which scales the mark and reads as noisy in the tabs strip.
17
+ */
18
+ const PULSE_ANIMATION = "peers-network-pulse-dot 2s ease-in-out infinite";
16
19
 
17
20
  const DOT_SIZE = "0.7rem";
18
21
 
@@ -35,6 +38,37 @@ interface INetworkStatusPresentation {
35
38
  compactIcon?: boolean;
36
39
  /** Solid / pulsing / indeterminate mark (syncing / error). */
37
40
  mark?: NetworkStatusMark;
41
+ /**
42
+ * Bottom-right count of sync contexts that have known peers but no live connection.
43
+ * Warning/yellow when shown; hidden when offline or count is 0.
44
+ */
45
+ disconnectedGroupsOverlay?: { count: number };
46
+ /**
47
+ * Top-right connected-groups count when the device has active connections.
48
+ * Green when > 0; red zero when connected devices exist but no groups are live.
49
+ */
50
+ connectedGroupsOverlay?: { count: number; colorClass: "text-success" | "text-danger" };
51
+ }
52
+
53
+ function disconnectedLabelSuffix(disconnectedContextCount: number): string {
54
+ return `; ${disconnectedContextCount} ${disconnectedContextCount === 1 ? "group" : "groups"} without a connection`;
55
+ }
56
+
57
+ function connectedLabelSuffix(connectedContextCount: number): string {
58
+ return `; ${connectedContextCount} ${connectedContextCount === 1 ? "group" : "groups"} connected`;
59
+ }
60
+
61
+ function connectedGroupsOverlay(
62
+ connectionCount: number,
63
+ connectedContextCount: number,
64
+ ): INetworkStatusPresentation["connectedGroupsOverlay"] {
65
+ if (connectionCount <= 0) {
66
+ return undefined;
67
+ }
68
+ return {
69
+ count: connectedContextCount,
70
+ colorClass: connectedContextCount > 0 ? "text-success" : "text-danger",
71
+ };
38
72
  }
39
73
 
40
74
  /** Convert host availability and network state into accessible indicator text and styling. */
@@ -63,13 +97,31 @@ export function getNetworkStatusPresentation(
63
97
  colorClass: "text-secondary",
64
98
  };
65
99
  }
66
- const { connectionCount, errorCount, state, syncingContextCount } = viewState.status;
100
+ const {
101
+ connectedContextCount,
102
+ connectionCount,
103
+ disconnectedContextCount,
104
+ errorCount,
105
+ state,
106
+ syncingContextCount,
107
+ } = viewState.status;
108
+ const disconnectedOverlay =
109
+ disconnectedContextCount > 0 && state !== "offline"
110
+ ? { count: disconnectedContextCount }
111
+ : undefined;
112
+ const disconnectedSuffix = disconnectedOverlay
113
+ ? disconnectedLabelSuffix(disconnectedContextCount)
114
+ : "";
115
+ const groupsOverlay = connectedGroupsOverlay(connectionCount, connectedContextCount);
116
+ const connectedSuffix = groupsOverlay ? connectedLabelSuffix(connectedContextCount) : "";
67
117
  if (state === "error") {
68
118
  return {
69
- label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
119
+ label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
70
120
  shortLabel: "Error",
71
121
  colorClass: "text-danger",
72
122
  mark: "solid",
123
+ disconnectedGroupsOverlay: disconnectedOverlay,
124
+ connectedGroupsOverlay: groupsOverlay,
73
125
  };
74
126
  }
75
127
  if (state === "syncing" || opts?.holdSyncingVisual) {
@@ -79,10 +131,12 @@ export function getNetworkStatusPresentation(
79
131
  ? `${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}`
80
132
  : "recent activity";
81
133
  return {
82
- label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
134
+ label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
83
135
  shortLabel: "Syncing",
84
136
  colorClass: longSync ? "text-primary" : "text-success",
85
137
  mark: longSync ? "indeterminate" : "pulse",
138
+ disconnectedGroupsOverlay: disconnectedOverlay,
139
+ connectedGroupsOverlay: groupsOverlay,
86
140
  };
87
141
  }
88
142
  if (state === "offline") {
@@ -94,28 +148,27 @@ export function getNetworkStatusPresentation(
94
148
  };
95
149
  }
96
150
  return {
97
- label: `Network synced; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
151
+ label: `Network synced; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}${connectedSuffix}${disconnectedSuffix}`,
98
152
  shortLabel: "Synced",
99
153
  iconClass: "bi-check-circle-fill",
100
154
  colorClass: "text-success",
101
155
  compactIcon: true,
156
+ disconnectedGroupsOverlay: disconnectedOverlay,
157
+ connectedGroupsOverlay: groupsOverlay,
102
158
  };
103
159
  }
104
160
 
105
- function StatusMark({ mode, colorClass }: { mode: NetworkStatusMark; colorClass: string }) {
106
- if (mode === "pulse") {
107
- return (
108
- <span
109
- className={`spinner-grow spinner-grow-sm ${colorClass}`}
110
- style={{
111
- width: DOT_SIZE,
112
- height: DOT_SIZE,
113
- animationDuration: PULSE_ANIMATION_DURATION,
114
- }}
115
- aria-hidden="true"
116
- />
117
- );
161
+ function statusMarkBgClass(colorClass: string): string {
162
+ if (colorClass === "text-danger") {
163
+ return "bg-danger";
164
+ }
165
+ if (colorClass === "text-primary") {
166
+ return "bg-primary";
118
167
  }
168
+ return "bg-success";
169
+ }
170
+
171
+ function StatusMark({ mode, colorClass }: { mode: NetworkStatusMark; colorClass: string }) {
119
172
  if (mode === "indeterminate") {
120
173
  return (
121
174
  <span
@@ -125,12 +178,31 @@ function StatusMark({ mode, colorClass }: { mode: NetworkStatusMark; colorClass:
125
178
  />
126
179
  );
127
180
  }
128
- const bgClass =
129
- colorClass === "text-danger"
130
- ? "bg-danger"
131
- : colorClass === "text-primary"
132
- ? "bg-primary"
133
- : "bg-success";
181
+
182
+ const bgClass = statusMarkBgClass(colorClass);
183
+ if (mode === "pulse") {
184
+ return (
185
+ <>
186
+ <span
187
+ className={`network-status-pulse-dot rounded-circle ${bgClass}`}
188
+ style={{
189
+ width: DOT_SIZE,
190
+ height: DOT_SIZE,
191
+ display: "inline-block",
192
+ animation: PULSE_ANIMATION,
193
+ }}
194
+ aria-hidden="true"
195
+ />
196
+ <style>{`
197
+ @keyframes peers-network-pulse-dot {
198
+ 0%, 100% { opacity: 1; }
199
+ 50% { opacity: 0.4; }
200
+ }
201
+ `}</style>
202
+ </>
203
+ );
204
+ }
205
+
134
206
  return (
135
207
  <span
136
208
  className={`rounded-circle ${bgClass}`}
@@ -271,6 +343,10 @@ function NetworkStatusDetails({
271
343
  <dd className="col-5 text-end">{status.syncingContextCount}</dd>
272
344
  <dt className="col-7">Sync errors</dt>
273
345
  <dd className="col-5 text-end">{status.errorCount}</dd>
346
+ <dt className="col-7">Groups connected</dt>
347
+ <dd className="col-5 text-end">{status.connectedContextCount}</dd>
348
+ <dt className="col-7">Groups offline</dt>
349
+ <dd className="col-5 text-end">{status.disconnectedContextCount}</dd>
274
350
  {status.lastSuccessfulSyncAt != null && (
275
351
  <>
276
352
  <dt className="col-7">Last sync</dt>
@@ -425,19 +501,51 @@ export function NetworkStatusIndicator({ compact = false }: { compact?: boolean
425
501
  title={presentation.label}
426
502
  onClick={() => setOpen((current) => !current)}
427
503
  >
428
- {presentation.mark ? (
429
- <StatusMark mode={presentation.mark} colorClass={presentation.colorClass} />
430
- ) : (
431
- <i
432
- className={`bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`}
433
- style={
434
- presentation.compactIcon
435
- ? { fontSize: DOT_SIZE, lineHeight: 1, display: "inline-block" }
436
- : undefined
437
- }
438
- aria-hidden="true"
439
- />
440
- )}
504
+ <span className="position-relative d-inline-flex align-items-center justify-content-center">
505
+ {presentation.mark ? (
506
+ <StatusMark mode={presentation.mark} colorClass={presentation.colorClass} />
507
+ ) : (
508
+ <i
509
+ className={`bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`}
510
+ style={
511
+ presentation.compactIcon
512
+ ? { fontSize: DOT_SIZE, lineHeight: 1, display: "inline-block" }
513
+ : undefined
514
+ }
515
+ aria-hidden="true"
516
+ />
517
+ )}
518
+ {presentation.connectedGroupsOverlay && (
519
+ <span
520
+ className={`network-status-connected-count position-absolute ${presentation.connectedGroupsOverlay.colorClass}`}
521
+ style={{
522
+ fontSize: "0.45rem",
523
+ lineHeight: 1,
524
+ fontWeight: 700,
525
+ right: "-0.22rem",
526
+ top: "-0.32rem",
527
+ }}
528
+ aria-hidden="true"
529
+ >
530
+ {presentation.connectedGroupsOverlay.count}
531
+ </span>
532
+ )}
533
+ {presentation.disconnectedGroupsOverlay && (
534
+ <span
535
+ className="network-status-disconnected-count text-warning position-absolute"
536
+ style={{
537
+ fontSize: "0.45rem",
538
+ lineHeight: 1,
539
+ fontWeight: 700,
540
+ right: "-0.28rem",
541
+ bottom: "-0.32rem",
542
+ }}
543
+ aria-hidden="true"
544
+ >
545
+ {presentation.disconnectedGroupsOverlay.count}
546
+ </span>
547
+ )}
548
+ </span>
441
549
  {!compact && (
442
550
  <span className="small">
443
551
  {viewState.status?.connectionCount ?? 0}
@@ -23,6 +23,8 @@ function makeStatus(overrides: Partial<INetworkStatusSummary> = {}): INetworkSta
23
23
  connectionCount: 0,
24
24
  syncingContextCount: 0,
25
25
  errorCount: 0,
26
+ disconnectedContextCount: 0,
27
+ connectedContextCount: 0,
26
28
  errors: [],
27
29
  ...overrides,
28
30
  };
@@ -597,7 +597,6 @@ export const peersSystemPackageId = "000peers0system000package";
597
597
  export async function registerInternalPeersUI(props: {
598
598
  component: IPeersUI["content"];
599
599
  /** Zod schemas may use a different zod instance than peers-sdk (duplicate resolution); keep loose. */
600
- // biome-ignore lint/suspicious/noExplicitAny: see JSDoc
601
600
  propsSchema?: any;
602
601
  routes?: Omit<IPeersUIRoute, "peersUIId" | "packageId">[];
603
602
  peersUIId?: string;