feat(portal): add incident review workflow

This commit is contained in:
igor04091968
2026-06-04 13:46:25 +03:00
parent 4c66e67661
commit 20fb47b457
4 changed files with 407 additions and 17 deletions
@@ -881,6 +881,12 @@ h1 {
justify-content: flex-end;
}
.compact-actions {
display: grid;
grid-template-columns: repeat(2, minmax(92px, 1fr));
gap: 6px;
}
.small-button {
min-height: 32px;
padding: 6px 8px;
@@ -23,9 +23,9 @@ async function postJson(path, payload) {
function statusClass(status) {
const s = String(status || "UNKNOWN").toLowerCase();
if (s === "ok" || s === "true" || s === "low") return "status-ok";
if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium") return "status-warn";
if (s === "degraded" || s === "high") return "status-degraded";
if (s === "ok" || s === "true" || s === "low" || s === "false_positive") return "status-ok";
if (s === "warn" || s === "warning" || s === "fallback" || s === "stale" || s === "medium" || s === "in_review" || s === "postponed") return "status-warn";
if (s === "degraded" || s === "high" || s === "confirmed") return "status-degraded";
if (s === "fail" || s === "false" || s === "error" || s === "critical" || s === "missing") return "status-fail";
return "status-unknown";
}
@@ -1735,8 +1735,10 @@ function renderRiskIncidentCandidates(items) {
<th>Подразделение</th>
<th>Узел</th>
<th>Риск</th>
<th>Проверка</th>
<th>Причина</th>
<th>Рекомендация</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
@@ -1746,8 +1748,10 @@ function renderRiskIncidentCandidates(items) {
<td>${ui(item.department || "-")}<br><span class="muted small">${ui(item.owner || "ответственный не указан")}</span></td>
<td>${ui(item.hostname || "-")}<br><span class="muted small">${ui(candidateSeenText(item))}</span></td>
<td><span class="badge ${statusClass(item.risk_level)}">${ui(item.risk_level || "UNKNOWN")}</span></td>
<td>${renderCandidateReview(item)}</td>
<td>${ui(candidateReasonText(item))}</td>
<td>${ui(item.recommendation || "Назначить ответственную ручную проверку.")}</td>
<td>${renderCandidateReviewActions(item)}</td>
</tr>
`).join("") : `
<tr>
@@ -1755,8 +1759,10 @@ function renderRiskIncidentCandidates(items) {
<td>Нет кандидатов</td>
<td>-</td>
<td><span class="badge status-ok">OK</span></td>
<td><span class="badge status-unknown">NEW</span></td>
<td>очередь проверки пуста</td>
<td>Действий не требуется.</td>
<td>-</td>
</tr>
`}
</tbody>
@@ -1767,6 +1773,37 @@ function renderRiskIncidentCandidates(items) {
`;
}
function renderCandidateReview(item) {
const review = item?.incident_review || {};
const status = review.status || "NEW";
const comment = review.comment || "комментария нет";
const reviewer = review.reviewer || "проверяющий не указан";
const updated = review.updated_at || "не обновлялось";
return `<span class="badge ${statusClass(status)}">${ui(reviewStatusText(status))}</span><br><span class="muted small">${ui(reviewer)} · ${ui(updated)}</span><br><span class="muted small">${ui(comment)}</span>`;
}
function renderCandidateReviewActions(item) {
const id = item?.id || "";
const actions = [
["IN_REVIEW", "В проверку"],
["CONFIRMED", "Подтвердить"],
["FALSE_POSITIVE", "Ложный"],
["POSTPONED", "Отложить"],
];
return `<div class="button-row compact-actions">${actions.map(([status, label]) => `
<button class="small-button" data-review-status="${escapeHtml(status)}" data-candidate-id="${escapeHtml(id)}">${ui(label)}</button>
`).join("")}</div>`;
}
function reviewStatusText(status) {
const value = String(status || "NEW").toUpperCase();
if (value === "IN_REVIEW") return "В проверке";
if (value === "CONFIRMED") return "Подтвержден";
if (value === "FALSE_POSITIVE") return "Ложный";
if (value === "POSTPONED") return "Отложен";
return "Новый";
}
function candidateReasonText(item) {
const evidence = Array.isArray(item?.evidence) && item.evidence.length
? ` Evidence: ${item.evidence.join("; ")}`
@@ -1994,6 +2031,12 @@ document.addEventListener("click", event => {
incidentAction(button).catch(showError);
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-review-status]");
if (!button) return;
candidateReviewAction(button).catch(showError);
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-print-report]");
if (!button) return;
@@ -2090,6 +2133,23 @@ async function incidentAction(button) {
await refresh();
}
async function candidateReviewAction(button) {
const candidateId = button.dataset.candidateId;
const status = button.dataset.reviewStatus;
const reviewer = window.prompt("Проверяющий", "");
if (reviewer === null || reviewer.trim() === "") return;
const comment = window.prompt("Комментарий к проверке", "");
if (comment === null) return;
button.disabled = true;
await postJson("/incident-review", {
candidate_id: candidateId,
status,
reviewer,
comment,
});
await refresh();
}
applySecurityMode(state.tab);
refresh().catch(showError);
setInterval(() => refresh().catch(showError), 60000);