@kadi.build/file-sharing 1.0.0

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.
@@ -0,0 +1,538 @@
1
+ /**
2
+ * HttpServerProvider - HTTP file server for @kadi.build/file-sharing
3
+ *
4
+ * Provides HTTP file serving with:
5
+ * - Static file serving with correct MIME types
6
+ * - Directory listing (HTML and JSON)
7
+ * - Range requests (video streaming)
8
+ * - CORS support
9
+ * - Optional basic authentication
10
+ * - SSL/TLS support
11
+ * - Download event tracking
12
+ * - Connection tracking for graceful shutdown
13
+ *
14
+ * Migrated from src/providers/httpServerProvider.js
15
+ */
16
+
17
+ import { EventEmitter } from 'events';
18
+ import http from 'http';
19
+ import https from 'https';
20
+ import path from 'path';
21
+ import fs from 'fs/promises';
22
+ import { createReadStream, statSync } from 'fs';
23
+ import mime from 'mime-types';
24
+
25
+ export class HttpServerProvider extends EventEmitter {
26
+ constructor(config = {}) {
27
+ super();
28
+
29
+ this.config = {
30
+ port: 3000,
31
+ host: '0.0.0.0',
32
+ staticDir: process.cwd(),
33
+ enableDirectoryListing: true,
34
+ cors: true,
35
+ auth: null,
36
+ ssl: null,
37
+ maxFileSize: null,
38
+ uploadDir: null,
39
+ ...config
40
+ };
41
+
42
+ this.server = null;
43
+ this.isRunning = false;
44
+ this.activeConnections = new Map();
45
+ this.startTime = null;
46
+ this.requestCount = 0;
47
+ this.errorCount = 0;
48
+ }
49
+
50
+ /**
51
+ * Start the HTTP server
52
+ * @returns {{ port: number, host: string, url: string }}
53
+ */
54
+ async start() {
55
+ if (this.isRunning) {
56
+ return { port: this.config.port, host: this.config.host, already: true };
57
+ }
58
+
59
+ return new Promise((resolve, reject) => {
60
+ const requestHandler = this._createRequestHandler();
61
+
62
+ // Create HTTP or HTTPS server
63
+ if (this.config.ssl) {
64
+ this.server = https.createServer({
65
+ key: this.config.ssl.key,
66
+ cert: this.config.ssl.cert,
67
+ ...(this.config.ssl.ca ? { ca: this.config.ssl.ca } : {})
68
+ }, requestHandler);
69
+ } else {
70
+ this.server = http.createServer(requestHandler);
71
+ }
72
+
73
+ // Track connections for graceful shutdown
74
+ this.server.on('connection', (socket) => {
75
+ const id = `${socket.remoteAddress}:${socket.remotePort}`;
76
+ this.activeConnections.set(id, socket);
77
+ socket.on('close', () => this.activeConnections.delete(id));
78
+ });
79
+
80
+ this.server.listen(this.config.port, this.config.host, () => {
81
+ const addr = this.server.address();
82
+ this.config.port = addr.port;
83
+ this.isRunning = true;
84
+ this.startTime = new Date();
85
+
86
+ const protocol = this.config.ssl ? 'https' : 'http';
87
+ const info = {
88
+ port: addr.port,
89
+ host: this.config.host,
90
+ url: `${protocol}://${this.config.host}:${addr.port}`
91
+ };
92
+
93
+ this.emit('started', info);
94
+ resolve(info);
95
+ });
96
+
97
+ this.server.on('error', (error) => {
98
+ this.emit('error', error);
99
+ reject(error);
100
+ });
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Stop the HTTP server
106
+ */
107
+ async stop() {
108
+ if (!this.isRunning) {
109
+ return;
110
+ }
111
+
112
+ return new Promise((resolve) => {
113
+ // Close active connections
114
+ for (const [id, socket] of this.activeConnections) {
115
+ socket.destroy();
116
+ this.activeConnections.delete(id);
117
+ }
118
+
119
+ this.server.close(() => {
120
+ this.isRunning = false;
121
+ this.server = null;
122
+ this.emit('stopped');
123
+ resolve();
124
+ });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Get active connection count
130
+ * @returns {number}
131
+ */
132
+ getActiveConnections() {
133
+ return this.activeConnections.size;
134
+ }
135
+
136
+ /**
137
+ * Force close all active connections
138
+ */
139
+ closeAllConnections() {
140
+ for (const [id, socket] of this.activeConnections) {
141
+ socket.destroy();
142
+ this.activeConnections.delete(id);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Create the main request handler
148
+ * @private
149
+ */
150
+ _createRequestHandler() {
151
+ return async (req, res) => {
152
+ this.requestCount++;
153
+
154
+ try {
155
+ // CORS headers
156
+ if (this.config.cors) {
157
+ this._setCorsHeaders(res);
158
+
159
+ if (req.method === 'OPTIONS') {
160
+ res.writeHead(204);
161
+ res.end();
162
+ return;
163
+ }
164
+ }
165
+
166
+ // Authentication
167
+ if (this.config.auth) {
168
+ if (!this._checkAuth(req)) {
169
+ // Set appropriate WWW-Authenticate based on configured scheme
170
+ const auth = this.config.auth;
171
+ if (auth.apiKey || auth.bearerToken) {
172
+ res.setHeader('WWW-Authenticate', 'Bearer');
173
+ } else {
174
+ res.setHeader('WWW-Authenticate', 'Basic realm="File Server"');
175
+ }
176
+ res.writeHead(401);
177
+ res.end('Unauthorized');
178
+ return;
179
+ }
180
+ }
181
+
182
+ // Route request
183
+ const urlObj = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
184
+ const urlPath = decodeURIComponent(urlObj.pathname);
185
+ const filePath = path.join(this.config.staticDir, urlPath);
186
+
187
+ // Security: prevent directory traversal
188
+ const resolvedStatic = path.resolve(this.config.staticDir);
189
+ const resolvedFile = path.resolve(filePath);
190
+ if (!resolvedFile.startsWith(resolvedStatic)) {
191
+ res.writeHead(403);
192
+ res.end('Forbidden');
193
+ return;
194
+ }
195
+
196
+ // Handle file upload
197
+ if (req.method === 'PUT' || req.method === 'POST') {
198
+ await this._handleUpload(req, res, resolvedFile, urlPath);
199
+ return;
200
+ }
201
+
202
+ await this._handleFileRequest(req, res, resolvedFile, urlPath);
203
+ } catch (error) {
204
+ this.errorCount++;
205
+ this.emit('error', error);
206
+ if (!res.headersSent) {
207
+ res.writeHead(500);
208
+ res.end('Internal Server Error');
209
+ }
210
+ }
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Set CORS headers on response
216
+ * @private
217
+ */
218
+ _setCorsHeaders(res) {
219
+ res.setHeader('Access-Control-Allow-Origin', '*');
220
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, HEAD');
221
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Range, Accept');
222
+ res.setHeader('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');
223
+ }
224
+
225
+ /**
226
+ * Handle file request (GET/HEAD)
227
+ * @private
228
+ */
229
+ async _handleFileRequest(req, res, filePath, urlPath) {
230
+ try {
231
+ const stats = await fs.stat(filePath);
232
+
233
+ if (stats.isDirectory()) {
234
+ // Check for index.html
235
+ const indexPath = path.join(filePath, 'index.html');
236
+ try {
237
+ await fs.access(indexPath);
238
+ return this._serveFile(req, res, indexPath);
239
+ } catch {
240
+ // No index.html, show directory listing
241
+ if (this.config.enableDirectoryListing) {
242
+ return this._serveDirectoryListing(req, res, filePath, urlPath);
243
+ } else {
244
+ res.writeHead(403);
245
+ res.end('Directory listing disabled');
246
+ }
247
+ }
248
+ } else {
249
+ return this._serveFile(req, res, filePath);
250
+ }
251
+ } catch (error) {
252
+ if (error.code === 'ENOENT') {
253
+ res.writeHead(404);
254
+ res.end('Not Found');
255
+ } else {
256
+ throw error;
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Serve a file with proper headers, range support, and event tracking
263
+ * @private
264
+ */
265
+ async _serveFile(req, res, filePath) {
266
+ const stats = await fs.stat(filePath);
267
+ const mimeType = mime.lookup(filePath) || 'application/octet-stream';
268
+
269
+ // Emit download start
270
+ this.emit('download:start', {
271
+ file: filePath,
272
+ size: stats.size,
273
+ clientIp: req.socket.remoteAddress
274
+ });
275
+
276
+ const startTime = Date.now();
277
+
278
+ // ETag and Last-Modified
279
+ const etag = `"${stats.size}-${stats.mtime.getTime()}"`;
280
+ res.setHeader('ETag', etag);
281
+ res.setHeader('Last-Modified', stats.mtime.toUTCString());
282
+ res.setHeader('Accept-Ranges', 'bytes');
283
+
284
+ // Conditional request handling
285
+ const ifNoneMatch = req.headers['if-none-match'];
286
+ if (ifNoneMatch && ifNoneMatch === etag) {
287
+ res.writeHead(304);
288
+ res.end();
289
+ return;
290
+ }
291
+
292
+ const ifModifiedSince = req.headers['if-modified-since'];
293
+ if (ifModifiedSince && new Date(ifModifiedSince) >= stats.mtime) {
294
+ res.writeHead(304);
295
+ res.end();
296
+ return;
297
+ }
298
+
299
+ // Handle range requests (for video streaming)
300
+ const range = req.headers.range;
301
+ if (range) {
302
+ const parts = range.replace(/bytes=/, '').split('-');
303
+ const start = parseInt(parts[0], 10);
304
+ const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;
305
+
306
+ if (start >= stats.size || start < 0 || end >= stats.size) {
307
+ res.writeHead(416, {
308
+ 'Content-Range': `bytes */${stats.size}`
309
+ });
310
+ res.end();
311
+ return;
312
+ }
313
+
314
+ const chunkSize = end - start + 1;
315
+
316
+ res.writeHead(206, {
317
+ 'Content-Range': `bytes ${start}-${end}/${stats.size}`,
318
+ 'Accept-Ranges': 'bytes',
319
+ 'Content-Length': chunkSize,
320
+ 'Content-Type': mimeType
321
+ });
322
+
323
+ const stream = createReadStream(filePath, { start, end });
324
+ stream.pipe(res);
325
+ } else {
326
+ res.writeHead(200, {
327
+ 'Content-Length': stats.size,
328
+ 'Content-Type': mimeType,
329
+ 'Accept-Ranges': 'bytes'
330
+ });
331
+
332
+ const stream = createReadStream(filePath);
333
+ stream.pipe(res);
334
+
335
+ stream.on('end', () => {
336
+ this.emit('download:complete', {
337
+ file: filePath,
338
+ size: stats.size,
339
+ duration: Date.now() - startTime,
340
+ clientIp: req.socket.remoteAddress
341
+ });
342
+ });
343
+
344
+ stream.on('error', (err) => {
345
+ this.emit('download:error', {
346
+ file: filePath,
347
+ error: err
348
+ });
349
+ });
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Serve directory listing in HTML or JSON format
355
+ * @private
356
+ */
357
+ async _serveDirectoryListing(req, res, dirPath, urlPath) {
358
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
359
+
360
+ // Check Accept header for JSON response
361
+ const acceptsJson = req.headers.accept?.includes('application/json');
362
+
363
+ if (acceptsJson) {
364
+ const items = await Promise.all(entries.map(async (entry) => {
365
+ const fullPath = path.join(dirPath, entry.name);
366
+ try {
367
+ const stats = await fs.stat(fullPath);
368
+ return {
369
+ name: entry.name,
370
+ type: entry.isDirectory() ? 'directory' : 'file',
371
+ size: stats.size,
372
+ modified: stats.mtime.toISOString()
373
+ };
374
+ } catch {
375
+ return {
376
+ name: entry.name,
377
+ type: entry.isDirectory() ? 'directory' : 'file',
378
+ size: 0,
379
+ modified: null
380
+ };
381
+ }
382
+ }));
383
+
384
+ res.writeHead(200, { 'Content-Type': 'application/json' });
385
+ res.end(JSON.stringify({ path: urlPath, entries: items }));
386
+ } else {
387
+ // HTML response
388
+ const html = this._generateDirectoryHtml(urlPath, entries);
389
+ res.writeHead(200, { 'Content-Type': 'text/html' });
390
+ res.end(html);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Handle file upload (PUT/POST)
396
+ * @private
397
+ */
398
+ async _handleUpload(req, res, filePath, urlPath) {
399
+ try {
400
+ // Ensure parent directory exists
401
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
402
+
403
+ const chunks = [];
404
+ for await (const chunk of req) {
405
+ chunks.push(chunk);
406
+ }
407
+ const body = Buffer.concat(chunks);
408
+
409
+ await fs.writeFile(filePath, body);
410
+
411
+ this.emit('upload:complete', {
412
+ file: filePath,
413
+ size: body.length,
414
+ clientIp: req.socket.remoteAddress
415
+ });
416
+
417
+ res.writeHead(201, { 'Content-Length': 0 });
418
+ res.end();
419
+ } catch (error) {
420
+ this.emit('upload:error', { file: filePath, error });
421
+ res.writeHead(500);
422
+ res.end('Upload failed');
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Generate HTML directory listing
428
+ * @private
429
+ */
430
+ _generateDirectoryHtml(urlPath, entries) {
431
+ // Sort: directories first, then files
432
+ const sorted = [...entries].sort((a, b) => {
433
+ if (a.isDirectory() && !b.isDirectory()) return -1;
434
+ if (!a.isDirectory() && b.isDirectory()) return 1;
435
+ return a.name.localeCompare(b.name);
436
+ });
437
+
438
+ const items = sorted.map(entry => {
439
+ const icon = entry.isDirectory() ? '📁' : '📄';
440
+ const href = path.posix.join(urlPath, entry.name);
441
+ const name = entry.isDirectory() ? `${entry.name}/` : entry.name;
442
+ return `<li><a href="${href}">${icon} ${name}</a></li>`;
443
+ }).join('\n ');
444
+
445
+ return `<!DOCTYPE html>
446
+ <html>
447
+ <head>
448
+ <title>Index of ${urlPath}</title>
449
+ <style>
450
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; max-width: 900px; margin: 0 auto; }
451
+ h1 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; }
452
+ ul { list-style: none; padding: 0; }
453
+ li { padding: 8px 4px; border-bottom: 1px solid #f0f0f0; }
454
+ a { text-decoration: none; color: #0066cc; font-size: 15px; }
455
+ a:hover { text-decoration: underline; }
456
+ .footer { margin-top: 20px; color: #999; font-size: 12px; }
457
+ </style>
458
+ </head>
459
+ <body>
460
+ <h1>Index of ${urlPath}</h1>
461
+ <ul>
462
+ ${urlPath !== '/' ? '<li><a href="..">⬆️ Parent Directory</a></li>' : ''}
463
+ ${items}
464
+ </ul>
465
+ <div class="footer">Powered by @kadi.build/file-sharing</div>
466
+ </body>
467
+ </html>`;
468
+ }
469
+
470
+ /**
471
+ * Check authentication on incoming request.
472
+ *
473
+ * Supports three auth schemes (configured via `config.auth`):
474
+ *
475
+ * 1. **API Key** — `config.auth.apiKey`
476
+ * Checked against: `Authorization: Bearer <key>` header,
477
+ * `X-API-Key: <key>` header, or `?apiKey=<key>` query param.
478
+ *
479
+ * 2. **Bearer Token** — `config.auth.bearerToken`
480
+ * Checked against: `Authorization: Bearer <token>` header.
481
+ *
482
+ * 3. **Basic Auth** — `config.auth.username` + `config.auth.password`
483
+ * Checked against: `Authorization: Basic <base64>` header.
484
+ *
485
+ * If multiple schemes are configured, ANY valid match succeeds (OR logic).
486
+ *
487
+ * @private
488
+ * @param {import('http').IncomingMessage} req
489
+ * @returns {boolean}
490
+ */
491
+ _checkAuth(req) {
492
+ const authHeader = req.headers.authorization || '';
493
+ const auth = this.config.auth;
494
+
495
+ // --- API Key auth ---
496
+ if (auth.apiKey) {
497
+ // Check Authorization: Bearer <apiKey>
498
+ if (authHeader.startsWith('Bearer ') && authHeader.slice(7) === auth.apiKey) {
499
+ return true;
500
+ }
501
+ // Check X-API-Key header
502
+ if (req.headers['x-api-key'] === auth.apiKey) {
503
+ return true;
504
+ }
505
+ // Check ?apiKey= query param
506
+ try {
507
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
508
+ if (url.searchParams.get('apiKey') === auth.apiKey) {
509
+ return true;
510
+ }
511
+ } catch {
512
+ // malformed URL — ignore
513
+ }
514
+ }
515
+
516
+ // --- Bearer token auth ---
517
+ if (auth.bearerToken) {
518
+ if (authHeader.startsWith('Bearer ') && authHeader.slice(7) === auth.bearerToken) {
519
+ return true;
520
+ }
521
+ }
522
+
523
+ // --- Basic auth ---
524
+ if (auth.username && auth.password) {
525
+ if (authHeader.startsWith('Basic ')) {
526
+ const credentials = Buffer.from(authHeader.slice(6), 'base64').toString();
527
+ const [username, password] = credentials.split(':');
528
+ if (username === auth.username && password === auth.password) {
529
+ return true;
530
+ }
531
+ }
532
+ }
533
+
534
+ return false;
535
+ }
536
+ }
537
+
538
+ export default HttpServerProvider;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * MonitoringDashboard - Console monitoring dashboard for @kadi.build/file-sharing
3
+ *
4
+ * Provides a console-based real-time dashboard showing server status,
5
+ * active downloads, transfer statistics, and progress information.
6
+ *
7
+ * Migrated from src/monitoringDashboard.js
8
+ */
9
+
10
+ import { EventEmitter } from 'events';
11
+
12
+ export class MonitoringDashboard extends EventEmitter {
13
+ constructor(config = {}) {
14
+ super();
15
+
16
+ this.config = {
17
+ refreshInterval: 1000,
18
+ showProgressBars: true,
19
+ maxDisplayItems: 10,
20
+ ...config
21
+ };
22
+
23
+ this.isRunning = false;
24
+ this._intervalId = null;
25
+ this._stats = {
26
+ totalBytes: 0,
27
+ totalDownloads: 0,
28
+ activeCount: 0,
29
+ activeDownloads: [],
30
+ averageSpeed: 0,
31
+ peakConcurrent: 0,
32
+ uptime: 0
33
+ };
34
+ this._serverInfo = null;
35
+ }
36
+
37
+ /**
38
+ * Start the monitoring dashboard
39
+ * @returns {{ running: boolean }}
40
+ */
41
+ async start() {
42
+ if (this.isRunning) {
43
+ return { running: true };
44
+ }
45
+
46
+ this.isRunning = true;
47
+
48
+ this._intervalId = setInterval(() => {
49
+ this._render();
50
+ }, this.config.refreshInterval);
51
+
52
+ this.emit('started');
53
+ return { running: true };
54
+ }
55
+
56
+ /**
57
+ * Stop the monitoring dashboard
58
+ */
59
+ async stop() {
60
+ if (!this.isRunning) return;
61
+
62
+ if (this._intervalId) {
63
+ clearInterval(this._intervalId);
64
+ this._intervalId = null;
65
+ }
66
+
67
+ this.isRunning = false;
68
+ this.emit('stopped');
69
+ }
70
+
71
+ /**
72
+ * Update dashboard statistics
73
+ * @param {object} stats - New stats to display
74
+ */
75
+ updateStats(stats) {
76
+ this._stats = { ...this._stats, ...stats };
77
+ }
78
+
79
+ /**
80
+ * Set server information
81
+ * @param {object} info - Server info (localUrl, publicUrl, etc.)
82
+ */
83
+ setServerInfo(info) {
84
+ this._serverInfo = info;
85
+ }
86
+
87
+ /**
88
+ * Render the dashboard to console
89
+ * @private
90
+ */
91
+ _render() {
92
+ if (!this.isRunning) return;
93
+
94
+ const lines = [];
95
+ lines.push('');
96
+ lines.push('╔══════════════════════════════════════════════════════╗');
97
+ lines.push('║ KĀDI File Sharing Monitor ║');
98
+ lines.push('╠══════════════════════════════════════════════════════╣');
99
+
100
+ // Server info
101
+ if (this._serverInfo) {
102
+ lines.push(`║ Local: ${(this._serverInfo.localUrl || 'N/A').padEnd(44)}║`);
103
+ if (this._serverInfo.publicUrl) {
104
+ lines.push(`║ Public: ${this._serverInfo.publicUrl.padEnd(44)}║`);
105
+ }
106
+ if (this._serverInfo.s3Endpoint) {
107
+ lines.push(`║ S3: ${this._serverInfo.s3Endpoint.padEnd(44)}║`);
108
+ }
109
+ }
110
+
111
+ lines.push('╠══════════════════════════════════════════════════════╣');
112
+
113
+ // Stats
114
+ const stats = this._stats;
115
+ lines.push(`║ Downloads: ${String(stats.totalDownloads).padEnd(8)} Active: ${String(stats.activeCount).padEnd(8)} ║`);
116
+ lines.push(`║ Total: ${this._formatBytes(stats.totalBytes).padEnd(8)} Speed: ${this._formatSpeed(stats.averageSpeed).padEnd(8)} ║`);
117
+
118
+ // Active downloads
119
+ if (stats.activeDownloads && stats.activeDownloads.length > 0) {
120
+ lines.push('╠══════════════════════════════════════════════════════╣');
121
+ lines.push('║ Active Downloads: ║');
122
+
123
+ const displayed = stats.activeDownloads.slice(0, this.config.maxDisplayItems);
124
+ for (const dl of displayed) {
125
+ const name = (dl.file || dl.id || 'unknown').slice(-30).padEnd(30);
126
+ const progress = dl.progress ? `${dl.progress.toFixed(0)}%` : '?%';
127
+
128
+ if (this.config.showProgressBars) {
129
+ const bar = this._progressBar(dl.progress || 0, 15);
130
+ lines.push(`║ ${name} ${bar} ${progress.padStart(4)} ║`);
131
+ } else {
132
+ lines.push(`║ ${name} ${progress.padStart(4)} ║`);
133
+ }
134
+ }
135
+ }
136
+
137
+ lines.push('╚══════════════════════════════════════════════════════╝');
138
+
139
+ // Output (in a real console dashboard, this would use cursor movement)
140
+ this.emit('render', lines.join('\n'));
141
+ }
142
+
143
+ /**
144
+ * Generate a progress bar string
145
+ * @param {number} percent - Progress percentage (0-100)
146
+ * @param {number} width - Bar width in characters
147
+ * @returns {string}
148
+ * @private
149
+ */
150
+ _progressBar(percent, width = 20) {
151
+ const filled = Math.round((percent / 100) * width);
152
+ const empty = width - filled;
153
+ return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
154
+ }
155
+
156
+ /**
157
+ * Format bytes to human-readable string
158
+ * @param {number} bytes
159
+ * @returns {string}
160
+ * @private
161
+ */
162
+ _formatBytes(bytes) {
163
+ if (bytes === 0) return '0 B';
164
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
165
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
166
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
167
+ }
168
+
169
+ /**
170
+ * Format speed to human-readable string
171
+ * @param {number} bytesPerSec
172
+ * @returns {string}
173
+ * @private
174
+ */
175
+ _formatSpeed(bytesPerSec) {
176
+ if (!bytesPerSec || bytesPerSec === 0) return '0 B/s';
177
+ return `${this._formatBytes(bytesPerSec)}/s`;
178
+ }
179
+ }
180
+
181
+ export default MonitoringDashboard;