323 lines
14 KiB
Cheetah
323 lines
14 KiB
Cheetah
{{define "ingest"}}
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
{{template "head" .}}
|
||
<body>
|
||
{{template "topbar" .}}
|
||
{{template "breadcrumbs" .}}
|
||
<style>
|
||
.ingest-response {
|
||
margin-top: 12px;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
background: #f8fafc;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
max-height: 360px;
|
||
overflow: auto;
|
||
max-width: 100%;
|
||
}
|
||
</style>
|
||
|
||
<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 ingest-response" data-response></pre>
|
||
</form>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>Hardware Ingest</h2>
|
||
<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>
|
||
</div>
|
||
<div class="field">
|
||
<label for="hardwareFile">Upload JSON snapshot(s) (per INTEGRATION_GUIDE.md)</label>
|
||
<input class="input" id="hardwareFile" type="file" accept="application/json,.json" multiple data-hardware-files="1" />
|
||
</div>
|
||
<div class="field" data-hardware-progress-wrap hidden>
|
||
<label for="hardwareBatchProgress">Import Progress</label>
|
||
<progress id="hardwareBatchProgress" max="100" value="0" style="width:100%;" data-hardware-progress></progress>
|
||
<div class="meta" data-hardware-progress-text>Waiting to start...</div>
|
||
</div>
|
||
<button class="button" type="submit">Send Hardware Snapshot</button>
|
||
<pre class="meta ingest-response" data-response></pre>
|
||
</form>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
const forms = document.querySelectorAll("form[data-endpoint]");
|
||
forms.forEach((form) => {
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const endpoint = form.getAttribute("data-endpoint");
|
||
const kind = form.getAttribute("data-kind");
|
||
const output = form.querySelector("[data-response]");
|
||
const metricsContainer = form.querySelector("[data-metrics]");
|
||
if (!endpoint || !output) return;
|
||
|
||
output.textContent = "Sending...";
|
||
if (metricsContainer) {
|
||
metricsContainer.style.display = "none";
|
||
metricsContainer.innerHTML = "";
|
||
}
|
||
try {
|
||
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 hardwareFileInput = form.querySelector("input[data-hardware-files='1']");
|
||
const progressWrap = form.querySelector("[data-hardware-progress-wrap]");
|
||
const progressBar = form.querySelector("[data-hardware-progress]");
|
||
const progressText = form.querySelector("[data-hardware-progress-text]");
|
||
resetHardwareProgress(progressWrap, progressBar, progressText);
|
||
|
||
const files = Array.from(hardwareFileInput?.files || []);
|
||
if (files.length > 0) {
|
||
output.textContent = `Uploading ${files.length} JSON file(s)...`;
|
||
showHardwareProgress(progressWrap, progressBar, progressText, files.length, 0, "Starting batch...");
|
||
const batchResults = [];
|
||
for (const [index, file] of files.entries()) {
|
||
showHardwareProgress(
|
||
progressWrap,
|
||
progressBar,
|
||
progressText,
|
||
files.length,
|
||
index,
|
||
`Uploading [${index + 1}/${files.length}] ${file.name || `file-${index + 1}.json`}...`,
|
||
);
|
||
const payload = await file.text();
|
||
const fileResponse = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: payload,
|
||
});
|
||
const fileResponseText = await fileResponse.text();
|
||
batchResults.push({
|
||
index: index + 1,
|
||
total: files.length,
|
||
fileName: file.name || `file-${index + 1}.json`,
|
||
status: fileResponse.status,
|
||
statusText: fileResponse.statusText,
|
||
responseText: safeRenderResponseText(fileResponseText),
|
||
});
|
||
showHardwareProgress(
|
||
progressWrap,
|
||
progressBar,
|
||
progressText,
|
||
files.length,
|
||
index + 1,
|
||
`Processed [${index + 1}/${files.length}] ${file.name || `file-${index + 1}.json`}`,
|
||
);
|
||
}
|
||
output.textContent = formatHardwareBatchResults(batchResults);
|
||
const successCount = batchResults.filter((item) => item.status >= 200 && item.status < 300).length;
|
||
showHardwareProgress(
|
||
progressWrap,
|
||
progressBar,
|
||
progressText,
|
||
files.length,
|
||
files.length,
|
||
`Batch completed: ${successCount}/${files.length} succeeded.`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
});
|
||
});
|
||
|
||
const hardwareFileInput = document.getElementById("hardwareFile");
|
||
const hardwareTextarea = document.getElementById("hardware");
|
||
if (hardwareFileInput && hardwareTextarea) {
|
||
hardwareFileInput.addEventListener("change", async () => {
|
||
const files = Array.from(hardwareFileInput.files || []);
|
||
if (files.length === 0) {
|
||
return;
|
||
}
|
||
if (files.length > 1) {
|
||
hardwareTextarea.value = `Selected ${files.length} files for batch ingest. Submit form to upload all files.`;
|
||
return;
|
||
}
|
||
try {
|
||
const file = files[0];
|
||
const text = await file.text();
|
||
hardwareTextarea.value = text;
|
||
} catch (err) {
|
||
alert(`Failed to load file: ${err}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
|
||
function formatHardwareBatchResults(batchResults) {
|
||
const successCount = batchResults.filter((item) => item.status >= 200 && item.status < 300).length;
|
||
const failedCount = batchResults.length - successCount;
|
||
const lines = [
|
||
`Batch complete: ${successCount}/${batchResults.length} succeeded, ${failedCount} failed.`,
|
||
"",
|
||
];
|
||
batchResults.forEach((item) => {
|
||
lines.push(`[${item.index}/${item.total}] ${item.fileName}`);
|
||
lines.push(`${item.status} ${item.statusText}`);
|
||
lines.push(item.responseText || "(empty response)");
|
||
lines.push("");
|
||
});
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function safeRenderResponseText(text) {
|
||
const rendered = prettyJSONOrRaw(text || "");
|
||
const maxChars = 1800;
|
||
if (rendered.length <= maxChars) return rendered;
|
||
return `${rendered.slice(0, maxChars)}\n...truncated (${rendered.length - maxChars} chars omitted)`;
|
||
}
|
||
|
||
function prettyJSONOrRaw(text) {
|
||
const trimmed = (text || "").trim();
|
||
if (!trimmed) return "(empty response)";
|
||
try {
|
||
const parsed = JSON.parse(trimmed);
|
||
return JSON.stringify(parsed, null, 2);
|
||
} catch (_) {
|
||
return trimmed;
|
||
}
|
||
}
|
||
|
||
function resetHardwareProgress(wrap, bar, text) {
|
||
if (wrap) wrap.hidden = true;
|
||
if (bar) bar.value = 0;
|
||
if (text) text.textContent = "Waiting to start...";
|
||
}
|
||
|
||
function showHardwareProgress(wrap, bar, text, total, completed, message) {
|
||
if (wrap) wrap.hidden = false;
|
||
const percent = total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;
|
||
if (bar) bar.value = percent;
|
||
if (text) text.textContent = `${message} (${percent}%)`;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
{{end}}
|