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 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-03-18 15:16:49 -04:00
parent d25c21d781
commit e74f21d57e
5 changed files with 217 additions and 1 deletions

View file

@ -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;

View file

@ -35,6 +35,10 @@
<i data-lucide="cpu" style="width:20px;height:20px"></i>
Models
</li>
<li data-tab="conversations">
<i data-lucide="message-circle" style="width:20px;height:20px"></i>
Conversations
</li>
<li data-tab="agents">
<i data-lucide="bot" style="width:20px;height:20px"></i>
Agents
@ -154,6 +158,33 @@
</div>
</div>
<!-- Conversations Tab -->
<div class="tab-content" id="tab-conversations">
<div class="table-container">
<div class="table-header">
<h3 id="conversationsTitle">Most Expensive Conversations</h3>
<div style="display:flex;gap:0.75rem;align-items:center">
<button class="btn-export btn-filter-user" id="clearUserFilter" onclick="clearUserFilter()" style="display:none"><i data-lucide="x" style="width:14px;height:14px"></i> Clear filter</button>
<button class="btn-export" onclick="exportConversations()"><i data-lucide="download" style="width:16px;height:16px"></i> Export CSV</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>User</th>
<th>Models</th>
<th class="text-right">Input Tokens</th>
<th class="text-right">Output Tokens</th>
<th class="text-right">Total Cost</th>
</tr>
</thead>
<tbody id="conversationsTableBody"></tbody>
</table>
</div>
</div>
<!-- Agents Tab -->
<div class="tab-content" id="tab-agents">
<div class="table-container">

View file

@ -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) => `
<tr>
<td>${i + 1}</td>
<td>${escHtml(u.name)}</td>
<td><a href="#" class="user-link" onclick="filterByUser('${u._id}', '${escHtml(u.name)}'); return false;">${escHtml(u.name)}</a></td>
<td>${escHtml(u.email)}</td>
<td class="text-right token-value">${fmtTokens(u.totalTokens)}</td>
<td class="text-right cost-value">${fmtCost(u.totalCost)}</td>
@ -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 = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted)">No conversations found in this period</td></tr>';
return;
}
tbody.innerHTML = d.map((c, i) => `
<tr>
<td>${i + 1}</td>
<td>${escHtml(c.title)}</td>
<td>${escHtml(c.userName)}</td>
<td><span class="model-tags">${c.models.map(m => `<span class="model-tag">${escHtml(m)}</span>`).join(' ')}</span></td>
<td class="text-right token-value">${fmtTokens(c.promptTokens)}</td>
<td class="text-right token-value">${fmtTokens(c.completionTokens)}</td>
<td class="text-right cost-value">${fmtCost(c.totalCost)}</td>
</tr>
`).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(),
]);
}

View file

@ -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;

View file

@ -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,
};