feat(portal): add risk incident candidates
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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`; реальные
|
||||
инциденты создаются отдельно ответственным сотрудником.
|
||||
|
||||
@@ -132,6 +132,7 @@ async function main() {
|
||||
"Покрытие агентов",
|
||||
"Риски подразделений",
|
||||
"Динамика рисков",
|
||||
"Кандидаты в инциденты",
|
||||
"ТОП-5 лучших подразделений",
|
||||
"ТОП-5 проблемных подразделений",
|
||||
"Требует внимания",
|
||||
|
||||
Reference in New Issue
Block a user