From e55b68868de44e449040aa6496108f419276879e Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 11:59:50 +0300 Subject: [PATCH] feat(portal): add agent data trust explain --- adk-rust/crates/detmir-portal/src/main.rs | 141 +++++++++++++++++- .../crates/detmir-portal/src/static/app.css | 32 ++++ .../crates/detmir-portal/src/static/app.js | 77 ++++++++-- docs/PORTAL_RU.md | 47 +++--- docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md | 20 ++- scripts/detmir-portal-tabs-smoke.mjs | 2 +- 6 files changed, 280 insertions(+), 39 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 40b8a5d..c42f3a9 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -189,6 +189,15 @@ impl Default for AgentQuality { } } +#[derive(Clone, Debug, Serialize)] +struct AgentQualityExplain { + status: String, + title: String, + summary: String, + recommendation: String, + kpi_accepted: bool, +} + #[derive(Debug, Serialize)] struct HealthResponse { ok: bool, @@ -1046,6 +1055,54 @@ fn critical_collector_error(error: &str) -> bool { .any(|needle| lower.contains(needle)) } +fn agent_quality_explain(quality: &AgentQuality) -> AgentQualityExplain { + let source = quality.collector_source.as_str(); + let has_error = quality.collector_error.is_some(); + if source == "wts_api" && !has_error { + return AgentQualityExplain { + status: "OK".to_string(), + title: "Данные агента подтверждают KPI".to_string(), + summary: "Сессии собраны основным способом через Windows WTS API; индекс активности можно использовать как рабочий управленческий KPI.".to_string(), + recommendation: "Использовать отчет как подтвержденный оперативный срез. Для расследований сверять с первичными событиями ActivityWatch.".to_string(), + kpi_accepted: true, + }; + } + if source == "local_fallback" { + return AgentQualityExplain { + status: "DEGRADED".to_string(), + title: "Диагностический режим агента".to_string(), + summary: "Диагностический режим, данные не засчитываются в KPI.".to_string(), + recommendation: "Проверить доступность WTS API, права запуска агента и состояние Rust Scheduled Task. Не использовать этот срез как доказательство активности.".to_string(), + kpi_accepted: false, + }; + } + if let Some(error) = &quality.collector_error { + return AgentQualityExplain { + status: "DEGRADED".to_string(), + title: "Достоверность данных снижена".to_string(), + summary: format!("Коллектор передал ошибку: {error}"), + recommendation: "Проверить журнал агента, источник сбора сессий и восстановить основной путь WTS API перед использованием отчета как доказательной базы.".to_string(), + kpi_accepted: false, + }; + } + match source { + "quser_utf16" | "quser_lossy" | "env_sessionname_fallback" => AgentQualityExplain { + status: "WARNING".to_string(), + title: "Данные собраны резервным способом".to_string(), + summary: "Активность собрана не основным WTS API. KPI можно использовать как оперативный ориентир, но доказательная точность ниже.".to_string(), + recommendation: "Проверить, почему WTS API недоступен, и вернуть агент на основной источник сбора.".to_string(), + kpi_accepted: true, + }, + _ => AgentQualityExplain { + status: "UNKNOWN".to_string(), + title: "Достоверность данных неизвестна".to_string(), + summary: "Агент не передал диагностику качества данных.".to_string(), + recommendation: "Обновить Rust agent до версии с diagnostics и проверить поступление telemetry.jsonl.".to_string(), + kpi_accepted: false, + }, + } +} + fn command_json_source(name: &str, command: &str, timeout: Duration) -> SourceStatus { match run_shell(command, timeout) { Ok((stdout, stderr, success)) => { @@ -1369,6 +1426,7 @@ fn build_reports( let grafana = grafana_block(snapshot); let collection = collection_block(snapshot.detmir_check.payload.as_ref()); let agent_quality = snapshot.agent_quality.clone(); + let agent_quality_explain = agent_quality_explain(&agent_quality); let worktime = worktime_block(snapshot); let one_c = one_c_block(snapshot); let dlp_block_value = dlp_block(snapshot); @@ -1398,8 +1456,8 @@ fn build_reports( let executive_points = vec![ format!("Сбор данных: {}. {}", collection.status, collection.text), format!( - "Качество данных агента: {} через {}", - agent_quality.quality_status, agent_quality.collector_source + "Достоверность данных агента: {}. {}", + agent_quality_explain.status, agent_quality_explain.summary ), format!( "Работа сегодня: сотрудников={}, активное время={}", @@ -1446,6 +1504,7 @@ fn build_reports( "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)), + report_kpi("Достоверность данных", agent_quality_explain.status.clone(), agent_quality_explain.status.clone(), &agent_quality_explain.title), report_kpi("Индекс активности", workforce_index_text(metrics.workforce_index), workforce_index_status(metrics.workforce_index), "proxy: активное время / плановое рабочее время"), weighted_activity_kpi_from_policy(&workforce_policy_explain), report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"), @@ -1463,6 +1522,7 @@ fn build_reports( report_item("DetMir status", snapshot.detmir_status.status.clone(), snapshot.detmir_status.summary.clone()), report_item("Сбор данных", collection.status.clone(), collection.text.clone()), report_item("Качество данных агента", agent_quality.quality_status.clone(), format!("source={}, sessions={}, active={}, rdp={}", agent_quality.collector_source, agent_quality.sessions_collected_total, agent_quality.active_sessions_total, agent_quality.rdp_sessions_total)), + report_item("Достоверность данных", agent_quality_explain.status.clone(), format!("KPI accepted={}, {}", agent_quality_explain.kpi_accepted, agent_quality_explain.recommendation)), report_item("Grafana", grafana.status.clone(), grafana.text.clone()), report_item("1C analytics", one_c.status.clone(), one_c.text.clone()) ] @@ -1512,6 +1572,7 @@ fn build_reports( "ueba_risk": ueba_risk, "ueba_baseline": ueba_baseline, "agent_quality": agent_quality, + "agent_quality_explain": agent_quality_explain, "workforce_policy": workforce_policy_explain, "workforce": { "department_comparison": department_items, @@ -2954,12 +3015,36 @@ fn render_report_markdown( for item in recommendations { text.push_str(&format!("- {item}\n")); } + append_agent_quality_markdown(&mut text, &snapshot.agent_quality); append_ueba_risk_markdown(&mut text, ueba_risk); append_workforce_policy_markdown(&mut text, workforce_policy); text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n"); text } +fn append_agent_quality_markdown(text: &mut String, quality: &AgentQuality) { + let explain = agent_quality_explain(quality); + text.push_str("\n## Достоверность данных\n\n"); + text.push_str(&format!("- Источник: {}\n", quality.collector_source)); + text.push_str(&format!("- Статус: {}\n", explain.status)); + text.push_str(&format!( + "- Принято в KPI: {}\n", + if explain.kpi_accepted { + "да" + } else { + "нет" + } + )); + text.push_str(&format!( + "- Сессии: всего={}, активные={}, RDP={}\n", + quality.sessions_collected_total, quality.active_sessions_total, quality.rdp_sessions_total + )); + if let Some(error) = &quality.collector_error { + text.push_str(&format!("- Ошибка коллектора: {error}\n")); + } + text.push_str(&format!("- Рекомендация: {}\n", explain.recommendation)); +} + fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { text.push_str("\n## UEBA риск\n\n"); text.push_str(&format!( @@ -4835,6 +4920,10 @@ mod tests { assert_eq!(quality.quality_status, "unknown"); assert_eq!(quality.collector_source, "unknown"); assert_eq!(quality.sessions_collected_total, 0); + let explain = agent_quality_explain(&quality); + assert_eq!(explain.status, "UNKNOWN"); + assert!(!explain.kpi_accepted); + assert!(explain.summary.contains("не передал диагностику")); } #[test] @@ -4854,6 +4943,54 @@ mod tests { assert_eq!(quality.sessions_collected_total, 3); assert_eq!(quality.active_sessions_total, 2); assert_eq!(quality.rdp_sessions_total, 2); + let explain = agent_quality_explain(&quality); + assert_eq!(explain.status, "OK"); + assert!(explain.kpi_accepted); + assert!(explain.summary.contains("WTS API")); + } + + #[test] + fn agent_quality_local_fallback_is_degraded_and_not_accepted_for_kpi() { + let quality = agent_quality_from_record(&json!({ + "diagnostics": { + "collector_source": "local_fallback", + "sessions_collected_total": 1, + "active_sessions_total": 1, + "rdp_sessions_total": 0 + } + })); + assert_eq!(quality.quality_status, "degraded"); + let explain = agent_quality_explain(&quality); + assert_eq!(explain.status, "DEGRADED"); + assert!(!explain.kpi_accepted); + assert_eq!( + explain.summary, + "Диагностический режим, данные не засчитываются в KPI." + ); + } + + #[test] + fn agent_quality_collector_error_is_visible_in_explain_and_markdown() { + let quality = agent_quality_from_record(&json!({ + "diagnostics": { + "collector_source": "wts_api", + "collector_error": "temporary WTS failure", + "sessions_collected_total": 0, + "active_sessions_total": 0, + "rdp_sessions_total": 0 + } + })); + assert_eq!(quality.quality_status, "degraded"); + let explain = agent_quality_explain(&quality); + assert_eq!(explain.status, "DEGRADED"); + assert!(!explain.kpi_accepted); + assert!(explain.summary.contains("temporary WTS failure")); + + let mut markdown = String::new(); + append_agent_quality_markdown(&mut markdown, &quality); + assert!(markdown.contains("## Достоверность данных")); + assert!(markdown.contains("Принято в KPI: нет")); + assert!(markdown.contains("Ошибка коллектора: temporary WTS failure")); } #[test] diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 471b1cd..c87f88a 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -511,6 +511,28 @@ h1 { margin: 12px 0 16px; } +.quality-summary { + margin: 10px 0; + font-weight: 700; + line-height: 1.45; +} + +.quality-decision { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin: 10px 0; +} + +.quality-decision > div { + display: grid; + gap: 4px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--soft); +} + .quality-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); @@ -550,6 +572,16 @@ h1 { overflow-wrap: anywhere; } +.quality-details { + margin-top: 10px; +} + +.quality-details summary { + cursor: pointer; + color: var(--link); + font-weight: 800; +} + .heatmap-table td { min-width: 120px; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index cd0b537..7876a21 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -806,7 +806,7 @@ function renderOperator(data, report) { ${renderPeriodBanner(report)} ${renderExecutiveMetrics(report, incidents)} - ${renderAgentQuality(report?.agent_quality)} + ${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)} ${renderOverviewAnalytics(report)}

Рабочая активность сотрудников

загрузка, простои, перегруз и дисциплина процессов
@@ -1336,28 +1336,79 @@ function renderUebaRisk(risk) { `; } -function renderAgentQuality(quality) { +function fallbackAgentQualityExplain(quality) { const q = quality || {}; - const status = q.quality_status || "unknown"; const source = q.collector_source || "unknown"; - const warn = ["fallback", "degraded", "error"].includes(String(status).toLowerCase()); + const hasError = Boolean(q.collector_error); + if (source === "wts_api" && !hasError) { + return { + status: "OK", + title: "Данные агента подтверждают KPI", + summary: "Сессии собраны основным способом через Windows WTS API; индекс активности можно использовать как рабочий управленческий KPI.", + recommendation: "Использовать отчет как подтвержденный оперативный срез.", + kpi_accepted: true + }; + } + if (source === "local_fallback") { + return { + status: "DEGRADED", + title: "Диагностический режим агента", + summary: "Диагностический режим, данные не засчитываются в KPI.", + recommendation: "Проверить доступность WTS API и права запуска агента.", + kpi_accepted: false + }; + } + if (hasError) { + return { + status: "DEGRADED", + title: "Достоверность данных снижена", + summary: `Коллектор передал ошибку: ${q.collector_error}`, + recommendation: "Восстановить основной путь WTS API перед использованием отчета как доказательной базы.", + kpi_accepted: false + }; + } + return { + status: "UNKNOWN", + title: "Достоверность данных неизвестна", + summary: "Агент не передал диагностику качества данных.", + recommendation: "Обновить Rust agent до версии с diagnostics и проверить telemetry.jsonl.", + kpi_accepted: false + }; +} + +function renderAgentQuality(quality, explain) { + const q = quality || {}; + const e = explain || fallbackAgentQualityExplain(q); + const status = e.status || q.quality_status || "UNKNOWN"; + const source = q.collector_source || "unknown"; + const warn = ["warning", "fallback", "degraded", "error"].includes(String(status).toLowerCase()); + const accepted = Boolean(e.kpi_accepted); return `
-

Качество данных агента

-

Доверие к источнику, который подтверждает активность и RDP-сессии.

+

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

+

${ui(e.title || "Оценка доверия к данным агента")}

${ui(status)}
- ${warn ? `
Внимание. Данные активности собраны не основным способом. Точность определения активности и RDP-сессий может быть снижена.
` : ""} -
+

${ui(e.summary || "")}

+
+
Принято в KPI${accepted ? "да" : "нет"}
Источник${ui(source)}
-
Сессий собрано${escapeHtml(q.sessions_collected_total ?? 0)}
-
Активных сессий${escapeHtml(q.active_sessions_total ?? 0)}
-
RDP-сессий${escapeHtml(q.rdp_sessions_total ?? 0)}
- ${q.collector_error ? `

Ошибка коллектора: ${ui(q.collector_error)}

` : ""} + ${warn ? `
Внимание. Данные активности собраны не основным способом. Точность определения активности и RDP-сессий может быть снижена.
` : ""} +

${ui(e.recommendation || "")}

+
+ Технические детали +
+
Источник коллектора${ui(source)}
+
Всего сессий${escapeHtml(q.sessions_collected_total ?? 0)}
+
Активных сессий${escapeHtml(q.active_sessions_total ?? 0)}
+
RDP-сессий${escapeHtml(q.rdp_sessions_total ?? 0)}
+
Ошибка коллектора${q.collector_error ? ui(q.collector_error) : "нет"}
+
+
`; } @@ -1396,7 +1447,7 @@ function renderReports(data) {

Ключевые показатели

${renderKpiCards(data.kpis)} - ${renderAgentQuality(data.agent_quality)} + ${renderAgentQuality(data.agent_quality, data.agent_quality_explain)} ${renderUebaRisk(data.ueba_risk)} ${renderWorkforceIndexExplanation(data.workforce_policy)}

Срезы отчета

diff --git a/docs/PORTAL_RU.md b/docs/PORTAL_RU.md index 2e05c6f..15551ef 100644 --- a/docs/PORTAL_RU.md +++ b/docs/PORTAL_RU.md @@ -28,36 +28,45 @@ - WARN/FAIL подразделения; - критические риски; - недельный тренд; -- качество данных агента; +- достоверность данных агента; - топ-5 лучших и проблемных подразделений; - Heat Map; - блок `Требует внимания`. -## Качество данных агента +## Достоверность данных агента -Портал показывает карточку `Качество данных агента` в `Обзор` и `Отчеты`. +Портал показывает карточку `Достоверность данных агента` в `Обзор` и +`Отчеты`. Это управленческий вывод о том, можно ли использовать текущий +`Индекс активности` и отчеты как подтвержденный KPI. -Карточка содержит: +API сохраняет старое поле `agent_quality` и дополнительно отдает +`agent_quality_explain`: -- `quality_status`; -- `collector_source`; -- `sessions_collected_total`; -- `active_sessions_total`; -- `rdp_sessions_total`; -- `collector_error`, если он есть. +- `status`; +- `title`; +- `summary`; +- `recommendation`; +- `kpi_accepted`. Цвета статусов: -- `ok` - зеленый; -- `fallback` - желтый; -- `degraded` - оранжевый; -- `error` - красный; -- `unknown` - серый. +- `OK` - зеленый, KPI подтвержден основным источником; +- `WARNING` - желтый, данные собраны резервным способом; +- `DEGRADED` - оранжевый, данные нельзя использовать как доказательный KPI; +- `UNKNOWN` - серый, агент не передал diagnostics. -Для `fallback`, `degraded` и `error` портал показывает предупреждение о том, -что активность собрана не основным способом и точность RDP/worktime KPI может -быть снижена. Старые агенты без diagnostics не ломают API и отображаются как -`unknown`. +Карточка сначала показывает управленческий вывод, принято ли значение в KPI, и +рекомендацию. Технические поля доступны в раскрываемом блоке: + +- источник коллектора; +- всего сессий; +- активных сессий; +- RDP-сессий; +- ошибка коллектора, если она есть. + +Если diagnostics отсутствует, статус `UNKNOWN`, рекомендация - обновить Rust +agent и проверить поступление telemetry JSONL. Если источник `local_fallback`, +портал прямо пишет: `Диагностический режим, данные не засчитываются в KPI`. ## Risk -> Investigation diff --git a/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md b/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md index 21b0735..401f41e 100644 --- a/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md +++ b/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md @@ -73,11 +73,14 @@ aw_worktime_enabled = true - `collector_source`; - `collector_error`. -## Качество данных агента +## Достоверность данных агента -Портал и отчеты поднимают diagnostics в блок `agent_quality`. +Портал и отчеты поднимают diagnostics в два блока: -Статусы: +- `agent_quality` - обратная совместимость и технические счетчики; +- `agent_quality_explain` - управленческий вывод о доверии к KPI. + +Статусы `agent_quality`: - `ok` - основной источник `wts_api`, ошибки коллектора нет; - `fallback` - данные получены через `quser_utf16`, `quser_lossy` или @@ -88,10 +91,19 @@ aw_worktime_enabled = true некорректный обязательный payload или ошибка парсинга; - `unknown` - старый агент или payload без diagnostics. +Статусы `agent_quality_explain`: + +- `OK` - данные приняты в KPI; +- `WARNING` - данные собраны резервным способом, допустимы как оперативный + ориентир, но требуют проверки для доказательной базы; +- `DEGRADED` - данные не приняты в KPI; +- `UNKNOWN` - агент не передал диагностику качества данных. + `local_fallback` считается диагностическим сигналом, а не доказательством активности. События worktime, опубликованные из `local_fallback`, получают `active=false`, `ignoredForKpi=true` и не должны увеличивать KPI сотрудника или -подтверждать RDP-активность. +подтверждать RDP-активность. В портале для этого режима выводится текст: +`Диагностический режим, данные не засчитываются в KPI`. ## PowerShell Legacy Fallback diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 01a736c..1848fb5 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -120,7 +120,7 @@ async function main() { const requiredExecutive = [ "Сотрудников в работе", "Средний индекс активности", - "Качество данных агента", + "Достоверность данных агента", "ТОП-5 лучших подразделений", "ТОП-5 проблемных подразделений", "Требует внимания",