feat(portal): add executive dashboard
This commit is contained in:
@@ -446,6 +446,53 @@ struct CaseDetailsResponse {
|
||||
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)]
|
||||
struct HealthResponse {
|
||||
ok: bool,
|
||||
@@ -920,6 +967,7 @@ struct ReportWorkforceSummary {
|
||||
|
||||
struct ReportMarkdownContext<'a> {
|
||||
workforce: &'a ReportWorkforceSummary,
|
||||
executive_dashboard: &'a ExecutiveDashboard,
|
||||
workforce_policy: &'a Value,
|
||||
ueba_risk: &'a Value,
|
||||
business_risk: &'a [BusinessRiskItem],
|
||||
@@ -933,6 +981,7 @@ struct ReportRuntimeInputs<'a> {
|
||||
incident_state: &'a IncidentStateFile,
|
||||
incident_reviews: &'a IncidentReviewFile,
|
||||
incident_review_audit: &'a [IncidentReviewAuditEntry],
|
||||
cases: &'a CaseFile,
|
||||
evidence: &'a DlpEvidenceResponse,
|
||||
}
|
||||
|
||||
@@ -954,6 +1003,7 @@ fn run() -> Result<i32> {
|
||||
let incident_state = load_incident_state_best_effort(&args);
|
||||
let incident_reviews = load_incident_review_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 ueba_baseline_path = ueba_baseline_state_path(&args);
|
||||
let smoke = json!({
|
||||
@@ -963,6 +1013,7 @@ fn run() -> Result<i32> {
|
||||
incident_state: &incident_state,
|
||||
incident_reviews: &incident_reviews,
|
||||
incident_review_audit: &incident_review_audit,
|
||||
cases: &cases,
|
||||
evidence: &evidence,
|
||||
}, &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false),
|
||||
"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_reviews = load_incident_review_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 ueba_baseline_path = ueba_baseline_state_path(args);
|
||||
respond_json(
|
||||
@@ -1090,6 +1142,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
||||
incident_state: &incident_state,
|
||||
incident_reviews: &incident_reviews,
|
||||
incident_review_audit: &incident_review_audit,
|
||||
cases: &cases,
|
||||
evidence: &evidence,
|
||||
},
|
||||
&args.workforce_policy_path,
|
||||
@@ -2221,6 +2274,15 @@ fn build_reports(
|
||||
);
|
||||
let incident_review_audit_summary =
|
||||
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 insight_items = workforce_insight_items(snapshot);
|
||||
let workforce_policy_explain =
|
||||
@@ -2311,6 +2373,18 @@ fn build_reports(
|
||||
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 workforce_summary = ReportWorkforceSummary {
|
||||
departments_count: department_items.len(),
|
||||
@@ -2326,6 +2400,7 @@ fn build_reports(
|
||||
&recommendations,
|
||||
ReportMarkdownContext {
|
||||
workforce: &workforce_summary,
|
||||
executive_dashboard: &executive_dashboard,
|
||||
workforce_policy: &workforce_policy_explain,
|
||||
ueba_risk: &ueba_risk,
|
||||
business_risk: &business_risk,
|
||||
@@ -2343,6 +2418,7 @@ fn build_reports(
|
||||
"operator_ok": summary.operator_ok,
|
||||
"headline": headline,
|
||||
"executive_points": executive_points,
|
||||
"executive_dashboard": executive_dashboard,
|
||||
"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("Качество данных агента", 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)
|
||||
}
|
||||
|
||||
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(
|
||||
snapshot: &Snapshot,
|
||||
candidate_id: &str,
|
||||
@@ -4797,6 +5070,7 @@ fn render_report_markdown(
|
||||
for item in recommendations {
|
||||
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_history_markdown(
|
||||
&mut text,
|
||||
@@ -4828,6 +5102,67 @@ fn render_report_markdown(
|
||||
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) {
|
||||
let explain = agent_quality_explain(quality);
|
||||
text.push_str("\n## Достоверность данных\n\n");
|
||||
@@ -8664,6 +8999,7 @@ mod tests {
|
||||
incident_state: &IncidentStateFile::default(),
|
||||
incident_reviews: &IncidentReviewFile::default(),
|
||||
incident_review_audit: &[],
|
||||
cases: &CaseFile::default(),
|
||||
evidence: &evidence,
|
||||
},
|
||||
&missing_policy,
|
||||
@@ -8716,6 +9052,22 @@ mod tests {
|
||||
50
|
||||
);
|
||||
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"][0]["department"], "Бухгалтерия");
|
||||
assert_eq!(report["business_risk"][0]["risk_level"], "MEDIUM");
|
||||
@@ -8901,6 +9253,12 @@ mod tests {
|
||||
.iter()
|
||||
.any(|item| item.as_str().unwrap().contains("Кандидат в инцидент"))
|
||||
);
|
||||
assert!(
|
||||
report["markdown"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("## Сводка руководителя")
|
||||
);
|
||||
assert!(
|
||||
report["markdown"]
|
||||
.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) {
|
||||
const value = String(status || "INFO").toUpperCase();
|
||||
if (value === "FAIL") return "high";
|
||||
@@ -824,6 +870,7 @@ function renderOperator(data, report) {
|
||||
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
|
||||
</div>
|
||||
${renderPeriodBanner(report)}
|
||||
${renderExecutiveDashboard(report)}
|
||||
${renderExecutiveMetrics(report, incidents)}
|
||||
${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)}
|
||||
${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>
|
||||
</div>
|
||||
${renderPeriodBanner(data)}
|
||||
${renderExecutiveDashboard(data)}
|
||||
${renderReportTypeCards()}
|
||||
<div class="grid-2">
|
||||
<section class="card report-hero">
|
||||
|
||||
@@ -34,12 +34,31 @@ Business Risk не является автоматическим обвинен
|
||||
- `missing_nodes_count` - сколько ожидаемых узлов не прислали telemetry;
|
||||
- `stale_nodes_count` - сколько узлов присылали telemetry, но данные устарели.
|
||||
|
||||
Также `GET /api/reports` отдает историю:
|
||||
Также `GET /api/reports` отдает управленческую сводку и историю:
|
||||
|
||||
- `executive_dashboard` - сводка руководителя по Trust KPI, покрытию агентов,
|
||||
рискам, кандидатам, делам и готовности расследований;
|
||||
- `business_risk_history` - timeline риска по подразделениям;
|
||||
- `business_risk_history_summary` - сводка динамики.
|
||||
- `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`:
|
||||
|
||||
- `date` - дата daily point;
|
||||
@@ -281,6 +300,7 @@ POST /api/cases/{case_id}/status
|
||||
Оперативный отчет содержит раздел:
|
||||
|
||||
```text
|
||||
## Сводка руководителя
|
||||
## Риски подразделений
|
||||
## Динамика бизнес-рисков
|
||||
## Кандидаты в инциденты
|
||||
|
||||
Reference in New Issue
Block a user