Skip to main content
Glama
dashboard.html53.2 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Browser Agent Dashboard</title> <script src="https://unpkg.com/htmx.org@2.0.4"></script> <style> :root { --bg-primary: #ffffff; --bg-secondary: #f6f8fa; --bg-tertiary: #f0f2f5; --text-primary: #24292f; --text-secondary: #57606a; --text-tertiary: #6e7781; --border-color: #d0d7de; --accent: #0969da; --status-running: #0969da; --status-completed: #1a7f37; --status-failed: #cf222e; --status-pending: #bf8700; --status-cancelled: #6e7781; --health-online: #1a7f37; --health-offline: #cf222e; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); } [data-theme="dark"] { --bg-primary: #0d1117; --bg-secondary: #161b22; --bg-tertiary: #1c2128; --text-primary: #e6edf3; --text-secondary: #9198a1; --text-tertiary: #7d8590; --border-color: #30363d; --accent: #4493f8; --status-running: #4493f8; --status-completed: #3fb950; --status-failed: #f85149; --status-pending: #d29922; --status-cancelled: #8b949e; --health-online: #3fb950; --health-offline: #f85149; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg-secondary); color: var(--text-primary); line-height: 1.5; min-height: 100vh; display: flex; flex-direction: column; } /* Header */ header { background: var(--bg-primary); border-bottom: 1px solid var(--border-color); padding: 16px 20px; box-shadow: var(--shadow-sm); } .header-content { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 20px; } .brand { font-size: 18px; font-weight: 600; color: var(--text-primary); } .health-status { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; } .status-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; animation: pulse 2s infinite; } .status-dot.online { background: var(--health-online); } .status-dot.offline { background: var(--health-offline); } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .health-info { display: flex; gap: 16px; font-size: 14px; color: var(--text-secondary); flex-wrap: wrap; } .health-info span { display: flex; align-items: center; gap: 6px; } .theme-toggle { background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 14px; color: var(--text-primary); transition: all 0.2s; flex-shrink: 0; } .theme-toggle:hover { background: var(--bg-secondary); } /* Navigation Tabs */ nav { background: var(--bg-primary); border-bottom: 1px solid var(--border-color); } .nav-container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } .tabs { display: flex; gap: 8px; list-style: none; } .tab { padding: 12px 20px; cursor: pointer; color: var(--text-secondary); font-weight: 500; border-bottom: 2px solid transparent; transition: all 0.2s; user-select: none; } .tab:hover { color: var(--text-primary); background: var(--bg-tertiary); } .tab.active { color: var(--accent); border-bottom-color: var(--accent); } /* Main Content */ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px; } .content-panel { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; min-height: 400px; box-shadow: var(--shadow-sm); } .panel-header { padding: 16px 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } .panel-title { font-weight: 600; font-size: 16px; } .panel-body { padding: 20px; } /* Loading State */ .loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: var(--text-secondary); } .spinner { display: inline-block; width: 24px; height: 24px; border: 3px solid var(--text-tertiary); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 12px; } @keyframes spin { to { transform: rotate(360deg); } } /* Empty State */ .empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); } .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; } /* Task List Styles */ .task-item { padding: 16px 20px; border-bottom: 1px solid var(--border-color); transition: background 0.2s; } .task-item:hover { background: var(--bg-secondary); } .task-item:last-child { border-bottom: none; } .task-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .task-main { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; } .task-id { font-family: "SF Mono", Monaco, monospace; font-size: 13px; color: var(--text-tertiary); flex-shrink: 0; } .task-tool { font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status-badge { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; color: white; text-transform: capitalize; } .status-badge.running { background: var(--status-running); } .status-badge.completed { background: var(--status-completed); } .status-badge.failed { background: var(--status-failed); } .status-badge.pending { background: var(--status-pending); } .status-badge.cancelled { background: var(--status-cancelled); } /* Skill Card */ .skill-card { padding: 16px; border: 1px solid var(--border-color); border-radius: 6px; margin-bottom: 12px; background: var(--bg-secondary); } .skill-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px; } .skill-name { font-weight: 600; color: var(--text-primary); } .skill-url { font-family: "SF Mono", Monaco, monospace; font-size: 12px; color: var(--text-secondary); margin-top: 4px; } .skill-actions { display: flex; gap: 8px; } .btn { padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 12px; cursor: pointer; transition: all 0.2s; } .btn:hover { background: var(--bg-tertiary); } .btn-danger { color: var(--status-failed); border-color: var(--status-failed); } .btn-danger:hover { background: var(--status-failed); color: white; } /* Skill Table */ .skill-table { width: 100%; border-collapse: collapse; } .skill-row { border-bottom: 1px solid var(--border-color); cursor: pointer; transition: background 0.2s; } .skill-row:hover { background: var(--bg-secondary); } .skill-row td { padding: 16px 20px; } .skill-row:last-child { border-bottom: none; } .skill-name-cell { font-weight: 500; color: var(--text-primary); } .skill-desc-cell { color: var(--text-secondary); font-size: 14px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .skill-stats { display: flex; gap: 16px; align-items: center; font-size: 13px; color: var(--text-tertiary); } .success-rate { color: var(--status-completed); font-weight: 500; } /* Modal */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; } .modal { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; max-width: 700px; width: 100%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-md); } .modal-header { padding: 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } .modal-title { font-size: 18px; font-weight: 600; color: var(--text-primary); } .modal-close { background: none; border: none; font-size: 24px; color: var(--text-secondary); cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.2s; } .modal-close:hover { background: var(--bg-tertiary); color: var(--text-primary); } .modal-body { padding: 20px; overflow-y: auto; flex: 1; } .modal-footer { padding: 16px 20px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; } .skill-detail-section { margin-bottom: 20px; } .skill-detail-section h3 { font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; margin-bottom: 8px; } .skill-detail-value { font-size: 14px; color: var(--text-primary); line-height: 1.6; } .skill-detail-code { background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; padding: 12px; font-family: "SF Mono", Monaco, monospace; font-size: 13px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; } .btn-primary { background: var(--accent); color: white; border: none; } .btn-primary:hover { opacity: 0.9; } .btn-secondary { background: transparent; border: 1px solid var(--border-color); } .btn-secondary:hover { background: var(--bg-tertiary); } /* Filter Controls */ .filter-container { padding: 20px; border-bottom: 1px solid var(--border-color); display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } .filter-group { display: flex; flex-direction: column; gap: 6px; } .filter-label { font-size: 12px; font-weight: 500; color: var(--text-secondary); text-transform: uppercase; } .filter-select { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; min-width: 140px; cursor: pointer; transition: all 0.2s; } .filter-select:hover { border-color: var(--accent); } .filter-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1); } .filter-actions { margin-left: auto; display: flex; gap: 8px; } /* Stats Summary */ .stats-summary { padding: 20px; border-bottom: 1px solid var(--border-color); display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; } .stat-card { padding: 12px 16px; background: var(--bg-secondary); border-radius: 6px; border: 1px solid var(--border-color); } .stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; text-transform: uppercase; font-weight: 500; } .stat-value { font-size: 24px; font-weight: 600; color: var(--text-primary); } .stat-subtext { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; } /* Pagination */ .pagination { padding: 16px 20px; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } .pagination-info { font-size: 14px; color: var(--text-secondary); } .pagination-controls { display: flex; gap: 8px; } .pagination-btn { padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; cursor: pointer; transition: all 0.2s; } .pagination-btn:hover:not(:disabled) { background: var(--bg-tertiary); border-color: var(--accent); } .pagination-btn:disabled { opacity: 0.5; cursor: not-allowed; } .pagination-btn.active { background: var(--accent); color: white; border-color: var(--accent); } /* Responsive */ @media (max-width: 768px) { .header-content { flex-wrap: wrap; } .health-info { width: 100%; order: 3; } main { padding: 12px; } .tabs { overflow-x: auto; } .tab { white-space: nowrap; } .filter-container { flex-direction: column; align-items: stretch; } .filter-actions { margin-left: 0; width: 100%; } .stats-summary { grid-template-columns: 1fr; } .pagination { flex-direction: column; gap: 12px; } } </style> </head> <body> <header> <div class="header-content"> <div class="brand">Browser Agent Dashboard</div> <div class="health-status"> <div class="status-dot" id="statusDot"></div> <strong id="statusText">Connecting...</strong> </div> <div class="health-info"> <span>Uptime: <strong id="uptime">-</strong></span> <span>Memory: <strong id="memory">-</strong></span> <span>Tasks: <strong id="runningCount">-</strong></span> </div> <button class="theme-toggle" id="themeToggle">🌙 Dark</button> </div> </header> <nav> <div class="nav-container"> <ul class="tabs"> <li class="tab active" hx-get="/api/tasks?limit=20" hx-target="#content" hx-trigger="click" onclick="setActiveTab(this)"> Tasks </li> <li class="tab" hx-get="/api/skills" hx-target="#content" hx-trigger="click" onclick="setActiveTab(this)"> Skills </li> <li class="tab" onclick="setActiveTab(this); loadHistory()"> History </li> </ul> </div> </nav> <main> <div id="content" class="content-panel"> <div class="loading"> <div class="spinner"></div> <p>Loading tasks...</p> </div> </div> </main> <script> const API_BASE = 'http://localhost:8383/api'; let healthCheckInterval = null; // Theme management function initTheme() { const saved = localStorage.getItem('theme'); const theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', theme); updateThemeButton(theme); } function toggleTheme() { const current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('theme', next); updateThemeButton(next); } function updateThemeButton(theme) { const btn = document.getElementById('themeToggle'); btn.textContent = theme === 'dark' ? '☀️ Light' : '🌙 Dark'; } // Tab management function setActiveTab(element) { document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); element.classList.add('active'); } // Health check async function checkHealth() { try { const response = await fetch(`${API_BASE}/health`); if (!response.ok) throw new Error('Health check failed'); const health = await response.json(); updateHealthDisplay(health); } catch (error) { console.error('Health check error:', error); showOfflineStatus(); } } function updateHealthDisplay(health) { const dot = document.getElementById('statusDot'); const text = document.getElementById('statusText'); const uptime = document.getElementById('uptime'); const memory = document.getElementById('memory'); const running = document.getElementById('runningCount'); dot.className = 'status-dot online'; text.textContent = 'Online'; const uptimeMin = Math.floor(health.uptime_seconds / 60); uptime.textContent = uptimeMin < 1 ? '< 1m' : `${uptimeMin}m`; memory.textContent = `${health.memory_mb} MB`; running.textContent = health.running_tasks; } function showOfflineStatus() { const dot = document.getElementById('statusDot'); const text = document.getElementById('statusText'); dot.className = 'status-dot offline'; text.textContent = 'Offline'; document.getElementById('uptime').textContent = '-'; document.getElementById('memory').textContent = '-'; document.getElementById('runningCount').textContent = '-'; } // HTMX event handlers document.body.addEventListener('htmx:beforeRequest', (event) => { const target = document.getElementById(event.detail.target.id); if (target) { target.innerHTML = '<div class="loading"><div class="spinner"></div><p>Loading...</p></div>'; } }); document.body.addEventListener('htmx:afterRequest', (event) => { if (event.detail.successful) { console.log('Content loaded successfully'); } else { const target = document.getElementById(event.detail.target.id); if (target) { target.innerHTML = ` <div class="empty-state"> <div class="empty-icon">⚠️</div> <p><strong>Failed to load content</strong></p> <p style="font-size: 14px; margin-top: 8px;">Ensure the server is running</p> </div> `; } } }); // Render tasks and skills from API response document.body.addEventListener('htmx:afterOnLoad', (event) => { if (event.detail.xhr.responseURL.includes('/api/tasks')) { try { const data = JSON.parse(event.detail.xhr.response); renderTasks(data); } catch (error) { console.error('Failed to parse tasks:', error); } } else if (event.detail.xhr.responseURL.includes('/api/skills') && !event.detail.xhr.responseURL.match(/\/api\/skills\/[^\/]+$/)) { try { const data = JSON.parse(event.detail.xhr.response); renderSkills(data); } catch (error) { console.error('Failed to parse skills:', error); } } }); function renderTasks(data) { const tasks = data.tasks || []; const content = document.getElementById('content'); if (tasks.length === 0) { content.innerHTML = ` <div class="panel-header"> <div class="panel-title">Recent Tasks</div> </div> <div class="empty-state"> <div class="empty-icon">📋</div> <p>No tasks yet</p> </div> `; return; } let html = '<div class="panel-header"><div class="panel-title">Recent Tasks</div></div>'; tasks.forEach(task => { const duration = task.duration_sec ? formatDuration(task.duration_sec) : '-'; html += ` <div class="task-item"> <div class="task-header"> <div class="task-main"> <span class="task-id">${escapeHtml(task.task_id)}</span> <span class="task-tool">${escapeHtml(task.tool)}</span> </div> <div style="display: flex; gap: 12px; align-items: center;"> <span class="status-badge ${task.status}">${task.status}</span> <span style="font-size: 13px; color: var(--text-tertiary);">${duration}</span> </div> </div> </div> `; }); content.innerHTML = html; } function renderSkills(data) { const skills = data.skills || []; const content = document.getElementById('content'); if (data.error) { content.innerHTML = ` <div class="panel-header"> <div class="panel-title">Browser Skills</div> </div> <div class="empty-state"> <div class="empty-icon">⚠️</div> <p><strong>${escapeHtml(data.error)}</strong></p> <p style="font-size: 14px; margin-top: 8px;">Skills feature may be disabled in config</p> </div> `; return; } if (skills.length === 0) { content.innerHTML = ` <div class="panel-header"> <div class="panel-title">Browser Skills</div> </div> <div class="empty-state"> <div class="empty-icon">🎯</div> <p>No skills learned yet</p> <p style="font-size: 14px; margin-top: 8px;">Skills are created automatically during browser automation</p> </div> `; return; } let html = '<div class="panel-header"><div class="panel-title">Browser Skills</div></div>'; html += '<table class="skill-table">'; skills.forEach(skill => { const lastUsed = skill.last_used ? new Date(skill.last_used).toLocaleDateString() : 'Never'; html += ` <tr class="skill-row" onclick="showSkillDetail('${escapeHtml(skill.name)}')"> <td> <div class="skill-name-cell">${escapeHtml(skill.name)}</div> <div class="skill-desc-cell">${escapeHtml(skill.description || 'No description')}</div> </td> <td> <div class="skill-stats"> <span class="success-rate">${skill.success_rate}%</span> <span>${skill.usage_count} uses</span> <span>Last: ${lastUsed}</span> </div> </td> </tr> `; }); html += '</table>'; content.innerHTML = html; } async function showSkillDetail(skillName) { const modal = document.getElementById('skill-modal'); modal.innerHTML = ` <div class="modal-overlay" onclick="closeSkillModal(event)"> <div class="modal" onclick="event.stopPropagation()"> <div class="modal-header"> <div class="modal-title">Loading skill...</div> <button class="modal-close" onclick="closeSkillModal()">&times;</button> </div> <div class="modal-body"> <div class="loading"> <div class="spinner"></div> <p>Loading skill details...</p> </div> </div> </div> </div> `; modal.style.display = 'block'; try { const response = await fetch(`${API_BASE}/skills/${encodeURIComponent(skillName)}`); if (!response.ok) { throw new Error(`Failed to load skill: ${response.statusText}`); } const skill = await response.json(); renderSkillDetail(skill); } catch (error) { console.error('Failed to load skill:', error); modal.innerHTML = ` <div class="modal-overlay" onclick="closeSkillModal(event)"> <div class="modal" onclick="event.stopPropagation()"> <div class="modal-header"> <div class="modal-title">Error</div> <button class="modal-close" onclick="closeSkillModal()">&times;</button> </div> <div class="modal-body"> <div class="empty-state"> <div class="empty-icon">⚠️</div> <p><strong>Failed to load skill</strong></p> <p style="font-size: 14px; margin-top: 8px;">${escapeHtml(error.message)}</p> </div> </div> <div class="modal-footer"> <button class="btn btn-secondary" onclick="closeSkillModal()">Close</button> </div> </div> </div> `; } } function renderSkillDetail(skill) { const modal = document.getElementById('skill-modal'); const successRate = skill.success_rate ? (skill.success_rate * 100).toFixed(1) : 0; const usageCount = (skill.success_count || 0) + (skill.failure_count || 0); const lastUsed = skill.last_used ? new Date(skill.last_used).toLocaleString() : 'Never'; let actionsHtml = ''; if (skill.actions && skill.actions.length > 0) { actionsHtml = skill.actions.map((action, idx) => `<div>${idx + 1}. ${escapeHtml(JSON.stringify(action))}</div>` ).join(''); } else { actionsHtml = '<div>No actions defined</div>'; } modal.innerHTML = ` <div class="modal-overlay" onclick="closeSkillModal(event)"> <div class="modal" onclick="event.stopPropagation()"> <div class="modal-header"> <div class="modal-title">${escapeHtml(skill.name)}</div> <button class="modal-close" onclick="closeSkillModal()">&times;</button> </div> <div class="modal-body"> <div class="skill-detail-section"> <h3>Description</h3> <div class="skill-detail-value">${escapeHtml(skill.description || 'No description')}</div> </div> <div class="skill-detail-section"> <h3>Statistics</h3> <div class="skill-detail-value"> <div>Success Rate: <strong class="success-rate">${successRate}%</strong></div> <div>Total Uses: <strong>${usageCount}</strong></div> <div>Successes: <strong>${skill.success_count || 0}</strong></div> <div>Failures: <strong>${skill.failure_count || 0}</strong></div> <div>Last Used: <strong>${lastUsed}</strong></div> </div> </div> ${skill.url ? ` <div class="skill-detail-section"> <h3>URL Pattern</h3> <div class="skill-detail-code">${escapeHtml(skill.url)}</div> </div> ` : ''} <div class="skill-detail-section"> <h3>Actions</h3> <div class="skill-detail-code">${actionsHtml}</div> </div> ${skill.success_indicators && skill.success_indicators.length > 0 ? ` <div class="skill-detail-section"> <h3>Success Indicators</h3> <div class="skill-detail-code">${escapeHtml(JSON.stringify(skill.success_indicators, null, 2))}</div> </div> ` : ''} </div> <div class="modal-footer"> <button class="btn btn-secondary" onclick="closeSkillModal()">Close</button> <button class="btn btn-danger" onclick="confirmDeleteSkill('${escapeHtml(skill.name)}')">Delete Skill</button> </div> </div> </div> `; } function closeSkillModal(event) { if (event && event.target !== event.currentTarget) return; const modal = document.getElementById('skill-modal'); modal.style.display = 'none'; modal.innerHTML = ''; } function confirmDeleteSkill(skillName) { const modal = document.getElementById('skill-modal'); modal.innerHTML = ` <div class="modal-overlay" onclick="closeSkillModal(event)"> <div class="modal" onclick="event.stopPropagation()"> <div class="modal-header"> <div class="modal-title">Confirm Delete</div> <button class="modal-close" onclick="closeSkillModal()">&times;</button> </div> <div class="modal-body"> <p>Are you sure you want to delete the skill <strong>${escapeHtml(skillName)}</strong>?</p> <p style="font-size: 14px; color: var(--text-secondary); margin-top: 8px;">This action cannot be undone.</p> </div> <div class="modal-footer"> <button class="btn btn-secondary" onclick="closeSkillModal()">Cancel</button> <button class="btn btn-danger" onclick="deleteSkill('${escapeHtml(skillName)}')">Delete</button> </div> </div> </div> `; } async function deleteSkill(skillName) { try { const response = await fetch(`${API_BASE}/skills/${encodeURIComponent(skillName)}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(`Failed to delete skill: ${response.statusText}`); } const result = await response.json(); closeSkillModal(); // Refresh the skills list htmx.ajax('GET', '/api/skills', { target: '#content' }); } catch (error) { console.error('Failed to delete skill:', error); alert(`Failed to delete skill: ${error.message}`); } } function formatDuration(seconds) { if (!seconds || seconds < 1) return '< 1s'; if (seconds < 60) return `${Math.round(seconds)}s`; const min = Math.floor(seconds / 60); const sec = Math.round(seconds % 60); return `${min}m ${sec}s`; } function escapeHtml(unsafe) { if (unsafe === null || unsafe === undefined) return ''; return String(unsafe) .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } // History Tab State let historyState = { statusFilter: 'all', dateFilter: 'all', toolFilter: 'all', currentPage: 1, pageSize: 20, allTasks: [], filteredTasks: [] }; async function loadHistory() { const content = document.getElementById('content'); content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Loading history...</p></div>'; try { const response = await fetch(`${API_BASE}/tasks?limit=1000`); if (!response.ok) throw new Error('Failed to load tasks'); const data = await response.json(); historyState.allTasks = data.tasks || []; historyState.currentPage = 1; applyHistoryFilters(); renderHistory(); } catch (error) { console.error('Failed to load history:', error); content.innerHTML = ` <div class="empty-state"> <div class="empty-icon">⚠️</div> <p><strong>Failed to load history</strong></p> <p style="font-size: 14px; margin-top: 8px;">${escapeHtml(error.message)}</p> </div> `; } } function applyHistoryFilters() { let filtered = [...historyState.allTasks]; // Status filter if (historyState.statusFilter !== 'all') { filtered = filtered.filter(task => task.status === historyState.statusFilter); } // Date filter if (historyState.dateFilter !== 'all') { const now = Date.now(); const cutoff = historyState.dateFilter === 'today' ? 24 * 60 * 60 * 1000 : historyState.dateFilter === 'week' ? 7 * 24 * 60 * 60 * 1000 : 30 * 24 * 60 * 60 * 1000; filtered = filtered.filter(task => { const taskTime = new Date(task.created_at).getTime(); return (now - taskTime) <= cutoff; }); } // Tool filter if (historyState.toolFilter !== 'all') { filtered = filtered.filter(task => task.tool === historyState.toolFilter); } historyState.filteredTasks = filtered; } function renderHistory() { const content = document.getElementById('content'); // Get unique tools for filter dropdown const uniqueTools = [...new Set(historyState.allTasks.map(t => t.tool))].sort(); // Calculate stats const stats = calculateHistoryStats(); // Pagination const totalTasks = historyState.filteredTasks.length; const totalPages = Math.ceil(totalTasks / historyState.pageSize); const startIdx = (historyState.currentPage - 1) * historyState.pageSize; const endIdx = Math.min(startIdx + historyState.pageSize, totalTasks); const pageTasks = historyState.filteredTasks.slice(startIdx, endIdx); let html = ` <div class="panel-header"> <div class="panel-title">Task History</div> </div> <div class="stats-summary"> <div class="stat-card"> <div class="stat-label">Total Tasks</div> <div class="stat-value">${stats.total}</div> <div class="stat-subtext">${historyState.filteredTasks.length} filtered</div> </div> <div class="stat-card"> <div class="stat-label">Success Rate</div> <div class="stat-value">${stats.successRate}%</div> <div class="stat-subtext">${stats.completed} completed</div> </div> <div class="stat-card"> <div class="stat-label">Avg Duration</div> <div class="stat-value">${stats.avgDuration}</div> <div class="stat-subtext">${stats.totalDuration} total</div> </div> <div class="stat-card"> <div class="stat-label">Failed Tasks</div> <div class="stat-value">${stats.failed}</div> <div class="stat-subtext">${stats.cancelled} cancelled</div> </div> </div> <div class="filter-container"> <div class="filter-group"> <label class="filter-label">Status</label> <select class="filter-select" id="statusFilter" onchange="updateHistoryFilter('status', this.value)"> <option value="all" ${historyState.statusFilter === 'all' ? 'selected' : ''}>All Statuses</option> <option value="completed" ${historyState.statusFilter === 'completed' ? 'selected' : ''}>Completed</option> <option value="failed" ${historyState.statusFilter === 'failed' ? 'selected' : ''}>Failed</option> <option value="cancelled" ${historyState.statusFilter === 'cancelled' ? 'selected' : ''}>Cancelled</option> <option value="running" ${historyState.statusFilter === 'running' ? 'selected' : ''}>Running</option> </select> </div> <div class="filter-group"> <label class="filter-label">Date Range</label> <select class="filter-select" id="dateFilter" onchange="updateHistoryFilter('date', this.value)"> <option value="all" ${historyState.dateFilter === 'all' ? 'selected' : ''}>All Time</option> <option value="today" ${historyState.dateFilter === 'today' ? 'selected' : ''}>Today</option> <option value="week" ${historyState.dateFilter === 'week' ? 'selected' : ''}>Last 7 Days</option> <option value="month" ${historyState.dateFilter === 'month' ? 'selected' : ''}>Last 30 Days</option> </select> </div> <div class="filter-group"> <label class="filter-label">Tool</label> <select class="filter-select" id="toolFilter" onchange="updateHistoryFilter('tool', this.value)"> <option value="all" ${historyState.toolFilter === 'all' ? 'selected' : ''}>All Tools</option> ${uniqueTools.map(tool => ` <option value="${escapeHtml(tool)}" ${historyState.toolFilter === tool ? 'selected' : ''}> ${escapeHtml(tool)} </option> `).join('')} </select> </div> <div class="filter-actions"> <button class="btn btn-secondary" onclick="resetHistoryFilters()">Reset Filters</button> <button class="btn btn-primary" onclick="exportHistoryToJSON()">📥 Export JSON</button> </div> </div> `; if (pageTasks.length === 0) { html += ` <div class="empty-state"> <div class="empty-icon">📋</div> <p>No tasks match the selected filters</p> </div> `; } else { pageTasks.forEach(task => { const duration = task.duration_sec ? formatDuration(task.duration_sec) : '-'; const createdAt = new Date(task.created_at).toLocaleString(); html += ` <div class="task-item"> <div class="task-header"> <div class="task-main"> <span class="task-id">${escapeHtml(task.task_id)}</span> <span class="task-tool">${escapeHtml(task.tool)}</span> </div> <div style="display: flex; gap: 12px; align-items: center;"> <span class="status-badge ${task.status}">${task.status}</span> <span style="font-size: 13px; color: var(--text-tertiary);">${duration}</span> </div> </div> <div style="margin-top: 8px; font-size: 13px; color: var(--text-secondary);"> ${createdAt} </div> </div> `; }); // Pagination controls if (totalPages > 1) { html += ` <div class="pagination"> <div class="pagination-info"> Showing ${startIdx + 1}-${endIdx} of ${totalTasks} tasks </div> <div class="pagination-controls"> <button class="pagination-btn" onclick="changeHistoryPage(${historyState.currentPage - 1})" ${historyState.currentPage === 1 ? 'disabled' : ''}> ← Previous </button> `; // Page numbers (show up to 5 pages) const pageStart = Math.max(1, historyState.currentPage - 2); const pageEnd = Math.min(totalPages, pageStart + 4); for (let i = pageStart; i <= pageEnd; i++) { html += ` <button class="pagination-btn ${i === historyState.currentPage ? 'active' : ''}" onclick="changeHistoryPage(${i})"> ${i} </button> `; } html += ` <button class="pagination-btn" onclick="changeHistoryPage(${historyState.currentPage + 1})" ${historyState.currentPage === totalPages ? 'disabled' : ''}> Next → </button> </div> </div> `; } } content.innerHTML = html; } function calculateHistoryStats() { const filtered = historyState.filteredTasks; const all = historyState.allTasks; const completed = filtered.filter(t => t.status === 'completed').length; const failed = filtered.filter(t => t.status === 'failed').length; const cancelled = filtered.filter(t => t.status === 'cancelled').length; const successRate = filtered.length > 0 ? Math.round((completed / filtered.length) * 100) : 0; const durations = filtered .filter(t => t.duration_sec && t.duration_sec > 0) .map(t => t.duration_sec); const avgDuration = durations.length > 0 ? formatDuration(durations.reduce((a, b) => a + b, 0) / durations.length) : '-'; const totalDuration = durations.length > 0 ? formatDuration(durations.reduce((a, b) => a + b, 0)) : '-'; return { total: all.length, completed, failed, cancelled, successRate, avgDuration, totalDuration }; } function updateHistoryFilter(type, value) { if (type === 'status') { historyState.statusFilter = value; } else if (type === 'date') { historyState.dateFilter = value; } else if (type === 'tool') { historyState.toolFilter = value; } historyState.currentPage = 1; applyHistoryFilters(); renderHistory(); } function resetHistoryFilters() { historyState.statusFilter = 'all'; historyState.dateFilter = 'all'; historyState.toolFilter = 'all'; historyState.currentPage = 1; applyHistoryFilters(); renderHistory(); } function changeHistoryPage(page) { const totalPages = Math.ceil(historyState.filteredTasks.length / historyState.pageSize); if (page < 1 || page > totalPages) return; historyState.currentPage = page; renderHistory(); } function exportHistoryToJSON() { const exportData = { exported_at: new Date().toISOString(), filters: { status: historyState.statusFilter, date_range: historyState.dateFilter, tool: historyState.toolFilter }, stats: calculateHistoryStats(), tasks: historyState.filteredTasks }; const dataStr = JSON.stringify(exportData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `browser-agent-history-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } // Initialize document.getElementById('themeToggle').addEventListener('click', toggleTheme); initTheme(); checkHealth(); healthCheckInterval = setInterval(checkHealth, 5000); // Load initial content htmx.trigger('.tab.active', 'click'); // Cleanup window.addEventListener('beforeunload', () => { if (healthCheckInterval) { clearInterval(healthCheckInterval); } }); </script> <!-- Modal container for skill details --> <div id="skill-modal" style="display: none;"></div> </body> </html>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Saik0s/mcp-browser-use'

If you have feedback or need assistance with the MCP directory API, please join our Discord server