feat: implement collection runs tracking and error acknowledgment features

This commit is contained in:
ludal committed 2026-09-21 13:45:58 +02:00
1 parent c39364eda4
commit 34903492f6
14 files changed
+484 -21

No files matched your search

+1 -5
View File
@@ -20,10 +20,6 @@ function renderKpis(data) {
document.getElementById("kpi-azure").textContent = formatEUR(data.kpis.total_azure_current_month);
const errorCount = data.kpis.tenants_error.length;
const errEl = document.getElementById("kpi-errors");
errEl.textContent = String(errorCount);
errEl.style.color = errorCount > 0 ? "var(--status-critical)" : "";
const banner = document.getElementById("error-banner");
if (errorCount > 0) {
const names = data.kpis.tenants_error
@@ -33,7 +29,7 @@ function renderKpis(data) {
<div class="banner error">
<div>
<strong>${errorCount} tenant${errorCount > 1 ? "s" : ""} en échec de collecte</strong> :
${names}. Voir la table ci-dessous ou <code>cron.log</code> pour le détail.
${names}. Voir la table ci-dessous ou <a href="/collecte">la page Collecte</a> pour le détail.
</div>
</div>`;
} else {
+181
View File
@@ -0,0 +1,181 @@
// collecte.js — page "Collecte" : historique des exécutions de tâches de
// collecte (cron quotidien + backfill historique), détail des erreurs par
// exécution, et acquittement manuel.
let currentRuns = [];
async function loadRuns() {
const res = await fetch("/api/collection-runs");
if (!res.ok) throw new Error(`API collection-runs: HTTP ${res.status}`);
return res.json();
}
function runTypeLabel(type) {
return type === "backfill" ? "Historique (backfill)" : "Journalière";
}
function formatDateTime(iso) {
if (!iso) return "—";
return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
}
function formatDuration(startIso, endIso) {
if (!startIso || !endIso) return "—";
const seconds = Math.max(0, Math.round((new Date(endIso) - new Date(startIso)) / 1000));
if (seconds < 60) return `${seconds} s`;
return `${Math.floor(seconds / 60)} min ${seconds % 60} s`;
}
function statusBadge(run) {
if (run.tenants_error === 0) {
return `<span class="status-label"><span class="status-dot ok"></span>OK</span>`;
}
if (run.acknowledged_at) {
return `<span class="status-label"><span class="status-dot warning"></span>Erreurs (acquittées)</span>`;
}
return `<span class="status-label"><span class="status-dot error"></span>Erreurs</span>`;
}
function renderRunsTable() {
const wrap = document.getElementById("runs-table-wrap");
if (currentRuns.length === 0) {
wrap.innerHTML = `<div class="empty-state">Aucune exécution enregistrée pour l'instant. Lance <code>collect.py</code> ou <code>backfill_historical.py</code>.</div>`;
return;
}
const rows = currentRuns
.map(
(r) => `
<tr class="clickable" data-id="${r.id}">
<td>${runTypeLabel(r.run_type)}</td>
<td>${formatDateTime(r.started_at)}</td>
<td>${formatDuration(r.started_at, r.finished_at)}</td>
<td class="num">${r.tenants_ok}</td>
<td class="num">${r.tenants_error}</td>
<td>${statusBadge(r)}</td>
</tr>`
)
.join("");
wrap.innerHTML = `
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Démarrée</th>
<th>Durée</th>
<th class="num">OK</th>
<th class="num">Erreurs</th>
<th>Statut</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
tr.addEventListener("click", () => openRunDrawer(Number(tr.dataset.id)));
});
}
function openRunDrawer(runId) {
const run = currentRuns.find((r) => r.id === runId);
if (!run) return;
document.getElementById("drawer-title").textContent = `${runTypeLabel(run.run_type)} — ${formatDateTime(run.started_at)}`;
document.getElementById("drawer-sub").textContent = `${run.tenants_ok} OK, ${run.tenants_error} en erreur`;
document.getElementById("run-drawer").classList.add("open");
document.getElementById("run-drawer").setAttribute("aria-hidden", "false");
document.getElementById("drawer-backdrop").classList.add("open");
if (run.tenants_error === 0) {
document.getElementById("drawer-body").innerHTML = `<div class="empty-state">Aucune erreur sur cette exécution.</div>`;
return;
}
document.getElementById("drawer-body").innerHTML = `<div class="skeleton">Chargement…</div>`;
fetch(`/api/collection-runs/${runId}/errors`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => renderDrawerContent(run, data.errors))
.catch((e) => {
document.getElementById("drawer-body").innerHTML =
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
});
}
function renderDrawerContent(run, errors) {
const bodyEl = document.getElementById("drawer-body");
const list = errors
.map(
(e) => `
<div class="resource-row" style="flex-direction:column; align-items:flex-start; gap:4px;">
<div class="resource-name">
<span class="provider-badge ${e.provider}">${providerLabel(e.provider)}</span>
${escapeHtml(e.tenant_name)} — ${escapeHtml(e.period_start)}
</div>
<div class="resource-pct">${escapeHtml(e.error_message || "erreur inconnue")}</div>
</div>`
)
.join("");
const ackBlock = run.acknowledged_at
? `<p class="chart-card-sub" style="margin: 0 0 14px;">Acquittée le ${formatDateTime(run.acknowledged_at)}</p>`
: `<button type="button" class="btn" id="ack-button" style="margin-bottom: 14px;">Acquitter cette erreur</button>`;
bodyEl.innerHTML = `${ackBlock}${list}`;
const btn = document.getElementById("ack-button");
if (btn) {
btn.addEventListener("click", () => acknowledgeRun(run.id));
}
}
async function acknowledgeRun(runId) {
const btn = document.getElementById("ack-button");
if (btn) btn.disabled = true;
try {
const res = await fetch(`/api/collection-runs/${runId}/acknowledge`, { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
closeRunDrawer();
await init();
refreshCollecteAlert();
} catch (e) {
document.getElementById("drawer-body").innerHTML =
`<div class="banner error"><strong>Erreur</strong> : ${escapeHtml(e.message)}</div>`;
}
}
function closeRunDrawer() {
document.getElementById("run-drawer").classList.remove("open");
document.getElementById("run-drawer").setAttribute("aria-hidden", "true");
document.getElementById("drawer-backdrop").classList.remove("open");
}
function setupDrawer() {
document.getElementById("drawer-close").addEventListener("click", closeRunDrawer);
document.getElementById("drawer-backdrop").addEventListener("click", closeRunDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeRunDrawer();
});
}
async function init() {
try {
const data = await loadRuns();
currentRuns = data.runs;
renderRunsTable();
} catch (e) {
document.getElementById("error-banner").innerHTML = `
<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
document.getElementById("runs-table-wrap").innerHTML = "";
}
}
init();
setupDrawer();
+18
View File
@@ -63,6 +63,24 @@ function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
// Pastille d'alerte sur le lien "Collecte" de la sidebar (présente sur
// toutes les pages) — signale une erreur de collecte pas encore acquittée.
// La page Collecte elle-même se rafraîchit après un acquittement.
async function refreshCollecteAlert() {
const dot = document.getElementById("nav-collecte-alert");
if (!dot) return;
try {
const res = await fetch("/api/collection-runs/alert-status");
if (!res.ok) return;
const data = await res.json();
dot.hidden = !data.has_unacknowledged_errors;
} catch (e) {
// silencieux : une pastille ratée ne doit pas bloquer le reste de la page
}
}
refreshCollecteAlert();
// Config partagée des axes/grilles Chart.js (gridlines discrètes, pas de
// deuxième axe, tooltip alignée sur le design system).
function chartBaseOptions() {
+31 -1
View File
@@ -180,7 +180,9 @@ a {
}
.sidebar-link {
display: block;
display: flex;
align-items: center;
gap: 7px;
padding: 9px 12px;
border-radius: var(--radius-control);
color: var(--text-secondary);
@@ -189,6 +191,14 @@ a {
font-weight: 500;
}
.nav-alert-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--status-critical);
flex: none;
}
.sidebar-link:hover {
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
color: var(--text-primary);
@@ -527,6 +537,7 @@ table.data-table {
.status-dot.ok { background: var(--status-good); }
.status-dot.error { background: var(--status-critical); }
.status-dot.warning { background: var(--status-warning); }
.status-dot.unknown { background: var(--text-muted); }
.status-label {
@@ -660,6 +671,25 @@ table.data-table {
padding: 8px 20px 20px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--surface-1);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: var(--radius-control);
cursor: pointer;
font-family: inherit;
}
.btn:hover {
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
}
.resource-row {
display: flex;
align-items: center;