feat(agent): expose data quality in portal

This commit is contained in:
igor04091968
2026-06-04 11:49:14 +03:00
parent d7616fd9a9
commit 95e7c0f314
7 changed files with 362 additions and 5 deletions
@@ -171,6 +171,15 @@ impl AwWorktimePublisher {
let mut sent = 0;
let sample_seconds = 60_i64;
for session in sessions {
let ignore_for_kpi = ignored_for_kpi(record, &session);
let active_for_kpi = session.active && !ignore_for_kpi;
let state = if ignore_for_kpi {
"IgnoredForKpi"
} else if session.active {
"Active"
} else {
"Disconnected"
};
let payload = serde_json::json!({
"timestamp": record.timestamp,
"duration": sample_seconds,
@@ -180,8 +189,10 @@ impl AwWorktimePublisher {
"sessionId": session_id_number(&session),
"sessionName": session.session_type,
"sessionSource": session.session_source,
"state": if session.active { "Active" } else { "Disconnected" },
"active": session.active,
"state": state,
"active": active_for_kpi,
"ignoredForKpi": ignore_for_kpi,
"qualityNote": if ignore_for_kpi { Some("local_fallback is diagnostics-only and is not accepted as activity proof") } else { None },
"sampleSeconds": sample_seconds,
"pollSeconds": sample_seconds,
"hostname": record.hostname,
@@ -227,6 +238,11 @@ impl AwWorktimePublisher {
}
}
fn ignored_for_kpi(record: &TelemetryRecord, session: &SessionInfo) -> bool {
record.diagnostics.collector_source == "local_fallback"
|| session.session_source.as_deref() == Some("local_fallback")
}
fn ensure_aw_bucket(
client: &Client,
aw_api_base: &str,
@@ -429,6 +445,30 @@ mod tests {
assert_eq!(session_id_number(&session), 12);
}
#[test]
fn local_fallback_sessions_are_diagnostics_only_for_kpi() {
let mut wts_record = record();
wts_record.diagnostics = diagnostics_for_sessions(&[], &[], "wts_api", None);
let session = SessionInfo {
session_id: "0".to_string(),
username: "user".to_string(),
session_type: "local".to_string(),
session_source: Some("local_fallback".to_string()),
remote_addr: None,
started_at: None,
active: true,
};
assert!(ignored_for_kpi(&wts_record, &session));
let mut fallback_record = record();
fallback_record.diagnostics = diagnostics_for_sessions(&[], &[], "local_fallback", None);
let session = SessionInfo {
session_source: Some("wts_api".to_string()),
..session
};
assert!(ignored_for_kpi(&fallback_record, &session));
}
#[test]
fn worktime_publisher_is_disabled_by_default() {
assert!(AwWorktimePublisher::new(&AgentConfig::default()).is_none());
+192
View File
@@ -165,6 +165,30 @@ struct SourceStatus {
payload: Option<Value>,
}
#[derive(Clone, Debug, Serialize)]
struct AgentQuality {
collector_source: String,
#[serde(skip_serializing_if = "Option::is_none")]
collector_error: Option<String>,
sessions_collected_total: usize,
active_sessions_total: usize,
rdp_sessions_total: usize,
quality_status: String,
}
impl Default for AgentQuality {
fn default() -> Self {
Self {
collector_source: "unknown".to_string(),
collector_error: None,
sessions_collected_total: 0,
active_sessions_total: 0,
rdp_sessions_total: 0,
quality_status: "unknown".to_string(),
}
}
}
#[derive(Debug, Serialize)]
struct HealthResponse {
ok: bool,
@@ -402,6 +426,7 @@ struct Snapshot {
worktime: SourceStatus,
worktime_management: SourceStatus,
one_c: SourceStatus,
agent_quality: AgentQuality,
}
#[derive(Debug)]
@@ -935,9 +960,92 @@ fn build_snapshot(args: &Cli) -> Snapshot {
&format!("{}/api/health", args.one_c_url.trim_end_matches('/')),
timeout,
),
agent_quality: load_agent_quality(&args.telemetry_store_path),
}
}
fn load_agent_quality(path: &Path) -> AgentQuality {
let Some(payload) = latest_telemetry_record(path) else {
return AgentQuality::default();
};
agent_quality_from_record(&payload)
}
fn latest_telemetry_record(path: &Path) -> Option<Value> {
let text = fs::read_to_string(path).ok()?;
text.lines().rev().find_map(|line| {
let envelope = serde_json::from_str::<Value>(line).ok()?;
envelope.get("record").cloned().or(Some(envelope))
})
}
fn agent_quality_from_record(record: &Value) -> AgentQuality {
let Some(diagnostics) = record.get("diagnostics").and_then(Value::as_object) else {
return AgentQuality::default();
};
let collector_source = diagnostics
.get("collector_source")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("unknown")
.to_string();
let collector_error = diagnostics
.get("collector_error")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string);
AgentQuality {
sessions_collected_total: diagnostics
.get("sessions_collected_total")
.and_then(Value::as_u64)
.unwrap_or(0) as usize,
active_sessions_total: diagnostics
.get("active_sessions_total")
.and_then(Value::as_u64)
.unwrap_or(0) as usize,
rdp_sessions_total: diagnostics
.get("rdp_sessions_total")
.and_then(Value::as_u64)
.unwrap_or(0) as usize,
quality_status: agent_quality_status(&collector_source, collector_error.as_deref())
.to_string(),
collector_source,
collector_error,
}
}
fn agent_quality_status(collector_source: &str, collector_error: Option<&str>) -> &'static str {
if let Some(error) = collector_error.filter(|value| !value.trim().is_empty()) {
return if critical_collector_error(error) {
"error"
} else {
"degraded"
};
}
match collector_source {
"wts_api" => "ok",
"quser_utf16" | "quser_lossy" | "env_sessionname_fallback" => "fallback",
"local_fallback" => "degraded",
_ => "unknown",
}
}
fn critical_collector_error(error: &str) -> bool {
let lower = error.to_lowercase();
[
"access denied",
"permission",
"unauthorized",
"invalid",
"panic",
"cannot parse",
"failed to parse",
"missing required",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn command_json_source(name: &str, command: &str, timeout: Duration) -> SourceStatus {
match run_shell(command, timeout) {
Ok((stdout, stderr, success)) => {
@@ -1260,6 +1368,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 worktime = worktime_block(snapshot);
let one_c = one_c_block(snapshot);
let dlp_block_value = dlp_block(snapshot);
@@ -1288,6 +1397,10 @@ fn build_reports(
};
let executive_points = vec![
format!("Сбор данных: {}. {}", collection.status, collection.text),
format!(
"Качество данных агента: {} через {}",
agent_quality.quality_status, agent_quality.collector_source
),
format!(
"Работа сегодня: сотрудников={}, активное время={}",
metrics.users_count,
@@ -1332,6 +1445,7 @@ fn build_reports(
"executive_points": executive_points,
"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("Индекс активности", 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 за сегодня"),
@@ -1348,6 +1462,7 @@ fn build_reports(
"items": [
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("Grafana", grafana.status.clone(), grafana.text.clone()),
report_item("1C analytics", one_c.status.clone(), one_c.text.clone())
]
@@ -1396,6 +1511,7 @@ fn build_reports(
],
"ueba_risk": ueba_risk,
"ueba_baseline": ueba_baseline,
"agent_quality": agent_quality,
"workforce_policy": workforce_policy_explain,
"workforce": {
"department_comparison": department_items,
@@ -2791,6 +2907,19 @@ fn render_report_markdown(
"- Индекс активности: {}\n",
workforce_index_text(metrics.workforce_index)
));
text.push_str(&format!(
"- Качество данных агента: {} через {}\n",
snapshot.agent_quality.quality_status, snapshot.agent_quality.collector_source
));
text.push_str(&format!(
"- Сессии агента: всего={}, активные={}, RDP={}\n",
snapshot.agent_quality.sessions_collected_total,
snapshot.agent_quality.active_sessions_total,
snapshot.agent_quality.rdp_sessions_total
));
if let Some(error) = &snapshot.agent_quality.collector_error {
text.push_str(&format!("- Ошибка коллектора агента: {error}\n"));
}
text.push_str(&format!(
"- Сотрудники за сегодня: {}\n",
metrics.users_count
@@ -4676,6 +4805,57 @@ mod tests {
assert_eq!(block.status, "OK");
}
#[test]
fn agent_quality_status_prioritizes_collector_risk() {
assert_eq!(agent_quality_status("wts_api", None), "ok");
assert_eq!(agent_quality_status("quser_utf16", None), "fallback");
assert_eq!(agent_quality_status("quser_lossy", None), "fallback");
assert_eq!(
agent_quality_status("env_sessionname_fallback", None),
"fallback"
);
assert_eq!(agent_quality_status("local_fallback", None), "degraded");
assert_eq!(agent_quality_status("unknown", None), "unknown");
assert_eq!(
agent_quality_status("wts_api", Some("temporary query failure")),
"degraded"
);
assert_eq!(
agent_quality_status("wts_api", Some("access denied by WTS API")),
"error"
);
}
#[test]
fn agent_quality_defaults_to_unknown_for_old_payloads() {
let quality = agent_quality_from_record(&json!({
"agent_id": "agent-legacy",
"hostname": "HOST-EXAMPLE"
}));
assert_eq!(quality.quality_status, "unknown");
assert_eq!(quality.collector_source, "unknown");
assert_eq!(quality.sessions_collected_total, 0);
}
#[test]
fn agent_quality_loads_latest_jsonl_diagnostics() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.jsonl");
fs::write(
&path,
r#"{"record":{"agent_id":"old","diagnostics":{"collector_source":"local_fallback","sessions_collected_total":1,"active_sessions_total":1,"rdp_sessions_total":0}}}
{"record":{"agent_id":"new","diagnostics":{"collector_source":"wts_api","sessions_collected_total":3,"active_sessions_total":2,"rdp_sessions_total":2}}}
"#,
)
.unwrap();
let quality = load_agent_quality(&path);
assert_eq!(quality.quality_status, "ok");
assert_eq!(quality.collector_source, "wts_api");
assert_eq!(quality.sessions_collected_total, 3);
assert_eq!(quality.active_sessions_total, 2);
assert_eq!(quality.rdp_sessions_total, 2);
}
#[test]
fn incident_id_is_stable() {
assert_eq!(
@@ -4996,6 +5176,7 @@ mod tests {
error: None,
payload: Some(json!({"status": "ok", "companies_total": 47})),
},
agent_quality: AgentQuality::default(),
};
let evidence = DlpEvidenceResponse {
ok: true,
@@ -5080,6 +5261,13 @@ mod tests {
assert_eq!(report["workforce_policy"]["configured"], false);
assert_eq!(report["workforce"]["trend_status"], "daily_only");
assert_eq!(report["workforce"]["insights"].as_array().unwrap().len(), 1);
assert_eq!(report["agent_quality"]["quality_status"], "unknown");
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("Качество данных агента")
);
assert_eq!(
report["workforce"]["department_comparison"]
.as_array()
@@ -5154,6 +5342,7 @@ confidence:
error: None,
payload: None,
},
agent_quality: AgentQuality::default(),
};
let metrics = ReportMetrics {
users_count: 1,
@@ -5256,6 +5445,7 @@ confidence:
error: None,
payload: None,
},
agent_quality: AgentQuality::default(),
}
}
@@ -5343,6 +5533,7 @@ confidence:
error: None,
payload: None,
},
agent_quality: AgentQuality::default(),
};
let policy = WorkforcePolicy {
default_role: "accountant".to_string(),
@@ -5441,6 +5632,7 @@ confidence:
error: None,
payload: None,
},
agent_quality: AgentQuality::default(),
};
let explain = build_workforce_policy_explain(&snapshot, &policy_path, false);
assert_eq!(explain["configured"], true);
@@ -9,6 +9,8 @@
--ok-bg: #dcfce7;
--warn: #f97316;
--warn-bg: #ffedd5;
--degraded: #ea580c;
--degraded-bg: #fed7aa;
--fail: #dc2626;
--fail-bg: #fee2e2;
--unknown: #64748b;
@@ -262,6 +264,7 @@ h1 {
.status-ok { background: var(--ok-bg); color: var(--ok); }
.status-warn { background: var(--warn-bg); color: var(--warn); }
.status-degraded { background: var(--degraded-bg); color: var(--degraded); }
.status-fail { background: var(--fail-bg); color: var(--fail); }
.status-unknown { background: var(--unknown-bg); color: var(--unknown); }
.text-ok { color: var(--ok); }
@@ -504,6 +507,49 @@ h1 {
line-height: 1.35;
}
.agent-quality-card {
margin: 12px 0 16px;
}
.quality-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-top: 10px;
}
.quality-grid > div {
display: grid;
gap: 4px;
min-width: 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.quality-grid strong {
overflow-wrap: anywhere;
}
.quality-warning {
margin-top: 10px;
padding: 10px 12px;
border-left: 4px solid var(--warn);
border-radius: 8px;
background: var(--warn-bg);
color: #9a3412;
font-weight: 700;
line-height: 1.35;
}
.quality-error {
margin: 10px 0 0;
color: var(--fail);
font-weight: 700;
overflow-wrap: anywhere;
}
.heatmap-table td {
min-width: 120px;
}
@@ -24,8 +24,9 @@ async function postJson(path, payload) {
function statusClass(status) {
const s = String(status || "UNKNOWN").toLowerCase();
if (s === "ok" || s === "true") return "status-ok";
if (s === "warn" || s === "warning") return "status-warn";
if (s === "fail" || s === "false") return "status-fail";
if (s === "warn" || s === "warning" || s === "fallback") return "status-warn";
if (s === "degraded") return "status-degraded";
if (s === "fail" || s === "false" || s === "error") return "status-fail";
return "status-unknown";
}
@@ -805,6 +806,7 @@ function renderOperator(data, report) {
</div>
${renderPeriodBanner(report)}
${renderExecutiveMetrics(report, incidents)}
${renderAgentQuality(report?.agent_quality)}
${renderOverviewAnalytics(report)}
<section class="dashboard-band">
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
@@ -1334,6 +1336,32 @@ function renderUebaRisk(risk) {
`;
}
function renderAgentQuality(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());
return `
<section class="card agent-quality-card">
<div class="section-head">
<div>
<h3>Качество данных агента</h3>
<p class="muted">Доверие к источнику, который подтверждает активность и RDP-сессии.</p>
</div>
<span class="badge ${statusClass(status)}">${ui(status)}</span>
</div>
${warn ? `<div class="quality-warning">Внимание. Данные активности собраны не основным способом. Точность определения активности и RDP-сессий может быть снижена.</div>` : ""}
<div class="quality-grid">
<div><span class="muted">Источник</span><strong>${ui(source)}</strong></div>
<div><span class="muted">Сессий собрано</span><strong>${escapeHtml(q.sessions_collected_total ?? 0)}</strong></div>
<div><span class="muted">Активных сессий</span><strong>${escapeHtml(q.active_sessions_total ?? 0)}</strong></div>
<div><span class="muted">RDP-сессий</span><strong>${escapeHtml(q.rdp_sessions_total ?? 0)}</strong></div>
</div>
${q.collector_error ? `<p class="quality-error">Ошибка коллектора: ${ui(q.collector_error)}</p>` : ""}
</section>
`;
}
function renderReports(data) {
data = periodReport(data);
return `
@@ -1368,6 +1396,7 @@ function renderReports(data) {
</div>
<h3 class="section-title">Ключевые показатели</h3>
${renderKpiCards(data.kpis)}
${renderAgentQuality(data.agent_quality)}
${renderUebaRisk(data.ueba_risk)}
${renderWorkforceIndexExplanation(data.workforce_policy)}
<h3 class="section-title">Срезы отчета</h3>