lexxit-automation-framework 2.0.7 → 2.0.9

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.
@@ -0,0 +1,510 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>PW — Execution Overview</title>
7
+ <style>
8
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
+ :root{
10
+ --bg:#0a0d14;--s:#111827;--s2:#1a2236;
11
+ --b:#1e2d45;--b2:#2d3f5e;
12
+ --t:#e2e8f0;--m:#64748b;--m2:#94a3b8;
13
+ --pass:#22c55e;--pb:#052e16;--pbd:#14532d;
14
+ --fail:#ef4444;--fb:#2d0a0a;--fbd:#7f1d1d;
15
+ --skip:#f59e0b;--sb:#2d1d00;--sbd:#78350f;
16
+ --run:#3b82f6;--rb:#0c1f3d;--rbd:#1e3a6e;
17
+ --pend:#64748b;--pendb:#0f172a;--pendbd:#1e2d45;
18
+ --cancel:#a855f7;--cnb:#1a0a2e;--cnbd:#4a1d7e;
19
+ --r:10px;--rs:6px;
20
+ }
21
+ html,body{height:100%;background:var(--bg);color:var(--t);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
22
+
23
+ /* ── Header ── */
24
+ header{background:var(--s);border-bottom:1px solid var(--b);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
25
+ .h-left{display:flex;align-items:center;gap:12px}
26
+ .h-logo{font-size:1.4rem}
27
+ .h-title{font-size:.95rem;font-weight:700}
28
+ .h-sub{font-size:.68rem;color:var(--m);font-family:monospace;margin-top:2px}
29
+ .h-right{display:flex;align-items:center;gap:8px}
30
+
31
+ .badge{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;border-radius:99px;font-size:.7rem;font-weight:700;letter-spacing:.04em;border:1px solid transparent}
32
+ .badge .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
33
+ .badge-running{background:var(--rb);color:var(--run);border-color:var(--rbd)}
34
+ .badge-running .dot{background:var(--run);animation:pulse .9s ease-in-out infinite}
35
+ .badge-pass{background:var(--pb);color:var(--pass);border-color:var(--pbd)}
36
+ .badge-pass .dot{background:var(--pass)}
37
+ .badge-fail{background:var(--fb);color:var(--fail);border-color:var(--fbd)}
38
+ .badge-fail .dot{background:var(--fail)}
39
+ .badge-idle{background:var(--s2);color:var(--m);border-color:var(--b)}
40
+ .badge-idle .dot{background:var(--m)}
41
+ .badge-mode{background:var(--s2);color:var(--m2);border-color:var(--b);font-size:.65rem;padding:3px 9px}
42
+
43
+ .btn-icon{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:99px;font-size:.72rem;font-weight:600;border:1px solid;cursor:pointer;transition:all .2s;text-decoration:none;background:none}
44
+ .btn-cancel{color:var(--fail);border-color:var(--fbd);background:var(--fb)}
45
+ .btn-cancel:hover:not(:disabled){background:#3d0a0a;border-color:var(--fail)}
46
+ .btn-cancel:disabled{opacity:.4;cursor:not-allowed}
47
+ .btn-queue{color:var(--m2);border-color:var(--b);background:var(--s2)}
48
+ .btn-queue:hover{color:var(--t);border-color:var(--b2)}
49
+
50
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
51
+
52
+ /* ── Layout ── */
53
+ .layout{display:grid;grid-template-columns:1fr 280px;height:calc(100vh - 53px);overflow:hidden}
54
+ .main{overflow-y:auto;padding:18px 20px;display:flex;flex-direction:column;gap:14px}
55
+ .sidebar{background:var(--s);border-left:1px solid var(--b);display:flex;flex-direction:column;overflow:hidden}
56
+
57
+ /* ── Top stats bar ── */
58
+ .stats-bar{display:grid;grid-template-columns:repeat(6,1fr);gap:8px}
59
+ .stat{background:var(--s2);border:1px solid var(--b);border-radius:var(--r);padding:10px;text-align:center}
60
+ .stat-v{font-size:1.5rem;font-weight:800;line-height:1}
61
+ .stat-l{font-size:.58rem;color:var(--m);margin-top:3px;text-transform:uppercase;letter-spacing:.08em}
62
+ .stat.total .stat-v{color:var(--t)}
63
+ .stat.running .stat-v{color:var(--run)}
64
+ .stat.passed .stat-v{color:var(--pass)}
65
+ .stat.failed .stat-v{color:var(--fail)}
66
+ .stat.pending .stat-v{color:var(--pend)}
67
+ .stat.dur .stat-v{color:var(--run);font-size:1.1rem}
68
+
69
+ /* ── Section label ── */
70
+ .sec-label{font-size:.62rem;font-weight:700;color:var(--m);text-transform:uppercase;letter-spacing:.1em}
71
+
72
+ /* ── Script cards grid ── */
73
+ .scripts-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:10px}
74
+
75
+ .script-card{background:var(--s2);border:1px solid var(--b);border-left:3px solid var(--pendbd);border-radius:var(--r);padding:13px 15px;transition:border-left-color .3s}
76
+ .script-card.pending {border-left-color:var(--pendbd)}
77
+ .script-card.running {border-left-color:var(--run)}
78
+ .script-card.passed {border-left-color:var(--pass)}
79
+ .script-card.failed {border-left-color:var(--fail)}
80
+ .script-card.cancelled{border-left-color:var(--cancel)}
81
+
82
+ .card-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:8px}
83
+ .card-info{flex:1;min-width:0}
84
+ .card-name{font-size:.82rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--t)}
85
+ .card-meta{font-size:.65rem;color:var(--m);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap}
86
+ .card-meta span{display:flex;align-items:center;gap:3px}
87
+
88
+ .sbadge{padding:2px 9px;border-radius:99px;font-size:.62rem;font-weight:700;flex-shrink:0;white-space:nowrap}
89
+ .sb-pending {background:var(--pendb);color:var(--pend);border:1px solid var(--pendbd)}
90
+ .sb-running {background:var(--rb);color:var(--run);border:1px solid var(--rbd)}
91
+ .sb-running span{animation:pulse .9s infinite}
92
+ .sb-passed {background:var(--pb);color:var(--pass);border:1px solid var(--pbd)}
93
+ .sb-failed {background:var(--fb);color:var(--fail);border:1px solid var(--fbd)}
94
+ .sb-cancelled{background:var(--cnb);color:var(--cancel);border:1px solid var(--cnbd)}
95
+
96
+ .prog-wrap{height:3px;background:var(--b);border-radius:99px;overflow:hidden;margin-bottom:8px}
97
+ .prog-fill{height:100%;background:var(--run);border-radius:99px;transition:width .4s ease;width:0%}
98
+ .prog-fill.done{background:var(--pass)}
99
+ .prog-fill.fail{background:var(--fail)}
100
+
101
+ .card-bottom{display:flex;align-items:center;justify-content:space-between;gap:8px}
102
+ .card-counts{display:flex;align-items:center;gap:8px;font-size:.68rem;color:var(--m)}
103
+ .card-counts .cp{color:var(--pass)}.card-counts .cf{color:var(--fail)}.card-counts .cs{color:var(--skip)}
104
+ .card-dur{font-size:.65rem;color:var(--m);font-family:monospace}
105
+
106
+ .btn-view{padding:3px 11px;border-radius:6px;font-size:.66rem;font-weight:600;cursor:pointer;border:1px solid var(--b);background:var(--s2);color:var(--run);text-decoration:none;transition:all .15s;display:inline-block}
107
+ .btn-view:hover{border-color:var(--run)}
108
+ .btn-view:not([href]){opacity:.35;pointer-events:none;cursor:default}
109
+
110
+ /* ── Sidebar ── */
111
+ .sb-sec{padding:12px 14px;border-bottom:1px solid var(--b);flex-shrink:0}
112
+ .sb-title{font-size:.62rem;font-weight:700;color:var(--m);text-transform:uppercase;letter-spacing:.12em;margin-bottom:8px}
113
+ .sum-box{background:var(--s2);border:1px solid var(--b);border-radius:var(--r)}
114
+ .sum-row{display:flex;justify-content:space-between;padding:7px 12px;font-size:.74rem;border-bottom:1px solid var(--b)}
115
+ .sum-row:last-child{border-bottom:none}
116
+ .sum-lbl{color:var(--m)}.sum-val{font-weight:600}
117
+
118
+ .log-box{flex:1;overflow-y:auto;padding:8px 12px;font-family:'Cascadia Code','Fira Code',monospace;font-size:.65rem;line-height:1.7;background:#060b12}
119
+ .ll{color:var(--m);word-break:break-word}
120
+ .lp{color:var(--pass)}.lf{color:var(--fail)}.ls{color:var(--skip)}.lr{color:var(--run)}.ly{color:#d2a8ff}
121
+
122
+ .empty{text-align:center;padding:40px;color:var(--m);font-size:.82rem}
123
+
124
+ ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--b2);border-radius:2px}
125
+ </style>
126
+ </head>
127
+ <body>
128
+
129
+ <header>
130
+ <div class="h-left">
131
+ <span class="h-logo">🎭</span>
132
+ <div>
133
+ <div class="h-title">Playwright — Execution Overview</div>
134
+ <div class="h-sub" id="execLabel">Connecting…</div>
135
+ </div>
136
+ </div>
137
+ <div class="h-right">
138
+ <span class="badge badge-mode" id="modeTag">SEQUENTIAL</span>
139
+ <a class="btn-icon btn-queue" href="/queue-monitor" target="_blank">☰ Queue</a>
140
+ <button class="btn-icon btn-cancel" id="cancelBtn" onclick="cancelExecution()" disabled>⏹ Cancel</button>
141
+ <div class="badge badge-idle" id="statusBadge"><span class="dot"></span><span id="statusText">CONNECTING</span></div>
142
+ </div>
143
+ </header>
144
+
145
+ <div class="layout">
146
+ <div class="main">
147
+ <!-- Stats bar -->
148
+ <div class="stats-bar">
149
+ <div class="stat total" ><div class="stat-v" id="stTotal">—</div><div class="stat-l">Scripts</div></div>
150
+ <div class="stat running"><div class="stat-v" id="stRun">0</div><div class="stat-l">Running</div></div>
151
+ <div class="stat passed" ><div class="stat-v" id="stPass">0</div><div class="stat-l">Passed</div></div>
152
+ <div class="stat failed" ><div class="stat-v" id="stFail">0</div><div class="stat-l">Failed</div></div>
153
+ <div class="stat pending"><div class="stat-v" id="stPend">—</div><div class="stat-l">Pending</div></div>
154
+ <div class="stat dur" ><div class="stat-v" id="stDur">0s</div><div class="stat-l">Elapsed</div></div>
155
+ </div>
156
+
157
+ <!-- Script cards -->
158
+ <div class="sec-label" id="scriptsLabel">Scripts</div>
159
+ <div class="scripts-grid" id="scriptsGrid">
160
+ <div class="empty">Loading scripts…</div>
161
+ </div>
162
+ </div>
163
+
164
+ <!-- Sidebar -->
165
+ <div class="sidebar">
166
+ <div class="sb-sec">
167
+ <div class="sb-title">🏁 Summary</div>
168
+ <div class="sum-box">
169
+ <div class="sum-row"><span class="sum-lbl">Status</span> <span class="sum-val" id="sumStatus">—</span></div>
170
+ <div class="sum-row"><span class="sum-lbl">Mode</span> <span class="sum-val" id="sumMode">—</span></div>
171
+ <div class="sum-row"><span class="sum-lbl">Total Scripts</span><span class="sum-val" id="sumScripts">—</span></div>
172
+ <div class="sum-row"><span class="sum-lbl">Passed</span> <span class="sum-val" style="color:var(--pass)" id="sumPass">—</span></div>
173
+ <div class="sum-row"><span class="sum-lbl">Failed</span> <span class="sum-val" style="color:var(--fail)" id="sumFail">—</span></div>
174
+ <div class="sum-row"><span class="sum-lbl">Duration</span> <span class="sum-val" id="sumDur">—</span></div>
175
+ </div>
176
+ </div>
177
+ <div class="sb-sec" style="flex-shrink:0"><div class="sb-title">📋 Live Log</div></div>
178
+ <div class="log-box" id="logBox"></div>
179
+ </div>
180
+ </div>
181
+
182
+ <script>
183
+ const execId = location.pathname.split('/').filter(Boolean)[1];
184
+ const $ = id => document.getElementById(id);
185
+ const esc = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
186
+
187
+ // ── State ────────────────────────────────────────────────────────────────────
188
+ let scripts = {}; // test_script_uid → { name, totalSteps, browser, status, passed, failed, skipped, startMs }
189
+ let execInfo = null; // from /api/execution/:id/info
190
+ let isDone = false;
191
+ let startMs = Date.now();
192
+ let timerInt = null;
193
+ let runCount = 0, passCount = 0, failCount = 0;
194
+
195
+ // ── Helpers ──────────────────────────────────────────────────────────────────
196
+ function fmtDur(ms) {
197
+ if (!ms && ms !== 0) return '—';
198
+ if (ms < 1000) return ms + 'ms';
199
+ return (ms / 1000).toFixed(1) + 's';
200
+ }
201
+
202
+ function log(msg, cls='') {
203
+ const b = $('logBox');
204
+ const ts = new Date().toLocaleTimeString('en-GB');
205
+ const d = document.createElement('div');
206
+ d.className = 'll ' + cls;
207
+ d.textContent = `[${ts}] ${msg}`;
208
+ b.appendChild(d);
209
+ b.scrollTop = b.scrollHeight;
210
+ }
211
+
212
+ function setStatus(text, cls) {
213
+ const b = $('statusBadge');
214
+ b.className = 'badge ' + cls;
215
+ b.querySelector('span:last-child').textContent = text;
216
+ }
217
+
218
+ function updateTopStats() {
219
+ const list = Object.values(scripts);
220
+ const pending = list.filter(s => s.status === 'pending').length;
221
+ const running = list.filter(s => s.status === 'running').length;
222
+ const passed = list.filter(s => s.status === 'passed').length;
223
+ const failed = list.filter(s => s.status === 'failed').length;
224
+ $('stTotal').textContent = list.length || '—';
225
+ $('stRun').textContent = running;
226
+ $('stPass').textContent = passed;
227
+ $('stFail').textContent = failed;
228
+ $('stPend').textContent = pending;
229
+ if (!isDone) $('stDur').textContent = ((Date.now() - startMs) / 1000).toFixed(1) + 's';
230
+
231
+ // Overall status badge
232
+ if (isDone) return;
233
+ if (running > 0) setStatus('RUNNING', 'badge-running');
234
+ else if (list.length > 0 && pending === list.length) setStatus('QUEUED', 'badge-idle');
235
+ }
236
+
237
+ // ── Script card builders ──────────────────────────────────────────────────────
238
+
239
+ function cardId(uid) { return 'sc-' + uid.replace(/-/g,''); }
240
+
241
+ function renderCard(uid) {
242
+ const s = scripts[uid];
243
+ if (!s) return;
244
+ const pct = s.totalSteps > 0 ? Math.round(((s.passed + s.failed + s.skipped) / s.totalSteps) * 100) : 0;
245
+ const dur = s.startMs ? fmtDur(Date.now() - s.startMs) : '—';
246
+ const fillCls = s.status === 'passed' ? 'done' : s.status === 'failed' ? 'fail' : '';
247
+ const shortUid = uid.slice(0, 8) + '…';
248
+
249
+ const badgeMap = {
250
+ pending: `<span class="sbadge sb-pending">PENDING</span>`,
251
+ running: `<span class="sbadge sb-running"><span>●</span> RUNNING</span>`,
252
+ passed: `<span class="sbadge sb-passed">✅ PASSED</span>`,
253
+ failed: `<span class="sbadge sb-failed">❌ FAILED</span>`,
254
+ cancelled: `<span class="sbadge sb-cancelled">CANCELLED</span>`,
255
+ };
256
+
257
+ // "View →" only available once script has started
258
+ const viewHref = (s.status !== 'pending') ? `/dashboard/${execId}/${uid}` : '';
259
+ const viewAttrs = viewHref ? `href="${viewHref}" target="_blank"` : '';
260
+
261
+ const el = $(cardId(uid));
262
+ if (!el) return;
263
+
264
+ el.className = `script-card ${s.status}`;
265
+ el.innerHTML = `
266
+ <div class="card-top">
267
+ <div class="card-info">
268
+ <div class="card-name" title="${esc(s.name)}">${esc(s.name)}</div>
269
+ <div class="card-meta">
270
+ <span>🆔 ${shortUid}</span>
271
+ <span>📋 ${s.totalSteps} steps</span>
272
+ <span>🌐 ${esc(s.browser)}</span>
273
+ ${s.startMs && !['passed','failed','cancelled'].includes(s.status) ? `<span>⏱ ${fmtDur(Date.now() - s.startMs)}</span>` : ''}
274
+ ${s.finalDur ? `<span>⏱ ${fmtDur(s.finalDur)}</span>` : ''}
275
+ </div>
276
+ </div>
277
+ ${badgeMap[s.status] || ''}
278
+ </div>
279
+ <div class="prog-wrap"><div class="prog-fill ${fillCls}" style="width:${pct}%"></div></div>
280
+ <div class="card-bottom">
281
+ <div class="card-counts">
282
+ <span class="cp">✅ ${s.passed}</span>
283
+ <span class="cf">❌ ${s.failed}</span>
284
+ <span class="cs">⏭ ${s.skipped}</span>
285
+ <span>${pct}%</span>
286
+ </div>
287
+ <a class="btn-view" ${viewAttrs}>View →</a>
288
+ </div>`;
289
+ }
290
+
291
+ function buildGrid() {
292
+ const grid = $('scriptsGrid');
293
+ // First call — create all card shells in order
294
+ grid.innerHTML = Object.keys(scripts).map(uid =>
295
+ `<div class="script-card pending" id="${cardId(uid)}"></div>`
296
+ ).join('');
297
+ // Then populate each
298
+ Object.keys(scripts).forEach(renderCard);
299
+ }
300
+
301
+ // ── Load initial script list from backend ─────────────────────────────────────
302
+
303
+ async function loadInfo() {
304
+ try {
305
+ const r = await fetch(`/api/execution/${execId}/info`);
306
+ const data = await r.json();
307
+ if (!data.scripts) return;
308
+
309
+ execInfo = data;
310
+
311
+ // Update mode badge
312
+ $('modeTag').textContent = data.parallel
313
+ ? `PARALLEL (max ${data.max_parallel})`
314
+ : 'SEQUENTIAL';
315
+
316
+ $('execLabel').textContent = data.test_set_name
317
+ ? `Set: ${data.test_set_name} · ID: ${execId}`
318
+ : 'ID: ' + execId;
319
+ $('sumMode').textContent = data.parallel ? 'Parallel' : 'Sequential';
320
+ $('sumScripts').textContent = data.scripts.length;
321
+
322
+ const label = data.parallel
323
+ ? `${data.scripts.length} Scripts — Parallel (max ${data.max_parallel})`
324
+ : `${data.scripts.length} Scripts — Sequential`;
325
+ $('scriptsLabel').textContent = label;
326
+
327
+ // Update browser tab title
328
+ if (data.test_set_name) {
329
+ document.title = `${data.test_set_name} — Execution Overview`;
330
+ document.querySelector('.h-title').textContent = data.test_set_name;
331
+ }
332
+
333
+ // Pre-populate script state map in order
334
+ data.scripts.forEach(s => {
335
+ scripts[s.test_script_uid] = {
336
+ name: s.test_case_name || s.test_script_uid,
337
+ totalSteps: s.total_steps,
338
+ browser: s.browser || 'chromium',
339
+ status: 'pending',
340
+ passed: 0, failed: 0, skipped: 0,
341
+ startMs: null,
342
+ finalDur: null,
343
+ };
344
+ });
345
+
346
+ buildGrid();
347
+ updateTopStats();
348
+ } catch(e) {
349
+ log('Failed to load execution info: ' + e.message, 'lf');
350
+ }
351
+ }
352
+
353
+ // ── SSE ───────────────────────────────────────────────────────────────────────
354
+
355
+ const es = new EventSource('/api/stream/' + execId);
356
+
357
+ es.onopen = () => {
358
+ log('Connected to execution stream.', 'ly');
359
+ if (!timerInt) timerInt = setInterval(() => {
360
+ // Refresh durations on running cards
361
+ Object.keys(scripts).forEach(uid => {
362
+ if (scripts[uid].status === 'running') renderCard(uid);
363
+ });
364
+ updateTopStats();
365
+ }, 1000);
366
+ };
367
+
368
+ es.onmessage = e => {
369
+ let ev;
370
+ try { ev = JSON.parse(e.data); } catch { return; }
371
+ const { type, data } = ev;
372
+
373
+ if (type === 'log') { log(data.message || '', ''); return; }
374
+ if (type === 'queue_update') return;
375
+
376
+ // ── execution_start ──────────────────────────────────────────────────────
377
+ if (type === 'execution_start') {
378
+ startMs = Date.now();
379
+ if (!timerInt) timerInt = setInterval(updateTopStats, 1000);
380
+ setStatus('RUNNING', 'badge-running');
381
+ $('cancelBtn').disabled = false;
382
+ log(`Execution started — ${data.total_scripts} script(s) | ${data.parallel ? 'Parallel' : 'Sequential'}`, 'lr');
383
+ }
384
+
385
+ // ── script_start ─────────────────────────────────────────────────────────
386
+ if (type === 'script_start') {
387
+ const uid = data.test_script_uid;
388
+ if (!scripts[uid]) {
389
+ // Fallback: script wasn't in info (shouldn't happen but handle gracefully)
390
+ scripts[uid] = {
391
+ name: data.test_case_name || uid,
392
+ totalSteps: data.total_steps || 0,
393
+ browser: data.browser || 'chromium',
394
+ status: 'running',
395
+ passed: 0, failed: 0, skipped: 0,
396
+ startMs: Date.now(), finalDur: null,
397
+ };
398
+ // Add a new card to the grid
399
+ const shell = document.createElement('div');
400
+ shell.className = 'script-card running';
401
+ shell.id = cardId(uid);
402
+ $('scriptsGrid').appendChild(shell);
403
+ } else {
404
+ scripts[uid].status = 'running';
405
+ scripts[uid].startMs = Date.now();
406
+ }
407
+ renderCard(uid);
408
+ updateTopStats();
409
+ log(`▶ Script started: "${esc(scripts[uid].name)}"`, 'lr');
410
+ }
411
+
412
+ // ── step_complete — update per-script counts ─────────────────────────────
413
+ if (type === 'step_complete') {
414
+ const uid = data.test_script_uid;
415
+ if (!uid || !scripts[uid]) return;
416
+ const s = scripts[uid];
417
+ if (data.status === 'PASS') s.passed++;
418
+ else if (data.status === 'FAIL') s.failed++;
419
+ else if (data.status === 'SKIP') s.skipped++;
420
+ renderCard(uid);
421
+ }
422
+
423
+ // ── script_complete ───────────────────────────────────────────────────────
424
+ if (type === 'script_complete') {
425
+ const uid = data.test_script_uid;
426
+ if (!uid || !scripts[uid]) return;
427
+ const s = scripts[uid];
428
+ s.status = data.overall_status === 'PASS' ? 'passed' : 'failed';
429
+ s.passed = data.passed_steps;
430
+ s.failed = data.failed_steps;
431
+ s.skipped = data.skipped_steps;
432
+ s.finalDur = data.duration;
433
+ renderCard(uid);
434
+ updateTopStats();
435
+ const icon = s.status === 'passed' ? '✅' : '❌';
436
+ log(`${icon} Script done: "${esc(s.name)}" — ${data.overall_status} | ✅${data.passed_steps} ❌${data.failed_steps} ⏭${data.skipped_steps} (${fmtDur(data.duration)})`,
437
+ s.status === 'passed' ? 'lp' : 'lf');
438
+ }
439
+
440
+ // ── execution_cancelled ───────────────────────────────────────────────────
441
+ if (type === 'execution_cancelled') {
442
+ isDone = true; clearInterval(timerInt);
443
+ $('cancelBtn').disabled = true;
444
+ setStatus('CANCELLED', 'badge-idle');
445
+ // Mark any still-pending/running scripts as cancelled
446
+ Object.values(scripts).forEach(s => {
447
+ if (s.status === 'pending' || s.status === 'running') s.status = 'cancelled';
448
+ });
449
+ buildGrid();
450
+ updateTopStats();
451
+ updateSummary('CANCELLED');
452
+ log('⏹ Execution cancelled', 'ly');
453
+ es.close();
454
+ }
455
+
456
+ // ── execution_complete ────────────────────────────────────────────────────
457
+ if (type === 'execution_complete') {
458
+ isDone = true; clearInterval(timerInt);
459
+ $('cancelBtn').disabled = true;
460
+ const ok = data.status === 'PASS';
461
+ const dur = fmtDur(data.duration || 0);
462
+ setStatus(ok ? '✅ PASSED' : '❌ FAILED', ok ? 'badge-pass' : 'badge-fail');
463
+ $('stDur').textContent = dur;
464
+ updateTopStats();
465
+ updateSummary(data.status, dur);
466
+ log(`🏁 Execution ${data.status} | ✅${data.passed} ❌${data.failed} scripts | ${dur}`, ok ? 'lp' : 'lf');
467
+ es.close();
468
+ }
469
+
470
+ if (type === 'error') {
471
+ setStatus('ERROR', 'badge-fail');
472
+ log('ERROR: ' + data.message, 'lf');
473
+ isDone = true; clearInterval(timerInt);
474
+ $('cancelBtn').disabled = true;
475
+ es.close();
476
+ }
477
+ };
478
+
479
+ es.onerror = () => { if (!isDone) log('Stream disconnected.', 'lf'); es.close(); };
480
+
481
+ function updateSummary(status, dur) {
482
+ const list = Object.values(scripts);
483
+ const passed = list.filter(s => s.status === 'passed').length;
484
+ const failed = list.filter(s => s.status !== 'passed').length;
485
+ $('sumStatus').textContent = status;
486
+ $('sumStatus').style.color = status === 'PASS' ? 'var(--pass)' : status === 'CANCELLED' ? 'var(--cancel)' : 'var(--fail)';
487
+ $('sumPass').textContent = passed;
488
+ $('sumFail').textContent = failed;
489
+ $('sumDur').textContent = dur || $('stDur').textContent;
490
+ }
491
+
492
+ async function cancelExecution() {
493
+ if (!confirm('Cancel? Running steps will finish, then remaining steps are skipped.')) return;
494
+ $('cancelBtn').disabled = true;
495
+ try {
496
+ const r = await fetch(`/api/cancel/${execId}`, { method: 'DELETE' });
497
+ const d = await r.json();
498
+ log('⏹ ' + d.message, 'ly');
499
+ } catch (e) {
500
+ log('Cancel failed: ' + e.message, 'lf');
501
+ $('cancelBtn').disabled = false;
502
+ }
503
+ }
504
+
505
+ // ── Boot ──────────────────────────────────────────────────────────────────────
506
+ $('execLabel').textContent = 'ID: ' + execId;
507
+ loadInfo();
508
+ </script>
509
+ </body>
510
+ </html>