feat(portal): add executive dashboard
This commit is contained in:
@@ -446,6 +446,53 @@ struct CaseDetailsResponse {
|
|||||||
markdown: String,
|
markdown: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct ExecutiveRiskDepartment {
|
||||||
|
department: String,
|
||||||
|
risk_level: String,
|
||||||
|
trust_score: u8,
|
||||||
|
activity_score: u8,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
reasons: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct ExecutiveCandidateSummary {
|
||||||
|
id: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
department: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
hostname: Option<String>,
|
||||||
|
risk_level: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct ExecutiveDashboardSummary {
|
||||||
|
main_risk: String,
|
||||||
|
main_improvement: String,
|
||||||
|
main_data_gap: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct ExecutiveDashboard {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
trust_kpi_score: Option<u8>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
agent_coverage_pct: Option<u8>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
high_risk_departments: Option<Vec<ExecutiveRiskDepartment>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
critical_candidates: Option<Vec<ExecutiveCandidateSummary>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
open_cases: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
resolved_cases_30d: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
forensics_readiness: Option<String>,
|
||||||
|
summary: ExecutiveDashboardSummary,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct HealthResponse {
|
struct HealthResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
@@ -920,6 +967,7 @@ struct ReportWorkforceSummary {
|
|||||||
|
|
||||||
struct ReportMarkdownContext<'a> {
|
struct ReportMarkdownContext<'a> {
|
||||||
workforce: &'a ReportWorkforceSummary,
|
workforce: &'a ReportWorkforceSummary,
|
||||||
|
executive_dashboard: &'a ExecutiveDashboard,
|
||||||
workforce_policy: &'a Value,
|
workforce_policy: &'a Value,
|
||||||
ueba_risk: &'a Value,
|
ueba_risk: &'a Value,
|
||||||
business_risk: &'a [BusinessRiskItem],
|
business_risk: &'a [BusinessRiskItem],
|
||||||
@@ -933,6 +981,7 @@ struct ReportRuntimeInputs<'a> {
|
|||||||
incident_state: &'a IncidentStateFile,
|
incident_state: &'a IncidentStateFile,
|
||||||
incident_reviews: &'a IncidentReviewFile,
|
incident_reviews: &'a IncidentReviewFile,
|
||||||
incident_review_audit: &'a [IncidentReviewAuditEntry],
|
incident_review_audit: &'a [IncidentReviewAuditEntry],
|
||||||
|
cases: &'a CaseFile,
|
||||||
evidence: &'a DlpEvidenceResponse,
|
evidence: &'a DlpEvidenceResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,6 +1003,7 @@ fn run() -> Result<i32> {
|
|||||||
let incident_state = load_incident_state_best_effort(&args);
|
let incident_state = load_incident_state_best_effort(&args);
|
||||||
let incident_reviews = load_incident_review_best_effort(&args);
|
let incident_reviews = load_incident_review_best_effort(&args);
|
||||||
let incident_review_audit = load_incident_review_audit_best_effort(&args);
|
let incident_review_audit = load_incident_review_audit_best_effort(&args);
|
||||||
|
let cases = load_cases_best_effort(&args);
|
||||||
let evidence = build_dlp_evidence_response(&args);
|
let evidence = build_dlp_evidence_response(&args);
|
||||||
let ueba_baseline_path = ueba_baseline_state_path(&args);
|
let ueba_baseline_path = ueba_baseline_state_path(&args);
|
||||||
let smoke = json!({
|
let smoke = json!({
|
||||||
@@ -963,6 +1013,7 @@ fn run() -> Result<i32> {
|
|||||||
incident_state: &incident_state,
|
incident_state: &incident_state,
|
||||||
incident_reviews: &incident_reviews,
|
incident_reviews: &incident_reviews,
|
||||||
incident_review_audit: &incident_review_audit,
|
incident_review_audit: &incident_review_audit,
|
||||||
|
cases: &cases,
|
||||||
evidence: &evidence,
|
evidence: &evidence,
|
||||||
}, &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false),
|
}, &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false),
|
||||||
"incidents": build_incidents(&snapshot, &incident_state),
|
"incidents": build_incidents(&snapshot, &incident_state),
|
||||||
@@ -1080,6 +1131,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
let incident_state = load_incident_state_best_effort(args);
|
let incident_state = load_incident_state_best_effort(args);
|
||||||
let incident_reviews = load_incident_review_best_effort(args);
|
let incident_reviews = load_incident_review_best_effort(args);
|
||||||
let incident_review_audit = load_incident_review_audit_best_effort(args);
|
let incident_review_audit = load_incident_review_audit_best_effort(args);
|
||||||
|
let cases = load_cases_best_effort(args);
|
||||||
let evidence = build_dlp_evidence_response(args);
|
let evidence = build_dlp_evidence_response(args);
|
||||||
let ueba_baseline_path = ueba_baseline_state_path(args);
|
let ueba_baseline_path = ueba_baseline_state_path(args);
|
||||||
respond_json(
|
respond_json(
|
||||||
@@ -1090,6 +1142,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
incident_state: &incident_state,
|
incident_state: &incident_state,
|
||||||
incident_reviews: &incident_reviews,
|
incident_reviews: &incident_reviews,
|
||||||
incident_review_audit: &incident_review_audit,
|
incident_review_audit: &incident_review_audit,
|
||||||
|
cases: &cases,
|
||||||
evidence: &evidence,
|
evidence: &evidence,
|
||||||
},
|
},
|
||||||
&args.workforce_policy_path,
|
&args.workforce_policy_path,
|
||||||
@@ -2221,6 +2274,15 @@ fn build_reports(
|
|||||||
);
|
);
|
||||||
let incident_review_audit_summary =
|
let incident_review_audit_summary =
|
||||||
summarize_incident_review_audit(inputs.incident_review_audit);
|
summarize_incident_review_audit(inputs.incident_review_audit);
|
||||||
|
let executive_dashboard = build_executive_dashboard(
|
||||||
|
snapshot,
|
||||||
|
&agent_quality_explain,
|
||||||
|
&business_risk,
|
||||||
|
&business_risk_history_summary,
|
||||||
|
&risk_incident_candidates,
|
||||||
|
inputs.cases,
|
||||||
|
inputs.evidence,
|
||||||
|
);
|
||||||
let trend = workforce_trend_json(snapshot);
|
let trend = workforce_trend_json(snapshot);
|
||||||
let insight_items = workforce_insight_items(snapshot);
|
let insight_items = workforce_insight_items(snapshot);
|
||||||
let workforce_policy_explain =
|
let workforce_policy_explain =
|
||||||
@@ -2311,6 +2373,18 @@ fn build_reports(
|
|||||||
candidate.reason.as_deref().unwrap_or("требуется проверка")
|
candidate.reason.as_deref().unwrap_or("требуется проверка")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
executive_points.push(format!(
|
||||||
|
"Главный риск: {}",
|
||||||
|
executive_dashboard.summary.main_risk
|
||||||
|
));
|
||||||
|
executive_points.push(format!(
|
||||||
|
"Главное улучшение: {}",
|
||||||
|
executive_dashboard.summary.main_improvement
|
||||||
|
));
|
||||||
|
executive_points.push(format!(
|
||||||
|
"Главный пробел в данных: {}",
|
||||||
|
executive_dashboard.summary.main_data_gap
|
||||||
|
));
|
||||||
let recommendations = owner_recommendations(snapshot, &summary);
|
let recommendations = owner_recommendations(snapshot, &summary);
|
||||||
let workforce_summary = ReportWorkforceSummary {
|
let workforce_summary = ReportWorkforceSummary {
|
||||||
departments_count: department_items.len(),
|
departments_count: department_items.len(),
|
||||||
@@ -2326,6 +2400,7 @@ fn build_reports(
|
|||||||
&recommendations,
|
&recommendations,
|
||||||
ReportMarkdownContext {
|
ReportMarkdownContext {
|
||||||
workforce: &workforce_summary,
|
workforce: &workforce_summary,
|
||||||
|
executive_dashboard: &executive_dashboard,
|
||||||
workforce_policy: &workforce_policy_explain,
|
workforce_policy: &workforce_policy_explain,
|
||||||
ueba_risk: &ueba_risk,
|
ueba_risk: &ueba_risk,
|
||||||
business_risk: &business_risk,
|
business_risk: &business_risk,
|
||||||
@@ -2343,6 +2418,7 @@ fn build_reports(
|
|||||||
"operator_ok": summary.operator_ok,
|
"operator_ok": summary.operator_ok,
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
"executive_points": executive_points,
|
"executive_points": executive_points,
|
||||||
|
"executive_dashboard": executive_dashboard,
|
||||||
"kpis": [
|
"kpis": [
|
||||||
report_kpi("UEBA риск", format!("{}/100", ueba_risk.get("score").and_then(Value::as_u64).unwrap_or(0)), ueba_risk.get("status").and_then(Value::as_str).unwrap_or("UNKNOWN").to_string(), ueba_risk.get("summary").and_then(Value::as_str).unwrap_or("risk score")),
|
report_kpi("UEBA риск", format!("{}/100", ueba_risk.get("score").and_then(Value::as_u64).unwrap_or(0)), ueba_risk.get("status").and_then(Value::as_str).unwrap_or("UNKNOWN").to_string(), ueba_risk.get("summary").and_then(Value::as_str).unwrap_or("risk score")),
|
||||||
report_kpi("Качество данных агента", agent_quality.quality_status.clone(), agent_quality.quality_status.clone(), &format!("источник: {}", agent_quality.collector_source)),
|
report_kpi("Качество данных агента", agent_quality.quality_status.clone(), agent_quality.quality_status.clone(), &format!("источник: {}", agent_quality.collector_source)),
|
||||||
@@ -2851,6 +2927,203 @@ fn build_reviewed_risk_incident_candidates(
|
|||||||
(candidates, business_risk)
|
(candidates, business_risk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_executive_dashboard(
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
agent_quality_explain: &AgentQualityExplain,
|
||||||
|
business_risk: &[BusinessRiskItem],
|
||||||
|
business_risk_history_summary: &BusinessRiskHistorySummary,
|
||||||
|
candidates: &[RiskIncidentCandidate],
|
||||||
|
cases: &CaseFile,
|
||||||
|
evidence: &DlpEvidenceResponse,
|
||||||
|
) -> ExecutiveDashboard {
|
||||||
|
let trust_kpi_score = executive_trust_kpi_score(snapshot, 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
|
||||||
|
.iter()
|
||||||
|
.filter(|item| business_risk_is_high(&item.risk_level))
|
||||||
|
.take(10)
|
||||||
|
.map(|item| ExecutiveRiskDepartment {
|
||||||
|
department: item.department.clone(),
|
||||||
|
risk_level: item.risk_level.clone(),
|
||||||
|
trust_score: item.trust_score,
|
||||||
|
activity_score: item.activity_score,
|
||||||
|
reasons: item.reasons.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let critical_candidates = candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
matches!(
|
||||||
|
item.risk_level.as_deref().unwrap_or("UNKNOWN"),
|
||||||
|
"HIGH" | "CRITICAL"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.take(10)
|
||||||
|
.map(|item| ExecutiveCandidateSummary {
|
||||||
|
id: item.id.clone(),
|
||||||
|
department: item.department.clone(),
|
||||||
|
hostname: item.hostname.clone(),
|
||||||
|
risk_level: item
|
||||||
|
.risk_level
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "UNKNOWN".to_string()),
|
||||||
|
reason: item
|
||||||
|
.reason
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "требуется проверка".to_string()),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let open_cases = 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 summary = ExecutiveDashboardSummary {
|
||||||
|
main_risk: executive_main_risk(
|
||||||
|
&high_risk_departments,
|
||||||
|
&critical_candidates,
|
||||||
|
&snapshot.agent_coverage_sla,
|
||||||
|
),
|
||||||
|
main_improvement: executive_main_improvement(
|
||||||
|
business_risk_history_summary,
|
||||||
|
resolved_cases_30d,
|
||||||
|
&critical_candidates,
|
||||||
|
),
|
||||||
|
main_data_gap: executive_main_data_gap(snapshot, agent_quality_explain),
|
||||||
|
};
|
||||||
|
ExecutiveDashboard {
|
||||||
|
trust_kpi_score,
|
||||||
|
agent_coverage_pct,
|
||||||
|
high_risk_departments: Some(high_risk_departments),
|
||||||
|
critical_candidates: Some(critical_candidates),
|
||||||
|
open_cases: Some(open_cases),
|
||||||
|
resolved_cases_30d: Some(resolved_cases_30d),
|
||||||
|
forensics_readiness: Some(forensics_readiness),
|
||||||
|
summary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executive_trust_kpi_score(
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
agent_quality_explain: &AgentQualityExplain,
|
||||||
|
) -> Option<u8> {
|
||||||
|
if snapshot.agent_quality_nodes_summary.total_nodes > 0 {
|
||||||
|
Some(snapshot.agent_quality_nodes_summary.accepted_kpi_nodes_pct)
|
||||||
|
} else if agent_quality_explain.status == "UNKNOWN" {
|
||||||
|
None
|
||||||
|
} else if agent_quality_explain.kpi_accepted {
|
||||||
|
Some(100)
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_cases_30d(cases: &CaseFile) -> usize {
|
||||||
|
let since = Utc::now() - chrono::Duration::days(30);
|
||||||
|
cases
|
||||||
|
.cases
|
||||||
|
.values()
|
||||||
|
.filter(|item| item.status == "RESOLVED")
|
||||||
|
.filter(|item| {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(&item.updated_at_utc)
|
||||||
|
.map(|timestamp| timestamp.with_timezone(&Utc) >= since)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forensics_readiness(
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
candidates: &[RiskIncidentCandidate],
|
||||||
|
evidence: &DlpEvidenceResponse,
|
||||||
|
) -> String {
|
||||||
|
let has_audit_ready_candidates = candidates
|
||||||
|
.iter()
|
||||||
|
.any(|item| !item.incident_review_audit.is_empty() || item.incident_review.status != "NEW");
|
||||||
|
if evidence.items.iter().any(|item| item.screenshot_available) && has_audit_ready_candidates {
|
||||||
|
"READY".to_string()
|
||||||
|
} else if !evidence.items.is_empty() || !candidates.is_empty() {
|
||||||
|
"PARTIAL".to_string()
|
||||||
|
} else if matches!(
|
||||||
|
snapshot.agent_quality.quality_status.as_str(),
|
||||||
|
"ok" | "fallback" | "degraded"
|
||||||
|
) {
|
||||||
|
"OBSERVE".to_string()
|
||||||
|
} else {
|
||||||
|
"LIMITED".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executive_main_risk(
|
||||||
|
high_risk_departments: &[ExecutiveRiskDepartment],
|
||||||
|
critical_candidates: &[ExecutiveCandidateSummary],
|
||||||
|
sla: &AgentCoverageSla,
|
||||||
|
) -> String {
|
||||||
|
if sla.sla_status == "CRITICAL" {
|
||||||
|
return "покрытие агентов критически недостаточно, KPI нерепрезентативен".to_string();
|
||||||
|
}
|
||||||
|
if let Some(item) = high_risk_departments.first() {
|
||||||
|
let reason = item
|
||||||
|
.reasons
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "требуется управленческая проверка".to_string());
|
||||||
|
return format!(
|
||||||
|
"подразделение {}: {} — {}",
|
||||||
|
item.department, item.risk_level, reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(item) = critical_candidates.first() {
|
||||||
|
return format!(
|
||||||
|
"кандидат {}: {} — {}",
|
||||||
|
item.id, item.risk_level, item.reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"критичных управленческих рисков в текущем срезе нет".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executive_main_improvement(
|
||||||
|
history_summary: &BusinessRiskHistorySummary,
|
||||||
|
resolved_cases_30d: usize,
|
||||||
|
critical_candidates: &[ExecutiveCandidateSummary],
|
||||||
|
) -> String {
|
||||||
|
if resolved_cases_30d > 0 {
|
||||||
|
return format!("закрыто дел за 30 дней: {resolved_cases_30d}");
|
||||||
|
}
|
||||||
|
if history_summary.departments_improved > 0 {
|
||||||
|
return format!(
|
||||||
|
"улучшились подразделения: {}",
|
||||||
|
history_summary.departments_improved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if critical_candidates.is_empty() {
|
||||||
|
return "нет критичных кандидатов в инциденты".to_string();
|
||||||
|
}
|
||||||
|
"улучшение пока не подтверждено накопленной историей".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executive_main_data_gap(
|
||||||
|
snapshot: &Snapshot,
|
||||||
|
agent_quality_explain: &AgentQualityExplain,
|
||||||
|
) -> String {
|
||||||
|
if snapshot.agent_coverage_sla.expected_nodes == 0 {
|
||||||
|
return "не настроен список ожидаемых рабочих мест для SLA покрытия".to_string();
|
||||||
|
}
|
||||||
|
if snapshot.agent_coverage_sla.coverage_pct < 90 {
|
||||||
|
return format!(
|
||||||
|
"покрытие агентов {}%, часть рабочих мест не подтверждает KPI",
|
||||||
|
snapshot.agent_coverage_sla.coverage_pct
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !agent_quality_explain.kpi_accepted {
|
||||||
|
return agent_quality_explain.summary.clone();
|
||||||
|
}
|
||||||
|
"критичных пробелов в данных не выявлено".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn build_investigation_pack(
|
fn build_investigation_pack(
|
||||||
snapshot: &Snapshot,
|
snapshot: &Snapshot,
|
||||||
candidate_id: &str,
|
candidate_id: &str,
|
||||||
@@ -4797,6 +5070,7 @@ fn render_report_markdown(
|
|||||||
for item in recommendations {
|
for item in recommendations {
|
||||||
text.push_str(&format!("- {item}\n"));
|
text.push_str(&format!("- {item}\n"));
|
||||||
}
|
}
|
||||||
|
append_executive_dashboard_markdown(&mut text, context.executive_dashboard);
|
||||||
append_agent_quality_markdown(&mut text, &snapshot.agent_quality);
|
append_agent_quality_markdown(&mut text, &snapshot.agent_quality);
|
||||||
append_agent_quality_history_markdown(
|
append_agent_quality_history_markdown(
|
||||||
&mut text,
|
&mut text,
|
||||||
@@ -4828,6 +5102,67 @@ fn render_report_markdown(
|
|||||||
text
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_executive_dashboard_markdown(text: &mut String, dashboard: &ExecutiveDashboard) {
|
||||||
|
text.push_str("\n## Сводка руководителя\n\n");
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Trust KPI: {}\n",
|
||||||
|
dashboard
|
||||||
|
.trust_kpi_score
|
||||||
|
.map(|value| format!("{value}%"))
|
||||||
|
.unwrap_or_else(|| "нет данных".to_string())
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Покрытие агентов: {}\n",
|
||||||
|
dashboard
|
||||||
|
.agent_coverage_pct
|
||||||
|
.map(|value| format!("{value}%"))
|
||||||
|
.unwrap_or_else(|| "не настроено".to_string())
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Подразделения высокого риска: {}\n",
|
||||||
|
dashboard
|
||||||
|
.high_risk_departments
|
||||||
|
.as_ref()
|
||||||
|
.map(Vec::len)
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Кандидаты в инциденты: {}\n",
|
||||||
|
dashboard
|
||||||
|
.critical_candidates
|
||||||
|
.as_ref()
|
||||||
|
.map(Vec::len)
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Открытые дела: {}\n",
|
||||||
|
dashboard.open_cases.unwrap_or(0)
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Закрытые дела за 30 дней: {}\n",
|
||||||
|
dashboard.resolved_cases_30d.unwrap_or(0)
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Готовность расследований: {}\n",
|
||||||
|
dashboard
|
||||||
|
.forensics_readiness
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("UNKNOWN")
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Главный риск: {}\n",
|
||||||
|
dashboard.summary.main_risk
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Главное улучшение: {}\n",
|
||||||
|
dashboard.summary.main_improvement
|
||||||
|
));
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- Главный пробел в данных: {}\n",
|
||||||
|
dashboard.summary.main_data_gap
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn append_agent_quality_markdown(text: &mut String, quality: &AgentQuality) {
|
fn append_agent_quality_markdown(text: &mut String, quality: &AgentQuality) {
|
||||||
let explain = agent_quality_explain(quality);
|
let explain = agent_quality_explain(quality);
|
||||||
text.push_str("\n## Достоверность данных\n\n");
|
text.push_str("\n## Достоверность данных\n\n");
|
||||||
@@ -8664,6 +8999,7 @@ mod tests {
|
|||||||
incident_state: &IncidentStateFile::default(),
|
incident_state: &IncidentStateFile::default(),
|
||||||
incident_reviews: &IncidentReviewFile::default(),
|
incident_reviews: &IncidentReviewFile::default(),
|
||||||
incident_review_audit: &[],
|
incident_review_audit: &[],
|
||||||
|
cases: &CaseFile::default(),
|
||||||
evidence: &evidence,
|
evidence: &evidence,
|
||||||
},
|
},
|
||||||
&missing_policy,
|
&missing_policy,
|
||||||
@@ -8716,6 +9052,22 @@ mod tests {
|
|||||||
50
|
50
|
||||||
);
|
);
|
||||||
assert_eq!(report["agent_coverage_sla"]["sla_status"], "UNKNOWN");
|
assert_eq!(report["agent_coverage_sla"]["sla_status"], "UNKNOWN");
|
||||||
|
assert!(report["executive_dashboard"].is_object());
|
||||||
|
assert_eq!(report["executive_dashboard"]["trust_kpi_score"], 50);
|
||||||
|
assert_eq!(report["executive_dashboard"]["open_cases"], 0);
|
||||||
|
assert_eq!(report["executive_dashboard"]["resolved_cases_30d"], 0);
|
||||||
|
assert!(
|
||||||
|
!report["executive_dashboard"]["critical_candidates"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report["executive_dashboard"]["summary"]["main_data_gap"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("не настроен список")
|
||||||
|
);
|
||||||
assert_eq!(report["business_risk"].as_array().unwrap().len(), 1);
|
assert_eq!(report["business_risk"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(report["business_risk"][0]["department"], "Бухгалтерия");
|
assert_eq!(report["business_risk"][0]["department"], "Бухгалтерия");
|
||||||
assert_eq!(report["business_risk"][0]["risk_level"], "MEDIUM");
|
assert_eq!(report["business_risk"][0]["risk_level"], "MEDIUM");
|
||||||
@@ -8901,6 +9253,12 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|item| item.as_str().unwrap().contains("Кандидат в инцидент"))
|
.any(|item| item.as_str().unwrap().contains("Кандидат в инцидент"))
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
report["markdown"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("## Сводка руководителя")
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
report["markdown"]
|
report["markdown"]
|
||||||
.as_str()
|
.as_str()
|
||||||
|
|||||||
@@ -313,6 +313,52 @@ function renderExecutiveMetrics(report, incidents) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderExecutiveDashboard(report) {
|
||||||
|
const dashboard = report?.executive_dashboard;
|
||||||
|
if (!dashboard) return "";
|
||||||
|
const highRisk = Array.isArray(dashboard.high_risk_departments) ? dashboard.high_risk_departments : [];
|
||||||
|
const candidates = Array.isArray(dashboard.critical_candidates) ? dashboard.critical_candidates : [];
|
||||||
|
const summary = dashboard.summary || {};
|
||||||
|
return `
|
||||||
|
<section class="card executive-dashboard-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>Сводка руководителя</h3>
|
||||||
|
<p class="muted">Что происходит в организации прямо сейчас: доверие к KPI, покрытие, риски, дела и готовность расследований.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${statusClass(executiveDashboardStatus(dashboard))}">${ui(dashboard.forensics_readiness || "UNKNOWN")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="quality-grid">
|
||||||
|
<div><span class="muted">Trust KPI</span><strong>${ui(optionalPercent(dashboard.trust_kpi_score))}</strong></div>
|
||||||
|
<div><span class="muted">Покрытие агентов</span><strong>${ui(optionalPercent(dashboard.agent_coverage_pct))}</strong></div>
|
||||||
|
<div><span class="muted">Высокий риск</span><strong>${ui(highRisk.length)}</strong></div>
|
||||||
|
<div><span class="muted">Кандидаты</span><strong>${ui(candidates.length)}</strong></div>
|
||||||
|
<div><span class="muted">Открытые дела</span><strong>${ui(dashboard.open_cases ?? 0)}</strong></div>
|
||||||
|
<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 || "нет данных")}</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>
|
||||||
|
</div>
|
||||||
|
${highRisk.length ? `<p class="muted small">Риск-подразделения: ${ui(highRisk.slice(0, 5).map(item => `${item.department} (${item.risk_level})`).join("; "))}</p>` : ""}
|
||||||
|
${candidates.length ? `<p class="muted small">Кандидаты для проверки: ${ui(candidates.slice(0, 5).map(item => `${item.id} (${item.risk_level})`).join("; "))}</p>` : ""}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPercent(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? `${Math.round(number)}%` : "нет данных";
|
||||||
|
}
|
||||||
|
|
||||||
|
function executiveDashboardStatus(dashboard) {
|
||||||
|
if ((dashboard.critical_candidates || []).length > 0) return "WARN";
|
||||||
|
if ((dashboard.high_risk_departments || []).length > 0) return "WARN";
|
||||||
|
if (Number(dashboard.agent_coverage_pct) < 75) return "FAIL";
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
|
||||||
function statusRiskLabel(status) {
|
function statusRiskLabel(status) {
|
||||||
const value = String(status || "INFO").toUpperCase();
|
const value = String(status || "INFO").toUpperCase();
|
||||||
if (value === "FAIL") return "high";
|
if (value === "FAIL") return "high";
|
||||||
@@ -824,6 +870,7 @@ function renderOperator(data, report) {
|
|||||||
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
|
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
|
||||||
</div>
|
</div>
|
||||||
${renderPeriodBanner(report)}
|
${renderPeriodBanner(report)}
|
||||||
|
${renderExecutiveDashboard(report)}
|
||||||
${renderExecutiveMetrics(report, incidents)}
|
${renderExecutiveMetrics(report, incidents)}
|
||||||
${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)}
|
${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)}
|
||||||
${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)}
|
${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)}
|
||||||
@@ -1941,6 +1988,7 @@ function renderReports(data) {
|
|||||||
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
|
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
|
||||||
</div>
|
</div>
|
||||||
${renderPeriodBanner(data)}
|
${renderPeriodBanner(data)}
|
||||||
|
${renderExecutiveDashboard(data)}
|
||||||
${renderReportTypeCards()}
|
${renderReportTypeCards()}
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<section class="card report-hero">
|
<section class="card report-hero">
|
||||||
|
|||||||
@@ -34,12 +34,31 @@ Business Risk не является автоматическим обвинен
|
|||||||
- `missing_nodes_count` - сколько ожидаемых узлов не прислали telemetry;
|
- `missing_nodes_count` - сколько ожидаемых узлов не прислали telemetry;
|
||||||
- `stale_nodes_count` - сколько узлов присылали telemetry, но данные устарели.
|
- `stale_nodes_count` - сколько узлов присылали telemetry, но данные устарели.
|
||||||
|
|
||||||
Также `GET /api/reports` отдает историю:
|
Также `GET /api/reports` отдает управленческую сводку и историю:
|
||||||
|
|
||||||
|
- `executive_dashboard` - сводка руководителя по Trust KPI, покрытию агентов,
|
||||||
|
рискам, кандидатам, делам и готовности расследований;
|
||||||
- `business_risk_history` - timeline риска по подразделениям;
|
- `business_risk_history` - timeline риска по подразделениям;
|
||||||
- `business_risk_history_summary` - сводка динамики.
|
- `business_risk_history_summary` - сводка динамики.
|
||||||
- `risk_incident_candidates` - read-only кандидаты для ручной проверки.
|
- `risk_incident_candidates` - read-only кандидаты для ручной проверки.
|
||||||
|
|
||||||
|
`executive_dashboard`:
|
||||||
|
|
||||||
|
- `trust_kpi_score` - доверие к KPI активности в процентах, если доступно;
|
||||||
|
- `agent_coverage_pct` - покрытие ожидаемых рабочих мест агентами, если
|
||||||
|
настроен список expected nodes;
|
||||||
|
- `high_risk_departments` - до 10 подразделений с уровнем риска `HIGH` или
|
||||||
|
`CRITICAL`;
|
||||||
|
- `critical_candidates` - до 10 кандидатов в инциденты с уровнем `HIGH` или
|
||||||
|
`CRITICAL`;
|
||||||
|
- `open_cases` - количество открытых дел;
|
||||||
|
- `resolved_cases_30d` - количество дел, закрытых за последние 30 дней;
|
||||||
|
- `forensics_readiness` - готовность доказательного слоя: `READY`, `PARTIAL`,
|
||||||
|
`OBSERVE` или `LIMITED`;
|
||||||
|
- `summary.main_risk` - главный риск текущего среза;
|
||||||
|
- `summary.main_improvement` - главное подтвержденное улучшение;
|
||||||
|
- `summary.main_data_gap` - главный пробел в данных.
|
||||||
|
|
||||||
Элемент `business_risk_history`:
|
Элемент `business_risk_history`:
|
||||||
|
|
||||||
- `date` - дата daily point;
|
- `date` - дата daily point;
|
||||||
@@ -281,6 +300,7 @@ POST /api/cases/{case_id}/status
|
|||||||
Оперативный отчет содержит раздел:
|
Оперативный отчет содержит раздел:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
## Сводка руководителя
|
||||||
## Риски подразделений
|
## Риски подразделений
|
||||||
## Динамика бизнес-рисков
|
## Динамика бизнес-рисков
|
||||||
## Кандидаты в инциденты
|
## Кандидаты в инциденты
|
||||||
|
|||||||
Reference in New Issue
Block a user