@peers-app/peers-ui 0.21.3 → 0.21.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tabs-layout/network-status-indicator.d.ts +24 -2
- package/dist/tabs-layout/network-status-indicator.js +167 -11
- package/dist/tabs-layout/network-status-indicator.test.js +253 -1
- package/package.json +3 -3
- package/src/tabs-layout/network-status-indicator.test.tsx +301 -4
- package/src/tabs-layout/network-status-indicator.tsx +286 -20
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
import { type INetworkStatusViewState } from "./use-network-status";
|
|
2
|
+
/** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
|
|
3
|
+
export declare const SYNCING_GREEN_MS = 2000;
|
|
4
|
+
/** How long status must stay fully synced before leaving the syncing visual. */
|
|
5
|
+
export declare const SYNCED_STABLE_MS = 1000;
|
|
6
|
+
type NetworkStatusMark = "solid" | "pulse" | "indeterminate";
|
|
2
7
|
interface INetworkStatusPresentation {
|
|
3
8
|
label: string;
|
|
4
9
|
shortLabel: string;
|
|
5
|
-
iconClass: string;
|
|
6
10
|
colorClass: string;
|
|
11
|
+
/** Bootstrap Icons class when not using a status mark. */
|
|
12
|
+
iconClass?: string;
|
|
7
13
|
spinning?: boolean;
|
|
14
|
+
/** Size the Bootstrap icon to match the status marks. */
|
|
15
|
+
compactIcon?: boolean;
|
|
16
|
+
/** Solid / pulsing / indeterminate mark (syncing / error). */
|
|
17
|
+
mark?: NetworkStatusMark;
|
|
8
18
|
}
|
|
9
19
|
/** Convert host availability and network state into accessible indicator text and styling. */
|
|
10
|
-
export declare function getNetworkStatusPresentation(viewState: INetworkStatusViewState
|
|
20
|
+
export declare function getNetworkStatusPresentation(viewState: INetworkStatusViewState, opts?: {
|
|
21
|
+
syncingLongerThanGreenMs?: boolean;
|
|
22
|
+
/** Keep the calm green syncing pulse while waiting for stable synced. */
|
|
23
|
+
holdSyncingVisual?: boolean;
|
|
24
|
+
}): INetworkStatusPresentation;
|
|
25
|
+
/** Compact relative time for the status popover (always short enough for one line). */
|
|
26
|
+
export declare function formatRelativeSyncTime(timestampMs: number, nowMs?: number): string;
|
|
27
|
+
/** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
|
|
28
|
+
export declare function connectionIdentityLabel(name: string | undefined, id: string): {
|
|
29
|
+
text: string;
|
|
30
|
+
isId: boolean;
|
|
31
|
+
title: string;
|
|
32
|
+
};
|
|
11
33
|
/** Accessible reactive network status button and details popover. */
|
|
12
34
|
export declare function NetworkStatusIndicator({ compact }: {
|
|
13
35
|
compact?: boolean;
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SYNCED_STABLE_MS = exports.SYNCING_GREEN_MS = void 0;
|
|
3
7
|
exports.getNetworkStatusPresentation = getNetworkStatusPresentation;
|
|
8
|
+
exports.formatRelativeSyncTime = formatRelativeSyncTime;
|
|
9
|
+
exports.connectionIdentityLabel = connectionIdentityLabel;
|
|
4
10
|
exports.NetworkStatusIndicator = NetworkStatusIndicator;
|
|
5
11
|
exports.TabsStripIndicators = TabsStripIndicators;
|
|
6
12
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
13
|
+
const moment_1 = __importDefault(require("moment"));
|
|
7
14
|
const react_1 = require("react");
|
|
15
|
+
const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-ipc");
|
|
8
16
|
const tabs_state_1 = require("./tabs-state");
|
|
9
17
|
const use_network_status_1 = require("./use-network-status");
|
|
18
|
+
/** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
|
|
19
|
+
exports.SYNCING_GREEN_MS = 2_000;
|
|
20
|
+
/** How long status must stay fully synced before leaving the syncing visual. */
|
|
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";
|
|
24
|
+
const DOT_SIZE = "0.7rem";
|
|
10
25
|
/** Convert host availability and network state into accessible indicator text and styling. */
|
|
11
|
-
function getNetworkStatusPresentation(viewState) {
|
|
26
|
+
function getNetworkStatusPresentation(viewState, opts) {
|
|
12
27
|
if (viewState.availability === "loading") {
|
|
13
28
|
return {
|
|
14
29
|
label: "Network status loading",
|
|
@@ -31,17 +46,20 @@ function getNetworkStatusPresentation(viewState) {
|
|
|
31
46
|
return {
|
|
32
47
|
label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
|
|
33
48
|
shortLabel: "Error",
|
|
34
|
-
iconClass: "bi-exclamation-triangle-fill",
|
|
35
49
|
colorClass: "text-danger",
|
|
50
|
+
mark: "solid",
|
|
36
51
|
};
|
|
37
52
|
}
|
|
38
|
-
if (state === "syncing") {
|
|
53
|
+
if (state === "syncing" || opts?.holdSyncingVisual) {
|
|
54
|
+
const longSync = state === "syncing" && opts?.syncingLongerThanGreenMs === true;
|
|
55
|
+
const contextsLabel = state === "syncing"
|
|
56
|
+
? `${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}`
|
|
57
|
+
: "recent activity";
|
|
39
58
|
return {
|
|
40
|
-
label: `Network syncing ${
|
|
59
|
+
label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
|
|
41
60
|
shortLabel: "Syncing",
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
spinning: true,
|
|
61
|
+
colorClass: longSync ? "text-primary" : "text-success",
|
|
62
|
+
mark: longSync ? "indeterminate" : "pulse",
|
|
45
63
|
};
|
|
46
64
|
}
|
|
47
65
|
if (state === "offline") {
|
|
@@ -57,18 +75,154 @@ function getNetworkStatusPresentation(viewState) {
|
|
|
57
75
|
shortLabel: "Synced",
|
|
58
76
|
iconClass: "bi-check-circle-fill",
|
|
59
77
|
colorClass: "text-success",
|
|
78
|
+
compactIcon: true,
|
|
60
79
|
};
|
|
61
80
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
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" }));
|
|
88
|
+
}
|
|
89
|
+
if (mode === "indeterminate") {
|
|
90
|
+
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
|
+
}
|
|
92
|
+
const bgClass = colorClass === "text-danger"
|
|
93
|
+
? "bg-danger"
|
|
94
|
+
: colorClass === "text-primary"
|
|
95
|
+
? "bg-primary"
|
|
96
|
+
: "bg-success";
|
|
97
|
+
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
|
+
}
|
|
99
|
+
/** Compact relative time for the status popover (always short enough for one line). */
|
|
100
|
+
function formatRelativeSyncTime(timestampMs, nowMs = Date.now()) {
|
|
101
|
+
const then = (0, moment_1.default)(timestampMs);
|
|
102
|
+
const now = (0, moment_1.default)(nowMs);
|
|
103
|
+
if (now.diff(then, "seconds") < 45) {
|
|
104
|
+
return "now";
|
|
105
|
+
}
|
|
106
|
+
if (now.clone().subtract(1, "day").isSame(then, "day")) {
|
|
107
|
+
return "yesterday";
|
|
108
|
+
}
|
|
109
|
+
return then.from(now);
|
|
110
|
+
}
|
|
111
|
+
function middleEllipsis(value, head = 4, tail = 4) {
|
|
112
|
+
if (value.length <= head + tail + 1) {
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
|
116
|
+
}
|
|
117
|
+
/** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
|
|
118
|
+
function connectionIdentityLabel(name, id) {
|
|
119
|
+
const trimmed = name?.trim();
|
|
120
|
+
if (trimmed) {
|
|
121
|
+
return { text: trimmed, isId: false, title: trimmed };
|
|
122
|
+
}
|
|
123
|
+
return { text: middleEllipsis(id), isId: true, title: id };
|
|
124
|
+
}
|
|
125
|
+
function IdentityPart({ label }) {
|
|
126
|
+
return ((0, jsx_runtime_1.jsx)("span", { className: `text-truncate ${label.isId ? "text-muted" : ""}`, style: {
|
|
127
|
+
minWidth: 0,
|
|
128
|
+
...(label.isId ? { fontSize: "0.7rem" } : undefined),
|
|
129
|
+
}, title: label.title, children: label.text }));
|
|
130
|
+
}
|
|
131
|
+
function ConnectedDeviceRow({ connection }) {
|
|
132
|
+
const user = connectionIdentityLabel(connection.userName, connection.userId);
|
|
133
|
+
const device = connectionIdentityLabel(connection.deviceName, connection.deviceId);
|
|
134
|
+
const groupCount = connection.sharedGroups.length;
|
|
135
|
+
return ((0, jsx_runtime_1.jsxs)("li", { className: "d-flex align-items-baseline gap-1 py-1", style: { listStyle: "none", minWidth: 0 }, children: [(0, jsx_runtime_1.jsx)(IdentityPart, { label: user }), (0, jsx_runtime_1.jsx)("span", { className: "text-muted flex-shrink-0", "aria-hidden": "true", children: "\u00B7" }), (0, jsx_runtime_1.jsx)(IdentityPart, { label: device }), groupCount > 0 && ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted flex-shrink-0 ms-auto", style: { fontSize: "0.7rem" }, title: `${groupCount} ${groupCount === 1 ? "group" : "groups"}`, children: [groupCount, "g"] }))] }));
|
|
136
|
+
}
|
|
137
|
+
function ConnectedDevicesList({ loadState }) {
|
|
138
|
+
if (loadState.status === "idle" || loadState.status === "loading") {
|
|
139
|
+
return (0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-2", children: "Loading\u2026" });
|
|
140
|
+
}
|
|
141
|
+
if (loadState.status === "error") {
|
|
142
|
+
return (0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-2", children: "Couldn't load devices" });
|
|
143
|
+
}
|
|
144
|
+
if (loadState.connections.length === 0) {
|
|
145
|
+
return (0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-2", children: "No connected devices" });
|
|
146
|
+
}
|
|
147
|
+
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
|
+
}
|
|
149
|
+
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" })] }) }));
|
|
64
151
|
}
|
|
65
152
|
/** Accessible reactive network status button and details popover. */
|
|
66
153
|
function NetworkStatusIndicator({ compact = false }) {
|
|
67
154
|
const viewState = (0, use_network_status_1.useNetworkStatus)();
|
|
68
|
-
const
|
|
155
|
+
const [displaySyncing, setDisplaySyncing] = (0, react_1.useState)(false);
|
|
156
|
+
const [syncingLongerThanGreenMs, setSyncingLongerThanGreenMs] = (0, react_1.useState)(false);
|
|
69
157
|
const [open, setOpen] = (0, react_1.useState)(false);
|
|
158
|
+
const [connectionsLoadState, setConnectionsLoadState] = (0, react_1.useState)({
|
|
159
|
+
status: "idle",
|
|
160
|
+
});
|
|
70
161
|
const containerRef = (0, react_1.useRef)(null);
|
|
71
162
|
const detailsId = (0, react_1.useId)();
|
|
163
|
+
const rawState = viewState.availability === "ready" ? viewState.status?.state : undefined;
|
|
164
|
+
const rawIsSyncing = rawState === "syncing";
|
|
165
|
+
const rawIsSynced = rawState === "synced";
|
|
166
|
+
const connectionCount = viewState.status?.connectionCount;
|
|
167
|
+
const diagnosticsReady = viewState.availability === "ready";
|
|
168
|
+
(0, react_1.useEffect)(() => {
|
|
169
|
+
if (rawIsSyncing) {
|
|
170
|
+
setDisplaySyncing(true);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (rawIsSynced) {
|
|
174
|
+
if (!displaySyncing) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const timer = window.setTimeout(() => {
|
|
178
|
+
setDisplaySyncing(false);
|
|
179
|
+
}, exports.SYNCED_STABLE_MS);
|
|
180
|
+
return () => {
|
|
181
|
+
window.clearTimeout(timer);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
setDisplaySyncing(false);
|
|
185
|
+
}, [rawIsSyncing, rawIsSynced, displaySyncing]);
|
|
186
|
+
(0, react_1.useEffect)(() => {
|
|
187
|
+
if (!rawIsSyncing) {
|
|
188
|
+
setSyncingLongerThanGreenMs(false);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const timer = window.setTimeout(() => {
|
|
192
|
+
setSyncingLongerThanGreenMs(true);
|
|
193
|
+
}, exports.SYNCING_GREEN_MS);
|
|
194
|
+
return () => {
|
|
195
|
+
window.clearTimeout(timer);
|
|
196
|
+
};
|
|
197
|
+
}, [rawIsSyncing]);
|
|
198
|
+
(0, react_1.useEffect)(() => {
|
|
199
|
+
if (!open || !diagnosticsReady) {
|
|
200
|
+
setConnectionsLoadState({ status: "idle" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let active = true;
|
|
204
|
+
setConnectionsLoadState({ status: "loading" });
|
|
205
|
+
void (0, network_viewer_ipc_1.getNetworkDiagnosticsConsumer)()
|
|
206
|
+
.then((diagnostics) => diagnostics.tools.getConnections())
|
|
207
|
+
.then((result) => {
|
|
208
|
+
if (active) {
|
|
209
|
+
setConnectionsLoadState({ status: "ready", connections: result.connections });
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
.catch(() => {
|
|
213
|
+
if (active) {
|
|
214
|
+
setConnectionsLoadState({ status: "error" });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return () => {
|
|
218
|
+
active = false;
|
|
219
|
+
};
|
|
220
|
+
}, [open, diagnosticsReady, connectionCount]);
|
|
221
|
+
const holdSyncingVisual = displaySyncing && !rawIsSyncing;
|
|
222
|
+
const presentation = getNetworkStatusPresentation(viewState, {
|
|
223
|
+
syncingLongerThanGreenMs,
|
|
224
|
+
holdSyncingVisual,
|
|
225
|
+
});
|
|
72
226
|
(0, react_1.useEffect)(() => {
|
|
73
227
|
if (!open) {
|
|
74
228
|
return;
|
|
@@ -94,7 +248,9 @@ function NetworkStatusIndicator({ compact = false }) {
|
|
|
94
248
|
border: "none",
|
|
95
249
|
background: "transparent",
|
|
96
250
|
minWidth: compact ? "30px" : undefined,
|
|
97
|
-
}, "aria-label": presentation.label, "aria-expanded": open, "aria-controls": detailsId, title: presentation.label, onClick: () => setOpen((current) => !current), children: [(0, jsx_runtime_1.jsx)(
|
|
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: () => {
|
|
98
254
|
setOpen(false);
|
|
99
255
|
(0, tabs_state_1.goToTabPath)("network-viewer");
|
|
100
256
|
} }))] }));
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
4
8
|
const react_1 = require("@testing-library/react");
|
|
9
|
+
const moment_1 = __importDefault(require("moment"));
|
|
5
10
|
const network_status_indicator_1 = require("./network-status-indicator");
|
|
6
11
|
const goToTabPath = jest.fn();
|
|
12
|
+
const getConnections = jest.fn();
|
|
7
13
|
let mockViewState = { availability: "loading" };
|
|
8
14
|
jest.mock("./tabs-state", () => ({
|
|
9
15
|
goToTabPath: (...args) => goToTabPath(...args),
|
|
@@ -11,6 +17,13 @@ jest.mock("./tabs-state", () => ({
|
|
|
11
17
|
jest.mock("./use-network-status", () => ({
|
|
12
18
|
useNetworkStatus: () => mockViewState,
|
|
13
19
|
}));
|
|
20
|
+
jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
|
|
21
|
+
getNetworkDiagnosticsConsumer: () => Promise.resolve({
|
|
22
|
+
tools: {
|
|
23
|
+
getConnections: (...args) => getConnections(...args),
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
14
27
|
function status(overrides) {
|
|
15
28
|
return {
|
|
16
29
|
state: "synced",
|
|
@@ -21,11 +34,115 @@ function status(overrides) {
|
|
|
21
34
|
...overrides,
|
|
22
35
|
};
|
|
23
36
|
}
|
|
37
|
+
function connection(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
deviceId: "device-1",
|
|
40
|
+
userId: "user-1",
|
|
41
|
+
userName: "Ada",
|
|
42
|
+
deviceName: "Laptop",
|
|
43
|
+
userTrustLevel: peers_sdk_1.TrustLevel.Trusted,
|
|
44
|
+
deviceTrustLevel: peers_sdk_1.TrustLevel.Trusted,
|
|
45
|
+
connectionState: "connected",
|
|
46
|
+
sharedGroups: ["group-a"],
|
|
47
|
+
roles: {},
|
|
48
|
+
pathType: "direct",
|
|
49
|
+
latencyMs: 10,
|
|
50
|
+
errorRate: 0,
|
|
51
|
+
isServer: false,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
describe("formatRelativeSyncTime", () => {
|
|
56
|
+
it("returns now for very recent syncs", () => {
|
|
57
|
+
const now = Date.parse("2026-07-28T23:43:06.000");
|
|
58
|
+
expect((0, network_status_indicator_1.formatRelativeSyncTime)(now - 10_000, now)).toBe("now");
|
|
59
|
+
});
|
|
60
|
+
it("returns yesterday for the previous calendar day", () => {
|
|
61
|
+
const now = (0, moment_1.default)("2026-07-28T23:43:06").valueOf();
|
|
62
|
+
const yesterdayAfternoon = (0, moment_1.default)("2026-07-28T23:43:06")
|
|
63
|
+
.subtract(1, "day")
|
|
64
|
+
.hour(18)
|
|
65
|
+
.minute(0)
|
|
66
|
+
.second(0)
|
|
67
|
+
.valueOf();
|
|
68
|
+
expect((0, network_status_indicator_1.formatRelativeSyncTime)(yesterdayAfternoon, now)).toBe("yesterday");
|
|
69
|
+
});
|
|
70
|
+
it("returns a short relative phrase otherwise", () => {
|
|
71
|
+
const now = Date.parse("2026-07-28T23:43:06.000");
|
|
72
|
+
expect((0, network_status_indicator_1.formatRelativeSyncTime)(now - 5 * 60_000, now)).toBe("5 minutes ago");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe("connectionIdentityLabel", () => {
|
|
76
|
+
it("prefers a friendly name", () => {
|
|
77
|
+
expect((0, network_status_indicator_1.connectionIdentityLabel)("Ada", "00mabcdefghijklmnopqrs")).toEqual({
|
|
78
|
+
text: "Ada",
|
|
79
|
+
isId: false,
|
|
80
|
+
title: "Ada",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
it("middle-ellipsizes raw ids", () => {
|
|
84
|
+
expect((0, network_status_indicator_1.connectionIdentityLabel)(undefined, "00mabcdefghijklmnopqrs")).toEqual({
|
|
85
|
+
text: "00ma…pqrs",
|
|
86
|
+
isId: true,
|
|
87
|
+
title: "00mabcdefghijklmnopqrs",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("getNetworkStatusPresentation", () => {
|
|
92
|
+
it("uses a compact green check-circle when synced", () => {
|
|
93
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({ availability: "ready", status: status({ state: "synced" }) })).toMatchObject({
|
|
94
|
+
shortLabel: "Synced",
|
|
95
|
+
colorClass: "text-success",
|
|
96
|
+
iconClass: "bi-check-circle-fill",
|
|
97
|
+
compactIcon: true,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
it("uses a solid red dot when in error", () => {
|
|
101
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
102
|
+
availability: "ready",
|
|
103
|
+
status: status({
|
|
104
|
+
state: "error",
|
|
105
|
+
errorCount: 1,
|
|
106
|
+
errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
|
|
107
|
+
}),
|
|
108
|
+
})).toMatchObject({
|
|
109
|
+
shortLabel: "Error",
|
|
110
|
+
colorClass: "text-danger",
|
|
111
|
+
mark: "solid",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
it("pulses green then switches to an indeterminate circle while syncing", () => {
|
|
115
|
+
const syncing = {
|
|
116
|
+
availability: "ready",
|
|
117
|
+
status: status({ state: "syncing", syncingContextCount: 1 }),
|
|
118
|
+
};
|
|
119
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)(syncing)).toMatchObject({
|
|
120
|
+
colorClass: "text-success",
|
|
121
|
+
mark: "pulse",
|
|
122
|
+
});
|
|
123
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)(syncing, { syncingLongerThanGreenMs: true })).toMatchObject({
|
|
124
|
+
colorClass: "text-primary",
|
|
125
|
+
mark: "indeterminate",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it("holds a green pulse while waiting for stable synced", () => {
|
|
129
|
+
expect((0, network_status_indicator_1.getNetworkStatusPresentation)({ availability: "ready", status: status({ state: "synced" }) }, { holdSyncingVisual: true, syncingLongerThanGreenMs: true })).toMatchObject({
|
|
130
|
+
shortLabel: "Syncing",
|
|
131
|
+
colorClass: "text-success",
|
|
132
|
+
mark: "pulse",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
24
136
|
describe("NetworkStatusIndicator", () => {
|
|
25
137
|
beforeEach(() => {
|
|
26
138
|
mockViewState = { availability: "loading" };
|
|
139
|
+
getConnections.mockReset();
|
|
140
|
+
getConnections.mockResolvedValue({ connections: [] });
|
|
27
141
|
jest.clearAllMocks();
|
|
28
142
|
});
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
jest.useRealTimers();
|
|
145
|
+
});
|
|
29
146
|
it.each([
|
|
30
147
|
[{ availability: "loading" }, /Network status loading/],
|
|
31
148
|
[{ availability: "unavailable" }, /Network status unavailable/],
|
|
@@ -57,7 +174,139 @@ describe("NetworkStatusIndicator", () => {
|
|
|
57
174
|
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
58
175
|
expect(view.getByRole("button", { name: accessibleName })).toBeTruthy();
|
|
59
176
|
});
|
|
60
|
-
it("
|
|
177
|
+
it("renders a compact green check-circle when synced and a solid red error dot", () => {
|
|
178
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
179
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
180
|
+
const syncedIcon = view.container.querySelector("i.bi-check-circle-fill");
|
|
181
|
+
expect(syncedIcon).toBeTruthy();
|
|
182
|
+
expect(syncedIcon).toHaveStyle({ fontSize: "0.7rem" });
|
|
183
|
+
expect(view.container.querySelector(".spinner-grow")).toBeNull();
|
|
184
|
+
mockViewState = {
|
|
185
|
+
availability: "ready",
|
|
186
|
+
status: status({
|
|
187
|
+
state: "error",
|
|
188
|
+
errorCount: 1,
|
|
189
|
+
errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
193
|
+
expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
|
|
194
|
+
});
|
|
195
|
+
it("uses a slower green pulse while syncing", () => {
|
|
196
|
+
mockViewState = {
|
|
197
|
+
availability: "ready",
|
|
198
|
+
status: status({ state: "syncing", syncingContextCount: 1 }),
|
|
199
|
+
};
|
|
200
|
+
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" });
|
|
203
|
+
});
|
|
204
|
+
it("pulses green while syncing, then an indeterminate circle after the green window", () => {
|
|
205
|
+
jest.useFakeTimers();
|
|
206
|
+
mockViewState = {
|
|
207
|
+
availability: "ready",
|
|
208
|
+
status: status({ state: "syncing", syncingContextCount: 1 }),
|
|
209
|
+
};
|
|
210
|
+
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();
|
|
212
|
+
(0, react_1.act)(() => {
|
|
213
|
+
jest.advanceTimersByTime(network_status_indicator_1.SYNCING_GREEN_MS);
|
|
214
|
+
});
|
|
215
|
+
expect(view.container.querySelector(".spinner-border.text-primary")).toBeTruthy();
|
|
216
|
+
expect(view.container.querySelector(".spinner-grow.text-success")).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
it("keeps the green syncing pulse through brief synced flaps until synced is stable", () => {
|
|
219
|
+
jest.useFakeTimers();
|
|
220
|
+
mockViewState = {
|
|
221
|
+
availability: "ready",
|
|
222
|
+
status: status({ state: "syncing", syncingContextCount: 1 }),
|
|
223
|
+
};
|
|
224
|
+
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();
|
|
226
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
227
|
+
view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
228
|
+
(0, react_1.act)(() => {
|
|
229
|
+
jest.advanceTimersByTime(network_status_indicator_1.SYNCED_STABLE_MS - 1);
|
|
230
|
+
});
|
|
231
|
+
expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
|
|
232
|
+
expect(view.container.querySelector("i.bi-check-circle-fill")).toBeNull();
|
|
233
|
+
mockViewState = {
|
|
234
|
+
availability: "ready",
|
|
235
|
+
status: status({ state: "syncing", syncingContextCount: 1 }),
|
|
236
|
+
};
|
|
237
|
+
view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
238
|
+
expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
|
|
239
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
240
|
+
view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
241
|
+
(0, react_1.act)(() => {
|
|
242
|
+
jest.advanceTimersByTime(network_status_indicator_1.SYNCED_STABLE_MS);
|
|
243
|
+
});
|
|
244
|
+
expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
|
|
245
|
+
expect(view.container.querySelector(".spinner-grow")).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
it("lists connected devices in the details popover", async () => {
|
|
248
|
+
getConnections.mockResolvedValue({
|
|
249
|
+
connections: [
|
|
250
|
+
connection({
|
|
251
|
+
deviceId: "device-1",
|
|
252
|
+
userName: "Ada",
|
|
253
|
+
deviceName: "Laptop",
|
|
254
|
+
sharedGroups: ["g1", "g2"],
|
|
255
|
+
}),
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
259
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
260
|
+
react_1.fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
|
|
261
|
+
await (0, react_1.waitFor)(() => {
|
|
262
|
+
expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent("Ada");
|
|
263
|
+
});
|
|
264
|
+
expect(view.getByRole("dialog")).toHaveTextContent("Laptop");
|
|
265
|
+
expect(view.getByRole("dialog")).toHaveTextContent("2g");
|
|
266
|
+
expect(getConnections).toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
it("shows middle-ellipsized ids when names are missing", async () => {
|
|
269
|
+
getConnections.mockResolvedValue({
|
|
270
|
+
connections: [
|
|
271
|
+
connection({
|
|
272
|
+
deviceId: "00mdeviceidabcdefghijklm",
|
|
273
|
+
userId: "00museridabcdefghijklmn",
|
|
274
|
+
userName: undefined,
|
|
275
|
+
deviceName: undefined,
|
|
276
|
+
sharedGroups: [],
|
|
277
|
+
}),
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
281
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
282
|
+
react_1.fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
|
|
283
|
+
await (0, react_1.waitFor)(() => {
|
|
284
|
+
expect(view.getByRole("dialog")).toHaveTextContent("00mu…klmn");
|
|
285
|
+
});
|
|
286
|
+
expect(view.getByRole("dialog")).toHaveTextContent("00md…jklm");
|
|
287
|
+
});
|
|
288
|
+
it("shows an empty connected-devices message when there are none", async () => {
|
|
289
|
+
getConnections.mockResolvedValue({ connections: [] });
|
|
290
|
+
mockViewState = {
|
|
291
|
+
availability: "ready",
|
|
292
|
+
status: status({ state: "offline", connectionCount: 0 }),
|
|
293
|
+
};
|
|
294
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
295
|
+
react_1.fireEvent.click(view.getByRole("button", { name: /Network offline/ }));
|
|
296
|
+
await (0, react_1.waitFor)(() => {
|
|
297
|
+
expect(view.getByRole("dialog")).toHaveTextContent("No connected devices");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
it("shows an error when connected devices fail to load", async () => {
|
|
301
|
+
getConnections.mockRejectedValue(new Error("offline"));
|
|
302
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
303
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
304
|
+
react_1.fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
|
|
305
|
+
await (0, react_1.waitFor)(() => {
|
|
306
|
+
expect(view.getByRole("dialog")).toHaveTextContent("Couldn't load devices");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
it("updates on rerender and opens details before navigating to Network Viewer", async () => {
|
|
61
310
|
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
62
311
|
expect(view.getByRole("button", { name: /loading/ })).toBeTruthy();
|
|
63
312
|
mockViewState = {
|
|
@@ -72,6 +321,9 @@ describe("NetworkStatusIndicator", () => {
|
|
|
72
321
|
view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
73
322
|
react_1.fireEvent.click(view.getByRole("button", { name: /Network error/ }));
|
|
74
323
|
expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent("sync failed");
|
|
324
|
+
await (0, react_1.waitFor)(() => {
|
|
325
|
+
expect(getConnections).toHaveBeenCalled();
|
|
326
|
+
});
|
|
75
327
|
react_1.fireEvent.keyDown(document, { key: "Escape" });
|
|
76
328
|
expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
|
|
77
329
|
react_1.fireEvent.click(view.getByRole("button", { name: /Network error/ }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.5",
|
|
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.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.21.5",
|
|
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.
|
|
42
|
+
"@peers-app/peers-sdk": "0.21.5",
|
|
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",
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import type { INetworkStatusSummary } from "@peers-app/peers-sdk";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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";
|
|
4
13
|
import type { INetworkStatusViewState } from "./use-network-status";
|
|
5
14
|
|
|
6
15
|
const goToTabPath = jest.fn();
|
|
16
|
+
const getConnections = jest.fn();
|
|
7
17
|
let mockViewState: INetworkStatusViewState = { availability: "loading" };
|
|
8
18
|
|
|
9
19
|
jest.mock("./tabs-state", () => ({
|
|
@@ -14,6 +24,15 @@ jest.mock("./use-network-status", () => ({
|
|
|
14
24
|
useNetworkStatus: () => mockViewState,
|
|
15
25
|
}));
|
|
16
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
|
+
|
|
17
36
|
function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummary {
|
|
18
37
|
return {
|
|
19
38
|
state: "synced",
|
|
@@ -25,12 +44,138 @@ function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummar
|
|
|
25
44
|
};
|
|
26
45
|
}
|
|
27
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
|
+
|
|
28
167
|
describe("NetworkStatusIndicator", () => {
|
|
29
168
|
beforeEach(() => {
|
|
30
169
|
mockViewState = { availability: "loading" };
|
|
170
|
+
getConnections.mockReset();
|
|
171
|
+
getConnections.mockResolvedValue({ connections: [] });
|
|
31
172
|
jest.clearAllMocks();
|
|
32
173
|
});
|
|
33
174
|
|
|
175
|
+
afterEach(() => {
|
|
176
|
+
jest.useRealTimers();
|
|
177
|
+
});
|
|
178
|
+
|
|
34
179
|
it.each([
|
|
35
180
|
[{ availability: "loading" }, /Network status loading/],
|
|
36
181
|
[{ availability: "unavailable" }, /Network status unavailable/],
|
|
@@ -65,7 +210,156 @@ describe("NetworkStatusIndicator", () => {
|
|
|
65
210
|
expect(view.getByRole("button", { name: accessibleName })).toBeTruthy();
|
|
66
211
|
});
|
|
67
212
|
|
|
68
|
-
it("
|
|
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 () => {
|
|
69
363
|
const view = render(<NetworkStatusIndicator />);
|
|
70
364
|
expect(view.getByRole("button", { name: /loading/ })).toBeTruthy();
|
|
71
365
|
|
|
@@ -84,6 +378,9 @@ describe("NetworkStatusIndicator", () => {
|
|
|
84
378
|
expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent(
|
|
85
379
|
"sync failed",
|
|
86
380
|
);
|
|
381
|
+
await waitFor(() => {
|
|
382
|
+
expect(getConnections).toHaveBeenCalled();
|
|
383
|
+
});
|
|
87
384
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
88
385
|
expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
|
|
89
386
|
|
|
@@ -1,19 +1,50 @@
|
|
|
1
|
-
import type { INetworkStatusSummary } from "@peers-app/peers-sdk";
|
|
1
|
+
import type { INetworkConnectionInfo, INetworkStatusSummary } from "@peers-app/peers-sdk";
|
|
2
|
+
import moment from "moment";
|
|
2
3
|
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { getNetworkDiagnosticsConsumer } from "../screens/network-viewer/network-viewer-ipc";
|
|
3
5
|
import { goToTabPath } from "./tabs-state";
|
|
4
6
|
import { type INetworkStatusViewState, useNetworkStatus } from "./use-network-status";
|
|
5
7
|
|
|
8
|
+
/** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
|
|
9
|
+
export const SYNCING_GREEN_MS = 2_000;
|
|
10
|
+
|
|
11
|
+
/** How long status must stay fully synced before leaving the syncing visual. */
|
|
12
|
+
export const SYNCED_STABLE_MS = 1_000;
|
|
13
|
+
|
|
14
|
+
/** Slightly slower than Bootstrap's default spinner-grow (~0.75s). */
|
|
15
|
+
const PULSE_ANIMATION_DURATION = "1.0s";
|
|
16
|
+
|
|
17
|
+
const DOT_SIZE = "0.7rem";
|
|
18
|
+
|
|
19
|
+
type NetworkStatusMark = "solid" | "pulse" | "indeterminate";
|
|
20
|
+
|
|
21
|
+
type ConnectionsLoadState =
|
|
22
|
+
| { status: "idle" }
|
|
23
|
+
| { status: "loading" }
|
|
24
|
+
| { status: "ready"; connections: INetworkConnectionInfo[] }
|
|
25
|
+
| { status: "error" };
|
|
26
|
+
|
|
6
27
|
interface INetworkStatusPresentation {
|
|
7
28
|
label: string;
|
|
8
29
|
shortLabel: string;
|
|
9
|
-
iconClass: string;
|
|
10
30
|
colorClass: string;
|
|
31
|
+
/** Bootstrap Icons class when not using a status mark. */
|
|
32
|
+
iconClass?: string;
|
|
11
33
|
spinning?: boolean;
|
|
34
|
+
/** Size the Bootstrap icon to match the status marks. */
|
|
35
|
+
compactIcon?: boolean;
|
|
36
|
+
/** Solid / pulsing / indeterminate mark (syncing / error). */
|
|
37
|
+
mark?: NetworkStatusMark;
|
|
12
38
|
}
|
|
13
39
|
|
|
14
40
|
/** Convert host availability and network state into accessible indicator text and styling. */
|
|
15
41
|
export function getNetworkStatusPresentation(
|
|
16
42
|
viewState: INetworkStatusViewState,
|
|
43
|
+
opts?: {
|
|
44
|
+
syncingLongerThanGreenMs?: boolean;
|
|
45
|
+
/** Keep the calm green syncing pulse while waiting for stable synced. */
|
|
46
|
+
holdSyncingVisual?: boolean;
|
|
47
|
+
},
|
|
17
48
|
): INetworkStatusPresentation {
|
|
18
49
|
if (viewState.availability === "loading") {
|
|
19
50
|
return {
|
|
@@ -37,17 +68,21 @@ export function getNetworkStatusPresentation(
|
|
|
37
68
|
return {
|
|
38
69
|
label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
|
|
39
70
|
shortLabel: "Error",
|
|
40
|
-
iconClass: "bi-exclamation-triangle-fill",
|
|
41
71
|
colorClass: "text-danger",
|
|
72
|
+
mark: "solid",
|
|
42
73
|
};
|
|
43
74
|
}
|
|
44
|
-
if (state === "syncing") {
|
|
75
|
+
if (state === "syncing" || opts?.holdSyncingVisual) {
|
|
76
|
+
const longSync = state === "syncing" && opts?.syncingLongerThanGreenMs === true;
|
|
77
|
+
const contextsLabel =
|
|
78
|
+
state === "syncing"
|
|
79
|
+
? `${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}`
|
|
80
|
+
: "recent activity";
|
|
45
81
|
return {
|
|
46
|
-
label: `Network syncing ${
|
|
82
|
+
label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
|
|
47
83
|
shortLabel: "Syncing",
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
spinning: true,
|
|
84
|
+
colorClass: longSync ? "text-primary" : "text-success",
|
|
85
|
+
mark: longSync ? "indeterminate" : "pulse",
|
|
51
86
|
};
|
|
52
87
|
}
|
|
53
88
|
if (state === "offline") {
|
|
@@ -63,18 +98,153 @@ export function getNetworkStatusPresentation(
|
|
|
63
98
|
shortLabel: "Synced",
|
|
64
99
|
iconClass: "bi-check-circle-fill",
|
|
65
100
|
colorClass: "text-success",
|
|
101
|
+
compactIcon: true,
|
|
66
102
|
};
|
|
67
103
|
}
|
|
68
104
|
|
|
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
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (mode === "indeterminate") {
|
|
120
|
+
return (
|
|
121
|
+
<span
|
|
122
|
+
className={`spinner-border spinner-border-sm ${colorClass}`}
|
|
123
|
+
style={{ width: DOT_SIZE, height: DOT_SIZE, borderWidth: "0.12em" }}
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const bgClass =
|
|
129
|
+
colorClass === "text-danger"
|
|
130
|
+
? "bg-danger"
|
|
131
|
+
: colorClass === "text-primary"
|
|
132
|
+
? "bg-primary"
|
|
133
|
+
: "bg-success";
|
|
134
|
+
return (
|
|
135
|
+
<span
|
|
136
|
+
className={`rounded-circle ${bgClass}`}
|
|
137
|
+
style={{ width: DOT_SIZE, height: DOT_SIZE, display: "inline-block" }}
|
|
138
|
+
aria-hidden="true"
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Compact relative time for the status popover (always short enough for one line). */
|
|
144
|
+
export function formatRelativeSyncTime(timestampMs: number, nowMs = Date.now()): string {
|
|
145
|
+
const then = moment(timestampMs);
|
|
146
|
+
const now = moment(nowMs);
|
|
147
|
+
if (now.diff(then, "seconds") < 45) {
|
|
148
|
+
return "now";
|
|
149
|
+
}
|
|
150
|
+
if (now.clone().subtract(1, "day").isSame(then, "day")) {
|
|
151
|
+
return "yesterday";
|
|
152
|
+
}
|
|
153
|
+
return then.from(now);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function middleEllipsis(value: string, head = 4, tail = 4): string {
|
|
157
|
+
if (value.length <= head + tail + 1) {
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
|
|
164
|
+
export function connectionIdentityLabel(
|
|
165
|
+
name: string | undefined,
|
|
166
|
+
id: string,
|
|
167
|
+
): { text: string; isId: boolean; title: string } {
|
|
168
|
+
const trimmed = name?.trim();
|
|
169
|
+
if (trimmed) {
|
|
170
|
+
return { text: trimmed, isId: false, title: trimmed };
|
|
171
|
+
}
|
|
172
|
+
return { text: middleEllipsis(id), isId: true, title: id };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function IdentityPart({ label }: { label: { text: string; isId: boolean; title: string } }) {
|
|
176
|
+
return (
|
|
177
|
+
<span
|
|
178
|
+
className={`text-truncate ${label.isId ? "text-muted" : ""}`}
|
|
179
|
+
style={{
|
|
180
|
+
minWidth: 0,
|
|
181
|
+
...(label.isId ? { fontSize: "0.7rem" } : undefined),
|
|
182
|
+
}}
|
|
183
|
+
title={label.title}
|
|
184
|
+
>
|
|
185
|
+
{label.text}
|
|
186
|
+
</span>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ConnectedDeviceRow({ connection }: { connection: INetworkConnectionInfo }) {
|
|
191
|
+
const user = connectionIdentityLabel(connection.userName, connection.userId);
|
|
192
|
+
const device = connectionIdentityLabel(connection.deviceName, connection.deviceId);
|
|
193
|
+
const groupCount = connection.sharedGroups.length;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<li
|
|
197
|
+
className="d-flex align-items-baseline gap-1 py-1"
|
|
198
|
+
style={{ listStyle: "none", minWidth: 0 }}
|
|
199
|
+
>
|
|
200
|
+
<IdentityPart label={user} />
|
|
201
|
+
<span className="text-muted flex-shrink-0" aria-hidden="true">
|
|
202
|
+
·
|
|
203
|
+
</span>
|
|
204
|
+
<IdentityPart label={device} />
|
|
205
|
+
{groupCount > 0 && (
|
|
206
|
+
<span
|
|
207
|
+
className="text-muted flex-shrink-0 ms-auto"
|
|
208
|
+
style={{ fontSize: "0.7rem" }}
|
|
209
|
+
title={`${groupCount} ${groupCount === 1 ? "group" : "groups"}`}
|
|
210
|
+
>
|
|
211
|
+
{groupCount}g
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
</li>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function ConnectedDevicesList({ loadState }: { loadState: ConnectionsLoadState }) {
|
|
219
|
+
if (loadState.status === "idle" || loadState.status === "loading") {
|
|
220
|
+
return <p className="small text-muted mb-2">Loading…</p>;
|
|
221
|
+
}
|
|
222
|
+
if (loadState.status === "error") {
|
|
223
|
+
return <p className="small text-muted mb-2">Couldn't load devices</p>;
|
|
224
|
+
}
|
|
225
|
+
if (loadState.connections.length === 0) {
|
|
226
|
+
return <p className="small text-muted mb-2">No connected devices</p>;
|
|
227
|
+
}
|
|
228
|
+
return (
|
|
229
|
+
<ul className="small mb-0 ps-0" style={{ maxHeight: "180px", overflowY: "auto" }}>
|
|
230
|
+
{loadState.connections.map((connection) => (
|
|
231
|
+
<ConnectedDeviceRow key={connection.deviceId} connection={connection} />
|
|
232
|
+
))}
|
|
233
|
+
</ul>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
69
237
|
function NetworkStatusDetails({
|
|
70
238
|
detailsId,
|
|
71
239
|
status,
|
|
72
240
|
unavailable,
|
|
241
|
+
connectionsLoadState,
|
|
73
242
|
onOpenViewer,
|
|
74
243
|
}: {
|
|
75
244
|
detailsId: string;
|
|
76
245
|
status?: INetworkStatusSummary;
|
|
77
246
|
unavailable: boolean;
|
|
247
|
+
connectionsLoadState: ConnectionsLoadState;
|
|
78
248
|
onOpenViewer: () => void;
|
|
79
249
|
}) {
|
|
80
250
|
return (
|
|
@@ -83,7 +253,7 @@ function NetworkStatusDetails({
|
|
|
83
253
|
role="dialog"
|
|
84
254
|
aria-label="Network status details"
|
|
85
255
|
className="card shadow position-absolute end-0 mt-1"
|
|
86
|
-
style={{ top: "100%", width: "
|
|
256
|
+
style={{ top: "100%", width: "320px", zIndex: 1100 }}
|
|
87
257
|
>
|
|
88
258
|
<div className="card-body p-3">
|
|
89
259
|
<h6 className="card-title mb-2">Network status</h6>
|
|
@@ -101,21 +271,35 @@ function NetworkStatusDetails({
|
|
|
101
271
|
<dd className="col-5 text-end">{status.syncingContextCount}</dd>
|
|
102
272
|
<dt className="col-7">Sync errors</dt>
|
|
103
273
|
<dd className="col-5 text-end">{status.errorCount}</dd>
|
|
274
|
+
{status.lastSuccessfulSyncAt != null && (
|
|
275
|
+
<>
|
|
276
|
+
<dt className="col-7">Last sync</dt>
|
|
277
|
+
<dd
|
|
278
|
+
className="col-5 text-end text-truncate"
|
|
279
|
+
title={new Date(status.lastSuccessfulSyncAt).toLocaleString()}
|
|
280
|
+
>
|
|
281
|
+
{formatRelativeSyncTime(status.lastSuccessfulSyncAt)}
|
|
282
|
+
</dd>
|
|
283
|
+
</>
|
|
284
|
+
)}
|
|
104
285
|
</dl>
|
|
105
|
-
{status.lastSuccessfulSyncAt && (
|
|
106
|
-
<p className="small text-muted mb-2">
|
|
107
|
-
Last successful sync: {new Date(status.lastSuccessfulSyncAt).toLocaleString()}
|
|
108
|
-
</p>
|
|
109
|
-
)}
|
|
110
286
|
{status.errors.length > 0 && (
|
|
111
287
|
<div className="alert alert-danger py-2 px-2 small mb-2">
|
|
112
288
|
{status.errors[0].message}
|
|
113
289
|
{status.errors.length > 1 && ` (+${status.errors.length - 1} more)`}
|
|
114
290
|
</div>
|
|
115
291
|
)}
|
|
292
|
+
<div className="border-top pt-2 mt-1 mb-2">
|
|
293
|
+
<div className="small text-muted mb-1">Devices</div>
|
|
294
|
+
<ConnectedDevicesList loadState={connectionsLoadState} />
|
|
295
|
+
</div>
|
|
116
296
|
</>
|
|
117
297
|
)}
|
|
118
|
-
<button
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
className="btn btn-sm btn-outline-primary w-100"
|
|
301
|
+
onClick={onOpenViewer}
|
|
302
|
+
>
|
|
119
303
|
Open Network Viewer
|
|
120
304
|
</button>
|
|
121
305
|
</div>
|
|
@@ -126,11 +310,83 @@ function NetworkStatusDetails({
|
|
|
126
310
|
/** Accessible reactive network status button and details popover. */
|
|
127
311
|
export function NetworkStatusIndicator({ compact = false }: { compact?: boolean }) {
|
|
128
312
|
const viewState = useNetworkStatus();
|
|
129
|
-
const
|
|
313
|
+
const [displaySyncing, setDisplaySyncing] = useState(false);
|
|
314
|
+
const [syncingLongerThanGreenMs, setSyncingLongerThanGreenMs] = useState(false);
|
|
130
315
|
const [open, setOpen] = useState(false);
|
|
316
|
+
const [connectionsLoadState, setConnectionsLoadState] = useState<ConnectionsLoadState>({
|
|
317
|
+
status: "idle",
|
|
318
|
+
});
|
|
131
319
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
132
320
|
const detailsId = useId();
|
|
133
321
|
|
|
322
|
+
const rawState = viewState.availability === "ready" ? viewState.status?.state : undefined;
|
|
323
|
+
const rawIsSyncing = rawState === "syncing";
|
|
324
|
+
const rawIsSynced = rawState === "synced";
|
|
325
|
+
const connectionCount = viewState.status?.connectionCount;
|
|
326
|
+
const diagnosticsReady = viewState.availability === "ready";
|
|
327
|
+
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (rawIsSyncing) {
|
|
330
|
+
setDisplaySyncing(true);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (rawIsSynced) {
|
|
334
|
+
if (!displaySyncing) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const timer = window.setTimeout(() => {
|
|
338
|
+
setDisplaySyncing(false);
|
|
339
|
+
}, SYNCED_STABLE_MS);
|
|
340
|
+
return () => {
|
|
341
|
+
window.clearTimeout(timer);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
setDisplaySyncing(false);
|
|
345
|
+
}, [rawIsSyncing, rawIsSynced, displaySyncing]);
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
if (!rawIsSyncing) {
|
|
349
|
+
setSyncingLongerThanGreenMs(false);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const timer = window.setTimeout(() => {
|
|
353
|
+
setSyncingLongerThanGreenMs(true);
|
|
354
|
+
}, SYNCING_GREEN_MS);
|
|
355
|
+
return () => {
|
|
356
|
+
window.clearTimeout(timer);
|
|
357
|
+
};
|
|
358
|
+
}, [rawIsSyncing]);
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!open || !diagnosticsReady) {
|
|
362
|
+
setConnectionsLoadState({ status: "idle" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
let active = true;
|
|
366
|
+
setConnectionsLoadState({ status: "loading" });
|
|
367
|
+
void getNetworkDiagnosticsConsumer()
|
|
368
|
+
.then((diagnostics) => diagnostics.tools.getConnections())
|
|
369
|
+
.then((result) => {
|
|
370
|
+
if (active) {
|
|
371
|
+
setConnectionsLoadState({ status: "ready", connections: result.connections });
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
.catch(() => {
|
|
375
|
+
if (active) {
|
|
376
|
+
setConnectionsLoadState({ status: "error" });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
return () => {
|
|
380
|
+
active = false;
|
|
381
|
+
};
|
|
382
|
+
}, [open, diagnosticsReady, connectionCount]);
|
|
383
|
+
|
|
384
|
+
const holdSyncingVisual = displaySyncing && !rawIsSyncing;
|
|
385
|
+
const presentation = getNetworkStatusPresentation(viewState, {
|
|
386
|
+
syncingLongerThanGreenMs,
|
|
387
|
+
holdSyncingVisual,
|
|
388
|
+
});
|
|
389
|
+
|
|
134
390
|
useEffect(() => {
|
|
135
391
|
if (!open) {
|
|
136
392
|
return;
|
|
@@ -169,10 +425,19 @@ export function NetworkStatusIndicator({ compact = false }: { compact?: boolean
|
|
|
169
425
|
title={presentation.label}
|
|
170
426
|
onClick={() => setOpen((current) => !current)}
|
|
171
427
|
>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
)}
|
|
176
441
|
{!compact && (
|
|
177
442
|
<span className="small">
|
|
178
443
|
{viewState.status?.connectionCount ?? 0}
|
|
@@ -185,6 +450,7 @@ export function NetworkStatusIndicator({ compact = false }: { compact?: boolean
|
|
|
185
450
|
detailsId={detailsId}
|
|
186
451
|
status={viewState.status}
|
|
187
452
|
unavailable={viewState.availability !== "ready"}
|
|
453
|
+
connectionsLoadState={connectionsLoadState}
|
|
188
454
|
onOpenViewer={() => {
|
|
189
455
|
setOpen(false);
|
|
190
456
|
goToTabPath("network-viewer");
|