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.
213 lines
6.8 KiB
JavaScript
213 lines
6.8 KiB
JavaScript
// <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);
|