diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index f533e01..a367752 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -345,6 +345,10 @@ struct RiskHeatmapItem { #[serde(skip_serializing_if = "Option::is_none")] critical_candidates: Option, heat_level: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + links: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, } #[derive(Clone, Debug, Serialize)] @@ -362,6 +366,15 @@ struct SecurityCorrelationItem { open_cases: Option, correlation_score: u8, correlation_reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, +} + +#[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, } #[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::>(); @@ -3222,6 +3255,125 @@ fn heatmap_rank(level: &str) -> u8 { } } +fn risk_narrative_links( + trust_kpi_score: Option, + activity_score: Option, + agent_coverage_pct: Option, + business_risk_level: Option<&str>, + open_cases: usize, + critical_candidates: usize, +) -> Vec { + 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, + activity_score: Option, + agent_coverage_pct: Option, + 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, + activity_score: Option, + agent_coverage_pct: Option, + 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 { 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::>(); - 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::>(); - 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 { + 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::>(); + 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() diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 4f8774a..0fc9460 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -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) {
Закрыто за 30 дней${ui(dashboard.resolved_cases_30d ?? 0)}
+
Главная причина риска${ui(summary.main_risk_cause || "связанный риск не выражен")}
Главный риск${ui(summary.main_risk || "нет данных")}
Главное улучшение${ui(summary.main_improvement || "нет данных")}
Пробел в данных${ui(summary.main_data_gap || "нет данных")}
@@ -1360,7 +1361,7 @@ function renderIncidents(data) { function renderCases(cases) { const rows = Array.isArray(cases) ? cases.slice(0, 20) : []; return ` -
+

Дела

@@ -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 ` -
+

Достоверность данных агента

@@ -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 ` -
+

Покрытие агентов

@@ -1728,7 +1729,7 @@ function renderBusinessRisk(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const worst = rows[0]?.risk_level || "UNKNOWN"; return ` -
+

Риски подразделений

@@ -1791,6 +1792,7 @@ function renderRiskHeatmap(items) { Покрытие Риск Дела + Связи @@ -1805,6 +1807,7 @@ function renderRiskHeatmap(items) { ${ui(item.business_risk_level || "UNKNOWN")} · кандидаты ${ui(item.critical_candidates ?? 0)} ${ui(item.open_cases ?? 0)} + ${renderRiskLayerLinks(item.links)} `).join("") : ` @@ -1814,6 +1817,7 @@ function renderRiskHeatmap(items) { UNKNOWN UNKNOWN 0 + - `} @@ -1823,6 +1827,24 @@ function renderRiskHeatmap(items) { `; } +function renderRiskLayerLinks(links) { + const items = Array.isArray(links) ? links : []; + if (!items.length) return "-"; + return `
${items.map(link => { + const target = riskLayerTarget(link.target); + return ``; + }).join("")}
`; +} + +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) { ${ui(item.business_risk_level || "UNKNOWN")} кандидаты ${ui(item.critical_candidates ?? 0)} · дела ${ui(item.open_cases ?? 0)} ${ui(Number(item.correlation_score || 0))}/100 - ${ui(item.correlation_reason || "связь не выражена")} + ${ui(item.explanation || item.correlation_reason || "связь не выражена")} `).join("") : ` @@ -1957,7 +1979,7 @@ function renderRiskIncidentCandidates(items) { const rows = Array.isArray(items) ? items.slice(0, 10) : []; const worst = rows[0]?.risk_level || "UNKNOWN"; return ` -
+

Кандидаты в инциденты

@@ -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 = `
${escapeHtml(error.stack || error.message || error)}
`; } @@ -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; diff --git a/docs/BUSINESS_RISK_RU.md b/docs/BUSINESS_RISK_RU.md index 66045e0..626a02d 100644 --- a/docs/BUSINESS_RISK_RU.md +++ b/docs/BUSINESS_RISK_RU.md @@ -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 +## Связанная картина риска ## Риски подразделений ## Динамика бизнес-рисков ## Кандидаты в инциденты diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index c41f1b3..88ef1ca 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -126,6 +126,7 @@ async function main() { const requiredExecutive = [ "Сотрудников в работе", "Средний индекс активности", + "Главная причина риска", "Достоверность данных агента", "Стабильность агента за 7 дней", "Качество данных по рабочим местам",