feat(portal): add risk incident candidates

This commit is contained in:
igor04091968
2026-06-04 13:32:55 +03:00
parent 3c7e1de96c
commit 4c66e67661
4 changed files with 432 additions and 0 deletions
+337
View File
@@ -329,6 +329,29 @@ struct BusinessRiskHistorySummary {
new_high_risk: usize,
}
#[derive(Clone, Debug, Serialize)]
struct RiskIncidentCandidate {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
department: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
risk_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
evidence: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
first_seen_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_seen_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
recommendation: Option<String>,
}
#[derive(Debug, Serialize)]
struct HealthResponse {
ok: bool,
@@ -742,6 +765,7 @@ struct ReportMarkdownContext<'a> {
business_risk: &'a [BusinessRiskItem],
business_risk_history: &'a [BusinessRiskHistoryItem],
business_risk_history_summary: &'a BusinessRiskHistorySummary,
risk_incident_candidates: &'a [RiskIncidentCandidate],
}
fn main() {
@@ -1958,6 +1982,8 @@ 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 =
build_risk_incident_candidates(snapshot, &business_risk, &business_risk_history);
let trend = workforce_trend_json(snapshot);
let insight_items = workforce_insight_items(snapshot);
let workforce_policy_explain =
@@ -2040,6 +2066,14 @@ fn build_reports(
"Подразделение {department} сохраняет высокий риск несколько дней подряд."
));
}
for candidate in risk_incident_candidates.iter().take(3) {
executive_points.push(format!(
"Кандидат в инцидент {}: {} — {}",
candidate.id,
candidate.risk_level.as_deref().unwrap_or("UNKNOWN"),
candidate.reason.as_deref().unwrap_or("требуется проверка")
));
}
let recommendations = owner_recommendations(snapshot, &summary);
let workforce_summary = ReportWorkforceSummary {
departments_count: department_items.len(),
@@ -2060,6 +2094,7 @@ fn build_reports(
business_risk: &business_risk,
business_risk_history: &business_risk_history,
business_risk_history_summary: &business_risk_history_summary,
risk_incident_candidates: &risk_incident_candidates,
},
);
json!({
@@ -2150,6 +2185,7 @@ fn build_reports(
"business_risk": business_risk,
"business_risk_history": business_risk_history,
"business_risk_history_summary": business_risk_history_summary,
"risk_incident_candidates": risk_incident_candidates,
"workforce_policy": workforce_policy_explain,
"workforce": {
"department_comparison": department_items,
@@ -2532,6 +2568,225 @@ fn stable_high_risk_departments(history: &[BusinessRiskHistoryItem], limit: usiz
departments
}
fn build_risk_incident_candidates(
snapshot: &Snapshot,
risks: &[BusinessRiskItem],
history: &[BusinessRiskHistoryItem],
) -> Vec<RiskIncidentCandidate> {
let mut candidates = Vec::new();
let problem_nodes_by_host = snapshot
.agent_coverage_sla
.problem_nodes
.iter()
.map(|node| (node.hostname.clone(), node))
.collect::<BTreeMap<_, _>>();
candidates.extend(stable_high_risk_candidates(history));
candidates.extend(low_trust_risk_candidates(risks, snapshot));
candidates.extend(agent_quality_candidates(snapshot, &problem_nodes_by_host));
candidates.extend(agent_coverage_candidates(snapshot));
candidates.sort_by(|left, right| {
risk_incident_candidate_rank(right)
.cmp(&risk_incident_candidate_rank(left))
.then_with(|| left.id.cmp(&right.id))
});
let mut seen = BTreeSet::new();
candidates
.into_iter()
.filter(|item| seen.insert(item.id.clone()))
.collect()
}
fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec<RiskIncidentCandidate> {
let mut by_department = BTreeMap::<String, Vec<&BusinessRiskHistoryItem>>::new();
for item in history {
by_department
.entry(item.department.clone())
.or_default()
.push(item);
}
by_department
.into_iter()
.filter_map(|(department, mut items)| {
items.sort_by(|left, right| left.date.cmp(&right.date));
let streak = latest_high_risk_streak(&items);
if streak < 3 {
return None;
}
let streak_items = items.iter().rev().take(streak).collect::<Vec<_>>();
let first = streak_items.last()?.date.clone();
let last = streak_items.first()?.date.clone();
let latest = items.last()?;
Some(RiskIncidentCandidate {
id: risk_candidate_id("stable-high-risk", &department, latest.risk_level.as_str()),
department: Some(department),
owner: None,
hostname: None,
risk_level: Some(latest.risk_level.clone()),
reason: Some("подразделение 3+ дня HIGH/CRITICAL".to_string()),
evidence: vec![
format!("risk_streak_days={streak}"),
format!("latest_activity_score={}%", latest.activity_score),
format!("latest_reasons={}", risk_reasons_text(&latest.reasons)),
],
first_seen_utc: Some(date_to_utc_start(&first)),
last_seen_utc: Some(date_to_utc_end(&last)),
recommendation: Some(
"Открыть управленческую проверку причин устойчивого высокого риска."
.to_string(),
),
})
})
.collect()
}
fn low_trust_risk_candidates(
risks: &[BusinessRiskItem],
snapshot: &Snapshot,
) -> Vec<RiskIncidentCandidate> {
risks
.iter()
.filter(|item| item.trust_score < 50)
.map(|item| RiskIncidentCandidate {
id: risk_candidate_id("low-trust", &item.department, &item.risk_level),
department: Some(item.department.clone()),
owner: None,
hostname: None,
risk_level: Some(item.risk_level.clone()),
reason: Some("trust_score < 50".to_string()),
evidence: vec![
format!("trust_score={}%", item.trust_score),
format!("activity_score={}%", item.activity_score),
format!("reasons={}", risk_reasons_text(&item.reasons)),
],
first_seen_utc: Some(snapshot.generated_at_utc.clone()),
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
recommendation: Some(item.recommendation.clone()),
})
.collect()
}
fn agent_quality_candidates(
snapshot: &Snapshot,
problem_nodes_by_host: &BTreeMap<String, &AgentCoverageProblemNode>,
) -> Vec<RiskIncidentCandidate> {
let mut items = Vec::new();
for node in &snapshot.agent_quality_nodes {
let coverage = problem_nodes_by_host.get(&node.hostname).copied();
if !node.kpi_accepted {
items.push(RiskIncidentCandidate {
id: risk_candidate_id("kpi-not-accepted", &node.hostname, &node.status),
department: coverage.map(|item| item.department.clone()),
owner: coverage.map(|item| item.owner.clone()),
hostname: Some(node.hostname.clone()),
risk_level: Some(agent_quality_candidate_level(node)),
reason: Some("KPI не принят".to_string()),
evidence: vec![
format!("agent_status={}", node.status),
format!("collector_source={}", node.source),
format!("sessions_total={}", node.sessions_total),
format!("rdp_sessions={}", node.rdp_sessions),
],
first_seen_utc: Some(node.last_seen_utc.clone()),
last_seen_utc: Some(node.last_seen_utc.clone()),
recommendation: Some(node.recommendation.clone()),
});
}
if let Some(error) = &node.collector_error {
items.push(RiskIncidentCandidate {
id: risk_candidate_id("collector-error", &node.hostname, error),
department: coverage.map(|item| item.department.clone()),
owner: coverage.map(|item| item.owner.clone()),
hostname: Some(node.hostname.clone()),
risk_level: Some("HIGH".to_string()),
reason: Some("collector_error".to_string()),
evidence: vec![
format!("collector_error={error}"),
format!("collector_source={}", node.source),
format!("agent_status={}", node.status),
],
first_seen_utc: Some(node.last_seen_utc.clone()),
last_seen_utc: Some(node.last_seen_utc.clone()),
recommendation: Some(
"Проверить журнал агента и восстановить основной сбор.".to_string(),
),
});
}
}
items
}
fn agent_coverage_candidates(snapshot: &Snapshot) -> Vec<RiskIncidentCandidate> {
snapshot
.agent_coverage_sla
.problem_nodes
.iter()
.filter(|node| matches!(node.status.as_str(), "MISSING" | "STALE"))
.map(|node| RiskIncidentCandidate {
id: risk_candidate_id("coverage-node", &node.hostname, &node.status),
department: Some(node.department.clone()),
owner: Some(node.owner.clone()),
hostname: Some(node.hostname.clone()),
risk_level: Some(if node.status == "MISSING" {
"HIGH".to_string()
} else {
"MEDIUM".to_string()
}),
reason: Some(format!("{} node", node.status)),
evidence: vec![
format!("coverage_status={}", node.status),
format!("last_seen_utc={}", node.last_seen_utc),
format!("sla_status={}", snapshot.agent_coverage_sla.sla_status),
],
first_seen_utc: Some(node.last_seen_utc.clone()),
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
recommendation: Some(node.recommendation.clone()),
})
.collect()
}
fn agent_quality_candidate_level(node: &AgentQualityNodeItem) -> String {
match node.status.as_str() {
"DEGRADED" => "HIGH",
"WARNING" => "MEDIUM",
"UNKNOWN" => "MEDIUM",
_ if !node.kpi_accepted => "MEDIUM",
_ => "LOW",
}
.to_string()
}
fn risk_incident_candidate_rank(item: &RiskIncidentCandidate) -> u8 {
business_risk_rank(item.risk_level.as_deref().unwrap_or("UNKNOWN"))
}
fn risk_candidate_id(kind: &str, primary: &str, reason: &str) -> String {
incident_id("risk-candidate", kind, &format!("{primary}:{reason}"))
}
fn risk_reasons_text(reasons: &[String]) -> String {
if reasons.is_empty() {
"нет существенных причин".to_string()
} else {
reasons.join("; ")
}
}
fn date_to_utc_start(date: &str) -> String {
if date.contains('T') {
return date.to_string();
}
format!("{date}T00:00:00Z")
}
fn date_to_utc_end(date: &str) -> String {
if date.contains('T') {
return date.to_string();
}
format!("{date}T23:59:59Z")
}
fn latest_high_risk_streak(items: &[&BusinessRiskHistoryItem]) -> usize {
items
.iter()
@@ -4040,6 +4295,7 @@ fn render_report_markdown(
context.business_risk_history,
context.business_risk_history_summary,
);
append_risk_incident_candidates_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");
@@ -4261,6 +4517,43 @@ fn append_business_risk_history_markdown(
}
}
fn append_risk_incident_candidates_markdown(
text: &mut String,
candidates: &[RiskIncidentCandidate],
) {
text.push_str("\n## Кандидаты в инциденты\n\n");
if candidates.is_empty() {
text.push_str("- Кандидаты для ручной проверки не найдены.\n");
return;
}
text.push_str("- Важно: кандидаты не являются автоматически созданными инцидентами; требуется ручная проверка.\n");
for item in candidates.iter().take(10) {
let evidence = if item.evidence.is_empty() {
"нет evidence".to_string()
} else {
item.evidence.join("; ")
};
text.push_str(&format!(
"- {}: department={}, owner={}, host={}, level={}, reason={}, first_seen={}, last_seen={}\n",
item.id,
item.department.as_deref().unwrap_or("-"),
item.owner.as_deref().unwrap_or("-"),
item.hostname.as_deref().unwrap_or("-"),
item.risk_level.as_deref().unwrap_or("UNKNOWN"),
item.reason.as_deref().unwrap_or("требуется проверка"),
item.first_seen_utc.as_deref().unwrap_or("-"),
item.last_seen_utc.as_deref().unwrap_or("-")
));
text.push_str(&format!(" - evidence: {evidence}\n"));
text.push_str(&format!(
" - рекомендация: {}\n",
item.recommendation
.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!(
@@ -6723,6 +7016,17 @@ mod tests {
stable_high_risk_departments(&history, 3),
vec!["Продажи".to_string()]
);
let stable_candidates = stable_high_risk_candidates(&history);
assert_eq!(stable_candidates.len(), 1);
assert_eq!(stable_candidates[0].department.as_deref(), Some("Продажи"));
assert_eq!(stable_candidates[0].risk_level.as_deref(), Some("CRITICAL"));
assert!(
stable_candidates[0]
.reason
.as_deref()
.unwrap()
.contains("3+ дня")
);
}
#[test]
@@ -7225,6 +7529,26 @@ mod tests {
report["business_risk_history_summary"]["stable_high_risk"],
0
);
assert!(report["risk_incident_candidates"].is_array());
assert_eq!(
report["risk_incident_candidates"].as_array().unwrap().len(),
1
);
assert_eq!(
report["risk_incident_candidates"][0]["hostname"],
"HOST-EXAMPLE-DEGRADED"
);
assert_eq!(
report["risk_incident_candidates"][0]["reason"],
"KPI не принят"
);
assert!(
report["risk_incident_candidates"][0]["evidence"]
.as_array()
.unwrap()
.iter()
.any(|item| item.as_str().unwrap().contains("collector_source"))
);
assert!(
report["executive_points"]
.as_array()
@@ -7239,6 +7563,13 @@ mod tests {
.iter()
.any(|item| item.as_str().unwrap().contains("причина:"))
);
assert!(
report["executive_points"]
.as_array()
.unwrap()
.iter()
.any(|item| item.as_str().unwrap().contains("Кандидат в инцидент"))
);
assert!(
report["markdown"]
.as_str()
@@ -7263,6 +7594,12 @@ mod tests {
.unwrap()
.contains("## Динамика бизнес-рисков")
);
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("## Кандидаты в инциденты")
);
assert!(report["markdown"].as_str().unwrap().contains("причины:"));
assert!(
report["markdown"]
@@ -831,6 +831,7 @@ function renderOperator(data, report) {
${renderAgentCoverageSla(report?.agent_coverage_sla)}
${renderBusinessRisk(report?.business_risk)}
${renderBusinessRiskTimeline(report?.business_risk_history, report?.business_risk_history_summary)}
${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
${renderOverviewAnalytics(report)}
<section class="dashboard-band">
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
@@ -1714,6 +1715,71 @@ function renderBusinessRiskTimeline(history, summary) {
`;
}
function renderRiskIncidentCandidates(items) {
const rows = Array.isArray(items) ? items.slice(0, 10) : [];
const worst = rows[0]?.risk_level || "UNKNOWN";
return `
<section class="card risk-candidates-card">
<div class="section-head">
<div>
<h3>Кандидаты в инциденты</h3>
<p class="muted">Read-only очередь ручной проверки. Реальные инциденты автоматически не создаются.</p>
</div>
<span class="badge ${statusClass(worst)}">${ui(worst)}</span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Подразделение</th>
<th>Узел</th>
<th>Риск</th>
<th>Причина</th>
<th>Рекомендация</th>
</tr>
</thead>
<tbody>
${rows.length ? rows.map(item => `
<tr>
<td><code>${ui(item.id || "-")}</code></td>
<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>${ui(candidateReasonText(item))}</td>
<td>${ui(item.recommendation || "Назначить ответственную ручную проверку.")}</td>
</tr>
`).join("") : `
<tr>
<td>-</td>
<td>Нет кандидатов</td>
<td>-</td>
<td><span class="badge status-ok">OK</span></td>
<td>очередь проверки пуста</td>
<td>Действий не требуется.</td>
</tr>
`}
</tbody>
</table>
</div>
${rows.length ? `<p class="muted small">Показаны кандидаты для проверки, а не автоматически подтвержденные инциденты.</p>` : ""}
</section>
`;
}
function candidateReasonText(item) {
const evidence = Array.isArray(item?.evidence) && item.evidence.length
? ` Evidence: ${item.evidence.join("; ")}`
: "";
return `${item?.reason || "требуется проверка"}.${evidence}`;
}
function candidateSeenText(item) {
const first = item?.first_seen_utc || "-";
const last = item?.last_seen_utc || "-";
return `первое: ${first}; последнее: ${last}`;
}
function businessTrendText(value) {
const trend = String(value || "UNKNOWN").toUpperCase();
if (trend === "FALLING") return "падает";
@@ -1762,6 +1828,7 @@ function renderReports(data) {
${renderAgentCoverageSla(data.agent_coverage_sla)}
${renderBusinessRisk(data.business_risk)}
${renderBusinessRiskTimeline(data.business_risk_history, data.business_risk_history_summary)}
${renderRiskIncidentCandidates(data.risk_incident_candidates)}
${renderUebaRisk(data.ueba_risk)}
${renderWorkforceIndexExplanation(data.workforce_policy)}
<h3 class="section-title">Срезы отчета</h3>
+27
View File
@@ -38,6 +38,7 @@ Business Risk не является автоматическим обвинен
- `business_risk_history` - timeline риска по подразделениям;
- `business_risk_history_summary` - сводка динамики.
- `risk_incident_candidates` - read-only кандидаты для ручной проверки.
Элемент `business_risk_history`:
@@ -55,6 +56,19 @@ Business Risk не является автоматическим обвинен
- `stable_high_risk` - сколько подразделений 3+ дня остаются в `HIGH`/`CRITICAL`;
- `new_high_risk` - сколько подразделений впервые вошли в `HIGH`/`CRITICAL`.
Элемент `risk_incident_candidates`:
- `id` - стабильный идентификатор кандидата;
- `department` - подразделение, если известно;
- `owner` - ответственный, если известен;
- `hostname` - рабочее место, если кандидат связан с узлом;
- `risk_level` - ориентировочный уровень риска;
- `reason` - причина постановки в очередь проверки;
- `evidence` - технические признаки, на которых основан кандидат;
- `first_seen_utc` - первое наблюдение;
- `last_seen_utc` - последнее наблюдение;
- `recommendation` - рекомендуемое действие.
## Логика риска
На риск влияют:
@@ -96,6 +110,10 @@ Business Risk не является автоматическим обвинен
- новый высокий риск;
- последние точки timeline с причинами.
Карточка `Кандидаты в инциденты` показывает TOP-10 записей для ручной
проверки. Это не автоматическое создание инцидента и не подтвержденное
нарушение.
## Markdown
Оперативный отчет содержит раздел:
@@ -103,6 +121,7 @@ Business Risk не является автоматическим обвинен
```text
## Риски подразделений
## Динамика бизнес-рисков
## Кандидаты в инциденты
```
Раздел можно использовать в PDF/Markdown-отчете для руководителя.
@@ -125,6 +144,12 @@ Executive summary показывает до 3 наиболее рискован
Подразделение Бухгалтерия сохраняет высокий риск несколько дней подряд.
```
Также выводятся до 3 кандидатов в инциденты:
```text
Кандидат в инцидент risk-candidate-...: HIGH — KPI не принят
```
## Ограничения
- Это proxy-модель управленческого риска, а не юридическое заключение.
@@ -132,3 +157,5 @@ Executive summary показывает до 3 наиболее рискован
telemetry и актуальные department rollups.
- Если данных по подразделению нет, риск может повышаться из-за недостаточной
доказательной базы, а не из-за фактической работы сотрудников.
- `risk_incident_candidates` - только очередь `review required`; реальные
инциденты создаются отдельно ответственным сотрудником.
+1
View File
@@ -132,6 +132,7 @@ async function main() {
"Покрытие агентов",
"Риски подразделений",
"Динамика рисков",
"Кандидаты в инциденты",
"ТОП-5 лучших подразделений",
"ТОП-5 проблемных подразделений",
"Требует внимания",