diff --git a/public/css/style.css b/public/css/style.css
index 9d75be1..96b8071 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -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);
diff --git a/public/index.html b/public/index.html
index c083160..76c2cf3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -43,6 +43,10 @@
Agents
+
+
+ User Lookup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Title |
+ Models |
+ Input Tokens |
+ Output Tokens |
+ Total Cost |
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Model |
+ Prompt Tokens |
+ Completion Tokens |
+ Prompt Cost |
+ Completion Cost |
+ Total Cost |
+
+
+
+
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index 6905bec..3bd272d 100644
--- a/public/js/app.js
+++ b/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 = 'No users found
';
+ container.style.display = 'block';
+ return;
+ }
+ container.innerHTML = results.map(u => `
+
+
+ ${escHtml(u.name)}
+ ${escHtml(u.email)}
+
+
+
+ `).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 = '| No conversations in this period |
';
+ } else {
+ convBody.innerHTML = d.conversations.map((c, i) => `
+
+ | ${i + 1} |
+ ${escHtml(c.title)} |
+ ${c.models.map(m => `${escHtml(m)}`).join(' ')} |
+ ${fmtTokens(c.promptTokens)} |
+ ${fmtTokens(c.completionTokens)} |
+ ${fmtCost(c.totalCost)} |
+
+ `).join('');
+ }
+
+ // Models table
+ const modelBody = document.getElementById('userModelsTableBody');
+ if (d.models.length === 0) {
+ modelBody.innerHTML = '| No model usage in this period |
';
+ } else {
+ modelBody.innerHTML = d.models.map((m, i) => `
+
+ | ${i + 1} |
+ ${escHtml(m.model)} |
+ ${fmtTokens(m.promptTokens)} |
+ ${fmtTokens(m.completionTokens)} |
+ ${fmtCost(m.promptCost)} |
+ ${fmtCost(m.completionCost)} |
+ ${fmtCost(m.totalCost)} |
+
+ `).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 ---
diff --git a/routes/api.js b/routes/api.js
index ce3c316..e56dbe1 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -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;
diff --git a/services/analytics.js b/services/analytics.js
index 513fb82..1139ce4 100644
--- a/services/analytics.js
+++ b/services/analytics.js
@@ -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,
};