@peers-app/peers-ui 0.8.0 → 0.8.1

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.
Files changed (30) hide show
  1. package/dist/command-palette/command-palette.d.ts +2 -2
  2. package/dist/command-palette/command-palette.js +3 -7
  3. package/dist/components/group-switcher.d.ts +2 -1
  4. package/dist/components/group-switcher.js +7 -6
  5. package/dist/screens/network-viewer/device-details-modal.js +44 -0
  6. package/dist/screens/network-viewer/group-details-modal.js +80 -2
  7. package/dist/screens/network-viewer/network-viewer.js +36 -16
  8. package/dist/screens/settings/settings-page.js +13 -7
  9. package/dist/screens/setup-user.js +8 -6
  10. package/dist/system-apps/index.d.ts +1 -0
  11. package/dist/system-apps/index.js +10 -1
  12. package/dist/system-apps/mobile-settings.app.d.ts +2 -0
  13. package/dist/system-apps/mobile-settings.app.js +8 -0
  14. package/dist/tabs-layout/tabs-layout.js +60 -38
  15. package/dist/tabs-layout/tabs-state.d.ts +10 -4
  16. package/dist/tabs-layout/tabs-state.js +41 -4
  17. package/dist/ui-router/ui-loader.js +45 -12
  18. package/package.json +3 -3
  19. package/src/command-palette/command-palette.ts +4 -8
  20. package/src/components/group-switcher.tsx +12 -8
  21. package/src/screens/network-viewer/device-details-modal.tsx +55 -0
  22. package/src/screens/network-viewer/group-details-modal.tsx +144 -1
  23. package/src/screens/network-viewer/network-viewer.tsx +36 -29
  24. package/src/screens/settings/settings-page.tsx +17 -9
  25. package/src/screens/setup-user.tsx +9 -6
  26. package/src/system-apps/index.ts +9 -0
  27. package/src/system-apps/mobile-settings.app.ts +8 -0
  28. package/src/tabs-layout/tabs-layout.tsx +108 -82
  29. package/src/tabs-layout/tabs-state.ts +54 -5
  30. 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: import("@peers-app/peers-sdk").PersistentVar<TabState[]>;
11
- export declare const activeTabId: import("@peers-app/peers-sdk").PersistentVar<string>;
12
- export declare const recentlyUsedApps: import("@peers-app/peers-sdk").PersistentVar<string[]>;
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
- // Global persistent variables for tab state
18
- exports.activeTabs = (0, peers_sdk_1.groupDeviceVar)('activeTabs', {
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
- exports.activeTabId = (0, peers_sdk_1.groupDeviceVar)('activeTabId', {
21
+ const _persistentActiveTabId = (0, peers_sdk_1.groupDeviceVar)('activeTabId', {
22
22
  defaultValue: 'launcher',
23
23
  });
24
- exports.recentlyUsedApps = (0, peers_sdk_1.groupUserVar)('recentlyUsedApps', {
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
- let bundleCode = '';
236
- if (pkg.uiBundleFileId) {
237
- bundleCode = await peers_sdk_1.rpcServerCalls.getFileContents(pkg.uiBundleFileId);
238
+ if (!pkg.uiBundleFileId) {
239
+ resolve();
240
+ return;
238
241
  }
239
- if (bundleCode) {
240
- const exportUIs = (peerUIs) => {
241
- // TODO maybe add packageId that this came from
242
- peerUIs?.uis?.forEach(ui => {
243
- peersUIs[ui.peersUIId] = ui;
244
- });
245
- };
246
- const bundleFunction = new Function('exportUIs', bundleCode);
247
- await bundleFunction(exportUIs);
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.0",
3
+ "version": "0.8.1",
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.0",
30
+ "@peers-app/peers-sdk": "^0.8.1",
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.0",
60
+ "@peers-app/peers-sdk": "0.8.1",
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 { deviceVar } from "@peers-app/peers-sdk";
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 (using device scope but with temporary defaults)
16
- export const isCommandPaletteOpen = deviceVar<boolean>('commandPaletteOpen', {
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 = deviceVar<string>('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="btn btn-sm me-2 d-flex align-items-center"
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
- <span className="text-truncate" style={{ maxWidth: '80px' }}>
87
- {getGroupName(currentGroup)}
88
- </span>
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 api.getGroupDetails(groupId);
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 React, { useEffect, useState } from 'react';
2
- import { packagesRootDir, reloadPackagesOnPageRefresh, rpcServerCalls, Devices, thisDeviceId, IDevice, getUserContext } from "@peers-app/peers-sdk";
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) return;
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
- if (!device) return;
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>