feat: Add User Lookup tab with search and full user workup

New tab lets you search for any user by name or email, then shows:
- Summary cards (cost, tokens, conversations, visits) for the period
- All their conversations with titles, models, and cost breakdown
- Model usage breakdown with prompt/completion split
- CSV export of the full user report

Two new API endpoints: /api/search-users and /api/user-detail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-03-20 15:09:43 -04:00
parent 31a70bbef2
commit 1673694b11
5 changed files with 445 additions and 2 deletions

View file

@ -410,6 +410,77 @@ tr:hover td {
margin-bottom: 1.5rem;
}
/* User Search */
.user-search-bar {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
}
.search-input-wrap {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
max-width: 500px;
padding: 0.625rem 1rem;
background: #0f172a;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-muted);
}
.search-input-wrap input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-main);
font-family: "Montserrat", system-ui, sans-serif;
font-size: 0.9375rem;
}
.search-input-wrap input::placeholder {
color: var(--text-muted);
}
.search-input-wrap:focus-within {
border-color: var(--accent-primary);
}
.search-results {
margin-bottom: 1.5rem;
}
.search-result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.search-result-item:hover {
border-color: var(--accent-primary);
background: rgba(255, 196, 7, 0.05);
}
.search-result-name {
font-weight: 600;
color: var(--accent-primary);
}
.search-result-email {
color: var(--text-muted);
font-size: 0.875rem;
}
/* User Links */
.user-link {
color: var(--accent-primary);

View file

@ -43,6 +43,10 @@
<i data-lucide="bot" style="width:20px;height:20px"></i>
Agents
</li>
<li data-tab="user-lookup">
<i data-lucide="search" style="width:20px;height:20px"></i>
User Lookup
</li>
</ul>
<div class="sidebar-footer">
Auto-refresh: 60s
@ -213,6 +217,79 @@
</table>
</div>
</div>
<!-- User Lookup Tab -->
<div class="tab-content" id="tab-user-lookup">
<div class="user-search-bar">
<div class="search-input-wrap">
<i data-lucide="search" style="width:18px;height:18px"></i>
<input type="text" id="userSearchInput" placeholder="Search by name or email..." autocomplete="off">
</div>
<button class="btn-export" onclick="searchUser()"><i data-lucide="search" style="width:16px;height:16px"></i> Search</button>
</div>
<div id="userSearchResults" class="search-results" style="display:none"></div>
<div id="userDetailSection" style="display:none">
<div class="table-header" style="padding:0;border:none;margin-bottom:1.5rem">
<h3 id="userLookupTitle">--</h3>
<button class="btn-export" onclick="exportUserLookup()"><i data-lucide="download" style="width:16px;height:16px"></i> Export CSV</button>
</div>
<div class="stats-grid" id="userSummaryCards">
<div class="stat-card">
<div class="stat-icon cost"><i data-lucide="dollar-sign"></i></div>
<div><span class="stat-label">Total Cost</span><span class="stat-value" id="userTotalCost">--</span></div>
</div>
<div class="stat-card">
<div class="stat-icon tokens"><i data-lucide="zap"></i></div>
<div><span class="stat-label">Total Tokens</span><span class="stat-value" id="userTotalTokens">--</span></div>
</div>
<div class="stat-card">
<div class="stat-icon convos"><i data-lucide="message-square"></i></div>
<div><span class="stat-label">Conversations</span><span class="stat-value" id="userConversations">--</span></div>
</div>
<div class="stat-card">
<div class="stat-icon visits"><i data-lucide="log-in"></i></div>
<div><span class="stat-label">Visits</span><span class="stat-value" id="userVisits">--</span></div>
</div>
</div>
<div class="table-container">
<div class="table-header"><h3>Conversations</h3></div>
<table>
<thead>
<tr>
<th>#</th>
<th>Title</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="userConvosTableBody"></tbody>
</table>
</div>
<div class="table-container">
<div class="table-header"><h3>Model Breakdown</h3></div>
<table>
<thead>
<tr>
<th>#</th>
<th>Model</th>
<th class="text-right">Prompt Tokens</th>
<th class="text-right">Completion Tokens</th>
<th class="text-right">Prompt Cost</th>
<th class="text-right">Completion Cost</th>
<th class="text-right">Total Cost</th>
</tr>
</thead>
<tbody id="userModelsTableBody"></tbody>
</table>
</div>
</div>
</div>
</main>
</div>

View file

@ -12,9 +12,11 @@ let cachedData = {
topModels: null,
topAgents: null,
topConversations: null,
userDetail: null,
};
let currentUserFilter = null;
let selectedLookupUser = null;
// --- API Helpers ---
function apiHeaders() {
@ -400,6 +402,133 @@ function clearUserFilter() {
loadTopConversations();
}
// --- User Lookup ---
async function searchUser() {
const q = document.getElementById('userSearchInput').value.trim();
if (q.length < 2) return;
try {
const results = await fetchAPI(`search-users?q=${encodeURIComponent(q)}`);
const container = document.getElementById('userSearchResults');
if (results.length === 0) {
container.innerHTML = '<div style="color:var(--text-muted);padding:0.75rem">No users found</div>';
container.style.display = 'block';
return;
}
container.innerHTML = results.map(u => `
<div class="search-result-item" onclick="selectLookupUser('${u._id}', '${escHtml(u.name)}', '${escHtml(u.email)}')">
<div>
<span class="search-result-name">${escHtml(u.name)}</span>
<span class="search-result-email">${escHtml(u.email)}</span>
</div>
<i data-lucide="chevron-right" style="width:16px;height:16px;color:var(--text-muted)"></i>
</div>
`).join('');
container.style.display = 'block';
lucide.createIcons();
} catch (e) { console.error('Search users:', e); }
}
async function selectLookupUser(userId, name, email) {
selectedLookupUser = { id: userId, name, email };
document.getElementById('userSearchResults').style.display = 'none';
document.getElementById('userLookupTitle').textContent = `${name} (${email})`;
document.getElementById('userDetailSection').style.display = 'block';
await loadUserDetail();
}
async function loadUserDetail() {
if (!selectedLookupUser) return;
try {
const d = await fetchAPI(`user-detail?userId=${selectedLookupUser.id}`);
cachedData.userDetail = d;
// Summary cards
document.getElementById('userTotalCost').textContent = fmtCost(d.summary.totalCost);
document.getElementById('userTotalTokens').textContent = fmtTokens(d.summary.totalTokens);
document.getElementById('userConversations').textContent = fmtNum(d.summary.conversationCount);
document.getElementById('userVisits').textContent = fmtNum(d.summary.visitCount);
// Conversations table
const convBody = document.getElementById('userConvosTableBody');
if (d.conversations.length === 0) {
convBody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-muted)">No conversations in this period</td></tr>';
} else {
convBody.innerHTML = d.conversations.map((c, i) => `
<tr>
<td>${i + 1}</td>
<td>${escHtml(c.title)}</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('');
}
// Models table
const modelBody = document.getElementById('userModelsTableBody');
if (d.models.length === 0) {
modelBody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted)">No model usage in this period</td></tr>';
} else {
modelBody.innerHTML = d.models.map((m, i) => `
<tr>
<td>${i + 1}</td>
<td>${escHtml(m.model)}</td>
<td class="text-right token-value">${fmtTokens(m.promptTokens)}</td>
<td class="text-right token-value">${fmtTokens(m.completionTokens)}</td>
<td class="text-right cost-value">${fmtCost(m.promptCost)}</td>
<td class="text-right cost-value">${fmtCost(m.completionCost)}</td>
<td class="text-right cost-value">${fmtCost(m.totalCost)}</td>
</tr>
`).join('');
}
} catch (e) { console.error('User detail:', e); }
}
function exportUserLookup() {
const d = cachedData.userDetail;
if (!d) return alert('No data to export');
const name = selectedLookupUser ? selectedLookupUser.name : 'user';
// Conversations rows
const convRows = d.conversations.map((c, i) => [
i + 1, c.title, c.models.join(', '), c.promptTokens, c.completionTokens, c.totalCost.toFixed(4)
]);
// Model rows
const modelRows = d.models.map((m, i) => [
i + 1, m.model, m.promptTokens, m.completionTokens, m.promptCost.toFixed(4), m.completionCost.toFixed(4), m.totalCost.toFixed(4)
]);
const lines = [];
lines.push(`User Report: ${name}`);
lines.push(`Period: ${currentPeriod}`);
lines.push(`Total Cost,$${d.summary.totalCost.toFixed(2)}`);
lines.push(`Total Tokens,${d.summary.totalTokens}`);
lines.push(`Conversations,${d.summary.conversationCount}`);
lines.push(`Visits,${d.summary.visitCount}`);
lines.push('');
lines.push('Conversations');
lines.push(['#', 'Title', 'Models', 'Input Tokens', 'Output Tokens', 'Cost (USD)'].map(escCSV).join(','));
for (const row of convRows) lines.push(row.map(escCSV).join(','));
lines.push('');
lines.push('Model Breakdown');
lines.push(['#', 'Model', 'Prompt Tokens', 'Completion Tokens', 'Prompt Cost', 'Completion Cost', 'Total Cost'].map(escCSV).join(','));
for (const row of modelRows) lines.push(row.map(escCSV).join(','));
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `user-report-${name.replace(/\s+/g, '-').toLowerCase()}-${currentPeriod}.csv`;
a.click();
URL.revokeObjectURL(a.href);
}
// Search on Enter key
document.getElementById('userSearchInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchUser();
});
async function loadTopAgents() {
try {
const d = await fetchAPI('top-agents?limit=20');
@ -435,7 +564,7 @@ async function refreshAll() {
document.getElementById('lastRefresh').textContent =
'Last refresh: ' + new Date().toLocaleTimeString();
await Promise.allSettled([
const tasks = [
loadSummary(),
loadUsageOverTime(),
loadCostByModel(),
@ -444,7 +573,9 @@ async function refreshAll() {
loadTopModels(),
loadTopAgents(),
loadTopConversations(),
]);
];
if (selectedLookupUser) tasks.push(loadUserDetail());
await Promise.allSettled(tasks);
}
// --- Init ---

View file

@ -77,4 +77,28 @@ router.get('/top-conversations', async (req, res) => {
}
});
router.get('/search-users', async (req, res) => {
try {
const q = req.query.q || '';
if (q.length < 2) return res.json([]);
const data = await analytics.searchUsers(req.db, q, 10);
res.json(data);
} catch (err) {
console.error('Search users error:', err);
res.status(500).json({ error: err.message });
}
});
router.get('/user-detail', async (req, res) => {
try {
const userId = req.query.userId;
if (!userId) return res.status(400).json({ error: 'userId required' });
const data = await analytics.getUserDetail(req.db, req.query, userId);
res.json(data);
} catch (err) {
console.error('User detail error:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View file

@ -355,6 +355,144 @@ async function getTopConversations(db, query, limit = 20, userId = null) {
}));
}
async function searchUsers(db, searchTerm, limit = 10) {
const regex = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
return db.collection('users').find({
$or: [{ name: regex }, { email: regex }]
}, { projection: { _id: 1, name: 1, email: 1 } })
.limit(limit)
.toArray();
}
async function getUserDetail(db, query, userId) {
await refreshAgentsCache(db);
const { startDate, endDate } = getDateRange(query);
const { ObjectId } = require('mongodb');
const userOid = new ObjectId(userId);
// Summary aggregation
const [summaryResult, visitCount, convos, modelBreakdown] = await Promise.all([
db.collection('transactions').aggregate([
{ $match: { user: userOid, createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: null,
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
promptTokens: {
$sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] }
},
completionTokens: {
$sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] }
},
conversations: { $addToSet: '$conversationId' },
}
}
]).toArray(),
db.collection('conversations').countDocuments({
user: userId,
createdAt: { $gte: startDate, $lte: endDate }
}),
// Conversations breakdown
db.collection('transactions').aggregate([
{ $match: { user: userOid, createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: '$conversationId',
totalCost: { $sum: { $abs: '$tokenValue' } },
models: { $addToSet: '$model' },
promptTokens: {
$sum: { $cond: [{ $eq: ['$tokenType', 'prompt'] }, { $abs: '$rawAmount' }, 0] }
},
completionTokens: {
$sum: { $cond: [{ $eq: ['$tokenType', 'completion'] }, { $abs: '$rawAmount' }, 0] }
},
}
},
{ $sort: { totalCost: -1 } },
{ $limit: 100 },
{
$lookup: {
from: 'conversations',
localField: '_id',
foreignField: 'conversationId',
as: 'convInfo'
}
},
{ $unwind: { path: '$convInfo', preserveNullAndEmptyArrays: true } },
{
$project: {
conversationId: '$_id',
title: { $ifNull: ['$convInfo.title', 'Unknown'] },
models: 1,
promptTokens: 1,
completionTokens: 1,
totalCost: { $divide: ['$totalCost', 1_000_000] },
}
}
]).toArray(),
// Model breakdown
db.collection('transactions').aggregate([
{ $match: { user: userOid, createdAt: { $gte: startDate, $lte: endDate } } },
{
$group: {
_id: { model: '$model', tokenType: '$tokenType' },
totalTokens: { $sum: { $abs: '$rawAmount' } },
totalCost: { $sum: { $abs: '$tokenValue' } },
}
}
]).toArray(),
]);
const s = summaryResult[0] || { totalTokens: 0, totalCost: 0, promptTokens: 0, completionTokens: 0, conversations: [] };
// Resolve agent models in conversations
const resolvedConvos = convos.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;
}),
}));
// Pivot model breakdown and resolve agents
const models = new Map();
for (const entry of modelBreakdown) {
const resolvedModel = resolveModel(entry._id.model);
if (!models.has(resolvedModel)) {
models.set(resolvedModel, { model: resolvedModel, promptTokens: 0, completionTokens: 0, promptCost: 0, completionCost: 0, totalCost: 0 });
}
const m = models.get(resolvedModel);
if (entry._id.tokenType === 'prompt') {
m.promptTokens += entry.totalTokens;
m.promptCost += entry.totalCost / 1_000_000;
} else {
m.completionTokens += entry.totalTokens;
m.completionCost += entry.totalCost / 1_000_000;
}
m.totalCost = m.promptCost + m.completionCost;
}
return {
summary: {
totalCost: s.totalCost / 1_000_000,
totalTokens: s.totalTokens,
promptTokens: s.promptTokens,
completionTokens: s.completionTokens,
conversationCount: s.conversations.length,
visitCount: visitCount,
},
conversations: resolvedConvos,
models: Array.from(models.values()).sort((a, b) => b.totalCost - a.totalCost),
};
}
module.exports = {
getSummary,
getTopUsers,
@ -363,4 +501,6 @@ module.exports = {
getCostBreakdown,
getUsageOverTime,
getTopConversations,
searchUsers,
getUserDetail,
};