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

+212
View File
@@ -0,0 +1,212 @@
// <steep-dashboard> : assemble le tableau de bord.
//
// Au montage, il charge l'état initial via REST (`/jobs`, `/dedup`), puis
// s'abonne au flux global `/events` pour tenir les listes à jour en direct.
// La sélection d'un job ouvre son flux fin via <steep-job-detail>.
import {
endpoints,
escapeHtml,
fetchJobs,
fetchReviews,
relativeTime,
subscribe,
} from "./api.js";
import "./theme-toggle.js";
import "./job-list.js";
import "./job-detail.js";
class Dashboard extends HTMLElement {
connectedCallback() {
this._jobs = { pending: [], processing: [] };
this._reviews = [];
this._selectedId = null;
this.render();
this.refresh();
this.connect();
}
disconnectedCallback() {
this._source?.close();
if (this._refreshTimer) clearTimeout(this._refreshTimer);
}
// ---- Chargement et flux -------------------------------------------------
async refresh() {
try {
const [jobs, reviews] = await Promise.all([fetchJobs(), fetchReviews()]);
this._jobs = {
pending: jobs.pending ?? [],
processing: jobs.processing ?? [],
};
this._reviews = reviews.reviews ?? [];
this.update();
} catch (error) {
console.error("failed to refresh the dashboard", error);
}
}
connect() {
this._source = subscribe(endpoints.lifecycle, {
onLifecycle: (event) => this.applyLifecycle(event),
// En cas de gap (évènements ratés), on recharge l'état depuis le REST.
onGap: () => this.refresh(),
});
}
// Recharge la liste des reviews peu après une fin de job, pour récupérer les
// champs que l'évènement ne porte pas (markdown, coût, modèle).
scheduleRefresh() {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
this._refreshTimer = setTimeout(() => this.refresh(), 400);
}
applyLifecycle(event) {
switch (event.type) {
case "queued":
if (!this._jobs.pending.some((job) => job.id === event.id)) {
this._jobs.pending.push(this.jobFromEvent(event));
}
break;
case "started": {
// On garde le job déjà connu s'il existe, pour conserver les champs que
// l'évènement `started` ne porte pas (event_id, created_at).
const known = this._jobs.pending.find((job) => job.id === event.id);
this.removeJob(event.id);
this._jobs.processing.push(known ?? this.jobFromEvent(event));
break;
}
case "review_done":
case "failed":
this.removeJob(event.id);
this.scheduleRefresh();
break;
case "skipped":
this.removeJob(event.id);
break;
default:
return;
}
this.update();
}
removeJob(id) {
this._jobs.pending = this._jobs.pending.filter((job) => job.id !== id);
this._jobs.processing = this._jobs.processing.filter((job) => job.id !== id);
}
// L'évènement de cycle de vie porte assez de champs pour l'affichage ; le
// rafraîchissement REST fournira le reste (created_at, payload…).
jobFromEvent(event) {
return {
id: event.id,
event_id: event.event_id,
repo: event.repo,
pr: event.pr,
created_at: event.created_at,
};
}
// ---- Rendu --------------------------------------------------------------
render() {
this.innerHTML = `
<section class="section">
<div class="section-heading">
<p class="eyebrow">Live operations</p>
<h1>Review dashboard</h1>
<p>Pending jobs, work in progress, and completed reviews — kept in sync over SSE.</p>
</div>
<div class="toolbar">
<button class="button button-secondary" id="refresh" type="button">Refresh</button>
</div>
<div class="board">
<section class="panel">
<header class="panel-head"><h2>Pending</h2><span class="count" id="pending-count">0</span></header>
<steep-job-list id="pending" empty="No pending job."></steep-job-list>
</section>
<section class="panel">
<header class="panel-head"><h2>In progress</h2><span class="count" id="processing-count">0</span></header>
<steep-job-list id="processing" empty="No job is running."></steep-job-list>
</section>
</div>
<section class="panel detail-panel">
<header class="panel-head"><h2>Activity</h2></header>
<steep-job-detail id="detail"></steep-job-detail>
</section>
<section class="panel">
<header class="panel-head"><h2>Completed reviews</h2><span class="count" id="reviews-count">0</span></header>
<div class="reviews" id="reviews"></div>
</section>
</section>`;
this._pending = this.querySelector("#pending");
this._processing = this.querySelector("#processing");
this._detail = this.querySelector("#detail");
this._reviewsEl = this.querySelector("#reviews");
this.querySelector("#refresh").addEventListener("click", () => this.refresh());
this.addEventListener("job-select", (event) => this.select(event.detail.id));
this.update();
}
update() {
this._pending.jobs = this._jobs.pending;
this._pending.selected = this._selectedId;
this._processing.jobs = this._jobs.processing;
this._processing.selected = this._selectedId;
this.querySelector("#pending-count").textContent = String(this._jobs.pending.length);
this.querySelector("#processing-count").textContent = String(this._jobs.processing.length);
this.querySelector("#reviews-count").textContent = String(this._reviews.length);
this._reviewsEl.innerHTML = this.reviewsHtml();
}
select(id) {
this._selectedId = id;
this._detail.jobId = id;
this.update();
this._detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
reviewsHtml() {
if (this._reviews.length === 0) {
return `<p class="empty">No review completed yet.</p>`;
}
const rows = [...this._reviews]
.sort((a, b) => String(b.completed_at ?? "").localeCompare(String(a.completed_at ?? "")))
.map(
(review) => `
<tr>
<td><span class="pill ${review.ok ? "done" : "failed"}">${review.ok ? "done" : "failed"}</span></td>
<td class="mono">${escapeHtml(review.repo)}#${review.pr}</td>
<td>${review.comments}</td>
<td>${review.duration_ms} ms</td>
<td class="mono muted">${escapeHtml(review.model ?? "—")}</td>
<td class="muted">${relativeTime(review.completed_at)}</td>
</tr>`,
)
.join("");
return `
<table class="reviews-table">
<thead>
<tr>
<th>Status</th><th>Pull request</th><th>Comments</th><th>Duration</th><th>Model</th><th>Completed</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
}
customElements.define("steep-dashboard", Dashboard);