feat: implement collection runs tracking and error acknowledgment features
This commit is contained in:
1 parent
c39364eda4
commit
34903492f6
14 files changed
+484
-21
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user