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`, `/reviews`), 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);
|