@peers-app/peers-ui 0.8.0 → 0.8.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/command-palette/command-palette.d.ts +2 -2
- package/dist/command-palette/command-palette.js +3 -7
- package/dist/components/group-switcher.d.ts +2 -1
- package/dist/components/group-switcher.js +7 -6
- package/dist/screens/network-viewer/device-details-modal.js +44 -0
- package/dist/screens/network-viewer/group-details-modal.js +80 -2
- package/dist/screens/network-viewer/network-viewer.js +36 -16
- package/dist/screens/settings/settings-page.js +13 -7
- package/dist/screens/setup-user.js +8 -6
- package/dist/system-apps/index.d.ts +1 -0
- package/dist/system-apps/index.js +10 -1
- package/dist/system-apps/mobile-settings.app.d.ts +2 -0
- package/dist/system-apps/mobile-settings.app.js +8 -0
- package/dist/tabs-layout/tabs-layout.js +60 -38
- package/dist/tabs-layout/tabs-state.d.ts +10 -4
- package/dist/tabs-layout/tabs-state.js +41 -4
- package/dist/ui-router/ui-loader.js +45 -12
- package/package.json +3 -3
- package/src/command-palette/command-palette.ts +4 -8
- package/src/components/group-switcher.tsx +12 -8
- package/src/screens/network-viewer/device-details-modal.tsx +55 -0
- package/src/screens/network-viewer/group-details-modal.tsx +144 -1
- package/src/screens/network-viewer/network-viewer.tsx +36 -29
- package/src/screens/settings/settings-page.tsx +17 -9
- package/src/screens/setup-user.tsx +9 -6
- package/src/system-apps/index.ts +9 -0
- package/src/system-apps/mobile-settings.app.ts +8 -0
- package/src/tabs-layout/tabs-layout.tsx +108 -82
- package/src/tabs-layout/tabs-state.ts +54 -5
- package/src/ui-router/ui-loader.tsx +50 -11
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IAppNav, IPackage } from "@peers-app/peers-sdk";
|
|
1
|
+
import { IAppNav, IPackage, Observable } from "@peers-app/peers-sdk";
|
|
2
2
|
export interface TabState {
|
|
3
3
|
tabId: string;
|
|
4
4
|
packageId?: string;
|
|
@@ -7,9 +7,15 @@ export interface TabState {
|
|
|
7
7
|
iconClassName?: string;
|
|
8
8
|
}
|
|
9
9
|
export declare const launcherApp: TabState;
|
|
10
|
-
export declare const activeTabs:
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
export declare const activeTabs: Observable<TabState[]> & {
|
|
11
|
+
loadingPromise: Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
export declare const activeTabId: Observable<string> & {
|
|
14
|
+
loadingPromise: Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
export declare const recentlyUsedApps: Observable<string[]> & {
|
|
17
|
+
loadingPromise: Promise<void>;
|
|
18
|
+
};
|
|
13
19
|
export declare const initializedTabs: Set<string>;
|
|
14
20
|
export declare function goToTabPath(path: string): void;
|
|
15
21
|
export declare const handleMainPathChanged: (oldPath: string, newPath: string, setNewMainPath: ((path: string) => any)) => void;
|
|
@@ -14,16 +14,53 @@ exports.launcherApp = {
|
|
|
14
14
|
title: 'Apps',
|
|
15
15
|
iconClassName: 'bi-grid-3x3-gap',
|
|
16
16
|
};
|
|
17
|
-
//
|
|
18
|
-
|
|
17
|
+
// Persistent vars for storage (write-only, no subscription to avoid oscillation)
|
|
18
|
+
const _persistentActiveTabs = (0, peers_sdk_1.groupDeviceVar)('activeTabs', {
|
|
19
19
|
defaultValue: [exports.launcherApp],
|
|
20
20
|
});
|
|
21
|
-
|
|
21
|
+
const _persistentActiveTabId = (0, peers_sdk_1.groupDeviceVar)('activeTabId', {
|
|
22
22
|
defaultValue: 'launcher',
|
|
23
23
|
});
|
|
24
|
-
|
|
24
|
+
const _persistentRecentlyUsedApps = (0, peers_sdk_1.groupUserVar)('recentlyUsedApps', {
|
|
25
25
|
defaultValue: [],
|
|
26
26
|
});
|
|
27
|
+
// In-memory observables for UI state (loaded once, then write-through to persistent vars)
|
|
28
|
+
exports.activeTabs = (() => {
|
|
29
|
+
const obs = (0, peers_sdk_1.observable)([exports.launcherApp]);
|
|
30
|
+
// Write-through to persistent var on change
|
|
31
|
+
obs.subscribe(value => {
|
|
32
|
+
_persistentActiveTabs(value);
|
|
33
|
+
});
|
|
34
|
+
// Load initial value once
|
|
35
|
+
const loadingPromise = _persistentActiveTabs.loadingPromise.then(() => {
|
|
36
|
+
obs(_persistentActiveTabs());
|
|
37
|
+
});
|
|
38
|
+
return Object.assign(obs, { loadingPromise });
|
|
39
|
+
})();
|
|
40
|
+
exports.activeTabId = (() => {
|
|
41
|
+
const obs = (0, peers_sdk_1.observable)('launcher');
|
|
42
|
+
// Write-through to persistent var on change
|
|
43
|
+
obs.subscribe(value => {
|
|
44
|
+
_persistentActiveTabId(value);
|
|
45
|
+
});
|
|
46
|
+
// Load initial value once
|
|
47
|
+
const loadingPromise = _persistentActiveTabId.loadingPromise.then(() => {
|
|
48
|
+
obs(_persistentActiveTabId());
|
|
49
|
+
});
|
|
50
|
+
return Object.assign(obs, { loadingPromise });
|
|
51
|
+
})();
|
|
52
|
+
exports.recentlyUsedApps = (() => {
|
|
53
|
+
const obs = (0, peers_sdk_1.observable)([]);
|
|
54
|
+
// Write-through to persistent var on change
|
|
55
|
+
obs.subscribe(value => {
|
|
56
|
+
_persistentRecentlyUsedApps(value);
|
|
57
|
+
});
|
|
58
|
+
// Load initial value once
|
|
59
|
+
const loadingPromise = _persistentRecentlyUsedApps.loadingPromise.then(() => {
|
|
60
|
+
obs(_persistentRecentlyUsedApps());
|
|
61
|
+
});
|
|
62
|
+
return Object.assign(obs, { loadingPromise });
|
|
63
|
+
})();
|
|
27
64
|
exports.initializedTabs = new Set();
|
|
28
65
|
function goToTabPath(path) {
|
|
29
66
|
const tab = (0, exports.activeTabs)().find(t => t.path === path);
|
|
@@ -225,26 +225,59 @@ const UILoader = (args) => {
|
|
|
225
225
|
return react_1.default.createElement(UIAsyncLoader, { ...args });
|
|
226
226
|
};
|
|
227
227
|
const uiLoadingPromises = {};
|
|
228
|
+
// Check if we're running in a React Native WebView (has injectUIBundle available)
|
|
229
|
+
const isReactNativeWebView = typeof window.ReactNativeWebView !== 'undefined';
|
|
228
230
|
function loadUIBundle(pkg, forceRefresh) {
|
|
229
231
|
// Dynamically import the bundle
|
|
230
232
|
let importPromise = uiLoadingPromises[pkg.packageId];
|
|
231
233
|
if (!importPromise || forceRefresh) {
|
|
234
|
+
const sTime = Date.now();
|
|
232
235
|
console.log(`loading ui bundle for ${pkg.name}`);
|
|
233
236
|
importPromise = new Promise(async (resolve, reject) => {
|
|
234
237
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
if (!pkg.uiBundleFileId) {
|
|
239
|
+
resolve();
|
|
240
|
+
return;
|
|
238
241
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
242
|
+
// Use fast injection path for React Native WebView
|
|
243
|
+
if (isReactNativeWebView && peers_sdk_1.rpcServerCalls.injectUIBundle) {
|
|
244
|
+
// Set up listeners for bundle load completion
|
|
245
|
+
const _window = window;
|
|
246
|
+
_window.__peersUIs = _window.__peersUIs || {};
|
|
247
|
+
const loadPromise = new Promise((resolveLoad, rejectLoad) => {
|
|
248
|
+
const fileId = pkg.uiBundleFileId;
|
|
249
|
+
_window.__peersUIBundleLoaded = (loadedFileId) => {
|
|
250
|
+
if (loadedFileId === fileId) {
|
|
251
|
+
// Copy loaded UIs to our local registry
|
|
252
|
+
Object.keys(_window.__peersUIs || {}).forEach(peersUIId => {
|
|
253
|
+
peersUIs[peersUIId] = _window.__peersUIs[peersUIId];
|
|
254
|
+
});
|
|
255
|
+
resolveLoad();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
_window.__peersUIBundleError = (errorFileId, errorMsg) => {
|
|
259
|
+
if (errorFileId === fileId) {
|
|
260
|
+
rejectLoad(new Error(errorMsg));
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
await peers_sdk_1.rpcServerCalls.injectUIBundle(pkg.uiBundleFileId);
|
|
265
|
+
await loadPromise;
|
|
266
|
+
console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms`);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// Fallback: use postMessage-based getFileContents (slower for large bundles)
|
|
270
|
+
let bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(pkg.uiBundleFileId);
|
|
271
|
+
if (bundleCode) {
|
|
272
|
+
const exportUIs = (peerUIs) => {
|
|
273
|
+
peerUIs?.uis?.forEach(ui => {
|
|
274
|
+
peersUIs[ui.peersUIId] = ui;
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
const bundleFunction = new Function('exportUIs', bundleCode);
|
|
278
|
+
await bundleFunction(exportUIs);
|
|
279
|
+
}
|
|
280
|
+
console.log(`finished loading ui bundle for ${pkg.name}: ${(Date.now() - sTime).toFixed(0)}ms, ${(bundleCode.length / 1000).toFixed(0)} KB`);
|
|
248
281
|
}
|
|
249
282
|
resolve();
|
|
250
283
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"bootstrap": "^5.3.3",
|
|
30
|
-
"@peers-app/peers-sdk": "^0.8.
|
|
30
|
+
"@peers-app/peers-sdk": "^0.8.2",
|
|
31
31
|
"react": "^18.0.0",
|
|
32
32
|
"react-dom": "^18.0.0"
|
|
33
33
|
},
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"jest": "^29.7.0",
|
|
58
58
|
"jest-environment-jsdom": "^30.0.5",
|
|
59
59
|
"path-browserify": "^1.0.1",
|
|
60
|
-
"@peers-app/peers-sdk": "0.8.
|
|
60
|
+
"@peers-app/peers-sdk": "0.8.2",
|
|
61
61
|
"react": "^18.0.0",
|
|
62
62
|
"react-dom": "^18.0.0",
|
|
63
63
|
"string-width": "^7.1.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { observable } from "@peers-app/peers-sdk";
|
|
2
2
|
import { goToTabPath } from '../tabs-layout/tabs-state';
|
|
3
3
|
|
|
4
4
|
export interface Command {
|
|
@@ -12,14 +12,10 @@ export interface Command {
|
|
|
12
12
|
isAvailable?: () => boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// Command palette state (
|
|
16
|
-
export const isCommandPaletteOpen =
|
|
17
|
-
defaultValue: false,
|
|
18
|
-
});
|
|
15
|
+
// Command palette state (in-memory only, no persistence needed)
|
|
16
|
+
export const isCommandPaletteOpen = observable<boolean>(false);
|
|
19
17
|
|
|
20
|
-
export const commandSearchQuery =
|
|
21
|
-
defaultValue: '',
|
|
22
|
-
});
|
|
18
|
+
export const commandSearchQuery = observable<string>('');
|
|
23
19
|
|
|
24
20
|
// Command registry
|
|
25
21
|
const registeredCommands = new Map<string, Command>();
|
|
@@ -4,9 +4,10 @@ import { usePromise } from '../hooks';
|
|
|
4
4
|
|
|
5
5
|
interface GroupSwitcherProps {
|
|
6
6
|
colorMode: string;
|
|
7
|
+
isMobile?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
export function GroupSwitcher({ colorMode }: GroupSwitcherProps) {
|
|
10
|
+
export function GroupSwitcher({ colorMode, isMobile = false }: GroupSwitcherProps) {
|
|
10
11
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
11
12
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
12
13
|
const [allGroups, setAllGroups] = useState<IGroup[]>([]);
|
|
@@ -62,16 +63,17 @@ export function GroupSwitcher({ colorMode }: GroupSwitcherProps) {
|
|
|
62
63
|
<>
|
|
63
64
|
<div className="dropdown">
|
|
64
65
|
<button
|
|
65
|
-
className=
|
|
66
|
+
className={`btn btn-sm ${isMobile ? '' : 'me-2'} d-flex align-items-center`}
|
|
66
67
|
onClick={() => setShowDropdown(!showDropdown)}
|
|
67
68
|
title={`Current group: ${getGroupName(currentGroup)}`}
|
|
68
69
|
style={{
|
|
69
|
-
padding: '4px 8px',
|
|
70
|
+
padding: isMobile ? '4px' : '4px 8px',
|
|
70
71
|
fontSize: '12px',
|
|
71
72
|
borderRadius: '6px',
|
|
72
73
|
border: 'none',
|
|
73
74
|
background: 'transparent',
|
|
74
|
-
color: isDark ? '#adb5bd' : '#6c757d'
|
|
75
|
+
color: isDark ? '#adb5bd' : '#6c757d',
|
|
76
|
+
minWidth: isMobile ? '36px' : undefined
|
|
75
77
|
}}
|
|
76
78
|
onMouseEnter={(e) => {
|
|
77
79
|
e.currentTarget.style.backgroundColor = isDark ? '#495057' : '#f8f9fa';
|
|
@@ -82,10 +84,12 @@ export function GroupSwitcher({ colorMode }: GroupSwitcherProps) {
|
|
|
82
84
|
e.currentTarget.style.color = isDark ? '#adb5bd' : '#6c757d';
|
|
83
85
|
}}
|
|
84
86
|
>
|
|
85
|
-
<i className={`${getGroupIcon(currentGroup)} me-1`} style={{ fontSize: '14px' }} />
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
|
|
87
|
+
<i className={`${getGroupIcon(currentGroup)} ${isMobile ? '' : 'me-1'}`} style={{ fontSize: '14px' }} />
|
|
88
|
+
{!isMobile && (
|
|
89
|
+
<span className="text-truncate" style={{ maxWidth: '80px' }}>
|
|
90
|
+
{getGroupName(currentGroup)}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
89
93
|
<i className="bi-chevron-down ms-1" style={{ fontSize: '10px' }} />
|
|
90
94
|
</button>
|
|
91
95
|
|
|
@@ -3,6 +3,25 @@ import { TrustLevel } from '@peers-app/peers-sdk';
|
|
|
3
3
|
import { LoadingIndicator } from '../../components/loading-indicator';
|
|
4
4
|
import { TrustLevelBadge } from '../../components/trust-level-badge';
|
|
5
5
|
|
|
6
|
+
/** Format bytes to human-readable string (KB, MB, GB) */
|
|
7
|
+
function formatBytes(bytes: number): string {
|
|
8
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
9
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
10
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
11
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Format transfer rate to human-readable string (KB/s or MB/s) */
|
|
15
|
+
function formatRate(mbps: number | undefined): string {
|
|
16
|
+
if (mbps === undefined || mbps === 0) return '0 KB/s';
|
|
17
|
+
// Show KB/s if less than 0.1 MB/s (100 KB/s)
|
|
18
|
+
if (mbps < 0.1) {
|
|
19
|
+
const kbps = mbps * 1024;
|
|
20
|
+
return `${kbps.toFixed(1)} KB/s`;
|
|
21
|
+
}
|
|
22
|
+
return `${mbps.toFixed(2)} MB/s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
// TypeScript type definitions (matching backend)
|
|
7
26
|
interface IDeviceDetails {
|
|
8
27
|
deviceId: string;
|
|
@@ -30,6 +49,11 @@ interface IDeviceDetails {
|
|
|
30
49
|
isPreferred: boolean;
|
|
31
50
|
};
|
|
32
51
|
};
|
|
52
|
+
// Throughput stats
|
|
53
|
+
bytesSent?: number;
|
|
54
|
+
bytesReceived?: number;
|
|
55
|
+
sendRateMBps?: number;
|
|
56
|
+
receiveRateMBps?: number;
|
|
33
57
|
}
|
|
34
58
|
|
|
35
59
|
interface IDeviceDetailsModalProps {
|
|
@@ -190,6 +214,37 @@ export function DeviceDetailsModal({ deviceId, onClose, onDisconnect }: IDeviceD
|
|
|
190
214
|
</div>
|
|
191
215
|
</div>
|
|
192
216
|
|
|
217
|
+
{/* Throughput Stats */}
|
|
218
|
+
<div className="mb-4">
|
|
219
|
+
<h6 className="border-bottom pb-2 mb-3">Throughput</h6>
|
|
220
|
+
<div className="row">
|
|
221
|
+
<div className="col-md-6 mb-3">
|
|
222
|
+
<strong>Send Rate:</strong>
|
|
223
|
+
<br />
|
|
224
|
+
<span className="text-success">
|
|
225
|
+
↑ {formatRate(device.sendRateMBps)}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="col-md-6 mb-3">
|
|
229
|
+
<strong>Receive Rate:</strong>
|
|
230
|
+
<br />
|
|
231
|
+
<span className="text-primary">
|
|
232
|
+
↓ {formatRate(device.receiveRateMBps)}
|
|
233
|
+
</span>
|
|
234
|
+
</div>
|
|
235
|
+
<div className="col-md-6 mb-3">
|
|
236
|
+
<strong>Total Sent:</strong>
|
|
237
|
+
<br />
|
|
238
|
+
<span>{formatBytes(device.bytesSent || 0)}</span>
|
|
239
|
+
</div>
|
|
240
|
+
<div className="col-md-6 mb-3">
|
|
241
|
+
<strong>Total Received:</strong>
|
|
242
|
+
<br />
|
|
243
|
+
<span>{formatBytes(device.bytesReceived || 0)}</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
193
248
|
{/* Shared Groups */}
|
|
194
249
|
{device.sharedGroups.length > 0 && (
|
|
195
250
|
<div className="mb-4">
|
|
@@ -17,6 +17,22 @@ interface IGroupDetails {
|
|
|
17
17
|
}[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
interface IConnectedDevice {
|
|
21
|
+
deviceId: string;
|
|
22
|
+
latencyMs: number;
|
|
23
|
+
errorRate: number;
|
|
24
|
+
timestampLastApplied: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface IGroupSyncInfo {
|
|
28
|
+
groupId: string;
|
|
29
|
+
groupName: string;
|
|
30
|
+
connectedDevices: IConnectedDevice[];
|
|
31
|
+
preferredDeviceIds: string[];
|
|
32
|
+
lastSyncTimestamp: number;
|
|
33
|
+
totalMembers: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
interface IGroupDetailsModalProps {
|
|
21
37
|
groupId: string;
|
|
22
38
|
onClose: () => void;
|
|
@@ -24,8 +40,17 @@ interface IGroupDetailsModalProps {
|
|
|
24
40
|
|
|
25
41
|
export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps) {
|
|
26
42
|
const [group, setGroup] = useState<IGroupDetails | null>(null);
|
|
43
|
+
const [syncInfo, setSyncInfo] = useState<IGroupSyncInfo | null>(null);
|
|
27
44
|
const [loading, setLoading] = useState(true);
|
|
28
45
|
const [notFound, setNotFound] = useState(false);
|
|
46
|
+
const [downloading, setDownloading] = useState(false);
|
|
47
|
+
const [downloadProgress, setDownloadProgress] = useState<string | null>(null);
|
|
48
|
+
const [downloadResult, setDownloadResult] = useState<{
|
|
49
|
+
success: boolean;
|
|
50
|
+
message: string;
|
|
51
|
+
filePath?: string;
|
|
52
|
+
size?: number;
|
|
53
|
+
} | null>(null);
|
|
29
54
|
|
|
30
55
|
const loadData = async () => {
|
|
31
56
|
try {
|
|
@@ -35,7 +60,10 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
|
|
|
35
60
|
return;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
|
-
const groupData = await
|
|
63
|
+
const [groupData, syncData] = await Promise.all([
|
|
64
|
+
api.getGroupDetails(groupId),
|
|
65
|
+
api.getGroupSyncStatus(groupId)
|
|
66
|
+
]);
|
|
39
67
|
|
|
40
68
|
if (!groupData) {
|
|
41
69
|
setNotFound(true);
|
|
@@ -43,6 +71,10 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
|
|
|
43
71
|
setGroup(groupData);
|
|
44
72
|
setNotFound(false);
|
|
45
73
|
}
|
|
74
|
+
|
|
75
|
+
if (syncData && syncData.length > 0) {
|
|
76
|
+
setSyncInfo(syncData[0]);
|
|
77
|
+
}
|
|
46
78
|
} catch (error) {
|
|
47
79
|
console.error('Error loading group details:', error);
|
|
48
80
|
setNotFound(true);
|
|
@@ -51,6 +83,40 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
|
|
|
51
83
|
}
|
|
52
84
|
};
|
|
53
85
|
|
|
86
|
+
const handleDownloadDatabase = async (deviceId: string) => {
|
|
87
|
+
try {
|
|
88
|
+
setDownloading(true);
|
|
89
|
+
setDownloadProgress('Preparing download...');
|
|
90
|
+
setDownloadResult(null);
|
|
91
|
+
|
|
92
|
+
const api = (window as any).electronAPI?.networkViewer;
|
|
93
|
+
if (!api?.downloadRemoteDatabase) {
|
|
94
|
+
setDownloadResult({
|
|
95
|
+
success: false,
|
|
96
|
+
message: 'Download API not available'
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setDownloadProgress('Downloading database...');
|
|
102
|
+
const result = await api.downloadRemoteDatabase(groupId, deviceId);
|
|
103
|
+
|
|
104
|
+
setDownloadResult(result);
|
|
105
|
+
if (result.success) {
|
|
106
|
+
setDownloadProgress(null);
|
|
107
|
+
}
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
console.error('Error downloading database:', error);
|
|
110
|
+
setDownloadResult({
|
|
111
|
+
success: false,
|
|
112
|
+
message: error?.message || 'Download failed'
|
|
113
|
+
});
|
|
114
|
+
} finally {
|
|
115
|
+
setDownloading(false);
|
|
116
|
+
setDownloadProgress(null);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
54
120
|
useEffect(() => {
|
|
55
121
|
loadData();
|
|
56
122
|
}, [groupId]);
|
|
@@ -168,6 +234,83 @@ export function GroupDetailsModal({ groupId, onClose }: IGroupDetailsModalProps)
|
|
|
168
234
|
</div>
|
|
169
235
|
</div>
|
|
170
236
|
)}
|
|
237
|
+
|
|
238
|
+
{/* Database Download Section */}
|
|
239
|
+
<div className="mb-4">
|
|
240
|
+
<h6 className="border-bottom pb-2 mb-3">
|
|
241
|
+
<i className="bi bi-download me-2"></i>
|
|
242
|
+
Download Database (Fast Sync)
|
|
243
|
+
</h6>
|
|
244
|
+
<p className="text-muted small mb-3">
|
|
245
|
+
Download the entire database from a connected device. This is useful for fast initial sync on a new device.
|
|
246
|
+
The database will be saved as a backup file.
|
|
247
|
+
</p>
|
|
248
|
+
|
|
249
|
+
{syncInfo && syncInfo.connectedDevices.length > 0 ? (
|
|
250
|
+
<div>
|
|
251
|
+
<label className="form-label">Select a connected device:</label>
|
|
252
|
+
<div className="d-flex flex-wrap gap-2">
|
|
253
|
+
{syncInfo.connectedDevices.map((device) => (
|
|
254
|
+
<button
|
|
255
|
+
key={device.deviceId}
|
|
256
|
+
className="btn btn-outline-primary btn-sm"
|
|
257
|
+
onClick={() => handleDownloadDatabase(device.deviceId)}
|
|
258
|
+
disabled={downloading}
|
|
259
|
+
>
|
|
260
|
+
{downloading ? (
|
|
261
|
+
<>
|
|
262
|
+
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
|
263
|
+
Downloading...
|
|
264
|
+
</>
|
|
265
|
+
) : (
|
|
266
|
+
<>
|
|
267
|
+
<i className="bi bi-cloud-download me-1"></i>
|
|
268
|
+
{device.deviceId.substring(0, 8)}...
|
|
269
|
+
<span className="badge bg-secondary ms-1">{Math.round(device.latencyMs)}ms</span>
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
) : (
|
|
277
|
+
<div className="alert alert-info mb-0">
|
|
278
|
+
<i className="bi bi-info-circle me-2"></i>
|
|
279
|
+
No devices are currently connected to this group. Connect to a device first to download its database.
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
{downloadProgress && (
|
|
284
|
+
<div className="mt-3">
|
|
285
|
+
<div className="d-flex align-items-center">
|
|
286
|
+
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
|
287
|
+
<span>{downloadProgress}</span>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{downloadResult && (
|
|
293
|
+
<div className={`alert mt-3 mb-0 ${downloadResult.success ? 'alert-success' : 'alert-danger'}`}>
|
|
294
|
+
{downloadResult.success ? (
|
|
295
|
+
<>
|
|
296
|
+
<i className="bi bi-check-circle me-2"></i>
|
|
297
|
+
<strong>Download complete!</strong>
|
|
298
|
+
<br />
|
|
299
|
+
<small>
|
|
300
|
+
File: <code>{downloadResult.filePath}</code>
|
|
301
|
+
<br />
|
|
302
|
+
Size: {downloadResult.size ? `${(downloadResult.size / (1024 * 1024)).toFixed(2)} MB` : 'Unknown'}
|
|
303
|
+
</small>
|
|
304
|
+
</>
|
|
305
|
+
) : (
|
|
306
|
+
<>
|
|
307
|
+
<i className="bi bi-exclamation-circle me-2"></i>
|
|
308
|
+
<strong>Download failed:</strong> {downloadResult.message}
|
|
309
|
+
</>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
171
314
|
</>
|
|
172
315
|
)}
|
|
173
316
|
</div>
|
|
@@ -8,6 +8,25 @@ import { DeviceDetailsModal } from './device-details-modal';
|
|
|
8
8
|
import { GroupDetailsModal } from './group-details-modal';
|
|
9
9
|
import { UsageGraph } from './usage-graph';
|
|
10
10
|
|
|
11
|
+
/** Format bytes to human-readable string (KB, MB, GB) */
|
|
12
|
+
function formatBytes(bytes: number): string {
|
|
13
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
14
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
15
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
16
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Format transfer rate to human-readable string (KB/s or MB/s) */
|
|
20
|
+
function formatRate(mbps: number | undefined): string {
|
|
21
|
+
if (mbps === undefined || mbps === 0) return '0 KB/s';
|
|
22
|
+
// Show KB/s if less than 0.1 MB/s (100 KB/s)
|
|
23
|
+
if (mbps < 0.1) {
|
|
24
|
+
const kbps = mbps * 1024;
|
|
25
|
+
return `${kbps.toFixed(1)} KB/s`;
|
|
26
|
+
}
|
|
27
|
+
return `${mbps.toFixed(2)} MB/s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
// TypeScript type definitions (matching backend)
|
|
12
31
|
interface INetworkOverview {
|
|
13
32
|
localDeviceId: string;
|
|
@@ -43,6 +62,11 @@ interface IConnectionInfo {
|
|
|
43
62
|
errorRate: number;
|
|
44
63
|
isServer: boolean;
|
|
45
64
|
connectionAddress?: string;
|
|
65
|
+
// Throughput stats
|
|
66
|
+
bytesSent?: number;
|
|
67
|
+
bytesReceived?: number;
|
|
68
|
+
sendRateMBps?: number;
|
|
69
|
+
receiveRateMBps?: number;
|
|
46
70
|
}
|
|
47
71
|
|
|
48
72
|
interface IDeviceConnection {
|
|
@@ -329,35 +353,6 @@ export function NetworkViewerList() {
|
|
|
329
353
|
</div>
|
|
330
354
|
</div>
|
|
331
355
|
|
|
332
|
-
{/* Groups Summary */}
|
|
333
|
-
{overview.groups.length > 0 && (
|
|
334
|
-
<div className="card mb-4">
|
|
335
|
-
<div className="card-body">
|
|
336
|
-
<h5 className="card-title">Groups: {overview.groups.length}</h5>
|
|
337
|
-
<div className="table-responsive">
|
|
338
|
-
<table className="table table-sm">
|
|
339
|
-
<thead>
|
|
340
|
-
<tr>
|
|
341
|
-
<th>Group Name</th>
|
|
342
|
-
<th>Connected Devices</th>
|
|
343
|
-
<th>Total Members</th>
|
|
344
|
-
</tr>
|
|
345
|
-
</thead>
|
|
346
|
-
<tbody>
|
|
347
|
-
{overview.groups.map(group => (
|
|
348
|
-
<tr key={group.groupId}>
|
|
349
|
-
<td>{group.groupName}</td>
|
|
350
|
-
<td>{group.connectedDevices}</td>
|
|
351
|
-
<td>{group.totalMembers}</td>
|
|
352
|
-
</tr>
|
|
353
|
-
))}
|
|
354
|
-
</tbody>
|
|
355
|
-
</table>
|
|
356
|
-
</div>
|
|
357
|
-
</div>
|
|
358
|
-
</div>
|
|
359
|
-
)}
|
|
360
|
-
|
|
361
356
|
{/* Connections List */}
|
|
362
357
|
<div className="card mb-4">
|
|
363
358
|
<div className="card-body">
|
|
@@ -379,6 +374,7 @@ export function NetworkViewerList() {
|
|
|
379
374
|
<th>Shared Groups</th>
|
|
380
375
|
<th>Latency</th>
|
|
381
376
|
<th>Error Rate</th>
|
|
377
|
+
<th>Throughput</th>
|
|
382
378
|
{/* <th>State</th> */}
|
|
383
379
|
<th>Actions</th>
|
|
384
380
|
</tr>
|
|
@@ -451,6 +447,17 @@ export function NetworkViewerList() {
|
|
|
451
447
|
{(conn.errorRate * 100).toFixed(1)}%
|
|
452
448
|
</span>
|
|
453
449
|
</td>
|
|
450
|
+
<td>
|
|
451
|
+
<small>
|
|
452
|
+
<span className="text-success">↑{formatRate(conn.sendRateMBps)}</span>
|
|
453
|
+
{' / '}
|
|
454
|
+
<span className="text-primary">↓{formatRate(conn.receiveRateMBps)}</span>
|
|
455
|
+
</small>
|
|
456
|
+
<br />
|
|
457
|
+
<small className="text-muted">
|
|
458
|
+
{formatBytes(conn.bytesSent || 0)} / {formatBytes(conn.bytesReceived || 0)}
|
|
459
|
+
</small>
|
|
460
|
+
</td>
|
|
454
461
|
{/* <td>
|
|
455
462
|
<span className="badge bg-success">{conn.connectionState}</span>
|
|
456
463
|
</td> */}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { Tooltip } from '../../components/tooltip';
|
|
4
|
-
import { ColorModeDropdown } from './color-mode-dropdown';
|
|
1
|
+
import { Devices, getUserContext, IDevice, packagesRootDir, reloadPackagesOnPageRefresh, rpcServerCalls, thisDeviceId, TrustLevel } from "@peers-app/peers-sdk";
|
|
2
|
+
import React, { useEffect } from 'react';
|
|
5
3
|
import { Input } from '../../components/input';
|
|
4
|
+
import { Tooltip } from '../../components/tooltip';
|
|
6
5
|
import { useObservable, useObservableState } from '../../hooks';
|
|
6
|
+
import { ColorModeDropdown } from './color-mode-dropdown';
|
|
7
7
|
|
|
8
8
|
export const SettingsPage: React.FC = () => {
|
|
9
9
|
return (
|
|
@@ -39,11 +39,19 @@ const DeviceName: React.FC = () => {
|
|
|
39
39
|
|
|
40
40
|
const handleSave = async () => {
|
|
41
41
|
try {
|
|
42
|
-
if (!deviceId)
|
|
42
|
+
if (!deviceId) {
|
|
43
|
+
console.error('No device id');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
43
46
|
const userContext = await getUserContext();
|
|
44
47
|
const devicesTable = Devices(userContext.userDataContext);
|
|
45
|
-
const device = await devicesTable.get(deviceId)
|
|
46
|
-
|
|
48
|
+
const device: IDevice = await devicesTable.get(deviceId) || {
|
|
49
|
+
deviceId,
|
|
50
|
+
userId: userContext.userId,
|
|
51
|
+
firstSeen: new Date(),
|
|
52
|
+
lastSeen: new Date(),
|
|
53
|
+
trustLevel: TrustLevel.NewDevice,
|
|
54
|
+
};
|
|
47
55
|
device.name = deviceName() || undefined;
|
|
48
56
|
await devicesTable.save(device);
|
|
49
57
|
currentDeviceName(deviceName());
|
|
@@ -69,8 +77,8 @@ const DeviceName: React.FC = () => {
|
|
|
69
77
|
/>
|
|
70
78
|
<button
|
|
71
79
|
className='btn btn-primary btn-sm'
|
|
72
|
-
onClick={handleSave}
|
|
73
|
-
disabled={currentDeviceName() === deviceName()}
|
|
80
|
+
onClick={handleSave}
|
|
81
|
+
disabled={currentDeviceName() === deviceName()}
|
|
74
82
|
>
|
|
75
83
|
Save
|
|
76
84
|
</button>
|