@peers-app/peers-ui 0.25.8 → 0.25.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/screens/network-viewer/network-viewer-section.d.ts +9 -0
- package/dist/screens/network-viewer/network-viewer-section.js +11 -0
- package/dist/screens/network-viewer/network-viewer.d.ts +6 -0
- package/dist/screens/network-viewer/network-viewer.js +19 -1
- package/dist/screens/network-viewer/network-viewer.section.test.d.ts +1 -0
- package/dist/screens/network-viewer/network-viewer.section.test.js +63 -0
- package/dist/screens/network-viewer/sync-recovery.d.ts +8 -0
- package/dist/screens/network-viewer/sync-recovery.js +182 -0
- package/dist/screens/network-viewer/sync-recovery.test.d.ts +1 -0
- package/dist/screens/network-viewer/sync-recovery.test.js +104 -0
- package/dist/screens/packages/local-folder-grants-panel.d.ts +15 -0
- package/dist/screens/packages/local-folder-grants-panel.js +131 -0
- package/dist/screens/packages/local-folders-ipc.d.ts +24 -0
- package/dist/screens/packages/local-folders-ipc.js +10 -0
- package/dist/screens/packages/package-details.js +5 -0
- package/dist/screens/packages/package-list.d.ts +1 -0
- package/dist/screens/packages/package-list.js +3 -1
- package/dist/screens/settings/settings-page.js +13 -3
- package/dist/tabs-layout/network-status-indicator.js +39 -11
- package/dist/tabs-layout/network-status-indicator.test.js +109 -0
- package/dist/tabs-layout/use-network-status.test.js +1 -0
- package/package.json +3 -3
- package/src/screens/network-viewer/network-viewer-section.ts +12 -0
- package/src/screens/network-viewer/network-viewer.section.test.tsx +68 -0
- package/src/screens/network-viewer/network-viewer.tsx +27 -0
- package/src/screens/network-viewer/sync-recovery.test.tsx +114 -0
- package/src/screens/network-viewer/sync-recovery.tsx +461 -0
- package/src/screens/packages/local-folder-grants-panel.tsx +290 -0
- package/src/screens/packages/local-folders-ipc.ts +28 -0
- package/src/screens/packages/package-details.tsx +9 -0
- package/src/screens/packages/package-list.tsx +4 -0
- package/src/screens/settings/settings-page.tsx +15 -5
- package/src/tabs-layout/network-status-indicator.test.tsx +131 -0
- package/src/tabs-layout/network-status-indicator.tsx +121 -43
- package/src/tabs-layout/use-network-status.test.tsx +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { ILocalFolderGrantInfo, LocalFolderMode } from "@peers-app/peers-sdk";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { groups } from "../../globals";
|
|
4
|
+
import { useObservable } from "../../hooks";
|
|
5
|
+
import { useHostContract } from "../../hooks/use-host-contract";
|
|
6
|
+
import { localFoldersConsumer } from "./local-folders-ipc";
|
|
7
|
+
|
|
8
|
+
/** Props for {@link LocalFolderGrantsPanel}. */
|
|
9
|
+
export interface ILocalFolderGrantsPanelProps {
|
|
10
|
+
/**
|
|
11
|
+
* When set, the list and new grants are limited to this package.
|
|
12
|
+
* Omit it for the device-wide operator list.
|
|
13
|
+
*/
|
|
14
|
+
packageId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Folder grants on this device, with add and revoke.
|
|
19
|
+
*
|
|
20
|
+
* Add opens the host folder picker. A host that does not provide System Local Folders
|
|
21
|
+
* (the PWA) shows the contract as unavailable.
|
|
22
|
+
*/
|
|
23
|
+
export function LocalFolderGrantsPanel(props: ILocalFolderGrantsPanelProps) {
|
|
24
|
+
const { consumer, error: transportError } = useHostContract(localFoldersConsumer);
|
|
25
|
+
const [groupList] = useObservable(groups);
|
|
26
|
+
const [grants, setGrants] = useState<ILocalFolderGrantInfo[] | undefined>();
|
|
27
|
+
const [loadError, setLoadError] = useState<Error | undefined>();
|
|
28
|
+
const [actionError, setActionError] = useState<string | undefined>();
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [groupId, setGroupId] = useState("");
|
|
31
|
+
const [mode, setMode] = useState<LocalFolderMode>("readwrite");
|
|
32
|
+
const [refresh, setRefresh] = useState(0);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!consumer) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
consumer.tools.listGrants().then(
|
|
40
|
+
(result) => {
|
|
41
|
+
if (!cancelled) {
|
|
42
|
+
setGrants(result.grants);
|
|
43
|
+
setLoadError(undefined);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
(cause: unknown) => {
|
|
47
|
+
if (!cancelled) {
|
|
48
|
+
setLoadError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
return () => {
|
|
53
|
+
cancelled = true;
|
|
54
|
+
};
|
|
55
|
+
}, [consumer, refresh]);
|
|
56
|
+
|
|
57
|
+
const unavailable = isUnavailable(transportError) || isUnavailable(loadError);
|
|
58
|
+
const visible = (grants ?? []).filter((grant) =>
|
|
59
|
+
props.packageId ? grant.packageId === props.packageId : true,
|
|
60
|
+
);
|
|
61
|
+
const names = new Map(groupList.map((group) => [group.groupId, group.name || group.groupId]));
|
|
62
|
+
|
|
63
|
+
async function addFolder(): Promise<void> {
|
|
64
|
+
if (!consumer || !groupId) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setBusy(true);
|
|
68
|
+
setActionError(undefined);
|
|
69
|
+
try {
|
|
70
|
+
await consumer.tools.requestGrant({
|
|
71
|
+
groupId,
|
|
72
|
+
mode,
|
|
73
|
+
...(props.packageId ? { packageId: props.packageId } : {}),
|
|
74
|
+
});
|
|
75
|
+
setRefresh((value) => value + 1);
|
|
76
|
+
} catch (cause) {
|
|
77
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
78
|
+
if (!/cancelled/i.test(message)) {
|
|
79
|
+
setActionError(message);
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
setBusy(false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function revoke(grantId: string): Promise<void> {
|
|
87
|
+
if (!consumer) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!window.confirm("Revoke this folder grant?")) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setBusy(true);
|
|
94
|
+
setActionError(undefined);
|
|
95
|
+
try {
|
|
96
|
+
await consumer.tools.revokeGrant({ grantId });
|
|
97
|
+
setRefresh((value) => value + 1);
|
|
98
|
+
} catch (cause) {
|
|
99
|
+
setActionError(cause instanceof Error ? cause.message : String(cause));
|
|
100
|
+
} finally {
|
|
101
|
+
setBusy(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<section className="mb-4">
|
|
107
|
+
<h5>{props.packageId ? "Folders" : "Local folders"}</h5>
|
|
108
|
+
<FolderStatus
|
|
109
|
+
unavailable={unavailable}
|
|
110
|
+
transportError={transportError}
|
|
111
|
+
loadError={loadError}
|
|
112
|
+
loading={!!consumer && grants === undefined && !loadError}
|
|
113
|
+
/>
|
|
114
|
+
{consumer && !unavailable && (
|
|
115
|
+
<>
|
|
116
|
+
<GrantTable grants={visible} names={names} busy={busy} onRevoke={revoke} />
|
|
117
|
+
<AddFolderForm
|
|
118
|
+
fieldScope={props.packageId ?? "device"}
|
|
119
|
+
groupId={groupId}
|
|
120
|
+
groups={groupList.map((group) => ({
|
|
121
|
+
groupId: group.groupId,
|
|
122
|
+
name: group.name || group.groupId,
|
|
123
|
+
}))}
|
|
124
|
+
mode={mode}
|
|
125
|
+
busy={busy}
|
|
126
|
+
onGroupId={setGroupId}
|
|
127
|
+
onMode={setMode}
|
|
128
|
+
onAdd={addFolder}
|
|
129
|
+
/>
|
|
130
|
+
{actionError && <div className="alert alert-danger mt-2 mb-0">{actionError}</div>}
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</section>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function FolderStatus(props: {
|
|
138
|
+
unavailable: boolean;
|
|
139
|
+
transportError: Error | undefined;
|
|
140
|
+
loadError: Error | undefined;
|
|
141
|
+
loading: boolean;
|
|
142
|
+
}) {
|
|
143
|
+
if (props.unavailable) {
|
|
144
|
+
return (
|
|
145
|
+
<div className="alert alert-secondary mb-0">
|
|
146
|
+
System Local Folders is not available on this host.
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const error = props.loadError ?? props.transportError;
|
|
151
|
+
if (error) {
|
|
152
|
+
return <div className="alert alert-danger mb-0">{error.message}</div>;
|
|
153
|
+
}
|
|
154
|
+
if (props.loading) {
|
|
155
|
+
return <div className="text-muted">Loading folder grants…</div>;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function GrantTable(props: {
|
|
161
|
+
grants: ILocalFolderGrantInfo[];
|
|
162
|
+
names: Map<string, string>;
|
|
163
|
+
busy: boolean;
|
|
164
|
+
onRevoke: (grantId: string) => void;
|
|
165
|
+
}) {
|
|
166
|
+
if (props.grants.length === 0) {
|
|
167
|
+
return <p className="text-muted">No folder grants.</p>;
|
|
168
|
+
}
|
|
169
|
+
return (
|
|
170
|
+
<div className="table-responsive">
|
|
171
|
+
<table className="table table-sm align-middle">
|
|
172
|
+
<thead>
|
|
173
|
+
<tr>
|
|
174
|
+
<th>Path</th>
|
|
175
|
+
<th>Group</th>
|
|
176
|
+
<th>Package</th>
|
|
177
|
+
<th>Mode</th>
|
|
178
|
+
<th>Created</th>
|
|
179
|
+
<th />
|
|
180
|
+
</tr>
|
|
181
|
+
</thead>
|
|
182
|
+
<tbody>
|
|
183
|
+
{props.grants.map((grant) => (
|
|
184
|
+
<GrantRow
|
|
185
|
+
key={grant.grantId}
|
|
186
|
+
grant={grant}
|
|
187
|
+
groupName={props.names.get(grant.groupId) ?? grant.groupId}
|
|
188
|
+
busy={props.busy}
|
|
189
|
+
onRevoke={props.onRevoke}
|
|
190
|
+
/>
|
|
191
|
+
))}
|
|
192
|
+
</tbody>
|
|
193
|
+
</table>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function GrantRow(props: {
|
|
199
|
+
grant: ILocalFolderGrantInfo;
|
|
200
|
+
groupName: string;
|
|
201
|
+
busy: boolean;
|
|
202
|
+
onRevoke: (grantId: string) => void;
|
|
203
|
+
}) {
|
|
204
|
+
const { grant } = props;
|
|
205
|
+
return (
|
|
206
|
+
<tr>
|
|
207
|
+
<td className="text-break">{grant.absolutePath}</td>
|
|
208
|
+
<td>{props.groupName}</td>
|
|
209
|
+
<td>{grant.packageId ?? "operator"}</td>
|
|
210
|
+
<td>{grant.mode}</td>
|
|
211
|
+
<td>{new Date(grant.createdAt).toLocaleString()}</td>
|
|
212
|
+
<td>
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
className="btn btn-sm btn-outline-danger"
|
|
216
|
+
disabled={props.busy}
|
|
217
|
+
onClick={() => props.onRevoke(grant.grantId)}
|
|
218
|
+
>
|
|
219
|
+
Revoke
|
|
220
|
+
</button>
|
|
221
|
+
</td>
|
|
222
|
+
</tr>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function AddFolderForm(props: {
|
|
227
|
+
fieldScope: string;
|
|
228
|
+
groupId: string;
|
|
229
|
+
groups: { groupId: string; name: string }[];
|
|
230
|
+
mode: LocalFolderMode;
|
|
231
|
+
busy: boolean;
|
|
232
|
+
onGroupId: (groupId: string) => void;
|
|
233
|
+
onMode: (mode: LocalFolderMode) => void;
|
|
234
|
+
onAdd: () => void;
|
|
235
|
+
}) {
|
|
236
|
+
return (
|
|
237
|
+
<form
|
|
238
|
+
className="row g-2 align-items-end"
|
|
239
|
+
onSubmit={(event) => {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
props.onAdd();
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
<div className="col-md-5">
|
|
245
|
+
<label className="form-label" htmlFor={`local-folder-group-${props.fieldScope}`}>
|
|
246
|
+
Group
|
|
247
|
+
</label>
|
|
248
|
+
<select
|
|
249
|
+
id={`local-folder-group-${props.fieldScope}`}
|
|
250
|
+
className="form-select"
|
|
251
|
+
value={props.groupId}
|
|
252
|
+
onChange={(event) => props.onGroupId(event.target.value)}
|
|
253
|
+
>
|
|
254
|
+
<option value="">Select a group</option>
|
|
255
|
+
{props.groups.map((group) => (
|
|
256
|
+
<option key={group.groupId} value={group.groupId}>
|
|
257
|
+
{group.name}
|
|
258
|
+
</option>
|
|
259
|
+
))}
|
|
260
|
+
</select>
|
|
261
|
+
</div>
|
|
262
|
+
<div className="col-md-3">
|
|
263
|
+
<label className="form-label" htmlFor={`local-folder-mode-${props.fieldScope}`}>
|
|
264
|
+
Mode
|
|
265
|
+
</label>
|
|
266
|
+
<select
|
|
267
|
+
id={`local-folder-mode-${props.fieldScope}`}
|
|
268
|
+
className="form-select"
|
|
269
|
+
value={props.mode}
|
|
270
|
+
onChange={(event) => props.onMode(event.target.value as LocalFolderMode)}
|
|
271
|
+
>
|
|
272
|
+
<option value="readwrite">readwrite</option>
|
|
273
|
+
<option value="read">read</option>
|
|
274
|
+
</select>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="col-md-4">
|
|
277
|
+
<button type="submit" className="btn btn-primary" disabled={props.busy || !props.groupId}>
|
|
278
|
+
{props.busy ? "Working…" : "Add folder"}
|
|
279
|
+
</button>
|
|
280
|
+
</div>
|
|
281
|
+
</form>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isUnavailable(error: Error | undefined): boolean {
|
|
286
|
+
if (!error) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
return /No contract provider registered|not available locally/i.test(error.message);
|
|
290
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLocalFoldersContractDefinition,
|
|
3
|
+
hostContractConsumer,
|
|
4
|
+
type IContractConsumerBase,
|
|
5
|
+
type IHostContractConsumer,
|
|
6
|
+
type ILocalFolderGrantInfo,
|
|
7
|
+
type ILocalFolderRequestGrantInput,
|
|
8
|
+
} from "@peers-app/peers-sdk";
|
|
9
|
+
|
|
10
|
+
/** Tools the Packages screens use on System Local Folders. */
|
|
11
|
+
export interface ILocalFoldersUiConsumer extends IContractConsumerBase {
|
|
12
|
+
tools: {
|
|
13
|
+
/** Create a grant. Omitting `absolutePath` opens the desktop folder picker. */
|
|
14
|
+
requestGrant(input: ILocalFolderRequestGrantInput): Promise<ILocalFolderGrantInfo>;
|
|
15
|
+
/** Active grants on this device. */
|
|
16
|
+
listGrants(): Promise<{ grants: ILocalFolderGrantInfo[] }>;
|
|
17
|
+
/** Revoke one grant. `revoked` is false when it was already gone. */
|
|
18
|
+
revokeGrant(input: { grantId: string }): Promise<{ revoked: boolean }>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared System Local Folders consumer for the Packages screens.
|
|
24
|
+
*
|
|
25
|
+
* The PWA host does not register this contract. Callers treat a missing provider as unavailable.
|
|
26
|
+
*/
|
|
27
|
+
export const localFoldersConsumer: IHostContractConsumer<ILocalFoldersUiConsumer> =
|
|
28
|
+
hostContractConsumer<ILocalFoldersUiConsumer>(getLocalFoldersContractDefinition());
|
|
@@ -5,6 +5,7 @@ import { type ISaveButtonProps, SaveButton } from "../../components/save-button"
|
|
|
5
5
|
import { ScreenTabBody, Tabs } from "../../components/tabs";
|
|
6
6
|
import { useObservableState, usePromise } from "../../hooks";
|
|
7
7
|
import { updateActiveTabTitle } from "../../tabs-layout/tabs-state";
|
|
8
|
+
import { LocalFolderGrantsPanel } from "./local-folder-grants-panel";
|
|
8
9
|
import { PackageInfo } from "./package-info";
|
|
9
10
|
import { PackageVersionsList } from "./package-versions";
|
|
10
11
|
|
|
@@ -127,6 +128,14 @@ export const PackageDetails = (props: IProps) => {
|
|
|
127
128
|
<ScreenTabBody>TODO - show all of the dependencies of the package</ScreenTabBody>
|
|
128
129
|
),
|
|
129
130
|
},
|
|
131
|
+
{
|
|
132
|
+
name: "Folders",
|
|
133
|
+
content: (
|
|
134
|
+
<ScreenTabBody>
|
|
135
|
+
<LocalFolderGrantsPanel packageId={pkg.packageId} />
|
|
136
|
+
</ScreenTabBody>
|
|
137
|
+
),
|
|
138
|
+
},
|
|
130
139
|
]}
|
|
131
140
|
/>
|
|
132
141
|
</div>
|
|
@@ -21,6 +21,7 @@ import { Tooltip } from "../../components/tooltip";
|
|
|
21
21
|
import { isDesktop, mainContentPath } from "../../globals";
|
|
22
22
|
import { useObservable, useObservableState, usePromise } from "../../hooks";
|
|
23
23
|
import { registerInternalPeersUI } from "../../ui-router/ui-loader";
|
|
24
|
+
import { LocalFolderGrantsPanel } from "./local-folder-grants-panel";
|
|
24
25
|
import {
|
|
25
26
|
activatePackageVersion,
|
|
26
27
|
checkVersionStatus,
|
|
@@ -236,6 +237,7 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
|
|
|
236
237
|
};
|
|
237
238
|
|
|
238
239
|
/** Searchable list of installed packages, plus create/import from the search box. */
|
|
240
|
+
/** Packages screen: search, local folder grants, and the installed package list. */
|
|
239
241
|
export const PackageList = () => {
|
|
240
242
|
const [searchTextObs] = useState(() => observable(""));
|
|
241
243
|
const [searchText] = useObservable(searchTextObs);
|
|
@@ -437,6 +439,8 @@ export const PackageList = () => {
|
|
|
437
439
|
</ul>
|
|
438
440
|
</div>
|
|
439
441
|
|
|
442
|
+
<LocalFolderGrantsPanel />
|
|
443
|
+
|
|
440
444
|
<div className="peers-list-container">
|
|
441
445
|
<LazyList
|
|
442
446
|
resetTrigger={`${searchText}_${refreshKey()}`}
|
|
@@ -17,6 +17,7 @@ import { useObservable, usePromise } from "../../hooks";
|
|
|
17
17
|
import { LayoutPicker } from "../../root-layout/layout-picker";
|
|
18
18
|
import { identityTools } from "../device-pairing/identity-ipc";
|
|
19
19
|
import { IDENTITY_ACCOUNT_PATH, IDENTITY_PAIR_PATH } from "../identity/identity-routes";
|
|
20
|
+
import { getNetworkControlConsumer } from "../network-viewer/network-viewer-ipc";
|
|
20
21
|
import { ColorModeDropdown } from "./color-mode-dropdown";
|
|
21
22
|
|
|
22
23
|
/** Electron file picker attaches a filesystem path on `File`. */
|
|
@@ -262,10 +263,15 @@ const ResetDeviceSyncInfos: React.FC = () => {
|
|
|
262
263
|
className="btn btn-secondary btn-sm ms-2"
|
|
263
264
|
onClick={async () => {
|
|
264
265
|
try {
|
|
265
|
-
await
|
|
266
|
+
const result = await (await getNetworkControlConsumer()).tools.resetDeviceSyncInfo();
|
|
267
|
+
if (!result.success) {
|
|
268
|
+
alert(result.message);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
266
271
|
alert("All device sync info has been reset. Syncing in progress...");
|
|
267
272
|
} catch (err) {
|
|
268
273
|
console.error("Error while resetting all device sync info", err);
|
|
274
|
+
alert(`Failed to reset device sync info: ${(err as Error).message || String(err)}`);
|
|
269
275
|
}
|
|
270
276
|
}}
|
|
271
277
|
>
|
|
@@ -362,9 +368,9 @@ const ResetChangeTracking: React.FC = () => {
|
|
|
362
368
|
<div className="mt-4 pt-3 border-top">
|
|
363
369
|
<h6 className="mb-2">Change tracking</h6>
|
|
364
370
|
<small className="text-muted d-block mb-2">
|
|
365
|
-
Clears
|
|
366
|
-
rows. Device sync cursors are cleared; a full resync with peers may follow. Use
|
|
367
|
-
debugging sync issues.
|
|
371
|
+
Clears change history for every personal and group context on this device and rebuilds it
|
|
372
|
+
from current rows. Device sync cursors are cleared; a full resync with peers may follow. Use
|
|
373
|
+
when debugging sync issues.
|
|
368
374
|
</small>
|
|
369
375
|
<button
|
|
370
376
|
className="btn btn-warning btn-sm"
|
|
@@ -376,7 +382,11 @@ const ResetChangeTracking: React.FC = () => {
|
|
|
376
382
|
if (!confirmed) return;
|
|
377
383
|
setBusy(true);
|
|
378
384
|
try {
|
|
379
|
-
await
|
|
385
|
+
const result = await (await getNetworkControlConsumer()).tools.resetChangeTracking();
|
|
386
|
+
if (!result.success) {
|
|
387
|
+
alert(result.message);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
380
390
|
await rpcServerCalls.flushDatabases();
|
|
381
391
|
alert("Change tracking reset successfully.");
|
|
382
392
|
} catch (err) {
|
|
@@ -3,6 +3,7 @@ import { TrustLevel } from "@peers-app/peers-sdk";
|
|
|
3
3
|
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
|
4
4
|
import moment from "moment";
|
|
5
5
|
import { IDENTITY_ACCEPT_PATH, IDENTITY_ACCOUNT_PATH } from "../screens/identity/identity-routes";
|
|
6
|
+
import { networkViewerSection } from "../screens/network-viewer/network-viewer-section";
|
|
6
7
|
import {
|
|
7
8
|
connectionIdentityLabel,
|
|
8
9
|
formatRelativeSyncTime,
|
|
@@ -42,6 +43,7 @@ function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummar
|
|
|
42
43
|
errorCount: 0,
|
|
43
44
|
disconnectedContextCount: 0,
|
|
44
45
|
connectedContextCount: 0,
|
|
46
|
+
stuckRecoveryCount: 0,
|
|
45
47
|
errors: [],
|
|
46
48
|
...overrides,
|
|
47
49
|
};
|
|
@@ -153,6 +155,38 @@ describe("getNetworkStatusPresentation", () => {
|
|
|
153
155
|
);
|
|
154
156
|
});
|
|
155
157
|
|
|
158
|
+
it("turns yellow for stuck recovery without hiding sync activity or errors", () => {
|
|
159
|
+
expect(
|
|
160
|
+
getNetworkStatusPresentation({
|
|
161
|
+
availability: "ready",
|
|
162
|
+
status: status({ state: "synced", stuckRecoveryCount: 2 }),
|
|
163
|
+
}),
|
|
164
|
+
).toMatchObject({
|
|
165
|
+
colorClass: "text-warning",
|
|
166
|
+
iconClass: "bi-check-circle-fill",
|
|
167
|
+
label: expect.stringContaining("2 records need attention"),
|
|
168
|
+
});
|
|
169
|
+
expect(
|
|
170
|
+
getNetworkStatusPresentation(
|
|
171
|
+
{
|
|
172
|
+
availability: "ready",
|
|
173
|
+
status: status({ state: "syncing", syncingContextCount: 1, stuckRecoveryCount: 1 }),
|
|
174
|
+
},
|
|
175
|
+
{ syncingLongerThanGreenMs: true },
|
|
176
|
+
),
|
|
177
|
+
).toMatchObject({
|
|
178
|
+
colorClass: "text-warning",
|
|
179
|
+
mark: "indeterminate",
|
|
180
|
+
label: expect.stringContaining("1 record needs attention"),
|
|
181
|
+
});
|
|
182
|
+
expect(
|
|
183
|
+
getNetworkStatusPresentation({
|
|
184
|
+
availability: "ready",
|
|
185
|
+
status: status({ state: "error", errorCount: 1, stuckRecoveryCount: 4 }),
|
|
186
|
+
}).colorClass,
|
|
187
|
+
).toBe("text-danger");
|
|
188
|
+
});
|
|
189
|
+
|
|
156
190
|
it("holds a green pulse while waiting for stable synced", () => {
|
|
157
191
|
expect(
|
|
158
192
|
getNetworkStatusPresentation(
|
|
@@ -230,11 +264,31 @@ describe("getNetworkStatusPresentation", () => {
|
|
|
230
264
|
}).connectedGroupsOverlay,
|
|
231
265
|
).toBeUndefined();
|
|
232
266
|
});
|
|
267
|
+
|
|
268
|
+
it("turns an offline indicator yellow when recovery is stuck and keeps the offline icon", () => {
|
|
269
|
+
expect(
|
|
270
|
+
getNetworkStatusPresentation({
|
|
271
|
+
availability: "ready",
|
|
272
|
+
status: status({ state: "offline", connectionCount: 0, stuckRecoveryCount: 2 }),
|
|
273
|
+
}),
|
|
274
|
+
).toMatchObject({
|
|
275
|
+
iconClass: "bi-cloud-slash-fill",
|
|
276
|
+
colorClass: "text-warning",
|
|
277
|
+
label: expect.stringContaining("2 records need attention"),
|
|
278
|
+
});
|
|
279
|
+
expect(
|
|
280
|
+
getNetworkStatusPresentation({
|
|
281
|
+
availability: "ready",
|
|
282
|
+
status: status({ state: "error", errorCount: 1, stuckRecoveryCount: 2 }),
|
|
283
|
+
}).colorClass,
|
|
284
|
+
).toBe("text-danger");
|
|
285
|
+
});
|
|
233
286
|
});
|
|
234
287
|
|
|
235
288
|
describe("NetworkStatusIndicator", () => {
|
|
236
289
|
beforeEach(() => {
|
|
237
290
|
mockViewState = { availability: "loading" };
|
|
291
|
+
networkViewerSection("main");
|
|
238
292
|
getConnections.mockReset();
|
|
239
293
|
getConnections.mockResolvedValue({ connections: [] });
|
|
240
294
|
jest.clearAllMocks();
|
|
@@ -546,5 +600,82 @@ describe("NetworkStatusIndicator", () => {
|
|
|
546
600
|
fireEvent.click(view.getByRole("button", { name: /Network error/ }));
|
|
547
601
|
fireEvent.click(view.getByRole("button", { name: "Open Network Viewer" }));
|
|
548
602
|
expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
|
|
603
|
+
expect(networkViewerSection()).toBe("main");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("hides zero problem counts and Sync recovery when nothing needs attention", async () => {
|
|
607
|
+
mockViewState = {
|
|
608
|
+
availability: "ready",
|
|
609
|
+
status: status({
|
|
610
|
+
state: "synced",
|
|
611
|
+
connectionCount: 4,
|
|
612
|
+
connectedContextCount: 5,
|
|
613
|
+
lastSuccessfulSyncAt: Date.parse("2026-09-27T16:00:00.000Z"),
|
|
614
|
+
}),
|
|
615
|
+
};
|
|
616
|
+
const view = render(<NetworkStatusIndicator />);
|
|
617
|
+
await openNetworkDetails(view, /Network synced/);
|
|
618
|
+
|
|
619
|
+
const labels = [...view.getByRole("dialog").querySelectorAll("dt")].map((el) => el.textContent);
|
|
620
|
+
expect(labels).toEqual(["Connections", "Groups connected", "Last sync"]);
|
|
621
|
+
expect(view.queryByRole("button", { name: "Sync recovery" })).toBeNull();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("orders non-zero status rows and offers Sync recovery when records are stuck", async () => {
|
|
625
|
+
mockViewState = {
|
|
626
|
+
availability: "ready",
|
|
627
|
+
status: status({
|
|
628
|
+
state: "error",
|
|
629
|
+
connectionCount: 4,
|
|
630
|
+
connectedContextCount: 5,
|
|
631
|
+
disconnectedContextCount: 1,
|
|
632
|
+
syncingContextCount: 2,
|
|
633
|
+
errorCount: 3,
|
|
634
|
+
stuckRecoveryCount: 1,
|
|
635
|
+
lastSuccessfulSyncAt: Date.parse("2026-09-27T16:00:00.000Z"),
|
|
636
|
+
errors: [{ dataContextId: "context", message: "sync failed", occurredAt: 1 }],
|
|
637
|
+
}),
|
|
638
|
+
};
|
|
639
|
+
const view = render(<NetworkStatusIndicator />);
|
|
640
|
+
await openNetworkDetails(view, /Network error/);
|
|
641
|
+
|
|
642
|
+
const labels = [...view.getByRole("dialog").querySelectorAll("dt")].map((el) => el.textContent);
|
|
643
|
+
expect(labels).toEqual([
|
|
644
|
+
"Connections",
|
|
645
|
+
"Groups connected",
|
|
646
|
+
"Syncing contexts",
|
|
647
|
+
"Groups offline",
|
|
648
|
+
"Sync errors",
|
|
649
|
+
"Last sync",
|
|
650
|
+
]);
|
|
651
|
+
const groupsOffline = view.getByText("Groups offline");
|
|
652
|
+
expect(groupsOffline.className).toContain("text-warning");
|
|
653
|
+
expect(groupsOffline.nextElementSibling?.textContent).toBe("1");
|
|
654
|
+
expect(groupsOffline.nextElementSibling?.className).toContain("text-warning");
|
|
655
|
+
expect(view.getByRole("button", { name: "Sync recovery" }).className).toContain("btn-warning");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("opens Sync recovery inside the Network tab", async () => {
|
|
659
|
+
networkViewerSection("main");
|
|
660
|
+
mockViewState = {
|
|
661
|
+
availability: "ready",
|
|
662
|
+
status: status({ state: "synced", stuckRecoveryCount: 2 }),
|
|
663
|
+
};
|
|
664
|
+
const view = render(<NetworkStatusIndicator />);
|
|
665
|
+
await openNetworkDetails(view, /Network synced/);
|
|
666
|
+
fireEvent.click(view.getByRole("button", { name: "Sync recovery" }));
|
|
667
|
+
|
|
668
|
+
expect(networkViewerSection()).toBe("sync-recovery");
|
|
669
|
+
expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
|
|
670
|
+
expect(goToTabPath).not.toHaveBeenCalledWith("sync-recovery");
|
|
671
|
+
expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
|
|
549
672
|
});
|
|
550
673
|
});
|
|
674
|
+
|
|
675
|
+
async function openNetworkDetails(view: ReturnType<typeof render>, name: RegExp): Promise<void> {
|
|
676
|
+
fireEvent.click(view.getByRole("button", { name }));
|
|
677
|
+
await waitFor(() => expect(getConnections).toHaveBeenCalled());
|
|
678
|
+
await act(async () => {
|
|
679
|
+
await Promise.resolve();
|
|
680
|
+
});
|
|
681
|
+
}
|