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:
parent
31a70bbef2
commit
1673694b11
5 changed files with 445 additions and 2 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
135
public/js/app.js
135
public/js/app.js
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue