Download static/visualizer.html from SushantGautam/simpleaudit-studio: direct link, hf CLI and curl.
- Browser
- Download file 119 kB
-
https://huggingface.co/spaces/SushantGautam/simpleaudit-studio/resolve/main/static/visualizer.html
- Command line
-
hf download hf://spaces/SushantGautam/simpleaudit-studio/static/visualizer.html
-
curl -L -o visualizer.html https://huggingface.co/spaces/SushantGautam/simpleaudit-studio/resolve/main/static/visualizer.html
119 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SimpleAudit Result Visualizer</title> | |
| <script></script> | |
| <link rel="icon" type="image/svg+xml" sizes="32x32" href="/static/favicon-32x32.svg"> | |
| <link rel="icon" type="image/svg+xml" sizes="16x16" href="/static/favicon-16x16.svg"> | |
| <script src="https://cdn.tailwindcss.com/3.4.17"></script> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| colors: { | |
| surface: { DEFAULT: '#0f1117', raised: '#1a1d27', overlay: '#242836' }, | |
| accent: { DEFAULT: '#6c8cff', hover: '#8da4ff' }, | |
| 'severity-pass': '#22c55e', | |
| 'severity-low': '#3b82f6', | |
| 'severity-medium': '#eab308', | |
| 'severity-high': '#f97316', | |
| 'severity-critical': '#ef4444', | |
| 'severity-error': '#6b7280', | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <script> | |
| // authentication helpers | |
| // API base: empty when served by the core `simpleaudit serve` server | |
| // (endpoints live at /api/*), or a prefix like "/api/visualizer" when | |
| // embedded in SimpleAudit Studio (set by the server before this script). | |
| const __API_BASE = window.__VISUALIZER_API_BASE || ''; | |
| async function apiFetch(path, opts = {}) { | |
| opts = opts || {}; | |
| opts.headers = opts.headers || {}; | |
| const token = localStorage.getItem('visualizer_secret'); | |
| if (token) { | |
| opts.headers['X-Secret'] = token; | |
| } | |
| const resp = await fetch(__API_BASE + path, opts); | |
| if (resp.status === 401) { | |
| localStorage.removeItem('visualizer_secret'); | |
| // window.location.href = '/'; | |
| throw new Error('Unauthorized'); | |
| } | |
| return resp; | |
| } | |
| function logout() { | |
| localStorage.removeItem('visualizer_secret'); | |
| window.location.href = '/'; | |
| } | |
| async function ensureLoggedIn() { | |
| // call the server to learn whether auth is enabled and whether the | |
| // stored token (if any) is valid. The endpoint returns | |
| // {ok:True, enabled:bool}. | |
| const token = localStorage.getItem('visualizer_secret'); | |
| const headers = token ? {'X-Secret': token} : {}; | |
| const resp = await fetch(__API_BASE + '/api/auth', {headers}); | |
| if (resp.status === 401) { | |
| // key required | |
| document.getElementById('auth-overlay').classList.remove('hidden'); | |
| document.getElementById('logout-button').classList.add('hidden'); | |
| return false; | |
| } | |
| const data = await resp.json(); | |
| // update contact email link based on server response | |
| const contactEmail = data.contact_email || 'sushant@simula.no'; | |
| const contactLink = document.getElementById('contact-email-link'); | |
| if (contactLink) { | |
| contactLink.href = `mailto:${contactEmail}`; | |
| contactLink.textContent = contactEmail; | |
| } | |
| if (!data.enabled) { | |
| // auth disabled on server side | |
| document.getElementById('logout-button').classList.add('hidden'); | |
| } else { | |
| document.getElementById('logout-button').classList.remove('hidden'); | |
| } | |
| // initialization every time we are authorized | |
| loadFileTree(); | |
| initResizer(); | |
| initInnerResizer(); | |
| return true; | |
| } | |
| window.addEventListener('load', async () => { | |
| // Standalone export: no server, skip auth and file tree loading | |
| if (window.__inlinedData) { | |
| return; | |
| } | |
| const submit = document.getElementById('auth-submit'); | |
| if (submit) { | |
| submit.addEventListener('click', async () => { | |
| const val = document.getElementById('auth-input').value; | |
| try { | |
| const resp = await fetch(__API_BASE + '/api/auth', {headers:{'X-Secret': val}}); | |
| if (!resp.ok) throw new Error('invalid'); | |
| localStorage.setItem('visualizer_secret', val); | |
| document.getElementById('auth-overlay').classList.add('hidden'); | |
| // login successful, now re-run ensureLoggedIn which will | |
| // also do the normal post-auth work | |
| await ensureLoggedIn(); | |
| window.location.reload(); | |
| } catch (e) { | |
| const err = document.getElementById('auth-error'); | |
| err.textContent = 'Invalid secret'; | |
| err.classList.remove('hidden'); | |
| } | |
| }); | |
| } | |
| await ensureLoggedIn(); | |
| }); | |
| </script> | |
| <style> | |
| .folder-icon::before { | |
| content: "📁"; | |
| margin-right: 0.25rem; | |
| } | |
| .folder-icon.open::before { | |
| content: "📂"; | |
| } | |
| .file-icon::before { | |
| content: "📄"; | |
| margin-right: 0.25rem; | |
| } | |
| .tree-item { | |
| cursor: pointer; | |
| user-select: none; | |
| transition: all 0.2s ease; | |
| } | |
| .tree-item.bg-accent/15 { | |
| box-shadow: 0 0 0 2px rgba(108, 140, 255, 0.35); | |
| } | |
| .fragility-tooltip { | |
| position: relative; | |
| cursor: default; | |
| } | |
| .fragility-tooltip .tooltip-content { | |
| visibility: hidden; | |
| opacity: 0; | |
| position: fixed; | |
| background: #1f2937; | |
| color: #f9fafb; | |
| padding: 10px 14px; | |
| border-radius: 8px; | |
| font-size: 12px; | |
| line-height: 1.6; | |
| white-space: nowrap; | |
| z-index: 9999; | |
| transition: opacity 0.15s ease, visibility 0.15s ease; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
| pointer-events: none; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .fragility-tooltip .tooltip-content::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: 100%; | |
| left: 16px; | |
| border: 6px solid transparent; | |
| border-bottom-color: #1f2937; | |
| } | |
| .fragility-tooltip:hover .tooltip-content { | |
| visibility: visible; | |
| opacity: 1; | |
| } | |
| .fragility-tooltip .tooltip-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .fragility-tooltip .tooltip-row + .tooltip-row { | |
| padding-top: 4px; | |
| border-top: 1px solid rgba(255,255,255,0.1); | |
| } | |
| .fragility-tooltip .tooltip-label { | |
| font-weight: 600; | |
| } | |
| .fragility-tooltip .tooltip-stable .tooltip-label { color: #34d399; } | |
| .fragility-tooltip .tooltip-fragile .tooltip-label { color: #fbbf24; } | |
| .tree-children { | |
| margin-left: 1rem; | |
| border-left: 1px solid #2a2e3a; | |
| padding-left: 0.5rem; | |
| } | |
| /* Resizer styles */ | |
| .resizer { | |
| width: 5px; | |
| cursor: col-resize; | |
| background: #2a2e3a; | |
| position: relative; | |
| flex-shrink: 0; | |
| transition: background 0.2s; | |
| } | |
| .resizer:hover { | |
| background: #6c8cff; | |
| } | |
| .resizer::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: -2px; | |
| right: -2px; | |
| bottom: 0; | |
| } | |
| .resizer::after { | |
| content: '⋮'; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: #6b7280; | |
| font-size: 20px; | |
| line-height: 1; | |
| pointer-events: none; | |
| } | |
| /* Mobile styles */ | |
| @media (max-width: 768px) { | |
| .resizer { | |
| display: none; | |
| } | |
| .resizer-inner { | |
| display: none; | |
| } | |
| /* Mobile view: Hide detail view by default, show as overlay when selected */ | |
| #detail-view { | |
| display: none; | |
| } | |
| #detail-view.has-selection { | |
| display: block; | |
| position: fixed; | |
| inset: 0; | |
| top: 73px; /* Below header */ | |
| z-index: 30; | |
| } | |
| #scenario-list-container.has-detail { | |
| display: none; | |
| } | |
| .back-button { | |
| display: inline-flex ; | |
| } | |
| } | |
| @media (min-width: 769px) { | |
| .back-button { | |
| display: none ; | |
| margin: 0 ; | |
| } | |
| } | |
| /* Standalone scenario export: no list to go back to */ | |
| body.standalone-scenario .back-button { | |
| display: none ; | |
| } | |
| .sidebar-hidden { | |
| display: none ; | |
| } | |
| /* Marquee effect for truncated file tree names */ | |
| .tree-item .marquee-wrap { | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .tree-item .marquee-text { | |
| display: inline-block; | |
| white-space: nowrap; | |
| padding-right: 2em; | |
| } | |
| .tree-item:hover .marquee-text { | |
| animation: marquee-scroll 3s linear infinite; | |
| } | |
| @keyframes marquee-scroll { | |
| 0% { transform: translateX(0); } | |
| 100% { transform: translateX(-50%); } | |
| } | |
| /* Print styles */ | |
| @media print { | |
| body.print-scenario, body.print-all { | |
| display: block ; | |
| height: auto ; | |
| overflow: visible ; | |
| } | |
| body.print-scenario > *:not(#detail-view), | |
| body.print-all > *:not(#print-all-scenarios) { | |
| display: none ; | |
| } | |
| body.print-scenario #detail-view, | |
| body.print-all #print-all-scenarios { | |
| display: block ; | |
| overflow: visible ; | |
| height: auto ; | |
| max-height: none ; | |
| position: static ; | |
| } | |
| body.print-scenario .back-button, | |
| body.print-all .back-button, | |
| body.print-scenario button[onclick*="exportScenarioPdf"], | |
| body.print-all button[onclick*="exportScenarioPdf"], | |
| body.print-all button[onclick*="exportFilePdf"] { | |
| display: none ; | |
| } | |
| body.print-scenario #scenario-detail, | |
| body.print-all #print-all-scenarios > div { | |
| max-width: 100% ; | |
| } | |
| body.print-scenario .shadow-sm, | |
| body.print-all .shadow-sm { | |
| box-shadow: none ; | |
| } | |
| } | |
| </style> | |
| <!-- VISUALIZER_SIDEBAR_CSS --> | |
| </head> | |
| <body class="bg-surface text-gray-100 h-screen flex overflow-hidden"> | |
| <!-- authentication overlay shown when a secret is required and not yet stored --> | |
| <div id="auth-overlay" class="fixed inset-0 bg-surface-raised/90 z-50 flex items-center justify-center hidden"> | |
| <div class="p-8 bg-surface-raised rounded-xl shadow-lg border border-gray-800 w-full max-w-sm space-y-5"> | |
| <div class="flex flex-col items-center gap-2"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-accent mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c1.104 0 2-.896 2-2V7a2 2 0 10-4 0v2c0 1.104.896 2 2 2zm6 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2v-5a6 6 0 1112 0z" /> | |
| </svg> | |
| <h2 class="text-xl font-bold text-gray-200">Protected Resource</h2> | |
| </div> | |
| <p class="text-gray-400 text-sm text-center"> | |
| This is a protected resource! Contact | |
| <a href="#" id="contact-email-link" class="text-accent underline">sushant@simula.no</a> | |
| for a key. | |
| </p> | |
| <a href="/scenario_viewer.html" | |
| class="inline-flex items-center justify-center px-3 py-2 bg-accent/10 text-accent rounded-lg shadow hover:bg-accent/15 text-sm font-medium border border-accent/30 w-full mb-2"> | |
| Or, access scenario viewer with local JSON | |
| </a> | |
| <input type="password" id="auth-input" class="bg-surface-overlay border border-gray-700 rounded-md px-3 py-2 w-full text-sm focus:ring-2 focus:ring-accent/40" placeholder="Enter secret key" autocomplete="current-password" /> | |
| <button id="auth-submit" class="w-full bg-accent hover:bg-accent-hover text-white font-medium px-4 py-2 rounded-lg transition-colors"> | |
| Submit | |
| </button> | |
| <p id="auth-error" class="text-red-400 text-sm text-center hidden"></p> | |
| </div> | |
| </div> | |
| <!-- VISUALIZER_SIDEBAR --> | |
| <div class="flex-1 flex flex-col min-w-0"> | |
| <main class="flex-1 flex flex-col overflow-hidden"> | |
| <!-- Slim in-content header (Studio style) --> | |
| <header class="h-12 shrink-0 flex items-center gap-2 px-3 border-b border-gray-800 bg-surface-raised z-10"> | |
| <div class="flex items-center gap-2.5 flex-1 min-w-0"> | |
| <button id="toggle-sidebar-btn" class="w-9 h-9 -ml-1 rounded-lg text-gray-300 hover:bg-surface-overlay flex items-center justify-center shrink-0" title="Toggle file tree"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> | |
| </svg> | |
| </button> | |
| <div class="min-w-0"> | |
| <h1 class="text-sm font-semibold text-gray-200 leading-tight truncate flex items-center gap-2"> | |
| <img src="/static/logo.svg?v=3" alt="SimpleAudit" class="h-4 w-4 object-contain rounded shrink-0" title="SimpleAudit Studio"> | |
| <a href="/visualizer/" class="hover:text-accent transition-colors">Visualizer</a> | |
| </h1> | |
| <p class="text-xs text-gray-500 leading-tight hidden sm:block truncate" id="file-info">No file loaded</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <input type="file" id="upload-json-input" accept=".json" class="hidden" onchange="handleUploadedFile(event)"> | |
| <div class="relative"> | |
| <button onclick="toggleOpenMenu()" class="h-8 px-2.5 rounded-lg bg-surface-overlay text-gray-300 text-xs font-medium hover:text-accent transition-colors flex items-center gap-1.5"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> | |
| </svg> | |
| <span class="hidden md:inline">Open</span> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </button> | |
| <div id="open-menu" class="hidden absolute right-0 top-full mt-1 w-44 bg-surface-raised border border-gray-800 rounded-lg shadow-lg z-50 py-1"> | |
| <button onclick="closeOpenMenu(); document.getElementById('upload-json-input').click()" class="w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-surface-overlay flex items-center gap-2"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> | |
| </svg> | |
| Upload JSON file | |
| </button> | |
| <button id="open-menu-folder" onclick="closeOpenMenu(); browseLocalFolder()" class="w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-surface-overlay flex items-center gap-2"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> | |
| </svg> | |
| Open Folder | |
| </button> | |
| </div> | |
| </div> | |
| <button id="logout-button" onclick="logout()" class="h-8 px-2.5 rounded-lg bg-surface-overlay text-gray-300 text-xs font-medium hover:text-accent transition-colors hidden"> | |
| Logout | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Main Content (file tree now lives in the Studio sidebar) --> | |
| <div class="flex flex-1 overflow-hidden"> | |
| <!-- Results View --> | |
| <div class="flex-1 flex flex-col overflow-hidden"> | |
| <!-- Sidebar / List View --> | |
| <div class="flex-1 flex flex-col md:flex-row overflow-hidden"> | |
| <div class="w-full md:w-1/2 md:max-w-[45%] bg-surface-raised flex flex-col overflow-hidden" id="scenario-list-container"> | |
| <!-- Dashboard / Stats --> | |
| <div id="dashboard" class="px-6 pt-2 pb-6 border-b border-gray-800 bg-surface hidden"> | |
| <!-- Run Selector (multi-run experiments) --> | |
| <div id="run-selector-bar" class="hidden mb-4 px-3 py-2 rounded-lg border border-accent/30 bg-accent/10"> | |
| <div class="flex items-center justify-between gap-2"> | |
| <button id="run-prev-btn" onclick="selectRun(currentRunIndex - 1)" class="px-2 py-1 text-xs rounded font-medium bg-surface-raised text-accent border border-accent/30 hover:bg-accent/15 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">← Prev</button> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-xs font-semibold text-accent uppercase tracking-wide">Run:</span> | |
| <span id="run-selector-count" class="text-sm font-bold text-accent">1 of 1</span> | |
| </div> | |
| <button id="run-next-btn" onclick="selectRun(currentRunIndex + 1)" class="px-2 py-1 text-xs rounded font-medium bg-surface-raised text-accent border border-accent/30 hover:bg-accent/15 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">Next →</button> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-2 mb-4"> | |
| <div class="bg-surface-raised p-2 rounded-lg shadow-sm border border-gray-800 text-center overflow-hidden"> | |
| <div class="text-[10px] text-gray-400 uppercase font-semibold">Pass Rate</div> | |
| <div class="text-sm font-bold text-gray-100 whitespace-nowrap overflow-hidden text-ellipsis" id="stat-pass-rate">-</div> | |
| </div> | |
| <div class="bg-surface-raised p-2 rounded-lg shadow-sm border border-gray-800 text-center overflow-hidden"> | |
| <div class="text-[10px] text-gray-400 uppercase font-semibold">Avg Score</div> | |
| <div class="text-sm font-bold text-gray-100 whitespace-nowrap overflow-hidden text-ellipsis" id="stat-avg-score">-</div> | |
| </div> | |
| </div> | |
| <!-- Severity Bar --> | |
| <div class="mt-2"> | |
| <div class="flex justify-between text-xs text-gray-400 mb-1"> | |
| <span>Severity Breakdown</span> | |
| <span id="stat-total-cases">0 cases</span> | |
| </div> | |
| <div class="h-3 rounded-full overflow-hidden flex w-full bg-surface-overlay" id="severity-bar"> | |
| <!-- Bars injected by JS --> | |
| </div> | |
| <div class="flex flex-wrap gap-2 mt-2 justify-center" id="severity-legend"> | |
| <!-- Legend injected by JS --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Filters --> | |
| <div class="p-3 border-b border-gray-800 flex flex-col gap-2 hidden" id="filters"> | |
| <div class="flex gap-2"> | |
| <input type="text" id="search-input" placeholder="Search scenarios..." | |
| class="flex-1 min-w-0 bg-surface-overlay border-none rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-accent/40"> | |
| <div class="relative shrink-0" id="sort-wrapper"> | |
| <button id="sort-btn" class="bg-surface-overlay border-none rounded-md p-2 text-gray-400 hover:text-gray-300 hover:bg-surface-overlay transition-colors" title="Sort scenarios"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9M3 12h5m8 0l4 4m0 0l-4 4m4-4H9"/> | |
| </svg> | |
| </button> | |
| <select id="sort-select" class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"> | |
| <option value="default">Default</option> | |
| <option value="score-asc">Score (Low-High)</option> | |
| <option value="score-desc">Score (High-Low)</option> | |
| <option value="severity">Severity</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- List --> | |
| <div id="scenario-list" class="flex-1 overflow-y-auto p-2 space-y-2"> | |
| <div class="text-center text-gray-400 mt-10"> | |
| Select a JSON file to view results. | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Inner Resizer --> | |
| <div class="resizer resizer-inner" id="resizer-inner"></div> | |
| <!-- Detail View --> | |
| <main class="flex-1 bg-surface overflow-y-auto overflow-x-hidden p-4 md:px-4 md:py-2 min-w-0" id="detail-view"> | |
| <div class="h-full flex flex-col items-center justify-center text-gray-400"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" | |
| viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> | |
| </svg> | |
| <p class="text-lg">Select a scenario to view details</p> | |
| </div> | |
| </main> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Image lightbox (click a thumbnail to enlarge) --> | |
| <div id="image-lightbox" class="hidden fixed inset-0 z-50 bg-black/80 items-center justify-center p-4 cursor-zoom-out" onclick="closeLightbox()"> | |
| <img src="" alt="attached image" class="max-w-full max-h-full rounded shadow-2xl" onclick="event.stopPropagation()" /> | |
| <button onclick="closeLightbox()" class="absolute top-4 right-4 text-white/90 hover:text-white text-3xl leading-none" aria-label="Close">×</button> | |
| </div> | |
| <!-- Logic --> | |
| <script> | |
| document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeLightbox(); }); | |
| // State | |
| let allScenarios = []; | |
| let currentScenarios = []; | |
| let selectedScenarioId = null; | |
| let currentFilePath = null; | |
| let currentExperimentSelection = null; | |
| let sidebarWidth = 320; // Default width in pixels | |
| let expandedFolders = new Set(); // Track expanded folders | |
| let uploadedExperiment = null; // Holds multi-result data from uploaded file | |
| let currentExperimentRuns = null; // { model, runs: [...] } for run switching | |
| let currentRunIndex = 0; // Index of the currently selected run | |
| let currentFragility = null; // { scenarioName: { agreement, entropy, spread, mode } } | |
| let localDirHandle = null; // FileSystemDirectoryHandle from Browse Folder | |
| let localDirTree = []; // File tree built from the local folder | |
| // On page load, check for file and scenario in URL | |
| window.addEventListener('DOMContentLoaded', () => { | |
| // Toggle sidebar button | |
| document.getElementById('toggle-sidebar-btn')?.addEventListener('click', () => { | |
| toggleSidebar(); | |
| }); | |
| // Clicking anywhere in the file tree while collapsed just expands the sidebar. | |
| document.getElementById('file-tree')?.addEventListener('click', (e) => { | |
| if (document.documentElement.dataset.sidebar === 'collapsed') { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| expandSidebarIfCollapsed(); | |
| } | |
| }, true); | |
| // Standalone export: the JSON was inlined into this HTML file, so | |
| // load it directly — no server, no upload step. | |
| if (window.__inlinedData) { | |
| window.__customUpload = true; | |
| const mode = window.__standaloneMode || 'single'; | |
| if (mode === 'experiment') { | |
| // Experiment export: file tree sidebar and resizer are | |
| // already removed from the HTML. Render the model picker | |
| // in the scenario list area so the user can choose a model. | |
| const data = window.__inlinedData; | |
| const models = experimentModels(data); | |
| const listEl = document.getElementById('scenario-list'); | |
| if (listEl && models.length > 0) { | |
| listEl.innerHTML = ''; | |
| models.forEach(model => { | |
| const runs = data.runs[model]; | |
| const runLabel = Array.isArray(runs) && runs.length > 1 ? ` — run 1 of ${runs.length}` : ''; | |
| const el = document.createElement('div'); | |
| el.className = 'p-3 rounded-lg border border-gray-800 bg-surface-raised cursor-pointer hover:bg-surface transition-colors'; | |
| el.innerHTML = `<div class="flex items-center gap-2"><span class="file-icon text-base"></span><span class="text-sm font-medium text-gray-300">${escapeHtml(model)}</span>${runLabel ? `<span class="text-xs text-gray-400">${runLabel}</span>` : ''}</div>`; | |
| el.onclick = () => { | |
| currentExperimentRuns = Array.isArray(runs) ? { model, runs } : null; | |
| currentRunIndex = 0; | |
| currentFragility = Array.isArray(runs) ? computeFragility(runs) : null; | |
| processData(Array.isArray(runs) ? runs[0] : runs, `${window.__inlinedName || 'export'} — ${model}${runLabel}`, true); | |
| }; | |
| listEl.appendChild(el); | |
| }); | |
| document.getElementById('file-info').textContent = `${window.__inlinedName || 'exported results'} — select a model`; | |
| return; | |
| } | |
| } | |
| // 'single' and 'scenario' modes: the HTML already has the | |
| // appropriate elements removed. Just process the data. | |
| if (mode === 'scenario') { | |
| document.body.classList.add('standalone-scenario'); | |
| } | |
| resetMultiRunState(); | |
| processData(window.__inlinedData, window.__inlinedName || 'exported results', true); | |
| return; | |
| } | |
| const params = new URLSearchParams(window.location.search); | |
| let file = params.get('case'); | |
| // If there is a file param, this is a server file, not a custom upload | |
| window.__customUpload = !file; | |
| if (file) { | |
| try { | |
| // undo the encodeURIComponent applied when the link was created | |
| file = decodeURIComponent(file); | |
| } catch (e) { | |
| // keep the raw value if it is not valid percent-encoding | |
| } | |
| const scenarioId = window.location.hash ? decodeURIComponent(window.location.hash.substring(1)) : null; | |
| if (file) { | |
| // Load the file, then select the scenario if present | |
| loadJsonFile(file, file).then(() => { | |
| if (scenarioId) { | |
| // Wait for scenarios to be loaded, then select | |
| setTimeout(() => { | |
| const found = allScenarios.find(s => s.id === scenarioId); | |
| if (found) selectScenario(found); | |
| }, 300); | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| // Fragility tooltip: position fixed, clamped to viewport on hover | |
| document.addEventListener('mouseover', (e) => { | |
| const trigger = e.target.closest('.fragility-tooltip[data-tooltip]'); | |
| if (!trigger) return; | |
| const tip = trigger.querySelector('.tooltip-content'); | |
| if (!tip) return; | |
| const rect = trigger.getBoundingClientRect(); | |
| tip.style.visibility = 'visible'; | |
| tip.style.opacity = '0'; | |
| // Measure after making visible | |
| const tipW = tip.offsetWidth; | |
| const tipH = tip.offsetHeight; | |
| let left = rect.left; | |
| let top = rect.bottom + 8; | |
| // Clamp horizontally | |
| if (left + tipW > window.innerWidth - 8) { | |
| left = window.innerWidth - tipW - 8; | |
| } | |
| if (left < 8) left = 8; | |
| // Clamp vertically (flip above if not enough room below) | |
| if (top + tipH > window.innerHeight - 8) { | |
| top = rect.top - tipH - 8; | |
| } | |
| if (top < 8) top = 8; | |
| tip.style.left = left + 'px'; | |
| tip.style.top = top + 'px'; | |
| tip.style.opacity = '1'; | |
| }); | |
| document.addEventListener('mouseout', (e) => { | |
| const trigger = e.target.closest('.fragility-tooltip[data-tooltip]'); | |
| if (!trigger) return; | |
| const tip = trigger.querySelector('.tooltip-content'); | |
| if (tip) { | |
| tip.style.opacity = '0'; | |
| tip.style.visibility = 'hidden'; | |
| } | |
| }); | |
| // Constants | |
| const SEVERITY_COLORS = { | |
| 'pass': 'bg-severity-pass', | |
| 'low': 'bg-severity-low', | |
| 'medium': 'bg-severity-medium', | |
| 'high': 'bg-severity-high', | |
| 'critical': 'bg-severity-critical', | |
| 'ERROR': 'bg-severity-error', | |
| 'custom': 'bg-purple-300', | |
| }; | |
| const SEVERITY_TEXT_COLORS = { | |
| 'pass': 'text-severity-pass', | |
| 'low': 'text-severity-low', | |
| 'medium': 'text-severity-medium', | |
| 'high': 'text-severity-high', | |
| 'critical': 'text-severity-critical', | |
| 'ERROR': 'text-severity-error', | |
| 'custom': 'text-purple-500', | |
| }; | |
| const SEVERITY_ORDER = { | |
| 'critical': 0, | |
| 'high': 1, | |
| 'medium': 2, | |
| 'low': 3, | |
| 'pass': 4, | |
| 'ERROR': -1, | |
| }; | |
| const SEVERITY_SCORES_MAP = { | |
| 'critical': 0, | |
| 'high': 2.5, | |
| 'medium': 5, | |
| 'low': 7.5, | |
| 'pass': 10, | |
| 'ERROR': 0, | |
| }; | |
| // Returns true if this scenario used a custom judge schema (no fixed severity/issues fields) | |
| function isCustomSchema(s) { | |
| return s.judgment && typeof s.judgment === 'object' && !('severity' in s.judgment); | |
| } | |
| // Render a single judgment value based on its JS type | |
| function renderJudgmentValue(value) { | |
| if (typeof value === 'number') { | |
| return `<span class="text-2xl font-bold text-accent">${value}</span>`; | |
| } | |
| if (typeof value === 'boolean') { | |
| return `<span class="font-medium ${value ? 'text-emerald-400' : 'text-red-400'}">${value}</span>`; | |
| } | |
| if (Array.isArray(value)) { | |
| if (value.length === 0) return '<span class="text-gray-400 italic">none</span>'; | |
| return `<ul class="list-disc list-inside space-y-1">${value.map(v => `<li>${escapeHtml(String(v))}</li>`).join('')}</ul>`; | |
| } | |
| if (typeof value === 'object' && value !== null) { | |
| return `<pre class="text-xs bg-surface p-2 rounded border border-gray-800 overflow-auto">${escapeHtml(JSON.stringify(value, null, 2))}</pre>`; | |
| } | |
| return `<span class="whitespace-pre-wrap">${escapeHtml(String(value))}</span>`; | |
| } | |
| // Label -> value pairs for the judge card. Custom judges expose their | |
| // raw judgment keys; the default judge exposes the standard structured | |
| // fields. Both are rendered the same way by renderJudgmentValue. | |
| function judgeFields(s) { | |
| if (isCustomSchema(s) && s.judgment) { | |
| return Object.entries(s.judgment); | |
| } | |
| const pairs = []; | |
| if (s.summary) pairs.push(['Summary', s.summary]); | |
| if (s.issues_found && s.issues_found.length) pairs.push(['Issues Found', s.issues_found]); | |
| if (s.positive_behaviors && s.positive_behaviors.length) pairs.push(['Positive Behaviors', s.positive_behaviors]); | |
| if (s.recommendations && s.recommendations.length) pairs.push(['Recommendations', s.recommendations]); | |
| if (!pairs.length) pairs.push(['Feedback', s.reasoning || s.feedback || 'No feedback available.']); | |
| return pairs; | |
| } | |
| // Initialize | |
| const searchInput = document.getElementById('search-input'); | |
| const sortSelect = document.getElementById('sort-select'); | |
| if (searchInput) searchInput.addEventListener('input', handleFilter); | |
| if (sortSelect) sortSelect.addEventListener('change', handleFilter); | |
| // the actual loading of data and resizer must wait until auth is settled; | |
| // the window 'load' handler configured earlier will call ensureLoggedIn | |
| // and then populate the UI. | |
| // The file tree now lives in the Studio app sidebar (#sidebar). The | |
| // Studio sidebar handles its own collapse (desktop) and drawer (mobile) | |
| // via the injected base.html JS; these helpers drive that from the | |
| // visualizer's header toggle button. | |
| function expandSidebarIfCollapsed() { | |
| const root = document.documentElement; | |
| if (window.innerWidth >= 768 && root.dataset.sidebar === 'collapsed') { | |
| delete root.dataset.sidebar; | |
| } | |
| } | |
| function toggleSidebar() { | |
| const root = document.documentElement; | |
| if (window.innerWidth < 768) { | |
| // Mobile: toggle the off-canvas drawer (html.sidebar-open). | |
| root.classList.toggle('sidebar-open'); | |
| const backdrop = document.getElementById('sidebar-backdrop'); | |
| if (backdrop) backdrop.hidden = !root.classList.contains('sidebar-open'); | |
| } else { | |
| // Desktop: toggle the collapsed icon rail (html[data-sidebar]). | |
| if (root.dataset.sidebar === 'collapsed') delete root.dataset.sidebar; | |
| else root.dataset.sidebar = 'collapsed'; | |
| } | |
| } | |
| function closeMobileSidebar() { | |
| document.documentElement.classList.remove('sidebar-open'); | |
| const backdrop = document.getElementById('sidebar-backdrop'); | |
| if (backdrop) backdrop.hidden = true; | |
| } | |
| // --- Resizer Functions --- | |
| // The file tree is in the Studio sidebar (fixed width, collapsible), so | |
| // there is no draggable resizer for it. The inner resizer (list vs detail) | |
| // is unaffected. | |
| function initResizer() { /* no-op: file tree uses the Studio sidebar */ } | |
| function initInnerResizer() { | |
| const resizer = document.getElementById('resizer-inner'); | |
| const listContainer = document.getElementById('scenario-list-container'); | |
| if (!resizer || !listContainer) return; | |
| let isResizing = false; | |
| let startX = 0; | |
| let startWidth = 0; | |
| resizer.addEventListener('mousedown', (e) => { | |
| isResizing = true; | |
| startX = e.clientX; | |
| startWidth = listContainer.offsetWidth; | |
| document.body.style.cursor = 'col-resize'; | |
| document.body.style.userSelect = 'none'; | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isResizing) return; | |
| const dx = e.clientX - startX; | |
| const parentWidth = listContainer.parentElement.offsetWidth; | |
| const minList = 220; | |
| const maxList = Math.floor(parentWidth * 0.45); | |
| const newWidth = Math.max(minList, Math.min(maxList, startWidth + dx)); | |
| listContainer.style.width = newWidth + 'px'; | |
| listContainer.style.flex = 'none'; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (isResizing) { | |
| isResizing = false; | |
| document.body.style.cursor = ''; | |
| document.body.style.userSelect = ''; | |
| } | |
| }); | |
| } | |
| // --- File Tree Functions --- | |
| let currentFileTree = []; | |
| async function loadFileTree() { | |
| // Standalone export: no server, skip file tree loading | |
| if (window.__inlinedData) { | |
| return; | |
| } | |
| // Hide the Local badge when switching back to the server tree | |
| const badge = document.getElementById('local-folder-badge'); | |
| if (badge) badge.classList.add('hidden'); | |
| const container = document.getElementById('file-tree'); | |
| container.innerHTML = '<div class="text-center text-gray-400"><div class="animate-spin h-8 w-8 border-4 border-accent/40 border-t-transparent rounded-full mx-auto mb-2"></div>Loading files...</div>'; | |
| try { | |
| const response = await apiFetch('/api/files'); | |
| if (!response.ok) throw new Error('Failed to load files'); | |
| const data = await response.json(); | |
| currentFileTree = data.tree; | |
| renderFileTree(data.tree, container); | |
| // Surface server warnings and truncation metadata above the tree. | |
| const warnings = (data.warnings || []); | |
| const truncated = !!data.truncated; | |
| if (warnings.length || truncated) { | |
| let notice = ''; | |
| warnings.forEach(w => { | |
| notice += `<div class="mb-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-300">${escapeHtml(w)}</div>`; | |
| }); | |
| if (truncated) { | |
| const reason = data.reason || 'limit'; | |
| const inspected = data.inspected ?? '?'; | |
| const elapsed = data.elapsed_seconds != null ? data.elapsed_seconds.toFixed(1) + 's' : ''; | |
| const reasonText = { | |
| 'file_limit': `file limit reached (${inspected} files inspected)`, | |
| 'time_budget': `time budget exceeded (${elapsed})`, | |
| 'depth_limit': `max depth reached`, | |
| }[reason] || reason; | |
| notice += `<div class="mb-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-300">Directory listing is truncated — ${reasonText}. Point the server at a smaller folder or raise the limits.</div>`; | |
| } | |
| const wrap = document.createElement('div'); | |
| wrap.innerHTML = notice; | |
| container.insertBefore(wrap, container.firstChild); | |
| } | |
| } catch (error) { | |
| container.innerHTML = `<div class="text-center text-red-400 p-4">Error loading files: ${escapeHtml(error.message)}</div>`; | |
| } | |
| // Re-append experiment tree items if an experiment file was uploaded | |
| if (uploadedExperiment) { | |
| appendExperimentToTree(uploadedExperiment.data, uploadedExperiment.filename); | |
| } | |
| } | |
| function renderFileTree(items, container, level = 0) { | |
| if (items.length === 0) { | |
| container.innerHTML = '<div class="text-center text-gray-400 p-4">No JSON files found.</div>'; | |
| return; | |
| } | |
| const ul = document.createElement('div'); | |
| ul.className = 'space-y-1'; | |
| items.forEach(item => { | |
| const li = document.createElement('div'); | |
| if (item.type === 'folder') { | |
| // Folder with children | |
| const folderDiv = document.createElement('div'); | |
| const header = document.createElement('div'); | |
| header.className = 'tree-item group flex items-center py-1 px-2 rounded hover:bg-surface-overlay text-sm'; | |
| // Check if this folder should be expanded (either explicitly or it contains the selected file) | |
| const shouldBeOpen = expandedFolders.has(item.path) || isParentOfSelectedFile(item); | |
| header.innerHTML = ` | |
| <span class="folder-icon text-base ${shouldBeOpen ? 'open' : ''} shrink-0"></span> | |
| <span class="marquee-wrap font-medium text-gray-300" title="${escapeHtml(item.name)}"><span class="marquee-text">${escapeHtml(item.name)}</span></span> | |
| <span class="text-xs text-gray-400 shrink-0">${item.children.length}</span> | |
| `; | |
| const childrenDiv = document.createElement('div'); | |
| childrenDiv.className = `tree-children ${shouldBeOpen ? '' : 'hidden'}`; | |
| header.onclick = (e) => { | |
| e.stopPropagation(); | |
| const icon = header.querySelector('.folder-icon'); | |
| const isOpen = icon.classList.toggle('open'); | |
| childrenDiv.classList.toggle('hidden'); | |
| // Track expanded state | |
| if (isOpen) { | |
| expandedFolders.add(item.path); | |
| } else { | |
| expandedFolders.delete(item.path); | |
| } | |
| }; | |
| folderDiv.appendChild(header); | |
| folderDiv.appendChild(childrenDiv); | |
| renderFileTree(item.children, childrenDiv, level + 1); | |
| li.appendChild(folderDiv); | |
| } else if (item.type === 'experiment') { | |
| // Multi-model experiment file — render as folder with model children | |
| li.appendChild(renderExperimentFolder(item.name, item.path, item.models, async (model) => { | |
| try { | |
| let data; | |
| if (item.local) { | |
| // Read from local file handle | |
| const parts = item.path.split('/'); | |
| let h = localDirHandle; | |
| for (let i = 0; i < parts.length - 1; i++) h = await h.getDirectoryHandle(parts[i]); | |
| const f = await h.getFile(parts[parts.length - 1]); | |
| data = JSON.parse(await f.text()); | |
| } else { | |
| const resp = await apiFetch(`/api/json/${encodeURIComponent(item.path)}`); | |
| if (!resp.ok) throw new Error('Failed to load'); | |
| data = await resp.json(); | |
| } | |
| const runs = data.runs[model]; | |
| const runLabel = Array.isArray(runs) && runs.length > 1 ? ` \u2014 run 1 of ${runs.length}` : ''; | |
| currentExperimentRuns = Array.isArray(runs) ? { model, runs } : null; | |
| currentRunIndex = 0; | |
| currentFragility = Array.isArray(runs) ? computeFragility(runs) : null; | |
| window.__customUpload = !!item.local; | |
| processData(Array.isArray(runs) ? runs[0] : runs, `${item.name} \u2014 ${model}${runLabel}`, !!item.local); | |
| } catch (e) { | |
| document.getElementById('file-info').textContent = `Error: ${e.message}`; | |
| } | |
| }, expandedFolders.has(item.path))); | |
| } else if (item.type === 'file') { | |
| // JSON file | |
| const fileDiv = document.createElement('div'); | |
| const isSelected = currentFilePath === item.path; | |
| fileDiv.className = `tree-item group flex items-center py-1 px-2 rounded hover:bg-accent/10 text-sm transition-colors ${isSelected ? 'bg-accent/15 ring-1 ring-accent/40' : ''}`; | |
| if (item.local) fileDiv.setAttribute('data-local-path', item.path); | |
| fileDiv.innerHTML = ` | |
| <span class="file-icon text-base shrink-0"></span> | |
| <span class="marquee-wrap text-gray-300 ${isSelected ? 'font-semibold' : ''}" title="${escapeHtml(item.name)}"><span class="marquee-text">${escapeHtml(item.name)}</span></span> | |
| ${item.local ? '' : `<button onclick="event.stopPropagation(); downloadFileHtml('${escapeHtml(item.path)}')" class="shrink-0 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-accent transition-all p-1" title="Download as standalone HTML"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> | |
| </svg> | |
| </button>`} | |
| `; | |
| fileDiv.onclick = () => { | |
| currentExperimentSelection = null; | |
| if (item.local) { | |
| loadLocalFile(item.path); | |
| } else { | |
| loadJsonFile(item.path, item.name); | |
| } | |
| }; | |
| li.appendChild(fileDiv); | |
| } | |
| ul.appendChild(li); | |
| }); | |
| container.innerHTML = ''; | |
| container.appendChild(ul); | |
| } | |
| // Helper function to check if a folder contains the selected file | |
| function isParentOfSelectedFile(folder) { | |
| if (!currentFilePath) return false; | |
| return currentFilePath.startsWith(folder.path + '/') || currentFilePath.startsWith(folder.path + '\\'); | |
| } | |
| // Render a multi-model experiment as an expandable folder with model children. | |
| // loadModel(model) is invoked when a model row is clicked. | |
| function renderExperimentFolder(name, key, models, loadModel, isOpen) { | |
| const displayName = name; | |
| const folderDiv = document.createElement('div'); | |
| const header = document.createElement('div'); | |
| header.className = 'tree-item group flex items-center py-1 px-2 rounded hover:bg-surface-overlay text-sm'; | |
| header.innerHTML = ` | |
| <span class="folder-icon text-base ${isOpen ? 'open' : ''} shrink-0"></span> | |
| <span class="marquee-wrap font-medium text-gray-300" title="${escapeHtml(displayName)}"><span class="marquee-text">${escapeHtml(displayName)}</span></span> | |
| <button onclick="event.stopPropagation(); downloadFileHtml('${escapeHtml(key)}')" class="shrink-0 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-accent transition-all p-1" title="Download this experiment as standalone HTML"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> | |
| </svg> | |
| </button> | |
| <span class="text-xs text-gray-400 shrink-0">${models.length}</span> | |
| `; | |
| const childrenDiv = document.createElement('div'); | |
| childrenDiv.className = `tree-children ${isOpen ? '' : 'hidden'}`; | |
| header.onclick = (e) => { | |
| e.stopPropagation(); | |
| const open = header.querySelector('.folder-icon').classList.toggle('open'); | |
| childrenDiv.classList.toggle('hidden'); | |
| if (open) expandedFolders.add(key); | |
| else expandedFolders.delete(key); | |
| }; | |
| models.forEach(model => { | |
| const selectionKey = `${key}::${model}`; | |
| const isSelected = currentExperimentSelection === selectionKey; | |
| const fileDiv = document.createElement('div'); | |
| fileDiv.className = `tree-item flex items-center py-1 px-2 rounded hover:bg-accent/10 text-sm transition-colors ${isSelected ? 'bg-accent/15 ring-1 ring-accent/40' : ''}`; | |
| fileDiv.innerHTML = `<span class="file-icon text-base"></span><span class="flex-1 text-gray-300 ${isSelected ? 'font-semibold' : ''}">${escapeHtml(model)}</span>`; | |
| fileDiv.onclick = () => { | |
| currentFilePath = null; | |
| // Experiment model views have no shareable URL scheme yet, | |
| // so hide Copy URL regardless of what was viewed before. | |
| window.__customUpload = true; | |
| currentExperimentSelection = selectionKey; | |
| applyTreeSelectionState(fileDiv); | |
| loadModel(model); | |
| }; | |
| childrenDiv.appendChild(fileDiv); | |
| }); | |
| folderDiv.appendChild(header); | |
| folderDiv.appendChild(childrenDiv); | |
| return folderDiv; | |
| } | |
| function applyTreeSelectionState(selectedElement) { | |
| document.querySelectorAll('.tree-item').forEach(el => { | |
| el.classList.remove('bg-accent/15', 'ring-1', 'ring-accent/40'); | |
| const text = el.querySelector('.flex-1'); | |
| if (text) text.classList.remove('font-semibold'); | |
| }); | |
| if (!selectedElement) return; | |
| selectedElement.classList.add('bg-accent/15', 'ring-1', 'ring-accent/40'); | |
| const text = selectedElement.querySelector('.flex-1'); | |
| if (text) text.classList.add('font-semibold'); | |
| } | |
| // Download a file (experiment or single run) as standalone HTML | |
| async function downloadFileHtml(filePath) { | |
| // Find the item in the file tree | |
| const item = findTreeItem(currentFileTree, filePath); | |
| if (!item) { | |
| alert('Could not find file: ' + filePath); | |
| return; | |
| } | |
| const name = item.name; | |
| const isExperiment = item.type === 'experiment'; | |
| // Fetch the actual JSON data from the server | |
| let data; | |
| try { | |
| const resp = await apiFetch(`/api/json/${encodeURIComponent(filePath)}`); | |
| if (!resp.ok) throw new Error('Failed to load'); | |
| data = await resp.json(); | |
| } catch (e) { | |
| alert('Failed to load data: ' + e.message); | |
| return; | |
| } | |
| const json = JSON.stringify(data) | |
| .replace(/</g, '\\u003c') | |
| .replace(/\u2028/g, '\u2028') | |
| .replace(/\u2029/g, '\u2029'); | |
| const script = '<script>window.__inlinedData = ' + json + '; window.__inlinedName = ' + JSON.stringify(name).replace(/</g, '\\u003c') + '; window.__standaloneMode = ' + JSON.stringify(isExperiment ? 'experiment' : 'single') + ';<' + '/script>'; | |
| let html = document.documentElement.outerHTML; | |
| // Remove the file tree sidebar and resizer (scenario picker stays) | |
| html = removeDivBlock(html, '<div id="file-tree-sidebar"'); | |
| html = removeDivBlock(html, '<div class="resizer" id="resizer">'); | |
| html = removeUploadButton(html); | |
| html = removeDownloadButtons(html); | |
| html = removeHamburgerButton(html); | |
| html = html.replace('</head>', `${script}\n</head>`); | |
| const blob = new Blob([html], { type: 'text/html' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${name.replace(/[^a-zA-Z0-9._-]+/g, '_')}.html`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| // Helper: find an item in the file tree by path | |
| function findTreeItem(items, path) { | |
| for (const item of items) { | |
| if (item.path === path) return item; | |
| if (item.children) { | |
| const found = findTreeItem(item.children, path); | |
| if (found) return found; | |
| } | |
| } | |
| return null; | |
| } | |
| // Remove a top-level <div> block from an HTML string by its opening | |
| // marker. The marker can be any substring within the opening <div> tag. | |
| // The function finds the <div> tag that contains the marker, then removes | |
| // the entire block including all nested divs. | |
| function removeDivBlock(html, marker) { | |
| const markerPos = html.indexOf(marker); | |
| if (markerPos === -1) return html; | |
| // Find the start of the <div> tag that contains this marker | |
| let divStart = html.lastIndexOf('<div', markerPos); | |
| if (divStart === -1) return html; | |
| // Verify this is actually a <div> tag (not </div>) | |
| if (html[divStart + 4] !== ' ' && html[divStart + 4] !== '>') return html; | |
| // Now count depth from the <div> tag start | |
| let depth = 0, i = divStart; | |
| while (i < html.length) { | |
| if (html[i] === '<' && html[i + 1] === 'd' && html[i + 2] === 'i' && html[i + 3] === 'v' && (html[i + 4] === ' ' || html[i + 4] === '>')) depth++; | |
| if (html.substring(i, i + 6) === '</div>') { | |
| depth--; | |
| if (depth === 0) { i += 6; break; } | |
| } | |
| i++; | |
| } | |
| return html.substring(0, divStart) + html.substring(i); | |
| } | |
| // Remove the Upload JSON button and its hidden file input from an HTML string | |
| function removeUploadButton(html) { | |
| const marker = 'id="upload-json-input"'; | |
| const markerPos = html.indexOf(marker); | |
| if (markerPos === -1) return html; | |
| // Find the start of the <input> tag | |
| const inputStart = html.lastIndexOf('<input', markerPos); | |
| if (inputStart === -1) return html; | |
| // Find the end of the <input> tag | |
| const inputEnd = html.indexOf('>', inputStart) + 1; | |
| // Now find the <button> that follows | |
| const btnStart = html.indexOf('<button', inputEnd); | |
| if (btnStart === -1) return html.substring(0, inputStart) + html.substring(inputEnd); | |
| const btnEnd = html.indexOf('</button>', btnStart) + '</button>'.length; | |
| return html.substring(0, inputStart) + html.substring(btnEnd); | |
| } | |
| // Remove all download buttons (file tree + scenario list) from an HTML string | |
| function removeDownloadButtons(html) { | |
| // Remove file tree download buttons | |
| html = html.replace(/<button onclick="event\.stopPropagation\(\); downloadFileHtml\([^)]*\)"[^>]*>[\s\S]*?<\/button>/g, ''); | |
| // Remove scenario download buttons | |
| html = html.replace(/<button onclick="event\.stopPropagation\(\); downloadScenarioHtml\([^)]*\)"[^>]*>[\s\S]*?<\/button>/g, ''); | |
| return html; | |
| } | |
| // Remove the hamburger (toggle sidebar) button from an HTML string | |
| function removeHamburgerButton(html) { | |
| const marker = 'title="Toggle file tree"'; | |
| const markerPos = html.indexOf(marker); | |
| if (markerPos === -1) return html; | |
| const btnStart = html.lastIndexOf('<button', markerPos); | |
| if (btnStart === -1) return html; | |
| const btnEnd = html.indexOf('</button>', btnStart) + '</button>'.length; | |
| return html.substring(0, btnStart) + html.substring(btnEnd); | |
| } | |
| function downloadScenarioHtml(scenarioId) { | |
| const s = currentScenarios.find(sc => sc.id === scenarioId); | |
| if (!s) return; | |
| const title = s.scenario_name || s.name || scenarioId; | |
| // Wrap in an array so processData() recognizes it as a valid format | |
| const json = JSON.stringify([s]) | |
| .replace(/</g, '\\u003c') | |
| .replace(/\u2028/g, '\\u2028') | |
| .replace(/\u2029/g, '\\u2029'); | |
| const script = '<script>window.__inlinedData = ' + json + '; window.__inlinedName = ' + JSON.stringify(title).replace(/</g, '\\u003c') + '; window.__standaloneMode = "scenario";<' + '/script>'; | |
| let html = document.documentElement.outerHTML; | |
| // Remove file tree sidebar | |
| html = removeDivBlock(html, '<div id="file-tree-sidebar"'); | |
| // Remove resizer | |
| html = removeDivBlock(html, '<div class="resizer" id="resizer">'); | |
| // Remove scenario list container (includes dashboard, filters, and list) | |
| // Use a simple marker since the class list can change (e.g. has-detail) | |
| html = removeDivBlock(html, 'id="scenario-list-container"'); | |
| html = removeUploadButton(html); | |
| html = removeDownloadButtons(html); | |
| html = removeHamburgerButton(html); | |
| html = html.replace('</head>', script + '\n</head>'); | |
| const blob = new Blob([html], { type: 'text/html' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${title.replace(/[^a-zA-Z0-9._-]+/g, '_')}.html`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| // Handle uploaded file | |
| function handleUploadedFile(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| try { | |
| const data = JSON.parse(e.target.result); | |
| // Clear file tree selection since this is a custom upload; | |
| // also drop any previously uploaded experiment so its | |
| // folder does not linger in the tree. | |
| currentFilePath = null; | |
| currentExperimentSelection = null; | |
| uploadedExperiment = null; | |
| // Update file tree to clear any selection | |
| loadFileTree(); | |
| // Close mobile sidebar | |
| if (window.innerWidth < 768) { | |
| closeMobileSidebar(); | |
| } | |
| // Process the uploaded data, mark as custom upload | |
| resetMultiRunState(); | |
| processData(data, file.name + ' (uploaded)', true); | |
| // Clear URL (remove query and hash) | |
| if (history.replaceState) { | |
| const url = new URL(window.location); | |
| url.search = ''; | |
| url.hash = ''; | |
| history.replaceState({}, '', url); | |
| } else { | |
| window.location.search = ''; | |
| window.location.hash = ''; | |
| } | |
| // Mark as custom upload for detail rendering | |
| window.__customUpload = true; | |
| } catch (error) { | |
| alert('Error parsing JSON file: ' + error.message); | |
| } | |
| }; | |
| reader.onerror = function() { | |
| alert('Error reading file'); | |
| }; | |
| reader.readAsText(file); | |
| // Reset input so the same file can be uploaded again | |
| event.target.value = ''; | |
| } | |
| // --- Open menu (Upload JSON / Browse Folder) --- | |
| function toggleOpenMenu() { | |
| const menu = document.getElementById('open-menu'); | |
| menu.classList.toggle('hidden'); | |
| } | |
| function closeOpenMenu() { | |
| document.getElementById('open-menu').classList.add('hidden'); | |
| } | |
| // Close the menu when clicking outside | |
| document.addEventListener('click', (e) => { | |
| const menu = document.getElementById('open-menu'); | |
| if (menu && !menu.classList.contains('hidden') && !e.target.closest('.relative')) { | |
| closeOpenMenu(); | |
| } | |
| }); | |
| // --- Browse local folder (File System Access API) --- | |
| // Hide the "Browse local folder" option in browsers without showDirectoryPicker. | |
| (function hideBrowseIfUnsupported() { | |
| if (!window.showDirectoryPicker) { | |
| const btn = document.getElementById('open-menu-folder'); | |
| if (btn) btn.style.display = 'none'; | |
| } | |
| })(); | |
| async function browseLocalFolder() { | |
| try { | |
| localDirHandle = await window.showDirectoryPicker({ mode: 'read' }); | |
| } catch (e) { | |
| if (e.name === 'AbortError') return; // user cancelled | |
| alert('Could not open folder: ' + e.message); | |
| return; | |
| } | |
| // Build the tree and render it | |
| localDirTree = []; | |
| try { | |
| localDirTree = await buildLocalDirTree(localDirHandle, ''); | |
| } catch (e) { | |
| alert('Error reading folder: ' + e.message); | |
| return; | |
| } | |
| // Clear server-side selection and render the local tree | |
| currentFilePath = null; | |
| currentExperimentSelection = null; | |
| uploadedExperiment = null; | |
| window.__customUpload = true; | |
| renderLocalDirTree(); | |
| // Close mobile sidebar | |
| if (window.innerWidth < 768) closeMobileSidebar(); | |
| } | |
| // --- SimpleAudit result detection (mirrors server-side is_valid_audit_data) --- | |
| function looksLikeAuditResult(obj) { | |
| return obj && typeof obj === 'object' && !Array.isArray(obj) | |
| && ('scenario_name' in obj || 'name' in obj) | |
| && 'severity' in obj; | |
| } | |
| function localExperimentModels(data) { | |
| if (!data || typeof data !== 'object' || Array.isArray(data) || !('runs' in data)) return []; | |
| const models = []; | |
| for (const [model, runs] of Object.entries(data.runs)) { | |
| if (!Array.isArray(runs) || runs.length === 0) continue; | |
| const first = runs[0]; | |
| if (first && typeof first === 'object' && Array.isArray(first.results) && first.results.length > 0 | |
| && first.results.every(looksLikeAuditResult)) { | |
| models.push(model); | |
| } | |
| } | |
| return models; | |
| } | |
| function isValidAuditData(data) { | |
| if (Array.isArray(data)) { | |
| return data.length > 0 && data.every(looksLikeAuditResult); | |
| } | |
| if (data && typeof data === 'object') { | |
| if ('results' in data) { | |
| const r = data.results; | |
| return Array.isArray(r) && r.length > 0 && r.every(looksLikeAuditResult); | |
| } | |
| if ('runs' in data) { | |
| return localExperimentModels(data).length > 0; | |
| } | |
| } | |
| return false; | |
| } | |
| async function buildLocalDirTree(dirHandle, prefix) { | |
| const items = []; | |
| for await (const entry of dirHandle.values()) { | |
| const path = prefix ? prefix + '/' + entry.name : entry.name; | |
| if (entry.kind === 'directory') { | |
| const children = await buildLocalDirTree(entry, path); | |
| if (children.length > 0) items.push({ name: entry.name, path: path, type: 'folder', children: children }); | |
| } else if (entry.name.endsWith('.json')) { | |
| // Only include files that look like SimpleAudit results | |
| try { | |
| const file = await entry.getFile(); | |
| const data = JSON.parse(await file.text()); | |
| const models = localExperimentModels(data); | |
| if (models.length > 0) { | |
| items.push({ name: entry.name, path: path, type: 'experiment', models: models, local: true }); | |
| } else if (isValidAuditData(data)) { | |
| items.push({ name: entry.name, path: path, type: 'file', local: true }); | |
| } | |
| } catch (e) { | |
| // Not valid JSON or not audit data — skip | |
| } | |
| } | |
| } | |
| // Sort: dirs first, then files, alphabetically | |
| items.sort((a, b) => { | |
| if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; | |
| return a.name.localeCompare(b.name); | |
| }); | |
| return items; | |
| } | |
| function renderLocalDirTree() { | |
| const container = document.getElementById('file-tree'); | |
| // Show the Local badge with the folder path on hover | |
| const badge = document.getElementById('local-folder-badge'); | |
| if (badge) { | |
| badge.classList.remove('hidden'); | |
| badge.title = localDirHandle.name; | |
| } | |
| if (localDirTree.length === 0) { | |
| container.innerHTML = '<div class="text-center text-gray-400 p-4 text-sm">No SimpleAudit result files found in this folder.</div>'; | |
| return; | |
| } | |
| renderFileTree(localDirTree, container); | |
| } | |
| async function loadLocalFile(path) { | |
| // Walk the directory handle to find the file | |
| const parts = path.split('/'); | |
| let handle = localDirHandle; | |
| for (let i = 0; i < parts.length - 1; i++) { | |
| handle = await handle.getDirectoryHandle(parts[i]); | |
| } | |
| const file = await handle.getFile(parts[parts.length - 1]); | |
| const text = await file.text(); | |
| try { | |
| const data = JSON.parse(text); | |
| currentFilePath = null; | |
| currentExperimentSelection = null; | |
| uploadedExperiment = null; | |
| window.__customUpload = true; | |
| resetMultiRunState(); | |
| // Experiment file: show model picker | |
| const models = localExperimentModels(data); | |
| if (models.length > 0) { | |
| const runLabel = models.length > 1 ? '' : ''; | |
| const runs = data.runs[models[0]]; | |
| const runCount = Array.isArray(runs) ? runs.length : 1; | |
| currentExperimentRuns = Array.isArray(runs) ? { model: models[0], runs } : null; | |
| currentRunIndex = 0; | |
| currentFragility = Array.isArray(runs) ? computeFragility(runs) : null; | |
| processData(Array.isArray(runs) ? runs[0] : runs, file.name + ' — ' + models[0] + (runCount > 1 ? ' — run 1 of ' + runCount : ''), true); | |
| } else { | |
| processData(data, file.name + ' (local)', true); | |
| } | |
| // Highlight in tree | |
| document.querySelectorAll('.tree-item').forEach(el => el.classList.remove('bg-accent/15', 'ring-1', 'ring-accent/40')); | |
| const el = document.querySelector('[data-local-path="' + CSS.escape(path) + '"]'); | |
| if (el) el.classList.add('bg-accent/15', 'ring-1', 'ring-accent/40'); | |
| } catch (e) { | |
| alert('Error parsing JSON: ' + e.message); | |
| } | |
| } | |
| async function loadJsonFile(path, filename) { | |
| currentFilePath = path; | |
| currentExperimentSelection = null; | |
| // Reset custom upload flag so Copy URL is visible | |
| window.__customUpload = false; | |
| uploadedExperiment = null; | |
| // Update URL with ?case=... so Copy URL works immediately | |
| if (history.replaceState) { | |
| const url = new URL(window.location); | |
| // URLSearchParams.set() handles encoding automatically | |
| url.searchParams.set('case', path); | |
| url.hash = ''; | |
| history.replaceState({case: path}, '', url); | |
| } | |
| // Close mobile sidebar when file is selected | |
| if (window.innerWidth < 768) { | |
| closeMobileSidebar(); | |
| } | |
| // Update file tree to show selection and expand parent folders | |
| await loadFileTree(); | |
| // Scroll selected file into view | |
| setTimeout(() => { | |
| const selectedFile = document.querySelector('.tree-item[class*="bg-accent"]'); | |
| if (selectedFile) { | |
| selectedFile.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| }, 100); | |
| // Show loading state | |
| document.getElementById('file-info').textContent = `Loading ${filename}...`; | |
| document.getElementById('scenario-list').innerHTML = '<div class="text-center text-gray-400 mt-10"><div class="animate-spin h-8 w-8 border-4 border-accent/40 border-t-transparent rounded-full mx-auto mb-2"></div>Loading...</div>'; | |
| try { | |
| const response = await apiFetch(`/api/json/${encodeURIComponent(path)}`); | |
| if (!response.ok) throw new Error('Failed to load JSON file'); | |
| const data = await response.json(); | |
| resetMultiRunState(); | |
| processData(data, filename); | |
| } catch (error) { | |
| document.getElementById('file-info').textContent = `Error: ${error.message}`; | |
| document.getElementById('scenario-list').innerHTML = `<div class="text-center text-red-400 p-4">Error loading file: ${escapeHtml(error.message)}</div>`; | |
| } | |
| } | |
| // --- Data Processing --- | |
| // Model labels in an experiment file that have at least one loadable | |
| // run (an object with a non-empty results array). Mirrors the server's | |
| // classification so uploaded files behave like served ones. | |
| function experimentModels(data) { | |
| if (!data || typeof data !== 'object' || Array.isArray(data)) return []; | |
| const runs = data.runs; | |
| if (!runs || typeof runs !== 'object' || Array.isArray(runs)) return []; | |
| return Object.keys(runs).filter(model => { | |
| const entries = Array.isArray(runs[model]) ? runs[model] : [runs[model]]; | |
| return entries.some(entry => entry && typeof entry === 'object' | |
| && Array.isArray(entry.results) && entry.results.length > 0); | |
| }); | |
| } | |
| function appendExperimentToTree(data, filename) { | |
| const key = `upload::${filename}`; | |
| const folderDiv = renderExperimentFolder(filename, key, experimentModels(data), (model) => { | |
| try { | |
| const runs = data.runs[model]; | |
| const runLabel = Array.isArray(runs) && runs.length > 1 ? ` \u2014 run 1 of ${runs.length}` : ''; | |
| currentExperimentRuns = Array.isArray(runs) ? { model, runs } : null; | |
| currentRunIndex = 0; | |
| currentFragility = Array.isArray(runs) ? computeFragility(runs) : null; | |
| processData(Array.isArray(runs) ? runs[0] : runs, `${filename} \u2014 ${model}${runLabel}`, true); | |
| } catch (e) { | |
| document.getElementById('file-info').textContent = `Error: ${e.message}`; | |
| } | |
| }, expandedFolders.has(key)); | |
| document.getElementById('file-tree').appendChild(folderDiv); | |
| } | |
| function selectRun(runIndex) { | |
| if (!currentExperimentRuns || !currentExperimentRuns.runs[runIndex]) return; | |
| const { model, runs } = currentExperimentRuns; | |
| currentRunIndex = runIndex; | |
| const runLabel = runs.length > 1 ? ` \u2014 run ${runIndex + 1} of ${runs.length}` : ''; | |
| processData(runs[runIndex], `${model}${runLabel}`, true); | |
| } | |
| // Compute per-scenario fragility from the full run list. | |
| // Mirrors the Python _build_stability_report logic. | |
| function computeFragility(runs) { | |
| if (!runs || runs.length < 2) return null; | |
| const SEV_ORDER = ['pass', 'low', 'medium', 'high', 'critical']; | |
| const scenarioSevs = {}; // name -> [severities] | |
| runs.forEach(run => { | |
| const results = run.results || []; | |
| results.forEach(r => { | |
| const name = r.scenario_name || r.name; | |
| if (!name) return; | |
| if (!scenarioSevs[name]) scenarioSevs[name] = []; | |
| scenarioSevs[name].push(r.severity); | |
| }); | |
| }); | |
| const fragility = {}; | |
| Object.entries(scenarioSevs).forEach(([name, sevs]) => { | |
| const counts = {}; | |
| sevs.forEach(s => { counts[s] = (counts[s] || 0) + 1; }); | |
| const mode = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; | |
| const agreement = counts[mode] / sevs.length; | |
| // Normalised Shannon entropy | |
| const total = sevs.length; | |
| const uniqueSevs = Object.keys(counts); | |
| let rawEntropy = 0; | |
| uniqueSevs.forEach(s => { | |
| const p = counts[s] / total; | |
| rawEntropy -= p * Math.log(p); | |
| }); | |
| const logBase = uniqueSevs.length > 1 ? Math.log(uniqueSevs.length) : 1; | |
| const entropy = logBase > 0 ? rawEntropy / logBase : 0; | |
| // Ordinal spread | |
| const positions = sevs.map(s => { | |
| const idx = SEV_ORDER.indexOf(s); | |
| return idx >= 0 ? idx : 0; | |
| }); | |
| const mean = positions.reduce((a, b) => a + b, 0) / positions.length; | |
| const variance = positions.reduce((a, b) => a + (b - mean) ** 2, 0) / positions.length; | |
| const spread = Math.sqrt(variance); | |
| fragility[name] = { agreement, entropy, spread, mode }; | |
| }); | |
| return fragility; | |
| } | |
| function resetMultiRunState() { | |
| currentFragility = null; | |
| currentExperimentRuns = null; | |
| currentRunIndex = 0; | |
| } | |
| function processData(data, filename, isCustomUpload = false) { | |
| // Remember the last processed payload so "Download HTML" can | |
| // re-embed it in a standalone export. | |
| window.__lastProcessedData = data; | |
| window.__lastProcessedName = filename; | |
| // Handle different potential JSON structures | |
| if (Array.isArray(data)) { | |
| allScenarios = data; | |
| } else if (data.results && Array.isArray(data.results)) { | |
| allScenarios = data.results; | |
| } else if (experimentModels(data).length > 0) { | |
| // Multi-model results from AuditExperiment — show models in the sidebar | |
| if (isCustomUpload) { | |
| uploadedExperiment = { data, filename }; | |
| expandedFolders.add(`upload::${filename}`); | |
| // In standalone exports the file tree sidebar is removed, | |
| // so skip appending to it. | |
| if (!window.__inlinedData) { | |
| appendExperimentToTree(data, filename); | |
| } | |
| } else if (currentFilePath) { | |
| // Server experiment opened directly (e.g. via a ?case= link): | |
| // the file tree already lists it, so just expand its folder | |
| // instead of appending a duplicate. | |
| expandedFolders.add(currentFilePath); | |
| loadFileTree(); | |
| } | |
| // Show a prompt to select a model | |
| const fileInfo = document.getElementById('file-info'); | |
| if (fileInfo) fileInfo.textContent = `${filename} — select a model`; | |
| const scenarioList = document.getElementById('scenario-list'); | |
| if (scenarioList) scenarioList.innerHTML = `<div class="text-center text-gray-400 mt-10 p-4">Select a model from the sidebar to view its results.</div>`; | |
| return; | |
| } else { | |
| const fileInfo = document.getElementById('file-info'); | |
| if (fileInfo) fileInfo.textContent = `Error: Invalid format`; | |
| const scenarioList = document.getElementById('scenario-list'); | |
| if (scenarioList) scenarioList.innerHTML = ` | |
| <div class="text-center text-red-400 p-4 m-4"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| </svg> | |
| <p class="font-semibold mb-2">Invalid JSON Format</p> | |
| <p class="text-sm text-gray-400">Expected a list of results, an object with a "results" array, or an AuditExperiment file with a "runs" object.</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Global score from summary if available | |
| let globalScore = null; | |
| if (data.summary && data.summary.score !== undefined) { | |
| globalScore = data.summary.score; | |
| } | |
| // Assign IDs and normalized fields if missing | |
| allScenarios.forEach((s, i) => { | |
| if (!s.id) s.id = `scenario-${i}`; | |
| // History normalization | |
| if (!s.history && s.conversation) { | |
| s.history = s.conversation; | |
| } | |
| if (isCustomSchema(s)) { | |
| // Custom judge schema — pick the first numeric judgment field as the display score | |
| if (s.score === undefined) { | |
| const numVal = Object.values(s.judgment).find(v => typeof v === 'number'); | |
| if (numVal !== undefined) s.score = numVal; | |
| } | |
| if (!s.severity) s.severity = 'custom'; | |
| } else { | |
| // Default schema normalization | |
| if (!s.severity) { | |
| if (s.score === 10) s.severity = 'pass'; | |
| else if (s.issues_found && s.issues_found.length > 0) s.severity = 'medium'; | |
| else s.severity = 'medium'; | |
| } | |
| if (s.score === undefined && s.severity) { | |
| s.score = SEVERITY_SCORES_MAP[s.severity.toLowerCase()] || 0; | |
| } | |
| } | |
| // Expected behavior normalization – keep as array | |
| if (s.expected_behavior) { | |
| if (typeof s.expected_behavior === 'string') { | |
| s.expected_behavior = [s.expected_behavior]; | |
| } | |
| } else { | |
| s.expected_behavior = []; | |
| } | |
| }); | |
| const fileInfo = document.getElementById('file-info'); | |
| if (fileInfo) fileInfo.textContent = allScenarios.length === 1 ? filename : `${filename} • ${allScenarios.length} scenarios`; | |
| const dashboard = document.getElementById('dashboard'); | |
| if (dashboard) dashboard.classList.remove('hidden'); | |
| const filters = document.getElementById('filters'); | |
| if (filters) filters.classList.remove('hidden'); | |
| updateStats(globalScore); | |
| handleFilter(); | |
| // Always select the first scenario after file load or upload | |
| if (allScenarios.length > 0) { | |
| selectedScenarioId = allScenarios[0].id; | |
| renderList(); | |
| // Pass custom upload flag to renderDetail | |
| renderDetail(allScenarios[0], isCustomUpload); | |
| } | |
| } | |
| // --- Stats & Dashboard --- | |
| function updateStats(globalScore) { | |
| // In standalone 'scenario' mode the dashboard is removed from the | |
| // HTML, so skip stats rendering. | |
| if (!document.getElementById('severity-bar')) return; | |
| const total = allScenarios.length; | |
| const fileIsCustom = total > 0 && isCustomSchema(allScenarios[0]); | |
| const scores = allScenarios.map(s => s.score).filter(s => s !== undefined); | |
| const totalScore = scores.reduce((sum, s) => sum + s, 0); | |
| const avgScore = scores.length === 0 ? 'N/A' : (totalScore / scores.length).toFixed(1); | |
| const barContainer = document.getElementById('severity-bar'); | |
| const legendContainer = document.getElementById('severity-legend'); | |
| barContainer.innerHTML = ''; | |
| legendContainer.innerHTML = ''; | |
| if (fileIsCustom) { | |
| // Custom schema: show avg score, hide pass rate, note custom schema in bar area | |
| document.getElementById('stat-pass-rate').textContent = avgScore; | |
| document.getElementById('stat-pass-rate').previousElementSibling.textContent = 'Avg Score'; | |
| document.getElementById('stat-avg-score').textContent = `${total}`; | |
| document.getElementById('stat-avg-score').previousElementSibling.textContent = 'Scenarios'; | |
| document.getElementById('stat-total-cases').textContent = `${total} cases`; | |
| legendContainer.innerHTML = `<span class="text-xs text-purple-500 italic">Custom judge schema — see individual results for full output</span>`; | |
| } else { | |
| // Default schema: pass rate + severity bar | |
| const passed = allScenarios.filter(s => s.severity === 'pass' || s.score === 10).length; | |
| const passRate = total === 0 ? 0 : Math.round((passed / total) * 100); | |
| document.getElementById('stat-pass-rate').textContent = `${passRate}%`; | |
| document.getElementById('stat-pass-rate').previousElementSibling.textContent = 'Pass Rate'; | |
| if (globalScore !== null && globalScore !== undefined) { | |
| // summary.score is on a 0-100 scale; label it as such | |
| document.getElementById('stat-avg-score').textContent = `${Math.round(globalScore * 100) / 100}/100`; | |
| document.getElementById('stat-avg-score').previousElementSibling.textContent = 'Safety Score'; | |
| } else { | |
| document.getElementById('stat-avg-score').textContent = avgScore === 'N/A' ? 'N/A' : `${avgScore}/10`; | |
| document.getElementById('stat-avg-score').previousElementSibling.textContent = 'Avg Score'; | |
| } | |
| document.getElementById('stat-total-cases').textContent = `${total} cases`; | |
| const counts = { 'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'pass': 0, 'ERROR': 0 }; | |
| allScenarios.forEach(s => { | |
| let sev = (s.severity || 'low').toLowerCase(); | |
| if (sev === 'error') sev = 'ERROR'; | |
| if (counts[sev] !== undefined) counts[sev]++; | |
| }); | |
| Object.keys(counts).forEach(sev => { | |
| const count = counts[sev]; | |
| if (count > 0) { | |
| const pct = (count / total) * 100; | |
| const segment = document.createElement('div'); | |
| segment.className = `${SEVERITY_COLORS[sev]} h-full transition-all duration-500`; | |
| segment.style.width = `${pct}%`; | |
| segment.title = `${sev}: ${count}`; | |
| barContainer.appendChild(segment); | |
| const item = document.createElement('div'); | |
| item.className = "flex items-center gap-1 text-xs bg-surface-raised px-2 py-1 rounded border border-gray-800 shadow-sm"; | |
| item.innerHTML = `<span class="w-2 h-2 rounded-full ${SEVERITY_COLORS[sev]}"></span> <span class="capitalize">${sev}</span> <span class="font-bold text-gray-300">${count}</span>`; | |
| legendContainer.appendChild(item); | |
| } | |
| }); | |
| } | |
| } | |
| // --- Filtering and List Rendering --- | |
| function handleFilter() { | |
| const searchInput = document.getElementById('search-input'); | |
| const sortSelect = document.getElementById('sort-select'); | |
| if (!searchInput || !sortSelect) return; | |
| const query = searchInput.value.toLowerCase(); | |
| const sortMode = sortSelect.value; | |
| currentScenarios = allScenarios.filter(s => { | |
| const name = (s.scenario_name || s.name || '').toLowerCase(); | |
| const desc = (s.scenario_description || s.description || '').toLowerCase(); | |
| return name.includes(query) || desc.includes(query); | |
| }); | |
| currentScenarios.sort((a, b) => { | |
| if (sortMode === 'score-asc') return (a.score || 0) - (b.score || 0); | |
| if (sortMode === 'score-desc') return (b.score || 0) - (a.score || 0); | |
| if (sortMode === 'severity') { | |
| return SEVERITY_ORDER[a.severity || 'low'] - SEVERITY_ORDER[b.severity || 'low']; | |
| } | |
| return 0; | |
| }); | |
| renderList(); | |
| } | |
| function updateRunSelectorBar() { | |
| const bar = document.getElementById('run-selector-bar'); | |
| const count = document.getElementById('run-selector-count'); | |
| const prevBtn = document.getElementById('run-prev-btn'); | |
| const nextBtn = document.getElementById('run-next-btn'); | |
| if (!bar || !count) return; | |
| if (currentExperimentRuns && currentExperimentRuns.runs.length > 1) { | |
| bar.classList.remove('hidden'); | |
| const total = currentExperimentRuns.runs.length; | |
| const idx = currentRunIndex; | |
| count.textContent = `${idx + 1} of ${total}`; | |
| if (prevBtn) prevBtn.disabled = idx <= 0; | |
| if (nextBtn) nextBtn.disabled = idx >= total - 1; | |
| } else { | |
| bar.classList.add('hidden'); | |
| } | |
| } | |
| function renderList() { | |
| const container = document.getElementById('scenario-list'); | |
| if (!container) return; | |
| container.innerHTML = ''; | |
| updateRunSelectorBar(); | |
| if (currentScenarios.length === 0) { | |
| container.innerHTML = '<div class="text-center text-gray-400 mt-10">No matching scenarios found.</div>'; | |
| return; | |
| } | |
| currentScenarios.forEach(s => { | |
| const el = document.createElement('div'); | |
| const isSelected = selectedScenarioId === s.id; | |
| const statusColor = SEVERITY_COLORS[s.severity || 'low']; | |
| const textColor = SEVERITY_TEXT_COLORS[s.severity || 'low']; | |
| el.className = `p-3 rounded-lg border cursor-pointer hover:bg-surface transition-colors flex gap-3 ${isSelected ? 'border-accent/40 ring-1 ring-accent/40 bg-accent/10' : 'border-gray-800 bg-surface-raised'}`; | |
| el.onclick = () => selectScenario(s); | |
| const scoreLabel = s.score !== undefined | |
| ? (isCustomSchema(s) ? String(s.score) : s.score + '/10') | |
| : ''; | |
| el.innerHTML = ` | |
| <div class="w-1.5 self-stretch rounded-full ${statusColor} shrink-0"></div> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex justify-between items-start"> | |
| <h3 class="font-medium text-gray-100 truncate text-sm" title="${escapeHtml(s.scenario_name || s.name || '')}">${escapeHtml(s.scenario_name || s.name || 'Untitled Scenario')}</h3> | |
| <div class="flex items-center gap-1 shrink-0"> | |
| <button onclick="event.stopPropagation(); downloadScenarioHtml('${s.id}')" class="text-gray-400 hover:text-accent transition-colors p-1" title="Download this scenario as standalone HTML"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> | |
| </svg> | |
| </button> | |
| <span class="text-xs font-bold ${textColor}">${escapeHtml(scoreLabel)}</span> | |
| </div> | |
| </div> | |
| <p class="text-xs text-gray-400 mt-1 line-clamp-2">${escapeHtml(s.scenario_description || s.description || 'No description')}</p> | |
| </div> | |
| `; | |
| container.appendChild(el); | |
| }); | |
| } | |
| // --- Detail Rendering --- | |
| function selectScenario(scenario) { | |
| selectedScenarioId = scenario.id; | |
| renderList(); | |
| renderDetail(scenario); | |
| // Update URL with ?case=...#scenario-id | |
| if (history.pushState) { | |
| const url = new URL(window.location); | |
| if (currentFilePath) { | |
| // URLSearchParams.set() handles encoding automatically | |
| url.searchParams.set('case', currentFilePath); | |
| } else { | |
| // No file path (experiment model view or upload) — drop any | |
| // stale reference to a previously viewed file so the URL | |
| // never points at the wrong data. | |
| url.searchParams.delete('case'); | |
| } | |
| url.hash = `#${encodeURIComponent(scenario.id)}`; | |
| window.history.pushState({scenarioId: scenario.id, case: currentFilePath}, '', url); | |
| } else { | |
| let hash = `#${encodeURIComponent(scenario.id)}`; | |
| if (currentFilePath) { | |
| window.location.search = `?case=${encodeURIComponent(currentFilePath)}`; | |
| } | |
| window.location.hash = hash; | |
| } | |
| // Mobile: Show detail view full screen | |
| if (window.innerWidth < 768) { | |
| document.getElementById('detail-view').classList.add('has-selection'); | |
| const listContainer = document.getElementById('scenario-list-container'); | |
| if (listContainer) listContainer.classList.add('has-detail'); | |
| } | |
| } | |
| function closeDetailView() { | |
| // Mobile: Go back to list view | |
| document.getElementById('detail-view').classList.remove('has-selection'); | |
| const listContainer = document.getElementById('scenario-list-container'); | |
| if (listContainer) listContainer.classList.remove('has-detail'); | |
| selectedScenarioId = null; | |
| // Reset detail view | |
| const container = document.getElementById('detail-view'); | |
| container.innerHTML = ` | |
| <div class="h-full flex flex-col items-center justify-center text-gray-400"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" | |
| viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> | |
| </svg> | |
| <p class="text-lg">Select a scenario to view details</p> | |
| </div> | |
| `; | |
| } | |
| function renderDetail(s, forceCustomUpload = false) { | |
| const container = document.getElementById('detail-view'); | |
| const statusColor = SEVERITY_COLORS[s.severity || 'low']; | |
| const textColor = SEVERITY_TEXT_COLORS[s.severity || 'low']; | |
| const name = s.scenario_name || s.name || 'Untitled'; | |
| const desc = s.scenario_description || s.description || ''; | |
| const score = s.score; | |
| const scoreLabel = score !== undefined | |
| ? (isCustomSchema(s) ? String(score) : score + '/10') | |
| : 'N/A'; | |
| const severity = s.severity || 'unknown'; | |
| const history = s.history || []; | |
| // Attached file(s) sent with the first prompt. The path lives on a | |
| // conversation entry (usually turn 0); fall back to a scenario-level | |
| // field. May be a single string or a list. | |
| const fileUris = fileUrisFromHistory(history, s); | |
| // In the standalone offline viewer there is no backend to load local | |
| // files, so only remote URLs can be previewed there. | |
| const standalone = (window.__customUpload || forceCustomUpload) === true; | |
| // Fragility tooltip for the Score element (multi-run experiments) | |
| let scoreHtml = ''; | |
| if (currentFragility && name && currentFragility[name]) { | |
| const f = currentFragility[name]; | |
| const isFragile = f.agreement < 0.6; | |
| const icon = isFragile ? '\u26a0\ufe0f' : '\u2705'; | |
| const label = isFragile ? 'Fragile' : 'Stable'; | |
| const cssClass = isFragile ? 'tooltip-fragile' : 'tooltip-stable'; | |
| scoreHtml = `<span class="fragility-tooltip" data-tooltip><span class="text-sm text-gray-400">Score: <strong class="${textColor}">${escapeHtml(scoreLabel)}</strong></span><span class="tooltip-content ${cssClass}"><span class="tooltip-row"><span>${icon}</span><span class="tooltip-label">${label}</span></span><span class="tooltip-row"><span>agreement ${(f.agreement * 100).toFixed(0)}%</span><span>\u00b7</span><span>entropy ${f.entropy.toFixed(2)}</span><span>\u00b7</span><span>spread ${f.spread.toFixed(2)}</span></span><span class="tooltip-row"><span>mode: ${f.mode}</span></span></span></span>`; | |
| } else { | |
| scoreHtml = `<span class="text-sm text-gray-400">Score: <strong class="${textColor}">${escapeHtml(scoreLabel)}</strong></span>`; | |
| } | |
| let html = ` | |
| <div class="max-w-4xl mx-auto space-y-6 pb-20" id="scenario-detail"> | |
| <!-- Mobile Back Button --> | |
| <button onclick="closeDetailView()" class="back-button flex items-center gap-2 text-accent hover:text-accent font-medium mb-4"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> | |
| </svg> | |
| Back to list | |
| </button> | |
| <div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 p-6"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <div> | |
| <h2 class="text-2xl font-bold text-gray-100 leading-tight">${escapeHtml(name)}</h2> | |
| <div class="flex items-center gap-3 mt-2 flex-wrap"> | |
| <span class="px-2.5 py-0.5 rounded-full text-xs font-medium uppercase tracking-wide ${statusColor} text-white bg-opacity-90"> | |
| ${escapeHtml(severity)} | |
| </span> | |
| ${scoreHtml} | |
| </div> | |
| </div> | |
| <button onclick="exportScenarioPdf()" title="Export this scenario as PDF" class="shrink-0 p-2 text-gray-400 hover:text-accent hover:bg-accent/10 rounded-lg transition-colors"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg> | |
| </button> | |
| </div> | |
| <p class="text-gray-400 leading-relaxed">${escapeHtml(desc)}</p> | |
| ${fileUris.length ? ` | |
| <div class="mt-4 pt-4 border-t border-gray-800"> | |
| <div class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Attached file${fileUris.length > 1 ? 's' : ''}</div> | |
| <div class="space-y-1"> | |
| ${fileUris.map(u => ` | |
| <div class="flex items-center gap-2 text-xs text-gray-400"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg> | |
| <code class="font-mono text-gray-400 break-all" title="${escapeHtml(u)}">${escapeHtml(u)}</code> | |
| </div>`).join('')} | |
| </div> | |
| </div>` : ''} | |
| </div> | |
| `; | |
| // show expected behavior if available | |
| if (s.expected_behavior && s.expected_behavior.length > 0) { | |
| html += ` | |
| <div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 p-6"> | |
| <h3 class="font-semibold text-gray-300 mb-2">Expected Behavior</h3> | |
| <ul class="list-disc list-inside text-gray-400"> | |
| ${s.expected_behavior.map(b => `<li>${escapeHtml(b)}</li>`).join('')} | |
| </ul> | |
| </div> | |
| `; | |
| } | |
| // Only show Copy URL if not a custom upload and not a standalone export | |
| const showCopyUrl = !(window.__customUpload || forceCustomUpload || window.__inlinedData); | |
| if (history && history.length > 0) { | |
| html += `<div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 overflow-hidden"> | |
| <div class="bg-surface px-6 py-3 border-b border-gray-800 flex items-center justify-between"> | |
| <h3 class="font-semibold text-gray-300">Conversation History</h3> | |
| ${showCopyUrl ? `<button onclick=\"copyCurrentUrl()\" title=\"Copy link to this scenario\" class=\"ml-2 px-2 py-1 text-xs bg-accent/15 hover:bg-accent/25 text-accent rounded transition-colors border border-accent/30 flex items-center gap-1\"><svg xmlns='http://www.w3.org/2000/svg' class='h-4 w-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 16h8a2 2 0 002-2V8a2 2 0 00-2-2H8a2 2 0 00-2 2v6a2 2 0 002 2zm0 0v2a2 2 0 002 2h4a2 2 0 002-2v-2' /></svg>Copy URL</button>` : ''} | |
| </div> | |
| <div class="divide-y divide-gray-800">`; | |
| history.forEach((turn, idx) => { | |
| const role = (turn.role || 'unknown').toUpperCase(); | |
| const content = turn.content || ''; | |
| const isUser = role === 'USER' || role === 'HUMAN'; | |
| const turnUris = turn.file_uri ? (Array.isArray(turn.file_uri) ? turn.file_uri : [turn.file_uri]) : []; | |
| const contentBlock = `<div class="prose prose-sm max-w-none text-gray-300 whitespace-pre-wrap font-mono text-xs bg-surface-overlay/50 p-2 rounded border border-gray-800">${escapeHtml(content)}</div>`; | |
| html += ` | |
| <div class="p-6 ${isUser ? 'bg-surface-raised' : 'bg-surface'}"> | |
| <div class="flex gap-4"> | |
| <div class="shrink-0 mt-1"> | |
| <span class="inline-flex items-center justify-center h-8 w-8 rounded-full ${isUser ? 'bg-accent/15 text-accent' : 'bg-emerald-500/20 text-emerald-400'} font-bold text-xs ring-4 ring-surface-raised"> | |
| ${isUser ? 'U' : 'AI'} | |
| </span> | |
| </div> | |
| <div class="flex-1 space-y-1"> | |
| <div class="flex items-center justify-between"> | |
| <h4 class="text-sm font-semibold text-gray-100">${escapeHtml(role)}</h4> | |
| <span class="text-xs text-gray-400">Turn ${idx + 1}</span> | |
| </div> | |
| ${contentBlock} | |
| ${turnUris.length ? `<div class="flex flex-wrap gap-3 mt-2">${turnUris.map(u => attachmentPreview(u, standalone)).join('')}</div>` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| html += ` </div> | |
| </div>`; | |
| } else { | |
| html += `<div class="p-6 text-center text-gray-400 italic">No conversation history data found.</div>`; | |
| } | |
| // One judge card for both default and custom judges. judgeFields() | |
| // yields the label/value pairs; renderJudgmentValue() renders each | |
| // value by type (arrays -> bullet lists, strings -> wrapped text). | |
| const judgeEntries = judgeFields(s) | |
| .map(([key, value]) => ` | |
| <div class="border-b border-gray-800 pb-4 last:border-0 last:pb-0"> | |
| <div class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">${escapeHtml(key)}</div> | |
| <div class="text-sm text-gray-200">${renderJudgmentValue(value)}</div> | |
| </div> | |
| `).join(''); | |
| html += ` | |
| <div class="bg-surface-raised rounded-xl shadow-sm border border-gray-800 overflow-hidden"> | |
| <div class="bg-accent/10 px-6 py-3 border-b border-accent/20 flex items-center gap-2"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" /> | |
| </svg> | |
| <h3 class="font-semibold text-accent">Judgment</h3> | |
| </div> | |
| <div class="p-6 space-y-4">${judgeEntries}</div> | |
| </div> | |
| `; | |
| html += `</div>`; | |
| container.innerHTML = html; | |
| populateFilePreviews(); | |
| } | |
| function escapeHtml(unsafe) { | |
| return String(unsafe ?? "") | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| } | |
| // --- attached-file preview helpers ------------------------------- | |
| function fileUrisFromHistory(history, s) { | |
| const entry = (history || []).find(t => t && t.file_uri); | |
| const raw = entry ? entry.file_uri : (s && s.file_uri); | |
| if (!raw) return []; | |
| return Array.isArray(raw) ? raw : [raw]; | |
| } | |
| function isRemoteUri(uri) { | |
| return /^https?:\/\//i.test(String(uri)); | |
| } | |
| function isImageUri(uri) { | |
| const path = String(uri).split('?')[0].split('#')[0]; | |
| return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(path); | |
| } | |
| function fileBaseName(uri) { | |
| const path = String(uri).split('?')[0].split('#')[0]; | |
| const parts = path.split('/'); | |
| return parts[parts.length - 1] || String(uri); | |
| } | |
| // A generic file card (icon + name) for non-images and previews that | |
| // cannot be loaded (e.g. local paths in the offline viewer). | |
| function fileIconBlock(uri, note) { | |
| return ` | |
| <div class="flex items-center gap-2 p-2 rounded border border-gray-800 bg-surface-raised max-w-[16rem]"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> | |
| <div class="min-w-0"> | |
| <div class="text-xs font-mono text-gray-400 truncate" title="${escapeHtml(uri)}">${escapeHtml(fileBaseName(uri))}</div> | |
| ${note ? `<div class="text-[10px] text-gray-400">${escapeHtml(note)}</div>` : ''} | |
| </div> | |
| </div>`; | |
| } | |
| function imagePreviewMarkup(uri, src) { | |
| const srcAttr = src ? `src="${escapeHtml(src)}"` : `data-load-uri="${escapeHtml(uri)}"`; | |
| return ` | |
| <div> | |
| <img ${srcAttr} data-orig-uri="${escapeHtml(uri)}" alt="attached image" | |
| onclick="openLightbox(this.src)" onerror="filePreviewError(this)" | |
| class="file-preview-img max-h-40 rounded border border-gray-800 cursor-pointer bg-surface-raised" /> | |
| <div class="text-[10px] text-gray-400 font-mono truncate mt-1 max-w-[16rem]" title="${escapeHtml(uri)}">${escapeHtml(fileBaseName(uri))}</div> | |
| </div>`; | |
| } | |
| // Decide the preview for one attachment: image thumbnail, or a file icon | |
| // for non-images / un-previewable local files. | |
| function attachmentPreview(uri, standalone) { | |
| if (!isImageUri(uri)) { | |
| return fileIconBlock(uri); | |
| } | |
| if (isRemoteUri(uri)) { | |
| return imagePreviewMarkup(uri, uri); | |
| } | |
| if (standalone) { | |
| return fileIconBlock(uri, 'Local image — preview unavailable offline'); | |
| } | |
| // Server mode, local path: load bytes lazily via the backend. | |
| return imagePreviewMarkup(uri, null); | |
| } | |
| // Load thumbnails that need the backend (local paths), fetching through | |
| // apiFetch so the X-Secret auth header is carried. | |
| async function populateFilePreviews() { | |
| const imgs = document.querySelectorAll('img.file-preview-img[data-load-uri]'); | |
| for (const img of imgs) { | |
| const uri = img.getAttribute('data-load-uri'); | |
| img.removeAttribute('data-load-uri'); | |
| try { | |
| const resp = await apiFetch('/api/image?uri=' + encodeURIComponent(uri)); | |
| if (!resp.ok) throw new Error('image load failed'); | |
| const data = await resp.json(); | |
| img.src = data.data_uri; | |
| } catch (e) { | |
| filePreviewError(img); | |
| } | |
| } | |
| } | |
| function filePreviewError(img) { | |
| const uri = img.getAttribute('data-orig-uri') || ''; | |
| const wrap = img.closest('div'); | |
| if (wrap) wrap.outerHTML = fileIconBlock(uri, 'Preview unavailable'); | |
| } | |
| function openLightbox(src) { | |
| if (!src) return; | |
| const ov = document.getElementById('image-lightbox'); | |
| if (!ov) return; | |
| const img = ov.querySelector('img'); | |
| if (img) img.src = src; | |
| ov.classList.remove('hidden'); | |
| ov.classList.add('flex'); | |
| } | |
| function closeLightbox() { | |
| const ov = document.getElementById('image-lightbox'); | |
| if (!ov) return; | |
| ov.classList.add('hidden'); | |
| ov.classList.remove('flex'); | |
| const img = ov.querySelector('img'); | |
| if (img) img.src = ''; | |
| } | |
| // Footer HTML for PDF exports (prints on white paper, so light-theme colors). | |
| function printFooterHtml() { | |
| return `<div style="margin-top:2rem;padding-top:1rem;border-top:1px solid #e5e7eb;text-align:center;font-size:0.75rem;color:#6b7280;"> | |
| <a href="https://github.com/SimulaMet/SimpleAuditStudio" style="color:#6c8cff;text-decoration:none;">SimpleAudit Studio</a> | |
| </div>`; | |
| } | |
| // Export current scenario as PDF via print dialog | |
| function exportScenarioPdf() { | |
| const detail = document.getElementById('scenario-detail'); | |
| if (!detail) return; | |
| // Set document title to file name for proper PDF filename | |
| const origTitle = document.title; | |
| const baseName = currentFilePath ? currentFilePath.split('/').pop().replace(/\.json$/i, '') : 'scenario'; | |
| document.title = baseName; | |
| // Clone into a standalone print container | |
| const container = document.createElement('div'); | |
| container.id = 'print-all-scenarios'; | |
| container.innerHTML = detail.outerHTML + printFooterHtml(); | |
| document.body.appendChild(container); | |
| document.body.classList.add('print-all'); | |
| setTimeout(() => { | |
| window.print(); | |
| document.title = origTitle; | |
| document.body.classList.remove('print-all'); | |
| container.remove(); | |
| }, 200); | |
| } | |
| // Export an entire file (all scenarios) as PDF | |
| async function exportFilePdf(filePath) { | |
| // Load the file if not already loaded | |
| if (currentFilePath !== filePath) { | |
| const filename = filePath.split('/').pop(); | |
| await loadJsonFile(filePath, filename); | |
| await new Promise(r => setTimeout(r, 500)); | |
| } | |
| // Render each scenario into a hidden container and collect HTML | |
| const hiddenContainer = document.createElement('div'); | |
| hiddenContainer.style.display = 'none'; | |
| document.body.appendChild(hiddenContainer); | |
| const blocks = []; | |
| for (const s of currentScenarios) { | |
| // Temporarily point renderDetail at our hidden container | |
| const detailView = document.getElementById('detail-view'); | |
| const origParent = detailView.parentNode; | |
| const origNext = detailView.nextSibling; | |
| origParent.removeChild(detailView); | |
| hiddenContainer.appendChild(detailView); | |
| renderDetail(s, true); | |
| const detail = document.getElementById('scenario-detail'); | |
| if (detail) blocks.push(detail.outerHTML); | |
| // Move detail-view back | |
| hiddenContainer.removeChild(detailView); | |
| origParent.insertBefore(detailView, origNext); | |
| } | |
| hiddenContainer.remove(); | |
| // Build print container with all scenarios | |
| const container = document.createElement('div'); | |
| container.id = 'print-all-scenarios'; | |
| container.innerHTML = blocks.join('<hr class="my-8 border-gray-700">') + printFooterHtml(); | |
| document.body.appendChild(container); | |
| document.body.classList.add('print-all'); | |
| setTimeout(() => { | |
| window.print(); | |
| document.body.classList.remove('print-all'); | |
| container.remove(); | |
| }, 200); | |
| } | |
| // Copy current URL to clipboard | |
| function copyCurrentUrl() { | |
| const url = window.location.href; | |
| if (navigator.clipboard) { | |
| navigator.clipboard.writeText(url).then(() => { | |
| showCopyToast(); | |
| }); | |
| } else { | |
| // fallback | |
| const tempInput = document.createElement('input'); | |
| tempInput.value = url; | |
| document.body.appendChild(tempInput); | |
| tempInput.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(tempInput); | |
| showCopyToast(); | |
| } | |
| } | |
| // Show a toast message after copying | |
| function showCopyToast() { | |
| let toast = document.getElementById('copy-toast'); | |
| if (!toast) { | |
| toast = document.createElement('div'); | |
| toast.id = 'copy-toast'; | |
| toast.className = 'fixed bottom-6 right-6 bg-accent text-white px-4 py-2 rounded shadow-lg z-50 text-sm'; | |
| toast.textContent = 'Link copied!'; | |
| document.body.appendChild(toast); | |
| } | |
| toast.style.display = 'block'; | |
| setTimeout(() => { toast.style.display = 'none'; }, 1800); | |
| } | |
| </script> | |
| </main> | |
| <!-- VISUALIZER_FOOTER --> | |
| </div> | |
| <!-- VISUALIZER_SIDEBAR_JS --> | |
| </body> | |
| </html> | |