omnilane 0.7.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/ui/app.js ADDED
@@ -0,0 +1,1106 @@
1
+ "use strict";
2
+
3
+ (function () {
4
+ const TOKEN_STORAGE_KEY = "omnilane.live-ui.token";
5
+ const DETAIL_CACHE_LIMIT = 50, DETAIL_PREFETCH_LIMIT = 12, DETAIL_CONCURRENCY = 3;
6
+ const OUTPUT_BOTTOM_THRESHOLD = 28;
7
+ const MOBILE_QUERY = "(max-width: 760px)";
8
+ const VALID_STATES = new Set(["starting", "running", "succeeded", "failed", "dead", "invalid"]);
9
+
10
+ const elements = {
11
+ connection: document.getElementById("connection-status"),
12
+ connectionLabel: document.getElementById("connection-label"),
13
+ jobCount: document.getElementById("job-count"), search: document.getElementById("job-search"),
14
+ filter: document.getElementById("status-filter"),
15
+ filterButtons: Array.from(document.querySelectorAll(".filter-button")),
16
+ jobList: document.getElementById("job-list"), listMessage: document.getElementById("list-message"),
17
+ inspector: document.querySelector(".job-inspector"), inspectorState: document.getElementById("inspector-state"),
18
+ stateCode: document.getElementById("state-code"), stateTitle: document.getElementById("state-title"),
19
+ stateMessage: document.getElementById("state-message"), mobileBack: document.getElementById("mobile-back"),
20
+ jobDetail: document.getElementById("job-detail"), selectedJobId: document.getElementById("selected-job-id"),
21
+ selectedJobState: document.getElementById("selected-job-state"), selectedJobTime: document.getElementById("selected-job-time"),
22
+ routeTrack: document.getElementById("route-track"), routeLane: document.getElementById("route-lane"),
23
+ routeVendor: document.getElementById("route-vendor"), routeModel: document.getElementById("route-model"),
24
+ routeState: document.getElementById("route-state"), factEffort: document.getElementById("fact-effort"),
25
+ factMode: document.getElementById("fact-mode"), factTimeout: document.getElementById("fact-timeout"),
26
+ factCandidate: document.getElementById("fact-candidate"), factStarted: document.getElementById("fact-started"),
27
+ factWorkdir: document.getElementById("fact-workdir"), requestMarkers: document.getElementById("request-markers"),
28
+ requestEmpty: document.getElementById("request-empty"), requestContent: document.getElementById("request-content"),
29
+ resultMarkers: document.getElementById("result-markers"), resultEmpty: document.getElementById("result-empty"),
30
+ resultContent: document.getElementById("result-content"), compareToggle: document.getElementById("compare-toggle"),
31
+ compareReferenceLabel: document.getElementById("compare-reference-label"), comparePanel: document.getElementById("compare-panel"),
32
+ compareClear: document.getElementById("compare-clear"), compareReferenceId: document.getElementById("compare-reference-id"),
33
+ compareReferenceLane: document.getElementById("compare-reference-lane"), compareReferenceVendor: document.getElementById("compare-reference-vendor"),
34
+ compareReferenceModel: document.getElementById("compare-reference-model"), compareReferenceState: document.getElementById("compare-reference-state"),
35
+ compareReferenceOutput: document.getElementById("compare-reference-output"), compareCurrentId: document.getElementById("compare-current-id"),
36
+ compareCurrentLane: document.getElementById("compare-current-lane"), compareCurrentVendor: document.getElementById("compare-current-vendor"),
37
+ compareCurrentModel: document.getElementById("compare-current-model"), compareCurrentState: document.getElementById("compare-current-state"),
38
+ compareCurrentOutput: document.getElementById("compare-current-output"),
39
+ };
40
+
41
+ const state = {
42
+ token: readToken(), jobs: [], selectedId: null,
43
+ query: "", filter: "all", eventSource: null,
44
+ detailCache: new Map(), detailInFlight: new Map(), detailQueue: [],
45
+ activeDetailRequests: 0, detailGeneration: 0, detailSequence: 0,
46
+ hasSnapshot: false, unauthorized: false, authProbeInFlight: false,
47
+ reconnectTimer: null, mobileListScroll: 0, mobileFocusId: null,
48
+ currentDetail: null, compareReference: null,
49
+ };
50
+
51
+ function boardUrl() { return window.location.pathname + window.location.search; }
52
+
53
+ function readToken() {
54
+ const fragment = new URLSearchParams(window.location.hash.slice(1));
55
+ const fragmentToken = fragment.get("token");
56
+
57
+ if (fragmentToken) {
58
+ try {
59
+ window.sessionStorage.setItem(TOKEN_STORAGE_KEY, fragmentToken);
60
+ } catch (_error) {
61
+ // Keep the fragment token in memory when session storage is unavailable.
62
+ }
63
+ window.history.replaceState(null, document.title, window.location.pathname + window.location.search);
64
+ return fragmentToken;
65
+ }
66
+
67
+ if (window.location.hash) {
68
+ window.history.replaceState(null, document.title, window.location.pathname + window.location.search);
69
+ }
70
+
71
+ try {
72
+ return window.sessionStorage.getItem(TOKEN_STORAGE_KEY);
73
+ } catch (_error) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function clearStoredToken() {
79
+ try {
80
+ window.sessionStorage.removeItem(TOKEN_STORAGE_KEY);
81
+ } catch (_error) {
82
+ // An unavailable session store has nothing useful to clear.
83
+ }
84
+ }
85
+
86
+ function setText(element, value) { element.textContent = value; }
87
+
88
+ function textOrFallback(value, fallback) { return typeof value === "string" && value.length > 0 ? value : fallback; }
89
+
90
+ function setConnection(mode, message) {
91
+ elements.connection.dataset.mode = mode;
92
+ setText(elements.connectionLabel, message);
93
+ }
94
+
95
+ function setControlsDisabled(disabled) {
96
+ elements.search.disabled = disabled;
97
+ elements.filterButtons.forEach(function (button) {
98
+ button.disabled = disabled;
99
+ });
100
+ }
101
+
102
+ function showInspectorState(code, title, message) {
103
+ setText(elements.stateCode, code);
104
+ setText(elements.stateTitle, title);
105
+ setText(elements.stateMessage, message);
106
+ elements.inspectorState.hidden = false;
107
+ elements.jobDetail.hidden = true;
108
+ }
109
+
110
+ function isAuthError(error) { return Boolean(error && (error.status === 401 || error.status === 403)); }
111
+
112
+ function cancelDetailRequests(clearCache) {
113
+ state.detailGeneration += 1;
114
+ state.detailQueue.splice(0).forEach(function (entry) {
115
+ entry.resolve(null);
116
+ });
117
+ state.detailInFlight.forEach(function (entry) {
118
+ entry.controller.abort();
119
+ });
120
+ state.detailInFlight.clear();
121
+ if (clearCache) {
122
+ state.detailCache.clear();
123
+ }
124
+ }
125
+
126
+ function showUnauthorized() {
127
+ if (state.unauthorized && state.token === null && state.jobs.length === 0) {
128
+ return;
129
+ }
130
+ state.unauthorized = true;
131
+ state.token = null;
132
+ state.jobs = [];
133
+ state.selectedId = null;
134
+ state.currentDetail = null;
135
+ state.compareReference = null;
136
+ state.hasSnapshot = false;
137
+ clearStoredToken();
138
+ closeEventStream();
139
+ cancelDetailRequests(true);
140
+ setConnection("unauthorized", "Not authorized");
141
+ setText(elements.jobCount, "0 jobs");
142
+ setControlsDisabled(true);
143
+ renderQueue();
144
+ setMobileView("list", false);
145
+ showInspectorState(
146
+ "Local · read only",
147
+ "Local access required",
148
+ "Run `omnilane ui url` for a fresh local link."
149
+ );
150
+ }
151
+
152
+ function showNoJobs() {
153
+ showInspectorState(
154
+ "Queue · empty",
155
+ "No tasks yet",
156
+ "Run an Omnilane task. This board will update automatically."
157
+ );
158
+ }
159
+
160
+ function showNoMatches() {
161
+ showInspectorState(
162
+ "Filter · no match",
163
+ "No matching tasks",
164
+ "Clear the search or choose another state."
165
+ );
166
+ }
167
+
168
+ function showReconnecting() {
169
+ setConnection("reconnecting", "Reconnecting");
170
+ if (!state.hasSnapshot) {
171
+ showInspectorState(
172
+ "Link · retrying",
173
+ "Local board unavailable",
174
+ "Reconnecting to the local job board."
175
+ );
176
+ }
177
+ }
178
+
179
+ function normalizeSummary(value) {
180
+ if (!value || typeof value !== "object" || typeof value.id !== "string") {
181
+ return null;
182
+ }
183
+
184
+ const meta = value.meta && typeof value.meta === "object" ? value.meta : {};
185
+ const jobState = VALID_STATES.has(value.state) ? value.state : "invalid";
186
+ const exitCode = Number.isInteger(value.exitCode) ? value.exitCode : null;
187
+
188
+ return {
189
+ id: value.id,
190
+ state: jobState,
191
+ exitCode: exitCode,
192
+ meta: meta,
193
+ signals: value.signals && typeof value.signals === "object" ? value.signals : {},
194
+ };
195
+ }
196
+
197
+ function summarySignature(summary) {
198
+ if (!summary) {
199
+ return "";
200
+ }
201
+ return JSON.stringify({
202
+ state: summary.state,
203
+ exitCode: summary.exitCode,
204
+ meta: summary.meta,
205
+ signals: summary.signals,
206
+ });
207
+ }
208
+
209
+ function summaryById(jobId) {
210
+ return state.jobs.find(function (job) {
211
+ return job.id === jobId;
212
+ }) || null;
213
+ }
214
+
215
+ function cachedDetail(jobId) {
216
+ const entry = state.detailCache.get(jobId);
217
+ const summary = summaryById(jobId);
218
+ if (!entry || !summary || entry.signature !== summarySignature(summary)) {
219
+ return null;
220
+ }
221
+ state.detailCache.delete(jobId);
222
+ state.detailCache.set(jobId, entry);
223
+ return entry.detail;
224
+ }
225
+
226
+ function taskSummary(job) {
227
+ const detail = cachedDetail(job.id);
228
+ if (!detail || typeof detail.task !== "string" || detail.task.trim().length === 0) {
229
+ return "Loading task…";
230
+ }
231
+ const compact = detail.task.replace(/\s+/g, " ").trim();
232
+ return compact.length > 150 ? compact.slice(0, 147) + "…" : compact;
233
+ }
234
+
235
+ function activeFilterMatches(job) {
236
+ if (state.filter === "active") {
237
+ return job.state === "starting" || job.state === "running";
238
+ }
239
+ if (state.filter === "succeeded") {
240
+ return job.state === "succeeded";
241
+ }
242
+ if (state.filter === "issues") {
243
+ return job.state === "failed" || job.state === "dead" || job.state === "invalid";
244
+ }
245
+ return true;
246
+ }
247
+
248
+ function searchMatches(job) {
249
+ if (!state.query) {
250
+ return true;
251
+ }
252
+ const meta = job.meta;
253
+ const searchText = [
254
+ job.id,
255
+ job.state,
256
+ taskSummary(job),
257
+ textOrFallback(meta.lane, ""),
258
+ textOrFallback(meta.vendor, ""),
259
+ textOrFallback(meta.model, ""),
260
+ ].join(" ").toLocaleLowerCase();
261
+ return searchText.includes(state.query);
262
+ }
263
+
264
+ function visibleJobs() {
265
+ return state.jobs.filter(function (job) {
266
+ return activeFilterMatches(job) && searchMatches(job);
267
+ });
268
+ }
269
+
270
+ function routeLabel(job) {
271
+ const meta = job.meta;
272
+ return [
273
+ textOrFallback(meta.lane, "Unknown lane"),
274
+ textOrFallback(meta.vendor, "Unknown vendor"),
275
+ textOrFallback(meta.model, "Unknown model"),
276
+ ].join(" · ");
277
+ }
278
+
279
+ function createTextElement(tagName, className) {
280
+ const element = document.createElement(tagName);
281
+ element.className = className;
282
+ return element;
283
+ }
284
+
285
+ function createJobRow(job) {
286
+ const item = document.createElement("li");
287
+ item.dataset.jobId = job.id;
288
+ const button = document.createElement("button");
289
+ button.type = "button";
290
+ button.className = "job-card";
291
+ button.dataset.jobId = job.id;
292
+ button.appendChild(createTextElement("span", "card-task"));
293
+ button.appendChild(createTextElement("span", "card-job-id"));
294
+ button.appendChild(createTextElement("span", "card-route"));
295
+ button.appendChild(createTextElement("span", "card-state"));
296
+ button.addEventListener("click", function () {
297
+ selectJob(button.dataset.jobId, true, true);
298
+ });
299
+ item.appendChild(button);
300
+ return item;
301
+ }
302
+
303
+ function updateJobRow(item, job) {
304
+ item.dataset.jobId = job.id;
305
+ const button = item.querySelector(".job-card");
306
+ button.dataset.jobId = job.id;
307
+ button.className = "job-card";
308
+ if (job.id === state.selectedId) {
309
+ button.classList.add("is-selected");
310
+ button.setAttribute("aria-current", "true");
311
+ } else {
312
+ button.removeAttribute("aria-current");
313
+ }
314
+ setText(button.querySelector(".card-task"), taskSummary(job));
315
+ setText(button.querySelector(".card-job-id"), job.id);
316
+ setText(button.querySelector(".card-route"), routeLabel(job));
317
+ const stateElement = button.querySelector(".card-state");
318
+ stateElement.className = "card-state state-" + job.state;
319
+ setText(stateElement, job.state);
320
+ }
321
+
322
+ function renderQueue() {
323
+ const jobs = visibleJobs();
324
+ const listScroll = elements.jobList.scrollTop;
325
+ const inspectorScroll = elements.inspector.scrollTop;
326
+ const focusedCard = document.activeElement && document.activeElement.closest
327
+ ? document.activeElement.closest(".job-card")
328
+ : null;
329
+ const focusedJobId = focusedCard ? focusedCard.dataset.jobId : null;
330
+ const existing = new Map();
331
+ Array.from(elements.jobList.children).forEach(function (item) {
332
+ existing.set(item.dataset.jobId, item);
333
+ });
334
+
335
+ let insertionPoint = elements.jobList.firstChild;
336
+ jobs.forEach(function (job) {
337
+ const item = existing.get(job.id) || createJobRow(job);
338
+ existing.delete(job.id);
339
+ updateJobRow(item, job);
340
+ if (item !== insertionPoint) {
341
+ elements.jobList.insertBefore(item, insertionPoint);
342
+ }
343
+ insertionPoint = item.nextSibling;
344
+ });
345
+ existing.forEach(function (item) {
346
+ item.remove();
347
+ });
348
+ elements.jobList.scrollTop = listScroll;
349
+ elements.inspector.scrollTop = inspectorScroll;
350
+ if (focusedJobId) {
351
+ const focusTarget = elements.jobList.querySelector(
352
+ '.job-card[data-job-id="' + CSS.escape(focusedJobId) + '"]'
353
+ );
354
+ if (focusTarget && document.activeElement !== focusTarget) {
355
+ focusTarget.focus({ preventScroll: true });
356
+ }
357
+ }
358
+
359
+ if (state.jobs.length === 0) {
360
+ setText(elements.listMessage, state.unauthorized ? "A fresh local link is required." : "No tasks yet.");
361
+ elements.listMessage.hidden = false;
362
+ } else if (jobs.length === 0) {
363
+ setText(elements.listMessage, "No tasks match this filter.");
364
+ elements.listMessage.hidden = false;
365
+ } else {
366
+ elements.listMessage.hidden = true;
367
+ }
368
+
369
+ const countLabel = jobs.length === state.jobs.length
370
+ ? String(state.jobs.length) + (state.jobs.length === 1 ? " job" : " jobs")
371
+ : String(jobs.length) + " / " + String(state.jobs.length) + " jobs";
372
+ setText(elements.jobCount, countLabel);
373
+ }
374
+
375
+ function reconcileSelection() {
376
+ const jobs = visibleJobs();
377
+ const selectedVisible = jobs.some(function (job) {
378
+ return job.id === state.selectedId;
379
+ });
380
+ if (!selectedVisible) {
381
+ state.selectedId = jobs.length > 0 ? jobs[0].id : null;
382
+ }
383
+ return jobs;
384
+ }
385
+
386
+ function currentSummary() {
387
+ return summaryById(state.selectedId);
388
+ }
389
+
390
+ function updateFilterSelection() {
391
+ const oldId = state.selectedId;
392
+ const jobs = reconcileSelection();
393
+ renderQueue();
394
+
395
+ if (jobs.length === 0) {
396
+ if (state.jobs.length === 0) {
397
+ showNoJobs();
398
+ } else {
399
+ showNoMatches();
400
+ }
401
+ return;
402
+ }
403
+
404
+ if (oldId !== state.selectedId || elements.jobDetail.hidden) {
405
+ selectJob(state.selectedId, true, false);
406
+ }
407
+ }
408
+
409
+ function cacheDetail(jobId, signature, detail) {
410
+ state.detailCache.delete(jobId);
411
+ state.detailCache.set(jobId, { signature: signature, detail: detail });
412
+ while (state.detailCache.size > DETAIL_CACHE_LIMIT) {
413
+ state.detailCache.delete(state.detailCache.keys().next().value);
414
+ }
415
+ }
416
+
417
+ function drainDetailQueue() {
418
+ while (state.activeDetailRequests < DETAIL_CONCURRENCY && state.detailQueue.length > 0) {
419
+ const entry = state.detailQueue.shift();
420
+ state.activeDetailRequests += 1;
421
+ requestJson("/api/jobs/" + encodeURIComponent(entry.jobId), entry.controller.signal)
422
+ .then(function (payload) {
423
+ if (!payload || payload.ok !== true || !payload.job || typeof payload.job !== "object") {
424
+ throw new Error("Invalid detail response");
425
+ }
426
+ if (entry.generation === state.detailGeneration) {
427
+ cacheDetail(entry.jobId, entry.signature, payload.job);
428
+ }
429
+ entry.resolve(payload.job);
430
+ })
431
+ .catch(function (error) {
432
+ if (isAuthError(error)) {
433
+ showUnauthorized();
434
+ entry.resolve(null);
435
+ return;
436
+ }
437
+ if (error && error.name === "AbortError") {
438
+ entry.resolve(null);
439
+ return;
440
+ }
441
+ entry.reject(error);
442
+ })
443
+ .finally(function () {
444
+ state.activeDetailRequests = Math.max(0, state.activeDetailRequests - 1);
445
+ state.detailInFlight.delete(entry.jobId);
446
+ drainDetailQueue();
447
+ });
448
+ }
449
+ }
450
+
451
+ function requestDetail(jobId, priority) {
452
+ const cached = cachedDetail(jobId);
453
+ if (cached) {
454
+ return Promise.resolve(cached);
455
+ }
456
+ const existing = state.detailInFlight.get(jobId);
457
+ if (existing) {
458
+ return existing.promise;
459
+ }
460
+ const summary = summaryById(jobId);
461
+ if (!summary || state.unauthorized || !state.token) {
462
+ return Promise.resolve(null);
463
+ }
464
+
465
+ const controller = new AbortController();
466
+ let resolveRequest;
467
+ let rejectRequest;
468
+ const promise = new Promise(function (resolve, reject) {
469
+ resolveRequest = resolve;
470
+ rejectRequest = reject;
471
+ });
472
+ const entry = {
473
+ jobId: jobId,
474
+ signature: summarySignature(summary),
475
+ generation: state.detailGeneration,
476
+ controller: controller,
477
+ promise: promise,
478
+ resolve: resolveRequest,
479
+ reject: rejectRequest,
480
+ };
481
+ state.detailInFlight.set(jobId, entry);
482
+ if (priority) {
483
+ state.detailQueue.unshift(entry);
484
+ } else {
485
+ state.detailQueue.push(entry);
486
+ }
487
+ drainDetailQueue();
488
+ return promise;
489
+ }
490
+
491
+ function prefetchVisibleTasks(jobs) {
492
+ jobs.slice(0, DETAIL_PREFETCH_LIMIT).forEach(function (job) {
493
+ requestDetail(job.id, false)
494
+ .then(function (detail) {
495
+ if (!detail) {
496
+ return;
497
+ }
498
+ const item = elements.jobList.querySelector('li[data-job-id="' + CSS.escape(job.id) + '"]');
499
+ const current = summaryById(job.id);
500
+ if (item && current) {
501
+ updateJobRow(item, current);
502
+ } else if (state.query) {
503
+ renderQueue();
504
+ }
505
+ })
506
+ .catch(function () {
507
+ // List summaries are optional; selected detail reports actionable failures.
508
+ });
509
+ });
510
+ }
511
+
512
+ function applySnapshot(payload) {
513
+ if (!payload || payload.ok !== true || !Array.isArray(payload.jobs)) {
514
+ return;
515
+ }
516
+
517
+ const previousSummary = currentSummary();
518
+ const previousSignature = summarySignature(previousSummary);
519
+ const previousId = state.selectedId;
520
+ state.jobs = payload.jobs.map(normalizeSummary).filter(Boolean);
521
+ state.hasSnapshot = true;
522
+ state.unauthorized = false;
523
+ setControlsDisabled(false);
524
+ setConnection("live", "Live local signal");
525
+
526
+ const jobs = reconcileSelection();
527
+ renderQueue();
528
+ prefetchVisibleTasks(jobs);
529
+
530
+ if (state.jobs.length === 0) {
531
+ state.selectedId = null;
532
+ showNoJobs();
533
+ return;
534
+ }
535
+
536
+ if (jobs.length === 0) {
537
+ showNoMatches();
538
+ return;
539
+ }
540
+
541
+ const selectedSummary = currentSummary();
542
+ renderSummary(selectedSummary);
543
+ const signatureChanged = previousSignature !== summarySignature(selectedSummary);
544
+ if (previousId !== state.selectedId || signatureChanged) {
545
+ state.currentDetail = null;
546
+ renderCompare(null);
547
+ fetchDetail(state.selectedId, previousId !== state.selectedId);
548
+ } else {
549
+ const detail = cachedDetail(state.selectedId);
550
+ if (detail) {
551
+ renderDetail(detail);
552
+ }
553
+ }
554
+ }
555
+
556
+ function setStateClass(element, jobState) {
557
+ VALID_STATES.forEach(function (name) {
558
+ element.classList.remove("state-" + name);
559
+ });
560
+ element.classList.add("state-" + jobState);
561
+ }
562
+
563
+ function metadataValue(meta, name, fallback) {
564
+ return textOrFallback(meta[name], fallback);
565
+ }
566
+
567
+ function detailSnapshot(detail, fallbackSummary) {
568
+ if (!detail || typeof detail !== "object") {
569
+ return null;
570
+ }
571
+ const summary = normalizeSummary(detail.summary) || fallbackSummary;
572
+ if (!summary) {
573
+ return null;
574
+ }
575
+ return {
576
+ summary: {
577
+ id: summary.id,
578
+ state: summary.state,
579
+ exitCode: summary.exitCode,
580
+ meta: Object.assign({}, summary.meta),
581
+ signals: {},
582
+ },
583
+ output: typeof detail.output === "string" ? detail.output : "",
584
+ outputTruncated: detail.outputTruncated === true,
585
+ invalidFiles: Array.isArray(detail.invalidFiles) ? detail.invalidFiles.slice() : [],
586
+ };
587
+ }
588
+
589
+ function comparisonOutput(snapshot) {
590
+ const invalidFiles = invalidFileSet(snapshot);
591
+ if (invalidFiles.has("out.txt")) {
592
+ return "The public result could not be read safely.";
593
+ }
594
+ if (snapshot.output.length > 0) {
595
+ return snapshot.output;
596
+ }
597
+ return emptyResultMessage(snapshot.summary);
598
+ }
599
+
600
+ function renderComparisonSide(prefix, snapshot) {
601
+ const summary = snapshot.summary;
602
+ const meta = summary.meta;
603
+ setText(elements[prefix + "Id"], summary.id);
604
+ setText(elements[prefix + "Lane"], metadataValue(meta, "lane", "Unknown"));
605
+ setText(elements[prefix + "Vendor"], metadataValue(meta, "vendor", "Unknown"));
606
+ setText(elements[prefix + "Model"], metadataValue(meta, "model", "Unknown"));
607
+ setText(elements[prefix + "State"], summary.state);
608
+ setText(elements[prefix + "Output"], comparisonOutput(snapshot));
609
+ }
610
+
611
+ function renderCompare(detail) {
612
+ const current = detailSnapshot(detail, currentSummary());
613
+ const reference = state.compareReference;
614
+ const isReference = Boolean(reference && current && reference.summary.id === current.summary.id);
615
+ elements.compareToggle.disabled = !current;
616
+ elements.compareToggle.setAttribute("aria-pressed", isReference ? "true" : "false");
617
+ setText(
618
+ elements.compareToggle,
619
+ isReference ? "Unpin reference" : reference ? "Replace reference" : "Pin for compare"
620
+ );
621
+ setText(
622
+ elements.compareReferenceLabel,
623
+ reference ? "Reference " + reference.summary.id + " pinned" : "No reference pinned"
624
+ );
625
+
626
+ if (!reference || !current || isReference) {
627
+ elements.comparePanel.hidden = true;
628
+ return;
629
+ }
630
+ renderComparisonSide("compareReference", reference);
631
+ renderComparisonSide("compareCurrent", current);
632
+ elements.comparePanel.hidden = false;
633
+ }
634
+
635
+ function toggleCompareReference() {
636
+ const current = detailSnapshot(state.currentDetail, currentSummary());
637
+ if (!current) {
638
+ return;
639
+ }
640
+ if (state.compareReference && state.compareReference.summary.id === current.summary.id) {
641
+ state.compareReference = null;
642
+ } else {
643
+ state.compareReference = current;
644
+ }
645
+ renderCompare(state.currentDetail);
646
+ }
647
+
648
+ function clearCompareReference() {
649
+ state.compareReference = null;
650
+ renderCompare(state.currentDetail);
651
+ elements.compareToggle.focus();
652
+ }
653
+
654
+ function renderSummary(summary) {
655
+ if (!summary) {
656
+ return;
657
+ }
658
+ const meta = summary.meta;
659
+ elements.inspectorState.hidden = true;
660
+ elements.jobDetail.hidden = false;
661
+ setText(elements.selectedJobId, summary.id);
662
+ setText(elements.selectedJobState, summary.state);
663
+ setText(elements.selectedJobTime, "Started " + metadataValue(meta, "started", "time unavailable"));
664
+ setStateClass(elements.selectedJobState, summary.state);
665
+ elements.routeTrack.dataset.state = summary.state;
666
+ setText(elements.routeLane, metadataValue(meta, "lane", "Unknown"));
667
+ setText(elements.routeVendor, metadataValue(meta, "vendor", "Unknown"));
668
+ setText(elements.routeModel, metadataValue(meta, "model", "Unknown"));
669
+ setText(elements.routeState, summary.state);
670
+ setText(elements.factEffort, metadataValue(meta, "effort", "Not recorded"));
671
+ setText(elements.factMode, metadataValue(meta, "mode", "Not recorded"));
672
+ setText(elements.factTimeout, Number.isInteger(meta.timeout) ? String(meta.timeout) + " s" : "Not recorded");
673
+ setText(elements.factCandidate, metadataValue(meta, "candidate", "Not recorded"));
674
+ setText(elements.factStarted, metadataValue(meta, "started", "Not recorded"));
675
+ setText(elements.factWorkdir, metadataValue(meta, "workdir", "Not recorded"));
676
+ }
677
+
678
+ function isMobile() {
679
+ return window.matchMedia(MOBILE_QUERY).matches;
680
+ }
681
+
682
+ function rememberMobileListState() {
683
+ state.mobileListScroll = elements.jobList.scrollTop;
684
+ const active = document.activeElement && document.activeElement.closest
685
+ ? document.activeElement.closest(".job-card")
686
+ : null;
687
+ state.mobileFocusId = active ? active.dataset.jobId : state.selectedId;
688
+ }
689
+
690
+ function restoreMobileListState() {
691
+ window.requestAnimationFrame(function () {
692
+ elements.jobList.scrollTop = state.mobileListScroll;
693
+ if (!state.mobileFocusId) {
694
+ return;
695
+ }
696
+ const button = elements.jobList.querySelector(
697
+ '.job-card[data-job-id="' + CSS.escape(state.mobileFocusId) + '"]'
698
+ );
699
+ if (button) {
700
+ button.focus({ preventScroll: true });
701
+ }
702
+ });
703
+ }
704
+
705
+ function setMobileView(view, restore) {
706
+ document.body.dataset.mobileView = view;
707
+ elements.mobileBack.hidden = view !== "detail";
708
+ if (view === "list" && restore) {
709
+ restoreMobileListState();
710
+ }
711
+ }
712
+
713
+ function enterMobileDetail(jobId, pushHistory) {
714
+ setMobileView("detail", false);
715
+ elements.inspector.scrollTop = 0;
716
+ if (pushHistory) {
717
+ window.history.pushState(
718
+ { omnilaneLiveBoard: true, view: "detail", jobId: jobId },
719
+ document.title,
720
+ boardUrl()
721
+ );
722
+ }
723
+ }
724
+
725
+ function returnToMobileList(useHistory) {
726
+ if (useHistory && window.history.state && window.history.state.view === "detail") {
727
+ window.history.back();
728
+ return;
729
+ }
730
+ setMobileView("list", true);
731
+ }
732
+
733
+ function selectJob(jobId, showLoading, navigateMobile) {
734
+ const job = summaryById(jobId);
735
+ if (!job) {
736
+ return;
737
+ }
738
+
739
+ if (navigateMobile && isMobile()) {
740
+ rememberMobileListState();
741
+ }
742
+ state.selectedId = jobId;
743
+ state.currentDetail = null;
744
+ renderQueue();
745
+ renderSummary(job);
746
+ const detail = cachedDetail(jobId);
747
+ if (detail) {
748
+ renderDetail(detail);
749
+ } else {
750
+ if (showLoading) {
751
+ showDetailLoading(job);
752
+ }
753
+ fetchDetail(jobId, false);
754
+ }
755
+ if (navigateMobile && isMobile()) {
756
+ enterMobileDetail(jobId, true);
757
+ }
758
+ }
759
+
760
+ function showDetailLoading(job) {
761
+ state.currentDetail = null;
762
+ renderCompare(null);
763
+ clearMarkers(elements.requestMarkers);
764
+ clearMarkers(elements.resultMarkers);
765
+ elements.requestContent.hidden = true;
766
+ elements.resultContent.hidden = true;
767
+ elements.requestEmpty.hidden = false;
768
+ elements.resultEmpty.hidden = false;
769
+ setText(elements.requestEmpty, "Loading task…");
770
+ setText(elements.resultEmpty, job.state === "running" ? "Worker is running. Waiting for output…" : "Loading output…");
771
+ }
772
+
773
+ function fetchDetail(jobId, showLoading) {
774
+ const sequence = state.detailSequence + 1;
775
+ state.detailSequence = sequence;
776
+ const job = summaryById(jobId);
777
+ if (showLoading && job) {
778
+ showDetailLoading(job);
779
+ }
780
+ requestDetail(jobId, true)
781
+ .then(function (detail) {
782
+ if (!detail || sequence !== state.detailSequence || state.selectedId !== jobId) {
783
+ return;
784
+ }
785
+ renderDetail(detail);
786
+ })
787
+ .catch(function (error) {
788
+ if (isAuthError(error) || (error && error.name === "AbortError")) {
789
+ return;
790
+ }
791
+ if (sequence === state.detailSequence && state.selectedId === jobId) {
792
+ showDetailError();
793
+ showReconnecting();
794
+ }
795
+ });
796
+ }
797
+
798
+ function clearMarkers(container) {
799
+ container.replaceChildren();
800
+ }
801
+
802
+ function addMarker(container, message, isFault) {
803
+ const marker = document.createElement("span");
804
+ marker.className = isFault ? "content-marker is-fault" : "content-marker";
805
+ marker.textContent = message;
806
+ container.appendChild(marker);
807
+ }
808
+
809
+ function invalidFileSet(detail) {
810
+ if (!Array.isArray(detail.invalidFiles)) {
811
+ return new Set();
812
+ }
813
+ return new Set(detail.invalidFiles.filter(function (name) {
814
+ return name === "task.txt" || name === "out.txt";
815
+ }));
816
+ }
817
+
818
+ function emptyResultMessage(summary) {
819
+ if (summary.state === "starting") {
820
+ return "Dispatch is starting. No public result has been recorded yet.";
821
+ }
822
+ if (summary.state === "running") {
823
+ return "Worker is running. The public result will appear here.";
824
+ }
825
+ if (summary.state === "failed") {
826
+ const code = summary.exitCode === null ? "an unknown code" : "exit code " + String(summary.exitCode);
827
+ return "Dispatch failed with " + code + ". No public result was recorded.";
828
+ }
829
+ if (summary.state === "dead") {
830
+ return "The worker is gone and no exit code was recorded.";
831
+ }
832
+ if (summary.state === "invalid") {
833
+ return "This dispatch contains invalid metadata or control files. No safe result is available.";
834
+ }
835
+ return "Dispatch completed without a public result.";
836
+ }
837
+
838
+ function renderPlainText(contentElement, emptyElement, value, emptyMessage, followBottom) {
839
+ const text = typeof value === "string" ? value : "";
840
+ const wasVisible = !contentElement.hidden && contentElement.textContent.length > 0;
841
+ const previousScroll = contentElement.scrollTop;
842
+ const wasNearBottom = wasVisible && (
843
+ contentElement.scrollHeight - contentElement.clientHeight - contentElement.scrollTop <= OUTPUT_BOTTOM_THRESHOLD
844
+ );
845
+
846
+ if (text.length > 0) {
847
+ contentElement.textContent = text;
848
+ contentElement.hidden = false;
849
+ emptyElement.hidden = true;
850
+ if (!wasVisible) {
851
+ contentElement.scrollTop = 0;
852
+ } else if (followBottom && wasNearBottom) {
853
+ contentElement.scrollTop = contentElement.scrollHeight;
854
+ } else {
855
+ const maximum = Math.max(0, contentElement.scrollHeight - contentElement.clientHeight);
856
+ contentElement.scrollTop = Math.min(previousScroll, maximum);
857
+ }
858
+ } else {
859
+ contentElement.textContent = "";
860
+ contentElement.hidden = true;
861
+ setText(emptyElement, emptyMessage);
862
+ emptyElement.hidden = false;
863
+ }
864
+ }
865
+
866
+ function renderDetail(detail) {
867
+ const detailSummary = normalizeSummary(detail.summary);
868
+ const summary = detailSummary || currentSummary();
869
+ if (!summary) {
870
+ showDetailError();
871
+ return;
872
+ }
873
+
874
+ state.currentDetail = detail;
875
+ renderSummary(summary);
876
+ renderCompare(detail);
877
+ clearMarkers(elements.requestMarkers);
878
+ clearMarkers(elements.resultMarkers);
879
+ const invalidFiles = invalidFileSet(detail);
880
+
881
+ if (detail.taskTruncated === true) {
882
+ addMarker(elements.requestMarkers, "Large content shortened to 512 KiB", false);
883
+ }
884
+ if (detail.outputTruncated === true) {
885
+ addMarker(elements.resultMarkers, "Large content shortened to 512 KiB", false);
886
+ }
887
+ if (invalidFiles.has("task.txt")) {
888
+ addMarker(elements.requestMarkers, "Task file could not be read safely", true);
889
+ }
890
+ if (invalidFiles.has("out.txt")) {
891
+ addMarker(elements.resultMarkers, "Result file could not be read safely", true);
892
+ }
893
+ if (summary.state === "failed") {
894
+ addMarker(
895
+ elements.resultMarkers,
896
+ summary.exitCode === null
897
+ ? "Dispatch failed without a readable exit code"
898
+ : "Dispatch failed with exit code " + String(summary.exitCode),
899
+ true
900
+ );
901
+ } else if (summary.state === "dead") {
902
+ addMarker(elements.resultMarkers, "Worker gone · exit code not recorded", true);
903
+ } else if (summary.state === "invalid") {
904
+ addMarker(elements.resultMarkers, "Invalid dispatch · safe fields only", true);
905
+ }
906
+
907
+ renderPlainText(
908
+ elements.requestContent,
909
+ elements.requestEmpty,
910
+ detail.task,
911
+ invalidFiles.has("task.txt") ? "The task could not be read safely." : "No task was recorded.",
912
+ false
913
+ );
914
+ renderPlainText(
915
+ elements.resultContent,
916
+ elements.resultEmpty,
917
+ detail.output,
918
+ invalidFiles.has("out.txt") ? "The public result could not be read safely." : emptyResultMessage(summary),
919
+ true
920
+ );
921
+ }
922
+
923
+ function showDetailError() {
924
+ clearMarkers(elements.requestMarkers);
925
+ clearMarkers(elements.resultMarkers);
926
+ addMarker(elements.resultMarkers, "Detail temporarily unavailable", true);
927
+ setText(elements.requestEmpty, "The last task snapshot is preserved while detail reloads.");
928
+ setText(elements.resultEmpty, "Reconnecting to the local job board.");
929
+ elements.requestEmpty.hidden = false;
930
+ elements.resultEmpty.hidden = false;
931
+ }
932
+
933
+ async function requestJson(path, signal) {
934
+ const response = await window.fetch(path, {
935
+ method: "GET",
936
+ headers: {
937
+ Authorization: "Bearer " + state.token,
938
+ Accept: "application/json",
939
+ },
940
+ cache: "no-store",
941
+ credentials: "omit",
942
+ referrerPolicy: "no-referrer",
943
+ signal: signal,
944
+ });
945
+
946
+ if (!response.ok) {
947
+ const error = new Error("Request failed");
948
+ error.status = response.status;
949
+ throw error;
950
+ }
951
+ return response.json();
952
+ }
953
+
954
+ function closeEventStream() {
955
+ if (state.reconnectTimer !== null) {
956
+ window.clearTimeout(state.reconnectTimer);
957
+ state.reconnectTimer = null;
958
+ }
959
+ if (state.eventSource) {
960
+ state.eventSource.close();
961
+ state.eventSource = null;
962
+ }
963
+ }
964
+
965
+ function openEventStream() {
966
+ closeEventStream();
967
+ if (!state.token) {
968
+ return;
969
+ }
970
+
971
+ const source = new EventSource("/api/events?token=" + encodeURIComponent(state.token));
972
+ state.eventSource = source;
973
+ source.addEventListener("snapshot", function (event) {
974
+ try {
975
+ const payload = JSON.parse(event.data);
976
+ applySnapshot(payload);
977
+ } catch (_error) {
978
+ showReconnecting();
979
+ }
980
+ });
981
+ source.onerror = function () {
982
+ if (state.unauthorized) {
983
+ return;
984
+ }
985
+ showReconnecting();
986
+ probeAuthorization();
987
+ if (
988
+ source.readyState === EventSource.CLOSED &&
989
+ state.eventSource === source &&
990
+ state.reconnectTimer === null
991
+ ) {
992
+ state.reconnectTimer = window.setTimeout(function () {
993
+ state.reconnectTimer = null;
994
+ if (!state.unauthorized && state.eventSource === source) {
995
+ openEventStream();
996
+ }
997
+ }, 3000);
998
+ }
999
+ };
1000
+ }
1001
+
1002
+ async function probeAuthorization() {
1003
+ if (state.authProbeInFlight || !state.token || state.unauthorized) {
1004
+ return;
1005
+ }
1006
+ state.authProbeInFlight = true;
1007
+ try {
1008
+ await requestJson("/api/health");
1009
+ } catch (error) {
1010
+ if (isAuthError(error)) {
1011
+ showUnauthorized();
1012
+ }
1013
+ } finally {
1014
+ state.authProbeInFlight = false;
1015
+ }
1016
+ }
1017
+
1018
+ async function loadInitialSnapshot() {
1019
+ try {
1020
+ const payload = await requestJson("/api/jobs");
1021
+ applySnapshot(payload);
1022
+ } catch (error) {
1023
+ if (isAuthError(error)) {
1024
+ showUnauthorized();
1025
+ } else {
1026
+ showReconnecting();
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ function bindControls() {
1032
+ elements.search.addEventListener("input", function () {
1033
+ state.query = elements.search.value.trim().toLocaleLowerCase();
1034
+ updateFilterSelection();
1035
+ });
1036
+
1037
+ elements.filter.addEventListener("click", function (event) {
1038
+ const button = event.target.closest("button[data-filter]");
1039
+ if (!button || button.disabled) {
1040
+ return;
1041
+ }
1042
+ const nextFilter = button.dataset.filter;
1043
+ if (!["all", "active", "succeeded", "issues"].includes(nextFilter)) {
1044
+ return;
1045
+ }
1046
+ state.filter = nextFilter;
1047
+ elements.filterButtons.forEach(function (candidate) {
1048
+ const active = candidate === button;
1049
+ candidate.classList.toggle("is-active", active);
1050
+ candidate.setAttribute("aria-pressed", active ? "true" : "false");
1051
+ });
1052
+ updateFilterSelection();
1053
+ });
1054
+
1055
+ elements.mobileBack.addEventListener("click", function () {
1056
+ returnToMobileList(true);
1057
+ });
1058
+
1059
+ elements.compareToggle.addEventListener("click", toggleCompareReference);
1060
+ elements.compareClear.addEventListener("click", clearCompareReference);
1061
+
1062
+ window.addEventListener("popstate", function (event) {
1063
+ const historyState = event.state;
1064
+ if (historyState && historyState.omnilaneLiveBoard && historyState.view === "detail") {
1065
+ if (historyState.jobId && summaryById(historyState.jobId)) {
1066
+ selectJob(historyState.jobId, true, false);
1067
+ }
1068
+ setMobileView("detail", false);
1069
+ } else {
1070
+ setMobileView("list", true);
1071
+ }
1072
+ });
1073
+
1074
+ window.addEventListener("keydown", function (event) {
1075
+ if (event.key === "Escape" && isMobile() && document.body.dataset.mobileView === "detail") {
1076
+ event.preventDefault();
1077
+ returnToMobileList(true);
1078
+ }
1079
+ });
1080
+ }
1081
+
1082
+ function initializeHistory() {
1083
+ window.history.replaceState(
1084
+ { omnilaneLiveBoard: true, view: "list" },
1085
+ document.title,
1086
+ boardUrl()
1087
+ );
1088
+ setMobileView("list", false);
1089
+ }
1090
+
1091
+ function start() {
1092
+ initializeHistory();
1093
+ bindControls();
1094
+ if (!state.token) {
1095
+ showUnauthorized();
1096
+ return;
1097
+ }
1098
+
1099
+ setControlsDisabled(false);
1100
+ setConnection("waiting", "Connecting");
1101
+ openEventStream();
1102
+ loadInitialSnapshot();
1103
+ }
1104
+
1105
+ start();
1106
+ })();