codedash-app 2.0.1 → 3.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.
- package/package.json +1 -1
- package/src/data.js +111 -0
- package/src/frontend/app.js +231 -1
- package/src/frontend/index.html +4 -0
- package/src/frontend/styles.css +322 -5
- package/src/server.js +16 -1
package/package.json
CHANGED
package/src/data.js
CHANGED
|
@@ -539,6 +539,115 @@ function searchFullText(query, sessions) {
|
|
|
539
539
|
|
|
540
540
|
// ── Exports ────────────────────────────────────────────────
|
|
541
541
|
|
|
542
|
+
// ── Session replay data (with timestamps) ─────────────────
|
|
543
|
+
|
|
544
|
+
function getSessionReplay(sessionId, project) {
|
|
545
|
+
const found = findSessionFile(sessionId, project);
|
|
546
|
+
if (!found) return { messages: [], duration: 0 };
|
|
547
|
+
|
|
548
|
+
const messages = [];
|
|
549
|
+
const lines = fs.readFileSync(found.file, 'utf8').split('\n').filter(Boolean);
|
|
550
|
+
|
|
551
|
+
for (const line of lines) {
|
|
552
|
+
try {
|
|
553
|
+
const entry = JSON.parse(line);
|
|
554
|
+
let role, content, ts;
|
|
555
|
+
|
|
556
|
+
if (found.format === 'claude') {
|
|
557
|
+
if (entry.type !== 'user' && entry.type !== 'assistant') continue;
|
|
558
|
+
role = entry.type;
|
|
559
|
+
content = extractContent((entry.message || {}).content);
|
|
560
|
+
ts = entry.timestamp || '';
|
|
561
|
+
} else {
|
|
562
|
+
if (entry.type !== 'response_item' || !entry.payload) continue;
|
|
563
|
+
role = entry.payload.role;
|
|
564
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
565
|
+
content = extractContent(entry.payload.content);
|
|
566
|
+
ts = entry.timestamp || '';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (!content || isSystemMessage(content)) continue;
|
|
570
|
+
|
|
571
|
+
messages.push({
|
|
572
|
+
role,
|
|
573
|
+
content: content.slice(0, 3000),
|
|
574
|
+
timestamp: ts,
|
|
575
|
+
ms: ts ? new Date(ts).getTime() : 0,
|
|
576
|
+
});
|
|
577
|
+
} catch {}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Calculate duration
|
|
581
|
+
const startMs = messages.length > 0 ? messages[0].ms : 0;
|
|
582
|
+
const endMs = messages.length > 0 ? messages[messages.length - 1].ms : 0;
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
messages,
|
|
586
|
+
startMs,
|
|
587
|
+
endMs,
|
|
588
|
+
duration: endMs - startMs,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Cost analytics ────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
function getCostAnalytics(sessions) {
|
|
595
|
+
const byDay = {};
|
|
596
|
+
const byProject = {};
|
|
597
|
+
const byWeek = {};
|
|
598
|
+
let totalCost = 0;
|
|
599
|
+
let totalTokens = 0;
|
|
600
|
+
const sessionCosts = [];
|
|
601
|
+
|
|
602
|
+
for (const s of sessions) {
|
|
603
|
+
if (!s.file_size) continue;
|
|
604
|
+
const tokens = s.file_size / 4;
|
|
605
|
+
const cost = tokens * 0.000015 * 0.3 + tokens * 0.000075 * 0.7;
|
|
606
|
+
totalCost += cost;
|
|
607
|
+
totalTokens += tokens;
|
|
608
|
+
|
|
609
|
+
// By day
|
|
610
|
+
const day = s.date || 'unknown';
|
|
611
|
+
if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 };
|
|
612
|
+
byDay[day].cost += cost;
|
|
613
|
+
byDay[day].sessions++;
|
|
614
|
+
byDay[day].tokens += tokens;
|
|
615
|
+
|
|
616
|
+
// By week
|
|
617
|
+
if (s.date) {
|
|
618
|
+
const d = new Date(s.date);
|
|
619
|
+
const weekStart = new Date(d);
|
|
620
|
+
weekStart.setDate(d.getDate() - d.getDay());
|
|
621
|
+
const weekKey = weekStart.toISOString().slice(0, 10);
|
|
622
|
+
if (!byWeek[weekKey]) byWeek[weekKey] = { cost: 0, sessions: 0 };
|
|
623
|
+
byWeek[weekKey].cost += cost;
|
|
624
|
+
byWeek[weekKey].sessions++;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// By project
|
|
628
|
+
const proj = s.project_short || s.project || 'unknown';
|
|
629
|
+
if (!byProject[proj]) byProject[proj] = { cost: 0, sessions: 0, tokens: 0 };
|
|
630
|
+
byProject[proj].cost += cost;
|
|
631
|
+
byProject[proj].sessions++;
|
|
632
|
+
byProject[proj].tokens += tokens;
|
|
633
|
+
|
|
634
|
+
sessionCosts.push({ id: s.id, cost, project: proj, date: s.date });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Sort top sessions by cost
|
|
638
|
+
sessionCosts.sort((a, b) => b.cost - a.cost);
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
totalCost,
|
|
642
|
+
totalTokens,
|
|
643
|
+
totalSessions: sessions.length,
|
|
644
|
+
byDay,
|
|
645
|
+
byWeek,
|
|
646
|
+
byProject,
|
|
647
|
+
topSessions: sessionCosts.slice(0, 10),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
542
651
|
// ── Active sessions detection ─────────────────────────────
|
|
543
652
|
|
|
544
653
|
function getActiveSessions() {
|
|
@@ -635,6 +744,8 @@ module.exports = {
|
|
|
635
744
|
getSessionPreview,
|
|
636
745
|
searchFullText,
|
|
637
746
|
getActiveSessions,
|
|
747
|
+
getSessionReplay,
|
|
748
|
+
getCostAnalytics,
|
|
638
749
|
CLAUDE_DIR,
|
|
639
750
|
CODEX_DIR,
|
|
640
751
|
HISTORY_FILE,
|
package/src/frontend/app.js
CHANGED
|
@@ -686,6 +686,11 @@ function render() {
|
|
|
686
686
|
return;
|
|
687
687
|
}
|
|
688
688
|
|
|
689
|
+
if (currentView === 'analytics') {
|
|
690
|
+
renderAnalytics(content);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
689
694
|
if (currentView === 'starred') {
|
|
690
695
|
var starredSessions = sessions.filter(function(s) { return stars.indexOf(s.id) >= 0; });
|
|
691
696
|
if (starredSessions.length === 0) {
|
|
@@ -1019,9 +1024,15 @@ async function openDetail(s) {
|
|
|
1019
1024
|
|
|
1020
1025
|
// Action buttons
|
|
1021
1026
|
infoHtml += '<div class="detail-actions">';
|
|
1022
|
-
|
|
1027
|
+
// Show Focus button for active sessions
|
|
1028
|
+
if (activeSessions[s.id]) {
|
|
1029
|
+
infoHtml += '<button class="launch-btn" style="background:var(--accent-green);color:#000" onclick="focusSession(\'' + s.id + '\')">Focus Terminal</button>';
|
|
1030
|
+
} else {
|
|
1031
|
+
infoHtml += '<button class="launch-btn" onclick="launchSession(\'' + s.id + '\',\'' + escHtml(s.tool) + '\',\'' + escHtml(s.project || '') + '\')">Resume in Terminal</button>';
|
|
1032
|
+
}
|
|
1023
1033
|
infoHtml += '<button class="launch-btn btn-secondary" onclick="copyResume(\'' + s.id + '\',\'' + escHtml(s.tool) + '\')">Copy Command</button>';
|
|
1024
1034
|
if (s.has_detail) {
|
|
1035
|
+
infoHtml += '<button class="launch-btn btn-secondary" onclick="closeDetail();openReplay(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Replay</button>';
|
|
1025
1036
|
infoHtml += '<button class="launch-btn btn-secondary" onclick="exportMd(\'' + s.id + '\',\'' + escHtml(s.project || '') + '\')">Export MD</button>';
|
|
1026
1037
|
}
|
|
1027
1038
|
infoHtml += '<button class="star-btn detail-star' + (isStarred ? ' active' : '') + '" onclick="toggleStar(\'' + s.id + '\')">★ ' + (isStarred ? 'Starred' : 'Star') + '</button>';
|
|
@@ -1388,6 +1399,225 @@ document.addEventListener('keydown', function(e) {
|
|
|
1388
1399
|
}
|
|
1389
1400
|
});
|
|
1390
1401
|
|
|
1402
|
+
// ── Session Replay ────────────────────────────────────────────
|
|
1403
|
+
|
|
1404
|
+
async function openReplay(sessionId, project) {
|
|
1405
|
+
var content = document.getElementById('content');
|
|
1406
|
+
content.innerHTML = '<div class="loading">Loading replay...</div>';
|
|
1407
|
+
|
|
1408
|
+
try {
|
|
1409
|
+
var resp = await fetch('/api/replay/' + sessionId + '?project=' + encodeURIComponent(project));
|
|
1410
|
+
var data = await resp.json();
|
|
1411
|
+
|
|
1412
|
+
if (!data.messages || data.messages.length === 0) {
|
|
1413
|
+
content.innerHTML = '<div class="empty-state">No messages to replay.</div>';
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
var msgs = data.messages;
|
|
1418
|
+
var html = '<div class="replay-container">';
|
|
1419
|
+
html += '<div class="replay-header">';
|
|
1420
|
+
html += '<button class="launch-btn btn-secondary" onclick="setView(\'sessions\')">Back</button>';
|
|
1421
|
+
html += '<span class="replay-title">Session Replay — ' + sessionId.slice(0, 12) + '</span>';
|
|
1422
|
+
html += '<span class="replay-duration">' + formatDuration(data.duration) + '</span>';
|
|
1423
|
+
html += '</div>';
|
|
1424
|
+
|
|
1425
|
+
// Timeline slider
|
|
1426
|
+
html += '<div class="replay-controls">';
|
|
1427
|
+
html += '<button class="replay-play-btn" id="replayPlayBtn" onclick="toggleReplayPlay()">▶</button>';
|
|
1428
|
+
html += '<input type="range" class="replay-slider" id="replaySlider" min="0" max="' + (msgs.length - 1) + '" value="0" oninput="seekReplay(this.value)">';
|
|
1429
|
+
html += '<span class="replay-counter" id="replayCounter">1 / ' + msgs.length + '</span>';
|
|
1430
|
+
html += '</div>';
|
|
1431
|
+
|
|
1432
|
+
// Messages area
|
|
1433
|
+
html += '<div class="replay-messages" id="replayMessages"></div>';
|
|
1434
|
+
html += '</div>';
|
|
1435
|
+
|
|
1436
|
+
content.innerHTML = html;
|
|
1437
|
+
|
|
1438
|
+
// Store messages for replay
|
|
1439
|
+
window._replayMsgs = msgs;
|
|
1440
|
+
window._replayPos = 0;
|
|
1441
|
+
window._replayPlaying = false;
|
|
1442
|
+
window._replayTimer = null;
|
|
1443
|
+
seekReplay(0);
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
content.innerHTML = '<div class="empty-state">Failed to load replay.</div>';
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function seekReplay(pos) {
|
|
1450
|
+
pos = parseInt(pos);
|
|
1451
|
+
var msgs = window._replayMsgs;
|
|
1452
|
+
if (!msgs) return;
|
|
1453
|
+
window._replayPos = pos;
|
|
1454
|
+
|
|
1455
|
+
var container = document.getElementById('replayMessages');
|
|
1456
|
+
var slider = document.getElementById('replaySlider');
|
|
1457
|
+
var counter = document.getElementById('replayCounter');
|
|
1458
|
+
if (!container) return;
|
|
1459
|
+
|
|
1460
|
+
var html = '';
|
|
1461
|
+
for (var i = 0; i <= pos && i < msgs.length; i++) {
|
|
1462
|
+
var m = msgs[i];
|
|
1463
|
+
var cls = m.role === 'user' ? 'preview-user' : 'preview-assistant';
|
|
1464
|
+
var label = m.role === 'user' ? 'You' : 'AI';
|
|
1465
|
+
var time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : '';
|
|
1466
|
+
var isLatest = i === pos;
|
|
1467
|
+
html += '<div class="replay-msg ' + cls + (isLatest ? ' replay-latest' : '') + '">';
|
|
1468
|
+
html += '<div class="replay-msg-header"><span class="preview-role">' + label + '</span><span class="replay-time">' + time + '</span></div>';
|
|
1469
|
+
html += '<div class="replay-msg-content">' + escHtml(m.content) + '</div>';
|
|
1470
|
+
html += '</div>';
|
|
1471
|
+
}
|
|
1472
|
+
container.innerHTML = html;
|
|
1473
|
+
container.scrollTop = container.scrollHeight;
|
|
1474
|
+
|
|
1475
|
+
if (slider) slider.value = pos;
|
|
1476
|
+
if (counter) counter.textContent = (pos + 1) + ' / ' + msgs.length;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function toggleReplayPlay() {
|
|
1480
|
+
var btn = document.getElementById('replayPlayBtn');
|
|
1481
|
+
if (window._replayPlaying) {
|
|
1482
|
+
window._replayPlaying = false;
|
|
1483
|
+
clearInterval(window._replayTimer);
|
|
1484
|
+
if (btn) btn.innerHTML = '▶';
|
|
1485
|
+
} else {
|
|
1486
|
+
window._replayPlaying = true;
|
|
1487
|
+
if (btn) btn.innerHTML = '▮▮';
|
|
1488
|
+
window._replayTimer = setInterval(function() {
|
|
1489
|
+
var next = window._replayPos + 1;
|
|
1490
|
+
if (next >= window._replayMsgs.length) {
|
|
1491
|
+
toggleReplayPlay();
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
seekReplay(next);
|
|
1495
|
+
}, 1500);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function formatDuration(ms) {
|
|
1500
|
+
if (!ms) return '';
|
|
1501
|
+
var s = Math.floor(ms / 1000);
|
|
1502
|
+
var m = Math.floor(s / 60);
|
|
1503
|
+
var h = Math.floor(m / 60);
|
|
1504
|
+
if (h > 0) return h + 'h ' + (m % 60) + 'm';
|
|
1505
|
+
if (m > 0) return m + 'm ' + (s % 60) + 's';
|
|
1506
|
+
return s + 's';
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// ── Cost Analytics ────────────────────────────────────────────
|
|
1510
|
+
|
|
1511
|
+
async function renderAnalytics(container) {
|
|
1512
|
+
container.innerHTML = '<div class="loading">Loading analytics...</div>';
|
|
1513
|
+
|
|
1514
|
+
try {
|
|
1515
|
+
var resp = await fetch('/api/analytics/cost');
|
|
1516
|
+
var data = await resp.json();
|
|
1517
|
+
|
|
1518
|
+
var html = '<div class="analytics-container">';
|
|
1519
|
+
html += '<h2 class="heatmap-title">Cost Analytics</h2>';
|
|
1520
|
+
|
|
1521
|
+
// Summary cards
|
|
1522
|
+
html += '<div class="analytics-summary">';
|
|
1523
|
+
html += '<div class="analytics-card"><span class="analytics-val">~$' + data.totalCost.toFixed(2) + '</span><span class="analytics-label">Total estimated cost</span></div>';
|
|
1524
|
+
html += '<div class="analytics-card"><span class="analytics-val">' + formatTokens(data.totalTokens) + '</span><span class="analytics-label">Total tokens</span></div>';
|
|
1525
|
+
html += '<div class="analytics-card"><span class="analytics-val">' + data.totalSessions + '</span><span class="analytics-label">Sessions</span></div>';
|
|
1526
|
+
html += '<div class="analytics-card"><span class="analytics-val">~$' + (data.totalCost / Math.max(data.totalSessions, 1)).toFixed(2) + '</span><span class="analytics-label">Avg per session</span></div>';
|
|
1527
|
+
html += '</div>';
|
|
1528
|
+
|
|
1529
|
+
// Cost by day chart (bar chart)
|
|
1530
|
+
var days = Object.keys(data.byDay).sort();
|
|
1531
|
+
var last30 = days.slice(-30);
|
|
1532
|
+
if (last30.length > 0) {
|
|
1533
|
+
var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; }));
|
|
1534
|
+
html += '<div class="chart-section"><h3>Daily Cost (last 30 days)</h3>';
|
|
1535
|
+
html += '<div class="bar-chart">';
|
|
1536
|
+
last30.forEach(function(d) {
|
|
1537
|
+
var c = data.byDay[d];
|
|
1538
|
+
var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0;
|
|
1539
|
+
var label = d.slice(5); // MM-DD
|
|
1540
|
+
html += '<div class="bar-col" title="' + d + ': ~$' + c.cost.toFixed(2) + ' (' + c.sessions + ' sessions)">';
|
|
1541
|
+
html += '<div class="bar-fill" style="height:' + pct + '%"></div>';
|
|
1542
|
+
html += '<div class="bar-label">' + label + '</div>';
|
|
1543
|
+
html += '</div>';
|
|
1544
|
+
});
|
|
1545
|
+
html += '</div></div>';
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Cost by project (horizontal bars)
|
|
1549
|
+
var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; });
|
|
1550
|
+
var topProjects = projects.slice(0, 10);
|
|
1551
|
+
if (topProjects.length > 0) {
|
|
1552
|
+
var maxProjCost = topProjects[0][1].cost;
|
|
1553
|
+
html += '<div class="chart-section"><h3>Cost by Project</h3>';
|
|
1554
|
+
html += '<div class="hbar-chart">';
|
|
1555
|
+
topProjects.forEach(function(entry) {
|
|
1556
|
+
var name = entry[0];
|
|
1557
|
+
var info = entry[1];
|
|
1558
|
+
var pct = maxProjCost > 0 ? (info.cost / maxProjCost * 100) : 0;
|
|
1559
|
+
html += '<div class="hbar-row">';
|
|
1560
|
+
html += '<span class="hbar-name">' + escHtml(name) + '</span>';
|
|
1561
|
+
html += '<div class="hbar-track"><div class="hbar-fill" style="width:' + pct + '%"></div></div>';
|
|
1562
|
+
html += '<span class="hbar-val">~$' + info.cost.toFixed(2) + '</span>';
|
|
1563
|
+
html += '</div>';
|
|
1564
|
+
});
|
|
1565
|
+
html += '</div></div>';
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Top expensive sessions
|
|
1569
|
+
if (data.topSessions && data.topSessions.length > 0) {
|
|
1570
|
+
html += '<div class="chart-section"><h3>Most Expensive Sessions</h3>';
|
|
1571
|
+
html += '<div class="top-sessions">';
|
|
1572
|
+
data.topSessions.forEach(function(s) {
|
|
1573
|
+
html += '<div class="top-session-row" onclick="onCardClick(\'' + s.id + '\', event)">';
|
|
1574
|
+
html += '<span class="top-session-cost">~$' + s.cost.toFixed(2) + '</span>';
|
|
1575
|
+
html += '<span class="top-session-project">' + escHtml(s.project) + '</span>';
|
|
1576
|
+
html += '<span class="top-session-date">' + (s.date || '') + '</span>';
|
|
1577
|
+
html += '<span class="top-session-id">' + s.id.slice(0, 8) + '</span>';
|
|
1578
|
+
html += '</div>';
|
|
1579
|
+
});
|
|
1580
|
+
html += '</div></div>';
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
html += '</div>';
|
|
1584
|
+
container.innerHTML = html;
|
|
1585
|
+
} catch (e) {
|
|
1586
|
+
container.innerHTML = '<div class="empty-state">Failed to load analytics.</div>';
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function formatTokens(n) {
|
|
1591
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
1592
|
+
if (n >= 1000) return (n / 1000).toFixed(0) + 'K';
|
|
1593
|
+
return String(n);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// ── Focus active session (switch to terminal) ─────────────────
|
|
1597
|
+
|
|
1598
|
+
function focusSession(sessionId) {
|
|
1599
|
+
var a = activeSessions[sessionId];
|
|
1600
|
+
if (!a) { showToast('Session not active'); return; }
|
|
1601
|
+
|
|
1602
|
+
// Use osascript via the launch API to focus the terminal window
|
|
1603
|
+
var terminal = localStorage.getItem('codedash-terminal') || '';
|
|
1604
|
+
fetch('/api/launch', {
|
|
1605
|
+
method: 'POST',
|
|
1606
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1607
|
+
body: JSON.stringify({
|
|
1608
|
+
sessionId: sessionId,
|
|
1609
|
+
tool: a.kind === 'codex' ? 'codex' : 'claude',
|
|
1610
|
+
flags: ['focus'],
|
|
1611
|
+
project: a.cwd || '',
|
|
1612
|
+
terminal: terminal,
|
|
1613
|
+
})
|
|
1614
|
+
}).then(function() {
|
|
1615
|
+
showToast('Focused terminal');
|
|
1616
|
+
}).catch(function() {
|
|
1617
|
+
showToast('Could not focus terminal');
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1391
1621
|
// ── Export/Import dialog ──────────────────────────────────────
|
|
1392
1622
|
|
|
1393
1623
|
function showExportDialog() {
|
package/src/frontend/index.html
CHANGED
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="7" width="3" height="3"/><rect x="14" y="7" width="3" height="3"/><rect x="7" y="14" width="3" height="3"/><rect x="14" y="14" width="3" height="3"/></svg>
|
|
31
31
|
Activity
|
|
32
32
|
</div>
|
|
33
|
+
<div class="sidebar-item" data-view="analytics">
|
|
34
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
|
35
|
+
Analytics
|
|
36
|
+
</div>
|
|
33
37
|
<div class="sidebar-item" data-view="starred">
|
|
34
38
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
|
35
39
|
Starred
|
package/src/frontend/styles.css
CHANGED
|
@@ -1437,15 +1437,65 @@ body {
|
|
|
1437
1437
|
50% { opacity: 0.3; transform: scale(0.6); }
|
|
1438
1438
|
}
|
|
1439
1439
|
|
|
1440
|
-
/*
|
|
1440
|
+
/* Animated border for live sessions */
|
|
1441
|
+
.card:has(.live-active),
|
|
1442
|
+
.card:has(.live-waiting) {
|
|
1443
|
+
border-color: transparent;
|
|
1444
|
+
position: relative;
|
|
1445
|
+
overflow: hidden;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
.card:has(.live-active)::before,
|
|
1449
|
+
.card:has(.live-waiting)::before {
|
|
1450
|
+
content: '';
|
|
1451
|
+
position: absolute;
|
|
1452
|
+
inset: -2px;
|
|
1453
|
+
border-radius: 12px;
|
|
1454
|
+
z-index: -1;
|
|
1455
|
+
background: conic-gradient(
|
|
1456
|
+
from var(--border-angle, 0deg),
|
|
1457
|
+
transparent 40%,
|
|
1458
|
+
var(--live-color) 50%,
|
|
1459
|
+
transparent 60%
|
|
1460
|
+
);
|
|
1461
|
+
animation: border-spin 3s linear infinite;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
.card:has(.live-active)::after,
|
|
1465
|
+
.card:has(.live-waiting)::after {
|
|
1466
|
+
content: '';
|
|
1467
|
+
position: absolute;
|
|
1468
|
+
inset: 1px;
|
|
1469
|
+
border-radius: 9px;
|
|
1470
|
+
background: var(--bg-card);
|
|
1471
|
+
z-index: -1;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1441
1474
|
.card:has(.live-active) {
|
|
1442
|
-
|
|
1443
|
-
box-shadow: 0 0 20px rgba(74, 222, 128, 0.08);
|
|
1475
|
+
--live-color: rgba(74, 222, 128, 0.6);
|
|
1444
1476
|
}
|
|
1445
1477
|
|
|
1446
1478
|
.card:has(.live-waiting) {
|
|
1447
|
-
|
|
1448
|
-
|
|
1479
|
+
--live-color: rgba(251, 191, 36, 0.4);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
.card:has(.live-waiting)::before {
|
|
1483
|
+
animation: none;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
@keyframes border-spin {
|
|
1487
|
+
to { --border-angle: 360deg; }
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
@property --border-angle {
|
|
1491
|
+
syntax: '<angle>';
|
|
1492
|
+
initial-value: 0deg;
|
|
1493
|
+
inherits: false;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
[data-theme="light"] .card:has(.live-active)::after,
|
|
1497
|
+
[data-theme="light"] .card:has(.live-waiting)::after {
|
|
1498
|
+
background: #ffffff;
|
|
1449
1499
|
}
|
|
1450
1500
|
|
|
1451
1501
|
/* ── Card expand preview ────────────────────────────────────── */
|
|
@@ -1546,6 +1596,273 @@ body {
|
|
|
1546
1596
|
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
|
1547
1597
|
}
|
|
1548
1598
|
|
|
1599
|
+
/* ── Session Replay ─────────────────────────────────────────── */
|
|
1600
|
+
|
|
1601
|
+
.replay-container { padding: 20px; }
|
|
1602
|
+
|
|
1603
|
+
.replay-header {
|
|
1604
|
+
display: flex;
|
|
1605
|
+
align-items: center;
|
|
1606
|
+
gap: 16px;
|
|
1607
|
+
margin-bottom: 16px;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
.replay-title {
|
|
1611
|
+
font-size: 16px;
|
|
1612
|
+
font-weight: 600;
|
|
1613
|
+
flex: 1;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
.replay-duration {
|
|
1617
|
+
color: var(--text-muted);
|
|
1618
|
+
font-size: 13px;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
.replay-controls {
|
|
1622
|
+
display: flex;
|
|
1623
|
+
align-items: center;
|
|
1624
|
+
gap: 12px;
|
|
1625
|
+
margin-bottom: 20px;
|
|
1626
|
+
padding: 12px 16px;
|
|
1627
|
+
background: var(--bg-card);
|
|
1628
|
+
border-radius: 10px;
|
|
1629
|
+
border: 1px solid var(--border);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
.replay-play-btn {
|
|
1633
|
+
width: 36px;
|
|
1634
|
+
height: 36px;
|
|
1635
|
+
border-radius: 50%;
|
|
1636
|
+
border: none;
|
|
1637
|
+
background: var(--accent-blue);
|
|
1638
|
+
color: #fff;
|
|
1639
|
+
font-size: 14px;
|
|
1640
|
+
cursor: pointer;
|
|
1641
|
+
display: flex;
|
|
1642
|
+
align-items: center;
|
|
1643
|
+
justify-content: center;
|
|
1644
|
+
flex-shrink: 0;
|
|
1645
|
+
}
|
|
1646
|
+
.replay-play-btn:hover { opacity: 0.85; }
|
|
1647
|
+
|
|
1648
|
+
.replay-slider {
|
|
1649
|
+
flex: 1;
|
|
1650
|
+
height: 6px;
|
|
1651
|
+
-webkit-appearance: none;
|
|
1652
|
+
appearance: none;
|
|
1653
|
+
background: var(--border);
|
|
1654
|
+
border-radius: 3px;
|
|
1655
|
+
outline: none;
|
|
1656
|
+
cursor: pointer;
|
|
1657
|
+
}
|
|
1658
|
+
.replay-slider::-webkit-slider-thumb {
|
|
1659
|
+
-webkit-appearance: none;
|
|
1660
|
+
width: 16px;
|
|
1661
|
+
height: 16px;
|
|
1662
|
+
border-radius: 50%;
|
|
1663
|
+
background: var(--accent-blue);
|
|
1664
|
+
cursor: pointer;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
.replay-counter {
|
|
1668
|
+
font-size: 12px;
|
|
1669
|
+
color: var(--text-muted);
|
|
1670
|
+
white-space: nowrap;
|
|
1671
|
+
min-width: 60px;
|
|
1672
|
+
text-align: right;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
.replay-messages {
|
|
1676
|
+
max-height: calc(100vh - 200px);
|
|
1677
|
+
overflow-y: auto;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
.replay-msg {
|
|
1681
|
+
padding: 12px 16px;
|
|
1682
|
+
margin-bottom: 8px;
|
|
1683
|
+
border-radius: 10px;
|
|
1684
|
+
animation: fadeIn 0.3s ease;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.replay-latest {
|
|
1688
|
+
box-shadow: 0 0 0 2px var(--accent-blue);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
.replay-msg-header {
|
|
1692
|
+
display: flex;
|
|
1693
|
+
justify-content: space-between;
|
|
1694
|
+
margin-bottom: 4px;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
.replay-time {
|
|
1698
|
+
font-size: 11px;
|
|
1699
|
+
color: var(--text-muted);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
.replay-msg-content {
|
|
1703
|
+
font-size: 13px;
|
|
1704
|
+
line-height: 1.6;
|
|
1705
|
+
white-space: pre-wrap;
|
|
1706
|
+
word-break: break-word;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/* ── Cost Analytics ─────────────────────────────────────────── */
|
|
1710
|
+
|
|
1711
|
+
.analytics-container { padding: 20px; }
|
|
1712
|
+
|
|
1713
|
+
.analytics-summary {
|
|
1714
|
+
display: grid;
|
|
1715
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
1716
|
+
gap: 12px;
|
|
1717
|
+
margin-bottom: 24px;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
.analytics-card {
|
|
1721
|
+
background: var(--bg-card);
|
|
1722
|
+
border: 1px solid var(--border);
|
|
1723
|
+
border-radius: 10px;
|
|
1724
|
+
padding: 16px;
|
|
1725
|
+
display: flex;
|
|
1726
|
+
flex-direction: column;
|
|
1727
|
+
gap: 4px;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
.analytics-val {
|
|
1731
|
+
font-size: 24px;
|
|
1732
|
+
font-weight: 700;
|
|
1733
|
+
color: var(--accent-green);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
.analytics-label {
|
|
1737
|
+
font-size: 12px;
|
|
1738
|
+
color: var(--text-muted);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
.chart-section {
|
|
1742
|
+
margin-bottom: 28px;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
.chart-section h3 {
|
|
1746
|
+
font-size: 14px;
|
|
1747
|
+
font-weight: 600;
|
|
1748
|
+
color: var(--text-secondary);
|
|
1749
|
+
margin-bottom: 12px;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/* Bar chart (vertical) */
|
|
1753
|
+
.bar-chart {
|
|
1754
|
+
display: flex;
|
|
1755
|
+
align-items: flex-end;
|
|
1756
|
+
gap: 3px;
|
|
1757
|
+
height: 160px;
|
|
1758
|
+
padding: 0 4px;
|
|
1759
|
+
border-bottom: 1px solid var(--border);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
.bar-col {
|
|
1763
|
+
flex: 1;
|
|
1764
|
+
display: flex;
|
|
1765
|
+
flex-direction: column;
|
|
1766
|
+
align-items: center;
|
|
1767
|
+
height: 100%;
|
|
1768
|
+
justify-content: flex-end;
|
|
1769
|
+
min-width: 0;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
.bar-fill {
|
|
1773
|
+
width: 100%;
|
|
1774
|
+
background: linear-gradient(to top, var(--accent-blue), var(--accent-purple));
|
|
1775
|
+
border-radius: 3px 3px 0 0;
|
|
1776
|
+
min-height: 2px;
|
|
1777
|
+
transition: height 0.3s ease;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
.bar-label {
|
|
1781
|
+
font-size: 9px;
|
|
1782
|
+
color: var(--text-muted);
|
|
1783
|
+
margin-top: 6px;
|
|
1784
|
+
transform: rotate(-45deg);
|
|
1785
|
+
white-space: nowrap;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/* Horizontal bar chart */
|
|
1789
|
+
.hbar-chart {
|
|
1790
|
+
display: flex;
|
|
1791
|
+
flex-direction: column;
|
|
1792
|
+
gap: 8px;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
.hbar-row {
|
|
1796
|
+
display: flex;
|
|
1797
|
+
align-items: center;
|
|
1798
|
+
gap: 12px;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
.hbar-name {
|
|
1802
|
+
width: 140px;
|
|
1803
|
+
font-size: 13px;
|
|
1804
|
+
overflow: hidden;
|
|
1805
|
+
text-overflow: ellipsis;
|
|
1806
|
+
white-space: nowrap;
|
|
1807
|
+
flex-shrink: 0;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
.hbar-track {
|
|
1811
|
+
flex: 1;
|
|
1812
|
+
height: 24px;
|
|
1813
|
+
background: var(--bg-card);
|
|
1814
|
+
border-radius: 6px;
|
|
1815
|
+
overflow: hidden;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
.hbar-fill {
|
|
1819
|
+
height: 100%;
|
|
1820
|
+
background: linear-gradient(to right, var(--accent-blue), var(--accent-green));
|
|
1821
|
+
border-radius: 6px;
|
|
1822
|
+
transition: width 0.5s ease;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
.hbar-val {
|
|
1826
|
+
font-size: 13px;
|
|
1827
|
+
font-weight: 600;
|
|
1828
|
+
color: var(--accent-green);
|
|
1829
|
+
min-width: 70px;
|
|
1830
|
+
text-align: right;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/* Top sessions list */
|
|
1834
|
+
.top-sessions { display: flex; flex-direction: column; gap: 4px; }
|
|
1835
|
+
|
|
1836
|
+
.top-session-row {
|
|
1837
|
+
display: flex;
|
|
1838
|
+
align-items: center;
|
|
1839
|
+
gap: 12px;
|
|
1840
|
+
padding: 8px 12px;
|
|
1841
|
+
background: var(--bg-card);
|
|
1842
|
+
border: 1px solid var(--border);
|
|
1843
|
+
border-radius: 8px;
|
|
1844
|
+
cursor: pointer;
|
|
1845
|
+
font-size: 13px;
|
|
1846
|
+
transition: background 0.15s;
|
|
1847
|
+
}
|
|
1848
|
+
.top-session-row:hover { background: var(--bg-card-hover); }
|
|
1849
|
+
|
|
1850
|
+
.top-session-cost {
|
|
1851
|
+
font-weight: 700;
|
|
1852
|
+
color: var(--accent-green);
|
|
1853
|
+
min-width: 70px;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
.top-session-project {
|
|
1857
|
+
flex: 1;
|
|
1858
|
+
overflow: hidden;
|
|
1859
|
+
text-overflow: ellipsis;
|
|
1860
|
+
white-space: nowrap;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
.top-session-date { color: var(--text-muted); font-size: 12px; }
|
|
1864
|
+
.top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); }
|
|
1865
|
+
|
|
1549
1866
|
/* ── Update banner ──────────────────────────────────────────── */
|
|
1550
1867
|
|
|
1551
1868
|
.update-banner {
|
package/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@ const http = require('http');
|
|
|
3
3
|
const https = require('https');
|
|
4
4
|
const { URL } = require('url');
|
|
5
5
|
const { exec } = require('child_process');
|
|
6
|
-
const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions } = require('./data');
|
|
6
|
+
const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics } = require('./data');
|
|
7
7
|
const { detectTerminals, openInTerminal } = require('./terminals');
|
|
8
8
|
const { getHTML } = require('./html');
|
|
9
9
|
|
|
@@ -127,6 +127,21 @@ function startServer(port, openBrowser = true) {
|
|
|
127
127
|
json(res, results);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// ── Session replay ─────────────────────
|
|
131
|
+
else if (req.method === 'GET' && pathname.startsWith('/api/replay/')) {
|
|
132
|
+
const sessionId = pathname.split('/').pop();
|
|
133
|
+
const project = parsed.searchParams.get('project') || '';
|
|
134
|
+
const data = getSessionReplay(sessionId, project);
|
|
135
|
+
json(res, data);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Cost analytics ──────────────────────
|
|
139
|
+
else if (req.method === 'GET' && pathname === '/api/analytics/cost') {
|
|
140
|
+
const sessions = loadSessions();
|
|
141
|
+
const data = getCostAnalytics(sessions);
|
|
142
|
+
json(res, data);
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
// ── Version check ────────────────────────
|
|
131
146
|
else if (req.method === 'GET' && pathname === '/api/version') {
|
|
132
147
|
const pkg = require('../package.json');
|