qualora / static /js /agents-controller.js
Qualora QA Bot
final 4/4
9121aae
Raw
History Blame Contribute Delete
18 kB
/**
* 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
};
})();