Implement async manual CSV ingest, unified UI pagination/filters, and serial placeholder strategy
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user