diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index c42f3a9..79959c8 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -198,6 +198,27 @@ struct AgentQualityExplain { kpi_accepted: bool, } +#[derive(Clone, Debug, Serialize)] +struct AgentQualityHistoryItem { + date: String, + status: String, + source: String, + kpi_accepted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + collector_error: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +struct AgentQualityHistorySummary { + days_observed: usize, + ok_days: usize, + warning_days: usize, + degraded_days: usize, + unknown_days: usize, + kpi_accepted_days: usize, + kpi_accepted_pct: u8, +} + #[derive(Debug, Serialize)] struct HealthResponse { ok: bool, @@ -436,6 +457,8 @@ struct Snapshot { worktime_management: SourceStatus, one_c: SourceStatus, agent_quality: AgentQuality, + agent_quality_history: Vec, + agent_quality_history_summary: AgentQualityHistorySummary, } #[derive(Debug)] @@ -943,6 +966,8 @@ fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot { fn build_snapshot(args: &Cli) -> Snapshot { let timeout = Duration::from_secs(args.timeout_seconds); + let agent_quality_history = load_agent_quality_history(&args.telemetry_store_path, 7); + let agent_quality_history_summary = summarize_agent_quality_history(&agent_quality_history); Snapshot { generated_at_utc: now(), detmir_status: command_json_source("detmir_status", &args.status_cmd, timeout), @@ -970,6 +995,8 @@ fn build_snapshot(args: &Cli) -> Snapshot { timeout, ), agent_quality: load_agent_quality(&args.telemetry_store_path), + agent_quality_history, + agent_quality_history_summary, } } @@ -988,6 +1015,94 @@ fn latest_telemetry_record(path: &Path) -> Option { }) } +fn load_agent_quality_history(path: &Path, days: i64) -> Vec { + load_agent_quality_history_for_date(path, days, Utc::now().date_naive()) +} + +fn load_agent_quality_history_for_date( + path: &Path, + days: i64, + today: NaiveDate, +) -> Vec { + let Ok(text) = fs::read_to_string(path) else { + return Vec::new(); + }; + let start = today - chrono::Duration::days(days.saturating_sub(1)); + let mut by_date = BTreeMap::new(); + for line in text.lines() { + let Some((date, record)) = telemetry_record_date_and_payload(line) else { + continue; + }; + if date < start || date > today { + continue; + } + by_date.insert(date, agent_quality_history_item(date, &record)); + } + by_date.into_values().collect() +} + +fn telemetry_record_date_and_payload(line: &str) -> Option<(NaiveDate, Value)> { + let envelope = serde_json::from_str::(line).ok()?; + let record = envelope + .get("record") + .cloned() + .unwrap_or_else(|| envelope.clone()); + let timestamp = record + .get("timestamp") + .or_else(|| envelope.get("stored_at_utc")) + .and_then(Value::as_str)?; + let date = chrono::DateTime::parse_from_rfc3339(timestamp) + .ok()? + .date_naive(); + Some((date, record)) +} + +fn agent_quality_history_item(date: NaiveDate, record: &Value) -> AgentQualityHistoryItem { + let quality = agent_quality_from_record(record); + let explain = agent_quality_explain(&quality); + AgentQualityHistoryItem { + date: date.to_string(), + status: explain.status, + source: quality.collector_source, + kpi_accepted: explain.kpi_accepted, + collector_error: quality.collector_error, + } +} + +fn summarize_agent_quality_history( + history: &[AgentQualityHistoryItem], +) -> AgentQualityHistorySummary { + let days_observed = history.len(); + let ok_days = history.iter().filter(|item| item.status == "OK").count(); + let warning_days = history + .iter() + .filter(|item| item.status == "WARNING") + .count(); + let degraded_days = history + .iter() + .filter(|item| item.status == "DEGRADED") + .count(); + let unknown_days = history + .iter() + .filter(|item| item.status == "UNKNOWN") + .count(); + let kpi_accepted_days = history.iter().filter(|item| item.kpi_accepted).count(); + let kpi_accepted_pct = if days_observed == 0 { + 0 + } else { + ((kpi_accepted_days * 100) / days_observed) as u8 + }; + AgentQualityHistorySummary { + days_observed, + ok_days, + warning_days, + degraded_days, + unknown_days, + kpi_accepted_days, + kpi_accepted_pct, + } +} + fn agent_quality_from_record(record: &Value) -> AgentQuality { let Some(diagnostics) = record.get("diagnostics").and_then(Value::as_object) else { return AgentQuality::default(); @@ -1427,6 +1542,8 @@ fn build_reports( 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 agent_quality_history = snapshot.agent_quality_history.clone(); + let agent_quality_history_summary = snapshot.agent_quality_history_summary.clone(); let worktime = worktime_block(snapshot); let one_c = one_c_block(snapshot); let dlp_block_value = dlp_block(snapshot); @@ -1453,7 +1570,7 @@ fn build_reports( } else { "Контур DetMir требует технической проверки" }; - let executive_points = vec![ + let mut executive_points = vec![ format!("Сбор данных: {}. {}", collection.status, collection.text), format!( "Достоверность данных агента: {}. {}", @@ -1477,6 +1594,9 @@ fn build_reports( metrics.open_incidents, metrics.acknowledged_incidents ), ]; + if agent_quality_history_summary.ok_days < 5 { + executive_points.push("KPI требует валидации: нестабильный сбор данных агента".to_string()); + } let recommendations = owner_recommendations(snapshot, &summary); let workforce_summary = ReportWorkforceSummary { departments_count: department_items.len(), @@ -1573,6 +1693,8 @@ fn build_reports( "ueba_baseline": ueba_baseline, "agent_quality": agent_quality, "agent_quality_explain": agent_quality_explain, + "agent_quality_history": agent_quality_history, + "agent_quality_history_summary": agent_quality_history_summary, "workforce_policy": workforce_policy_explain, "workforce": { "department_comparison": department_items, @@ -3016,6 +3138,11 @@ fn render_report_markdown( text.push_str(&format!("- {item}\n")); } append_agent_quality_markdown(&mut text, &snapshot.agent_quality); + append_agent_quality_history_markdown( + &mut text, + &snapshot.agent_quality_history, + &snapshot.agent_quality_history_summary, + ); 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"); @@ -3045,6 +3172,43 @@ fn append_agent_quality_markdown(text: &mut String, quality: &AgentQuality) { text.push_str(&format!("- Рекомендация: {}\n", explain.recommendation)); } +fn append_agent_quality_history_markdown( + text: &mut String, + history: &[AgentQualityHistoryItem], + summary: &AgentQualityHistorySummary, +) { + text.push_str("\n## Стабильность данных за период\n\n"); + text.push_str(&format!("- Дней с данными: {}\n", summary.days_observed)); + text.push_str(&format!("- OK дней: {}\n", summary.ok_days)); + text.push_str(&format!( + "- WARNING/DEGRADED/UNKNOWN дней: {}\n", + summary.warning_days + summary.degraded_days + summary.unknown_days + )); + text.push_str(&format!( + "- KPI принят: {}% дней\n", + summary.kpi_accepted_pct + )); + if history.is_empty() { + text.push_str("- История качества агента за период отсутствует.\n"); + return; + } + for item in history { + let error = item + .collector_error + .as_ref() + .map(|value| format!(", error={value}")) + .unwrap_or_default(); + text.push_str(&format!( + "- {}: status={}, source={}, KPI={}{}\n", + item.date, + item.status, + item.source, + if item.kpi_accepted { "да" } else { "нет" }, + error + )); + } +} + fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { text.push_str("\n## UEBA риск\n\n"); text.push_str(&format!( @@ -4993,6 +5157,101 @@ mod tests { assert!(markdown.contains("Ошибка коллектора: temporary WTS failure")); } + fn telemetry_line(date: &str, source: Option<&str>, error: Option<&str>) -> String { + let diagnostics = source.map(|collector_source| { + json!({ + "collector_source": collector_source, + "collector_error": error, + "sessions_collected_total": 3, + "active_sessions_total": 2, + "rdp_sessions_total": 1 + }) + }); + serde_json::to_string(&json!({ + "record": { + "agent_id": "agent-1", + "hostname": "HOST-EXAMPLE", + "timestamp": format!("{date}T10:00:00Z"), + "diagnostics": diagnostics + } + })) + .unwrap() + } + + fn write_history(lines: Vec) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("telemetry.jsonl"); + fs::write(&path, lines.join("\n")).unwrap(); + (dir, path) + } + + #[test] + fn agent_quality_history_counts_seven_ok_days() { + let (_dir, path) = write_history( + (1..=7) + .map(|day| telemetry_line(&format!("2026-06-0{day}"), Some("wts_api"), None)) + .collect(), + ); + let today = NaiveDate::from_ymd_opt(2026, 6, 7).unwrap(); + let history = load_agent_quality_history_for_date(&path, 7, today); + let summary = summarize_agent_quality_history(&history); + assert_eq!(history.len(), 7); + assert_eq!(summary.ok_days, 7); + assert_eq!(summary.degraded_days, 0); + assert_eq!(summary.kpi_accepted_pct, 100); + } + + #[test] + fn agent_quality_history_counts_mixed_statuses() { + let (_dir, path) = write_history(vec![ + telemetry_line("2026-06-01", Some("wts_api"), None), + telemetry_line("2026-06-02", Some("local_fallback"), None), + telemetry_line("2026-06-03", Some("quser_utf16"), None), + telemetry_line("2026-06-04", None, None), + ]); + let today = NaiveDate::from_ymd_opt(2026, 6, 4).unwrap(); + let history = load_agent_quality_history_for_date(&path, 7, today); + let summary = summarize_agent_quality_history(&history); + assert_eq!(summary.days_observed, 4); + assert_eq!(summary.ok_days, 1); + assert_eq!(summary.warning_days, 1); + assert_eq!(summary.degraded_days, 1); + assert_eq!(summary.unknown_days, 1); + assert_eq!(summary.kpi_accepted_days, 2); + assert_eq!(summary.kpi_accepted_pct, 50); + } + + #[test] + fn agent_quality_history_handles_absent_history() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("missing.jsonl"); + let today = NaiveDate::from_ymd_opt(2026, 6, 4).unwrap(); + let history = load_agent_quality_history_for_date(&path, 7, today); + let summary = summarize_agent_quality_history(&history); + assert!(history.is_empty()); + assert_eq!(summary.days_observed, 0); + assert_eq!(summary.kpi_accepted_pct, 0); + } + + #[test] + fn agent_quality_history_preserves_collector_errors() { + let (_dir, path) = write_history(vec![telemetry_line( + "2026-06-04", + Some("wts_api"), + Some("collector timeout"), + )]); + let today = NaiveDate::from_ymd_opt(2026, 6, 4).unwrap(); + let history = load_agent_quality_history_for_date(&path, 7, today); + let summary = summarize_agent_quality_history(&history); + assert_eq!(history[0].status, "DEGRADED"); + assert_eq!( + history[0].collector_error.as_deref(), + Some("collector timeout") + ); + assert_eq!(summary.degraded_days, 1); + assert_eq!(summary.kpi_accepted_days, 0); + } + #[test] fn incident_id_is_stable() { assert_eq!( @@ -5314,6 +5573,8 @@ mod tests { payload: Some(json!({"status": "ok", "companies_total": 47})), }, agent_quality: AgentQuality::default(), + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), }; let evidence = DlpEvidenceResponse { ok: true, @@ -5480,6 +5741,8 @@ confidence: payload: None, }, agent_quality: AgentQuality::default(), + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), }; let metrics = ReportMetrics { users_count: 1, @@ -5583,6 +5846,8 @@ confidence: payload: None, }, agent_quality: AgentQuality::default(), + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), } } @@ -5671,6 +5936,8 @@ confidence: payload: None, }, agent_quality: AgentQuality::default(), + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), }; let policy = WorkforcePolicy { default_role: "accountant".to_string(), @@ -5770,6 +6037,8 @@ confidence: payload: None, }, agent_quality: AgentQuality::default(), + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), }; let explain = build_workforce_policy_explain(&snapshot, &policy_path, false); assert_eq!(explain["configured"], true); diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index c87f88a..61e66f9 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -507,7 +507,8 @@ h1 { line-height: 1.35; } -.agent-quality-card { +.agent-quality-card, +.agent-quality-history-card { margin: 12px 0 16px; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 7876a21..8efb6a2 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -807,6 +807,7 @@ function renderOperator(data, report) { ${renderPeriodBanner(report)} ${renderExecutiveMetrics(report, incidents)} ${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)} + ${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)} ${renderOverviewAnalytics(report)}

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

загрузка, простои, перегруз и дисциплина процессов
@@ -1413,6 +1414,40 @@ function renderAgentQuality(quality, explain) { `; } +function renderAgentQualityHistory(history, summary) { + const items = Array.isArray(history) ? history : []; + const s = summary || {}; + const unstableDays = Number(s.warning_days || 0) + Number(s.degraded_days || 0) + Number(s.unknown_days || 0); + const status = items.length === 0 ? "UNKNOWN" : (Number(s.ok_days || 0) >= 5 ? "OK" : "WARNING"); + return ` +
+
+
+

Стабильность агента за 7 дней

+

Показывает, можно ли доверять недельному KPI, а не только текущему срезу.

+
+ ${ui(status)} +
+
+
OK дней${escapeHtml(s.ok_days ?? 0)}
+
Проблемных дней${escapeHtml(unstableDays)}
+
KPI принят${escapeHtml(s.kpi_accepted_pct ?? 0)}%
+
+ ${Number(s.ok_days || 0) < 5 ? `
KPI требует валидации: нестабильный сбор данных агента.
` : ""} +
+ История по дням +
${items.length ? items.map(item => ` +
+ ${escapeHtml(item.date || "-")} + source=${ui(item.source || "unknown")} · KPI=${item.kpi_accepted ? "да" : "нет"}${item.collector_error ? ` · ${ui(item.collector_error)}` : ""} + ${ui(item.status || "UNKNOWN")} +
+ `).join("") : `
ИсторияИстория качества агента за период отсутствует.UNKNOWN
`}
+
+
+ `; +} + function renderReports(data) { data = periodReport(data); return ` @@ -1448,6 +1483,7 @@ function renderReports(data) {

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

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

Срезы отчета

diff --git a/docs/PORTAL_RU.md b/docs/PORTAL_RU.md index 15551ef..ce396bd 100644 --- a/docs/PORTAL_RU.md +++ b/docs/PORTAL_RU.md @@ -68,6 +68,34 @@ API сохраняет старое поле `agent_quality` и дополнит agent и проверить поступление telemetry JSONL. Если источник `local_fallback`, портал прямо пишет: `Диагностический режим, данные не засчитываются в KPI`. +## Стабильность агента за 7 дней + +Портал показывает мини-блок `Стабильность агента за 7 дней`. Он отвечает на +вопрос, можно ли доверять недельному KPI, а не только текущему срезу. + +`GET /api/reports` отдает: + +- `agent_quality_history` - последняя запись качества по каждому дню за + 7-дневное окно; +- `agent_quality_history_summary` - сводка по дням. + +Элемент `agent_quality_history` содержит: + +- `date`; +- `status`; +- `source`; +- `kpi_accepted`; +- `collector_error`, если он есть. + +Сводка считает: + +- сколько дней были `OK`; +- сколько дней были `WARNING`, `DEGRADED` или `UNKNOWN`; +- процент дней, когда KPI был принят. + +Если за 7 дней меньше 5 дней `OK`, портал и executive summary показывают: +`KPI требует валидации: нестабильный сбор данных агента`. + ## Risk -> Investigation Кнопка `Открыть расследование` переводит пользователя в read-only карточку. diff --git a/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md b/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md index 401f41e..73885a7 100644 --- a/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md +++ b/docs/WINDOWS_RUST_AGENT_WORKTIME_RU.md @@ -105,6 +105,10 @@ aw_worktime_enabled = true подтверждать RDP-активность. В портале для этого режима выводится текст: `Диагностический режим, данные не засчитываются в KPI`. +Портал также строит `agent_quality_history` за 7 дней из telemetry JSONL: +последняя запись каждого дня превращается в статус доверия к KPI. Если за +неделю меньше 5 дней `OK`, недельный KPI считается требующим валидации. + ## PowerShell Legacy Fallback В `deployment-config.json` используется блок: diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 1848fb5..4b2795e 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -121,6 +121,7 @@ async function main() { "Сотрудников в работе", "Средний индекс активности", "Достоверность данных агента", + "Стабильность агента за 7 дней", "ТОП-5 лучших подразделений", "ТОП-5 проблемных подразделений", "Требует внимания",