Files
core/internal/api/ui_ingest.tmpl

323 lines
14 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{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}}