refactor(portal): split hardening and kpi modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::now;
|
||||
|
||||
pub(crate) fn build_healthz() -> Value {
|
||||
json!({
|
||||
"status": "ok",
|
||||
"generated_at_utc": now(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::NaiveDate;
|
||||
use serde_json::{Value, json};
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
use crate::{
|
||||
Cli, MAX_ALLOWED_PAGE_SIZE, MAX_ALLOWED_REPORT_DATE_RANGE_DAYS, MAX_ALLOWED_REQUEST_BODY_BYTES,
|
||||
MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS, query_param,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ApiLimitError {
|
||||
pub(crate) status: StatusCode,
|
||||
pub(crate) payload: Value,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
|
||||
let (host, port) = args
|
||||
.bind
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| anyhow!("invalid config bind: expected host:port"))?;
|
||||
if host.trim().is_empty() {
|
||||
return Err(anyhow!("invalid config host: value is empty"));
|
||||
}
|
||||
let port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| anyhow!("invalid config port: expected 1..65535"))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("invalid config port: expected 1..65535"));
|
||||
}
|
||||
if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE {
|
||||
return Err(anyhow!(
|
||||
"invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}"
|
||||
));
|
||||
}
|
||||
if args.default_page_size == 0 || args.default_page_size > args.max_page_size {
|
||||
return Err(anyhow!(
|
||||
"invalid config default_page_size: expected 1..=max_page_size"
|
||||
));
|
||||
}
|
||||
if args.max_report_date_range_days <= 0
|
||||
|| args.max_report_date_range_days > MAX_ALLOWED_REPORT_DATE_RANGE_DAYS
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid config max_report_date_range_days: expected 1..={MAX_ALLOWED_REPORT_DATE_RANGE_DAYS}"
|
||||
));
|
||||
}
|
||||
if args.request_timeout_seconds == 0
|
||||
|| args.request_timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid config request_timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
|
||||
));
|
||||
}
|
||||
if args.timeout_seconds == 0 || args.timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS {
|
||||
return Err(anyhow!(
|
||||
"invalid config timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
|
||||
));
|
||||
}
|
||||
if args.max_request_body_bytes < 1024
|
||||
|| args.max_request_body_bytes > MAX_ALLOWED_REQUEST_BODY_BYTES
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}"
|
||||
));
|
||||
}
|
||||
if !is_safe_environment_name(&args.environment) {
|
||||
return Err(anyhow!(
|
||||
"invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -"
|
||||
));
|
||||
}
|
||||
let modules = enabled_modules(&args.enabled_modules);
|
||||
if modules.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"invalid config enabled_modules: no modules enabled"
|
||||
));
|
||||
}
|
||||
let allowed = [
|
||||
"executive",
|
||||
"workforce",
|
||||
"security",
|
||||
"forensics",
|
||||
"admin",
|
||||
"ueba",
|
||||
"pfsense",
|
||||
"reports",
|
||||
];
|
||||
for module in modules {
|
||||
if !allowed.contains(&module.as_str()) {
|
||||
return Err(anyhow!(
|
||||
"invalid config enabled_modules: unsupported module {module}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_safe_environment_name(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
!value.is_empty()
|
||||
&& value.len() <= 32
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
|
||||
}
|
||||
|
||||
fn enabled_modules(value: &str) -> BTreeSet<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|item| item.trim().to_ascii_lowercase())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_limited_api_route(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/api/reports"
|
||||
| "/api/executive"
|
||||
| "/api/workforce"
|
||||
| "/api/security"
|
||||
| "/api/forensics"
|
||||
| "/api/ueba"
|
||||
| "/api/pfsense"
|
||||
| "/api/workforce/kpi/explain"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_api_query_limits(
|
||||
url: &str,
|
||||
args: &Cli,
|
||||
) -> std::result::Result<(), ApiLimitError> {
|
||||
for key in ["page_size", "limit"] {
|
||||
if let Some(value) = query_param(url, key) {
|
||||
let parsed = value.parse::<u32>().ok();
|
||||
if parsed.is_none_or(|page_size| page_size == 0 || page_size > args.max_page_size) {
|
||||
return Err(api_limit_error(
|
||||
StatusCode(400),
|
||||
"invalid_page_size",
|
||||
&format!("{key} must be between 1 and {}", args.max_page_size),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (from_key, to_key) in [("date_from", "date_to"), ("from", "to"), ("start", "end")] {
|
||||
let Some(from) = query_param(url, from_key).and_then(|value| parse_query_date(&value))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(to) = query_param(url, to_key).and_then(|value| parse_query_date(&value)) else {
|
||||
continue;
|
||||
};
|
||||
let days = (to - from).num_days().abs() + 1;
|
||||
if days > args.max_report_date_range_days {
|
||||
return Err(api_limit_error(
|
||||
StatusCode(400),
|
||||
"report_range_too_large",
|
||||
&format!(
|
||||
"report date range must be <= {} days",
|
||||
args.max_report_date_range_days
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_query_date(value: &str) -> Option<NaiveDate> {
|
||||
let date = value.split('T').next().unwrap_or(value);
|
||||
NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
fn api_limit_error(status: StatusCode, code: &str, message: &str) -> ApiLimitError {
|
||||
ApiLimitError {
|
||||
status,
|
||||
payload: json!({
|
||||
"ok": false,
|
||||
"error_code": code,
|
||||
"message": message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
DEFAULT_MAX_PAGE_SIZE, DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, DEFAULT_MAX_REQUEST_BODY_BYTES,
|
||||
DEFAULT_PAGE_SIZE, DEFAULT_REQUEST_TIMEOUT_SECONDS, DEFAULT_SLOW_REQUEST_LOG_MS,
|
||||
};
|
||||
|
||||
fn test_cli(dir: &Path) -> Cli {
|
||||
Cli {
|
||||
bind: "127.0.0.1:8720".to_string(),
|
||||
status_cmd: "true".to_string(),
|
||||
check_cmd: "true".to_string(),
|
||||
failed_units_cmd: "true".to_string(),
|
||||
worktime_url: "http://127.0.0.1".to_string(),
|
||||
one_c_url: "http://127.0.0.1".to_string(),
|
||||
workforce_policy_path: dir.join("workforce-policy.json"),
|
||||
ueba_policy_path: dir.join("ueba-policy.yaml"),
|
||||
timeout_seconds: 1,
|
||||
max_page_size: DEFAULT_MAX_PAGE_SIZE,
|
||||
default_page_size: DEFAULT_PAGE_SIZE,
|
||||
max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS,
|
||||
request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
||||
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
|
||||
slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS,
|
||||
environment: "test".to_string(),
|
||||
enabled_modules: "executive,workforce,security,forensics,admin".to_string(),
|
||||
state_dir: dir.join("state"),
|
||||
dlp_db_path: dir.join("dlp.sqlite"),
|
||||
evidence_root: dir.to_path_buf(),
|
||||
readiness_bundle_dir: dir.join("readiness-bundle"),
|
||||
evidence_limit: 10,
|
||||
evidence_max_bytes: 1024,
|
||||
json_smoke: false,
|
||||
evidence_only: false,
|
||||
evidence_upload_token: None,
|
||||
telemetry_api_key: "test-key".to_string(),
|
||||
telemetry_store_path: dir.join("telemetry.jsonl"),
|
||||
expected_nodes_path: dir.join("expected_nodes.json"),
|
||||
security_events_backend: "disabled".to_string(),
|
||||
clickhouse_url: "http://127.0.0.1:8123".to_string(),
|
||||
clickhouse_database: "analytics_1c".to_string(),
|
||||
clickhouse_user: "default".to_string(),
|
||||
clickhouse_password: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_bad_values() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args = test_cli(dir.path());
|
||||
assert!(validate_portal_config(&args).is_ok());
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.bind = "127.0.0.1:bad".to_string();
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("port")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.max_page_size = 0;
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("max_page_size")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.max_report_date_range_days = 0;
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("max_report_date_range_days")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.request_timeout_seconds = 0;
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("request_timeout_seconds")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_limits_reject_page_size_and_report_range() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args = test_cli(dir.path());
|
||||
assert!(validate_api_query_limits("/api/reports?page_size=100", &args).is_ok());
|
||||
let page_error =
|
||||
validate_api_query_limits("/api/reports?page_size=999999", &args).unwrap_err();
|
||||
assert_eq!(page_error.status.0, 400);
|
||||
assert_eq!(page_error.payload["error_code"], "invalid_page_size");
|
||||
|
||||
let range_error = validate_api_query_limits(
|
||||
"/api/reports?date_from=2026-01-01&date_to=2026-12-31",
|
||||
&args,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(range_error.status.0, 400);
|
||||
assert_eq!(range_error.payload["error_code"], "report_range_too_large");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde_json::{Value, json};
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
use super::request_context::HttpRequestMetadata;
|
||||
use crate::now;
|
||||
|
||||
pub(crate) fn log_http_request(
|
||||
metadata: &HttpRequestMetadata,
|
||||
status: StatusCode,
|
||||
response_bytes: usize,
|
||||
) {
|
||||
let level = if status.0 >= 500 {
|
||||
"ERROR"
|
||||
} else if status.0 >= 400 {
|
||||
"WARN"
|
||||
} else {
|
||||
"INFO"
|
||||
};
|
||||
let error_code = if status.0 >= 400 {
|
||||
Value::String(format!("http_{}", status.0))
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
eprintln!(
|
||||
"{}",
|
||||
json!({
|
||||
"timestamp": now(),
|
||||
"level": level,
|
||||
"request_id": &metadata.request_id,
|
||||
"correlation_id": &metadata.correlation_id,
|
||||
"method": &metadata.method,
|
||||
"path": &metadata.path,
|
||||
"route": &metadata.route,
|
||||
"status": status.0,
|
||||
"latency_ms": metadata.latency_ms,
|
||||
"user_role": &metadata.role,
|
||||
"module": &metadata.module,
|
||||
"error_code": error_code,
|
||||
"response_bytes": response_bytes,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as FmtWrite;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
use super::readiness::build_readyz;
|
||||
use super::request_context::HttpRequestMetadata;
|
||||
use crate::Cli;
|
||||
|
||||
static PORTAL_METRICS: OnceLock<Mutex<PortalMetrics>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct HttpMetricKey {
|
||||
method: String,
|
||||
route: String,
|
||||
status: u16,
|
||||
module: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct HttpMetricValue {
|
||||
requests_total: u64,
|
||||
duration_seconds_sum: f64,
|
||||
duration_seconds_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct PortalMetrics {
|
||||
http: BTreeMap<HttpMetricKey, HttpMetricValue>,
|
||||
reports_generated_total: u64,
|
||||
ingestion_records_total: u64,
|
||||
ingestion_rejected_total: u64,
|
||||
role_denied_total: u64,
|
||||
}
|
||||
|
||||
fn portal_metrics() -> &'static Mutex<PortalMetrics> {
|
||||
PORTAL_METRICS.get_or_init(|| Mutex::new(PortalMetrics::default()))
|
||||
}
|
||||
|
||||
pub(crate) fn record_http_metric(metadata: &HttpRequestMetadata, status: StatusCode) {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
let entry = metrics
|
||||
.http
|
||||
.entry(HttpMetricKey {
|
||||
method: metadata.method.clone(),
|
||||
route: metadata.route.clone(),
|
||||
status: status.0,
|
||||
module: metadata.module.clone(),
|
||||
})
|
||||
.or_default();
|
||||
entry.requests_total = entry.requests_total.saturating_add(1);
|
||||
entry.duration_seconds_sum += metadata.latency_ms as f64 / 1_000.0;
|
||||
entry.duration_seconds_count = entry.duration_seconds_count.saturating_add(1);
|
||||
if status.0 == 403 {
|
||||
metrics.role_denied_total = metrics.role_denied_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_report_generated() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.reports_generated_total = metrics.reports_generated_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_ingestion_accepted() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.ingestion_records_total = metrics.ingestion_records_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_ingestion_rejected() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.ingestion_rejected_total = metrics.ingestion_rejected_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_prometheus_metrics(args: &Cli) -> String {
|
||||
let mut text = String::new();
|
||||
let ready_value = if build_readyz(args)
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("not_ready")
|
||||
== "ready"
|
||||
{
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# HELP awatch_http_requests_total HTTP requests handled by AWatch-rus portal"
|
||||
)
|
||||
.ok();
|
||||
writeln!(&mut text, "# TYPE awatch_http_requests_total counter").ok();
|
||||
let metrics = portal_metrics()
|
||||
.lock()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default();
|
||||
for (key, value) in &metrics.http {
|
||||
writeln!(
|
||||
&mut text,
|
||||
"awatch_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
|
||||
prom_escape(&key.method),
|
||||
prom_escape(&key.route),
|
||||
key.status,
|
||||
prom_escape(&key.module),
|
||||
value.requests_total
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# HELP awatch_http_request_duration_seconds HTTP request duration in seconds"
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# TYPE awatch_http_request_duration_seconds summary"
|
||||
)
|
||||
.ok();
|
||||
for (key, value) in &metrics.http {
|
||||
writeln!(
|
||||
&mut text,
|
||||
"awatch_http_request_duration_seconds_sum{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {:.6}",
|
||||
prom_escape(&key.method),
|
||||
prom_escape(&key.route),
|
||||
key.status,
|
||||
prom_escape(&key.module),
|
||||
value.duration_seconds_sum
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
&mut text,
|
||||
"awatch_http_request_duration_seconds_count{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
|
||||
prom_escape(&key.method),
|
||||
prom_escape(&key.route),
|
||||
key.status,
|
||||
prom_escape(&key.module),
|
||||
value.duration_seconds_count
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
for (name, help, value) in [
|
||||
(
|
||||
"awatch_reports_generated_total",
|
||||
"Reports generated by the portal",
|
||||
metrics.reports_generated_total,
|
||||
),
|
||||
(
|
||||
"awatch_ingestion_records_total",
|
||||
"Telemetry ingestion records accepted",
|
||||
metrics.ingestion_records_total,
|
||||
),
|
||||
(
|
||||
"awatch_ingestion_rejected_total",
|
||||
"Telemetry ingestion records rejected",
|
||||
metrics.ingestion_rejected_total,
|
||||
),
|
||||
(
|
||||
"awatch_role_denied_total",
|
||||
"Requests denied by role gates",
|
||||
metrics.role_denied_total,
|
||||
),
|
||||
] {
|
||||
writeln!(&mut text, "# HELP {name} {help}").ok();
|
||||
writeln!(&mut text, "# TYPE {name} counter").ok();
|
||||
writeln!(&mut text, "{name} {value}").ok();
|
||||
}
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# HELP awatch_readyz_status Portal readiness status, 1=ready, 0=not_ready"
|
||||
)
|
||||
.ok();
|
||||
writeln!(&mut text, "# TYPE awatch_readyz_status gauge").ok();
|
||||
writeln!(&mut text, "awatch_readyz_status {ready_value}").ok();
|
||||
text
|
||||
}
|
||||
|
||||
fn prom_escape(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
pub(crate) mod health;
|
||||
pub(crate) mod limits;
|
||||
pub(crate) mod logging;
|
||||
pub(crate) mod metrics;
|
||||
pub(crate) mod readiness;
|
||||
pub(crate) mod request_context;
|
||||
pub(crate) mod version;
|
||||
|
||||
pub(crate) use health::build_healthz;
|
||||
pub(crate) use limits::{is_limited_api_route, validate_api_query_limits, validate_portal_config};
|
||||
pub(crate) use logging::log_http_request;
|
||||
pub(crate) use metrics::{
|
||||
record_http_metric, record_ingestion_accepted, record_ingestion_rejected,
|
||||
record_report_generated, render_prometheus_metrics,
|
||||
};
|
||||
pub(crate) use readiness::build_readyz;
|
||||
pub(crate) use request_context::{http_request_metadata, mark_request_started};
|
||||
pub(crate) use version::build_version;
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::limits::validate_portal_config;
|
||||
use crate::{Cli, now};
|
||||
|
||||
pub(crate) fn build_readyz(args: &Cli) -> Value {
|
||||
let config_ok = validate_portal_config(args).is_ok();
|
||||
let storage_status = storage_readiness_status(&args.state_dir);
|
||||
let telemetry_status = storage_parent_readiness_status(&args.telemetry_store_path);
|
||||
let evidence_status = storage_readiness_status(&args.evidence_root);
|
||||
let ready = config_ok && !matches!(storage_status.as_str(), "error");
|
||||
json!({
|
||||
"status": if ready { "ready" } else { "not_ready" },
|
||||
"generated_at_utc": now(),
|
||||
"checks": {
|
||||
"config": if config_ok { "ok" } else { "error" },
|
||||
"storage": storage_status,
|
||||
"telemetry_store": telemetry_status,
|
||||
"evidence_storage": evidence_status,
|
||||
"pfsense": "contract_only",
|
||||
"security_events": security_events_readiness_status(args),
|
||||
"clickhouse": if args.security_events_backend == "clickhouse" { "configured" } else { "not_required" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn storage_readiness_status(path: &Path) -> String {
|
||||
if path.exists() {
|
||||
if path.is_dir() {
|
||||
"ok".to_string()
|
||||
} else {
|
||||
"error".to_string()
|
||||
}
|
||||
} else {
|
||||
"not_configured".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_parent_readiness_status(path: &Path) -> String {
|
||||
path.parent()
|
||||
.map(storage_readiness_status)
|
||||
.unwrap_or_else(|| "not_configured".to_string())
|
||||
}
|
||||
|
||||
fn security_events_readiness_status(args: &Cli) -> &'static str {
|
||||
match args
|
||||
.security_events_backend
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"disabled" | "" => "disabled",
|
||||
"clickhouse" => "configured",
|
||||
_ => "configured",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tiny_http::Request;
|
||||
|
||||
use crate::{
|
||||
normalize_path, parse_case_path, parse_case_status_path, parse_evidence_screenshot_path,
|
||||
parse_investigation_pack_path, portal_role_from_request,
|
||||
};
|
||||
|
||||
static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
thread_local! {
|
||||
static REQUEST_STARTED_AT: RefCell<Option<Instant>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct HttpRequestMetadata {
|
||||
pub(crate) method: String,
|
||||
pub(crate) path: String,
|
||||
pub(crate) route: String,
|
||||
pub(crate) module: String,
|
||||
pub(crate) role: String,
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) correlation_id: String,
|
||||
pub(crate) latency_ms: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn mark_request_started() {
|
||||
REQUEST_STARTED_AT.with(|started| {
|
||||
*started.borrow_mut() = Some(Instant::now());
|
||||
});
|
||||
}
|
||||
|
||||
fn request_latency_ms() -> u64 {
|
||||
REQUEST_STARTED_AT.with(|started| {
|
||||
started
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|instant| instant.elapsed().as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn http_request_metadata(request: &Request) -> HttpRequestMetadata {
|
||||
let raw_url = request.url().to_string();
|
||||
let path = normalize_path(&raw_url);
|
||||
let method = request.method().as_str().to_string();
|
||||
let route = metrics_route(&path);
|
||||
let module = metrics_module(&route).to_string();
|
||||
let role = portal_role_from_request(request, &raw_url)
|
||||
.as_str()
|
||||
.to_string();
|
||||
let request_id =
|
||||
request_header(request, "X-Request-Id").or_else(|| request_header(request, "X-Request-ID"));
|
||||
let correlation_id = request_header(request, "X-Correlation-Id")
|
||||
.or_else(|| request_header(request, "X-Correlation-ID"));
|
||||
let (request_id, correlation_id) = resolve_request_ids(request_id, correlation_id);
|
||||
HttpRequestMetadata {
|
||||
method,
|
||||
path,
|
||||
route,
|
||||
module,
|
||||
role,
|
||||
request_id,
|
||||
correlation_id,
|
||||
latency_ms: request_latency_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_header(request: &Request, name: &str) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|header| header.field.to_string().eq_ignore_ascii_case(name))
|
||||
.map(|header| header.value.as_str().to_string())
|
||||
}
|
||||
|
||||
fn resolve_request_ids(
|
||||
request_id: Option<String>,
|
||||
correlation_id: Option<String>,
|
||||
) -> (String, String) {
|
||||
let request_id = request_id
|
||||
.map(sanitize_request_token)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(generate_request_id);
|
||||
let correlation_id = correlation_id
|
||||
.map(sanitize_request_token)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| request_id.clone());
|
||||
(request_id, correlation_id)
|
||||
}
|
||||
|
||||
fn sanitize_request_token(value: String) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':'))
|
||||
.take(96)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_request_id() -> String {
|
||||
let seq = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("awatch-{millis}-{seq}")
|
||||
}
|
||||
|
||||
fn metrics_route(path: &str) -> String {
|
||||
if parse_investigation_pack_path(path).is_some() {
|
||||
return "/api/investigation-pack/{candidate_id}".to_string();
|
||||
}
|
||||
if parse_case_path(path).is_some() {
|
||||
return "/api/cases/{case_id}".to_string();
|
||||
}
|
||||
if parse_case_status_path(path).is_some() {
|
||||
return "/api/cases/{case_id}/status".to_string();
|
||||
}
|
||||
if parse_evidence_screenshot_path(path).is_some() {
|
||||
return "/api/dlp/evidence/{evidence_id}/asset".to_string();
|
||||
}
|
||||
match path {
|
||||
"/" | "/operator" | "/manager" | "/owner" | "/incidents" | "/reports" | "/architecture" => {
|
||||
path.to_string()
|
||||
}
|
||||
"/healthz" | "/api/healthz" => "/healthz".to_string(),
|
||||
"/readyz" | "/api/readyz" => "/readyz".to_string(),
|
||||
"/version" | "/api/version" => "/version".to_string(),
|
||||
"/metrics" | "/api/metrics" => "/metrics".to_string(),
|
||||
_ if path.starts_with("/api/") => path.to_string(),
|
||||
_ => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn metrics_module(route: &str) -> &'static str {
|
||||
if route.starts_with("/healthz")
|
||||
|| route.starts_with("/readyz")
|
||||
|| route.starts_with("/version")
|
||||
|| route.starts_with("/metrics")
|
||||
{
|
||||
"runtime"
|
||||
} else if route.contains("/workforce") || route == "/manager" {
|
||||
"workforce"
|
||||
} else if route.contains("/security")
|
||||
|| route.contains("/incidents")
|
||||
|| route.contains("/incident-review")
|
||||
{
|
||||
"security"
|
||||
} else if route.contains("/forensics")
|
||||
|| route.contains("/investigation-pack")
|
||||
|| route.contains("/cases")
|
||||
|| route.contains("/dlp/evidence")
|
||||
{
|
||||
"forensics"
|
||||
} else if route.contains("/ueba") {
|
||||
"ueba"
|
||||
} else if route.contains("/pfsense") {
|
||||
"pfsense"
|
||||
} else if route.contains("/reports") || route.contains("/executive") || route == "/operator" {
|
||||
"reports"
|
||||
} else {
|
||||
"portal"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_id_and_correlation_id_are_sanitized_and_linked() {
|
||||
let (request_id, correlation_id) = resolve_request_ids(
|
||||
Some(" req-123\nbad ".to_string()),
|
||||
Some("corr-456/ignored".to_string()),
|
||||
);
|
||||
assert_eq!(request_id, "req-123bad");
|
||||
assert_eq!(correlation_id, "corr-456ignored");
|
||||
|
||||
let (request_id, correlation_id) =
|
||||
resolve_request_ids(Some("rid_1".to_string()), Some(" \n ".to_string()));
|
||||
assert_eq!(request_id, "rid_1");
|
||||
assert_eq!(correlation_id, "rid_1");
|
||||
|
||||
let (generated, generated_correlation) = resolve_request_ids(None, None);
|
||||
assert!(generated.starts_with("awatch-"));
|
||||
assert_eq!(generated, generated_correlation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Cli, PORTAL_SCHEMA_VERSION};
|
||||
|
||||
pub(crate) fn build_version(args: &Cli) -> Value {
|
||||
json!({
|
||||
"app_version": env!("CARGO_PKG_VERSION"),
|
||||
"git_commit": option_env!("GIT_COMMIT").unwrap_or("unknown"),
|
||||
"build_time": option_env!("BUILD_TIME").unwrap_or("unknown"),
|
||||
"schema_version": PORTAL_SCHEMA_VERSION,
|
||||
"environment": args.environment,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::production::limits::parse_query_date;
|
||||
use crate::{
|
||||
PortalRole, Snapshot, display_text_opt, query_param, role_envelope, trend_status,
|
||||
workforce_index, workforce_index_status, workforce_trend_json, worktime_totals,
|
||||
};
|
||||
|
||||
struct KpiFactorInputs<'a> {
|
||||
users_count: usize,
|
||||
active_seconds: i64,
|
||||
apps_count: usize,
|
||||
kpi_score: u8,
|
||||
agent_coverage_percent: u8,
|
||||
missing_sources: &'a [String],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct KpiExplainQuery {
|
||||
date: Option<String>,
|
||||
department: Option<String>,
|
||||
owner: Option<String>,
|
||||
}
|
||||
|
||||
impl KpiExplainQuery {
|
||||
pub(crate) fn from_url(url: &str) -> Self {
|
||||
Self {
|
||||
date: query_param(url, "date").filter(|value| parse_query_date(value).is_some()),
|
||||
department: query_param(url, "department"),
|
||||
owner: query_param(url, "owner"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_workforce_kpi_explain(
|
||||
snapshot: &Snapshot,
|
||||
policy_explain: &Value,
|
||||
role: PortalRole,
|
||||
query: &KpiExplainQuery,
|
||||
anonymize: bool,
|
||||
) -> Value {
|
||||
let (users_count, active_seconds, apps_count) = worktime_totals(snapshot);
|
||||
let base_index = workforce_index(users_count, active_seconds);
|
||||
let policy_index = policy_explain
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value.min(100) as u8);
|
||||
let kpi_score = policy_index.or(base_index).unwrap_or(0);
|
||||
let agent_coverage_percent = kpi_agent_coverage(snapshot);
|
||||
let data_freshness = kpi_data_freshness(snapshot);
|
||||
let missing_sources = kpi_missing_sources(snapshot, users_count, apps_count);
|
||||
let confidence = kpi_confidence(
|
||||
users_count,
|
||||
agent_coverage_percent,
|
||||
&data_freshness,
|
||||
&missing_sources,
|
||||
);
|
||||
let factors = kpi_explain_factors(
|
||||
snapshot,
|
||||
policy_explain,
|
||||
KpiFactorInputs {
|
||||
users_count,
|
||||
active_seconds,
|
||||
apps_count,
|
||||
kpi_score,
|
||||
agent_coverage_percent,
|
||||
missing_sources: &missing_sources,
|
||||
},
|
||||
);
|
||||
let top_applications = kpi_top_applications(snapshot, anonymize);
|
||||
let warnings = kpi_warnings(kpi_score, confidence, &data_freshness, &missing_sources);
|
||||
let recommendations = kpi_recommendations(confidence, &missing_sources, kpi_score);
|
||||
let mut payload = json!({
|
||||
"ok": true,
|
||||
"scope": "aggregate",
|
||||
"role_context": role_envelope(role, "workforce_kpi_explain"),
|
||||
"query": {
|
||||
"date": query.date.clone(),
|
||||
"department": query.department.clone(),
|
||||
"owner": query.owner.clone(),
|
||||
"employee_id_supported": false
|
||||
},
|
||||
"kpi_score": kpi_score,
|
||||
"kpi_status": workforce_index_status(Some(kpi_score)),
|
||||
"confidence": confidence,
|
||||
"coverage": {
|
||||
"agent_coverage_percent": agent_coverage_percent,
|
||||
"data_freshness": data_freshness,
|
||||
"missing_sources": missing_sources,
|
||||
},
|
||||
"factors": factors,
|
||||
"top_applications": top_applications,
|
||||
"warnings": warnings,
|
||||
"recommendations": recommendations,
|
||||
"formula": "rule_based: activity + business app usage - idle/afterhours/missing data with coverage confidence",
|
||||
"model": {
|
||||
"type": "rule_based",
|
||||
"ml": false,
|
||||
"llm": false,
|
||||
"version": "workforce-kpi-explain-v1"
|
||||
},
|
||||
"generated_at_utc": snapshot.generated_at_utc,
|
||||
});
|
||||
filter_kpi_explain_for_role(&mut payload, role);
|
||||
payload
|
||||
}
|
||||
|
||||
fn kpi_agent_coverage(snapshot: &Snapshot) -> u8 {
|
||||
if snapshot.agent_coverage_sla.expected_nodes > 0 {
|
||||
snapshot.agent_coverage_sla.coverage_pct
|
||||
} else if snapshot.agent_quality.sessions_collected_total > 0 {
|
||||
75
|
||||
} else if snapshot.worktime.ok {
|
||||
60
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn kpi_data_freshness(snapshot: &Snapshot) -> String {
|
||||
if snapshot.worktime.status.eq_ignore_ascii_case("OK")
|
||||
&& snapshot
|
||||
.worktime_management
|
||||
.status
|
||||
.eq_ignore_ascii_case("OK")
|
||||
{
|
||||
"fresh".to_string()
|
||||
} else if snapshot.worktime.status.eq_ignore_ascii_case("DEGRADED")
|
||||
|| snapshot
|
||||
.worktime_management
|
||||
.status
|
||||
.eq_ignore_ascii_case("DEGRADED")
|
||||
|| snapshot
|
||||
.worktime
|
||||
.summary
|
||||
.to_ascii_lowercase()
|
||||
.contains("stale")
|
||||
|| snapshot
|
||||
.worktime_management
|
||||
.summary
|
||||
.to_ascii_lowercase()
|
||||
.contains("stale")
|
||||
{
|
||||
"stale".to_string()
|
||||
} else {
|
||||
"missing".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn kpi_missing_sources(snapshot: &Snapshot, users_count: usize, apps_count: usize) -> Vec<String> {
|
||||
let mut missing = Vec::new();
|
||||
if !snapshot.worktime.ok || users_count == 0 {
|
||||
missing.push("worktime".to_string());
|
||||
}
|
||||
if !snapshot.worktime_management.ok {
|
||||
missing.push("worktime_management".to_string());
|
||||
}
|
||||
if apps_count == 0 {
|
||||
missing.push("applications".to_string());
|
||||
}
|
||||
if snapshot.agent_coverage_sla.expected_nodes > 0
|
||||
&& snapshot.agent_coverage_sla.coverage_pct < 50
|
||||
{
|
||||
missing.push("agent_coverage".to_string());
|
||||
}
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
missing
|
||||
}
|
||||
|
||||
fn kpi_confidence(
|
||||
users_count: usize,
|
||||
agent_coverage_percent: u8,
|
||||
data_freshness: &str,
|
||||
missing_sources: &[String],
|
||||
) -> &'static str {
|
||||
if users_count == 0
|
||||
|| agent_coverage_percent < 50
|
||||
|| data_freshness == "missing"
|
||||
|| missing_sources.iter().any(|item| item == "worktime")
|
||||
{
|
||||
"low"
|
||||
} else if agent_coverage_percent < 80
|
||||
|| data_freshness != "fresh"
|
||||
|| !missing_sources.is_empty()
|
||||
{
|
||||
"medium"
|
||||
} else {
|
||||
"high"
|
||||
}
|
||||
}
|
||||
|
||||
fn kpi_explain_factors(
|
||||
snapshot: &Snapshot,
|
||||
policy_explain: &Value,
|
||||
inputs: KpiFactorInputs<'_>,
|
||||
) -> Vec<Value> {
|
||||
let planned_seconds = (inputs.users_count as i64).saturating_mul(8 * 3600);
|
||||
let idle_ratio = if planned_seconds > 0 {
|
||||
((planned_seconds - inputs.active_seconds).max(0) as f64 / planned_seconds as f64)
|
||||
.clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let weighted_apps = policy_explain
|
||||
.get("matched_applications")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let afterhours_seconds = kpi_afterhours_seconds(snapshot);
|
||||
let remote_sessions = snapshot.agent_quality.rdp_sessions_total as u64;
|
||||
let trend = trend_status(&workforce_trend_json(snapshot));
|
||||
|
||||
vec![
|
||||
kpi_factor(
|
||||
"productive_activity",
|
||||
"Полезная активность",
|
||||
positive_impact((inputs.kpi_score as i64 * 40) / 100),
|
||||
if inputs.kpi_score >= 80 {
|
||||
"Высокая доля активности относительно планового рабочего времени"
|
||||
} else if inputs.kpi_score >= 60 {
|
||||
"Активность близка к рабочему уровню, но есть просадка"
|
||||
} else {
|
||||
"Активность ниже ожидаемого рабочего уровня"
|
||||
},
|
||||
),
|
||||
kpi_factor(
|
||||
"business_app_usage",
|
||||
"Рабочие приложения",
|
||||
positive_impact(
|
||||
((weighted_apps.max(inputs.apps_count as u64).min(12) as i64) * 2).min(24),
|
||||
),
|
||||
if weighted_apps > 0 {
|
||||
"В данных есть приложения, попавшие под рабочие правила"
|
||||
} else if inputs.apps_count > 0 {
|
||||
"Есть активность по приложениям, но правила рабочих приложений требуют настройки"
|
||||
} else {
|
||||
"Данных о рабочих приложениях нет"
|
||||
},
|
||||
),
|
||||
kpi_factor(
|
||||
"idle_time",
|
||||
"Простой",
|
||||
negative_impact((idle_ratio * 30.0).round() as i64),
|
||||
if idle_ratio > 0.35 {
|
||||
"Есть значимые периоды неактивности в рабочее время"
|
||||
} else {
|
||||
"Простой не является основным фактором снижения индекса"
|
||||
},
|
||||
),
|
||||
kpi_factor(
|
||||
"afterhours_activity",
|
||||
"Активность вне рабочего времени",
|
||||
negative_impact((afterhours_seconds / 3600).min(12)),
|
||||
if afterhours_seconds > 0 {
|
||||
"Есть признаки активности за пределами рабочего окна"
|
||||
} else {
|
||||
"Существенная активность вне рабочего времени не выявлена"
|
||||
},
|
||||
),
|
||||
kpi_factor(
|
||||
"remote_session_activity",
|
||||
"Удаленные сессии",
|
||||
positive_impact((remote_sessions.min(5) * 2) as i64),
|
||||
if remote_sessions > 0 {
|
||||
"RDP/удаленные сессии подтверждают источник активности"
|
||||
} else {
|
||||
"Удаленные сессии не подтверждены текущим срезом"
|
||||
},
|
||||
),
|
||||
kpi_factor(
|
||||
"data_coverage",
|
||||
"Полнота данных",
|
||||
if inputs.agent_coverage_percent >= 80 {
|
||||
positive_impact(12)
|
||||
} else {
|
||||
negative_impact(
|
||||
((80_u8.saturating_sub(inputs.agent_coverage_percent) as i64) / 4).max(1),
|
||||
)
|
||||
},
|
||||
if inputs.agent_coverage_percent >= 80 {
|
||||
"Покрытие данных достаточно для уверенного управленческого вывода"
|
||||
} else {
|
||||
"Покрытие данных снижает доверие к индексу"
|
||||
},
|
||||
),
|
||||
kpi_factor(
|
||||
"missing_data",
|
||||
"Отсутствующие данные",
|
||||
negative_impact((inputs.missing_sources.len() as i64 * 8).min(32)),
|
||||
if inputs.missing_sources.is_empty() {
|
||||
"Критичных пропусков источников не выявлено"
|
||||
} else {
|
||||
"Есть пропуски источников, влияющие на надежность KPI"
|
||||
},
|
||||
),
|
||||
kpi_factor(
|
||||
"trend_change",
|
||||
"Изменение тренда",
|
||||
match trend.as_str() {
|
||||
"monthly" | "weekly" => positive_impact(6),
|
||||
"daily_only" => negative_impact(2),
|
||||
_ => "0".to_string(),
|
||||
},
|
||||
match trend.as_str() {
|
||||
"monthly" => "Есть месячная история для оценки тренда",
|
||||
"weekly" => "Есть недельная история для оценки тренда",
|
||||
"daily_only" => "Доступен только дневной срез, исторический тренд ограничен",
|
||||
_ => "История тренда пока не накоплена",
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn kpi_factor(name: &str, label: &str, impact: String, explanation: &str) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"label": label,
|
||||
"impact": impact,
|
||||
"explanation": explanation,
|
||||
})
|
||||
}
|
||||
|
||||
fn positive_impact(value: i64) -> String {
|
||||
format!("+{}", value.max(0))
|
||||
}
|
||||
|
||||
fn negative_impact(value: i64) -> String {
|
||||
if value <= 0 {
|
||||
"0".to_string()
|
||||
} else {
|
||||
format!("-{value}")
|
||||
}
|
||||
}
|
||||
|
||||
fn kpi_afterhours_seconds(snapshot: &Snapshot) -> i64 {
|
||||
snapshot
|
||||
.worktime_management
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| payload.get("department_rollups"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let total = item
|
||||
.get("calendar_total_active_seconds")
|
||||
.or_else(|| item.get("total_active_seconds"))
|
||||
.and_then(Value::as_i64)?;
|
||||
let workday = item
|
||||
.get("workday_total_active_seconds")
|
||||
.or_else(|| item.get("active_seconds"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(total);
|
||||
Some((total - workday).max(0))
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn kpi_top_applications(snapshot: &Snapshot, anonymize: bool) -> Vec<Value> {
|
||||
let Some(apps) = snapshot
|
||||
.worktime
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| payload.get("true_active_apps"))
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut items = apps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, app)| {
|
||||
let raw_name = app.get("application").and_then(Value::as_str)?;
|
||||
let seconds = app
|
||||
.get("proved_work_seconds")
|
||||
.or_else(|| app.get("active_seconds"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
if seconds == 0 {
|
||||
return None;
|
||||
}
|
||||
let name = if anonymize {
|
||||
format!("Приложение {}", idx + 1)
|
||||
} else {
|
||||
display_text_opt(Some(raw_name), &format!("Приложение {}", idx + 1))
|
||||
};
|
||||
let category = kpi_application_category(raw_name);
|
||||
let contribution = if category == "business" {
|
||||
"positive"
|
||||
} else {
|
||||
"neutral"
|
||||
};
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"category": category,
|
||||
"active_minutes": seconds / 60,
|
||||
"contribution": contribution,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by_key(|item| {
|
||||
-item
|
||||
.get("active_minutes")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
items.truncate(8);
|
||||
items
|
||||
}
|
||||
|
||||
fn kpi_application_category(name: &str) -> &'static str {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let lower_ru = name.to_lowercase();
|
||||
if lower.contains("1c")
|
||||
|| lower_ru.contains("1с")
|
||||
|| lower.contains("erp")
|
||||
|| lower.contains("sap")
|
||||
|| lower.contains("excel")
|
||||
|| lower.contains("office")
|
||||
|| lower.contains("word")
|
||||
{
|
||||
"business"
|
||||
} else if lower.contains("browser")
|
||||
|| lower.contains("chrome")
|
||||
|| lower.contains("edge")
|
||||
|| lower.contains("firefox")
|
||||
{
|
||||
"mixed"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
fn kpi_warnings(
|
||||
kpi_score: u8,
|
||||
confidence: &str,
|
||||
data_freshness: &str,
|
||||
missing_sources: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
if confidence == "low" {
|
||||
warnings
|
||||
.push("Низкое доверие к KPI: данных недостаточно для уверенного вывода.".to_string());
|
||||
}
|
||||
if data_freshness != "fresh" {
|
||||
warnings.push(format!("Свежесть данных: {data_freshness}."));
|
||||
}
|
||||
for source in missing_sources {
|
||||
warnings.push(format!("Не хватает источника данных: {source}."));
|
||||
}
|
||||
if kpi_score < 60 {
|
||||
warnings.push("Индекс активности ниже рабочего ориентира.".to_string());
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
fn kpi_recommendations(confidence: &str, missing_sources: &[String], kpi_score: u8) -> Vec<String> {
|
||||
let mut recommendations = Vec::new();
|
||||
if !missing_sources.is_empty() {
|
||||
recommendations
|
||||
.push("Проверить свежесть источников и восстановить пропущенные данные.".to_string());
|
||||
}
|
||||
if confidence != "high" {
|
||||
recommendations.push(
|
||||
"Перед управленческим выводом проверить покрытие данных рабочих мест.".to_string(),
|
||||
);
|
||||
}
|
||||
if kpi_score < 60 {
|
||||
recommendations
|
||||
.push("Проверить подразделения или ответственных с низкой активностью.".to_string());
|
||||
}
|
||||
if recommendations.is_empty() {
|
||||
recommendations.push("Использовать KPI как агрегированный управленческий индикатор, не как персональную HR-оценку.".to_string());
|
||||
}
|
||||
recommendations
|
||||
}
|
||||
|
||||
fn filter_kpi_explain_for_role(payload: &mut Value, role: PortalRole) {
|
||||
match role {
|
||||
PortalRole::Executive | PortalRole::Manager => {
|
||||
payload["scope_note"] = json!("Агрегированный Workforce KPI без персональных деталей.");
|
||||
}
|
||||
PortalRole::Security => {
|
||||
payload["scope_note"] =
|
||||
json!("ИБ видит только факторы, релевантные риску и надежности данных.");
|
||||
filter_kpi_factors(
|
||||
payload,
|
||||
&[
|
||||
"afterhours_activity",
|
||||
"remote_session_activity",
|
||||
"data_coverage",
|
||||
"missing_data",
|
||||
"trend_change",
|
||||
],
|
||||
);
|
||||
payload["top_applications"] = Value::Array(Vec::new());
|
||||
}
|
||||
PortalRole::Forensics => {
|
||||
payload["scope_note"] = json!(
|
||||
"Расследования видят только контекст надежности данных и временных отклонений."
|
||||
);
|
||||
filter_kpi_factors(
|
||||
payload,
|
||||
&["afterhours_activity", "data_coverage", "missing_data"],
|
||||
);
|
||||
payload["top_applications"] = Value::Array(Vec::new());
|
||||
}
|
||||
PortalRole::Admin => {
|
||||
payload["scope_note"] =
|
||||
json!("Администратор видит техническое покрытие источников и rule-based факторы.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_kpi_factors(payload: &mut Value, allowed: &[&str]) {
|
||||
if let Some(factors) = payload.get_mut("factors").and_then(Value::as_array_mut) {
|
||||
factors.retain(|item| {
|
||||
item.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|name| allowed.contains(&name))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
AgentCoverageSla, AgentQuality, AgentQualityHistorySummary, AgentQualityNodesSummary,
|
||||
SecurityEventsSummary, SourceStatus,
|
||||
};
|
||||
|
||||
fn kpi_snapshot(
|
||||
worktime_ok: bool,
|
||||
coverage_pct: u8,
|
||||
active_seconds: i64,
|
||||
apps: Vec<Value>,
|
||||
) -> Snapshot {
|
||||
let rows = if active_seconds > 0 {
|
||||
json!([
|
||||
{"user": "USER-1", "user_id": "EMP-1", "active_seconds": active_seconds}
|
||||
])
|
||||
} else {
|
||||
json!([])
|
||||
};
|
||||
Snapshot {
|
||||
generated_at_utc: "2026-06-07T10:00:00Z".to_string(),
|
||||
detmir_status: SourceStatus {
|
||||
ok: true,
|
||||
status: "OK".to_string(),
|
||||
summary: String::new(),
|
||||
error: None,
|
||||
payload: None,
|
||||
},
|
||||
detmir_check: SourceStatus {
|
||||
ok: true,
|
||||
status: "OK".to_string(),
|
||||
summary: String::new(),
|
||||
error: None,
|
||||
payload: None,
|
||||
},
|
||||
failed_units: SourceStatus {
|
||||
ok: true,
|
||||
status: "OK".to_string(),
|
||||
summary: String::new(),
|
||||
error: None,
|
||||
payload: None,
|
||||
},
|
||||
worktime: SourceStatus {
|
||||
ok: worktime_ok,
|
||||
status: if worktime_ok { "OK" } else { "FAIL" }.to_string(),
|
||||
summary: String::new(),
|
||||
error: None,
|
||||
payload: worktime_ok.then(|| {
|
||||
json!({
|
||||
"rows": rows,
|
||||
"true_active_apps": apps
|
||||
})
|
||||
}),
|
||||
},
|
||||
worktime_management: SourceStatus {
|
||||
ok: worktime_ok,
|
||||
status: if worktime_ok { "OK" } else { "FAIL" }.to_string(),
|
||||
summary: String::new(),
|
||||
error: None,
|
||||
payload: worktime_ok.then(|| {
|
||||
json!({
|
||||
"department_rollups": [
|
||||
{
|
||||
"name": "DEPT-1",
|
||||
"users_count": 1,
|
||||
"active_users": 1,
|
||||
"portfolio_coverage_pct": coverage_pct,
|
||||
"workday_total_active_seconds": active_seconds,
|
||||
"calendar_total_active_seconds": active_seconds
|
||||
}
|
||||
],
|
||||
"trend": [
|
||||
{"report_date": "2026-06-06", "portfolio_coverage_pct": coverage_pct},
|
||||
{"report_date": "2026-06-07", "portfolio_coverage_pct": coverage_pct}
|
||||
]
|
||||
})
|
||||
}),
|
||||
},
|
||||
one_c: SourceStatus {
|
||||
ok: true,
|
||||
status: "OK".to_string(),
|
||||
summary: String::new(),
|
||||
error: None,
|
||||
payload: None,
|
||||
},
|
||||
one_c_overview: SourceStatus {
|
||||
ok: true,
|
||||
status: "OK".to_string(),
|
||||
summary: String::new(),
|
||||
error: None,
|
||||
payload: None,
|
||||
},
|
||||
agent_quality: AgentQuality {
|
||||
collector_source: "awatch-agent-rs".to_string(),
|
||||
collector_error: None,
|
||||
sessions_collected_total: if worktime_ok { 1 } else { 0 },
|
||||
active_sessions_total: if worktime_ok { 1 } else { 0 },
|
||||
rdp_sessions_total: if worktime_ok { 1 } else { 0 },
|
||||
quality_status: if worktime_ok { "OK" } else { "UNKNOWN" }.to_string(),
|
||||
},
|
||||
agent_quality_history: Vec::new(),
|
||||
agent_quality_history_summary: AgentQualityHistorySummary::default(),
|
||||
agent_quality_nodes: Vec::new(),
|
||||
agent_quality_nodes_summary: AgentQualityNodesSummary::default(),
|
||||
agent_coverage_sla: AgentCoverageSla {
|
||||
expected_nodes: 1,
|
||||
reporting_nodes_24h: if worktime_ok { 1 } else { 0 },
|
||||
stale_nodes: 0,
|
||||
missing_nodes: if worktime_ok { 0 } else { 1 },
|
||||
coverage_pct,
|
||||
freshness_pct: coverage_pct,
|
||||
sla_status: if coverage_pct >= 80 { "OK" } else { "CRITICAL" }.to_string(),
|
||||
problem_nodes: Vec::new(),
|
||||
},
|
||||
security_events_summary: SecurityEventsSummary::disabled(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confidence_is_low_when_required_data_is_missing() {
|
||||
let snapshot = kpi_snapshot(false, 0, 0, Vec::new());
|
||||
let explain = build_workforce_kpi_explain(
|
||||
&snapshot,
|
||||
&json!({"configured": false}),
|
||||
PortalRole::Executive,
|
||||
&KpiExplainQuery::default(),
|
||||
false,
|
||||
);
|
||||
assert_eq!(explain["confidence"], "low");
|
||||
assert_eq!(explain["kpi_score"], 0);
|
||||
assert!(
|
||||
explain["warnings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item.as_str().unwrap().contains("Низкое доверие"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factors_are_deterministic_and_role_filtered() {
|
||||
let snapshot = kpi_snapshot(
|
||||
true,
|
||||
95,
|
||||
8 * 3600,
|
||||
vec![json!({"application": "1C", "proved_work_seconds": 6 * 3600})],
|
||||
);
|
||||
let policy = json!({
|
||||
"configured": true,
|
||||
"index": 82,
|
||||
"matched_applications": 1
|
||||
});
|
||||
let explain = build_workforce_kpi_explain(
|
||||
&snapshot,
|
||||
&policy,
|
||||
PortalRole::Executive,
|
||||
&KpiExplainQuery::default(),
|
||||
false,
|
||||
);
|
||||
assert_eq!(explain["kpi_score"], 82);
|
||||
assert_eq!(explain["confidence"], "high");
|
||||
let factors = explain["factors"].as_array().unwrap();
|
||||
assert_eq!(factors.len(), 8);
|
||||
assert_eq!(factors[0]["name"], "productive_activity");
|
||||
assert!(
|
||||
factors
|
||||
.iter()
|
||||
.any(|item| item["name"] == "business_app_usage")
|
||||
);
|
||||
assert!(
|
||||
explain["top_applications"][0]["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("1C")
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_string(&explain)
|
||||
.unwrap()
|
||||
.find("EMP-1")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let security = build_workforce_kpi_explain(
|
||||
&snapshot,
|
||||
&policy,
|
||||
PortalRole::Security,
|
||||
&KpiExplainQuery::default(),
|
||||
false,
|
||||
);
|
||||
assert_eq!(security["top_applications"].as_array().unwrap().len(), 0);
|
||||
assert!(
|
||||
security["factors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|item| item["name"] != "productive_activity")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user