@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.
- package/dist/components/io-schema-values.js +0 -1
- package/dist/components/markdown-editor/editor.js +1 -1
- package/dist/components/save-button.d.ts +1 -0
- package/dist/screens/network-viewer/network-viewer-ipc.test.js +2 -0
- package/dist/tabs-layout/network-status-indicator.d.ts +16 -1
- package/dist/tabs-layout/network-status-indicator.js +78 -23
- package/dist/tabs-layout/network-status-indicator.test.js +128 -10
- package/dist/tabs-layout/use-network-status.test.js +2 -0
- package/package.json +3 -3
- package/src/components/io-schema-values.tsx +0 -1
- package/src/components/markdown-editor/editor.tsx +1 -1
- package/src/components/save-button.tsx +1 -1
- package/src/screens/network-viewer/network-viewer-ipc.test.ts +2 -0
- package/src/tabs-layout/network-status-indicator.test.tsx +147 -10
- package/src/tabs-layout/network-status-indicator.tsx +147 -39
- package/src/tabs-layout/use-network-status.test.tsx +2 -0
- package/src/ui-router/ui-loader.tsx +0 -1
|
@@ -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
|
-
//
|
|
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?: ({
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
|
82
|
-
if (
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
:
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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(".
|
|
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("
|
|
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(".
|
|
202
|
-
expect(pulse).toHaveStyle({
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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({
|
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.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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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?: (
|
|
@@ -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(".
|
|
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("
|
|
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(".
|
|
240
|
-
expect(pulse).toHaveStyle({
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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 =
|
|
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
|
-
/**
|
|
15
|
-
|
|
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 {
|
|
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
|
|
106
|
-
if (
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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}
|
|
@@ -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;
|