@peers-app/peers-ui 0.26.0 → 0.26.2
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/app-updates/app-update-controls.d.ts +23 -0
- package/dist/app-updates/app-update-controls.js +40 -0
- package/dist/app-updates/app-updates-consumer.d.ts +41 -0
- package/dist/app-updates/app-updates-consumer.js +96 -0
- package/dist/app-updates/app-updates-consumer.test.d.ts +1 -0
- package/dist/app-updates/app-updates-consumer.test.js +56 -0
- package/dist/app-updates/app-updates-settings.d.ts +5 -0
- package/dist/app-updates/app-updates-settings.js +44 -0
- package/dist/app-updates/app-updates-settings.test.d.ts +1 -0
- package/dist/app-updates/app-updates-settings.test.js +93 -0
- package/dist/app-updates/remote-app-update-panel.d.ts +18 -0
- package/dist/app-updates/remote-app-update-panel.js +125 -0
- package/dist/app-updates/remote-app-update-panel.test.d.ts +1 -0
- package/dist/app-updates/remote-app-update-panel.test.js +165 -0
- package/dist/app-updates/use-app-update-status.d.ts +8 -0
- package/dist/app-updates/use-app-update-status.js +17 -0
- package/dist/screens/devices/devices-list.js +2 -1
- package/dist/screens/network-viewer/device-details-modal.js +2 -1
- package/dist/screens/settings/settings-page.js +5 -0
- package/dist/tabs-layout/network-status-indicator.d.ts +12 -0
- package/dist/tabs-layout/network-status-indicator.js +33 -5
- package/dist/tabs-layout/network-status-indicator.test.js +90 -0
- package/package.json +3 -3
- package/src/app-updates/app-update-controls.tsx +73 -0
- package/src/app-updates/app-updates-consumer.test.ts +83 -0
- package/src/app-updates/app-updates-consumer.ts +121 -0
- package/src/app-updates/app-updates-settings.test.tsx +109 -0
- package/src/app-updates/app-updates-settings.tsx +136 -0
- package/src/app-updates/remote-app-update-panel.test.tsx +205 -0
- package/src/app-updates/remote-app-update-panel.tsx +210 -0
- package/src/app-updates/use-app-update-status.ts +16 -0
- package/src/screens/devices/devices-list.tsx +4 -0
- package/src/screens/network-viewer/device-details-modal.tsx +6 -0
- package/src/screens/settings/settings-page.tsx +5 -0
- package/src/tabs-layout/network-status-indicator.test.tsx +119 -1
- package/src/tabs-layout/network-status-indicator.tsx +56 -5
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_1 = require("@testing-library/react");
|
|
5
|
+
const remote_app_update_panel_1 = require("./remote-app-update-panel");
|
|
6
|
+
const getDeviceUpdateStatus = jest.fn();
|
|
7
|
+
const installUpdateAndRestartOnDevice = jest.fn();
|
|
8
|
+
jest.mock("./app-updates-consumer", () => ({
|
|
9
|
+
...jest.requireActual("./app-updates-consumer"),
|
|
10
|
+
getAppUpdatesConsumer: () => Promise.resolve({
|
|
11
|
+
tools: {
|
|
12
|
+
getDeviceUpdateStatus: (...args) => getDeviceUpdateStatus(...args),
|
|
13
|
+
installUpdateAndRestartOnDevice: (...args) => installUpdateAndRestartOnDevice(...args),
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
function remoteStatus(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
supported: true,
|
|
20
|
+
canRestart: true,
|
|
21
|
+
platform: "win32",
|
|
22
|
+
currentVersion: "1.0.0",
|
|
23
|
+
latestVersion: "1.1.0",
|
|
24
|
+
downloadedVersion: "1.1.0",
|
|
25
|
+
state: "downloaded",
|
|
26
|
+
installOnRestart: true,
|
|
27
|
+
installOnQuitArmed: true,
|
|
28
|
+
remoteRestartAllowed: true,
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
describe("RemoteAppUpdatePanel", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
getDeviceUpdateStatus.mockReset();
|
|
35
|
+
installUpdateAndRestartOnDevice.mockReset();
|
|
36
|
+
installUpdateAndRestartOnDevice.mockResolvedValue({ action: "installing" });
|
|
37
|
+
jest.spyOn(window, "confirm").mockReturnValue(true);
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
jest.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
it("updates the other device after confirmation", async () => {
|
|
43
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
|
|
44
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop", deviceName: "Desktop" }));
|
|
45
|
+
const button = await view.findByRole("button", { name: /Update & restart/ });
|
|
46
|
+
expect(view.getByTestId("remote-app-update")).toHaveTextContent("Version 1.0.0 · Version 1.1.0 is ready to install");
|
|
47
|
+
expect(getDeviceUpdateStatus).toHaveBeenCalledWith({ deviceId: "desktop" });
|
|
48
|
+
react_1.fireEvent.click(button);
|
|
49
|
+
expect(window.confirm).toHaveBeenCalledWith("Update Desktop to 1.1.0 and restart it?");
|
|
50
|
+
await (0, react_1.waitFor)(() => expect(installUpdateAndRestartOnDevice).toHaveBeenCalledWith({ deviceId: "desktop" }));
|
|
51
|
+
await view.findByText("Request sent");
|
|
52
|
+
});
|
|
53
|
+
it("offers a plain restart when the device is up to date", async () => {
|
|
54
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ state: "up-to-date" }));
|
|
55
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
56
|
+
react_1.fireEvent.click(await view.findByRole("button", { name: /^Restart$/ }));
|
|
57
|
+
expect(window.confirm).toHaveBeenCalledWith("Restart desktop? It will check for an update first.");
|
|
58
|
+
await (0, react_1.waitFor)(() => expect(installUpdateAndRestartOnDevice).toHaveBeenCalled());
|
|
59
|
+
});
|
|
60
|
+
it("does nothing when the confirmation is declined", async () => {
|
|
61
|
+
window.confirm.mockReturnValue(false);
|
|
62
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
|
|
63
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
64
|
+
react_1.fireEvent.click(await view.findByRole("button", { name: /Update & restart/ }));
|
|
65
|
+
expect(installUpdateAndRestartOnDevice).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
it("explains why the action is disabled", async () => {
|
|
68
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ remoteRestartAllowed: false }));
|
|
69
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
70
|
+
expect(await view.findByRole("button", { name: /Update & restart/ })).toBeDisabled();
|
|
71
|
+
expect(view.getByTestId("remote-app-update")).toHaveTextContent("does not allow remote restarts");
|
|
72
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ installNeedsElevation: true }));
|
|
73
|
+
const elevated = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "linux-box" }));
|
|
74
|
+
await (0, react_1.waitFor)(() => expect(elevated.getAllByRole("button", { name: /Update & restart/ })[1]).toBeDisabled());
|
|
75
|
+
expect(elevated.getAllByTestId("remote-app-update")[1]).toHaveTextContent("administrator password");
|
|
76
|
+
});
|
|
77
|
+
it.each([
|
|
78
|
+
[
|
|
79
|
+
"a device that cannot restart itself",
|
|
80
|
+
() => Promise.resolve(remoteStatus({ canRestart: false })),
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
"a device that does not trust this user",
|
|
84
|
+
() => Promise.reject(new Error("Permission denied: tool 'getUpdateStatus'.run on contract x")),
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
"a build without App Updates",
|
|
88
|
+
() => Promise.reject(new Error("No contract provider registered for x v1")),
|
|
89
|
+
],
|
|
90
|
+
])("renders nothing for %s", async (_label, result) => {
|
|
91
|
+
getDeviceUpdateStatus.mockImplementation(result);
|
|
92
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
93
|
+
await (0, react_1.waitFor)(() => expect(view.queryByTestId("remote-app-update")).toBeNull());
|
|
94
|
+
});
|
|
95
|
+
it("reports an unreachable device", async () => {
|
|
96
|
+
getDeviceUpdateStatus.mockRejectedValue(new Error("Device desktop is not connected"));
|
|
97
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
98
|
+
await view.findByText("Not reachable: Device desktop is not connected");
|
|
99
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
|
|
100
|
+
react_1.fireEvent.click(view.getByRole("button", { name: "Retry" }));
|
|
101
|
+
await view.findByRole("button", { name: /Update & restart/ });
|
|
102
|
+
});
|
|
103
|
+
it("clears the previous device's action while a new target loads", async () => {
|
|
104
|
+
getDeviceUpdateStatus.mockResolvedValueOnce(remoteStatus());
|
|
105
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
106
|
+
await view.findByRole("button", { name: /Update & restart/ });
|
|
107
|
+
let finish;
|
|
108
|
+
getDeviceUpdateStatus.mockImplementationOnce(() => new Promise((resolve) => {
|
|
109
|
+
finish = resolve;
|
|
110
|
+
}));
|
|
111
|
+
view.rerender((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "another-desktop" }));
|
|
112
|
+
expect(view.queryByRole("button", { name: /Update & restart/ })).toBeNull();
|
|
113
|
+
expect(view.getByText("Checking this device…")).toBeTruthy();
|
|
114
|
+
await (0, react_1.act)(async () => {
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
finish(remoteStatus({ remoteRestartAllowed: false }));
|
|
117
|
+
});
|
|
118
|
+
expect(view.getByRole("button", { name: /Update & restart/ })).toBeDisabled();
|
|
119
|
+
});
|
|
120
|
+
it("accepts slow poll responses without overlapping requests and cleans up timers", async () => {
|
|
121
|
+
jest.useFakeTimers();
|
|
122
|
+
try {
|
|
123
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
|
|
124
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
125
|
+
await (0, react_1.act)(async () => { });
|
|
126
|
+
let finish;
|
|
127
|
+
getDeviceUpdateStatus.mockImplementationOnce(() => new Promise((resolve) => {
|
|
128
|
+
finish = resolve;
|
|
129
|
+
}));
|
|
130
|
+
react_1.fireEvent.click(view.getByRole("button", { name: /Update & restart/ }));
|
|
131
|
+
await (0, react_1.act)(async () => { });
|
|
132
|
+
await (0, react_1.act)(async () => {
|
|
133
|
+
jest.advanceTimersByTime(remote_app_update_panel_1.REMOTE_UPDATE_POLL_MS * 3);
|
|
134
|
+
});
|
|
135
|
+
expect(getDeviceUpdateStatus).toHaveBeenCalledTimes(2);
|
|
136
|
+
await (0, react_1.act)(async () => {
|
|
137
|
+
finish(remoteStatus({ currentVersion: "1.1.0", state: "up-to-date" }));
|
|
138
|
+
});
|
|
139
|
+
expect(view.getByText("Version 1.1.0 · Up to date")).toBeTruthy();
|
|
140
|
+
await (0, react_1.act)(async () => {
|
|
141
|
+
jest.advanceTimersByTime(remote_app_update_panel_1.REMOTE_UPDATE_POLL_MS);
|
|
142
|
+
});
|
|
143
|
+
expect(getDeviceUpdateStatus).toHaveBeenCalledTimes(3);
|
|
144
|
+
view.unmount();
|
|
145
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
jest.useRealTimers();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
it("confirms the newer downloading version instead of the older cached version", async () => {
|
|
152
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ state: "downloading", latestVersion: "1.2.0", downloadedVersion: "1.1.0" }));
|
|
153
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop", deviceName: "Desktop" }));
|
|
154
|
+
react_1.fireEvent.click(await view.findByRole("button", { name: /Update & restart/ }));
|
|
155
|
+
expect(window.confirm).toHaveBeenCalledWith("Update Desktop to 1.2.0 and restart it?");
|
|
156
|
+
await view.findByText("Request sent");
|
|
157
|
+
});
|
|
158
|
+
it("shows a failed request", async () => {
|
|
159
|
+
getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
|
|
160
|
+
installUpdateAndRestartOnDevice.mockRejectedValue(new Error("Permission denied: this device does not allow other devices to update"));
|
|
161
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
|
|
162
|
+
react_1.fireEvent.click(await view.findByRole("button", { name: /Update & restart/ }));
|
|
163
|
+
await view.findByText(/does not allow other devices to update/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IAppUpdateStatus } from "@peers-app/peers-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Subscribe to the local host's application update status.
|
|
4
|
+
*
|
|
5
|
+
* @returns The live status, or `undefined` while the host transport is down. Hosts that
|
|
6
|
+
* cannot update themselves (PWA, headless) report `supported: false`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useAppUpdateStatus(): IAppUpdateStatus | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useAppUpdateStatus = useAppUpdateStatus;
|
|
4
|
+
const hooks_1 = require("../hooks");
|
|
5
|
+
const use_host_contract_1 = require("../hooks/use-host-contract");
|
|
6
|
+
const app_updates_consumer_1 = require("./app-updates-consumer");
|
|
7
|
+
/**
|
|
8
|
+
* Subscribe to the local host's application update status.
|
|
9
|
+
*
|
|
10
|
+
* @returns The live status, or `undefined` while the host transport is down. Hosts that
|
|
11
|
+
* cannot update themselves (PWA, headless) report `supported: false`.
|
|
12
|
+
*/
|
|
13
|
+
function useAppUpdateStatus() {
|
|
14
|
+
const { consumer } = (0, use_host_contract_1.useHostContract)(app_updates_consumer_1.appUpdatesContract);
|
|
15
|
+
const [status] = (0, hooks_1.useObservable)(consumer?.observables.status);
|
|
16
|
+
return status;
|
|
17
|
+
}
|
|
@@ -6,6 +6,7 @@ exports.devicesForUser = devicesForUser;
|
|
|
6
6
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
7
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
8
8
|
const react_1 = require("react");
|
|
9
|
+
const remote_app_update_panel_1 = require("../../app-updates/remote-app-update-panel");
|
|
9
10
|
const input_1 = require("../../components/input");
|
|
10
11
|
const loading_indicator_1 = require("../../components/loading-indicator");
|
|
11
12
|
const globals_1 = require("../../globals");
|
|
@@ -133,7 +134,7 @@ function DevicesSection(props) {
|
|
|
133
134
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-1", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-laptop me-2" }), "My devices"] }), (0, jsx_runtime_1.jsxs)("div", { className: "small text-muted", children: ["Installations signed in to this account. Connection diagnostics stay in", " ", (0, jsx_runtime_1.jsx)("a", { href: "#network-viewer", children: "Network Viewer" }), "."] })] }), (0, jsx_runtime_1.jsx)(activity_view_1.PendingWorkLink, { type: "device" }), (0, jsx_runtime_1.jsxs)("div", { className: "list-group", children: [mine ? ((0, jsx_runtime_1.jsx)(DeviceRow, { device: mine, connection: byId.get(mine.deviceId), isThisDevice: true })) : null, others.map((device) => ((0, jsx_runtime_1.jsx)(DeviceRow, { device: device, connection: byId.get(device.deviceId), isThisDevice: false }, device.deviceId)))] }), !mine && others.length === 0 ? ((0, jsx_runtime_1.jsxs)("div", { className: "border rounded p-4 text-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-laptop fs-2 text-muted" }), (0, jsx_runtime_1.jsx)("h6", { className: "mt-2", children: "No devices are registered yet" }), (0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_PAIR_PATH}`, className: "btn btn-primary btn-sm", children: "Add a device" })] })) : null, mine && others.length === 0 ? ((0, jsx_runtime_1.jsxs)("p", { className: "small text-muted mt-3", children: ["This is your only device. Use ", (0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_PAIR_PATH}`, children: "Add device" }), " to sign in somewhere else."] })) : null] }));
|
|
134
135
|
}
|
|
135
136
|
function DeviceDetail(props) {
|
|
136
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_DEVICES_PATH}`, className: "btn btn-link btn-sm px-0 mb-2", children: "Back to devices" }), (0, jsx_runtime_1.jsx)("h4", { className: "mb-3", children: props.device.name || "Unnamed device" }), props.isThisDevice ? (0, jsx_runtime_1.jsx)(ThisDeviceCard, { device: props.device }) : null, (0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-3", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Trust" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: trustLabel(props.device.trustLevel) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Connection" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: connectionLabel(props.connection) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Last seen" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: props.device.lastSeen ? new Date(props.device.lastSeen).toLocaleString() : "Unknown" }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Device ID" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8 text-break", children: props.device.deviceId })] }), (0, jsx_runtime_1.jsx)("a", { href: "#network-viewer", className: "btn btn-outline-secondary btn-sm me-2", children: "Open Network Viewer" }), props.isThisDevice ? null : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger btn-sm", onClick: () => void forgetDevice(props.device), children: "Forget" }))] }));
|
|
137
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_DEVICES_PATH}`, className: "btn btn-link btn-sm px-0 mb-2", children: "Back to devices" }), (0, jsx_runtime_1.jsx)("h4", { className: "mb-3", children: props.device.name || "Unnamed device" }), props.isThisDevice ? (0, jsx_runtime_1.jsx)(ThisDeviceCard, { device: props.device }) : null, (0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-3", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Trust" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: trustLabel(props.device.trustLevel) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Connection" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: connectionLabel(props.connection) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Last seen" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: props.device.lastSeen ? new Date(props.device.lastSeen).toLocaleString() : "Unknown" }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Device ID" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8 text-break", children: props.device.deviceId })] }), (0, jsx_runtime_1.jsx)("a", { href: "#network-viewer", className: "btn btn-outline-secondary btn-sm me-2", children: "Open Network Viewer" }), props.isThisDevice ? null : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger btn-sm", onClick: () => void forgetDevice(props.device), children: "Forget" })), props.isThisDevice ? null : ((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: props.device.deviceId, deviceName: props.device.name }))] }));
|
|
137
138
|
}
|
|
138
139
|
async function forgetDevice(device) {
|
|
139
140
|
const confirmed = confirm(`Forget ${device.name || device.deviceId}?`);
|
|
@@ -4,6 +4,7 @@ exports.DeviceDetailsModal = DeviceDetailsModal;
|
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
5
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
6
6
|
const react_1 = require("react");
|
|
7
|
+
const remote_app_update_panel_1 = require("../../app-updates/remote-app-update-panel");
|
|
7
8
|
const loading_indicator_1 = require("../../components/loading-indicator");
|
|
8
9
|
const trust_level_badge_1 = require("../../components/trust-level-badge");
|
|
9
10
|
const network_viewer_ipc_1 = require("./network-viewer-ipc");
|
|
@@ -84,7 +85,7 @@ function DeviceDetailsModal({ deviceId, onClose, onDisconnect }) {
|
|
|
84
85
|
overflowY: "auto",
|
|
85
86
|
paddingTop: "2rem",
|
|
86
87
|
paddingBottom: "2rem",
|
|
87
|
-
}, onClick: onClose, children: (0, jsx_runtime_1.jsxs)("div", { className: "card", style: { maxWidth: "900px", width: "90%", maxHeight: "90vh" }, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("div", { className: "card-header d-flex justify-content-between align-items-center", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-0", children: loading ? "Loading..." : device?.userName || device?.userId || "Device Details" }), device?.userName && ((0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: (0, jsx_runtime_1.jsx)("code", { children: device.userId }) }))] }), (0, jsx_runtime_1.jsxs)("div", { children: [device && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-danger me-2", onClick: handleDisconnect, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-x-circle" }), " Disconnect"] })), (0, jsx_runtime_1.jsx)("button", { className: "btn-close", onClick: onClose, "aria-label": "Close" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "card-body", style: { overflowY: "auto", maxHeight: "calc(90vh - 120px)" }, children: [loading && (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}), !loading && (notFound || !device) && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning", children: "Device not found or connection has been closed." })), !loading && device && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Connection Information" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [device.deviceName && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Name:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: device.deviceName })] })), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device ID:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: device.deviceId })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Connection State:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: "badge bg-success", children: device.connectionState })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "User Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.userTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.deviceTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path Type:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: `badge bg-${device.pathType === "direct" ? "success" : "info"}`, children: device.pathType })] }), device.throughDeviceIds && device.throughDeviceIds.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-12 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: device.throughDeviceIds.join(" → ") })] }))] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Throughput" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Send Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-success", children: ["\u2191 ", formatRate(device.sendRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Receive Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-primary", children: ["\u2193 ", formatRate(device.receiveRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Sent:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesSent || 0) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Received:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesReceived || 0) })] })] })] }), device.canReadRemoteLogs && (0, jsx_runtime_1.jsx)(remote_logs_panel_1.RemoteLogsPanel, { deviceId: device.deviceId }), device.sharedGroups.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsxs)("h6", { className: "border-bottom pb-2 mb-3", children: ["Shared Groups (", device.sharedGroups.length, ")"] }), (0, jsx_runtime_1.jsx)("div", { className: "table-responsive", children: (0, jsx_runtime_1.jsxs)("table", { className: "table table-sm table-hover", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("th", { children: "Group ID" }), (0, jsx_runtime_1.jsx)("th", { children: "Role" }), (0, jsx_runtime_1.jsx)("th", { children: "Sync Status" })] }) }), (0, jsx_runtime_1.jsx)("tbody", { children: device.sharedGroups.map((groupId) => {
|
|
88
|
+
}, onClick: onClose, children: (0, jsx_runtime_1.jsxs)("div", { className: "card", style: { maxWidth: "900px", width: "90%", maxHeight: "90vh" }, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("div", { className: "card-header d-flex justify-content-between align-items-center", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-0", children: loading ? "Loading..." : device?.userName || device?.userId || "Device Details" }), device?.userName && ((0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: (0, jsx_runtime_1.jsx)("code", { children: device.userId }) }))] }), (0, jsx_runtime_1.jsxs)("div", { children: [device && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-danger me-2", onClick: handleDisconnect, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-x-circle" }), " Disconnect"] })), (0, jsx_runtime_1.jsx)("button", { className: "btn-close", onClick: onClose, "aria-label": "Close" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "card-body", style: { overflowY: "auto", maxHeight: "calc(90vh - 120px)" }, children: [loading && (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}), !loading && (notFound || !device) && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning", children: "Device not found or connection has been closed." })), !loading && device && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Connection Information" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [device.deviceName && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Name:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: device.deviceName })] })), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device ID:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: device.deviceId })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Connection State:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: "badge bg-success", children: device.connectionState })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "User Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.userTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.deviceTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path Type:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: `badge bg-${device.pathType === "direct" ? "success" : "info"}`, children: device.pathType })] }), device.throughDeviceIds && device.throughDeviceIds.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-12 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: device.throughDeviceIds.join(" → ") })] }))] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Throughput" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Send Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-success", children: ["\u2191 ", formatRate(device.sendRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Receive Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-primary", children: ["\u2193 ", formatRate(device.receiveRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Sent:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesSent || 0) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Received:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesReceived || 0) })] })] })] }), device.canReadRemoteLogs && (0, jsx_runtime_1.jsx)(remote_logs_panel_1.RemoteLogsPanel, { deviceId: device.deviceId }), device.canReadRemoteLogs && ((0, jsx_runtime_1.jsx)("div", { className: "mb-4", children: (0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: device.deviceId, deviceName: device.deviceName }) })), device.sharedGroups.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsxs)("h6", { className: "border-bottom pb-2 mb-3", children: ["Shared Groups (", device.sharedGroups.length, ")"] }), (0, jsx_runtime_1.jsx)("div", { className: "table-responsive", children: (0, jsx_runtime_1.jsxs)("table", { className: "table table-sm table-hover", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("th", { children: "Group ID" }), (0, jsx_runtime_1.jsx)("th", { children: "Role" }), (0, jsx_runtime_1.jsx)("th", { children: "Sync Status" })] }) }), (0, jsx_runtime_1.jsx)("tbody", { children: device.sharedGroups.map((groupId) => {
|
|
88
89
|
const syncStats = device.syncStatsByGroup[groupId];
|
|
89
90
|
const role = device.roles[groupId];
|
|
90
91
|
return ((0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("td", { children: (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: groupId }) }), (0, jsx_runtime_1.jsx)("td", { children: role != null ? String(role) : "N/A" }), (0, jsx_runtime_1.jsx)("td", { children: syncStats ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [syncStats.isPreferred && ((0, jsx_runtime_1.jsx)("span", { className: "badge bg-primary me-2", children: "Preferred" })), (0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: new Date(syncStats.lastSyncTime).toLocaleString() })] })) : ((0, jsx_runtime_1.jsx)("span", { className: "text-muted", children: "-" })) })] }, groupId));
|
|
@@ -37,6 +37,7 @@ exports.SettingsPage = void 0;
|
|
|
37
37
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
38
38
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
39
39
|
const react_1 = require("react");
|
|
40
|
+
const app_updates_settings_1 = require("../../app-updates/app-updates-settings");
|
|
40
41
|
const input_1 = require("../../components/input");
|
|
41
42
|
const tabs_1 = require("../../components/tabs");
|
|
42
43
|
const tooltip_1 = require("../../components/tooltip");
|
|
@@ -65,6 +66,10 @@ const SettingsPage = () => {
|
|
|
65
66
|
name: "Appearance",
|
|
66
67
|
content: (0, jsx_runtime_1.jsx)(AppearanceSettingsTab, {}),
|
|
67
68
|
},
|
|
69
|
+
{
|
|
70
|
+
name: "Updates",
|
|
71
|
+
content: (0, jsx_runtime_1.jsx)(app_updates_settings_1.AppUpdatesSettings, {}),
|
|
72
|
+
},
|
|
68
73
|
{
|
|
69
74
|
name: "Advanced",
|
|
70
75
|
content: (0, jsx_runtime_1.jsx)(AdvancedSettingsTab, {}),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IAppUpdateStatus } from "@peers-app/peers-sdk";
|
|
1
2
|
import { type INetworkStatusViewState } from "./use-network-status";
|
|
2
3
|
/** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
|
|
3
4
|
export declare const SYNCING_GREEN_MS = 4000;
|
|
@@ -30,6 +31,8 @@ interface INetworkStatusPresentation {
|
|
|
30
31
|
count: number;
|
|
31
32
|
colorClass: "text-success" | "text-danger";
|
|
32
33
|
};
|
|
34
|
+
/** Bottom-left yellow upgrade arrow when this device has an app update available. */
|
|
35
|
+
updateAvailableOverlay?: boolean;
|
|
33
36
|
}
|
|
34
37
|
/** Convert host availability and network state into accessible indicator text and styling. */
|
|
35
38
|
export declare function getNetworkStatusPresentation(viewState: INetworkStatusViewState, opts?: {
|
|
@@ -37,6 +40,15 @@ export declare function getNetworkStatusPresentation(viewState: INetworkStatusVi
|
|
|
37
40
|
/** Keep the calm green syncing pulse while waiting for stable synced. */
|
|
38
41
|
holdSyncingVisual?: boolean;
|
|
39
42
|
}): INetworkStatusPresentation;
|
|
43
|
+
/**
|
|
44
|
+
* Add the app-update badge and label to a network status presentation. Applies in every
|
|
45
|
+
* network state, offline included, because installing does not depend on connectivity.
|
|
46
|
+
*
|
|
47
|
+
* @param presentation - Presentation from {@link getNetworkStatusPresentation}.
|
|
48
|
+
* @param appUpdate - This device's update status, if known.
|
|
49
|
+
* @returns The presentation, with `updateAvailableOverlay` when an update is available.
|
|
50
|
+
*/
|
|
51
|
+
export declare function withAppUpdate(presentation: INetworkStatusPresentation, appUpdate: IAppUpdateStatus | undefined): INetworkStatusPresentation;
|
|
40
52
|
/** Compact relative time for the status popover (always short enough for one line). */
|
|
41
53
|
export declare function formatRelativeSyncTime(timestampMs: number, nowMs?: number): string;
|
|
42
54
|
/** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.SYNCED_STABLE_MS = exports.SYNCING_GREEN_MS = void 0;
|
|
7
7
|
exports.getNetworkStatusPresentation = getNetworkStatusPresentation;
|
|
8
|
+
exports.withAppUpdate = withAppUpdate;
|
|
8
9
|
exports.formatRelativeSyncTime = formatRelativeSyncTime;
|
|
9
10
|
exports.connectionIdentityLabel = connectionIdentityLabel;
|
|
10
11
|
exports.NetworkStatusIndicator = NetworkStatusIndicator;
|
|
@@ -12,6 +13,9 @@ exports.TabsStripIndicators = TabsStripIndicators;
|
|
|
12
13
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
13
14
|
const moment_1 = __importDefault(require("moment"));
|
|
14
15
|
const react_1 = require("react");
|
|
16
|
+
const app_update_controls_1 = require("../app-updates/app-update-controls");
|
|
17
|
+
const app_updates_consumer_1 = require("../app-updates/app-updates-consumer");
|
|
18
|
+
const use_app_update_status_1 = require("../app-updates/use-app-update-status");
|
|
15
19
|
const identity_routes_1 = require("../screens/identity/identity-routes");
|
|
16
20
|
const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-ipc");
|
|
17
21
|
const network_viewer_section_1 = require("../screens/network-viewer/network-viewer-section");
|
|
@@ -125,6 +129,24 @@ function getNetworkStatusPresentation(viewState, opts) {
|
|
|
125
129
|
connectedGroupsOverlay: groupsOverlay,
|
|
126
130
|
}, stuckRecoveryCount);
|
|
127
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Add the app-update badge and label to a network status presentation. Applies in every
|
|
134
|
+
* network state, offline included, because installing does not depend on connectivity.
|
|
135
|
+
*
|
|
136
|
+
* @param presentation - Presentation from {@link getNetworkStatusPresentation}.
|
|
137
|
+
* @param appUpdate - This device's update status, if known.
|
|
138
|
+
* @returns The presentation, with `updateAvailableOverlay` when an update is available.
|
|
139
|
+
*/
|
|
140
|
+
function withAppUpdate(presentation, appUpdate) {
|
|
141
|
+
if (!appUpdate?.supported || !(0, app_updates_consumer_1.hasAppUpdate)(appUpdate)) {
|
|
142
|
+
return presentation;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...presentation,
|
|
146
|
+
label: `${presentation.label}; ${(0, app_updates_consumer_1.describeAppUpdateStatus)(appUpdate)}`,
|
|
147
|
+
updateAvailableOverlay: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
128
150
|
function statusMarkBgClass(colorClass) {
|
|
129
151
|
if (colorClass === "text-danger") {
|
|
130
152
|
return "bg-danger";
|
|
@@ -211,14 +233,15 @@ function StatusCountRow({ label, value, className, }) {
|
|
|
211
233
|
const tone = className ? ` ${className}` : "";
|
|
212
234
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: `col-7${tone}`, children: label }), (0, jsx_runtime_1.jsx)("dd", { className: `col-5 text-end${tone}`, children: value })] }));
|
|
213
235
|
}
|
|
214
|
-
function NetworkStatusDetails({ detailsId, status, unavailable, connectionsLoadState, onUseInvite, onOpenIdentity, onOpenViewer, onOpenRecovery, }) {
|
|
215
|
-
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.jsxs)("div", { className: "d-flex gap-2 mb-3", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onUseInvite, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-qr-code me-1", "aria-hidden": "true" }), "Use Invite"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onOpenIdentity, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-badge me-1", "aria-hidden": "true" }), "Identity"] })] }), (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)(StatusCountRow, { label: "Connections", value: status.connectionCount }), (0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups connected", value: status.connectedContextCount }), status.syncingContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Syncing contexts", value: status.syncingContextCount })), status.disconnectedContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups offline", value: status.disconnectedContextCount, className: "text-warning" })), status.errorCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Sync errors", value: 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)`] })), status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning py-2 px-2 small mb-2", children: status.stuckRecoveryCount === 1
|
|
236
|
+
function NetworkStatusDetails({ detailsId, status, appUpdate, unavailable, connectionsLoadState, onUseInvite, onOpenIdentity, onOpenViewer, onOpenRecovery, }) {
|
|
237
|
+
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.jsxs)("div", { className: "d-flex gap-2 mb-3", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onUseInvite, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-qr-code me-1", "aria-hidden": "true" }), "Use Invite"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onOpenIdentity, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-badge me-1", "aria-hidden": "true" }), "Identity"] })] }), (0, jsx_runtime_1.jsx)(app_update_controls_1.AppUpdatePopoverSection, { status: appUpdate }), (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)(StatusCountRow, { label: "Connections", value: status.connectionCount }), (0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups connected", value: status.connectedContextCount }), status.syncingContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Syncing contexts", value: status.syncingContextCount })), status.disconnectedContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups offline", value: status.disconnectedContextCount, className: "text-warning" })), status.errorCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Sync errors", value: 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)`] })), status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning py-2 px-2 small mb-2", children: status.stuckRecoveryCount === 1
|
|
216
238
|
? "1 record needs attention"
|
|
217
239
|
: `${status.stuckRecoveryCount} records need attention` })), (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: "Connected 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${status && status.stuckRecoveryCount > 0 ? " mb-2" : ""}`, onClick: onOpenViewer, children: "Open Network Viewer" }), status && status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-warning w-100", onClick: onOpenRecovery, children: "Sync recovery" }))] }) }));
|
|
218
240
|
}
|
|
219
241
|
/** Accessible reactive network status button and details popover. */
|
|
220
242
|
function NetworkStatusIndicator({ compact = false }) {
|
|
221
243
|
const viewState = (0, use_network_status_1.useNetworkStatus)();
|
|
244
|
+
const appUpdate = (0, use_app_update_status_1.useAppUpdateStatus)();
|
|
222
245
|
const [displaySyncing, setDisplaySyncing] = (0, react_1.useState)(false);
|
|
223
246
|
const [syncingLongerThanGreenMs, setSyncingLongerThanGreenMs] = (0, react_1.useState)(false);
|
|
224
247
|
const [open, setOpen] = (0, react_1.useState)(false);
|
|
@@ -286,10 +309,10 @@ function NetworkStatusIndicator({ compact = false }) {
|
|
|
286
309
|
};
|
|
287
310
|
}, [open, diagnosticsReady, connectionCount]);
|
|
288
311
|
const holdSyncingVisual = displaySyncing && !rawIsSyncing;
|
|
289
|
-
const presentation = getNetworkStatusPresentation(viewState, {
|
|
312
|
+
const presentation = withAppUpdate(getNetworkStatusPresentation(viewState, {
|
|
290
313
|
syncingLongerThanGreenMs,
|
|
291
314
|
holdSyncingVisual,
|
|
292
|
-
});
|
|
315
|
+
}), appUpdate);
|
|
293
316
|
(0, react_1.useEffect)(() => {
|
|
294
317
|
if (!open) {
|
|
295
318
|
return;
|
|
@@ -329,7 +352,12 @@ function NetworkStatusIndicator({ compact = false }) {
|
|
|
329
352
|
fontWeight: 700,
|
|
330
353
|
right: "-0.28rem",
|
|
331
354
|
bottom: "-0.32rem",
|
|
332
|
-
}, "aria-hidden": "true", children: presentation.disconnectedGroupsOverlay.count }))
|
|
355
|
+
}, "aria-hidden": "true", children: presentation.disconnectedGroupsOverlay.count })), presentation.updateAvailableOverlay && ((0, jsx_runtime_1.jsx)("i", { className: `network-status-update-badge bi ${app_update_controls_1.APP_UPDATE_ICON_CLASS} text-warning position-absolute`, style: {
|
|
356
|
+
fontSize: "0.5rem",
|
|
357
|
+
lineHeight: 1,
|
|
358
|
+
left: "-0.3rem",
|
|
359
|
+
bottom: "-0.32rem",
|
|
360
|
+
}, "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, appUpdate: appUpdate, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onUseInvite: () => {
|
|
333
361
|
setOpen(false);
|
|
334
362
|
(0, tabs_state_1.goToTabPath)(identity_routes_1.IDENTITY_ACCEPT_PATH);
|
|
335
363
|
}, onOpenIdentity: () => {
|
|
@@ -13,12 +13,40 @@ const network_status_indicator_1 = require("./network-status-indicator");
|
|
|
13
13
|
const goToTabPath = jest.fn();
|
|
14
14
|
const getConnections = jest.fn();
|
|
15
15
|
let mockViewState = { availability: "loading" };
|
|
16
|
+
let mockAppUpdate;
|
|
17
|
+
const installUpdateAndRestart = jest.fn();
|
|
16
18
|
jest.mock("./tabs-state", () => ({
|
|
17
19
|
goToTabPath: (...args) => goToTabPath(...args),
|
|
18
20
|
}));
|
|
19
21
|
jest.mock("./use-network-status", () => ({
|
|
20
22
|
useNetworkStatus: () => mockViewState,
|
|
21
23
|
}));
|
|
24
|
+
jest.mock("../app-updates/use-app-update-status", () => ({
|
|
25
|
+
useAppUpdateStatus: () => mockAppUpdate,
|
|
26
|
+
}));
|
|
27
|
+
jest.mock("../app-updates/app-updates-consumer", () => ({
|
|
28
|
+
...jest.requireActual("../app-updates/app-updates-consumer"),
|
|
29
|
+
getAppUpdatesConsumer: () => Promise.resolve({
|
|
30
|
+
tools: {
|
|
31
|
+
installUpdateAndRestart: (...args) => installUpdateAndRestart(...args),
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
function appUpdate(overrides = {}) {
|
|
36
|
+
return {
|
|
37
|
+
supported: true,
|
|
38
|
+
canRestart: true,
|
|
39
|
+
platform: "darwin",
|
|
40
|
+
currentVersion: "1.0.0",
|
|
41
|
+
latestVersion: "1.1.0",
|
|
42
|
+
downloadedVersion: "1.1.0",
|
|
43
|
+
state: "downloaded",
|
|
44
|
+
installOnRestart: true,
|
|
45
|
+
installOnQuitArmed: true,
|
|
46
|
+
remoteRestartAllowed: true,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
22
50
|
jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
|
|
23
51
|
getNetworkDiagnosticsConsumer: () => Promise.resolve({
|
|
24
52
|
tools: {
|
|
@@ -225,9 +253,49 @@ describe("getNetworkStatusPresentation", () => {
|
|
|
225
253
|
}).colorClass).toBe("text-danger");
|
|
226
254
|
});
|
|
227
255
|
});
|
|
256
|
+
describe("withAppUpdate", () => {
|
|
257
|
+
const synced = (0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
258
|
+
availability: "ready",
|
|
259
|
+
status: status({ state: "synced" }),
|
|
260
|
+
});
|
|
261
|
+
it("leaves the presentation alone without an available update", () => {
|
|
262
|
+
expect((0, network_status_indicator_1.withAppUpdate)(synced, undefined)).toBe(synced);
|
|
263
|
+
expect((0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ state: "up-to-date" }))).toBe(synced);
|
|
264
|
+
expect((0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ supported: false }))).toBe(synced);
|
|
265
|
+
expect((0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({
|
|
266
|
+
state: "error",
|
|
267
|
+
latestVersion: "1.0.0",
|
|
268
|
+
downloadedVersion: undefined,
|
|
269
|
+
error: "feed unreachable",
|
|
270
|
+
}))).toBe(synced);
|
|
271
|
+
});
|
|
272
|
+
it("keeps the update badge after a failed download while a newer version is known", () => {
|
|
273
|
+
const presentation = (0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ state: "error", downloadedVersion: undefined, error: "download failed" }));
|
|
274
|
+
expect(presentation.updateAvailableOverlay).toBe(true);
|
|
275
|
+
expect(presentation.label).toBe(`${synced.label}; Version 1.1.0 is available. Update problem: download failed`);
|
|
276
|
+
});
|
|
277
|
+
it("adds the update badge and label while an update is available, downloading, or ready", () => {
|
|
278
|
+
for (const state of ["available", "downloading", "downloaded"]) {
|
|
279
|
+
const presentation = (0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ state }));
|
|
280
|
+
expect(presentation.updateAvailableOverlay).toBe(true);
|
|
281
|
+
expect(presentation.label).toMatch(/^Network synced; .*1\.1\.0/);
|
|
282
|
+
expect(presentation.colorClass).toBe(synced.colorClass);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
it("applies offline too", () => {
|
|
286
|
+
const offline = (0, network_status_indicator_1.getNetworkStatusPresentation)({
|
|
287
|
+
availability: "ready",
|
|
288
|
+
status: status({ state: "offline", connectionCount: 0 }),
|
|
289
|
+
});
|
|
290
|
+
expect((0, network_status_indicator_1.withAppUpdate)(offline, appUpdate()).updateAvailableOverlay).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
228
293
|
describe("NetworkStatusIndicator", () => {
|
|
229
294
|
beforeEach(() => {
|
|
230
295
|
mockViewState = { availability: "loading" };
|
|
296
|
+
mockAppUpdate = undefined;
|
|
297
|
+
installUpdateAndRestart.mockReset();
|
|
298
|
+
installUpdateAndRestart.mockResolvedValue({ action: "installing" });
|
|
231
299
|
(0, network_viewer_section_1.networkViewerSection)("main");
|
|
232
300
|
getConnections.mockReset();
|
|
233
301
|
getConnections.mockResolvedValue({ connections: [] });
|
|
@@ -286,6 +354,28 @@ describe("NetworkStatusIndicator", () => {
|
|
|
286
354
|
view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
287
355
|
expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
|
|
288
356
|
});
|
|
357
|
+
it("shows a bottom-left update badge and an Update & restart action when an update is ready", async () => {
|
|
358
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
359
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
360
|
+
expect(view.container.querySelector(".network-status-update-badge")).toBeNull();
|
|
361
|
+
mockAppUpdate = appUpdate();
|
|
362
|
+
view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
363
|
+
const badge = view.container.querySelector(".network-status-update-badge");
|
|
364
|
+
expect(badge).toHaveClass("bi-arrow-up-circle-fill", "text-warning");
|
|
365
|
+
expect(badge).toHaveStyle({ left: "-0.3rem", bottom: "-0.32rem" });
|
|
366
|
+
react_1.fireEvent.click(view.getByRole("button", { name: /Version 1\.1\.0 is ready to install/ }));
|
|
367
|
+
const section = view.getByTestId("app-update-section");
|
|
368
|
+
expect(section).toHaveTextContent("Installs automatically the next time Peers restarts");
|
|
369
|
+
react_1.fireEvent.click(view.getByRole("button", { name: /Update & restart/ }));
|
|
370
|
+
await (0, react_1.waitFor)(() => expect(installUpdateAndRestart).toHaveBeenCalledTimes(1));
|
|
371
|
+
});
|
|
372
|
+
it("omits the install-on-restart note when nothing is armed to install", async () => {
|
|
373
|
+
mockViewState = { availability: "ready", status: status({ state: "synced" }) };
|
|
374
|
+
mockAppUpdate = appUpdate({ installOnRestart: false, installOnQuitArmed: false });
|
|
375
|
+
const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
|
|
376
|
+
await openNetworkDetails(view, /ready to install/);
|
|
377
|
+
expect(view.getByTestId("app-update-section")).not.toHaveTextContent("Installs automatically");
|
|
378
|
+
});
|
|
289
379
|
it("shows a yellow disconnected-groups count when synced with offline groups", () => {
|
|
290
380
|
mockViewState = {
|
|
291
381
|
availability: "ready",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"lint:fix": "biome check --write ."
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@peers-app/peers-sdk": "^0.26.
|
|
33
|
+
"@peers-app/peers-sdk": "^0.26.2",
|
|
34
34
|
"bootstrap": "^5.3.3",
|
|
35
35
|
"react": "^18.0.0",
|
|
36
36
|
"react-dom": "^18.0.0"
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@babel/preset-react": "^7.24.1",
|
|
42
42
|
"@babel/preset-typescript": "^7.27.1",
|
|
43
43
|
"@biomejs/biome": "2.3.11",
|
|
44
|
-
"@peers-app/peers-sdk": "0.26.
|
|
44
|
+
"@peers-app/peers-sdk": "0.26.2",
|
|
45
45
|
"@testing-library/dom": "^10.4.0",
|
|
46
46
|
"@testing-library/jest-dom": "^6.6.3",
|
|
47
47
|
"@testing-library/react": "^16.3.0",
|