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.
79 lines
2.3 KiB
JavaScript
79 lines
2.3 KiB
JavaScript
// Accès aux routes exposées par le serveur axum et petits utilitaires partagés
|
|
// par les web components du dashboard.
|
|
|
|
export const endpoints = {
|
|
jobs: "/jobs",
|
|
reviews: "/dedup",
|
|
lifecycle: "/events",
|
|
jobEvents: (id) => `/jobs/${id}/events`,
|
|
};
|
|
|
|
async function getJson(url) {
|
|
const response = await fetch(url, { headers: { accept: "application/json" } });
|
|
if (!response.ok) {
|
|
throw new Error(`${url} → ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export const fetchJobs = () => getJson(endpoints.jobs);
|
|
export const fetchReviews = () => getJson(endpoints.reviews);
|
|
|
|
// Ouvre un flux SSE et route les évènements nommés vers les callbacks fournis.
|
|
// Les évènements du serveur sont nommés (`lifecycle`, `progress`, `gap`), donc
|
|
// `addEventListener("message")` ne suffit pas : il faut écouter par nom.
|
|
export function subscribe(url, handlers = {}) {
|
|
const source = new EventSource(url);
|
|
|
|
const onData = (name, handler) => {
|
|
if (handler) {
|
|
source.addEventListener(name, (event) => handler(JSON.parse(event.data)));
|
|
}
|
|
};
|
|
|
|
if (handlers.onOpen) source.addEventListener("open", () => handlers.onOpen());
|
|
if (handlers.onError) source.addEventListener("error", () => handlers.onError());
|
|
onData("lifecycle", handlers.onLifecycle);
|
|
onData("progress", handlers.onProgress);
|
|
if (handlers.onGap) {
|
|
source.addEventListener("gap", (event) => handlers.onGap(Number(event.data)));
|
|
}
|
|
|
|
return source;
|
|
}
|
|
|
|
export function escapeHtml(value) {
|
|
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
})[char]);
|
|
}
|
|
|
|
// Horodatage relatif (« il y a 3 min »), tolérant sur les valeurs absentes.
|
|
export function relativeTime(value) {
|
|
if (!value) return "—";
|
|
const then = Date.parse(value);
|
|
if (Number.isNaN(then)) return "—";
|
|
|
|
const seconds = Math.round((then - Date.now()) / 1000);
|
|
const steps = [
|
|
["year", 60 * 60 * 24 * 365],
|
|
["month", 60 * 60 * 24 * 30],
|
|
["day", 60 * 60 * 24],
|
|
["hour", 60 * 60],
|
|
["minute", 60],
|
|
["second", 1],
|
|
];
|
|
|
|
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
for (const [unit, size] of steps) {
|
|
if (Math.abs(seconds) >= size || unit === "second") {
|
|
return formatter.format(Math.round(seconds / size), unit);
|
|
}
|
|
}
|
|
return "—";
|
|
}
|