feat(ops): surface readiness in portal

This commit is contained in:
igor04091968
2026-06-03 21:45:03 +03:00
parent 59d5e1f1fc
commit 5aab90f27d
7 changed files with 258 additions and 4 deletions
@@ -130,6 +130,40 @@ h1 {
.status-warn { background: var(--warn-bg); color: var(--warn); }
.status-fail { background: var(--fail-bg); color: var(--fail); }
.status-unknown { background: var(--unknown-bg); color: var(--unknown); }
.text-ok { color: var(--ok); }
.text-fail { color: var(--fail); }
.readiness-card {
grid-column: span 2;
}
.readiness-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.readiness-head h3 {
margin-top: 8px;
}
.readiness-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 10px 0 6px;
}
.readiness-metrics > div {
display: grid;
gap: 3px;
min-width: 0;
}
.readiness-card code {
overflow-wrap: anywhere;
}
.grid-2 {
display: grid;
@@ -350,6 +384,9 @@ pre {
.row { grid-template-columns: 1fr; }
.incident-row { grid-template-columns: 1fr; }
.evidence-row { grid-template-columns: 1fr; }
.readiness-card { grid-column: span 1; }
.readiness-head { flex-direction: column; }
.readiness-metrics { grid-template-columns: 1fr; }
.app-weight-row { grid-template-columns: 1fr; }
.employee-index-row { grid-template-columns: 1fr; }
.section-head { flex-direction: column; }
@@ -1,4 +1,4 @@
const state = { tab: "operator", links: null };
const state = { tab: "operator", links: null, readiness: null };
function apiBase() {
const path = window.location.pathname;
@@ -37,12 +37,13 @@ function escapeHtml(value) {
.replaceAll('"', """);
}
function renderSummary(summary) {
function renderSummary(summary, readiness) {
const global = document.getElementById("globalStatus");
global.className = `status-pill ${statusClass(summary.severity)}`;
global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
const blocks = Object.entries(summary.blocks || {});
document.getElementById("summary").innerHTML = blocks.map(([name, block]) => `
const readinessCard = renderReadinessSummaryCard(readiness);
document.getElementById("summary").innerHTML = readinessCard + blocks.map(([name, block]) => `
<article class="card">
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
<h3>${escapeHtml(label(name))}</h3>
@@ -51,6 +52,44 @@ function renderSummary(summary) {
`).join("");
}
function renderReadinessSummaryCard(readiness) {
const bundle = readiness?.bundle || {};
const verify = readiness?.verify || {};
const status = bundle.status?.status || bundle.status || (verify.ok ? "OK" : "UNKNOWN");
const generated = bundle.status?.generated_at_utc || bundle.generated_at_utc || "-";
const signature = bundle.status?.signature || {};
const signatureOk = Boolean(verify.signature_verified || signature.verified);
const checksumOk = Boolean(verify.checksum_verified || bundle.status?.checksum_verified || verify.ok);
const fingerprint = signature.public_key_fingerprint_sha256 || "-";
const verificationText = verify.generated_at_utc
? `Проверено: ${verify.generated_at_utc}`
: "Проверка не запускалась";
return `
<article class="card readiness-card">
<div class="readiness-head">
<div>
<span class="badge ${statusClass(status)}">${escapeHtml(status)}</span>
<h3>Готовность системы</h3>
</div>
<button class="small-button" data-readiness-verify="true">Проверить bundle</button>
</div>
<div class="readiness-metrics">
<div><span class="muted">Дата</span><strong>${escapeHtml(generated)}</strong></div>
<div><span class="muted">Подпись</span><strong class="${signatureOk ? "text-ok" : "text-fail"}">${signatureOk ? "OK" : "FAIL"}</strong></div>
<div><span class="muted">Checksum</span><strong class="${checksumOk ? "text-ok" : "text-fail"}">${checksumOk ? "OK" : "FAIL"}</strong></div>
</div>
<p class="muted small">Отпечаток: <code>${escapeHtml(shortFingerprint(fingerprint))}</code></p>
<p id="readinessVerifyStatus" class="muted small">${escapeHtml(verificationText)}</p>
</article>
`;
}
function shortFingerprint(value) {
const text = String(value || "-");
if (text.length <= 24) return text;
return `${text.slice(0, 12)}${text.slice(-12)}`;
}
function label(name) {
return {
collection: "Сбор данных",
@@ -464,7 +503,11 @@ function renderReports(data) {
async function refresh() {
if (!state.links) state.links = await loadJson("/links");
const summary = await loadJson("/summary");
renderSummary(summary);
state.readiness = {
bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })),
verify: state.readiness?.verify || null
};
renderSummary(summary, state.readiness);
const content = document.getElementById("content");
const data = await loadJson(`/${state.tab}`);
if (state.tab === "operator") content.innerHTML = renderOperator(data);
@@ -515,6 +558,28 @@ document.addEventListener("click", event => {
anonymizeReport(button).catch(showError);
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-readiness-verify]");
if (!button) return;
verifyReadinessBundle(button).catch(showError);
});
async function verifyReadinessBundle(button) {
button.disabled = true;
button.textContent = "Проверка...";
const verify = await loadJson("/readiness/verify");
state.readiness = {
...(state.readiness || {}),
verify
};
const status = document.getElementById("readinessVerifyStatus");
if (status) {
status.textContent = `Проверено: checksum ${verify.checksum_verified ? "OK" : "FAIL"} · signature ${verify.signature_verified ? "OK" : "FAIL"}`;
}
button.disabled = false;
button.textContent = "Проверить bundle";
}
async function anonymizeReport(button) {
button.disabled = true;
const data = await loadJson("/reports?anonymize=1");