@mce-bt/microagents-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/views/404.ejs ADDED
@@ -0,0 +1,10 @@
1
+ <div class="page-header">
2
+ <h2>Not Found</h2>
3
+ <p><%= message || 'The page you are looking for does not exist.' %></p>
4
+ </div>
5
+ <div class="card">
6
+ <div class="empty-state">
7
+ <div class="icon">⚠</div>
8
+ <p><a href="/" class="link">← Back to Agents</a></p>
9
+ </div>
10
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="page-header">
2
+ <h2><%= agent.name %></h2>
3
+ <p><code><%= agent.id %></code> · <%= agent.domain || 'no domain' %> · v<%= agent.version || '0.0.0' %></p>
4
+ </div>
5
+
6
+ <div hx-get="/partial/agents/<%= agent.id %>" hx-trigger="every 10s" hx-swap="innerHTML">
7
+ <%- include('partials/agent-detail-content', { agent, metrics }) %>
8
+ </div>
@@ -0,0 +1,181 @@
1
+ <div class="page-header">
2
+ <h2>Agents Overview</h2>
3
+ <p>All registered agents and their current status</p>
4
+ </div>
5
+
6
+ <div class="grid grid-4 mb-4">
7
+ <div class="stat-card">
8
+ <div class="label">Total Agents</div>
9
+ <div class="value accent"><%= agents.length %></div>
10
+ </div>
11
+ <div class="stat-card">
12
+ <div class="label">Online</div>
13
+ <div class="value green"><%= agents.filter(a => a.status === 'online').length %></div>
14
+ </div>
15
+ <div class="stat-card">
16
+ <div class="label">Offline</div>
17
+ <div class="value red"><%= agents.filter(a => a.status === 'offline').length %></div>
18
+ </div>
19
+ <div class="stat-card">
20
+ <div class="label">Unhealthy</div>
21
+ <div class="value yellow"><%= agents.filter(a => a.status === 'unhealthy').length %></div>
22
+ </div>
23
+ </div>
24
+
25
+ <!-- Token Usage Chart (Last 24h) -->
26
+ <%
27
+ const now = Date.now();
28
+ const h24 = 24 * 60 * 60 * 1000;
29
+ const bucketCount = 24;
30
+ const bucketMs = h24 / bucketCount;
31
+ const agentColors = ['#4dabf7','#bc8cff','#51cf66','#ffd43b','#ff6b6b','#20c997','#ff922b','#da77f2'];
32
+ const agentColorsBg = ['rgba(77,171,247,0.7)','rgba(188,140,255,0.7)','rgba(81,207,102,0.7)','rgba(255,212,59,0.7)','rgba(255,107,107,0.7)','rgba(32,201,151,0.7)','rgba(255,146,43,0.7)','rgba(218,119,242,0.7)'];
33
+ const chartAgents = [];
34
+ const chartBuckets = {};
35
+
36
+ (agentMetrics || []).forEach(({ agent, metrics }) => {
37
+ if (!metrics?.callHistory?.length) return;
38
+ const idx = chartAgents.length;
39
+ chartAgents.push({ name: agent.name, id: agent.id });
40
+ metrics.callHistory.forEach(call => {
41
+ const ts = new Date(call.timestamp).getTime();
42
+ const age = now - ts;
43
+ if (age > h24 || age < 0) return;
44
+ const bi = Math.min(Math.floor((h24 - age) / bucketMs), bucketCount - 1);
45
+ const key = `${idx}_${bi}`;
46
+ chartBuckets[key] = (chartBuckets[key] || 0) + (call.totalTokens || 0);
47
+ });
48
+ });
49
+
50
+ const hasChartData = chartAgents.length > 0 && Object.values(chartBuckets).some(v => v > 0);
51
+ const hourLabels = [];
52
+ for (let b = 0; b < bucketCount; b++) {
53
+ const h = new Date(now - h24 + (b + 1) * bucketMs).getHours();
54
+ hourLabels.push(String(h).padStart(2, '0') + ':00');
55
+ }
56
+ %>
57
+ <% if (hasChartData) { %>
58
+ <div class="card mb-4">
59
+ <div class="card-header">
60
+ <h3>Token Usage (Last 24 Hours)</h3>
61
+ <span class="text-muted text-sm">Hourly, stacked per agent</span>
62
+ </div>
63
+ <div style="position:relative;height:220px;padding:8px 0;">
64
+ <canvas id="tokenChart"></canvas>
65
+ </div>
66
+ </div>
67
+ <script>
68
+ (function() {
69
+ const ctx = document.getElementById('tokenChart');
70
+ const labels = <%- JSON.stringify(hourLabels) %>;
71
+ const datasets = [
72
+ <% chartAgents.forEach((a, idx) => { %>
73
+ {
74
+ label: '<%= a.name %>',
75
+ data: [<% for (let b = 0; b < bucketCount; b++) { %><%= chartBuckets[`${idx}_${b}`] || 0 %><% if (b < bucketCount - 1) { %>,<% } %><% } %>],
76
+ backgroundColor: '<%= agentColorsBg[idx % agentColorsBg.length] %>',
77
+ borderColor: '<%= agentColors[idx % agentColors.length] %>',
78
+ borderWidth: 1,
79
+ borderRadius: 3,
80
+ },
81
+ <% }) %>
82
+ ];
83
+ new Chart(ctx, {
84
+ type: 'bar',
85
+ data: { labels, datasets },
86
+ options: {
87
+ responsive: true,
88
+ maintainAspectRatio: false,
89
+ interaction: { mode: 'index', intersect: false },
90
+ plugins: {
91
+ legend: {
92
+ position: 'bottom',
93
+ labels: { color: '#8b95a5', usePointStyle: true, pointStyle: 'rectRounded', padding: 16, font: { size: 11 } }
94
+ },
95
+ tooltip: {
96
+ backgroundColor: '#1e2230',
97
+ titleColor: '#e1e4ea',
98
+ bodyColor: '#8b95a5',
99
+ borderColor: '#2d3348',
100
+ borderWidth: 1,
101
+ callbacks: {
102
+ label: function(ctx) { return ctx.dataset.label + ': ' + ctx.parsed.y.toLocaleString() + ' tokens'; }
103
+ }
104
+ }
105
+ },
106
+ scales: {
107
+ x: {
108
+ stacked: true,
109
+ grid: { color: 'rgba(45,51,72,0.5)', drawBorder: false },
110
+ ticks: { color: '#8b95a5', font: { size: 10 }, maxRotation: 0 }
111
+ },
112
+ y: {
113
+ stacked: true,
114
+ grid: { color: 'rgba(45,51,72,0.5)', drawBorder: false },
115
+ ticks: {
116
+ color: '#8b95a5',
117
+ font: { size: 10 },
118
+ callback: function(v) { return v >= 1000 ? (v/1000).toFixed(0) + 'k' : v; }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ });
124
+ })();
125
+ </script>
126
+ <% } %>
127
+
128
+ <div class="card mb-4">
129
+ <div class="card-header">
130
+ <h3>Registered Agents</h3>
131
+ <span class="htmx-indicator"><span class="spinner"></span></span>
132
+ </div>
133
+ <div hx-get="/partial/agents" hx-trigger="every 10s" hx-indicator="closest .card" hx-swap="innerHTML">
134
+ <%- include('partials/agents-table', { agents }) %>
135
+ </div>
136
+ </div>
137
+
138
+ <!-- Tasks -->
139
+ <div class="card">
140
+ <div class="card-header">
141
+ <h3>Tasks</h3>
142
+ <span class="text-muted text-sm"><%= tasks.length %> items</span>
143
+ </div>
144
+ <% if (tasks.length === 0) { %>
145
+ <div class="empty-state">
146
+ <div class="icon">◈</div>
147
+ <p>No tasks created yet</p>
148
+ </div>
149
+ <% } else { %>
150
+ <div class="table-wrap">
151
+ <table>
152
+ <thead>
153
+ <tr>
154
+ <th>Title</th>
155
+ <th>Status</th>
156
+ <th>Quadrant</th>
157
+ <th>Priority</th>
158
+ <th>Project</th>
159
+ <th>Tags</th>
160
+ <th>Due</th>
161
+ <th>Created</th>
162
+ </tr>
163
+ </thead>
164
+ <tbody>
165
+ <% tasks.forEach(t => { %>
166
+ <tr>
167
+ <td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;"><%= t.title || '—' %></td>
168
+ <td><span class="badge badge-<%= t.status === 'done' ? 'green' : t.status === 'in-progress' ? 'yellow' : 'muted' %>"><%= t.status || '—' %></span></td>
169
+ <td class="text-sm mono"><%= t.quadrant || '—' %></td>
170
+ <td class="text-sm mono"><%= t.priority ?? '—' %></td>
171
+ <td class="text-sm mono"><%= t.project || '—' %></td>
172
+ <td class="text-sm"><%= (t.tags || []).join(', ') || '—' %></td>
173
+ <td class="text-sm mono" style="white-space:nowrap;"><%= t.due_date ? new Date(t.due_date).toLocaleDateString() : '—' %></td>
174
+ <td class="text-sm mono" style="white-space:nowrap;"><%= t.created_at ? new Date(t.created_at).toLocaleString() : '—' %></td>
175
+ </tr>
176
+ <% }) %>
177
+ </tbody>
178
+ </table>
179
+ </div>
180
+ <% } %>
181
+ </div>
@@ -0,0 +1,373 @@
1
+ <% if (!session) { %>
2
+ <div class="page-header">
3
+ <h2>Browser Session</h2>
4
+ <p>Session not found</p>
5
+ </div>
6
+ <div class="empty-state">
7
+ <div class="icon">⊞</div>
8
+ <p>This browser session does not exist or has been deleted.</p>
9
+ </div>
10
+ <% } else { %>
11
+
12
+ <%
13
+ const started = new Date(session.started_at);
14
+ const ended = session.ended_at ? new Date(session.ended_at) : null;
15
+ const durationMs = ended ? ended - started : Date.now() - started;
16
+ const statusConfig = {
17
+ completed: { icon: '✓', class: 'badge-green', color: 'var(--green)' },
18
+ running: { icon: '⟳', class: 'badge-yellow', color: 'var(--yellow)' },
19
+ failed: { icon: '✕', class: 'badge-red', color: 'var(--red)' },
20
+ };
21
+ const sc = statusConfig[session.status] || statusConfig.failed;
22
+
23
+ function fmtDuration(ms) {
24
+ if (ms < 1000) return '<1s';
25
+ const sec = Math.round(ms / 1000);
26
+ if (sec < 60) return sec + 's';
27
+ const min = Math.floor(sec / 60);
28
+ const rem = sec % 60;
29
+ return min + 'm ' + rem + 's';
30
+ }
31
+
32
+ function fmtTime(date) {
33
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
34
+ }
35
+
36
+ function fmtDate(date) {
37
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + fmtTime(date);
38
+ }
39
+
40
+ const actionConfig = {
41
+ goto: { icon: '→', label: 'Navigate', class: 'step-goto' },
42
+ act: { icon: '⚡', label: 'Action', class: 'step-act' },
43
+ extract: { icon: '◎', label: 'Extract', class: 'step-extract' },
44
+ observe: { icon: '◉', label: 'Observe', class: 'step-observe' },
45
+ agent: { icon: '⬡', label: 'Agent', class: 'step-agent' },
46
+ };
47
+
48
+ // Build list of URLs visited (from goto steps)
49
+ const urlsVisited = steps
50
+ ? steps.filter(s => s.action_type === 'goto').map(s => s.instruction)
51
+ : [];
52
+
53
+ // Collect screenshot step indices for the player
54
+ const screenshotSteps = steps
55
+ ? steps.filter(s => s.screenshot_path).map(s => s.step_index)
56
+ : [];
57
+
58
+ // Helper: try to parse a URL from instruction
59
+ function isUrl(str) {
60
+ try { new URL(str); return true; } catch { return false; }
61
+ }
62
+
63
+ // Helper: format extracted data nicely
64
+ function formatExtracted(data, depth) {
65
+ depth = depth || 0;
66
+ if (depth > 3) return JSON.stringify(data);
67
+ if (data === null || data === undefined) return '';
68
+ if (typeof data === 'string') return data;
69
+ if (typeof data === 'number' || typeof data === 'boolean') return String(data);
70
+ if (Array.isArray(data)) {
71
+ return data.map(function(item) { return formatExtracted(item, depth + 1); }).join(', ');
72
+ }
73
+ if (typeof data === 'object') {
74
+ return Object.entries(data).map(function(pair) {
75
+ return '<span class="bsd-kv-key">' + pair[0] + ':</span> ' + formatExtracted(pair[1], depth + 1);
76
+ }).join('<br/>');
77
+ }
78
+ return String(data);
79
+ }
80
+ %>
81
+
82
+ <div class="page-header" style="margin-bottom: 16px;">
83
+ <a href="/browser" class="bsd-back">← Browser Sessions</a>
84
+ <h2>Session Detail</h2>
85
+ </div>
86
+
87
+ <!-- Session header card -->
88
+ <div class="bsd-header" style="border-left-color: <%= sc.color %>;">
89
+ <div class="bsd-header-top">
90
+ <div class="bsd-header-left">
91
+ <span class="badge <%= sc.class %>" style="font-size: 12px; padding: 3px 10px;"><%= sc.icon %> <%= session.status %></span>
92
+ <% if (session.platform) { %>
93
+ <span class="bsd-platform-tag"><%= session.platform %></span>
94
+ <% } %>
95
+ <span class="bsd-agent-tag"><%= session.agent_id %></span>
96
+ </div>
97
+ <span class="bsd-id mono"><%= session.id %></span>
98
+ </div>
99
+ <p class="bsd-goal"><%= session.goal %></p>
100
+
101
+ <% if (urlsVisited.length > 0) { %>
102
+ <div class="bsd-urls">
103
+ <span class="bsd-urls-label">URLs visited:</span>
104
+ <% urlsVisited.forEach(function(url) { %>
105
+ <a href="<%= url %>" target="_blank" rel="noopener noreferrer" class="bsd-url-chip mono"><%= url %></a>
106
+ <% }); %>
107
+ </div>
108
+ <% } %>
109
+
110
+ <div class="bsd-stats">
111
+ <div class="bsd-stat">
112
+ <span class="bsd-stat-label">Started</span>
113
+ <span class="bsd-stat-value"><%= fmtDate(started) %></span>
114
+ </div>
115
+ <% if (ended) { %>
116
+ <div class="bsd-stat">
117
+ <span class="bsd-stat-label">Ended</span>
118
+ <span class="bsd-stat-value"><%= fmtDate(ended) %></span>
119
+ </div>
120
+ <% } %>
121
+ <div class="bsd-stat">
122
+ <span class="bsd-stat-label">Duration</span>
123
+ <span class="bsd-stat-value mono"><%= fmtDuration(durationMs) %></span>
124
+ </div>
125
+ <div class="bsd-stat">
126
+ <span class="bsd-stat-label">Steps</span>
127
+ <span class="bsd-stat-value mono"><%= session.step_count %></span>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- Screenshot player (replay) -->
133
+ <% if (screenshotSteps.length > 1) { %>
134
+ <div class="bsd-player" id="bsd-player">
135
+ <div class="bsd-player-header">
136
+ <span class="bsd-player-title">Session Recording</span>
137
+ <div class="bsd-player-controls">
138
+ <button class="bsd-player-btn" id="player-prev" title="Previous step">◀</button>
139
+ <button class="bsd-player-btn" id="player-play" title="Play / Pause">▶</button>
140
+ <button class="bsd-player-btn" id="player-next" title="Next step">▶</button>
141
+ <span class="bsd-player-counter mono" id="player-counter">1 / <%= screenshotSteps.length %></span>
142
+ <select class="bsd-player-speed" id="player-speed" title="Playback speed">
143
+ <option value="2000">0.5×</option>
144
+ <option value="1000" selected>1×</option>
145
+ <option value="500">2×</option>
146
+ <option value="250">4×</option>
147
+ </select>
148
+ </div>
149
+ </div>
150
+ <div class="bsd-player-viewport">
151
+ <img id="player-img" src="/browser/<%= session.id %>/screenshots/<%= screenshotSteps[0] %>" alt="Recording frame" />
152
+ <div class="bsd-player-caption" id="player-caption"></div>
153
+ </div>
154
+ <div class="bsd-player-progress">
155
+ <div class="bsd-player-bar" id="player-bar" style="width: 0%"></div>
156
+ </div>
157
+ </div>
158
+ <% } %>
159
+
160
+ <!-- Steps timeline -->
161
+ <% if (!steps || steps.length === 0) { %>
162
+ <div class="card mt-4" style="padding: 24px;">
163
+ <p class="text-muted" style="text-align: center;">No steps recorded for this session.</p>
164
+ </div>
165
+ <% } else { %>
166
+ <div class="bsd-timeline">
167
+ <% steps.forEach((step, i) => {
168
+ const ac = actionConfig[step.action_type] || actionConfig.agent;
169
+ const isSuccess = step.result && (step.result.success === true || step.result.extracted);
170
+ const isFailed = step.result && step.result.success === false;
171
+ %>
172
+ <div class="bsd-step <%= ac.class %> <%= isFailed ? 'bsd-step-failed' : '' %>" id="step-<%= step.step_index %>">
173
+ <div class="bsd-step-connector">
174
+ <div class="bsd-step-dot"></div>
175
+ <% if (i < steps.length - 1) { %><div class="bsd-step-line"></div><% } %>
176
+ </div>
177
+ <div class="bsd-step-content">
178
+ <div class="bsd-step-header">
179
+ <div class="bsd-step-title">
180
+ <span class="bsd-step-icon"><%= ac.icon %></span>
181
+ <span class="bsd-step-type"><%= ac.label %></span>
182
+ <span class="bsd-step-num">Step <%= step.step_index %></span>
183
+ </div>
184
+ <span class="bsd-step-duration mono"><%= step.duration_ms >= 1000 ? (step.duration_ms / 1000).toFixed(1) + 's' : step.duration_ms + 'ms' %></span>
185
+ </div>
186
+
187
+ <% if (step.action_type === 'goto' && isUrl(step.instruction)) { %>
188
+ <div class="bsd-step-url">
189
+ <a href="<%= step.instruction %>" target="_blank" rel="noopener noreferrer" class="bsd-url-link mono"><%= step.instruction %></a>
190
+ </div>
191
+ <% } else { %>
192
+ <p class="bsd-step-instruction"><%= step.instruction %></p>
193
+ <% } %>
194
+
195
+ <% if (step.result && Object.keys(step.result).length > 0) { %>
196
+ <div class="bsd-step-result">
197
+ <% if (step.action_type === 'extract' && step.result.extracted) { %>
198
+ <div class="bsd-extract-result">
199
+ <div class="bsd-extract-label">Extracted data</div>
200
+ <div class="bsd-extract-data"><%- formatExtracted(step.result.extracted) %></div>
201
+ </div>
202
+ <% } else if (step.action_type === 'observe' && step.result.action_count !== undefined) { %>
203
+ <div class="bsd-step-message">
204
+ Found <strong><%= step.result.action_count %></strong> available action<%= step.result.action_count !== 1 ? 's' : '' %> on page
205
+ </div>
206
+ <% } else if (step.action_type === 'agent') { %>
207
+ <div class="bsd-step-message <%= isFailed ? 'bsd-step-message-error' : '' %>">
208
+ <% if (step.result.completed) { %>✓ Completed<% } else if (isFailed) { %>✕ Failed<% } else { %>⟳ Partial<% } %>
209
+ <% if (step.result.message) { %> — <%= step.result.message %><% } %>
210
+ </div>
211
+ <% } else if (step.result.message) { %>
212
+ <div class="bsd-step-message <%= isFailed ? 'bsd-step-message-error' : '' %>">
213
+ <% if (isFailed) { %>✕<% } else if (isSuccess) { %>✓<% } %>
214
+ <%= step.result.message %>
215
+ </div>
216
+ <% } %>
217
+ <details class="bsd-step-details">
218
+ <summary>Raw JSON</summary>
219
+ <pre><%= JSON.stringify(step.result, null, 2) %></pre>
220
+ </details>
221
+ </div>
222
+ <% } %>
223
+
224
+ <% if (step.screenshot_path) { %>
225
+ <div class="bsd-step-screenshot">
226
+ <img
227
+ src="/browser/<%= session.id %>/screenshots/<%= step.step_index %>"
228
+ alt="Screenshot after step <%= step.step_index %>"
229
+ loading="lazy"
230
+ onclick="openLightbox(this.src, <%= step.step_index %>)"
231
+ />
232
+ </div>
233
+ <% } %>
234
+ </div>
235
+ </div>
236
+ <% }); %>
237
+ </div>
238
+ <% } %>
239
+
240
+ <!-- Lightbox with navigation -->
241
+ <div class="bsd-lightbox" id="lightbox" onclick="closeLightbox(event)">
242
+ <div class="bsd-lightbox-close" onclick="closeLightbox(event)">✕</div>
243
+ <button class="bsd-lightbox-nav bsd-lightbox-prev" id="lb-prev" onclick="lightboxNav(-1, event)">‹</button>
244
+ <img id="lightbox-img" src="" alt="Screenshot" />
245
+ <button class="bsd-lightbox-nav bsd-lightbox-next" id="lb-next" onclick="lightboxNav(1, event)">›</button>
246
+ <div class="bsd-lightbox-info" id="lb-info"></div>
247
+ </div>
248
+
249
+ <script>
250
+ (function() {
251
+ // Screenshot steps data for lightbox navigation
252
+ var screenshotSteps = <%= JSON.stringify(screenshotSteps) %>;
253
+ var sessionId = '<%= session.id %>';
254
+ var stepsData = <%- JSON.stringify((steps || []).map(function(s) { return { step_index: s.step_index, action_type: s.action_type, instruction: s.instruction }; })) %>;
255
+ var currentLbIndex = 0;
256
+
257
+ function getStepInfo(stepIndex) {
258
+ var s = stepsData.find(function(x) { return x.step_index === stepIndex; });
259
+ if (!s) return '';
260
+ var labels = { goto: 'Navigate', act: 'Action', extract: 'Extract', observe: 'Observe', agent: 'Agent' };
261
+ return (labels[s.action_type] || s.action_type) + ': ' + s.instruction;
262
+ }
263
+
264
+ window.openLightbox = function(src, stepIndex) {
265
+ var lb = document.getElementById('lightbox');
266
+ document.getElementById('lightbox-img').src = src;
267
+ currentLbIndex = screenshotSteps.indexOf(stepIndex);
268
+ if (currentLbIndex < 0) currentLbIndex = 0;
269
+ document.getElementById('lb-info').textContent = 'Step ' + stepIndex + ' — ' + getStepInfo(stepIndex);
270
+ document.getElementById('lb-prev').style.display = currentLbIndex > 0 ? '' : 'none';
271
+ document.getElementById('lb-next').style.display = currentLbIndex < screenshotSteps.length - 1 ? '' : 'none';
272
+ lb.classList.add('active');
273
+ document.body.style.overflow = 'hidden';
274
+ };
275
+
276
+ window.closeLightbox = function(e) {
277
+ if (e && (e.target.classList.contains('bsd-lightbox-nav') || e.target === document.getElementById('lightbox-img'))) return;
278
+ document.getElementById('lightbox').classList.remove('active');
279
+ document.body.style.overflow = '';
280
+ };
281
+
282
+ window.lightboxNav = function(dir, e) {
283
+ if (e) { e.stopPropagation(); e.preventDefault(); }
284
+ currentLbIndex = Math.max(0, Math.min(screenshotSteps.length - 1, currentLbIndex + dir));
285
+ var step = screenshotSteps[currentLbIndex];
286
+ document.getElementById('lightbox-img').src = '/browser/' + sessionId + '/screenshots/' + step;
287
+ document.getElementById('lb-info').textContent = 'Step ' + step + ' — ' + getStepInfo(step);
288
+ document.getElementById('lb-prev').style.display = currentLbIndex > 0 ? '' : 'none';
289
+ document.getElementById('lb-next').style.display = currentLbIndex < screenshotSteps.length - 1 ? '' : 'none';
290
+ };
291
+
292
+ document.addEventListener('keydown', function(e) {
293
+ if (!document.getElementById('lightbox').classList.contains('active')) return;
294
+ if (e.key === 'Escape') { document.getElementById('lightbox').classList.remove('active'); document.body.style.overflow = ''; }
295
+ if (e.key === 'ArrowLeft') lightboxNav(-1, e);
296
+ if (e.key === 'ArrowRight') lightboxNav(1, e);
297
+ });
298
+
299
+ // --- Screenshot Player ---
300
+ var playerEl = document.getElementById('bsd-player');
301
+ if (playerEl && screenshotSteps.length > 1) {
302
+ var playerIdx = 0;
303
+ var playing = false;
304
+ var playTimer = null;
305
+ var img = document.getElementById('player-img');
306
+ var counter = document.getElementById('player-counter');
307
+ var bar = document.getElementById('player-bar');
308
+ var caption = document.getElementById('player-caption');
309
+ var speedSelect = document.getElementById('player-speed');
310
+
311
+ function updatePlayer() {
312
+ var step = screenshotSteps[playerIdx];
313
+ img.src = '/browser/' + sessionId + '/screenshots/' + step;
314
+ counter.textContent = (playerIdx + 1) + ' / ' + screenshotSteps.length;
315
+ bar.style.width = ((playerIdx + 1) / screenshotSteps.length * 100) + '%';
316
+ caption.textContent = 'Step ' + step + ' — ' + getStepInfo(step);
317
+ // Highlight corresponding timeline step
318
+ document.querySelectorAll('.bsd-step').forEach(function(el) { el.classList.remove('bsd-step-active'); });
319
+ var stepEl = document.getElementById('step-' + step);
320
+ if (stepEl) stepEl.classList.add('bsd-step-active');
321
+ }
322
+
323
+ function getSpeed() { return parseInt(speedSelect.value, 10); }
324
+
325
+ document.getElementById('player-prev').onclick = function() {
326
+ if (playerIdx > 0) { playerIdx--; updatePlayer(); }
327
+ };
328
+ document.getElementById('player-next').onclick = function() {
329
+ if (playerIdx < screenshotSteps.length - 1) { playerIdx++; updatePlayer(); }
330
+ };
331
+ document.getElementById('player-play').onclick = function() {
332
+ if (playing) {
333
+ playing = false;
334
+ clearInterval(playTimer);
335
+ this.textContent = '▶';
336
+ } else {
337
+ playing = true;
338
+ this.textContent = '⏸';
339
+ if (playerIdx >= screenshotSteps.length - 1) playerIdx = 0;
340
+ playTimer = setInterval(function() {
341
+ if (playerIdx < screenshotSteps.length - 1) {
342
+ playerIdx++;
343
+ updatePlayer();
344
+ } else {
345
+ playing = false;
346
+ clearInterval(playTimer);
347
+ document.getElementById('player-play').textContent = '▶';
348
+ }
349
+ }, getSpeed());
350
+ }
351
+ };
352
+ speedSelect.onchange = function() {
353
+ if (playing) {
354
+ clearInterval(playTimer);
355
+ playTimer = setInterval(function() {
356
+ if (playerIdx < screenshotSteps.length - 1) {
357
+ playerIdx++;
358
+ updatePlayer();
359
+ } else {
360
+ playing = false;
361
+ clearInterval(playTimer);
362
+ document.getElementById('player-play').textContent = '▶';
363
+ }
364
+ }, getSpeed());
365
+ }
366
+ };
367
+
368
+ updatePlayer();
369
+ }
370
+ })();
371
+ </script>
372
+
373
+ <% } %>
@@ -0,0 +1,57 @@
1
+ <div class="page-header">
2
+ <h2>Browser Sessions</h2>
3
+ <p>AI-driven browser automation recordings</p>
4
+ </div>
5
+
6
+ <%
7
+ const running = sessions.filter(s => s.status === 'running').length;
8
+ const completed = sessions.filter(s => s.status === 'completed').length;
9
+ const failed = sessions.filter(s => s.status === 'failed').length;
10
+ const totalSteps = sessions.reduce((s, x) => s + (x.step_count || 0), 0);
11
+
12
+ function timeAgo(dateStr) {
13
+ const now = Date.now();
14
+ const then = new Date(dateStr).getTime();
15
+ const diffMs = now - then;
16
+ const mins = Math.floor(diffMs / 60000);
17
+ if (mins < 1) return 'just now';
18
+ if (mins < 60) return mins + 'm ago';
19
+ const hours = Math.floor(mins / 60);
20
+ if (hours < 24) return hours + 'h ago';
21
+ const days = Math.floor(hours / 24);
22
+ if (days < 7) return days + 'd ago';
23
+ return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
24
+ }
25
+
26
+ function formatDuration(ms) {
27
+ if (ms < 1000) return ms + 'ms';
28
+ const sec = Math.round(ms / 1000);
29
+ if (sec < 60) return sec + 's';
30
+ const min = Math.floor(sec / 60);
31
+ const rem = sec % 60;
32
+ return min + 'm ' + rem + 's';
33
+ }
34
+ %>
35
+
36
+ <div class="grid grid-4 mb-4">
37
+ <div class="stat-card">
38
+ <div class="label">Total Sessions</div>
39
+ <div class="value accent"><%= sessions.length %></div>
40
+ </div>
41
+ <div class="stat-card">
42
+ <div class="label">Running</div>
43
+ <div class="value yellow"><%= running %></div>
44
+ </div>
45
+ <div class="stat-card">
46
+ <div class="label">Completed</div>
47
+ <div class="value green"><%= completed %></div>
48
+ </div>
49
+ <div class="stat-card">
50
+ <div class="label">Failed</div>
51
+ <div class="value red"><%= failed %></div>
52
+ </div>
53
+ </div>
54
+
55
+ <div hx-get="/partial/browser" hx-trigger="every 10s" hx-swap="innerHTML">
56
+ <%- include('partials/browser-table', { sessions }) %>
57
+ </div>