235 lines
6.9 KiB
JavaScript
235 lines
6.9 KiB
JavaScript
const $ = (sel) => document.querySelector(sel);
|
|
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
|
|
|
function badgeClass(sev) {
|
|
const s = (sev || "").toLowerCase();
|
|
if (["ok", "info", "low"].includes(s)) return "good";
|
|
if (["medium", "warn", "warning"].includes(s)) return "warn";
|
|
if (["high", "critical", "bad"].includes(s)) return "bad";
|
|
return "";
|
|
}
|
|
|
|
function fmtTime(ts) {
|
|
if (!ts) return "—";
|
|
try {
|
|
if (typeof ts === "number") return new Date(ts * 1000).toLocaleString();
|
|
return new Date(ts).toLocaleString();
|
|
} catch {
|
|
return String(ts);
|
|
}
|
|
}
|
|
|
|
function toast(msg) {
|
|
const t = $("#toast");
|
|
t.textContent = msg;
|
|
t.classList.add("show");
|
|
setTimeout(() => t.classList.remove("show"), 3500);
|
|
}
|
|
|
|
async function api(path, opts) {
|
|
const res = await fetch(path, opts);
|
|
const ct = res.headers.get("content-type") || "";
|
|
const data = ct.includes("application/json") ? await res.json() : await res.text();
|
|
return { ok: res.ok, status: res.status, data };
|
|
}
|
|
|
|
let state = {
|
|
view: "dashboard",
|
|
reports: [],
|
|
selected: null,
|
|
};
|
|
|
|
function setView(v) {
|
|
state.view = v;
|
|
$$(".nav-item").forEach((a) => a.classList.toggle("active", a.dataset.view === v));
|
|
$("#view-dashboard").classList.toggle("hidden", v !== "dashboard");
|
|
$("#view-reports").classList.toggle("hidden", v !== "reports");
|
|
$("#view-settings").classList.toggle("hidden", v !== "settings");
|
|
|
|
if (v === "dashboard") {
|
|
$("#page-title").textContent = "Dashboard";
|
|
$("#page-sub").textContent = "Обзор состояния и последние отчёты";
|
|
}
|
|
if (v === "reports") {
|
|
$("#page-title").textContent = "Reports";
|
|
$("#page-sub").textContent = "Просмотр отчётов и применение действий (по согласию)";
|
|
}
|
|
if (v === "settings") {
|
|
$("#page-title").textContent = "Settings";
|
|
$("#page-sub").textContent = "Локальные параметры интерфейса";
|
|
}
|
|
}
|
|
|
|
function renderTable(el, rows, { limit = null } = {}) {
|
|
const r = limit ? rows.slice(0, limit) : rows;
|
|
const header = `
|
|
<div class="row header">
|
|
<div>Summary</div>
|
|
<div>Severity</div>
|
|
<div>Updated</div>
|
|
<div></div>
|
|
</div>
|
|
`;
|
|
|
|
const body = r
|
|
.map((it) => {
|
|
const cls = badgeClass(it.severity);
|
|
return `
|
|
<div class="row clickable" data-id="${it.id}">
|
|
<div>${escapeHtml(it.summary || "(no summary)")}</div>
|
|
<div><span class="badge ${cls}">${escapeHtml(it.severity || "unknown")}</span></div>
|
|
<div>${escapeHtml(fmtTime(it.created_utc || it.mtime))}</div>
|
|
<div style="text-align:right;"><span class="pill">open</span></div>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
el.innerHTML = header + body;
|
|
el.querySelectorAll(".row.clickable").forEach((row) => {
|
|
row.addEventListener("click", () => openReport(row.dataset.id));
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
async function refresh() {
|
|
const h = await api("/api/health");
|
|
if (h.ok) {
|
|
$("#health-pill").textContent = "UI: OK";
|
|
$("#status-value").textContent = "Protected";
|
|
$("#status-hint").textContent = "local UI service running";
|
|
} else {
|
|
$("#health-pill").textContent = "UI: error";
|
|
$("#status-value").textContent = "Degraded";
|
|
$("#status-hint").textContent = "health check failed";
|
|
}
|
|
|
|
const r = await api("/api/reports");
|
|
if (r.ok) {
|
|
state.reports = r.data.reports || [];
|
|
$("#reports-count").textContent = String(state.reports.length);
|
|
renderTable($("#latest-table"), state.reports, { limit: 6 });
|
|
renderTable($("#reports-table"), state.reports);
|
|
} else {
|
|
toast("Failed to load reports");
|
|
}
|
|
}
|
|
|
|
async function openReport(reportId) {
|
|
const p = await api(`/api/reports/${encodeURIComponent(reportId)}`);
|
|
if (!p.ok) {
|
|
toast("Report not found");
|
|
return;
|
|
}
|
|
const report = p.data.report;
|
|
state.selected = { id: reportId, report };
|
|
|
|
$("#report-details").classList.remove("hidden");
|
|
$("#details-title").textContent = `Report: ${reportId}`;
|
|
$("#details-sub").textContent = "Read-only details";
|
|
$("#details-summary").textContent = report.summary || "—";
|
|
|
|
const sev = report.severity || "unknown";
|
|
const cls = badgeClass(sev);
|
|
const badge = $("#details-severity");
|
|
badge.textContent = sev;
|
|
badge.className = `badge ${cls}`;
|
|
|
|
$("#details-created").textContent = fmtTime(report.created_utc || "—");
|
|
$("#raw").textContent = JSON.stringify(report, null, 2);
|
|
|
|
const fixesEl = $("#fixes");
|
|
fixesEl.classList.add("fixes");
|
|
const fixes = report.suggested_fixes || [];
|
|
if (!fixes.length) {
|
|
fixesEl.innerHTML = `<div class="muted" style="padding:12px;">No suggested fixes</div>`;
|
|
} else {
|
|
fixesEl.innerHTML = fixes
|
|
.map((f) => {
|
|
return `
|
|
<div class="fix">
|
|
<div class="fix-title">${escapeHtml(f.title || f.id || "Fix")}</div>
|
|
<div class="fix-desc">${escapeHtml(f.description || "")}</div>
|
|
<div class="fix-actions">
|
|
<button class="btn" data-fix="${escapeHtml(f.id || "")}">Apply…</button>
|
|
<span class="pill">consent</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
fixesEl.querySelectorAll("button[data-fix]").forEach((b) => {
|
|
b.addEventListener("click", () => applyFix(b.dataset.fix));
|
|
});
|
|
}
|
|
}
|
|
|
|
async function applyFix(fixId) {
|
|
if (!state.selected) return;
|
|
const reportId = state.selected.id;
|
|
|
|
const confirm1 = window.confirm(
|
|
`Apply fix '${fixId}' for report '${reportId}'?\n\nThis action requires explicit operator confirmation.`
|
|
);
|
|
if (!confirm1) return;
|
|
|
|
const res = await api("/api/apply-fix", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ report_id: reportId, fix_id: fixId, confirm: true }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
toast("Fix applied");
|
|
} else {
|
|
if (res.status === 501) {
|
|
toast("Fix runner not implemented yet (MVP UI only)");
|
|
return;
|
|
}
|
|
if (res.status === 409) {
|
|
toast("Confirmation required");
|
|
return;
|
|
}
|
|
toast("Failed to apply fix");
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
$$(".nav-item").forEach((a) => {
|
|
a.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
setView(a.dataset.view);
|
|
});
|
|
});
|
|
|
|
$("#refresh-btn").addEventListener("click", () => refresh());
|
|
|
|
$("#close-details").addEventListener("click", () => {
|
|
$("#report-details").classList.add("hidden");
|
|
state.selected = null;
|
|
});
|
|
|
|
$("#filter").addEventListener("input", () => {
|
|
const q = $("#filter").value.trim().toLowerCase();
|
|
const rows = !q
|
|
? state.reports
|
|
: state.reports.filter((r) =>
|
|
String(r.summary || "").toLowerCase().includes(q) || String(r.id || "").toLowerCase().includes(q)
|
|
);
|
|
renderTable($("#reports-table"), rows);
|
|
});
|
|
}
|
|
|
|
bindEvents();
|
|
setView("dashboard");
|
|
refresh();
|