feat(portal): add agent quality history
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<AgentQualityHistoryItem>,
|
||||
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<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn load_agent_quality_history(path: &Path, days: i64) -> Vec<AgentQualityHistoryItem> {
|
||||
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<AgentQualityHistoryItem> {
|
||||
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::<Value>(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<String>) -> (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);
|
||||
|
||||
@@ -507,7 +507,8 @@ h1 {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.agent-quality-card {
|
||||
.agent-quality-card,
|
||||
.agent-quality-history-card {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
<section class="dashboard-band">
|
||||
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
|
||||
@@ -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 `
|
||||
<section class="card agent-quality-history-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>Стабильность агента за 7 дней</h3>
|
||||
<p class="muted">Показывает, можно ли доверять недельному KPI, а не только текущему срезу.</p>
|
||||
</div>
|
||||
<span class="badge ${statusClass(status)}">${ui(status)}</span>
|
||||
</div>
|
||||
<div class="quality-decision">
|
||||
<div><span class="muted">OK дней</span><strong>${escapeHtml(s.ok_days ?? 0)}</strong></div>
|
||||
<div><span class="muted">Проблемных дней</span><strong>${escapeHtml(unstableDays)}</strong></div>
|
||||
<div><span class="muted">KPI принят</span><strong>${escapeHtml(s.kpi_accepted_pct ?? 0)}%</strong></div>
|
||||
</div>
|
||||
${Number(s.ok_days || 0) < 5 ? `<div class="quality-warning">KPI требует валидации: нестабильный сбор данных агента.</div>` : ""}
|
||||
<details class="quality-details">
|
||||
<summary>История по дням</summary>
|
||||
<div class="list compact-list">${items.length ? items.map(item => `
|
||||
<div class="row compact-row">
|
||||
<strong>${escapeHtml(item.date || "-")}</strong>
|
||||
<span class="muted">source=${ui(item.source || "unknown")} · KPI=${item.kpi_accepted ? "да" : "нет"}${item.collector_error ? ` · ${ui(item.collector_error)}` : ""}</span>
|
||||
<span class="badge ${statusClass(item.status)}">${ui(item.status || "UNKNOWN")}</span>
|
||||
</div>
|
||||
`).join("") : `<div class="row compact-row"><strong>История</strong><span class="muted">История качества агента за период отсутствует.</span><span class="badge status-unknown">UNKNOWN</span></div>`}</div>
|
||||
</details>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderReports(data) {
|
||||
data = periodReport(data);
|
||||
return `
|
||||
@@ -1448,6 +1483,7 @@ function renderReports(data) {
|
||||
<h3 class="section-title">Ключевые показатели</h3>
|
||||
${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)}
|
||||
<h3 class="section-title">Срезы отчета</h3>
|
||||
|
||||
Reference in New Issue
Block a user