Add live dashboard with SSE job events
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.
This commit is contained in:
qpismont committed 2026-10-13 16:14:48 +00:00
1 parent 8c763786c1
commit 7493de6952
32 files changed
+1681 -61

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
// 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);