ci/woodpecker/push/tests Pipeline was successful
Expose /jobs, /dedup, and /events routes backed by a new EventBus that broadcasts job lifecycle and per-job progress over SSE. Serve a static dashboard from front/ that lists pending and in-progress jobs, streams agent activity, and shows completed reviews. Also add migrate-apply/migrate-generate scripts that load .env, update the Containerfile to ship the front assets and use the apply script, and refresh the roadmap.
151 lines
4.2 KiB
JavaScript
151 lines
4.2 KiB
JavaScript
// Panneau d'activité d'un job : ouvre un flux SSE vers `/jobs/{id}/events`,
|
|
// rejoue l'historique renvoyé par le serveur puis affiche le direct.
|
|
|
|
import { endpoints, escapeHtml, subscribe } from "./api.js";
|
|
|
|
class JobDetail extends HTMLElement {
|
|
set jobId(value) {
|
|
const id = value ?? null;
|
|
if (this._jobId === id) return;
|
|
this._jobId = id;
|
|
this.open();
|
|
}
|
|
|
|
connectedCallback() {
|
|
if (!this._jobId) this.renderPlaceholder();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.close();
|
|
}
|
|
|
|
close() {
|
|
if (this._source) {
|
|
this._source.close();
|
|
this._source = null;
|
|
}
|
|
}
|
|
|
|
open() {
|
|
this.close();
|
|
this._finished = false;
|
|
|
|
if (!this._jobId) {
|
|
this.renderPlaceholder();
|
|
return;
|
|
}
|
|
|
|
this.innerHTML = `
|
|
<div class="log" id="log" role="log" aria-live="polite"></div>`;
|
|
this._log = this.querySelector("#log");
|
|
|
|
this._source = subscribe(endpoints.jobEvents(this._jobId), {
|
|
onProgress: (event) => {
|
|
this.append(event);
|
|
if (event.type === "finished") {
|
|
this._finished = true;
|
|
this.close();
|
|
}
|
|
},
|
|
onGap: (skipped) =>
|
|
this.append({ type: "gap", note: `missed ${skipped} events, refresh to resync` }),
|
|
// Un `error` survient aussi quand le serveur ferme le flux (job terminé).
|
|
// On ferme alors la source pour éviter la boucle de reconnexion (le job
|
|
// n'est plus en cours, la route renverrait 404).
|
|
onError: () => {
|
|
if (this._finished) return;
|
|
this.append({ type: "gap", note: "stream closed" });
|
|
this.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
renderPlaceholder() {
|
|
this.innerHTML = `<p class="empty">Select a job in progress to watch the agent work.</p>`;
|
|
}
|
|
|
|
append(event) {
|
|
if (!this._log) return;
|
|
this._log.append(this.entry(event));
|
|
this._log.scrollTop = this._log.scrollHeight;
|
|
}
|
|
|
|
entry(event) {
|
|
return event.type === "tool_result" ? this.resultEntry(event) : this.lineEntry(event);
|
|
}
|
|
|
|
// Ligne simple : appel d'outil, fin de review, gap.
|
|
lineEntry(event) {
|
|
const row = document.createElement("div");
|
|
row.className = "log-entry";
|
|
row.dataset.kind = event.type;
|
|
|
|
const { tag, body } = this.describe(event);
|
|
|
|
const tagEl = document.createElement("span");
|
|
tagEl.className = "log-tag";
|
|
tagEl.textContent = tag;
|
|
|
|
const bodyEl = document.createElement("span");
|
|
bodyEl.className = "log-body";
|
|
bodyEl.innerHTML = body;
|
|
|
|
row.append(tagEl, bodyEl);
|
|
return row;
|
|
}
|
|
|
|
// Résultat d'outil : replié par défaut, avec un aperçu sur une ligne.
|
|
resultEntry(event) {
|
|
const details = document.createElement("details");
|
|
details.className = "log-result";
|
|
|
|
const summary = document.createElement("summary");
|
|
const chev = document.createElement("span");
|
|
chev.className = "chev";
|
|
chev.setAttribute("aria-hidden", "true");
|
|
const tag = document.createElement("span");
|
|
tag.className = "log-tag";
|
|
tag.textContent = "result";
|
|
const preview = document.createElement("span");
|
|
preview.className = "log-preview";
|
|
preview.textContent = this.preview(event.output);
|
|
summary.append(chev, tag, preview);
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "log-body";
|
|
const truncated = event.truncated
|
|
? ' <span class="log-truncated">(truncated)</span>'
|
|
: "";
|
|
body.innerHTML = `${escapeHtml(event.output)}${truncated}`;
|
|
|
|
details.append(summary, body);
|
|
return details;
|
|
}
|
|
|
|
preview(text) {
|
|
const line = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
return line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
}
|
|
|
|
describe(event) {
|
|
switch (event.type) {
|
|
case "tool_call":
|
|
return {
|
|
tag: "call",
|
|
body: `${escapeHtml(event.name)} <span class="log-args">${escapeHtml(event.arguments)}</span>`,
|
|
};
|
|
case "finished":
|
|
return {
|
|
tag: "done",
|
|
body: event.ok ? "review finished" : "review failed",
|
|
};
|
|
case "gap":
|
|
return { tag: "gap", body: escapeHtml(event.note ?? "gap") };
|
|
default:
|
|
return { tag: escapeHtml(event.type ?? "event"), body: "" };
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define("steep-job-detail", JobDetail);
|