300 lines
14 KiB
HTML
300 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<header class="mb-stack-lg">
|
|
<h1 class="text-headline-xl-mobile font-bold text-on-background md:text-headline-xl">Inboxes</h1>
|
|
<p class="mt-1 text-body-base text-on-surface-variant">Polling health and manual import controls.</p>
|
|
</header>
|
|
|
|
<section class="surface-card divide-y divide-outline-variant overflow-hidden">
|
|
{% for inbox in inboxes %}
|
|
{% set job = jobs.get(inbox.inbox_id) if jobs else none %}
|
|
{% set running = job and job.status in ["queued", "running"] %}
|
|
{% set skipped = skipped_payloads.get(inbox.inbox_id, []) if skipped_payloads else [] %}
|
|
<article class="p-stack-md" id="inbox-row-{{ inbox.inbox_id }}">
|
|
<div class="dw-inbox-row">
|
|
<div class="min-w-0">
|
|
<div class="flex flex-wrap items-center gap-stack-sm">
|
|
<h2 class="text-headline-md font-semibold text-on-background">{{ inbox.label }}</h2>
|
|
<code class="rounded bg-surface-container px-2 py-0.5 font-mono text-data-mono text-secondary">{{ inbox.inbox_id }}</code>
|
|
{% set status_label = "running" if running else ("disabled" if not inbox.enabled else ("error" if inbox.last_error else "ready")) %}
|
|
<span id="inbox-status-chip-{{ inbox.inbox_id }}" class="status-chip {{ 'chip-warning' if running or not inbox.enabled else ('chip-fail' if inbox.last_error else 'chip-pass') }}">{{ status_label }}</span>
|
|
</div>
|
|
<p class="mt-stack-sm text-body-sm text-on-surface-variant">{{ inbox.domain }} · {{ inbox.folder }} · {{ inbox.recipient }}</p>
|
|
<div class="dw-inbox-meta mt-stack-md">
|
|
<div><span class="label-caps block">Last Check</span><span id="inbox-last-check-{{ inbox.inbox_id }}">{{ inbox.last_check_at | fmt_dt }}</span></div>
|
|
<div><span class="label-caps block">Last Success</span><span id="inbox-last-success-{{ inbox.inbox_id }}">{{ inbox.last_success_at | fmt_dt }}</span></div>
|
|
<div><span class="label-caps block">New Messages</span><span id="inbox-new-messages-{{ inbox.inbox_id }}">{{ inbox.last_new_messages }}</span></div>
|
|
<div><span class="label-caps block">Imported</span><span id="inbox-imported-{{ inbox.inbox_id }}">{{ inbox.last_reports_imported }}</span></div>
|
|
</div>
|
|
<p id="inbox-last-error-{{ inbox.inbox_id }}" class="mt-stack-md border-l-4 border-l-error bg-error-container p-stack-sm text-body-sm text-on-error-container {{ '' if inbox.last_error else 'hidden' }}">{{ inbox.last_error or "" }}</p>
|
|
</div>
|
|
<div class="dw-inbox-work">
|
|
<div class="dw-inbox-actions">
|
|
<a class="button-secondary" href="/domains/{{ inbox.domain }}">
|
|
<span class="material-symbols-outlined text-[18px]">visibility</span>
|
|
View Domain
|
|
</a>
|
|
<button class="button-secondary js-inbox-action" type="button" data-action="process-now" data-inbox-id="{{ inbox.inbox_id }}" {% if not inbox.enabled or running %}disabled{% endif %}>
|
|
<span class="material-symbols-outlined text-[18px]">sync</span>
|
|
Process Now
|
|
</button>
|
|
<button class="button-secondary js-inbox-action" type="button" data-action="backlog" data-inbox-id="{{ inbox.inbox_id }}" {% if not inbox.enabled or running %}disabled{% endif %}>
|
|
<span class="material-symbols-outlined text-[18px]">manage_search</span>
|
|
Backlog Scan
|
|
</button>
|
|
</div>
|
|
<div class="dw-inbox-job">
|
|
<div class="inbox-action-result {{ 'is-running' if running else '' }}" id="inbox-action-result-{{ inbox.inbox_id }}" data-job-id="{{ job.id if job else '' }}" role="status" aria-live="polite">
|
|
{% if running %}
|
|
{{ job.processed_messages }} of {{ job.scanned_messages or "?" }} scanned · {{ job.valid_reports_imported }} imported
|
|
{% endif %}
|
|
</div>
|
|
<div class="inbox-progress {{ 'is-active' if running else '' }}" id="inbox-progress-{{ inbox.inbox_id }}">
|
|
<span style="width: {{ job.progress_percent if job and job.progress_percent is not none else 100 }}%;"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="inbox-duplicate-list {{ 'hidden' if not skipped else '' }}" id="inbox-duplicate-list-{{ inbox.inbox_id }}">
|
|
{% if skipped %}
|
|
<details>
|
|
<summary>
|
|
<span>Skipped report payloads</span>
|
|
<strong>{{ skipped|length }}</strong>
|
|
</summary>
|
|
<table class="inbox-duplicate-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Reason</th>
|
|
<th>Reporter</th>
|
|
<th>Report ID</th>
|
|
<th>Report Date</th>
|
|
<th>Skipped IMAP UID</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in skipped %}
|
|
<tr>
|
|
<td>{{ item.reason.replace("_", " ") }}</td>
|
|
<td>{{ item.reporting_org or "unknown" }}</td>
|
|
<td>{{ item.report_identifier or ("DB #" ~ item.existing_report_id) }}</td>
|
|
<td>{{ item.report_date | fmt_date }}</td>
|
|
<td>{{ item.imap_uid }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</details>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{% else %}
|
|
<div class="p-gutter text-on-surface-variant">No inboxes are configured.</div>
|
|
{% endfor %}
|
|
</section>
|
|
|
|
<script>
|
|
(() => {
|
|
const formatDate = (value) => {
|
|
if (!value) return "";
|
|
const parts = value.split("-");
|
|
return parts.length === 3 ? `${parts[2]}/${parts[1]}/${parts[0]}` : value;
|
|
};
|
|
|
|
const summarize = (data) => {
|
|
const imported = data.valid_reports_imported ?? 0;
|
|
const duplicateMessages = data.duplicate_messages_skipped ?? 0;
|
|
const duplicateReports = data.duplicate_reports_skipped ?? 0;
|
|
const rejected = data.rejected_messages ?? 0;
|
|
const failed = data.failed_messages ?? 0;
|
|
const notImported = [];
|
|
if (duplicateMessages) notImported.push(`${duplicateMessages} messages already processed earlier`);
|
|
if (duplicateReports) notImported.push(`${duplicateReports} duplicate report payloads`);
|
|
if (rejected) notImported.push(`${rejected} rejected by validation/guardrails`);
|
|
const skipped = notImported.length ? ` Not imported: ${notImported.join(", ")}.` : "";
|
|
return `Done: ${data.scanned_messages ?? 0} scanned, ${data.candidate_messages ?? 0} candidate messages, ${imported} new reports imported.${skipped} Failures: ${failed}.`;
|
|
};
|
|
|
|
const text = (id, value) => {
|
|
const element = document.getElementById(id);
|
|
if (element) {
|
|
element.textContent = value;
|
|
}
|
|
};
|
|
|
|
const formatDateTime = (value, fallback = "never") => {
|
|
if (!value) return fallback;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
const pad = (item) => String(item).padStart(2, "0");
|
|
return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
};
|
|
|
|
const escapeHtml = (value) => String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
|
|
const renderDuplicateReports = (inboxId, samples = []) => {
|
|
const list = document.getElementById(`inbox-duplicate-list-${inboxId}`);
|
|
if (!list) return;
|
|
if (!samples.length) {
|
|
list.classList.add("hidden");
|
|
list.innerHTML = "";
|
|
return;
|
|
}
|
|
const rows = samples.map((item) => {
|
|
const reportLabel = item.existing_report_id || `DB #${item.existing_report_db_id}`;
|
|
const messageLabel = item.duplicate_message_uid || item.duplicate_message_id || "unknown message";
|
|
return `
|
|
<tr>
|
|
<td>duplicate report payload</td>
|
|
<td>${escapeHtml(item.reporting_org || "unknown")}</td>
|
|
<td>${escapeHtml(reportLabel)}</td>
|
|
<td>${escapeHtml(item.report_date ? formatDate(item.report_date) : "unknown")}</td>
|
|
<td>${escapeHtml(messageLabel)}</td>
|
|
</tr>`;
|
|
}).join("");
|
|
const wasOpen = list.querySelector("details")?.open || false;
|
|
list.classList.remove("hidden");
|
|
list.innerHTML = `
|
|
<details ${wasOpen ? "open" : ""}>
|
|
<summary>
|
|
<span>Skipped report payloads</span>
|
|
<strong>${samples.length}</strong>
|
|
</summary>
|
|
<table class="inbox-duplicate-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Reason</th>
|
|
<th>Reporter</th>
|
|
<th>Report ID</th>
|
|
<th>Report Date</th>
|
|
<th>Skipped IMAP UID</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</details>`;
|
|
};
|
|
|
|
const renderInboxStatus = async (inboxId, keepRunning = false) => {
|
|
const response = await fetch(`/api/admin/inboxes/${encodeURIComponent(inboxId)}/status`, { credentials: "same-origin" });
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
const status = await response.json();
|
|
text(`inbox-last-check-${inboxId}`, formatDateTime(status.last_check_at));
|
|
text(`inbox-last-success-${inboxId}`, formatDateTime(status.last_success_at));
|
|
text(`inbox-new-messages-${inboxId}`, status.last_new_messages ?? 0);
|
|
text(`inbox-imported-${inboxId}`, status.last_reports_imported ?? 0);
|
|
|
|
const chip = document.getElementById(`inbox-status-chip-${inboxId}`);
|
|
if (chip) {
|
|
const label = keepRunning ? "running" : (!status.enabled ? "disabled" : (status.last_error ? "error" : "ready"));
|
|
chip.textContent = label;
|
|
chip.className = `status-chip ${keepRunning || !status.enabled ? "chip-warning" : (status.last_error ? "chip-fail" : "chip-pass")}`;
|
|
}
|
|
|
|
const error = document.getElementById(`inbox-last-error-${inboxId}`);
|
|
if (error) {
|
|
error.textContent = status.last_error || "";
|
|
error.classList.toggle("hidden", !status.last_error);
|
|
}
|
|
};
|
|
|
|
document.querySelectorAll(".js-inbox-action").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const inboxId = button.dataset.inboxId;
|
|
const result = document.getElementById(`inbox-action-result-${inboxId}`);
|
|
const action = button.dataset.action;
|
|
const endpoint = action === "backlog" ? "/api/admin/import-jobs/backlog" : "/api/admin/import-jobs/process-now";
|
|
const payload = action === "backlog"
|
|
? { inbox_id: inboxId, limit: 200 }
|
|
: { inbox_id: inboxId, mode: "new", limit: 200 };
|
|
const original = button.innerHTML;
|
|
const progress = document.getElementById(`inbox-progress-${inboxId}`);
|
|
|
|
button.disabled = true;
|
|
button.innerHTML = `<span class="material-symbols-outlined text-[18px]">sync</span>Running`;
|
|
result.className = "inbox-action-result is-running";
|
|
result.textContent = "Starting import job...";
|
|
progress.classList.add("is-active");
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { ...window.adminPostHeaders, "Content-Type": "application/json" },
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data));
|
|
}
|
|
pollJob(data.id, inboxId);
|
|
} catch (error) {
|
|
result.className = "inbox-action-result is-error";
|
|
result.textContent = error.message || "Processing failed.";
|
|
button.disabled = false;
|
|
button.innerHTML = original;
|
|
}
|
|
});
|
|
});
|
|
|
|
const renderJob = (job, inboxId) => {
|
|
const result = document.getElementById(`inbox-action-result-${inboxId}`);
|
|
const progress = document.getElementById(`inbox-progress-${inboxId}`);
|
|
const fill = progress.querySelector("span");
|
|
const running = job.status === "queued" || job.status === "running";
|
|
const buttons = document.querySelectorAll(`.js-inbox-action[data-inbox-id="${inboxId}"]`);
|
|
buttons.forEach((button) => { button.disabled = running; });
|
|
progress.classList.toggle("is-active", running || job.status === "succeeded");
|
|
fill.style.width = `${job.progress_percent ?? (running ? 100 : 0)}%`;
|
|
progress.classList.toggle("is-indeterminate", running && job.progress_percent === null);
|
|
text(`inbox-last-check-${inboxId}`, formatDateTime(job.started_at, "running"));
|
|
text(`inbox-new-messages-${inboxId}`, job.scanned_messages ?? 0);
|
|
text(`inbox-imported-${inboxId}`, job.valid_reports_imported ?? 0);
|
|
if (running) {
|
|
result.className = "inbox-action-result is-running";
|
|
result.textContent = `${job.processed_messages} of ${job.scanned_messages || "?"} scanned · ${job.valid_reports_imported} imported · ${job.duplicate_reports_skipped ?? 0} duplicate reports · ${job.duplicate_messages_skipped ?? 0} already processed · ${job.rejected_messages ?? 0} rejected · ${job.alerts_created} alerts`;
|
|
renderDuplicateReports(inboxId, job.duplicate_report_samples || []);
|
|
const chip = document.getElementById(`inbox-status-chip-${inboxId}`);
|
|
if (chip) {
|
|
chip.textContent = "running";
|
|
chip.className = "status-chip chip-warning";
|
|
}
|
|
} else if (job.status === "succeeded") {
|
|
result.className = "inbox-action-result is-success";
|
|
result.textContent = summarize(job);
|
|
renderDuplicateReports(inboxId, job.duplicate_report_samples || []);
|
|
} else if (job.status === "failed") {
|
|
result.className = "inbox-action-result is-error";
|
|
result.textContent = job.error || "Processing failed.";
|
|
renderDuplicateReports(inboxId, []);
|
|
}
|
|
return running;
|
|
};
|
|
|
|
const pollJob = async (jobId, inboxId) => {
|
|
const response = await fetch(`/api/admin/import-jobs/${jobId}`, { credentials: "same-origin" });
|
|
const job = await response.json();
|
|
if (renderJob(job, inboxId)) {
|
|
window.setTimeout(() => pollJob(jobId, inboxId), 2000);
|
|
} else {
|
|
renderInboxStatus(inboxId);
|
|
}
|
|
};
|
|
|
|
document.querySelectorAll(".inbox-action-result[data-job-id]").forEach((result) => {
|
|
if (result.dataset.jobId) {
|
|
const inboxId = result.id.replace("inbox-action-result-", "");
|
|
pollJob(result.dataset.jobId, inboxId);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|