feat(portal): connect risk narrative layers
This commit is contained in:
@@ -345,6 +345,10 @@ struct RiskHeatmapItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
critical_candidates: Option<usize>,
|
||||
heat_level: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
links: Vec<RiskNarrativeLink>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -362,6 +366,15 @@ struct SecurityCorrelationItem {
|
||||
open_cases: Option<usize>,
|
||||
correlation_score: u8,
|
||||
correlation_reason: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
explanation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct RiskNarrativeLink {
|
||||
target: String,
|
||||
label: String,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -507,6 +520,8 @@ struct ExecutiveDashboardSummary {
|
||||
main_risk: String,
|
||||
main_improvement: String,
|
||||
main_data_gap: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
main_risk_cause: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -1022,6 +1037,17 @@ struct ReportRuntimeInputs<'a> {
|
||||
evidence: &'a DlpEvidenceResponse,
|
||||
}
|
||||
|
||||
struct ExecutiveDashboardInputs<'a> {
|
||||
agent_quality_explain: &'a AgentQualityExplain,
|
||||
business_risk: &'a [BusinessRiskItem],
|
||||
business_risk_history_summary: &'a BusinessRiskHistorySummary,
|
||||
risk_heatmap: &'a [RiskHeatmapItem],
|
||||
security_correlation: &'a [SecurityCorrelationItem],
|
||||
candidates: &'a [RiskIncidentCandidate],
|
||||
cases: &'a CaseFile,
|
||||
evidence: &'a DlpEvidenceResponse,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
@@ -2320,12 +2346,16 @@ fn build_reports(
|
||||
let security_correlation = build_security_correlation(&risk_heatmap);
|
||||
let executive_dashboard = build_executive_dashboard(
|
||||
snapshot,
|
||||
&agent_quality_explain,
|
||||
&business_risk,
|
||||
&business_risk_history_summary,
|
||||
&risk_incident_candidates,
|
||||
inputs.cases,
|
||||
inputs.evidence,
|
||||
ExecutiveDashboardInputs {
|
||||
agent_quality_explain: &agent_quality_explain,
|
||||
business_risk: &business_risk,
|
||||
business_risk_history_summary: &business_risk_history_summary,
|
||||
risk_heatmap: &risk_heatmap,
|
||||
security_correlation: &security_correlation,
|
||||
candidates: &risk_incident_candidates,
|
||||
cases: inputs.cases,
|
||||
evidence: inputs.evidence,
|
||||
},
|
||||
);
|
||||
let trend = workforce_trend_json(snapshot);
|
||||
let insight_items = workforce_insight_items(snapshot);
|
||||
@@ -2423,23 +2453,8 @@ fn build_reports(
|
||||
.take(3)
|
||||
{
|
||||
executive_points.push(format!(
|
||||
"Проблемная зона {}: {} — Trust {}, активность {}, покрытие {}, дела {}",
|
||||
item.department,
|
||||
item.heat_level,
|
||||
optional_score_text(item.trust_kpi_score),
|
||||
optional_score_text(item.activity_score),
|
||||
optional_score_text(item.agent_coverage_pct),
|
||||
item.open_cases.unwrap_or(0)
|
||||
));
|
||||
}
|
||||
for item in security_correlation
|
||||
.iter()
|
||||
.filter(|item| item.correlation_score > 0)
|
||||
.take(3)
|
||||
{
|
||||
executive_points.push(format!(
|
||||
"Корреляция Workforce/Security {}: {}/100 — {}",
|
||||
item.department, item.correlation_score, item.correlation_reason
|
||||
"Связанная картина риска: {}",
|
||||
linked_risk_statement(item)
|
||||
));
|
||||
}
|
||||
executive_points.push(format!(
|
||||
@@ -3044,6 +3059,22 @@ fn build_risk_heatmap(
|
||||
open_cases,
|
||||
critical_candidates,
|
||||
);
|
||||
let links = risk_narrative_links(
|
||||
trust_kpi_score,
|
||||
activity_score,
|
||||
agent_coverage_pct,
|
||||
business_risk_level.as_deref(),
|
||||
open_cases,
|
||||
critical_candidates,
|
||||
);
|
||||
let summary = risk_heatmap_summary(
|
||||
trust_kpi_score,
|
||||
activity_score,
|
||||
agent_coverage_pct,
|
||||
business_risk_level.as_deref(),
|
||||
open_cases,
|
||||
critical_candidates,
|
||||
);
|
||||
RiskHeatmapItem {
|
||||
department,
|
||||
trust_kpi_score,
|
||||
@@ -3053,6 +3084,8 @@ fn build_risk_heatmap(
|
||||
open_cases: Some(open_cases),
|
||||
critical_candidates: Some(critical_candidates),
|
||||
heat_level,
|
||||
links,
|
||||
summary: Some(summary),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -3222,6 +3255,125 @@ fn heatmap_rank(level: &str) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
fn risk_narrative_links(
|
||||
trust_kpi_score: Option<u8>,
|
||||
activity_score: Option<u8>,
|
||||
agent_coverage_pct: Option<u8>,
|
||||
business_risk_level: Option<&str>,
|
||||
open_cases: usize,
|
||||
critical_candidates: usize,
|
||||
) -> Vec<RiskNarrativeLink> {
|
||||
vec![
|
||||
RiskNarrativeLink {
|
||||
target: "trust_kpi".to_string(),
|
||||
label: "Trust KPI".to_string(),
|
||||
summary: format!(
|
||||
"Trust KPI {}, активность {}",
|
||||
optional_score_text(trust_kpi_score),
|
||||
optional_score_text(activity_score)
|
||||
),
|
||||
},
|
||||
RiskNarrativeLink {
|
||||
target: "business_risk".to_string(),
|
||||
label: "Business Risk".to_string(),
|
||||
summary: format!("уровень {}", business_risk_level.unwrap_or("UNKNOWN")),
|
||||
},
|
||||
RiskNarrativeLink {
|
||||
target: "incident_candidates".to_string(),
|
||||
label: "Кандидаты".to_string(),
|
||||
summary: format!("кандидатов высокого риска: {critical_candidates}"),
|
||||
},
|
||||
RiskNarrativeLink {
|
||||
target: "cases".to_string(),
|
||||
label: "Дела".to_string(),
|
||||
summary: format!("открытых дел: {open_cases}"),
|
||||
},
|
||||
RiskNarrativeLink {
|
||||
target: "agent_coverage".to_string(),
|
||||
label: "Покрытие агентов".to_string(),
|
||||
summary: format!("покрытие {}", optional_score_text(agent_coverage_pct)),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn risk_heatmap_summary(
|
||||
trust_kpi_score: Option<u8>,
|
||||
activity_score: Option<u8>,
|
||||
agent_coverage_pct: Option<u8>,
|
||||
business_risk_level: Option<&str>,
|
||||
open_cases: usize,
|
||||
critical_candidates: usize,
|
||||
) -> String {
|
||||
format!(
|
||||
"Trust {} → Coverage {} → Business Risk {} → Candidates {} → Cases {} → {}",
|
||||
optional_score_text(trust_kpi_score),
|
||||
optional_score_text(agent_coverage_pct),
|
||||
business_risk_level.unwrap_or("UNKNOWN"),
|
||||
critical_candidates,
|
||||
open_cases,
|
||||
risk_narrative_conclusion(
|
||||
trust_kpi_score,
|
||||
activity_score,
|
||||
agent_coverage_pct,
|
||||
business_risk_level,
|
||||
open_cases,
|
||||
critical_candidates,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn risk_narrative_conclusion(
|
||||
trust_kpi_score: Option<u8>,
|
||||
activity_score: Option<u8>,
|
||||
agent_coverage_pct: Option<u8>,
|
||||
business_risk_level: Option<&str>,
|
||||
open_cases: usize,
|
||||
critical_candidates: usize,
|
||||
) -> String {
|
||||
let mut reasons = Vec::new();
|
||||
if trust_kpi_score.is_some_and(|value| value < 75) {
|
||||
reasons.push("слабого Trust KPI");
|
||||
}
|
||||
if activity_score.is_some_and(|value| value < 60) {
|
||||
reasons.push("падения активности");
|
||||
}
|
||||
if agent_coverage_pct.is_some_and(|value| value < 90) || agent_coverage_pct.is_none() {
|
||||
reasons.push("слабого покрытия агентов");
|
||||
}
|
||||
if matches!(
|
||||
business_risk_level.unwrap_or("UNKNOWN"),
|
||||
"HIGH" | "CRITICAL" | "MEDIUM"
|
||||
) {
|
||||
reasons.push("повышенного Business Risk");
|
||||
}
|
||||
if critical_candidates > 0 {
|
||||
reasons.push("критичных кандидатов");
|
||||
}
|
||||
if open_cases > 0 {
|
||||
reasons.push("открытых расследований");
|
||||
}
|
||||
if reasons.is_empty() {
|
||||
"связанный риск не выражен".to_string()
|
||||
} else {
|
||||
format!("риск связан из-за {}", reasons.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn linked_risk_statement(item: &RiskHeatmapItem) -> String {
|
||||
format!(
|
||||
"В подразделении {} {}.",
|
||||
item.department,
|
||||
risk_narrative_conclusion(
|
||||
item.trust_kpi_score,
|
||||
item.activity_score,
|
||||
item.agent_coverage_pct,
|
||||
item.business_risk_level.as_deref(),
|
||||
item.open_cases.unwrap_or(0),
|
||||
item.critical_candidates.unwrap_or(0),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_security_correlation(heatmap: &[RiskHeatmapItem]) -> Vec<SecurityCorrelationItem> {
|
||||
let mut items = heatmap
|
||||
.iter()
|
||||
@@ -3305,22 +3457,31 @@ fn security_correlation_item(item: &RiskHeatmapItem) -> SecurityCorrelationItem
|
||||
open_cases: item.open_cases,
|
||||
correlation_score: score.min(100) as u8,
|
||||
correlation_reason: reasons.join("; "),
|
||||
explanation: Some(security_correlation_explanation(item, &reasons)),
|
||||
}
|
||||
}
|
||||
|
||||
fn security_correlation_explanation(item: &RiskHeatmapItem, reasons: &[String]) -> String {
|
||||
format!(
|
||||
"Связаны слои: Trust KPI {}, активность {}, Business Risk {}, кандидаты {}, дела {}. Причина: {}. Для руководителя это означает, что управленческую просадку нужно проверять вместе с качеством данных и очередью ИБ-проверок.",
|
||||
optional_score_text(item.trust_kpi_score),
|
||||
optional_score_text(item.activity_score),
|
||||
item.business_risk_level.as_deref().unwrap_or("UNKNOWN"),
|
||||
item.critical_candidates.unwrap_or(0),
|
||||
item.open_cases.unwrap_or(0),
|
||||
reasons.join("; ")
|
||||
)
|
||||
}
|
||||
|
||||
fn build_executive_dashboard(
|
||||
snapshot: &Snapshot,
|
||||
agent_quality_explain: &AgentQualityExplain,
|
||||
business_risk: &[BusinessRiskItem],
|
||||
business_risk_history_summary: &BusinessRiskHistorySummary,
|
||||
candidates: &[RiskIncidentCandidate],
|
||||
cases: &CaseFile,
|
||||
evidence: &DlpEvidenceResponse,
|
||||
inputs: ExecutiveDashboardInputs<'_>,
|
||||
) -> ExecutiveDashboard {
|
||||
let trust_kpi_score = executive_trust_kpi_score(snapshot, agent_quality_explain);
|
||||
let trust_kpi_score = executive_trust_kpi_score(snapshot, inputs.agent_quality_explain);
|
||||
let agent_coverage_pct = (snapshot.agent_coverage_sla.expected_nodes > 0)
|
||||
.then_some(snapshot.agent_coverage_sla.coverage_pct);
|
||||
let high_risk_departments = business_risk
|
||||
let high_risk_departments = inputs
|
||||
.business_risk
|
||||
.iter()
|
||||
.filter(|item| business_risk_is_high(&item.risk_level))
|
||||
.take(10)
|
||||
@@ -3332,7 +3493,8 @@ fn build_executive_dashboard(
|
||||
reasons: item.reasons.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let critical_candidates = candidates
|
||||
let critical_candidates = inputs
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
@@ -3355,13 +3517,14 @@ fn build_executive_dashboard(
|
||||
.unwrap_or_else(|| "требуется проверка".to_string()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let open_cases = cases
|
||||
let open_cases = inputs
|
||||
.cases
|
||||
.cases
|
||||
.values()
|
||||
.filter(|item| matches!(item.status.as_str(), "OPEN" | "IN_PROGRESS"))
|
||||
.count();
|
||||
let resolved_cases_30d = resolved_cases_30d(cases);
|
||||
let forensics_readiness = forensics_readiness(snapshot, candidates, evidence);
|
||||
let resolved_cases_30d = resolved_cases_30d(inputs.cases);
|
||||
let forensics_readiness = forensics_readiness(snapshot, inputs.candidates, inputs.evidence);
|
||||
let summary = ExecutiveDashboardSummary {
|
||||
main_risk: executive_main_risk(
|
||||
&high_risk_departments,
|
||||
@@ -3369,11 +3532,16 @@ fn build_executive_dashboard(
|
||||
&snapshot.agent_coverage_sla,
|
||||
),
|
||||
main_improvement: executive_main_improvement(
|
||||
business_risk_history_summary,
|
||||
inputs.business_risk_history_summary,
|
||||
resolved_cases_30d,
|
||||
&critical_candidates,
|
||||
),
|
||||
main_data_gap: executive_main_data_gap(snapshot, agent_quality_explain),
|
||||
main_data_gap: executive_main_data_gap(snapshot, inputs.agent_quality_explain),
|
||||
main_risk_cause: executive_main_risk_cause(
|
||||
inputs.risk_heatmap,
|
||||
inputs.security_correlation,
|
||||
&forensics_readiness,
|
||||
),
|
||||
};
|
||||
ExecutiveDashboard {
|
||||
trust_kpi_score,
|
||||
@@ -3505,6 +3673,32 @@ fn executive_main_data_gap(
|
||||
"критичных пробелов в данных не выявлено".to_string()
|
||||
}
|
||||
|
||||
fn executive_main_risk_cause(
|
||||
risk_heatmap: &[RiskHeatmapItem],
|
||||
security_correlation: &[SecurityCorrelationItem],
|
||||
forensics_readiness: &str,
|
||||
) -> Option<String> {
|
||||
let top = risk_heatmap
|
||||
.iter()
|
||||
.find(|item| !matches!(item.heat_level.as_str(), "LOW" | "UNKNOWN"))?;
|
||||
let correlation = security_correlation
|
||||
.iter()
|
||||
.find(|item| item.department == top.department);
|
||||
let mut statement = linked_risk_statement(top);
|
||||
if let Some(item) = correlation.filter(|item| item.correlation_score > 0) {
|
||||
statement.push_str(&format!(
|
||||
" Корреляция Workforce/Security: {}/100, {}.",
|
||||
item.correlation_score, item.correlation_reason
|
||||
));
|
||||
}
|
||||
if !matches!(forensics_readiness, "READY") {
|
||||
statement.push_str(&format!(
|
||||
" Готовность Forensics: {forensics_readiness}, доказательная база требует проверки."
|
||||
));
|
||||
}
|
||||
Some(statement)
|
||||
}
|
||||
|
||||
fn build_investigation_pack(
|
||||
snapshot: &Snapshot,
|
||||
candidate_id: &str,
|
||||
@@ -5478,6 +5672,11 @@ fn render_report_markdown(
|
||||
);
|
||||
append_risk_heatmap_markdown(&mut text, context.risk_heatmap);
|
||||
append_security_correlation_markdown(&mut text, context.security_correlation);
|
||||
append_linked_risk_narrative_markdown(
|
||||
&mut text,
|
||||
context.risk_heatmap,
|
||||
context.security_correlation,
|
||||
);
|
||||
append_risk_incident_candidates_markdown(&mut text, context.risk_incident_candidates);
|
||||
append_incident_review_markdown(&mut text, context.risk_incident_candidates);
|
||||
append_incident_review_audit_markdown(
|
||||
@@ -5538,6 +5737,14 @@ fn append_executive_dashboard_markdown(text: &mut String, dashboard: &ExecutiveD
|
||||
.as_deref()
|
||||
.unwrap_or("UNKNOWN")
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"- Главная причина риска: {}\n",
|
||||
dashboard
|
||||
.summary
|
||||
.main_risk_cause
|
||||
.as_deref()
|
||||
.unwrap_or("связанный риск не выражен")
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"- Главный риск: {}\n",
|
||||
dashboard.summary.main_risk
|
||||
@@ -5812,6 +6019,46 @@ fn append_security_correlation_markdown(text: &mut String, items: &[SecurityCorr
|
||||
}
|
||||
}
|
||||
|
||||
fn append_linked_risk_narrative_markdown(
|
||||
text: &mut String,
|
||||
heatmap: &[RiskHeatmapItem],
|
||||
correlations: &[SecurityCorrelationItem],
|
||||
) {
|
||||
text.push_str("\n## Связанная картина риска\n\n");
|
||||
if heatmap.is_empty() {
|
||||
text.push_str(
|
||||
"- Связанная картина риска пока не сформирована: нет данных по подразделениям.\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let correlations_by_department = correlations
|
||||
.iter()
|
||||
.map(|item| (item.department.as_str(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for item in heatmap.iter().take(10) {
|
||||
let correlation = correlations_by_department
|
||||
.get(item.department.as_str())
|
||||
.copied();
|
||||
let correlation_text = correlation
|
||||
.map(|value| format!("{}/100", value.correlation_score))
|
||||
.unwrap_or_else(|| "0/100".to_string());
|
||||
text.push_str(&format!(
|
||||
"- {} → Trust {} → Coverage {} → Business Risk {} → Candidates {} → Cases {} → Correlation {} → {}\n",
|
||||
item.department,
|
||||
optional_score_text(item.trust_kpi_score),
|
||||
optional_score_text(item.agent_coverage_pct),
|
||||
item.business_risk_level.as_deref().unwrap_or("UNKNOWN"),
|
||||
item.critical_candidates.unwrap_or(0),
|
||||
item.open_cases.unwrap_or(0),
|
||||
correlation_text,
|
||||
item.summary.as_deref().unwrap_or("вывод не сформирован")
|
||||
));
|
||||
if let Some(value) = correlation.and_then(|value| value.explanation.as_deref()) {
|
||||
text.push_str(&format!(" - объяснение: {value}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_risk_incident_candidates_markdown(
|
||||
text: &mut String,
|
||||
candidates: &[RiskIncidentCandidate],
|
||||
@@ -9502,6 +9749,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("не настроен список")
|
||||
);
|
||||
assert!(
|
||||
report["executive_dashboard"]["summary"]["main_risk_cause"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("В подразделении")
|
||||
);
|
||||
assert_eq!(report["business_risk"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(report["business_risk"][0]["department"], "Бухгалтерия");
|
||||
assert_eq!(report["business_risk"][0]["risk_level"], "MEDIUM");
|
||||
@@ -9533,6 +9786,13 @@ mod tests {
|
||||
assert_eq!(report["risk_heatmap"][0]["heat_level"], "HIGH");
|
||||
assert_eq!(report["risk_heatmap"][0]["trust_kpi_score"], 50);
|
||||
assert_eq!(report["risk_heatmap"][0]["activity_score"], 50);
|
||||
assert!(report["risk_heatmap"][0]["links"].is_array());
|
||||
assert!(
|
||||
report["risk_heatmap"][0]["summary"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Trust")
|
||||
);
|
||||
assert!(report["security_correlation"].is_array());
|
||||
assert_eq!(
|
||||
report["security_correlation"][0]["department"],
|
||||
@@ -9545,6 +9805,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("покрытие агентов")
|
||||
);
|
||||
assert!(
|
||||
report["security_correlation"][0]["explanation"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Связаны слои")
|
||||
);
|
||||
assert_eq!(report["business_risk_history"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(
|
||||
report["business_risk_history"][0]["department"],
|
||||
@@ -9708,17 +9974,7 @@ mod tests {
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item.as_str().unwrap().contains("Проблемная зона"))
|
||||
);
|
||||
assert!(
|
||||
report["executive_points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Корреляция Workforce/Security"))
|
||||
.any(|item| item.as_str().unwrap().contains("Связанная картина риска"))
|
||||
);
|
||||
assert!(
|
||||
report["executive_points"]
|
||||
@@ -9745,6 +10001,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("## Корреляция Workforce ↔ Security")
|
||||
);
|
||||
assert!(
|
||||
report["markdown"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("## Связанная картина риска")
|
||||
);
|
||||
assert!(
|
||||
report["markdown"]
|
||||
.as_str()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const state = { tab: "operator", period: "today", links: null, readiness: null, reports: null };
|
||||
const state = { tab: "operator", period: "today", links: null, readiness: null, reports: null, pendingScrollSelector: null };
|
||||
|
||||
function apiBase() {
|
||||
const path = window.location.pathname;
|
||||
@@ -337,6 +337,7 @@ function renderExecutiveDashboard(report) {
|
||||
<div><span class="muted">Закрыто за 30 дней</span><strong>${ui(dashboard.resolved_cases_30d ?? 0)}</strong></div>
|
||||
</div>
|
||||
<div class="list compact-list executive-summary-list">
|
||||
<div class="row compact-row"><strong>Главная причина риска</strong><span class="muted">${ui(summary.main_risk_cause || "связанный риск не выражен")}</span><span></span></div>
|
||||
<div class="row compact-row"><strong>Главный риск</strong><span class="muted">${ui(summary.main_risk || "нет данных")}</span><span></span></div>
|
||||
<div class="row compact-row"><strong>Главное улучшение</strong><span class="muted">${ui(summary.main_improvement || "нет данных")}</span><span></span></div>
|
||||
<div class="row compact-row"><strong>Пробел в данных</strong><span class="muted">${ui(summary.main_data_gap || "нет данных")}</span><span></span></div>
|
||||
@@ -1360,7 +1361,7 @@ function renderIncidents(data) {
|
||||
function renderCases(cases) {
|
||||
const rows = Array.isArray(cases) ? cases.slice(0, 20) : [];
|
||||
return `
|
||||
<section class="card cases-card">
|
||||
<section class="card cases-card" id="cases-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>Дела</h3>
|
||||
@@ -1533,7 +1534,7 @@ function renderAgentQuality(quality, explain) {
|
||||
const warn = ["warning", "fallback", "degraded", "error"].includes(String(status).toLowerCase());
|
||||
const accepted = Boolean(e.kpi_accepted);
|
||||
return `
|
||||
<section class="card agent-quality-card">
|
||||
<section class="card agent-quality-card" id="trust-kpi-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>Достоверность данных агента</h3>
|
||||
@@ -1665,7 +1666,7 @@ function renderAgentCoverageSla(sla) {
|
||||
const rows = Array.isArray(s.problem_nodes) ? s.problem_nodes.slice(0, 10) : [];
|
||||
const status = s.sla_status || "UNKNOWN";
|
||||
return `
|
||||
<section class="card agent-coverage-card">
|
||||
<section class="card agent-coverage-card" id="agent-coverage-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>Покрытие агентов</h3>
|
||||
@@ -1728,7 +1729,7 @@ function renderBusinessRisk(items) {
|
||||
const rows = Array.isArray(items) ? items.slice(0, 10) : [];
|
||||
const worst = rows[0]?.risk_level || "UNKNOWN";
|
||||
return `
|
||||
<section class="card business-risk-card">
|
||||
<section class="card business-risk-card" id="business-risk-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>Риски подразделений</h3>
|
||||
@@ -1791,6 +1792,7 @@ function renderRiskHeatmap(items) {
|
||||
<th>Покрытие</th>
|
||||
<th>Риск</th>
|
||||
<th>Дела</th>
|
||||
<th>Связи</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1805,6 +1807,7 @@ function renderRiskHeatmap(items) {
|
||||
<span class="muted small">${ui(item.business_risk_level || "UNKNOWN")} · кандидаты ${ui(item.critical_candidates ?? 0)}</span>
|
||||
</td>
|
||||
<td>${ui(item.open_cases ?? 0)}</td>
|
||||
<td>${renderRiskLayerLinks(item.links)}</td>
|
||||
</tr>
|
||||
`).join("") : `
|
||||
<tr>
|
||||
@@ -1814,6 +1817,7 @@ function renderRiskHeatmap(items) {
|
||||
<td>UNKNOWN</td>
|
||||
<td><span class="badge status-unknown">UNKNOWN</span></td>
|
||||
<td>0</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
`}
|
||||
</tbody>
|
||||
@@ -1823,6 +1827,24 @@ function renderRiskHeatmap(items) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRiskLayerLinks(links) {
|
||||
const items = Array.isArray(links) ? links : [];
|
||||
if (!items.length) return "-";
|
||||
return `<div class="button-row compact-actions">${items.map(link => {
|
||||
const target = riskLayerTarget(link.target);
|
||||
return `<button class="small-button" data-risk-layer-tab="${ui(target.tab)}" data-risk-layer-selector="${ui(target.selector)}" title="${ui(link.summary || link.label || "")}">${ui(link.label || link.target || "слой")}</button>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function riskLayerTarget(target) {
|
||||
const value = String(target || "");
|
||||
if (value === "business_risk") return { tab: "operator", selector: "#business-risk-section" };
|
||||
if (value === "incident_candidates") return { tab: "operator", selector: "#risk-candidates-section" };
|
||||
if (value === "cases") return { tab: "incidents", selector: "#cases-section" };
|
||||
if (value === "agent_coverage") return { tab: "operator", selector: "#agent-coverage-section" };
|
||||
return { tab: "operator", selector: "#trust-kpi-section" };
|
||||
}
|
||||
|
||||
function riskPercentText(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? `${Math.round(number)}%` : "UNKNOWN";
|
||||
@@ -1863,7 +1885,7 @@ function renderSecurityCorrelation(items) {
|
||||
<td><span class="badge ${statusClass(item.business_risk_level)}">${ui(item.business_risk_level || "UNKNOWN")}</span></td>
|
||||
<td>кандидаты ${ui(item.critical_candidates ?? 0)} · дела ${ui(item.open_cases ?? 0)}</td>
|
||||
<td><strong>${ui(Number(item.correlation_score || 0))}/100</strong></td>
|
||||
<td>${ui(item.correlation_reason || "связь не выражена")}</td>
|
||||
<td>${ui(item.explanation || item.correlation_reason || "связь не выражена")}</td>
|
||||
</tr>
|
||||
`).join("") : `
|
||||
<tr>
|
||||
@@ -1957,7 +1979,7 @@ 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">
|
||||
<section class="card risk-candidates-card" id="risk-candidates-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>Кандидаты в инциденты</h3>
|
||||
@@ -2254,6 +2276,7 @@ async function refresh() {
|
||||
content.innerHTML = renderSettings(data);
|
||||
updateFilters(data);
|
||||
}
|
||||
consumePendingScroll();
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
@@ -2270,6 +2293,16 @@ function applySecurityMode(tab) {
|
||||
document.body.classList.toggle("security-mode", tab === "owner" || tab === "incidents" || tab === "perimeter");
|
||||
}
|
||||
|
||||
function consumePendingScroll() {
|
||||
const selector = state.pendingScrollSelector;
|
||||
if (!selector) return;
|
||||
state.pendingScrollSelector = null;
|
||||
window.setTimeout(() => {
|
||||
const target = document.querySelector(selector);
|
||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
document.getElementById("content").innerHTML = `<pre>${escapeHtml(error.stack || error.message || error)}</pre>`;
|
||||
}
|
||||
@@ -2297,6 +2330,19 @@ document.addEventListener("click", event => {
|
||||
setTab("incidents");
|
||||
});
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
const button = event.target.closest("[data-risk-layer-tab]");
|
||||
if (!button) return;
|
||||
const selector = button.dataset.riskLayerSelector || "";
|
||||
state.pendingScrollSelector = selector;
|
||||
const tab = button.dataset.riskLayerTab || state.tab;
|
||||
if (tab === state.tab) {
|
||||
consumePendingScroll();
|
||||
} else {
|
||||
setTab(tab);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
const button = event.target.closest("[data-incident-action]");
|
||||
if (!button) return;
|
||||
|
||||
@@ -62,6 +62,9 @@ Business Risk не является автоматическим обвинен
|
||||
- `summary.main_risk` - главный риск текущего среза;
|
||||
- `summary.main_improvement` - главное подтвержденное улучшение;
|
||||
- `summary.main_data_gap` - главный пробел в данных.
|
||||
- `summary.main_risk_cause` - optional связанная причина риска:
|
||||
Trust KPI, coverage, Business Risk, candidates, cases и готовность
|
||||
Forensics.
|
||||
|
||||
`risk_heatmap`:
|
||||
|
||||
@@ -75,6 +78,10 @@ Business Risk не является автоматическим обвинен
|
||||
- `critical_candidates` - кандидаты `HIGH`/`CRITICAL`;
|
||||
- `heat_level` - итоговая зона карты: `LOW`, `MEDIUM`, `HIGH`,
|
||||
`CRITICAL` или `UNKNOWN`.
|
||||
- `links` - optional read-only переходы к уже существующим слоям портала:
|
||||
Trust KPI, Business Risk, кандидаты, дела и покрытие агентов.
|
||||
- `summary` - optional связанная строка
|
||||
`Trust → Coverage → Business Risk → Candidates → Cases → вывод`.
|
||||
|
||||
`security_correlation`:
|
||||
|
||||
@@ -88,6 +95,8 @@ Business Risk не является автоматическим обвинен
|
||||
- `correlation_reason` - человеко-понятное объяснение связи, например
|
||||
`низкий Trust KPI + высокий риск` или
|
||||
`снижение активности + рост кандидатов`.
|
||||
- `explanation` - optional управленческое объяснение: какие слои связаны,
|
||||
почему корреляция высокая и что это значит для руководителя.
|
||||
|
||||
Элемент `business_risk_history`:
|
||||
|
||||
@@ -333,6 +342,7 @@ POST /api/cases/{case_id}/status
|
||||
## Сводка руководителя
|
||||
## Карта рисков подразделений
|
||||
## Корреляция Workforce ↔ Security
|
||||
## Связанная картина риска
|
||||
## Риски подразделений
|
||||
## Динамика бизнес-рисков
|
||||
## Кандидаты в инциденты
|
||||
|
||||
@@ -126,6 +126,7 @@ async function main() {
|
||||
const requiredExecutive = [
|
||||
"Сотрудников в работе",
|
||||
"Средний индекс активности",
|
||||
"Главная причина риска",
|
||||
"Достоверность данных агента",
|
||||
"Стабильность агента за 7 дней",
|
||||
"Качество данных по рабочим местам",
|
||||
|
||||
Reference in New Issue
Block a user