labgate 0.5.13 → 0.5.14

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/lib/ui.html CHANGED
@@ -627,6 +627,59 @@
627
627
  line-height: 1.4;
628
628
  }
629
629
 
630
+ .dataset-card-stats {
631
+ display: flex;
632
+ align-items: center;
633
+ gap: 6px;
634
+ padding-left: 42px;
635
+ margin-bottom: 8px;
636
+ font-size: 0.75rem;
637
+ color: var(--text-muted);
638
+ }
639
+
640
+ .dataset-card-stats .stat-dot {
641
+ color: var(--border-color);
642
+ }
643
+
644
+ .dataset-card-stats .stat-warning {
645
+ color: #d97706;
646
+ font-weight: 500;
647
+ }
648
+
649
+ .dataset-card-stats .stat-loading {
650
+ color: var(--text-muted);
651
+ font-style: italic;
652
+ }
653
+
654
+ .dataset-card-stats .stat-scan-btn {
655
+ background: none;
656
+ border: 1px solid var(--border-color);
657
+ color: var(--text-secondary);
658
+ font-size: 0.6875rem;
659
+ font-family: inherit;
660
+ padding: 1px 8px;
661
+ border-radius: 4px;
662
+ cursor: pointer;
663
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
664
+ }
665
+
666
+ .dataset-card-stats .stat-scan-btn:hover {
667
+ background: var(--bg-secondary);
668
+ color: var(--text-primary);
669
+ border-color: #B8CBB8;
670
+ }
671
+
672
+ .dataset-card-stats .stat-scan-btn:disabled {
673
+ opacity: 0.5;
674
+ cursor: default;
675
+ }
676
+
677
+ .dataset-card-stats .stat-scanned-at {
678
+ color: var(--text-muted);
679
+ font-size: 0.6875rem;
680
+ margin-left: 2px;
681
+ }
682
+
630
683
  .dataset-card-paths {
631
684
  display: flex;
632
685
  align-items: center;
@@ -3173,7 +3226,7 @@ function renderDatasets() {
3173
3226
  var descHtml = ds.description
3174
3227
  ? '<div class="dataset-card-desc">' + escapeHtml(ds.description) + '</div>'
3175
3228
  : '';
3176
- return '<div class="dataset-card">'
3229
+ return '<div class="dataset-card" data-ds-name="' + escapeHtml(ds.name) + '">'
3177
3230
  + '<div class="dataset-card-header">'
3178
3231
  + '<div class="dataset-card-icon">' + dbIcon + '</div>'
3179
3232
  + '<div class="dataset-card-title">'
@@ -3183,6 +3236,7 @@ function renderDatasets() {
3183
3236
  + '<button class="remove-btn" data-index="' + i + '" title="Remove dataset">&times;</button>'
3184
3237
  + '</div>'
3185
3238
  + descHtml
3239
+ + '<div class="dataset-card-stats"><span class="stat-loading">Loading stats\u2026</span></div>'
3186
3240
  + '<div class="dataset-card-paths">'
3187
3241
  + '<span class="dataset-path">' + escapeHtml(ds.path) + '</span>'
3188
3242
  + '<span class="dataset-path-arrow">' + arrowIcon + '</span>'
@@ -3198,6 +3252,98 @@ function renderDatasets() {
3198
3252
  removeDataset(idx);
3199
3253
  });
3200
3254
  });
3255
+ fetchDatasetStats();
3256
+ }
3257
+
3258
+ function renderStatsContent(statsEl, name, s) {
3259
+ if (!s.exists) {
3260
+ statsEl.innerHTML = '<span class="stat-warning">Path not found</span>';
3261
+ return;
3262
+ }
3263
+ if (!s.scanned_at) {
3264
+ statsEl.innerHTML = '<span style="color:var(--text-muted)">Not initialized</span>'
3265
+ + ' <button class="stat-scan-btn" data-scan-name="' + escapeHtml(name) + '" title="Initialize dataset">Scan</button>'
3266
+ + '<span class="stat-scanned-at" style="margin-left:4px">or run: <code style="font-size:0.6875rem">labgate dataset init ' + escapeHtml(name) + '</code></span>';
3267
+ bindScanButtons(statsEl);
3268
+ return;
3269
+ }
3270
+ var ago = timeAgo(s.scanned_at);
3271
+ statsEl.innerHTML = '<span>' + s.file_count + ' file' + (s.file_count !== 1 ? 's' : '') + '</span>'
3272
+ + '<span class="stat-dot">&middot;</span>'
3273
+ + '<span>' + escapeHtml(s.total_size_formatted) + '</span>'
3274
+ + '<span class="stat-scanned-at">(' + ago + ')</span>'
3275
+ + ' <button class="stat-scan-btn" data-scan-name="' + escapeHtml(name) + '" title="Rescan">&#8635;</button>';
3276
+ bindScanButtons(statsEl);
3277
+ }
3278
+
3279
+ function bindScanButtons(root) {
3280
+ root.querySelectorAll('.stat-scan-btn[data-scan-name]').forEach(function(btn) {
3281
+ btn.addEventListener('click', function() {
3282
+ scanDataset(btn.getAttribute('data-scan-name'));
3283
+ });
3284
+ });
3285
+ }
3286
+
3287
+ function timeAgo(isoStr) {
3288
+ var diff = Date.now() - new Date(isoStr).getTime();
3289
+ var sec = Math.floor(diff / 1000);
3290
+ if (sec < 60) return 'just now';
3291
+ var min = Math.floor(sec / 60);
3292
+ if (min < 60) return min + 'm ago';
3293
+ var hr = Math.floor(min / 60);
3294
+ if (hr < 24) return hr + 'h ago';
3295
+ var days = Math.floor(hr / 24);
3296
+ return days + 'd ago';
3297
+ }
3298
+
3299
+ function scanDataset(name) {
3300
+ var card = document.querySelector('.dataset-card[data-ds-name="' + name + '"]');
3301
+ if (!card) return;
3302
+ var statsEl = card.querySelector('.dataset-card-stats');
3303
+ if (statsEl) statsEl.innerHTML = '<span class="stat-loading">Scanning\u2026</span>';
3304
+
3305
+ fetch('/api/dataset-scan', {
3306
+ method: 'POST',
3307
+ headers: apiWriteHeaders(),
3308
+ body: JSON.stringify({ name: name })
3309
+ })
3310
+ .then(function(r) { return r.json(); })
3311
+ .then(function(data) {
3312
+ if (!statsEl) return;
3313
+ if (!data.ok) {
3314
+ statsEl.innerHTML = '<span class="stat-warning">' + escapeHtml(data.error || 'Scan failed') + '</span>';
3315
+ return;
3316
+ }
3317
+ renderStatsContent(statsEl, name, {
3318
+ exists: true,
3319
+ file_count: data.stats.file_count,
3320
+ total_size_formatted: data.stats.total_size_formatted,
3321
+ scanned_at: data.stats.scanned_at
3322
+ });
3323
+ showToast('Dataset "' + name + '" scanned: ' + data.stats.file_count + ' files, ' + data.stats.total_size_formatted, 'success');
3324
+ })
3325
+ .catch(function() {
3326
+ if (statsEl) statsEl.innerHTML = '<span class="stat-warning">Scan failed</span>';
3327
+ });
3328
+ }
3329
+
3330
+ function fetchDatasetStats() {
3331
+ fetch('/api/dataset-stats', { headers: apiReadHeaders() })
3332
+ .then(function(r) { return r.json(); })
3333
+ .then(function(data) {
3334
+ if (!data || !data.stats) return;
3335
+ var cards = document.querySelectorAll('.dataset-card[data-ds-name]');
3336
+ cards.forEach(function(card) {
3337
+ var name = card.getAttribute('data-ds-name');
3338
+ var statsEl = card.querySelector('.dataset-card-stats');
3339
+ if (!statsEl || !name || !data.stats[name]) return;
3340
+ renderStatsContent(statsEl, name, data.stats[name]);
3341
+ });
3342
+ })
3343
+ .catch(function() {
3344
+ var els = document.querySelectorAll('.dataset-card-stats .stat-loading');
3345
+ els.forEach(function(el) { el.textContent = ''; });
3346
+ });
3201
3347
  }
3202
3348
 
3203
3349
  function addDataset() {
@@ -3565,12 +3711,14 @@ function checkDatasetRestartNeeded() {
3565
3711
  var notice = document.getElementById('datasetNotice');
3566
3712
  var text = notice ? notice.querySelector('.dataset-notice-text') : null;
3567
3713
  if (needsRestart.length > 0) {
3714
+ if (notice) notice.style.display = '';
3568
3715
  if (text) text.textContent = needsRestart.length + ' active session' + (needsRestart.length > 1 ? 's' : '') + ' running with outdated config. Restart them now to apply dataset changes.';
3569
3716
  if (btn) { btn.style.display = ''; btn.disabled = false; btn.textContent = 'Restart Sessions Now'; }
3570
3717
  } else if (sessions.length > 0) {
3571
- if (text) text.textContent = 'Dataset changes are saved. Active sessions keep old mounts until you restart them.';
3718
+ if (notice) notice.style.display = 'none';
3572
3719
  if (btn) btn.style.display = 'none';
3573
3720
  } else {
3721
+ if (notice) notice.style.display = '';
3574
3722
  if (text) text.textContent = 'No active sessions. Dataset changes will be used the next time you start a session.';
3575
3723
  if (btn) btn.style.display = 'none';
3576
3724
  }
package/dist/lib/ui.js CHANGED
@@ -1502,6 +1502,129 @@ async function handleValidatePath(req, res) {
1502
1502
  json(res, { valid: false, error: err.message ?? String(err) }, 400);
1503
1503
  }
1504
1504
  }
1505
+ // ── Dataset stats helpers ───────────────────────────────
1506
+ function formatBytesUI(bytes) {
1507
+ if (bytes === 0)
1508
+ return '0 B';
1509
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
1510
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
1511
+ const val = bytes / Math.pow(1024, i);
1512
+ return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
1513
+ }
1514
+ function walkDirShallow(dir, maxDepth, depth = 0) {
1515
+ let files = 0;
1516
+ let totalSize = 0;
1517
+ if (depth > maxDepth)
1518
+ return { files, totalSize };
1519
+ let entries;
1520
+ try {
1521
+ entries = (0, fs_1.readdirSync)(dir);
1522
+ }
1523
+ catch {
1524
+ return { files, totalSize };
1525
+ }
1526
+ for (const entry of entries) {
1527
+ if (entry.startsWith('.') && depth === 0)
1528
+ continue;
1529
+ const fullPath = (0, path_1.join)(dir, entry);
1530
+ try {
1531
+ const st = (0, fs_1.statSync)(fullPath);
1532
+ if (st.isDirectory()) {
1533
+ if (depth < maxDepth) {
1534
+ const sub = walkDirShallow(fullPath, maxDepth, depth + 1);
1535
+ files += sub.files;
1536
+ totalSize += sub.totalSize;
1537
+ }
1538
+ }
1539
+ else {
1540
+ files++;
1541
+ totalSize += st.size;
1542
+ }
1543
+ }
1544
+ catch { /* skip inaccessible entries */ }
1545
+ }
1546
+ return { files, totalSize };
1547
+ }
1548
+ function handleGetDatasetStats(_req, res) {
1549
+ const config = (0, config_js_1.loadConfig)();
1550
+ const datasets = config.datasets || [];
1551
+ const stats = {};
1552
+ for (const ds of datasets) {
1553
+ const hostPath = ds.path.replace(/^~/, (0, os_1.homedir)());
1554
+ const pathExists = (0, fs_1.existsSync)(hostPath);
1555
+ if (ds.stats) {
1556
+ stats[ds.name] = {
1557
+ exists: pathExists,
1558
+ file_count: ds.stats.file_count,
1559
+ total_size: ds.stats.total_size,
1560
+ total_size_formatted: ds.stats.total_size_formatted,
1561
+ scanned_at: ds.stats.scanned_at,
1562
+ };
1563
+ }
1564
+ else {
1565
+ stats[ds.name] = {
1566
+ exists: pathExists,
1567
+ file_count: 0,
1568
+ total_size: 0,
1569
+ total_size_formatted: '0 B',
1570
+ scanned_at: null,
1571
+ };
1572
+ }
1573
+ }
1574
+ json(res, { stats });
1575
+ }
1576
+ async function handlePostDatasetScan(req, res) {
1577
+ try {
1578
+ const body = await readBody(req);
1579
+ const { name } = JSON.parse(body);
1580
+ if (!name || typeof name !== 'string') {
1581
+ json(res, { ok: false, error: 'Missing dataset name' }, 400);
1582
+ return;
1583
+ }
1584
+ const configPath = (0, config_js_1.getConfigPath)();
1585
+ let obj = {};
1586
+ if ((0, fs_1.existsSync)(configPath)) {
1587
+ const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
1588
+ const stripped = rawText
1589
+ .split('\n')
1590
+ .filter(line => !line.trimStart().startsWith('//'))
1591
+ .join('\n');
1592
+ try {
1593
+ obj = JSON.parse(stripped);
1594
+ }
1595
+ catch {
1596
+ obj = {};
1597
+ }
1598
+ }
1599
+ const datasets = (obj.datasets || []);
1600
+ const idx = datasets.findIndex((d) => d?.name && String(d.name).toLowerCase() === name.toLowerCase());
1601
+ if (idx < 0) {
1602
+ json(res, { ok: false, error: `Dataset "${name}" not found` }, 404);
1603
+ return;
1604
+ }
1605
+ const ds = datasets[idx];
1606
+ const hostPath = String(ds.path || '').replace(/^~/, (0, os_1.homedir)());
1607
+ if (!(0, fs_1.existsSync)(hostPath)) {
1608
+ json(res, { ok: false, error: `Path does not exist: ${hostPath}` }, 400);
1609
+ return;
1610
+ }
1611
+ const walked = walkDirShallow(hostPath, 1);
1612
+ const statsObj = {
1613
+ file_count: walked.files,
1614
+ total_size: walked.totalSize,
1615
+ total_size_formatted: formatBytesUI(walked.totalSize),
1616
+ scanned_at: new Date().toISOString(),
1617
+ };
1618
+ ds.stats = statsObj;
1619
+ obj.datasets = datasets;
1620
+ (0, fs_1.writeFileSync)(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
1621
+ (0, config_js_1.ensurePrivateFile)(configPath);
1622
+ json(res, { ok: true, stats: statsObj });
1623
+ }
1624
+ catch (err) {
1625
+ json(res, { ok: false, error: err.message ?? String(err) }, 500);
1626
+ }
1627
+ }
1505
1628
  function handleGetLogs(_req, res) {
1506
1629
  const config = (0, config_js_1.loadConfig)();
1507
1630
  if (!config.audit.enabled) {
@@ -2700,6 +2823,12 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
2700
2823
  else if (pathname === '/api/validate-path' && method === 'POST') {
2701
2824
  await handleValidatePath(req, res);
2702
2825
  }
2826
+ else if (pathname === '/api/dataset-stats' && method === 'GET') {
2827
+ handleGetDatasetStats(req, res);
2828
+ }
2829
+ else if (pathname === '/api/dataset-scan' && method === 'POST') {
2830
+ await handlePostDatasetScan(req, res);
2831
+ }
2703
2832
  else if (/^\/api\/sessions\/[^/]+\/instructions$/.test(pathname) && method === 'GET') {
2704
2833
  handleGetSessionInstructions(pathname, reqUrl, res);
2705
2834
  }