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 +
  • + + +
    + + + + +
    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, };