index.html 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>OpenCode Agent Test Dashboard</title>
  7. <style>
  8. /* CSS Variables for theming */
  9. :root {
  10. --bg-primary: #ffffff;
  11. --bg-secondary: #f8f9fa;
  12. --bg-card: #ffffff;
  13. --text-primary: #212529;
  14. --text-secondary: #6c757d;
  15. --border-color: #dee2e6;
  16. --success: #28a745;
  17. --danger: #dc3545;
  18. --warning: #ffc107;
  19. --info: #17a2b8;
  20. --primary: #007bff;
  21. --shadow: rgba(0, 0, 0, 0.1);
  22. }
  23. [data-theme="dark"] {
  24. --bg-primary: #1a1a1a;
  25. --bg-secondary: #2d2d2d;
  26. --bg-card: #242424;
  27. --text-primary: #e9ecef;
  28. --text-secondary: #adb5bd;
  29. --border-color: #495057;
  30. --shadow: rgba(0, 0, 0, 0.3);
  31. }
  32. * {
  33. margin: 0;
  34. padding: 0;
  35. box-sizing: border-box;
  36. }
  37. body {
  38. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  39. background: var(--bg-primary);
  40. color: var(--text-primary);
  41. line-height: 1.6;
  42. transition: background-color 0.3s, color 0.3s;
  43. }
  44. .container {
  45. max-width: 1400px;
  46. margin: 0 auto;
  47. padding: 20px;
  48. }
  49. /* Header */
  50. header {
  51. background: var(--bg-card);
  52. border-bottom: 2px solid var(--border-color);
  53. padding: 20px 0;
  54. margin-bottom: 30px;
  55. box-shadow: 0 2px 4px var(--shadow);
  56. }
  57. .header-content {
  58. display: flex;
  59. justify-content: space-between;
  60. align-items: center;
  61. flex-wrap: wrap;
  62. gap: 20px;
  63. }
  64. h1 {
  65. font-size: 28px;
  66. font-weight: 600;
  67. color: var(--text-primary);
  68. }
  69. .header-actions {
  70. display: flex;
  71. gap: 10px;
  72. align-items: center;
  73. }
  74. /* Buttons */
  75. button {
  76. padding: 8px 16px;
  77. border: 1px solid var(--border-color);
  78. background: var(--bg-card);
  79. color: var(--text-primary);
  80. border-radius: 6px;
  81. cursor: pointer;
  82. font-size: 14px;
  83. transition: all 0.2s;
  84. }
  85. button:hover {
  86. background: var(--bg-secondary);
  87. transform: translateY(-1px);
  88. }
  89. button.primary {
  90. background: var(--primary);
  91. color: white;
  92. border-color: var(--primary);
  93. }
  94. button.primary:hover {
  95. background: #0056b3;
  96. }
  97. /* Filters */
  98. .filters {
  99. background: var(--bg-card);
  100. padding: 20px;
  101. border-radius: 8px;
  102. margin-bottom: 30px;
  103. box-shadow: 0 2px 4px var(--shadow);
  104. }
  105. .filter-row {
  106. display: flex;
  107. gap: 15px;
  108. flex-wrap: wrap;
  109. align-items: center;
  110. }
  111. .filter-group {
  112. display: flex;
  113. flex-direction: column;
  114. gap: 5px;
  115. }
  116. .filter-group label {
  117. font-size: 12px;
  118. font-weight: 600;
  119. color: var(--text-secondary);
  120. text-transform: uppercase;
  121. }
  122. select, input {
  123. padding: 8px 12px;
  124. border: 1px solid var(--border-color);
  125. background: var(--bg-primary);
  126. color: var(--text-primary);
  127. border-radius: 6px;
  128. font-size: 14px;
  129. }
  130. /* Stats Cards */
  131. .stats-grid {
  132. display: grid;
  133. grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  134. gap: 20px;
  135. margin-bottom: 30px;
  136. }
  137. .stat-card {
  138. background: var(--bg-card);
  139. padding: 20px;
  140. border-radius: 8px;
  141. box-shadow: 0 2px 4px var(--shadow);
  142. border-left: 4px solid var(--primary);
  143. }
  144. .stat-card.success {
  145. border-left-color: var(--success);
  146. }
  147. .stat-card.danger {
  148. border-left-color: var(--danger);
  149. }
  150. .stat-card.warning {
  151. border-left-color: var(--warning);
  152. }
  153. .stat-label {
  154. font-size: 12px;
  155. font-weight: 600;
  156. color: var(--text-secondary);
  157. text-transform: uppercase;
  158. margin-bottom: 8px;
  159. }
  160. .stat-value {
  161. font-size: 32px;
  162. font-weight: 700;
  163. color: var(--text-primary);
  164. }
  165. .stat-subtitle {
  166. font-size: 14px;
  167. color: var(--text-secondary);
  168. margin-top: 5px;
  169. }
  170. /* Chart Container */
  171. .chart-container {
  172. background: var(--bg-card);
  173. padding: 20px;
  174. border-radius: 8px;
  175. margin-bottom: 30px;
  176. box-shadow: 0 2px 4px var(--shadow);
  177. }
  178. .chart-container h2 {
  179. font-size: 18px;
  180. margin-bottom: 15px;
  181. color: var(--text-primary);
  182. }
  183. #trendChart {
  184. max-height: 300px;
  185. }
  186. /* Test Results Table */
  187. .results-container {
  188. background: var(--bg-card);
  189. border-radius: 8px;
  190. box-shadow: 0 2px 4px var(--shadow);
  191. overflow: hidden;
  192. }
  193. .results-header {
  194. padding: 20px;
  195. border-bottom: 1px solid var(--border-color);
  196. display: flex;
  197. justify-content: space-between;
  198. align-items: center;
  199. }
  200. .results-header h2 {
  201. font-size: 18px;
  202. color: var(--text-primary);
  203. }
  204. .search-box {
  205. position: relative;
  206. }
  207. .search-box input {
  208. padding-left: 35px;
  209. width: 300px;
  210. }
  211. .search-box::before {
  212. content: "🔍";
  213. position: absolute;
  214. left: 12px;
  215. top: 50%;
  216. transform: translateY(-50%);
  217. }
  218. table {
  219. width: 100%;
  220. border-collapse: collapse;
  221. }
  222. thead {
  223. background: var(--bg-secondary);
  224. }
  225. th {
  226. padding: 12px 16px;
  227. text-align: left;
  228. font-size: 12px;
  229. font-weight: 600;
  230. color: var(--text-secondary);
  231. text-transform: uppercase;
  232. cursor: pointer;
  233. user-select: none;
  234. }
  235. th:hover {
  236. background: var(--border-color);
  237. }
  238. th.sortable::after {
  239. content: " ↕";
  240. opacity: 0.3;
  241. }
  242. th.sort-asc::after {
  243. content: " ↑";
  244. opacity: 1;
  245. }
  246. th.sort-desc::after {
  247. content: " ↓";
  248. opacity: 1;
  249. }
  250. td {
  251. padding: 12px 16px;
  252. border-bottom: 1px solid var(--border-color);
  253. }
  254. tr:hover {
  255. background: var(--bg-secondary);
  256. }
  257. .status-badge {
  258. display: inline-block;
  259. padding: 4px 8px;
  260. border-radius: 4px;
  261. font-size: 12px;
  262. font-weight: 600;
  263. }
  264. .status-badge.passed {
  265. background: #d4edda;
  266. color: #155724;
  267. }
  268. .status-badge.failed {
  269. background: #f8d7da;
  270. color: #721c24;
  271. }
  272. [data-theme="dark"] .status-badge.passed {
  273. background: #1e4620;
  274. color: #7dce82;
  275. }
  276. [data-theme="dark"] .status-badge.failed {
  277. background: #5a1f1f;
  278. color: #f5a3a3;
  279. }
  280. .category-badge {
  281. display: inline-block;
  282. padding: 4px 8px;
  283. border-radius: 4px;
  284. font-size: 11px;
  285. font-weight: 600;
  286. background: var(--bg-secondary);
  287. color: var(--text-secondary);
  288. }
  289. .expandable-row {
  290. cursor: pointer;
  291. }
  292. .details-row {
  293. display: none;
  294. background: var(--bg-secondary);
  295. }
  296. .details-row.show {
  297. display: table-row;
  298. }
  299. .details-content {
  300. padding: 20px;
  301. }
  302. .violation-item {
  303. padding: 10px;
  304. margin: 5px 0;
  305. border-left: 3px solid var(--danger);
  306. background: var(--bg-card);
  307. border-radius: 4px;
  308. }
  309. .violation-item.warning {
  310. border-left-color: var(--warning);
  311. }
  312. /* Loading State */
  313. .loading {
  314. text-align: center;
  315. padding: 40px;
  316. color: var(--text-secondary);
  317. }
  318. .spinner {
  319. border: 3px solid var(--border-color);
  320. border-top: 3px solid var(--primary);
  321. border-radius: 50%;
  322. width: 40px;
  323. height: 40px;
  324. animation: spin 1s linear infinite;
  325. margin: 0 auto 20px;
  326. }
  327. @keyframes spin {
  328. 0% { transform: rotate(0deg); }
  329. 100% { transform: rotate(360deg); }
  330. }
  331. /* Empty State */
  332. .empty-state {
  333. text-align: center;
  334. padding: 60px 20px;
  335. color: var(--text-secondary);
  336. }
  337. .empty-state-icon {
  338. font-size: 64px;
  339. margin-bottom: 20px;
  340. opacity: 0.3;
  341. }
  342. /* Responsive */
  343. @media (max-width: 768px) {
  344. .header-content {
  345. flex-direction: column;
  346. align-items: flex-start;
  347. }
  348. .filter-row {
  349. flex-direction: column;
  350. align-items: stretch;
  351. }
  352. .search-box input {
  353. width: 100%;
  354. }
  355. table {
  356. font-size: 14px;
  357. }
  358. th, td {
  359. padding: 8px;
  360. }
  361. }
  362. /* Theme Toggle */
  363. .theme-toggle {
  364. background: none;
  365. border: none;
  366. font-size: 24px;
  367. cursor: pointer;
  368. padding: 8px;
  369. }
  370. </style>
  371. </head>
  372. <body>
  373. <header>
  374. <div class="container">
  375. <div class="header-content">
  376. <h1>🎯 OpenCode Agent Test Dashboard</h1>
  377. <div class="header-actions">
  378. <button id="refreshBtn" class="primary">🔄 Refresh</button>
  379. <button id="exportBtn">📊 Export CSV</button>
  380. <button class="theme-toggle" id="themeToggle" title="Toggle dark mode">🌙</button>
  381. </div>
  382. </div>
  383. </div>
  384. </header>
  385. <div class="container">
  386. <!-- Filters -->
  387. <div class="filters">
  388. <div class="filter-row">
  389. <div class="filter-group">
  390. <label>Agent</label>
  391. <select id="agentFilter">
  392. <option value="all">All Agents</option>
  393. </select>
  394. </div>
  395. <div class="filter-group">
  396. <label>Category</label>
  397. <select id="categoryFilter">
  398. <option value="all">All Categories</option>
  399. <option value="developer">Developer</option>
  400. <option value="business">Business</option>
  401. <option value="creative">Creative</option>
  402. <option value="edge-case">Edge Case</option>
  403. </select>
  404. </div>
  405. <div class="filter-group">
  406. <label>Status</label>
  407. <select id="statusFilter">
  408. <option value="all">All Tests</option>
  409. <option value="passed">Passed Only</option>
  410. <option value="failed">Failed Only</option>
  411. </select>
  412. </div>
  413. <div class="filter-group">
  414. <label>Time Range</label>
  415. <select id="timeFilter">
  416. <option value="latest">Latest Run</option>
  417. <option value="today">Today</option>
  418. <option value="week">Last 7 Days</option>
  419. <option value="month">Last 30 Days</option>
  420. </select>
  421. </div>
  422. </div>
  423. </div>
  424. <!-- Stats Cards -->
  425. <div class="stats-grid" id="statsGrid">
  426. <div class="stat-card">
  427. <div class="stat-label">Total Tests</div>
  428. <div class="stat-value" id="totalTests">-</div>
  429. <div class="stat-subtitle">Across all agents</div>
  430. </div>
  431. <div class="stat-card success">
  432. <div class="stat-label">Pass Rate</div>
  433. <div class="stat-value" id="passRate">-</div>
  434. <div class="stat-subtitle" id="passedCount">- passed</div>
  435. </div>
  436. <div class="stat-card danger">
  437. <div class="stat-label">Failed Tests</div>
  438. <div class="stat-value" id="failedTests">-</div>
  439. <div class="stat-subtitle" id="failedSubtitle">-</div>
  440. </div>
  441. <div class="stat-card warning">
  442. <div class="stat-label">Avg Duration</div>
  443. <div class="stat-value" id="avgDuration">-</div>
  444. <div class="stat-subtitle">Per test</div>
  445. </div>
  446. </div>
  447. <!-- Trend Chart -->
  448. <div class="chart-container">
  449. <h2>📈 Pass Rate Trend (Last 30 Days)</h2>
  450. <canvas id="trendChart"></canvas>
  451. </div>
  452. <!-- Test Results Table -->
  453. <div class="results-container">
  454. <div class="results-header">
  455. <h2>Test Results</h2>
  456. <div class="search-box">
  457. <input type="text" id="searchInput" placeholder="Search tests...">
  458. </div>
  459. </div>
  460. <div id="tableContainer">
  461. <div class="loading">
  462. <div class="spinner"></div>
  463. <p>Loading test results...</p>
  464. </div>
  465. </div>
  466. </div>
  467. </div>
  468. <!-- Chart.js from CDN -->
  469. <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
  470. <script>
  471. // Dashboard State
  472. let allResults = [];
  473. let filteredResults = [];
  474. let currentSort = { column: null, direction: 'asc' };
  475. let trendChart = null;
  476. // Initialize Dashboard
  477. document.addEventListener('DOMContentLoaded', () => {
  478. initializeTheme();
  479. setupEventListeners();
  480. loadResults();
  481. });
  482. // Theme Management
  483. function initializeTheme() {
  484. const savedTheme = localStorage.getItem('theme') || 'light';
  485. document.documentElement.setAttribute('data-theme', savedTheme);
  486. updateThemeIcon(savedTheme);
  487. }
  488. function toggleTheme() {
  489. const current = document.documentElement.getAttribute('data-theme');
  490. const newTheme = current === 'dark' ? 'light' : 'dark';
  491. document.documentElement.setAttribute('data-theme', newTheme);
  492. localStorage.setItem('theme', newTheme);
  493. updateThemeIcon(newTheme);
  494. }
  495. function updateThemeIcon(theme) {
  496. document.getElementById('themeToggle').textContent = theme === 'dark' ? '☀️' : '🌙';
  497. }
  498. // Event Listeners
  499. function setupEventListeners() {
  500. document.getElementById('themeToggle').addEventListener('click', toggleTheme);
  501. document.getElementById('refreshBtn').addEventListener('click', loadResults);
  502. document.getElementById('exportBtn').addEventListener('click', exportToCSV);
  503. document.getElementById('searchInput').addEventListener('input', applyFilters);
  504. document.getElementById('agentFilter').addEventListener('change', applyFilters);
  505. document.getElementById('categoryFilter').addEventListener('change', applyFilters);
  506. document.getElementById('statusFilter').addEventListener('change', applyFilters);
  507. document.getElementById('timeFilter').addEventListener('change', loadResults);
  508. }
  509. // Load Results
  510. async function loadResults() {
  511. showLoading();
  512. try {
  513. const timeFilter = document.getElementById('timeFilter').value;
  514. const results = await fetchResults(timeFilter);
  515. allResults = results;
  516. populateAgentFilter(results);
  517. applyFilters();
  518. updateStats(results);
  519. updateTrendChart(results);
  520. } catch (error) {
  521. showError('Failed to load results: ' + error.message);
  522. }
  523. }
  524. // Fetch Results
  525. async function fetchResults(timeFilter) {
  526. try {
  527. if (timeFilter === 'latest') {
  528. // Load latest.json
  529. const response = await fetch('latest.json');
  530. if (!response.ok) {
  531. throw new Error('Cannot load latest.json. See instructions below.');
  532. }
  533. const data = await response.json();
  534. return [data];
  535. } else {
  536. // Load from history
  537. const files = await fetchHistoryFiles(timeFilter);
  538. const results = await Promise.all(
  539. files.map(file => fetch(file).then(r => r.json()))
  540. );
  541. return results;
  542. }
  543. } catch (error) {
  544. // If fetch fails (CORS/local file), show helpful message
  545. throw new Error('Cannot load results from local file. Please use one of these methods:\n\n' +
  546. '1. Serve via HTTP:\n' +
  547. ' cd evals/results && python3 -m http.server 8000\n' +
  548. ' Then open: http://localhost:8000\n\n' +
  549. '2. Use browser flag:\n' +
  550. ' Chrome: --allow-file-access-from-files\n\n' +
  551. 'Original error: ' + error.message);
  552. }
  553. }
  554. // Fetch History Files
  555. async function fetchHistoryFiles(timeFilter) {
  556. // For now, we'll just load latest.json
  557. // In a real implementation, you'd need a file listing endpoint
  558. // or generate an index.json with all available files
  559. return ['latest.json'];
  560. }
  561. // Populate Agent Filter
  562. function populateAgentFilter(results) {
  563. const agents = [...new Set(results.map(r => r.meta.agent))];
  564. const select = document.getElementById('agentFilter');
  565. // Keep "All Agents" option
  566. select.innerHTML = '<option value="all">All Agents</option>';
  567. agents.forEach(agent => {
  568. const option = document.createElement('option');
  569. option.value = agent;
  570. option.textContent = agent.charAt(0).toUpperCase() + agent.slice(1);
  571. select.appendChild(option);
  572. });
  573. }
  574. // Apply Filters
  575. function applyFilters() {
  576. const searchTerm = document.getElementById('searchInput').value.toLowerCase();
  577. const agentFilter = document.getElementById('agentFilter').value;
  578. const categoryFilter = document.getElementById('categoryFilter').value;
  579. const statusFilter = document.getElementById('statusFilter').value;
  580. // Flatten all tests from all results
  581. const allTests = allResults.flatMap(result =>
  582. result.tests.map(test => ({
  583. ...test,
  584. agent: result.meta.agent,
  585. timestamp: result.meta.timestamp,
  586. model: result.meta.model
  587. }))
  588. );
  589. filteredResults = allTests.filter(test => {
  590. // Search filter
  591. if (searchTerm && !test.id.toLowerCase().includes(searchTerm)) {
  592. return false;
  593. }
  594. // Agent filter
  595. if (agentFilter !== 'all' && test.agent !== agentFilter) {
  596. return false;
  597. }
  598. // Category filter
  599. if (categoryFilter !== 'all' && test.category !== categoryFilter) {
  600. return false;
  601. }
  602. // Status filter
  603. if (statusFilter === 'passed' && !test.passed) {
  604. return false;
  605. }
  606. if (statusFilter === 'failed' && test.passed) {
  607. return false;
  608. }
  609. return true;
  610. });
  611. renderTable(filteredResults);
  612. }
  613. // Render Table
  614. function renderTable(tests) {
  615. const container = document.getElementById('tableContainer');
  616. if (tests.length === 0) {
  617. container.innerHTML = `
  618. <div class="empty-state">
  619. <div class="empty-state-icon">📭</div>
  620. <h3>No results found</h3>
  621. <p>Try adjusting your filters or run some tests</p>
  622. </div>
  623. `;
  624. return;
  625. }
  626. const html = `
  627. <table>
  628. <thead>
  629. <tr>
  630. <th class="sortable" data-column="id">Test ID</th>
  631. <th class="sortable" data-column="agent">Agent</th>
  632. <th class="sortable" data-column="category">Category</th>
  633. <th class="sortable" data-column="passed">Status</th>
  634. <th class="sortable" data-column="duration_ms">Duration</th>
  635. <th class="sortable" data-column="events">Events</th>
  636. <th class="sortable" data-column="violations.total">Violations</th>
  637. </tr>
  638. </thead>
  639. <tbody>
  640. ${tests.map((test, idx) => renderTestRow(test, idx)).join('')}
  641. </tbody>
  642. </table>
  643. `;
  644. container.innerHTML = html;
  645. // Add sort listeners
  646. container.querySelectorAll('th.sortable').forEach(th => {
  647. th.addEventListener('click', () => sortTable(th.dataset.column));
  648. });
  649. // Add expand listeners
  650. container.querySelectorAll('.expandable-row').forEach(row => {
  651. row.addEventListener('click', () => toggleDetails(row.dataset.index));
  652. });
  653. }
  654. // Render Test Row
  655. function renderTestRow(test, index) {
  656. const statusClass = test.passed ? 'passed' : 'failed';
  657. const statusText = test.passed ? '✅ Passed' : '❌ Failed';
  658. const duration = (test.duration_ms / 1000).toFixed(2) + 's';
  659. return `
  660. <tr class="expandable-row" data-index="${index}">
  661. <td><strong>${test.id}</strong></td>
  662. <td>${test.agent}</td>
  663. <td><span class="category-badge">${test.category}</span></td>
  664. <td><span class="status-badge ${statusClass}">${statusText}</span></td>
  665. <td>${duration}</td>
  666. <td>${test.events}</td>
  667. <td>${test.violations.total} ${test.violations.errors > 0 ? '⚠️' : ''}</td>
  668. </tr>
  669. <tr class="details-row" id="details-${index}">
  670. <td colspan="7">
  671. ${renderTestDetails(test)}
  672. </td>
  673. </tr>
  674. `;
  675. }
  676. // Render Test Details
  677. function renderTestDetails(test) {
  678. let html = '<div class="details-content">';
  679. html += `<p><strong>Approvals:</strong> ${test.approvals}</p>`;
  680. html += `<p><strong>Events:</strong> ${test.events}</p>`;
  681. if (test.violations.total > 0) {
  682. html += '<h4>Violations:</h4>';
  683. test.violations.details?.forEach(v => {
  684. html += `
  685. <div class="violation-item ${v.severity}">
  686. <strong>[${v.severity.toUpperCase()}] ${v.type}</strong><br>
  687. ${v.message}
  688. </div>
  689. `;
  690. });
  691. } else {
  692. html += '<p>✅ No violations</p>';
  693. }
  694. html += '</div>';
  695. return html;
  696. }
  697. // Toggle Details
  698. function toggleDetails(index) {
  699. const detailsRow = document.getElementById(`details-${index}`);
  700. detailsRow.classList.toggle('show');
  701. }
  702. // Sort Table
  703. function sortTable(column) {
  704. if (currentSort.column === column) {
  705. currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
  706. } else {
  707. currentSort.column = column;
  708. currentSort.direction = 'asc';
  709. }
  710. filteredResults.sort((a, b) => {
  711. let aVal = getNestedValue(a, column);
  712. let bVal = getNestedValue(b, column);
  713. if (typeof aVal === 'string') {
  714. aVal = aVal.toLowerCase();
  715. bVal = bVal.toLowerCase();
  716. }
  717. if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
  718. if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
  719. return 0;
  720. });
  721. renderTable(filteredResults);
  722. updateSortIndicators();
  723. }
  724. // Get Nested Value
  725. function getNestedValue(obj, path) {
  726. return path.split('.').reduce((curr, prop) => curr?.[prop], obj);
  727. }
  728. // Update Sort Indicators
  729. function updateSortIndicators() {
  730. document.querySelectorAll('th.sortable').forEach(th => {
  731. th.classList.remove('sort-asc', 'sort-desc');
  732. if (th.dataset.column === currentSort.column) {
  733. th.classList.add(`sort-${currentSort.direction}`);
  734. }
  735. });
  736. }
  737. // Update Stats
  738. function updateStats(results) {
  739. const allTests = results.flatMap(r => r.tests);
  740. const total = allTests.length;
  741. const passed = allTests.filter(t => t.passed).length;
  742. const failed = total - passed;
  743. const passRate = total > 0 ? ((passed / total) * 100).toFixed(1) : 0;
  744. const avgDuration = total > 0
  745. ? (allTests.reduce((sum, t) => sum + t.duration_ms, 0) / total / 1000).toFixed(2)
  746. : 0;
  747. document.getElementById('totalTests').textContent = total;
  748. document.getElementById('passRate').textContent = passRate + '%';
  749. document.getElementById('passedCount').textContent = `${passed} passed`;
  750. document.getElementById('failedTests').textContent = failed;
  751. document.getElementById('failedSubtitle').textContent = failed === 0 ? 'All tests passing! 🎉' : `${failed} tests need attention`;
  752. document.getElementById('avgDuration').textContent = avgDuration + 's';
  753. }
  754. // Update Trend Chart
  755. function updateTrendChart(results) {
  756. const ctx = document.getElementById('trendChart');
  757. // Sort by timestamp
  758. const sorted = [...results].sort((a, b) =>
  759. new Date(a.meta.timestamp) - new Date(b.meta.timestamp)
  760. );
  761. const labels = sorted.map(r => {
  762. const date = new Date(r.meta.timestamp);
  763. return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
  764. });
  765. const passRates = sorted.map(r => (r.summary.pass_rate * 100).toFixed(1));
  766. if (trendChart) {
  767. trendChart.destroy();
  768. }
  769. trendChart = new Chart(ctx, {
  770. type: 'line',
  771. data: {
  772. labels: labels,
  773. datasets: [{
  774. label: 'Pass Rate (%)',
  775. data: passRates,
  776. borderColor: '#28a745',
  777. backgroundColor: 'rgba(40, 167, 69, 0.1)',
  778. tension: 0.4,
  779. fill: true
  780. }]
  781. },
  782. options: {
  783. responsive: true,
  784. maintainAspectRatio: true,
  785. plugins: {
  786. legend: {
  787. display: false
  788. }
  789. },
  790. scales: {
  791. y: {
  792. beginAtZero: true,
  793. max: 100,
  794. ticks: {
  795. callback: function(value) {
  796. return value + '%';
  797. }
  798. }
  799. }
  800. }
  801. }
  802. });
  803. }
  804. // Export to CSV
  805. function exportToCSV() {
  806. const tests = filteredResults.length > 0 ? filteredResults : allResults.flatMap(r => r.tests);
  807. const headers = ['Test ID', 'Agent', 'Category', 'Status', 'Duration (ms)', 'Events', 'Approvals', 'Violations'];
  808. const rows = tests.map(test => [
  809. test.id,
  810. test.agent || 'unknown',
  811. test.category,
  812. test.passed ? 'Passed' : 'Failed',
  813. test.duration_ms,
  814. test.events,
  815. test.approvals,
  816. test.violations.total
  817. ]);
  818. const csv = [headers, ...rows]
  819. .map(row => row.map(cell => `"${cell}"`).join(','))
  820. .join('\n');
  821. const blob = new Blob([csv], { type: 'text/csv' });
  822. const url = URL.createObjectURL(blob);
  823. const a = document.createElement('a');
  824. a.href = url;
  825. a.download = `test-results-${new Date().toISOString().split('T')[0]}.csv`;
  826. a.click();
  827. URL.revokeObjectURL(url);
  828. }
  829. // Show Loading
  830. function showLoading() {
  831. document.getElementById('tableContainer').innerHTML = `
  832. <div class="loading">
  833. <div class="spinner"></div>
  834. <p>Loading test results...</p>
  835. </div>
  836. `;
  837. }
  838. // Show Error
  839. function showError(message) {
  840. // Format multi-line messages
  841. const formattedMessage = message.split('\n').map(line =>
  842. line.trim() ? `<p style="margin: 5px 0; text-align: left;">${line}</p>` : '<br>'
  843. ).join('');
  844. document.getElementById('tableContainer').innerHTML = `
  845. <div class="empty-state">
  846. <div class="empty-state-icon">⚠️</div>
  847. <h3>Cannot Load Results</h3>
  848. <div style="max-width: 600px; margin: 20px auto; background: var(--bg-secondary); padding: 20px; border-radius: 8px; text-align: left;">
  849. <h4 style="margin-top: 0;">Solution: Serve via HTTP</h4>
  850. <pre style="background: var(--bg-card); padding: 10px; border-radius: 4px; overflow-x: auto;">cd evals/results
  851. python3 -m http.server 8000</pre>
  852. <p>Then open: <a href="http://localhost:8000" target="_blank">http://localhost:8000</a></p>
  853. <hr style="margin: 15px 0; border: none; border-top: 1px solid var(--border-color);">
  854. <p style="font-size: 12px; color: var(--text-secondary);">
  855. <strong>Why?</strong> Browsers block loading local JSON files for security.
  856. Serving via HTTP solves this.
  857. </p>
  858. </div>
  859. <button onclick="loadResults()" class="primary">Try Again</button>
  860. </div>
  861. `;
  862. }
  863. </script>
  864. </body>
  865. </html>