Spaces:
Sleeping
Sleeping
| /** | |
| * agents-controller.js - Agent Profile Dashboard Controller | |
| * ========================================================== | |
| * Handles agent list, detail views, charts, search, and sort. | |
| * * Hardened for Enterprise MVC: | |
| * - Uses central `apiFetch` to automatically inherit HTTP-Only Cookies and CSRF headers. | |
| * - Uses `SecurityUtils` for XSS-safe DOM generation. | |
| * - Uses `ErrorHandler` for standardized exception recovery. | |
| */ | |
| (function() { | |
| 'use strict'; | |
| let allAgents = []; | |
| let currentAgentId = null; | |
| let f1TrendChart = null; | |
| let qualityRadarChart = null; | |
| // DOM Elements | |
| const agentsListView = document.getElementById('agents-list-view'); | |
| const agentDetailView = document.getElementById('agent-detail-view'); | |
| const agentsGrid = document.getElementById('agents-grid'); | |
| const agentsEmptyState = document.getElementById('agents-empty-state'); | |
| const agentSearch = document.getElementById('agent-search'); | |
| const sortSelect = document.getElementById('sort-select'); | |
| const backToListBtn = document.getElementById('back-to-list-btn'); | |
| const reindexAgentBtn = document.getElementById('reindex-agent-btn'); | |
| // Stats elements | |
| const totalAgentsEl = document.getElementById('total-agents'); | |
| const avgF1ScoreEl = document.getElementById('avg-f1-score'); | |
| const improvingCountEl = document.getElementById('improving-count'); | |
| const decliningCountEl = document.getElementById('declining-count'); | |
| // Detail elements | |
| const detailAgentName = document.getElementById('detail-agent-name'); | |
| const detailAgentEmail = document.getElementById('detail-agent-email'); | |
| const detailTotalAudits = document.getElementById('detail-total-audits'); | |
| const detailAvgF1 = document.getElementById('detail-avg-f1'); | |
| const detailTrend = document.getElementById('detail-trend'); | |
| const detailRiskFlags = document.getElementById('detail-risk-flags'); | |
| // Safe HTML Escaping (Fallback to native if SecurityUtils isn't loaded yet) | |
| const escapeHTML = (text) => { | |
| if (window.SecurityUtils) return window.SecurityUtils.escapeHTML(text); | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| }; | |
| async function init() { | |
| await loadAgents(); | |
| setupEventListeners(); | |
| } | |
| async function loadAgents() { | |
| if (window.toggleLoader) window.toggleLoader(true, 'Synchronizing agent profiles...'); | |
| try { | |
| // Enterprise Architecture: Centralized apiFetch handles CSRF and Cookies automatically | |
| const data = await window.apiFetch('/agents'); | |
| if (data.success) { | |
| allAgents = data.agents || []; | |
| renderAgentsList(allAgents); | |
| updateStatsBar(allAgents); | |
| } | |
| } catch (error) { | |
| console.error('[Agents] Load failed:', error); | |
| if (window.ErrorHandler) { | |
| window.ErrorHandler.showError(error); | |
| } else if (window.SecurityUtils) { | |
| window.SecurityUtils.showToast('Failed to load agents', 'error'); | |
| } | |
| if (agentsGrid) agentsGrid.innerHTML = ''; | |
| if (agentsEmptyState) agentsEmptyState.hidden = false; | |
| updateStatsBar([]); | |
| } finally { | |
| if (window.toggleLoader) window.toggleLoader(false); | |
| } | |
| } | |
| function renderAgentsList(agents) { | |
| if (!agentsGrid || !agentsEmptyState) return; | |
| if (!agents || agents.length === 0) { | |
| agentsGrid.innerHTML = ''; | |
| agentsEmptyState.hidden = false; | |
| return; | |
| } | |
| agentsEmptyState.hidden = true; | |
| agentsGrid.innerHTML = agents.map(agent => { | |
| const stats = agent.stats || {}; | |
| const trendIcon = getTrendIcon(stats.trend_direction); | |
| const trendClass = getTrendClass(stats.trend_direction); | |
| const f1Score = (stats.avg_f1_score || 0).toFixed(2); | |
| return ` | |
| <div class="agent-card" data-agent-id="${escapeHTML(agent.agent_id)}" role="button" tabindex="0"> | |
| <div class="agent-card-header"> | |
| <div class="agent-avatar"><span class="material-symbols-rounded">person</span></div> | |
| <div class="agent-info"> | |
| <h3 class="agent-name">${escapeHTML(agent.name || 'Unknown')}</h3> | |
| <p class="agent-email">${escapeHTML(agent.email || '')}</p> | |
| </div> | |
| <div class="trend-indicator ${trendClass}"><span class="material-symbols-rounded">${trendIcon}</span></div> | |
| </div> | |
| <div class="agent-stats"> | |
| <div class="agent-stat-row"><span class="agent-stat-label">F1 Score</span><span class="agent-stat-value f1-score">${f1Score}</span></div> | |
| <div class="agent-stat-row"><span class="agent-stat-label">Audits</span><span class="agent-stat-value">${escapeHTML(stats.total_audits || 0)}</span></div> | |
| <div class="agent-stat-row"><span class="agent-stat-label">Risk</span><span class="agent-stat-value risk-flags">${escapeHTML((stats.red_risk_count || 0) + (stats.amber_risk_count || 0))}</span></div> | |
| </div> | |
| <div class="agent-card-footer"> | |
| <span class="agent-last-audit">Last: ${escapeHTML(formatDate(stats.last_audit_at))}</span> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| document.querySelectorAll('.agent-card').forEach(card => { | |
| card.addEventListener('click', () => loadAgentDetail(card.dataset.agentId)); | |
| card.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| loadAgentDetail(card.dataset.agentId); | |
| } | |
| }); | |
| }); | |
| } | |
| function updateStatsBar(agents) { | |
| const total = agents.length; | |
| const avg = total > 0 ? (agents.reduce((s, a) => s + (a.stats?.avg_f1_score || 0), 0) / total).toFixed(2) : '0.00'; | |
| if (totalAgentsEl) totalAgentsEl.textContent = total; | |
| if (avgF1ScoreEl) avgF1ScoreEl.textContent = avg; | |
| if (improvingCountEl) improvingCountEl.textContent = agents.filter(a => a.stats?.trend_direction === 'improving').length; | |
| if (decliningCountEl) decliningCountEl.textContent = agents.filter(a => a.stats?.trend_direction === 'declining').length; | |
| } | |
| async function loadAgentDetail(agentId) { | |
| currentAgentId = agentId; | |
| if (window.toggleLoader) window.toggleLoader(true, 'Loading profile...'); | |
| try { | |
| const data = await window.apiFetch(`/agents/${agentId}`); | |
| if (data.success) { | |
| renderAgentDetail(data.agent); | |
| showDetailView(); | |
| } | |
| } catch (error) { | |
| console.error('[Agents] Profile load failed:', error); | |
| if (window.ErrorHandler) { | |
| window.ErrorHandler.showError(error); | |
| } else if (window.SecurityUtils) { | |
| window.SecurityUtils.showToast('Failed to load agent profile', 'error'); | |
| } | |
| } finally { | |
| if (window.toggleLoader) window.toggleLoader(false); | |
| } | |
| } | |
| function renderAgentDetail(agent) { | |
| const s = agent.stats || {}; | |
| if (detailAgentName) { | |
| detailAgentName.textContent = agent.name || 'Unknown'; | |
| // Accessibility / UX: expose the full name on hover via title and aria-label | |
| detailAgentName.title = agent.name || ''; | |
| detailAgentName.setAttribute('aria-label', agent.name || 'Agent Name'); | |
| } | |
| if (detailAgentEmail) { | |
| detailAgentEmail.textContent = agent.email || ''; | |
| detailAgentEmail.title = agent.email || ''; | |
| } | |
| if (detailTotalAudits) detailTotalAudits.textContent = s.total_audits || 0; | |
| if (detailAvgF1) detailAvgF1.textContent = (s.avg_f1_score || 0).toFixed(2); | |
| if (detailTrend) { | |
| detailTrend.textContent = (s.trend_direction || 'stable').toUpperCase(); | |
| detailTrend.className = `detail-stat-value trend-${s.trend_direction || 'stable'}`; | |
| } | |
| if (detailRiskFlags) detailRiskFlags.textContent = (s.red_risk_count || 0) + (s.amber_risk_count || 0); | |
| if (window.Chart) { | |
| renderF1TrendChart(agent.score_history || []); | |
| renderQualityRadarChart(s); | |
| } | |
| renderScoreHistoryTable(agent.score_history || []); | |
| if (reindexAgentBtn) reindexAgentBtn.dataset.agentId = agent.agent_id; | |
| } | |
| function renderF1TrendChart(history) { | |
| const ctx = document.getElementById('f1-trend-chart')?.getContext('2d'); | |
| if (!ctx) return; | |
| if (f1TrendChart) f1TrendChart.destroy(); | |
| // Ensure history is chronological for standard L-to-R line chart | |
| const data = history.slice().reverse().map(h => h.f1_score || 0); | |
| const labels = data.map((_, i) => `A${i+1}`); | |
| // CSS Variable support for themes | |
| const rootStyles = getComputedStyle(document.documentElement); | |
| const primaryColor = rootStyles.getPropertyValue('--md-primary').trim() || '#14B8A6'; | |
| const primaryLight = 'rgba(20, 184, 166, 0.1)'; | |
| f1TrendChart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels, | |
| datasets: [{ | |
| label: 'F1 Score', | |
| data, | |
| borderColor: primaryColor, | |
| backgroundColor: primaryLight, | |
| fill: true, | |
| tension: 0.4 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| y: { min: 0, max: 1 }, | |
| x: { ticks: { maxTicksLimit: 10 } } | |
| } | |
| } | |
| }); | |
| } | |
| function renderQualityRadarChart(stats) { | |
| const ctx = document.getElementById('quality-radar-chart')?.getContext('2d'); | |
| if (!ctx) return; | |
| if (qualityRadarChart) qualityRadarChart.destroy(); | |
| const rootStyles = getComputedStyle(document.documentElement); | |
| const accentColor = rootStyles.getPropertyValue('--md-secondary').trim() || '#a78bfa'; | |
| const accentLight = 'rgba(167, 139, 250, 0.2)'; | |
| qualityRadarChart = new Chart(ctx, { | |
| type: 'radar', | |
| data: { | |
| labels: ['Empathy', 'Efficiency', 'Listening', 'Language', 'Bias'], | |
| datasets: [{ | |
| data: [stats.avg_empathy||0, stats.avg_efficiency||0, stats.avg_listening||0, stats.avg_language||0, stats.avg_bias||0], | |
| borderColor: accentColor, | |
| backgroundColor: accentLight, | |
| pointBackgroundColor: accentColor | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| plugins: { legend: { display: false } }, | |
| scales: { r: { beginAtZero: true, max: 10, ticks: { display: false } } } | |
| } | |
| }); | |
| } | |
| function renderScoreHistoryTable(history) { | |
| const table = document.getElementById('score-history-table'); | |
| if (!table) return; | |
| if (!history.length) { | |
| table.innerHTML = '<p class="empty-message">No history found</p>'; | |
| return; | |
| } | |
| table.innerHTML = ` | |
| <table class="audit-history-table"> | |
| <thead><tr><th>Date</th><th>F1</th><th>Risk</th><th>Actions</th></tr></thead> | |
| <tbody> | |
| ${history.slice(0, 10).map(h => ` | |
| <tr> | |
| <td>${escapeHTML(formatDate(h.audited_at))}</td> | |
| <td><span class="f1-badge">${(h.f1_score || 0).toFixed(2)}</span></td> | |
| <td><span class="risk-badge risk-${(h.compliance_risk || 'green').toLowerCase()}">${escapeHTML(h.compliance_risk || 'Green')}</span></td> | |
| <td><a href="/audit?id=${escapeHTML(h.audit_id)}" class="view-audit-link">View</a></td> | |
| </tr> | |
| `).join('')} | |
| </tbody> | |
| </table> | |
| `; | |
| // Ensure navigation works: if we're already on the Audit workspace, | |
| // load the audit in-place; otherwise perform a full navigation to | |
| // `/audit?id=...` so the audit page renders the dashboard exactly | |
| // as it appears in the archive view. | |
| const links = table.querySelectorAll('.view-audit-link'); | |
| links.forEach(link => { | |
| link.addEventListener('click', (e) => { | |
| // Allow user to open in new tab/window | |
| if (e.ctrlKey || e.metaKey || e.button === 1) return; | |
| e.preventDefault(); | |
| try { | |
| const href = link.getAttribute('href') || ''; | |
| const qs = href.split('?')[1] || ''; | |
| const params = new URLSearchParams(qs); | |
| const auditId = params.get('id'); | |
| // If we're already on the Audit page, prefer the in-page loader. | |
| const onAuditPage = !!document.getElementById('page-audit-main'); | |
| if (onAuditPage && auditId && typeof window.loadAuditDetails === 'function') { | |
| window.loadAuditDetails(auditId); | |
| return; | |
| } | |
| // Otherwise navigate to the audit page so it can render the | |
| // full audit dashboard (archive-like experience). | |
| if (href) window.location.href = href; | |
| } catch (err) { | |
| const href = link.getAttribute('href'); | |
| if (href) window.location.href = href; | |
| } | |
| }); | |
| }); | |
| } | |
| function searchAgents(term) { | |
| const t = term.toLowerCase().trim(); | |
| renderAgentsList(t ? allAgents.filter(a => (a.name||'').toLowerCase().includes(t) || (a.email||'').toLowerCase().includes(t)) : allAgents); | |
| } | |
| function sortAgents(sortBy) { | |
| const sorted = [...allAgents].sort((a, b) => { | |
| if (sortBy === 'f1_desc') return (b.stats?.avg_f1_score || 0) - (a.stats?.avg_f1_score || 0); | |
| if (sortBy === 'f1_asc') return (a.stats?.avg_f1_score || 0) - (b.stats?.avg_f1_score || 0); | |
| if (sortBy === 'name_asc') return (a.name || '').localeCompare(b.name || ''); | |
| if (sortBy === 'audits_desc') return (b.stats?.total_audits || 0) - (a.stats?.total_audits || 0); | |
| return 0; | |
| }); | |
| renderAgentsList(sorted); | |
| } | |
| async function reindexAgent() { | |
| if (!currentAgentId) return; | |
| if (window.SecurityUtils && reindexAgentBtn) { | |
| window.SecurityUtils.setButtonLoading(reindexAgentBtn, true); | |
| } | |
| try { | |
| const data = await window.apiFetch(`/agents/${currentAgentId}/reindex`, { method: 'POST' }); | |
| if (data.success) { | |
| if (window.SecurityUtils) window.SecurityUtils.showToast('Profile reindex queued.', 'success'); | |
| setTimeout(() => loadAgentDetail(currentAgentId), 2000); | |
| } | |
| } catch (error) { | |
| if (window.ErrorHandler) { | |
| window.ErrorHandler.showError(error); | |
| } else if (window.SecurityUtils) { | |
| window.SecurityUtils.showToast('Reindex failed', 'error'); | |
| } | |
| } finally { | |
| if (window.SecurityUtils && reindexAgentBtn) { | |
| window.SecurityUtils.setButtonLoading(reindexAgentBtn, false); | |
| } | |
| } | |
| } | |
| function showDetailView() { | |
| if (agentsListView) agentsListView.classList.remove('active'); | |
| if (agentDetailView) agentDetailView.classList.add('active'); | |
| window.scrollTo(0,0); | |
| } | |
| function showListView() { | |
| if (agentDetailView) agentDetailView.classList.remove('active'); | |
| if (agentsListView) agentsListView.classList.add('active'); | |
| currentAgentId = null; | |
| } | |
| function setupEventListeners() { | |
| if (agentSearch) agentSearch.addEventListener('input', e => searchAgents(e.target.value)); | |
| if (sortSelect) sortSelect.addEventListener('change', e => sortAgents(e.target.value)); | |
| if (backToListBtn) backToListBtn.addEventListener('click', showListView); | |
| if (reindexAgentBtn) reindexAgentBtn.addEventListener('click', reindexAgent); | |
| // Auto-load when agents tab is clicked | |
| const agentsTab = document.querySelector('[data-tab="agents"]'); | |
| if (agentsTab) { | |
| agentsTab.addEventListener('click', () => { | |
| // Short timeout to allow panel transition | |
| setTimeout(() => { | |
| if (allAgents.length === 0) loadAgents(); | |
| }, 50); | |
| }); | |
| } | |
| } | |
| // Helpers | |
| function getTrendIcon(t) { return t === 'improving' ? 'trending_up' : t === 'declining' ? 'trending_down' : 'trending_flat'; } | |
| function getTrendClass(t) { return `trend-${t || 'stable'}`; } | |
| function formatDate(d) { return d ? new Date(d).toLocaleDateString() : 'Never'; } | |
| // Init | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| // Expose reload capability globally | |
| window.AgentsController = { | |
| reload: () => loadAgents(), | |
| showList: showListView | |
| }; | |
| })(); |