Implement async manual CSV ingest, unified UI pagination/filters, and serial placeholder strategy

This commit is contained in:
2026-02-21 22:14:04 +03:00
parent ca762a658b
commit c84102d2f1
44 changed files with 3314 additions and 342 deletions

View File

@@ -7,9 +7,33 @@
{{template "breadcrumbs" .}}
<main class="container">
<section class="card">
<h2>Manual CSV Import</h2>
<div class="meta" style="margin-bottom: 12px;">
Required columns: <code>дата_осмотра</code>, <code>серийный_номер_сервера</code>, <code>вендор</code>, <code>p/n_устройства</code>, <code>s/n_устройства</code>.
</div>
<div class="meta" style="margin-bottom: 12px;">
Optional columns: <code>локейшн_в_сервере</code>, <code>версия_прошивки</code>, <code>состояние_оборудования</code>
(values: <code>Рабочий</code>, <code>Не рабочий</code>, <code>Не проверял</code>).
</div>
<div class="meta" style="margin-bottom: 16px;">
<a href="/ui/ingest/manual-template.csv">Download CSV template</a>
</div>
<form class="form" data-endpoint="/ingest/manual/csv" data-kind="multipart">
<div class="field">
<label for="manualCsvFile">CSV File</label>
<input class="input" id="manualCsvFile" name="file" type="file" accept=".csv,text/csv" />
</div>
<button class="button" type="submit">Import CSV</button>
<div class="meta-grid" data-metrics style="display:none;"></div>
<pre class="meta" data-response></pre>
</form>
</section>
<section class="card">
<h2>Hardware Ingest</h2>
<form class="form" data-endpoint="/ingest/hardware">
<form class="form" data-endpoint="/ingest/hardware" data-kind="json">
<div class="field">
<label for="hardware">Payload (JSON)</label>
<textarea class="input" id="hardware" name="payload" rows="10">{{.HardwarePayload}}</textarea>
@@ -30,19 +54,101 @@
form.addEventListener("submit", async (event) => {
event.preventDefault();
const endpoint = form.getAttribute("data-endpoint");
const textarea = form.querySelector("textarea[name='payload']");
const kind = form.getAttribute("data-kind");
const output = form.querySelector("[data-response]");
if (!endpoint || !textarea || !output) return;
const metricsContainer = form.querySelector("[data-metrics]");
if (!endpoint || !output) return;
output.textContent = "Sending...";
if (metricsContainer) {
metricsContainer.style.display = "none";
metricsContainer.innerHTML = "";
}
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: textarea.value,
});
let response;
if (kind === "multipart") {
const fileInput = form.querySelector("input[name='file']");
const file = fileInput?.files?.[0];
if (!file) {
output.textContent = "Please select a CSV file.";
return;
}
const formData = new FormData();
formData.set("file", file);
response = await fetch(`${endpoint}?async=1`, {
method: "POST",
body: formData,
});
} else {
const textarea = form.querySelector("textarea[name='payload']");
if (!textarea) {
output.textContent = "Payload input is missing.";
return;
}
response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: textarea.value,
});
}
const text = await response.text();
if (kind === "multipart" && response.status === 202) {
const accepted = JSON.parse(text);
const jobID = accepted?.job_id;
if (!jobID) {
output.textContent = `${response.status} ${response.statusText}\n${text}`;
return;
}
output.textContent = `202 Accepted\nJob: ${jobID}\nStatus: queued`;
const result = await pollManualJob(jobID, output);
output.textContent = `${result.result_code ?? 200} Completed\n${JSON.stringify(result.result ?? {}, null, 2)}`;
if (metricsContainer) {
const metrics = result?.result?.metrics;
if (metrics && typeof metrics === "object") {
const rowsPerSec = Number(metrics.rows_per_sec || 0);
const items = [
["Parse, ms", metrics.parse_ms],
["Grouping, ms", metrics.grouping_ms],
["Ingest total, ms", metrics.ingest_ms_total],
["Total request, ms", metrics.total_ms],
["Rows/sec", rowsPerSec.toFixed(2)],
["Groups total", metrics.groups_total],
["Groups success", metrics.groups_success],
["Workers used", metrics.workers_used],
];
metricsContainer.innerHTML = items.map(([label, value]) => (
`<div><span>${label}</span><strong>${value ?? ""}</strong></div>`
)).join("");
metricsContainer.style.display = "grid";
}
}
return;
}
output.textContent = `${response.status} ${response.statusText}\n${text}`;
if (metricsContainer) {
try {
const parsed = JSON.parse(text);
const metrics = parsed?.metrics;
if (metrics && typeof metrics === "object") {
const rowsPerSec = Number(metrics.rows_per_sec || 0);
const items = [
["Parse, ms", metrics.parse_ms],
["Grouping, ms", metrics.grouping_ms],
["Ingest total, ms", metrics.ingest_ms_total],
["Total request, ms", metrics.total_ms],
["Rows/sec", rowsPerSec.toFixed(2)],
["Groups total", metrics.groups_total],
["Groups success", metrics.groups_success],
["Workers used", metrics.workers_used],
];
metricsContainer.innerHTML = items.map(([label, value]) => (
`<div><span>${label}</span><strong>${value ?? ""}</strong></div>`
)).join("");
metricsContainer.style.display = "grid";
}
} catch (_) {}
}
} catch (err) {
output.textContent = `Request failed: ${err}`;
}
@@ -65,6 +171,22 @@
}
});
}
async function pollManualJob(jobID, outputNode) {
const startedAt = Date.now();
while (true) {
const response = await fetch(`/ingest/manual/csv/jobs/${encodeURIComponent(jobID)}`);
const payload = await response.json().catch(() => ({}));
const job = payload?.job;
const status = job?.status || "unknown";
const elapsed = Math.round((Date.now() - startedAt) / 1000);
outputNode.textContent = `202 Accepted\nJob: ${jobID}\nStatus: ${status}\nElapsed: ${elapsed}s`;
if (status === "completed" || status === "failed") {
return payload;
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
}
</script>
</body>
</html>