From e74f21d57e7fb4838f858bce0a8f41d76cf0cd24 Mon Sep 17 00:00:00 2001 From: DJP Date: Wed, 18 Mar 2026 15:16:49 -0400 Subject: [PATCH] feat: Add Conversations tab with user drill-down New tab showing most expensive conversations with title, user, models, and token breakdown. Clicking a user name in the Users tab filters conversations to that user. Includes CSV export. Co-Authored-By: Claude Opus 4.6 --- public/css/style.css | 42 +++++++++++++++++++++++++ public/index.html | 31 ++++++++++++++++++ public/js/app.js | 60 ++++++++++++++++++++++++++++++++++- routes/api.js | 12 +++++++ services/analytics.js | 73 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 1 deletion(-) diff --git a/public/css/style.css b/public/css/style.css index aa02bd9..071b1af 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -408,6 +408,48 @@ tr:hover td { margin-bottom: 1.5rem; } +/* User Links */ +.user-link { + color: var(--accent-primary); + text-decoration: none; + font-weight: 600; + transition: opacity 0.2s; +} + +.user-link:hover { + opacity: 0.8; + text-decoration: underline; +} + +/* Model Tags */ +.model-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.model-tag { + display: inline-block; + padding: 0.15rem 0.5rem; + background: rgba(255, 196, 7, 0.08); + border: 1px solid rgba(255, 196, 7, 0.15); + border-radius: 0.35rem; + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; +} + +.btn-filter-user { + background: rgba(248, 113, 113, 0.1); + color: var(--accent-red); + border-color: rgba(248, 113, 113, 0.2); +} + +.btn-filter-user:hover { + background: rgba(248, 113, 113, 0.2); + border-color: rgba(248, 113, 113, 0.4); +} + /* Loading */ .loading { display: flex; diff --git a/public/index.html b/public/index.html index 758d2d0..f12c194 100644 --- a/public/index.html +++ b/public/index.html @@ -35,6 +35,10 @@ Models +
  • + + Conversations +
  • Agents @@ -154,6 +158,33 @@ + +
    +
    +
    +

    Most Expensive Conversations

    +
    + + +
    +
    + + + + + + + + + + + + + +
    #TitleUserModelsInput TokensOutput TokensTotal Cost
    +
    +
    +
    diff --git a/public/js/app.js b/public/js/app.js index 4813b9d..45a2285 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -11,8 +11,11 @@ let cachedData = { topUsers: null, topModels: null, topAgents: null, + topConversations: null, }; +let currentUserFilter = null; + // --- API Helpers --- function apiHeaders() { const h = { 'Content-Type': 'application/json' }; @@ -129,6 +132,16 @@ function exportAgents() { ); } +function exportConversations() { + const d = cachedData.topConversations; + if (!d || !d.length) return alert('No data to export'); + downloadCSV( + ['Rank', 'Title', 'User', 'Email', 'Models', 'Input Tokens', 'Output Tokens', 'Total Cost (USD)'], + d.map((c, i) => [i + 1, c.title, c.userName, c.userEmail, c.models.join(', '), c.promptTokens, c.completionTokens, c.totalCost.toFixed(4)]), + `top-conversations-${currentPeriod}.csv` + ); +} + // --- Tab Navigation --- document.querySelectorAll('.nav-links li').forEach(li => { li.addEventListener('click', () => { @@ -286,7 +299,7 @@ async function loadTopUsers() { tbody.innerHTML = d.map((u, i) => ` ${i + 1} - ${escHtml(u.name)} + ${escHtml(u.name)} ${escHtml(u.email)} ${fmtTokens(u.totalTokens)} ${fmtCost(u.totalCost)} @@ -340,6 +353,50 @@ async function loadTopModels() { } catch (e) { console.error('Top models:', e); } } +async function loadTopConversations() { + try { + let endpoint = 'top-conversations?limit=50'; + if (currentUserFilter) endpoint += `&userId=${currentUserFilter.id}`; + const d = await fetchAPI(endpoint); + cachedData.topConversations = d; + const tbody = document.getElementById('conversationsTableBody'); + if (d.length === 0) { + tbody.innerHTML = 'No conversations found in this period'; + return; + } + tbody.innerHTML = d.map((c, i) => ` + + ${i + 1} + ${escHtml(c.title)} + ${escHtml(c.userName)} + ${c.models.map(m => `${escHtml(m)}`).join(' ')} + ${fmtTokens(c.promptTokens)} + ${fmtTokens(c.completionTokens)} + ${fmtCost(c.totalCost)} + + `).join(''); + } catch (e) { console.error('Top conversations:', e); } +} + +function filterByUser(userId, userName) { + currentUserFilter = { id: userId, name: userName }; + document.getElementById('conversationsTitle').textContent = `Conversations — ${userName}`; + document.getElementById('clearUserFilter').style.display = 'inline-flex'; + // Switch to conversations tab + document.querySelectorAll('.nav-links li').forEach(l => l.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); + document.querySelector('[data-tab="conversations"]').classList.add('active'); + document.getElementById('tab-conversations').classList.add('active'); + loadTopConversations(); +} + +function clearUserFilter() { + currentUserFilter = null; + document.getElementById('conversationsTitle').textContent = 'Most Expensive Conversations'; + document.getElementById('clearUserFilter').style.display = 'none'; + loadTopConversations(); +} + async function loadTopAgents() { try { const d = await fetchAPI('top-agents?limit=20'); @@ -383,6 +440,7 @@ async function refreshAll() { loadTopUsers(), loadTopModels(), loadTopAgents(), + loadTopConversations(), ]); } diff --git a/routes/api.js b/routes/api.js index 52f12db..ce3c316 100644 --- a/routes/api.js +++ b/routes/api.js @@ -65,4 +65,16 @@ router.get('/usage-over-time', async (req, res) => { } }); +router.get('/top-conversations', async (req, res) => { + try { + const limit = parseInt(req.query.limit) || 20; + const userId = req.query.userId || null; + const data = await analytics.getTopConversations(req.db, req.query, limit, userId); + res.json(data); + } catch (err) { + console.error('Top conversations error:', err); + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/services/analytics.js b/services/analytics.js index b297cb4..8d50f4f 100644 --- a/services/analytics.js +++ b/services/analytics.js @@ -275,6 +275,78 @@ async function getUsageOverTime(db, query) { }; } +async function getTopConversations(db, query, limit = 20, userId = null) { + await refreshAgentsCache(db); + const { startDate, endDate } = getDateRange(query); + + const match = { createdAt: { $gte: startDate, $lte: endDate } }; + if (userId) match.user = new (require('mongodb').ObjectId)(userId); + + const results = await db.collection('transactions').aggregate([ + { $match: match }, + { + $group: { + _id: '$conversationId', + totalTokens: { $sum: { $abs: '$rawAmount' } }, + totalCost: { $sum: { $abs: '$tokenValue' } }, + models: { $addToSet: '$model' }, + user: { $first: '$user' }, + promptTokens: { + $sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] } + }, + completionTokens: { + $sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] } + }, + } + }, + { $sort: { totalCost: -1 } }, + { $limit: limit }, + { + $lookup: { + from: 'users', + localField: 'user', + foreignField: '_id', + as: 'userInfo' + } + }, + { $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } }, + { + $lookup: { + from: 'conversations', + localField: '_id', + foreignField: 'conversationId', + as: 'convInfo' + } + }, + { $unwind: { path: '$convInfo', preserveNullAndEmptyArrays: true } }, + { + $project: { + conversationId: '$_id', + title: { $ifNull: ['$convInfo.title', 'Unknown'] }, + userName: { $ifNull: ['$userInfo.name', 'Unknown'] }, + userEmail: { $ifNull: ['$userInfo.email', ''] }, + models: 1, + totalTokens: 1, + promptTokens: 1, + completionTokens: 1, + totalCost: { $divide: ['$totalCost', 1_000_000] }, + } + } + ]).toArray(); + + // Resolve agent model names + return results.map(r => ({ + ...r, + models: r.models.map(m => { + if (m && m.startsWith('agent_')) { + const agent = agentsCache.get(m); + return agent ? `${agent.name} (${agent.model})` : m; + } + return m; + }), + })); +} + module.exports = { getSummary, getTopUsers, @@ -282,4 +354,5 @@ module.exports = { getTopAgents, getCostBreakdown, getUsageOverTime, + getTopConversations, };