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
+294 -14
View File
@@ -350,6 +350,7 @@ struct RiskIncidentCandidate {
last_seen_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
recommendation: Option<String>,
incident_review: IncidentReviewState,
}
#[derive(Debug, Serialize)]
@@ -432,6 +433,50 @@ struct IncidentActionResponse {
state: IncidentActionState,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct IncidentReviewFile {
reviews: BTreeMap<String, IncidentReviewState>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct IncidentReviewState {
candidate_id: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
reviewer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
updated_at: String,
}
impl Default for IncidentReviewState {
fn default() -> Self {
Self {
candidate_id: String::new(),
status: "NEW".to_string(),
reviewer: None,
comment: None,
updated_at: String::new(),
}
}
}
#[derive(Debug, Deserialize)]
struct IncidentReviewRequest {
candidate_id: String,
status: String,
#[serde(default)]
reviewer: Option<String>,
#[serde(default)]
comment: Option<String>,
}
#[derive(Debug, Serialize)]
struct IncidentReviewResponse {
ok: bool,
review: IncidentReviewState,
}
#[derive(Debug, Serialize)]
struct IncidentAuditEntry {
generated_at_utc: String,
@@ -768,6 +813,12 @@ struct ReportMarkdownContext<'a> {
risk_incident_candidates: &'a [RiskIncidentCandidate],
}
struct ReportRuntimeInputs<'a> {
incident_state: &'a IncidentStateFile,
incident_reviews: &'a IncidentReviewFile,
evidence: &'a DlpEvidenceResponse,
}
fn main() {
let code = match run() {
Ok(code) => code,
@@ -788,7 +839,11 @@ fn run() -> Result<i32> {
let smoke = json!({
"health": build_health(&snapshot),
"summary": build_summary(&snapshot),
"reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args), &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false),
"reports": build_reports(&snapshot, ReportRuntimeInputs {
incident_state: &incident_state,
incident_reviews: &load_incident_review_best_effort(&args),
evidence: &build_dlp_evidence_response(&args),
}, &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false),
"incidents": build_incidents(&snapshot, &incident_state),
"dlp_evidence": build_dlp_evidence_response(&args),
});
@@ -824,6 +879,9 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
if method == Method::Post && path == "/api/incidents/action" {
return handle_incident_action(request, args);
}
if method == Method::Post && path == "/api/incident-review" {
return handle_incident_review(request, args);
}
if method == Method::Post && path == "/api/telemetry" {
return handle_telemetry_ingest(request, args);
}
@@ -885,14 +943,18 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
"/api/reports" => {
let snapshot = cached_snapshot(args, snapshot_cache);
let incident_state = load_incident_state_best_effort(args);
let incident_reviews = load_incident_review_best_effort(args);
let evidence = build_dlp_evidence_response(args);
let ueba_baseline_path = ueba_baseline_state_path(args);
respond_json(
request,
&build_reports(
&snapshot,
&incident_state,
&evidence,
ReportRuntimeInputs {
incident_state: &incident_state,
incident_reviews: &incident_reviews,
evidence: &evidence,
},
&args.workforce_policy_path,
&args.ueba_policy_path,
&ueba_baseline_path,
@@ -1937,15 +1999,14 @@ fn build_owner(snapshot: &Snapshot) -> Value {
fn build_reports(
snapshot: &Snapshot,
incident_state: &IncidentStateFile,
evidence: &DlpEvidenceResponse,
inputs: ReportRuntimeInputs<'_>,
workforce_policy_path: &Path,
ueba_policy_path: &Path,
ueba_baseline_path: &Path,
anonymize: bool,
) -> Value {
let summary = build_summary(snapshot);
let incidents = build_incidents(snapshot, incident_state);
let incidents = build_incidents(snapshot, inputs.incident_state);
let (users_count, active_seconds, apps_count) = worktime_totals(snapshot);
let dlp = dlp_counts(snapshot);
let metrics = ReportMetrics {
@@ -1955,8 +2016,9 @@ fn build_reports(
dlp_ok: dlp.0,
dlp_warn: dlp.1,
dlp_fail: dlp.2,
evidence_total: evidence.items.len(),
evidence_screenshots: evidence
evidence_total: inputs.evidence.items.len(),
evidence_screenshots: inputs
.evidence
.items
.iter()
.filter(|item| item.screenshot_available)
@@ -1982,8 +2044,9 @@ fn build_reports(
let business_risk = build_business_risk(snapshot, &department_items);
let business_risk_history = build_business_risk_history(snapshot);
let business_risk_history_summary = summarize_business_risk_history(&business_risk_history);
let risk_incident_candidates =
let mut risk_incident_candidates =
build_risk_incident_candidates(snapshot, &business_risk, &business_risk_history);
apply_incident_reviews_to_candidates(&mut risk_incident_candidates, inputs.incident_reviews);
let trend = workforce_trend_json(snapshot);
let insight_items = workforce_insight_items(snapshot);
let workforce_policy_explain =
@@ -2116,7 +2179,7 @@ fn build_reports(
report_kpi("Приложения", metrics.apps_count.to_string(), worktime.status.clone(), "true active applications"),
report_kpi("Подразделения", department_items.len().to_string(), snapshot.worktime_management.status.clone(), "сравнение групп за текущий день"),
report_kpi("DLP WARN/FAIL", format!("{}/{}", metrics.dlp_warn, metrics.dlp_fail), dlp_block_value.status.clone(), "технические сигналы DLP"),
report_kpi("Evidence", format!("{}/{}", metrics.evidence_screenshots, metrics.evidence_total), evidence_status(evidence), "скриншоты / все evidence items"),
report_kpi("Evidence", format!("{}/{}", metrics.evidence_screenshots, metrics.evidence_total), evidence_status(inputs.evidence), "скриншоты / все evidence items"),
report_kpi("Открытые вопросы", metrics.open_incidents.to_string(), incident_status(metrics.open_incidents), "не взятые в работу items")
],
"sections": [
@@ -2163,8 +2226,8 @@ fn build_reports(
"title": "ИБ и evidence",
"items": [
report_item("DLP", dlp_block_value.status.clone(), dlp_block_value.text.clone()),
report_item("Evidence metadata", evidence_status(evidence), format!("items={}", metrics.evidence_total)),
report_item("Скриншоты", evidence_status(evidence), format!("available={}", metrics.evidence_screenshots)),
report_item("Evidence metadata", evidence_status(inputs.evidence), format!("items={}", metrics.evidence_total)),
report_item("Скриншоты", evidence_status(inputs.evidence), format!("available={}", metrics.evidence_screenshots)),
report_item("Формулировка", "OK", "derived detections/cases, не сертифицированная СЗИ")
]
},
@@ -2598,6 +2661,26 @@ fn build_risk_incident_candidates(
.collect()
}
fn apply_incident_reviews_to_candidates(
candidates: &mut [RiskIncidentCandidate],
reviews: &IncidentReviewFile,
) {
for candidate in candidates {
candidate.incident_review =
reviews
.reviews
.get(&candidate.id)
.cloned()
.unwrap_or_else(|| IncidentReviewState {
candidate_id: candidate.id.clone(),
status: "NEW".to_string(),
reviewer: None,
comment: None,
updated_at: String::new(),
});
}
}
fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec<RiskIncidentCandidate> {
let mut by_department = BTreeMap::<String, Vec<&BusinessRiskHistoryItem>>::new();
for item in history {
@@ -2636,6 +2719,7 @@ fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec<RiskI
"Открыть управленческую проверку причин устойчивого высокого риска."
.to_string(),
),
incident_review: IncidentReviewState::default(),
})
})
.collect()
@@ -2663,6 +2747,7 @@ fn low_trust_risk_candidates(
first_seen_utc: Some(snapshot.generated_at_utc.clone()),
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
recommendation: Some(item.recommendation.clone()),
incident_review: IncidentReviewState::default(),
})
.collect()
}
@@ -2691,6 +2776,7 @@ fn agent_quality_candidates(
first_seen_utc: Some(node.last_seen_utc.clone()),
last_seen_utc: Some(node.last_seen_utc.clone()),
recommendation: Some(node.recommendation.clone()),
incident_review: IncidentReviewState::default(),
});
}
if let Some(error) = &node.collector_error {
@@ -2711,6 +2797,7 @@ fn agent_quality_candidates(
recommendation: Some(
"Проверить журнал агента и восстановить основной сбор.".to_string(),
),
incident_review: IncidentReviewState::default(),
});
}
}
@@ -2742,6 +2829,7 @@ fn agent_coverage_candidates(snapshot: &Snapshot) -> Vec<RiskIncidentCandidate>
first_seen_utc: Some(node.last_seen_utc.clone()),
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
recommendation: Some(node.recommendation.clone()),
incident_review: IncidentReviewState::default(),
})
.collect()
}
@@ -4296,6 +4384,7 @@ fn render_report_markdown(
context.business_risk_history_summary,
);
append_risk_incident_candidates_markdown(&mut text, context.risk_incident_candidates);
append_incident_review_markdown(&mut text, context.risk_incident_candidates);
append_ueba_risk_markdown(&mut text, context.ueba_risk);
append_workforce_policy_markdown(&mut text, context.workforce_policy);
text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n");
@@ -4554,6 +4643,29 @@ fn append_risk_incident_candidates_markdown(
}
}
fn append_incident_review_markdown(text: &mut String, candidates: &[RiskIncidentCandidate]) {
text.push_str("\n## Проверка кандидатов в инциденты\n\n");
if candidates.is_empty() {
text.push_str("- Очередь проверки пуста.\n");
return;
}
for item in candidates.iter().take(10) {
let review = &item.incident_review;
text.push_str(&format!(
"- {}: status={}, reviewer={}, updated_at={}, comment={}\n",
review.candidate_id,
review.status,
review.reviewer.as_deref().unwrap_or("-"),
if review.updated_at.is_empty() {
"-"
} else {
review.updated_at.as_str()
},
review.comment.as_deref().unwrap_or("-")
));
}
}
fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
text.push_str("\n## UEBA риск\n\n");
text.push_str(&format!(
@@ -5022,6 +5134,27 @@ fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> {
}
}
fn handle_incident_review(mut request: Request, args: &Cli) -> Result<()> {
let actor = request_actor(&request);
let mut body = String::new();
request
.as_reader()
.take(32 * 1024)
.read_to_string(&mut body)?;
let response = apply_incident_review(args, &actor, &body);
match response {
Ok(response) => respond_json(request, &response),
Err(err) => respond_json_status(
request,
StatusCode(400),
&json!({
"ok": false,
"error": err.to_string()
}),
),
}
}
fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> {
if !telemetry_authorized(&request, args) {
return respond_json_status(
@@ -5195,6 +5328,28 @@ fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result<Incident
})
}
fn apply_incident_review(args: &Cli, actor: &str, body: &str) -> Result<IncidentReviewResponse> {
let request: IncidentReviewRequest =
serde_json::from_str(body).map_err(|err| anyhow!("invalid incident review JSON: {err}"))?;
let candidate_id = validate_short_token(&request.candidate_id, "candidate_id", 128)?;
let status = validate_incident_review_status(&request.status)?;
let reviewer = sanitize_optional_text(request.reviewer, 80)
.or_else(|| Some(sanitize_text(actor, 80)))
.filter(|value| !value.is_empty());
let comment = sanitize_optional_text(request.comment, 500);
let review = IncidentReviewState {
candidate_id: candidate_id.clone(),
status: status.to_string(),
reviewer,
comment,
updated_at: now(),
};
let mut state = load_incident_review(args)?;
state.reviews.insert(candidate_id, review.clone());
save_incident_review(args, &state)?;
Ok(IncidentReviewResponse { ok: true, review })
}
fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile {
match load_incident_state(args) {
Ok(state) => state,
@@ -5205,6 +5360,16 @@ fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile {
}
}
fn load_incident_review_best_effort(args: &Cli) -> IncidentReviewFile {
match load_incident_review(args) {
Ok(state) => state,
Err(err) => {
eprintln!("detmir-portal incident review read failed: {err:#}");
IncidentReviewFile::default()
}
}
}
fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
let path = incident_state_path(args);
if !path.exists() {
@@ -5214,6 +5379,15 @@ fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))
}
fn load_incident_review(args: &Cli) -> Result<IncidentReviewFile> {
let path = incident_review_path(args);
if !path.exists() {
return Ok(IncidentReviewFile::default());
}
let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))
}
fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
fs::create_dir_all(&args.state_dir)
.with_context(|| format!("create {}", args.state_dir.display()))?;
@@ -5225,6 +5399,18 @@ fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
Ok(())
}
fn save_incident_review(args: &Cli, state: &IncidentReviewFile) -> Result<()> {
let path = incident_review_path(args);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, serde_json::to_vec_pretty(state)?)
.with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
Ok(())
}
fn append_incident_audit(args: &Cli, entry: &IncidentAuditEntry) -> Result<()> {
fs::create_dir_all(&args.state_dir)
.with_context(|| format!("create {}", args.state_dir.display()))?;
@@ -5243,6 +5429,10 @@ fn incident_state_path(args: &Cli) -> PathBuf {
args.state_dir.join("incidents-state.json")
}
fn incident_review_path(args: &Cli) -> PathBuf {
args.state_dir.join("data").join("incident_reviews.json")
}
fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse {
let generated_at_utc = now();
let db_available = args.dlp_db_path.exists();
@@ -5992,6 +6182,17 @@ fn validate_action(value: &str) -> Result<&'static str> {
}
}
fn validate_incident_review_status(value: &str) -> Result<&'static str> {
match value.trim() {
"NEW" => Ok("NEW"),
"IN_REVIEW" => Ok("IN_REVIEW"),
"CONFIRMED" => Ok("CONFIRMED"),
"FALSE_POSITIVE" => Ok("FALSE_POSITIVE"),
"POSTPONED" => Ok("POSTPONED"),
_ => Err(anyhow!("unsupported incident review status")),
}
}
fn sanitize_optional_text(value: Option<String>, max_len: usize) -> Option<String> {
value
.map(|text| sanitize_text(&text, max_len))
@@ -7071,6 +7272,72 @@ mod tests {
assert_eq!(item.assigned_to.as_deref(), Some("operator"));
}
#[test]
fn incident_review_persists_and_applies_to_candidates() {
let dir = tempfile::tempdir().unwrap();
let args = Cli {
bind: "127.0.0.1:0".to_string(),
status_cmd: "true".to_string(),
check_cmd: "true".to_string(),
failed_units_cmd: "true".to_string(),
worktime_url: "http://127.0.0.1".to_string(),
one_c_url: "http://127.0.0.1".to_string(),
workforce_policy_path: dir.path().join("workforce-policy.json"),
ueba_policy_path: dir.path().join("ueba-policy.yaml"),
timeout_seconds: 1,
state_dir: dir.path().join("state"),
dlp_db_path: dir.path().join("dlp.sqlite"),
evidence_root: dir.path().to_path_buf(),
readiness_bundle_dir: dir.path().join("readiness-bundle"),
evidence_limit: 10,
evidence_max_bytes: 1024,
json_smoke: false,
evidence_only: false,
evidence_upload_token: None,
telemetry_api_key: "test-key".to_string(),
telemetry_store_path: dir.path().join("telemetry.jsonl"),
expected_nodes_path: dir.path().join("expected_nodes.json"),
};
let body = json!({
"candidate_id": "risk-candidate-123",
"status": "CONFIRMED",
"reviewer": "operator",
"comment": "confirmed by manual check"
})
.to_string();
let response = apply_incident_review(&args, "fallback-actor", &body).unwrap();
assert!(response.ok);
assert_eq!(response.review.status, "CONFIRMED");
assert!(incident_review_path(&args).ends_with("data/incident_reviews.json"));
let stored = load_incident_review(&args).unwrap();
assert_eq!(
stored.reviews["risk-candidate-123"].comment.as_deref(),
Some("confirmed by manual check")
);
let mut candidates = vec![RiskIncidentCandidate {
id: "risk-candidate-123".to_string(),
department: Some("Подразделение".to_string()),
owner: None,
hostname: None,
risk_level: Some("HIGH".to_string()),
reason: Some("KPI не принят".to_string()),
evidence: vec!["test".to_string()],
first_seen_utc: None,
last_seen_utc: None,
recommendation: None,
incident_review: IncidentReviewState::default(),
}];
apply_incident_reviews_to_candidates(&mut candidates, &stored);
assert_eq!(candidates[0].incident_review.status, "CONFIRMED");
assert_eq!(
validate_incident_review_status("FALSE_POSITIVE").unwrap(),
"FALSE_POSITIVE"
);
assert!(validate_incident_review_status("AUTO_CREATE").is_err());
}
#[test]
fn evidence_id_is_stable_and_parseable() {
let id = evidence_id(42, "event-1", Some(r"C:\tmp\shot.png"));
@@ -7443,8 +7710,11 @@ mod tests {
let baseline_path = dir.path().join("ueba-baseline-state.json");
let report = build_reports(
&snapshot,
&IncidentStateFile::default(),
&evidence,
ReportRuntimeInputs {
incident_state: &IncidentStateFile::default(),
incident_reviews: &IncidentReviewFile::default(),
evidence: &evidence,
},
&missing_policy,
&missing_ueba_policy,
&baseline_path,
@@ -7549,6 +7819,10 @@ mod tests {
.iter()
.any(|item| item.as_str().unwrap().contains("collector_source"))
);
assert_eq!(
report["risk_incident_candidates"][0]["incident_review"]["status"],
"NEW"
);
assert!(
report["executive_points"]
.as_array()
@@ -7600,6 +7874,12 @@ mod tests {
.unwrap()
.contains("## Кандидаты в инциденты")
);
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("## Проверка кандидатов в инциденты")
);
assert!(report["markdown"].as_str().unwrap().contains("причины:"));
assert!(
report["markdown"]
@@ -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);
+44
View File
@@ -68,6 +68,40 @@ Business Risk не является автоматическим обвинен
- `first_seen_utc` - первое наблюдение;
- `last_seen_utc` - последнее наблюдение;
- `recommendation` - рекомендуемое действие.
- `incident_review` - статус проверки кандидата.
`incident_review`:
- `candidate_id` - идентификатор кандидата;
- `status` - `NEW`, `IN_REVIEW`, `CONFIRMED`, `FALSE_POSITIVE` или `POSTPONED`;
- `reviewer` - проверяющий, если указан;
- `comment` - комментарий проверки;
- `updated_at` - время последнего изменения статуса.
Для изменения review-статуса используется:
```http
POST /api/incident-review
```
Пример тела:
```json
{
"candidate_id": "risk-candidate-example",
"status": "IN_REVIEW",
"reviewer": "operator",
"comment": "Назначена ручная проверка"
}
```
Хранение пока файловое:
```text
<state_dir>/data/incident_reviews.json
```
Если файла нет, все кандидаты отображаются как `NEW`.
## Логика риска
@@ -114,6 +148,13 @@ Business Risk не является автоматическим обвинен
проверки. Это не автоматическое создание инцидента и не подтвержденное
нарушение.
В карточке можно сменить review-статус кандидата:
- `В проверку` -> `IN_REVIEW`;
- `Подтвердить` -> `CONFIRMED`;
- `Ложный` -> `FALSE_POSITIVE`;
- `Отложить` -> `POSTPONED`.
## Markdown
Оперативный отчет содержит раздел:
@@ -122,6 +163,7 @@ Business Risk не является автоматическим обвинен
## Риски подразделений
## Динамика бизнес-рисков
## Кандидаты в инциденты
## Проверка кандидатов в инциденты
```
Раздел можно использовать в PDF/Markdown-отчете для руководителя.
@@ -159,3 +201,5 @@ Executive summary показывает до 3 наиболее рискован
доказательной базы, а не из-за фактической работы сотрудников.
- `risk_incident_candidates` - только очередь `review required`; реальные
инциденты создаются отдельно ответственным сотрудником.
- Смена `incident_review.status` не создает запись в обычной очереди
`incidents` и не подтверждает нарушение автоматически.