Files
AWatch-rus/adk-rust/crates/worktime-api/src/main.rs
T

2586 lines
96 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
fs,
io::Cursor,
path::PathBuf,
sync::{Arc, Mutex},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, anyhow};
use chrono::{
DateTime, Datelike, FixedOffset, Local, NaiveDate, SecondsFormat, TimeDelta, TimeZone, Utc,
};
use clap::Parser;
use reqwest::{
blocking::Client,
header::{CONNECTION, HeaderMap, HeaderValue},
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use url::form_urlencoded;
const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600";
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
#[derive(Debug, Parser)]
#[command(about = "AW Worktime Report API")]
struct Cli {
#[arg(long)]
once: bool,
}
#[derive(Debug, Clone)]
struct Config {
aw_api_base: String,
ioc_dir: PathBuf,
default_host: String,
default_sample_seconds: f64,
max_sample_seconds: f64,
listen_host: String,
listen_port: u16,
workday_start_hour: u32,
workday_end_hour: u32,
manager_target_coverage_pct: i64,
manager_low_coverage_pct: i64,
manager_late_start_grace_minutes: i64,
manager_early_finish_grace_minutes: i64,
manager_critical_source_max_age_seconds: i64,
manager_web_source_max_age_seconds: i64,
manager_session_source_max_age_seconds: i64,
manager_infra_source_max_age_seconds: i64,
manager_aliases_json: PathBuf,
manager_exclude_users: BTreeSet<String>,
events_cache_ttl_seconds: i64,
worktime_events_limit: usize,
aw_http_timeout_seconds: f64,
source_http_timeout_seconds: f64,
report_cache_ttl_seconds: i64,
report_stale_ttl_seconds: i64,
report_disk_cache_dir: PathBuf,
report_disk_stale_ttl_seconds: i64,
true_active_evidence_window_seconds: i64,
true_active_max_event_seconds: i64,
offset: FixedOffset,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct AwEvent {
timestamp: Option<String>,
#[serde(default)]
duration: Option<f64>,
#[serde(default)]
data: Value,
}
#[derive(Debug, Clone)]
struct UserBucket {
user: String,
user_id: String,
samples_count: i64,
active_samples: i64,
session_ids: BTreeSet<String>,
intervals: Vec<(DateTime<Utc>, DateTime<Utc>)>,
}
#[derive(Debug, Clone)]
struct AliasProfile {
display_name: String,
manager_owner: String,
department: String,
role: String,
notes: String,
canonical_user_id: String,
exclude: bool,
}
#[derive(Debug, Clone)]
struct CachedResponse {
stored: Instant,
data: Vec<u8>,
content_type: String,
}
type EventsCache = Arc<Mutex<HashMap<String, (Instant, Vec<AwEvent>)>>>;
type ReportCache = Arc<Mutex<HashMap<String, CachedResponse>>>;
type IdentitySamples = BTreeMap<(String, String), Vec<(DateTime<Utc>, AwEvent)>>;
type DateBounds = (DateTime<Utc>, DateTime<Utc>);
type EventsForDate = (DateBounds, Vec<AwEvent>);
#[derive(Clone)]
struct App {
config: Arc<Config>,
aw: Client,
events_cache: EventsCache,
report_cache: ReportCache,
}
fn env(name: &str, fallback: &str) -> String {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| fallback.to_string())
}
fn env_f64(name: &str, fallback: f64) -> f64 {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse().ok())
.filter(|value| *value > 0.0)
.unwrap_or(fallback)
}
fn env_i64(name: &str, fallback: i64) -> i64 {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse().ok())
.unwrap_or(fallback)
}
fn env_usize(name: &str, fallback: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse().ok())
.filter(|value| *value > 0)
.unwrap_or(fallback)
}
fn build_aw_api_base(raw_url: &str) -> String {
let url = raw_url.trim().trim_end_matches('/');
if url.ends_with("/api/0") {
url.to_string()
} else {
format!("{url}/api/0")
}
}
fn load_config() -> Config {
let default_sample_seconds = env_f64("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", 30.0).max(1.0);
let max_sample_seconds =
env_f64("AW_WORKTIME_MAX_SAMPLE_SECONDS", 300.0).max(default_sample_seconds);
let manager_target_coverage_pct =
env_i64("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", 75).clamp(1, 100);
let manager_low_coverage_pct =
env_i64("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", 35).clamp(1, 100);
let report_cache_ttl_seconds = env_i64("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", 60).max(0);
let report_stale_ttl_seconds =
env_i64("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", 900).max(report_cache_ttl_seconds);
Config {
aw_api_base: build_aw_api_base(&env("AW_SERVER_URL", DEFAULT_AW_URL)),
ioc_dir: PathBuf::from(env("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output")),
default_host: env("AW_WORKTIME_HOST", DEFAULT_HOST),
default_sample_seconds,
max_sample_seconds,
listen_host: env("AW_WORKTIME_LISTEN_HOST", "0.0.0.0"),
listen_port: env("AW_WORKTIME_PORT", "5610").parse().unwrap_or(5610),
workday_start_hour: env_i64("AW_WORKTIME_MANAGER_START_HOUR", 9).clamp(0, 23) as u32,
workday_end_hour: env_i64("AW_WORKTIME_MANAGER_END_HOUR", 18).clamp(0, 23) as u32,
manager_target_coverage_pct,
manager_low_coverage_pct,
manager_late_start_grace_minutes: env_i64(
"AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES",
60,
)
.max(0),
manager_early_finish_grace_minutes: env_i64(
"AW_WORKTIME_MANAGER_EARLY_FINISH_GRACE_MINUTES",
90,
)
.max(0),
manager_critical_source_max_age_seconds: env_i64(
"AW_WORKTIME_MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS",
900,
)
.max(60),
manager_web_source_max_age_seconds: env_i64(
"AW_WORKTIME_MANAGER_WEB_SOURCE_MAX_AGE_SECONDS",
259200,
)
.max(3600),
manager_session_source_max_age_seconds: env_i64(
"AW_WORKTIME_MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS",
604800,
)
.max(3600),
manager_infra_source_max_age_seconds: env_i64(
"AW_WORKTIME_MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS",
172800,
)
.max(3600),
manager_aliases_json: PathBuf::from(env(
"AW_WORKTIME_MANAGER_ALIASES_JSON",
"/etc/activitywatch/worktime-manager-aliases.json",
)),
manager_exclude_users: env("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "")
.split(',')
.map(|item| item.trim().to_lowercase())
.filter(|item| !item.is_empty())
.collect(),
events_cache_ttl_seconds: env_i64("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", 30).max(0),
worktime_events_limit: env_usize("AW_WORKTIME_EVENTS_LIMIT", 50_000).max(1000),
aw_http_timeout_seconds: env_f64("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", 8.0).max(0.5),
source_http_timeout_seconds: env_f64("AW_WORKTIME_SOURCE_HTTP_TIMEOUT_SECONDS", 1.5)
.max(0.25),
report_cache_ttl_seconds,
report_stale_ttl_seconds,
report_disk_cache_dir: PathBuf::from(env(
"AW_WORKTIME_REPORT_DISK_CACHE_DIR",
"/var/lib/activitywatch/worktime-report-cache",
)),
report_disk_stale_ttl_seconds: env_i64("AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS", 86400)
.max(report_stale_ttl_seconds),
true_active_evidence_window_seconds: env_i64(
"AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS",
180,
)
.max(30),
true_active_max_event_seconds: env_i64("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", 600)
.max(30),
offset: FixedOffset::east_opt(3 * 3600).expect("valid Moscow offset"),
}
}
impl App {
fn new(config: Config) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(CONNECTION, HeaderValue::from_static("close"));
let aw = Client::builder()
.timeout(Duration::from_secs_f64(config.aw_http_timeout_seconds))
.no_proxy()
.pool_max_idle_per_host(0)
.default_headers(headers)
.build()
.context("build AW HTTP client")?;
Ok(Self {
config: Arc::new(config),
aw,
events_cache: Arc::new(Mutex::new(HashMap::new())),
report_cache: Arc::new(Mutex::new(HashMap::new())),
})
}
fn aw_get_json_once(&self, path: &str) -> Result<Value> {
self.aw_get_json_with(path, 1, None)
}
fn aw_get_json_with(
&self,
path: &str,
attempts: usize,
timeout: Option<Duration>,
) -> Result<Value> {
let url = format!("{}{}", self.config.aw_api_base, path);
let mut last_error = None;
for attempt in 1..=attempts.max(1) {
let mut request = self.aw.get(&url);
if let Some(timeout) = timeout {
request = request.timeout(timeout);
}
match request.send() {
Ok(response) => {
if !response.status().is_success() {
return Err(anyhow!("AW {path} returned HTTP {}", response.status()));
}
return response
.json()
.with_context(|| format!("decode AW JSON {path}"));
}
Err(error) => {
let is_timeout = error.is_timeout();
last_error = Some(error);
if is_timeout {
break;
}
if attempt < attempts {
std::thread::sleep(Duration::from_millis(250));
}
}
}
}
Err(last_error.expect("request failed")).with_context(|| format!("request AW {path}"))
}
fn fetch_bucket_events(
&self,
bucket_id: &str,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
) -> Vec<AwEvent> {
match self.fetch_bucket_events_result(bucket_id, start, end) {
Ok(events) => events,
Err(error) => {
eprintln!(
"[aw-worktime-api-rust] events fetch failed bucket={bucket_id}: {error:#}"
);
Vec::new()
}
}
}
fn fetch_bucket_events_result(
&self,
bucket_id: &str,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
) -> Result<Vec<AwEvent>> {
let mut key = bucket_id.to_string();
let query_limit = self.config.worktime_events_limit.min(5_000);
let params = [format!("limit={query_limit}")];
if let (Some(start), Some(end)) = (start, end) {
let s = to_iso_utc(start);
let e = to_iso_utc(end);
key = format!("{bucket_id}|{s}|{e}");
}
if self.config.events_cache_ttl_seconds > 0 {
if let Some((stored, events)) = self
.events_cache
.lock()
.ok()
.and_then(|cache| cache.get(&key).cloned())
{
if stored.elapsed().as_secs() <= self.config.events_cache_ttl_seconds as u64 {
return Ok(events);
}
}
}
let path = format!("/buckets/{bucket_id}/events?{}", params.join("&"));
let mut events: Vec<AwEvent> = serde_json::from_value(self.aw_get_json_once(&path)?)
.with_context(|| format!("decode AW events for {bucket_id}"))?;
if let (Some(start), Some(end)) = (start, end) {
events.retain(|event| {
event
.timestamp
.as_deref()
.and_then(parse_iso_utc)
.is_some_and(|ts| ts >= start && ts <= end)
});
}
if self.config.events_cache_ttl_seconds > 0 {
if let Ok(mut cache) = self.events_cache.lock() {
cache.insert(key, (Instant::now(), events.clone()));
}
}
Ok(events)
}
fn latest_bucket_event(&self, bucket_id: &str) -> Option<AwEvent> {
let bucket = self
.aw_get_json_with(
&format!("/buckets/{bucket_id}"),
1,
Some(Duration::from_secs_f64(
self.config.source_http_timeout_seconds,
)),
)
.ok()?;
let end = bucket.pointer("/metadata/end").and_then(Value::as_str)?;
Some(AwEvent {
timestamp: Some(end.to_string()),
duration: Some(0.0),
data: json!({"source": "bucket_metadata"}),
})
}
fn report_response(
&self,
path: &str,
params: &Params,
accept: &str,
) -> (Vec<u8>, String, Vec<(String, String)>) {
let fmt = resolve_report_format(params, accept);
let host = resolve_host(&self.config, params.first("host").as_deref());
let report_date = resolve_report_date(
&self.config,
params.first("day").as_deref(),
params.first("date").as_deref(),
);
let owner = normalize_filter(params.first("owner").as_deref().unwrap_or(""));
let department = normalize_filter(params.first("department").as_deref().unwrap_or(""));
let cache_key = make_report_cache_key(
path,
&fmt,
&host,
report_date,
params.first("day").as_deref().unwrap_or(""),
&owner,
&department,
);
if let Some(cached) = self.get_report_cache(&cache_key, false) {
return (
cached.data,
cached.content_type,
vec![
("X-AW-Worktime-Cache".into(), "fresh".into()),
("X-AW-Worktime-Cache-Reason".into(), "ttl".into()),
],
);
}
let built =
self.build_report_response(path, params, &fmt, &host, report_date, &owner, &department);
match built {
Ok((data, content_type)) => {
self.save_report_cache(cache_key, data.clone(), content_type.clone());
(data, content_type, Vec::new())
}
Err(error) => {
eprintln!("[aw-worktime-api-rust] report build failed path={path}: {error:#}");
if let Some(cached) = self.get_report_cache(&cache_key, true) {
return (
cached.data,
cached.content_type,
vec![
("X-AW-Worktime-Cache".into(), "stale".into()),
("X-AW-Worktime-Cache-Reason".into(), "build-error".into()),
],
);
}
let data = serde_json::to_vec_pretty(&json!({
"ok": false,
"error": "report_unavailable",
"message": "report build failed and no cached response is available",
"generated_at_utc": to_iso_utc(Utc::now()),
}))
.unwrap_or_default();
(
data,
"application/json; charset=utf-8".to_string(),
Vec::new(),
)
}
}
}
fn get_report_cache(&self, key: &str, allow_stale: bool) -> Option<CachedResponse> {
let cached = self.report_cache.lock().ok()?.get(key).cloned();
if let Some(cached) = cached {
let max_age = if allow_stale {
self.config.report_stale_ttl_seconds
} else {
self.config.report_cache_ttl_seconds
};
if max_age > 0 && cached.stored.elapsed().as_secs() <= max_age as u64 {
return Some(cached);
}
}
if allow_stale {
return self.load_disk_cache(key);
}
None
}
fn save_report_cache(&self, key: String, data: Vec<u8>, content_type: String) {
if self.config.report_stale_ttl_seconds <= 0 {
return;
}
if let Ok(mut cache) = self.report_cache.lock() {
cache.insert(
key.clone(),
CachedResponse {
stored: Instant::now(),
data: data.clone(),
content_type: content_type.clone(),
},
);
}
self.save_disk_cache(&key, &data, &content_type);
}
fn disk_cache_path(&self, key: &str) -> PathBuf {
let mut h: u64 = 1469598103934665603;
for b in key.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(1099511628211);
}
self.config
.report_disk_cache_dir
.join(format!("{h:016x}.json"))
}
fn save_disk_cache(&self, key: &str, data: &[u8], content_type: &str) {
if self.config.report_disk_stale_ttl_seconds <= 0 {
return;
}
let path = self.disk_cache_path(key);
let _ = fs::create_dir_all(&self.config.report_disk_cache_dir);
let payload = json!({
"stored_epoch": SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0),
"content_type": content_type,
"data": String::from_utf8_lossy(data),
});
let _ = fs::write(path, serde_json::to_vec(&payload).unwrap_or_default());
}
fn load_disk_cache(&self, key: &str) -> Option<CachedResponse> {
let path = self.disk_cache_path(key);
let payload: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
let stored_epoch = payload.get("stored_epoch")?.as_f64()?;
let now_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()?
.as_secs_f64();
if now_epoch - stored_epoch > self.config.report_disk_stale_ttl_seconds as f64 {
return None;
}
Some(CachedResponse {
stored: Instant::now() - Duration::from_secs_f64((now_epoch - stored_epoch).max(0.0)),
data: payload.get("data")?.as_str()?.as_bytes().to_vec(),
content_type: payload.get("content_type")?.as_str()?.to_string(),
})
}
#[allow(clippy::too_many_arguments)]
fn build_report_response(
&self,
path: &str,
params: &Params,
fmt: &str,
host: &str,
report_date: NaiveDate,
owner: &str,
department: &str,
) -> Result<(Vec<u8>, String)> {
let is_management = path == "/reports/worktime/management";
if is_management {
let payload = self.management_report_for_date(host, report_date, owner, department)?;
return Ok(match fmt {
"csv" => (
management_csv(&payload).into_bytes(),
"text/csv; charset=utf-8".to_string(),
),
"html" => (
render_management_html(&payload).into_bytes(),
"text/html; charset=utf-8".to_string(),
),
_ => (
serde_json::to_vec_pretty(&payload)?,
"application/json; charset=utf-8".to_string(),
),
});
}
let (bounds, events) = self.fetch_events_for_date(host, report_date)?;
let rows = aggregate_rows(&self.config, &events, bounds.0, bounds.1, host, false);
let total_active_seconds = rows
.iter()
.map(|row| {
row.get("active_seconds")
.and_then(Value::as_i64)
.unwrap_or(0)
})
.sum::<i64>();
let true_active_apps = if total_active_seconds > 0 {
self.build_true_active_apps(host, report_date)
} else {
Vec::new()
};
let day = params.first("day");
Ok(match fmt {
"csv" => (
today_csv(&rows).into_bytes(),
"text/csv; charset=utf-8".to_string(),
),
"html" => (
render_today_html(
&self.config,
&rows,
host,
report_date,
day.as_deref(),
&true_active_apps,
)
.into_bytes(),
"text/html; charset=utf-8".to_string(),
),
_ => {
let payload = json!({
"generated_at_utc": to_iso_utc(Utc::now()),
"report_timezone": "Europe/Moscow",
"host": host,
"report_date": report_date.to_string(),
"bucket_id": sessions_bucket(host),
"rows": rows,
"true_active_apps": true_active_apps,
});
(
serde_json::to_vec_pretty(&payload)?,
"application/json; charset=utf-8".to_string(),
)
}
})
}
fn fetch_events_for_date(&self, host: &str, report_date: NaiveDate) -> Result<EventsForDate> {
let bounds = report_bounds(&self.config, report_date);
let events = self.fetch_bucket_events_result(
&sessions_bucket(host),
Some(bounds.0),
Some(bounds.1),
)?;
Ok((bounds, events))
}
fn management_report_for_date(
&self,
host: &str,
report_date: NaiveDate,
owner: &str,
department: &str,
) -> Result<Value> {
let (bounds, events) = self.fetch_events_for_date(host, report_date)?;
let rows = aggregate_rows(&self.config, &events, bounds.0, bounds.1, host, true);
Ok(self.build_management_payload(rows, host, report_date, owner, department))
}
fn build_true_active_apps(&self, host: &str, report_date: NaiveDate) -> Vec<Value> {
let bounds = report_bounds(&self.config, report_date);
let window_events = {
let primary = self.fetch_bucket_events(
&format!("aw-watcher-window_{host}"),
Some(bounds.0),
Some(bounds.1),
);
if primary.is_empty() {
self.fetch_bucket_events(
&format!("aw-rdp-window_{host}"),
Some(bounds.0),
Some(bounds.1),
)
} else {
primary
}
};
let afk_events = {
let primary = self.fetch_bucket_events(
&format!("aw-watcher-afk_{host}"),
Some(bounds.0),
Some(bounds.1),
);
if primary.is_empty() {
self.fetch_bucket_events(
&format!("aw-rdp-afk_{host}"),
Some(bounds.0),
Some(bounds.1),
)
} else {
primary
}
};
let mut evidence = HashMap::new();
for bucket in [
format!("aw-file-operations_{host}"),
format!("aw-dlp-endpoint-signals_{host}"),
format!("aw-watcher-web-chrome_{host}"),
format!("aw-watcher-web-edge_{host}"),
format!("aw-detmir-web-category_{host}"),
] {
evidence.insert(
bucket.clone(),
self.fetch_bucket_events(&bucket, Some(bounds.0), Some(bounds.1)),
);
}
build_true_active_apps_from_events(
&self.config,
&window_events,
&afk_events,
&evidence,
bounds.0,
bounds.1,
)
}
}
#[derive(Debug, Clone, Default)]
struct Params(HashMap<String, Vec<String>>);
impl Params {
fn parse(query: &str) -> Self {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for (k, v) in form_urlencoded::parse(query.as_bytes()) {
if !v.is_empty() {
map.entry(k.into_owned()).or_default().push(v.into_owned());
}
}
Self(map)
}
fn first(&self, name: &str) -> Option<String> {
self.0.get(name).and_then(|v| v.first()).cloned()
}
}
fn parse_iso_utc(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.map(|dt| dt.with_timezone(&Utc))
.ok()
}
fn to_iso_utc(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn hhmm(seconds: i64) -> String {
let seconds = seconds.max(0);
format!("{:02}:{:02}", seconds / 3600, (seconds % 3600) / 60)
}
fn human_duration_ru(seconds: i64) -> String {
let seconds = seconds.max(0);
let h = seconds / 3600;
let m = (seconds % 3600) / 60;
if h > 0 && m > 0 {
format!("{h} ч {m} мин")
} else if h > 0 {
format!("{h} ч")
} else if m > 0 {
format!("{m} мин")
} else {
format!("{seconds} сек")
}
}
fn resolve_host(config: &Config, host: Option<&str>) -> String {
let h = host.unwrap_or(&config.default_host).trim();
if h.is_empty() {
config.default_host.clone()
} else {
h.to_string()
}
}
fn sessions_bucket(host: &str) -> String {
format!("aw-worktime-sessions_{host}")
}
fn resolve_report_date(config: &Config, day: Option<&str>, date: Option<&str>) -> NaiveDate {
if let Some(date) = date.and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()) {
return date;
}
let today = Utc::now().with_timezone(&config.offset).date_naive();
if day == Some("yesterday") {
today - TimeDelta::days(1)
} else {
today
}
}
fn report_bounds(config: &Config, report_date: NaiveDate) -> (DateTime<Utc>, DateTime<Utc>) {
let start_local = config
.offset
.with_ymd_and_hms(
report_date.year(),
report_date.month(),
report_date.day(),
0,
0,
0,
)
.single()
.unwrap();
let start = start_local.with_timezone(&Utc);
let end = (start_local + TimeDelta::days(1) - TimeDelta::seconds(1)).with_timezone(&Utc);
(start, end)
}
fn workday_bounds(
config: &Config,
report_date: NaiveDate,
) -> (DateTime<FixedOffset>, DateTime<FixedOffset>, i64) {
let start = config
.offset
.with_ymd_and_hms(
report_date.year(),
report_date.month(),
report_date.day(),
config.workday_start_hour,
0,
0,
)
.single()
.unwrap();
let mut end = config
.offset
.with_ymd_and_hms(
report_date.year(),
report_date.month(),
report_date.day(),
config.workday_end_hour,
0,
0,
)
.single()
.unwrap();
if end <= start {
end = start + TimeDelta::hours(8);
}
let duration = (end - start).num_seconds();
(start, end, duration)
}
fn value_string(data: &Value, key: &str) -> String {
match data.get(key) {
Some(Value::String(value)) => value.trim().to_string(),
Some(Value::Number(value)) => value.to_string(),
Some(Value::Bool(value)) => value.to_string(),
Some(Value::Null) | None => String::new(),
Some(value) => value.to_string().trim_matches('"').trim().to_string(),
}
}
fn is_machine_user(user: &str) -> bool {
let u = user.trim().to_lowercase();
u.ends_with('$') || matches!(u.as_str(), "system" | "localservice" | "networkservice")
}
fn is_active_sample(data: &Value) -> bool {
if data.get("active").and_then(Value::as_bool).unwrap_or(false) {
return true;
}
let state = value_string(data, "state").to_lowercase();
if state.contains("актив") || state == "active" {
return true;
}
if state == "unknown" {
let sid = value_string(data, "sessionId").parse::<i64>().unwrap_or(-1);
let user = value_string(data, "username");
let session_name = value_string(data, "sessionName").to_lowercase();
return sid > 0
&& !user.is_empty()
&& !is_machine_user(&user)
&& (session_name.starts_with("rdp-") || session_name == "console");
}
false
}
fn normalize_user_id(data: &Value, host: &str, username: &str) -> String {
let user_id = value_string(data, "userId");
if !user_id.is_empty() {
if let Some((_, right)) = user_id.split_once('\\') {
return format!("{host}\\{right}");
}
return user_id;
}
format!("{host}\\{username}")
}
fn clamp_seconds(config: &Config, value: f64, fallback: f64) -> f64 {
let seconds = if value > 0.0 { value } else { fallback };
seconds.min(config.max_sample_seconds).max(1.0)
}
fn event_sample_seconds(
config: &Config,
event: &AwEvent,
ts: DateTime<Utc>,
next_ts: Option<DateTime<Utc>>,
) -> f64 {
for key in ["sampleSeconds", "pollSeconds"] {
let value = event.data.get(key).and_then(|v| {
if let Some(n) = v.as_f64() {
Some(n)
} else {
v.as_str().and_then(|s| s.parse().ok())
}
});
if let Some(value) = value.filter(|v| *v > 0.0) {
return clamp_seconds(config, value, config.default_sample_seconds);
}
}
if let Some(duration) = event.duration.filter(|v| *v > 0.0) {
return clamp_seconds(config, duration, config.default_sample_seconds);
}
if let Some(next_ts) = next_ts {
let delta = (next_ts - ts).num_milliseconds() as f64 / 1000.0;
if delta > 0.0 {
return clamp_seconds(config, delta, config.default_sample_seconds);
}
}
clamp_seconds(
config,
config.default_sample_seconds,
config.default_sample_seconds,
)
}
fn merge_intervals(
mut intervals: Vec<(DateTime<Utc>, DateTime<Utc>)>,
) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
intervals.sort_by_key(|i| i.0);
let mut merged: Vec<(DateTime<Utc>, DateTime<Utc>)> = Vec::new();
for (start, end) in intervals {
if let Some(last) = merged.last_mut() {
if start <= last.1 {
if end > last.1 {
last.1 = end;
}
continue;
}
}
merged.push((start, end));
}
merged
}
fn aggregate_rows(
config: &Config,
events: &[AwEvent],
start: DateTime<Utc>,
end: DateTime<Utc>,
host: &str,
intervals: bool,
) -> Vec<Value> {
let mut by_identity: IdentitySamples = BTreeMap::new();
for event in events {
let Some(ts) = event.timestamp.as_deref().and_then(parse_iso_utc) else {
continue;
};
if ts < start || ts > end {
continue;
}
let username = value_string(&event.data, "username");
if username.is_empty() {
continue;
}
let session_id = value_string(&event.data, "sessionId");
by_identity
.entry((
username,
if session_id.is_empty() {
"unknown".into()
} else {
session_id
},
))
.or_default()
.push((ts, event.clone()));
}
let end_exclusive = end + TimeDelta::seconds(1);
let mut by_user: BTreeMap<String, UserBucket> = BTreeMap::new();
for ((username, session_id), mut samples) in by_identity {
samples.sort_by_key(|item| item.0);
for idx in 0..samples.len() {
let (ts, event) = &samples[idx];
let next_ts = samples.get(idx + 1).map(|item| item.0);
let active = is_active_sample(&event.data);
let row = by_user
.entry(username.clone())
.or_insert_with(|| UserBucket {
user: username.clone(),
user_id: normalize_user_id(&event.data, host, &username),
samples_count: 0,
active_samples: 0,
session_ids: BTreeSet::new(),
intervals: Vec::new(),
});
row.samples_count += 1;
row.session_ids.insert(session_id.clone());
if active {
row.active_samples += 1;
let sample_seconds = event_sample_seconds(config, event, *ts, next_ts);
let interval_end = (*ts
+ TimeDelta::milliseconds((sample_seconds * 1000.0) as i64))
.min(end_exclusive);
if interval_end > *ts {
row.intervals.push((*ts, interval_end));
}
}
}
}
let full_range = (end - start).num_seconds() + 1;
let mut rows = Vec::new();
for (_, row) in by_user {
let merged = merge_intervals(row.intervals);
let active_seconds = merged
.iter()
.map(|(s, e)| (*e - *s).num_seconds())
.sum::<i64>()
.min(full_range);
let first = merged.first().map(|i| to_iso_utc(i.0)).unwrap_or_default();
let last = merged.last().map(|i| to_iso_utc(i.1)).unwrap_or_default();
let mut obj = Map::new();
obj.insert("user".into(), json!(row.user));
obj.insert("user_id".into(), json!(row.user_id));
obj.insert("active_seconds".into(), json!(active_seconds));
obj.insert("active_hhmm".into(), json!(hhmm(active_seconds)));
obj.insert("first_activity".into(), json!(first));
obj.insert("last_activity".into(), json!(last));
obj.insert(
"idle_seconds".into(),
json!((full_range - active_seconds).max(0)),
);
obj.insert("sessions_count".into(), json!(row.session_ids.len()));
obj.insert("samples_count".into(), json!(row.samples_count));
obj.insert("active_samples".into(), json!(row.active_samples));
if intervals {
obj.insert(
"_intervals".into(),
json!(
merged
.iter()
.map(|(s, e)| json!([to_iso_utc(*s), to_iso_utc(*e)]))
.collect::<Vec<_>>()
),
);
}
rows.push(Value::Object(obj));
}
rows
}
fn build_report_summary(rows: &[Value]) -> Value {
let total_active = rows
.iter()
.map(|r| r.get("active_seconds").and_then(Value::as_i64).unwrap_or(0))
.sum::<i64>();
let first = rows
.iter()
.filter_map(|r| r.get("first_activity").and_then(Value::as_str))
.filter(|s| !s.is_empty())
.min()
.unwrap_or("");
let last = rows
.iter()
.filter_map(|r| r.get("last_activity").and_then(Value::as_str))
.filter(|s| !s.is_empty())
.max()
.unwrap_or("");
let top = rows
.iter()
.max_by_key(|r| r.get("active_seconds").and_then(Value::as_i64).unwrap_or(0));
json!({
"users_count": rows.len(),
"total_active_seconds": total_active,
"total_active_hhmm": hhmm(total_active),
"first_activity": first,
"last_activity": last,
"top_user": top.and_then(|r| r.get("user")).and_then(Value::as_str).unwrap_or(""),
"top_user_active_hhmm": top.and_then(|r| r.get("active_hhmm")).and_then(Value::as_str).unwrap_or("00:00"),
})
}
fn normalize_key(value: &str) -> String {
value.trim().to_lowercase()
}
fn default_display_name(user: &str, user_id: &str) -> String {
let mut base = if !user_id.trim().is_empty() {
user_id.trim().to_string()
} else {
user.trim().to_string()
};
if let Some((_, right)) = base.split_once('\\') {
base = right.to_string();
}
if base.is_ascii() && base.to_lowercase() == base && base.chars().any(|c| c.is_alphabetic()) {
base.to_uppercase()
} else {
base
}
}
fn load_aliases(config: &Config) -> Value {
fs::read_to_string(&config.manager_aliases_json)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_else(|| json!({}))
}
fn resolve_user_alias(config: &Config, user: &str, user_id: &str, host: &str) -> AliasProfile {
let raw = load_aliases(config);
let users = raw.get("users").unwrap_or(&raw);
let candidates = [
normalize_key(user_id),
normalize_key(&format!("{host}\\{user}")),
normalize_key(user),
];
let mut alias = json!({});
if let Some(map) = users.as_object() {
for candidate in candidates {
if let Some(value) = map.get(&candidate) {
alias = if value.is_string() {
json!({"display_name": value.as_str().unwrap_or("")})
} else {
value.clone()
};
break;
}
}
}
let display_name = value_string(&alias, "display_name")
.if_empty(value_string(&alias, "name"))
.if_empty(default_display_name(user, user_id));
let manager_owner = value_string(&alias, "manager")
.if_empty(value_string(&alias, "owner"))
.if_empty(display_name.clone());
let exclude = alias
.get("exclude")
.and_then(Value::as_bool)
.unwrap_or(false)
|| config.manager_exclude_users.contains(&normalize_key(user))
|| config
.manager_exclude_users
.contains(&normalize_key(&display_name));
AliasProfile {
display_name,
manager_owner,
department: value_string(&alias, "department"),
role: value_string(&alias, "role"),
notes: value_string(&alias, "notes"),
canonical_user_id: value_string(&alias, "canonical_user_id").if_empty(user_id.to_string()),
exclude,
}
}
trait IfEmpty {
fn if_empty(self, fallback: String) -> String;
}
impl IfEmpty for String {
fn if_empty(self, fallback: String) -> String {
if self.trim().is_empty() {
fallback
} else {
self
}
}
}
fn normalize_filter(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn filter_key(value: &str) -> String {
normalize_filter(value).to_lowercase()
}
fn interval_overlap_seconds(
intervals: &[Value],
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> (i64, Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
let mut total = 0;
let mut first = None;
let mut last = None;
for item in intervals {
let Some(arr) = item.as_array() else {
continue;
};
if arr.len() != 2 {
continue;
}
let Some(s) = arr[0].as_str().and_then(parse_iso_utc) else {
continue;
};
let Some(e) = arr[1].as_str().and_then(parse_iso_utc) else {
continue;
};
let os = s.max(start);
let oe = e.min(end);
if oe <= os {
continue;
}
total += (oe - os).num_seconds();
if first.is_none_or(|f| os < f) {
first = Some(os);
}
if last.is_none_or(|l| oe > l) {
last = Some(oe);
}
}
(total, first, last)
}
impl App {
fn build_management_payload(
&self,
rows: Vec<Value>,
host: &str,
report_date: NaiveDate,
owner_filter: &str,
department_filter: &str,
) -> Value {
let (day_start, day_end) = report_bounds(&self.config, report_date);
let (work_start_local, work_end_local, work_duration) =
workday_bounds(&self.config, report_date);
let now_local = Utc::now().with_timezone(&self.config.offset);
let is_today = report_date == now_local.date_naive();
let effective_end_local = if is_today && now_local < work_end_local {
now_local
} else {
work_end_local
};
let expected_seconds = if is_today {
(effective_end_local - work_start_local)
.num_seconds()
.max(0)
.min(work_duration)
} else {
work_duration
};
let target_seconds = expected_seconds * self.config.manager_target_coverage_pct / 100;
let low_seconds = expected_seconds * self.config.manager_low_coverage_pct / 100;
let late_start =
work_start_local + TimeDelta::minutes(self.config.manager_late_start_grace_minutes);
let early_finish =
work_end_local - TimeDelta::minutes(self.config.manager_early_finish_grace_minutes);
let owner_filter = normalize_filter(owner_filter);
let department_filter = normalize_filter(department_filter);
let mut roster = Vec::new();
let mut actions = Vec::new();
for row in rows {
let user = row.get("user").and_then(Value::as_str).unwrap_or("");
let user_id = row.get("user_id").and_then(Value::as_str).unwrap_or("");
let alias = resolve_user_alias(&self.config, user, user_id, host);
if alias.exclude {
continue;
}
if !owner_filter.is_empty()
&& filter_key(&alias.manager_owner) != filter_key(&owner_filter)
{
continue;
}
if !department_filter.is_empty()
&& filter_key(&alias.department) != filter_key(&department_filter)
{
continue;
}
let intervals = row
.get("_intervals")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let (work_secs, work_first, work_last) = interval_overlap_seconds(
&intervals,
work_start_local.with_timezone(&Utc),
effective_end_local.with_timezone(&Utc),
);
let calendar_secs = row
.get("active_seconds")
.and_then(Value::as_i64)
.unwrap_or(0);
let coverage = if expected_seconds > 0 {
clamp_pct(work_secs as f64 / expected_seconds as f64 * 100.0)
} else {
0.0
};
let status = if work_secs <= 0 {
"inactive"
} else if work_secs < target_seconds {
"below_target"
} else {
"ok"
};
let first_local = row
.get("first_activity")
.and_then(Value::as_str)
.and_then(parse_iso_utc)
.map(|dt| dt.with_timezone(&self.config.offset).to_rfc3339())
.unwrap_or_default();
let last_local = row
.get("last_activity")
.and_then(Value::as_str)
.and_then(parse_iso_utc)
.map(|dt| dt.with_timezone(&self.config.offset).to_rfc3339())
.unwrap_or_default();
let work_first_local = work_first
.map(|dt| dt.with_timezone(&self.config.offset))
.map(|dt| dt.to_rfc3339())
.unwrap_or_default();
let work_last_local = work_last
.map(|dt| dt.with_timezone(&self.config.offset))
.map(|dt| dt.to_rfc3339())
.unwrap_or_default();
let mut public = row.as_object().cloned().unwrap_or_default();
public.remove("_intervals");
public.insert("user".into(), json!(alias.display_name));
public.insert("user_original".into(), json!(user));
public.insert("manager_owner".into(), json!(alias.manager_owner));
public.insert("department".into(), json!(alias.department));
public.insert("role".into(), json!(alias.role));
public.insert("notes".into(), json!(alias.notes));
public.insert("canonical_user_id".into(), json!(alias.canonical_user_id));
public.insert("calendar_active_seconds".into(), json!(calendar_secs));
public.insert("calendar_active_hhmm".into(), json!(hhmm(calendar_secs)));
public.insert("workday_active_seconds".into(), json!(work_secs));
public.insert("workday_active_hhmm".into(), json!(hhmm(work_secs)));
public.insert("coverage_pct".into(), json!(coverage));
public.insert("status".into(), json!(status));
public.insert("first_activity_local".into(), json!(first_local));
public.insert("last_activity_local".into(), json!(last_local));
public.insert(
"workday_first_activity_local".into(),
json!(work_first_local),
);
public.insert("workday_last_activity_local".into(), json!(work_last_local));
let public_value = Value::Object(public.clone());
let evidence = json!({
"calendar_active_hhmm": hhmm(calendar_secs),
"workday_active_hhmm": hhmm(work_secs),
"coverage_pct": coverage,
"first_activity": row.get("first_activity").cloned().unwrap_or(json!("")),
"last_activity": row.get("last_activity").cloned().unwrap_or(json!("")),
"sessions_count": row.get("sessions_count").cloned().unwrap_or(json!(0)),
"manager_owner": public.get("manager_owner").cloned().unwrap_or(json!("")),
"department": public.get("department").cloned().unwrap_or(json!("")),
"role": public.get("role").cloned().unwrap_or(json!("")),
});
let owner = public
.get("manager_owner")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let display = public
.get("user")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let canonical = public
.get("canonical_user_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if work_secs <= 0 {
actions.push(action("missing_activity", "critical", &owner, "today", &format!("За {} у сотрудника {} нет подтверждённой активности в рабочем окне RDP.", report_date, display), &format!("Проверить сотрудника {display}: работал ли он в рабочее время, была ли потеря сбора данных или отсутствие входа в систему."), &canonical, evidence));
} else {
if expected_seconds > 0 && work_secs < low_seconds {
actions.push(action("low_activity_review", "high", &owner, "24h", &format!("У сотрудника {display} активное время в рабочем окне {} ниже {}% от ожидаемого окна.", hhmm(work_secs), self.config.manager_low_coverage_pct), &format!("Проверить загрузку сотрудника {display}, задачи и фактическое присутствие в рабочем процессе."), &canonical, evidence.clone()));
} else if expected_seconds > 0 && work_secs < target_seconds {
actions.push(action("target_gap_review", "medium", &owner, "24h", &format!("У сотрудника {display} активное время в рабочем окне {} ниже управленческого целевого порога {}%.", hhmm(work_secs), self.config.manager_target_coverage_pct), &format!("Уточнить причину отклонения по сотруднику {display} и подтвердить план работ."), &canonical, evidence.clone()));
}
if work_first.is_some_and(|dt| dt.with_timezone(&self.config.offset) > late_start) {
actions.push(action("late_start_review", "medium", &owner, "24h", &format!("У сотрудника {display} первая активность в рабочем окне зафиксирована поздно."), &format!("Проверить причину позднего старта сотрудника {display} и подтвердить, что это не проблема доступа или дисциплины."), &canonical, evidence.clone()));
}
if !is_today
&& work_last
.is_some_and(|dt| dt.with_timezone(&self.config.offset) < early_finish)
{
actions.push(action("early_finish_review", "medium", &owner, "24h", &format!("У сотрудника {display} последняя активность в рабочем окне завершилась рано."), &format!("Проверить, было ли досрочное завершение рабочего дня сотрудника {display} согласовано и чем оно объясняется."), &canonical, evidence));
}
}
roster.push(public_value);
}
actions.sort_by_key(|a| {
(
priority_rank(a.get("priority").and_then(Value::as_str).unwrap_or("")),
a.get("owner")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase(),
a.get("action_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
)
});
let sources_actions = self.build_source_freshness(
host,
roster
.iter()
.any(|r| r.get("status").and_then(Value::as_str) != Some("inactive")),
);
let sources = sources_actions.0;
actions.extend(sources_actions.1);
actions.sort_by_key(|a| {
(
priority_rank(a.get("priority").and_then(Value::as_str).unwrap_or("")),
a.get("owner")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase(),
a.get("action_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
)
});
let summary = summarize_management_rows(&roster, &actions, expected_seconds);
let owner_rollups = build_rollups(&roster, &actions, "manager_owner");
let department_rollups = build_rollups(&roster, &actions, "department");
let owner_roster = owner_rollups.clone();
let executive = build_executive_summary(&summary, &actions, &sources);
let trend = vec![json!({
"report_date": report_date.to_string(),
"users_count": summary["users_count"],
"active_users": summary["active_users"],
"inactive_users": summary["inactive_users"],
"workday_total_active_seconds": summary["workday_total_active_seconds"],
"workday_total_active_hhmm": summary["workday_total_active_hhmm"],
"portfolio_coverage_pct": summary["portfolio_coverage_pct"],
"actions_count": summary["actions_count"],
"critical_actions_count": summary["critical_actions_count"],
})];
json!({
"generated_at_utc": to_iso_utc(Utc::now()),
"host": host,
"report_date": report_date.to_string(),
"report_timezone": "Europe/Moscow",
"filters": {"owner": owner_filter, "department": department_filter},
"workday": {
"start_local": work_start_local.to_rfc3339(),
"end_local": work_end_local.to_rfc3339(),
"expected_seconds_per_user": expected_seconds,
"expected_hhmm_per_user": hhmm(expected_seconds),
"target_coverage_pct": self.config.manager_target_coverage_pct,
"low_coverage_pct": self.config.manager_low_coverage_pct,
},
"summary": summary,
"actions": actions,
"rows": roster,
"sources": sources,
"trend": trend,
"trend_scope": "portfolio",
"executive": executive,
"owner_rollups": owner_rollups,
"department_rollups": department_rollups,
"owner_roster": owner_roster,
"bucket_id": sessions_bucket(host),
"report_bounds": {"start_utc": to_iso_utc(day_start), "end_utc": to_iso_utc(day_end)},
})
}
fn build_source_freshness(
&self,
host: &str,
interactive_required: bool,
) -> (Vec<Value>, Vec<Value>) {
let specs = vec![
(
"worktime_sessions",
"RDP worktime sessions",
sessions_bucket(host),
self.config.manager_critical_source_max_age_seconds,
true,
false,
),
(
"rdp_window",
"RDP current window",
format!("aw-rdp-window_{host}"),
self.config.manager_critical_source_max_age_seconds,
true,
false,
),
(
"rdp_afk",
"RDP AFK",
format!("aw-rdp-afk_{host}"),
self.config.manager_critical_source_max_age_seconds,
true,
false,
),
(
"watcher_window",
"Local watcher window",
format!("aw-watcher-window_{host}"),
self.config.manager_critical_source_max_age_seconds,
true,
true,
),
(
"watcher_afk",
"Local watcher AFK",
format!("aw-watcher-afk_{host}"),
self.config.manager_critical_source_max_age_seconds,
true,
false,
),
(
"file_operations",
"File operations collector",
format!("aw-file-operations_{host}"),
self.config.manager_critical_source_max_age_seconds,
true,
true,
),
(
"web_categories",
"Browser/web categories",
format!("aw-detmir-web-category_{host}"),
self.config.manager_web_source_max_age_seconds,
false,
false,
),
(
"session_events",
"Windows session events",
format!("aw-session-events_{host}"),
self.config.manager_session_source_max_age_seconds,
false,
false,
),
(
"pve_tasks",
"PVE task feed",
"aw-pve-task-events_pve-detmir".to_string(),
self.config.manager_infra_source_max_age_seconds,
false,
false,
),
];
let now = Utc::now();
let mut sources = Vec::new();
let mut actions = Vec::new();
for (source_id, label, bucket, max_age, required, interactive_only) in specs {
if interactive_only && !interactive_required {
sources.push(json!({
"source_id": source_id,
"label": label,
"status": "inactive",
"status_label": "inactive",
"bucket_id": bucket,
"timestamp": "",
"age_seconds": null,
"required": required,
"interactive_only": interactive_only,
"interactive_required": interactive_required,
"max_age_seconds": max_age,
"summary": "inactive: no active interactive users",
"event_summary": "",
}));
continue;
}
let event = self.latest_bucket_event(&bucket);
let ts = event
.as_ref()
.and_then(|e| e.timestamp.as_deref())
.and_then(parse_iso_utc);
let age = ts.map(|dt| (now - dt).num_seconds().max(0));
let mut status = if event.is_none() {
if required { "fail" } else { "warn" }
} else if age.is_none() {
"warn"
} else if age.unwrap() > max_age {
if required { "fail" } else { "warn" }
} else {
"ok"
};
let mut summary = if event.is_none() {
"bucket missing or empty".to_string()
} else if age.is_none() {
"timestamp parse failed".to_string()
} else if status == "ok" {
format!("fresh ({}s)", age.unwrap())
} else {
format!("stale ({}s)", age.unwrap())
};
if interactive_only && !interactive_required && status != "ok" {
status = "inactive";
summary = "inactive: no active interactive users".to_string();
}
let source = json!({
"source_id": source_id,
"label": label,
"status": status,
"status_label": source_status_label(status),
"bucket_id": bucket,
"timestamp": event.as_ref().and_then(|e| e.timestamp.clone()).unwrap_or_default(),
"age_seconds": age,
"required": required,
"interactive_only": interactive_only,
"interactive_required": interactive_required,
"max_age_seconds": max_age,
"summary": summary,
"event_summary": event.as_ref().map(source_summary).unwrap_or_default(),
});
if !matches!(status, "ok" | "inactive") {
actions.push(action(
"source_freshness_review",
if required { "critical" } else { "medium" },
"ops",
if required { "today" } else { "3d" },
&format!("Источник '{label}' в состоянии {}: {}.", source_status_label(status), summary),
"Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.",
"",
json!({"source_id": source_id, "bucket_id": bucket, "age_seconds": age, "required": required}),
));
}
sources.push(source);
}
(sources, actions)
}
}
#[allow(clippy::too_many_arguments)]
fn action(
id: &str,
priority: &str,
owner: &str,
deadline: &str,
reason: &str,
recommended: &str,
user_id: &str,
evidence: Value,
) -> Value {
json!({
"action_id": id,
"priority": priority,
"owner": owner,
"user_id": user_id,
"deadline_hint": deadline,
"reason": reason,
"recommended_action": recommended,
"evidence": evidence,
})
}
fn priority_rank(priority: &str) -> i32 {
match priority {
"critical" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
_ => 9,
}
}
fn clamp_pct(value: f64) -> f64 {
(value.clamp(0.0, 100.0) * 100.0).round() / 100.0
}
fn source_status_label(status: &str) -> &str {
match status {
"ok" => "fresh",
"warn" => "stale",
"fail" => "missing",
"inactive" => "inactive",
_ => status,
}
}
fn source_summary(event: &AwEvent) -> String {
let d = &event.data;
let signal = value_string(d, "signalType");
if signal == "collector_health" {
return format!(
"queue={} failures={} flushed={}",
d.get("queueDepth").unwrap_or(&json!(0)),
d.get("sendFailures").unwrap_or(&json!(0)),
d.get("eventsFlushed").unwrap_or(&json!(0))
);
}
for key in ["domain", "eventType", "action", "title", "status", "app"] {
let value = value_string(d, key);
if !value.is_empty() {
return value.chars().take(120).collect();
}
}
String::new()
}
fn summarize_management_rows(rows: &[Value], actions: &[Value], expected_seconds: i64) -> Value {
let users_count = rows.len() as i64;
let active_users = rows
.iter()
.filter(|r| r.get("status").and_then(Value::as_str) != Some("inactive"))
.count() as i64;
let inactive_users = rows
.iter()
.filter(|r| r.get("status").and_then(Value::as_str) == Some("inactive"))
.count() as i64;
let below_target_users = rows
.iter()
.filter(|r| r.get("status").and_then(Value::as_str) == Some("below_target"))
.count() as i64;
let on_target_users = rows
.iter()
.filter(|r| r.get("status").and_then(Value::as_str) == Some("ok"))
.count() as i64;
let work_total = rows
.iter()
.map(|r| {
r.get("workday_active_seconds")
.and_then(Value::as_i64)
.unwrap_or(0)
})
.sum::<i64>();
let cal_total = rows
.iter()
.map(|r| {
r.get("calendar_active_seconds")
.and_then(Value::as_i64)
.unwrap_or(0)
})
.sum::<i64>();
let coverage = if users_count > 0 && expected_seconds > 0 {
clamp_pct(work_total as f64 / (expected_seconds * users_count) as f64 * 100.0)
} else {
0.0
};
let top = rows.iter().max_by_key(|r| {
r.get("workday_active_seconds")
.and_then(Value::as_i64)
.unwrap_or(0)
});
json!({
"users_count": users_count,
"active_users": active_users,
"inactive_users": inactive_users,
"on_target_users": on_target_users,
"below_target_users": below_target_users,
"portfolio_coverage_pct": coverage,
"actions_count": actions.len(),
"critical_actions_count": actions.iter().filter(|a| a.get("priority").and_then(Value::as_str) == Some("critical")).count(),
"high_actions_count": actions.iter().filter(|a| a.get("priority").and_then(Value::as_str) == Some("high")).count(),
"calendar_total_active_seconds": cal_total,
"calendar_total_active_hhmm": hhmm(cal_total),
"workday_total_active_seconds": work_total,
"workday_total_active_hhmm": hhmm(work_total),
"total_active_seconds": work_total,
"total_active_hhmm": hhmm(work_total),
"first_activity": rows.iter().filter_map(|r| r.get("workday_first_activity_local").and_then(Value::as_str)).filter(|s| !s.is_empty()).min().unwrap_or(""),
"last_activity": rows.iter().filter_map(|r| r.get("workday_last_activity_local").and_then(Value::as_str)).filter(|s| !s.is_empty()).max().unwrap_or(""),
"top_user": top.and_then(|r| r.get("user")).and_then(Value::as_str).unwrap_or(""),
"top_user_active_hhmm": top.and_then(|r| r.get("workday_active_hhmm")).and_then(Value::as_str).unwrap_or("00:00"),
})
}
fn build_rollups(rows: &[Value], actions: &[Value], field: &str) -> Vec<Value> {
let mut groups: BTreeMap<String, Map<String, Value>> = BTreeMap::new();
for row in rows {
let name = row
.get(field)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("Без подразделения")
.to_string();
let group = groups.entry(name.clone()).or_insert_with(|| {
let mut g = Map::new();
g.insert("name".into(), json!(name));
for key in [
"users_count",
"active_users",
"inactive_users",
"below_target_users",
"workday_total_active_seconds",
"actions_count",
"critical_actions_count",
"high_actions_count",
"medium_actions_count",
"low_actions_count",
] {
g.insert(key.into(), json!(0));
}
g.insert("users".into(), json!([]));
g
});
inc(group, "users_count", 1);
if row.get("status").and_then(Value::as_str) == Some("inactive") {
inc(group, "inactive_users", 1);
} else {
inc(group, "active_users", 1);
}
if row.get("status").and_then(Value::as_str) == Some("below_target") {
inc(group, "below_target_users", 1);
}
inc(
group,
"workday_total_active_seconds",
row.get("workday_active_seconds")
.and_then(Value::as_i64)
.unwrap_or(0),
);
if let Some(arr) = group.get_mut("users").and_then(Value::as_array_mut) {
arr.push(row.get("user").cloned().unwrap_or(json!("")));
}
}
for action in actions {
let name = action
.get("owner")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("unassigned")
.to_string();
let group = groups.entry(name.clone()).or_insert_with(|| {
let mut g = Map::new();
g.insert("name".into(), json!(name));
for key in [
"users_count",
"active_users",
"inactive_users",
"below_target_users",
"workday_total_active_seconds",
"actions_count",
"critical_actions_count",
"high_actions_count",
"medium_actions_count",
"low_actions_count",
] {
g.insert(key.into(), json!(0));
}
g.insert("users".into(), json!([]));
g
});
inc(group, "actions_count", 1);
let key = match action.get("priority").and_then(Value::as_str).unwrap_or("") {
"critical" => "critical_actions_count",
"high" => "high_actions_count",
"medium" => "medium_actions_count",
"low" => "low_actions_count",
_ => "low_actions_count",
};
inc(group, key, 1);
}
groups
.into_values()
.map(|mut g| {
let secs = g
.get("workday_total_active_seconds")
.and_then(Value::as_i64)
.unwrap_or(0);
let users = g.get("users_count").and_then(Value::as_i64).unwrap_or(0);
g.insert("workday_total_active_hhmm".into(), json!(hhmm(secs)));
g.insert(
"portfolio_coverage_pct".into(),
json!(if users > 0 {
clamp_pct(secs as f64 / (users * 9 * 3600) as f64 * 100.0)
} else {
0.0
}),
);
Value::Object(g)
})
.collect()
}
fn inc(map: &mut Map<String, Value>, key: &str, amount: i64) {
let old = map.get(key).and_then(Value::as_i64).unwrap_or(0);
map.insert(key.into(), json!(old + amount));
}
fn build_executive_summary(summary: &Value, actions: &[Value], sources: &[Value]) -> Value {
let critical = actions
.iter()
.filter(|a| a.get("priority").and_then(Value::as_str) == Some("critical"))
.count();
let high = actions
.iter()
.filter(|a| a.get("priority").and_then(Value::as_str) == Some("high"))
.count();
let portfolio_state = if critical > 0 {
"critical"
} else if high > 0 {
"attention"
} else {
"stable"
};
let headline = if critical > 0 {
format!("Есть {critical} критичных вопроса, требующих решения сегодня.")
} else if high > 0 {
format!("Критичных провалов нет, но есть {high} вопроса повышенного внимания.")
} else {
"Критичных отклонений не найдено, рабочий день идёт в пределах нормы.".to_string()
};
let stale = sources
.iter()
.filter(|s| s.get("status").and_then(Value::as_str) != Some("ok"))
.count();
json!({
"portfolio_state": portfolio_state,
"headline": headline,
"message": format!("Активны {} из {} сотрудников. Покрытие рабочего окна {}%.", summary["active_users"], summary["users_count"], summary["portfolio_coverage_pct"]),
"focus_items": actions.iter().take(5).map(|a| json!({"priority": a["priority"], "owner": a["owner"], "title": a["action_id"], "reason": a["reason"], "recommended_action": a["recommended_action"]})).collect::<Vec<_>>(),
"stale_sources": sources.iter().filter(|s| s.get("status").and_then(Value::as_str) != Some("ok")).take(3).cloned().collect::<Vec<_>>(),
"stale_sources_count": stale,
})
}
fn normalize_app_name(app: &str, title: &str) -> String {
let app_l = app.trim().to_lowercase();
let title_l = title.to_lowercase();
if app_l.starts_with("1cv8") || app_l.starts_with("1cestart") {
"1С".into()
} else if app_l == "chrome.exe" || title_l.contains("google chrome") {
"Chrome".into()
} else if app_l == "msedge.exe" || title_l.contains("microsoft edge") {
"Edge".into()
} else if app_l == "browser.exe" || title_l.contains("яндекс") {
"Яндекс Браузер".into()
} else if app_l == "excel.exe" {
"Excel".into()
} else if app_l == "winword.exe" {
"Word".into()
} else if app_l == "explorer.exe" {
"Проводник".into()
} else if app_l == "totalcmd.exe" || app_l == "totalcmd64.exe" {
"Total Commander".into()
} else if !app.trim().is_empty() {
app.trim().trim_end_matches(".exe").to_string()
} else if !title.trim().is_empty() {
title.trim().to_string()
} else {
"Неизвестное приложение".into()
}
}
fn event_duration(config: &Config, event: &AwEvent, fallback: f64) -> f64 {
event
.duration
.unwrap_or(0.0)
.max(fallback)
.min(config.true_active_max_event_seconds as f64)
.max(1.0)
}
fn event_context(event: &AwEvent) -> String {
for key in [
"title",
"url",
"path",
"filePath",
"targetPath",
"windowTitle",
"foregroundTitle",
"signalType",
] {
let value = value_string(&event.data, key);
if !value.is_empty() {
return value;
}
}
"активность".into()
}
fn is_real_evidence(event: &AwEvent) -> bool {
let signal = value_string(&event.data, "signalType").to_lowercase();
if matches!(
signal.as_str(),
"collector_health" | "self_test" | "heartbeat" | "health"
) {
return false;
}
["url", "title", "path", "filePath", "targetPath"]
.iter()
.any(|key| !value_string(&event.data, key).is_empty())
|| !signal.is_empty()
}
fn build_true_active_apps_from_events(
config: &Config,
window_events: &[AwEvent],
afk_events: &[AwEvent],
evidence_by_bucket: &HashMap<String, Vec<AwEvent>>,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Vec<Value> {
let mut windows = Vec::new();
let mut prev_key = None;
for event in sorted_events(window_events, start, end) {
let app = value_string(&event.data, "app")
.if_empty(value_string(&event.data, "process"))
.if_empty(value_string(&event.data, "processName"));
let title =
value_string(&event.data, "title").if_empty(value_string(&event.data, "windowTitle"));
if app.is_empty() && title.is_empty() {
continue;
}
let ts = event.timestamp.as_deref().and_then(parse_iso_utc).unwrap();
let dur = event_duration(config, event, config.default_sample_seconds);
let interval = (
ts.max(start),
(ts + TimeDelta::milliseconds((dur * 1000.0) as i64)).min(end + TimeDelta::seconds(1)),
);
if interval.1 <= interval.0 {
continue;
}
let app_name = normalize_app_name(&app, &title);
let key = (app_name.clone(), title.clone());
let changed = prev_key.as_ref().is_some_and(|p| p != &key);
prev_key = Some(key);
windows.push((app_name, app, title, interval.0, interval.1, changed, ts));
}
let mut afk_intervals = Vec::new();
for event in sorted_events(afk_events, start, end) {
let status = value_string(&event.data, "status").to_lowercase();
if !matches!(
status.as_str(),
"not-afk" | "not_afk" | "active" | "активно"
) {
continue;
}
let ts = event.timestamp.as_deref().and_then(parse_iso_utc).unwrap();
let dur = event_duration(config, event, 5.0);
let interval = (
ts.max(start),
(ts + TimeDelta::milliseconds((dur * 1000.0) as i64)).min(end + TimeDelta::seconds(1)),
);
if interval.1 > interval.0 {
afk_intervals.push(interval);
}
}
afk_intervals = merge_intervals(afk_intervals);
let mut evidence: HashMap<String, Vec<(DateTime<Utc>, String)>> = HashMap::new();
for (app_name, _raw, title, _s, _e, changed, ts) in &windows {
if *changed {
evidence
.entry(app_name.clone())
.or_default()
.push((*ts, title.clone()));
}
}
for events in evidence_by_bucket.values() {
for event in sorted_events(events, start, end) {
if !is_real_evidence(event) {
continue;
}
let ts = event.timestamp.as_deref().and_then(parse_iso_utc).unwrap();
if let Some((app_name, _, _, _, _, _, _)) = windows
.iter()
.find(|(_, _, _, s, e, _, _)| *s <= ts && ts < *e)
{
evidence
.entry(app_name.clone())
.or_default()
.push((ts, event_context(event)));
}
}
}
let mut rows = Vec::new();
let delta = TimeDelta::seconds(config.true_active_evidence_window_seconds);
let apps: BTreeSet<String> = windows
.iter()
.map(|w| w.0.clone())
.chain(evidence.keys().cloned())
.collect();
for app in apps {
let app_evidence = evidence.get(&app).cloned().unwrap_or_default();
if app_evidence.is_empty() {
continue;
}
let evidence_windows = merge_intervals(
app_evidence
.iter()
.map(|(ts, _)| (*ts - delta, *ts + delta))
.collect(),
);
let mut proved = Vec::new();
for window in windows.iter().filter(|w| w.0 == app) {
for afk in &afk_intervals {
if let Some(active) = overlap((window.3, window.4), *afk) {
for ev in &evidence_windows {
if let Some(p) = overlap(active, *ev) {
proved.push(p);
}
}
}
}
}
let proved = merge_intervals(proved);
let seconds = proved
.iter()
.map(|(s, e)| (*e - *s).num_seconds())
.sum::<i64>();
if seconds <= 0 {
continue;
}
let (last_ts, last_context) = app_evidence.last().cloned().unwrap();
rows.push(json!({
"application": app,
"proved_work_seconds": seconds,
"proved_work_hhmm": hhmm(seconds),
"proved_work_human": human_duration_ru(seconds),
"last_action_utc": to_iso_utc(last_ts),
"last_action_local": last_ts.with_timezone(&config.offset).format("%H:%M").to_string(),
"last_action": last_context,
"evidence_events": app_evidence.len(),
}));
}
rows.sort_by_key(|r| {
(
-r.get("proved_work_seconds")
.and_then(Value::as_i64)
.unwrap_or(0),
r.get("application")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase(),
)
});
rows
}
fn sorted_events(events: &[AwEvent], start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<&AwEvent> {
let mut out: Vec<&AwEvent> = events
.iter()
.filter(|e| {
e.timestamp
.as_deref()
.and_then(parse_iso_utc)
.is_some_and(|ts| ts >= start && ts <= end)
})
.collect();
out.sort_by_key(|e| e.timestamp.clone());
out
}
fn overlap(
a: (DateTime<Utc>, DateTime<Utc>),
b: (DateTime<Utc>, DateTime<Utc>),
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
let s = a.0.max(b.0);
let e = a.1.min(b.1);
if e > s { Some((s, e)) } else { None }
}
fn resolve_report_format(params: &Params, accept: &str) -> String {
let requested = params.first("format").unwrap_or_default().to_lowercase();
if matches!(requested.as_str(), "csv" | "html" | "json") {
return requested;
}
let accept = accept.to_lowercase();
if accept.contains("text/html") && !accept.contains("application/json") {
"html".into()
} else {
"json".into()
}
}
fn make_report_cache_key(
path: &str,
fmt: &str,
host: &str,
report_date: NaiveDate,
day: &str,
owner: &str,
department: &str,
) -> String {
format!("{path}|{fmt}|{host}|{report_date}|{day}|{owner}|{department}")
}
fn today_csv(rows: &[Value]) -> String {
let mut out = "user,user_id,active_seconds,active_hhmm,first_activity,last_activity,idle_seconds,sessions_count,samples_count,active_samples\n".to_string();
for row in rows {
out.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{}\n",
csv_cell(row["user"].as_str().unwrap_or("")),
csv_cell(row["user_id"].as_str().unwrap_or("")),
row["active_seconds"].as_i64().unwrap_or(0),
row["active_hhmm"].as_str().unwrap_or(""),
row["first_activity"].as_str().unwrap_or(""),
row["last_activity"].as_str().unwrap_or(""),
row["idle_seconds"].as_i64().unwrap_or(0),
row["sessions_count"].as_i64().unwrap_or(0),
row["samples_count"].as_i64().unwrap_or(0),
row["active_samples"].as_i64().unwrap_or(0)
));
}
out
}
fn management_csv(payload: &Value) -> String {
let mut out =
"priority,owner,user_id,action_id,deadline_hint,reason,recommended_action\n".to_string();
for action in payload
.get("actions")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
out.push_str(&format!(
"{},{},{},{},{},{},{}\n",
csv_cell(action["priority"].as_str().unwrap_or("")),
csv_cell(action["owner"].as_str().unwrap_or("")),
csv_cell(action["user_id"].as_str().unwrap_or("")),
csv_cell(action["action_id"].as_str().unwrap_or("")),
csv_cell(action["deadline_hint"].as_str().unwrap_or("")),
csv_cell(action["reason"].as_str().unwrap_or("")),
csv_cell(action["recommended_action"].as_str().unwrap_or(""))
));
}
out
}
fn csv_cell(value: &str) -> String {
if value.contains([',', '"', '\n']) {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
fn esc(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn render_today_html(
_config: &Config,
rows: &[Value],
host: &str,
report_date: NaiveDate,
selected_day: Option<&str>,
true_apps: &[Value],
) -> String {
let summary = build_report_summary(rows);
let cards = format!(
"<div class='card'><b>Пользователи</b><strong>{}</strong></div><div class='card'><b>Активное время</b><strong>{}</strong></div><div class='card'><b>Лидер дня</b><strong>{}</strong></div>",
summary["users_count"],
summary["total_active_hhmm"].as_str().unwrap_or("00:00"),
esc(summary["top_user"].as_str().unwrap_or(""))
);
let app_rows = if true_apps.is_empty() {
"<tr><td colspan='3'>Пока нет доказанной активной работы по приложениям за выбранную дату.</td></tr>".to_string()
} else {
true_apps
.iter()
.map(|r| {
format!(
"<tr><td>{}</td><td class='good'>{}</td><td>{} · {}</td></tr>",
esc(r["application"].as_str().unwrap_or("-")),
esc(r["proved_work_human"].as_str().unwrap_or("0 сек")),
esc(r["last_action_local"].as_str().unwrap_or("-")),
esc(r["last_action"].as_str().unwrap_or("-"))
)
})
.collect::<String>()
};
let user_rows = if rows.is_empty() {
"<tr><td colspan='9'>За выбранную дату данных пока нет.</td></tr>".to_string()
} else {
rows.iter().map(|r| format!("<tr><td>{}</td><td>{}</td><td class='good'>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>", esc(r["user"].as_str().unwrap_or("")), esc(r["user_id"].as_str().unwrap_or("")), esc(r["active_hhmm"].as_str().unwrap_or("")), r["active_seconds"], esc(r["first_activity"].as_str().unwrap_or("")), esc(r["last_activity"].as_str().unwrap_or("")), r["idle_seconds"], r["sessions_count"], r["samples_count"])).collect::<String>()
};
let day_query = selected_day
.filter(|d| matches!(*d, "today" | "yesterday"))
.map(|d| format!("day={d}"))
.unwrap_or_else(|| format!("date={report_date}"));
format!(
r#"<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AW-rus Отчёт по работе в RDP</title><style>{}</style></head><body><main><section class="hero"><h1>Отчёт по работе в RDP</h1><p>Хост: {} · Дата: {} · Часовой пояс: Europe/Moscow · Сформировано: {}</p><nav><a href="/reports/worktime/today?format=html&host={}&day=today">Сегодня</a><a href="/reports/worktime/today?format=html&host={}&day=yesterday">Вчера</a><a href="/reports/worktime/today?format=csv&host={}&{}">CSV</a><a href="/reports/worktime/today?format=json&host={}&{}">JSON</a><a href="/reports/worktime/management?format=html&host={}&{}">Управленческий отчёт</a></nav><div class="grid">{}</div></section><section><h2>Доказанная работа по приложениям</h2><table><thead><tr><th>Приложение</th><th>Доказанная работа</th><th>Последнее действие</th></tr></thead><tbody>{}</tbody></table></section><section><h2>Таблица по пользователям</h2><table><thead><tr><th>Пользователь</th><th>Учётная запись</th><th>Активно</th><th>Активно, сек</th><th>Начало</th><th>Конец</th><th>Простой</th><th>Сессии</th><th>Сэмплы</th></tr></thead><tbody>{}</tbody></table></section></main></body></html>"#,
base_css(),
esc(host),
report_date,
Local::now().format("%F %T"),
esc(host),
esc(host),
esc(host),
day_query,
esc(host),
day_query,
esc(host),
day_query,
cards,
app_rows,
user_rows
)
}
fn render_management_html(payload: &Value) -> String {
let rows = payload["rows"].as_array().cloned().unwrap_or_default();
let actions = payload["actions"].as_array().cloned().unwrap_or_default();
let sources = payload["sources"].as_array().cloned().unwrap_or_default();
let action_rows = if actions.is_empty() {
"<tr><td colspan='6'>Нет действий.</td></tr>".to_string()
} else {
actions
.iter()
.map(|a| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
esc(a["priority"].as_str().unwrap_or("")),
esc(a["owner"].as_str().unwrap_or("")),
esc(a["action_id"].as_str().unwrap_or("")),
esc(a["deadline_hint"].as_str().unwrap_or("")),
esc(a["reason"].as_str().unwrap_or("")),
esc(a["recommended_action"].as_str().unwrap_or(""))
)
})
.collect()
};
let user_rows = if rows.is_empty() {
"<tr><td colspan='8'>Нет сотрудников в выборке.</td></tr>".to_string()
} else {
rows.iter().map(|r| format!("<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td class='good'>{}</td><td>{}%</td><td>{}</td><td>{}</td></tr>", esc(r["user"].as_str().unwrap_or("")), esc(r["manager_owner"].as_str().unwrap_or("")), esc(r["department"].as_str().unwrap_or("")), esc(r["status"].as_str().unwrap_or("")), esc(r["workday_active_hhmm"].as_str().unwrap_or("")), r["coverage_pct"].as_f64().unwrap_or(0.0), esc(r["workday_first_activity_local"].as_str().unwrap_or("")), esc(r["workday_last_activity_local"].as_str().unwrap_or("")))).collect()
};
let source_rows = sources
.iter()
.map(|s| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
esc(s["label"].as_str().unwrap_or("")),
esc(s["status_label"].as_str().unwrap_or("")),
esc(s["bucket_id"].as_str().unwrap_or("")),
esc(s["timestamp"].as_str().unwrap_or("")),
s["age_seconds"],
esc(s["summary"].as_str().unwrap_or(""))
)
})
.collect::<String>();
format!(
r#"<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AW-rus Управленческий отчёт по работе в RDP</title><style>{}</style></head><body><main><section class="hero"><h1>AW-rus Управленческий отчёт по работе в RDP</h1><p>{} · {} · {}</p><h2>Что делать сегодня</h2><p>{}</p><nav><a href="/reports/worktime/management?format=json&host={}">JSON</a><a href="/reports/worktime/management?format=csv&host={}">CSV</a><a href="/reports/worktime/today?format=html&host={}">Классический отчёт</a><a href="/reports/worktime/management?format=html&host={}">Сбросить</a></nav></section><section><h2>Очередь действий руководителя</h2><table><tbody>{}</tbody></table></section><section><h2>Сотрудники</h2><table><tbody>{}</tbody></table></section><section><h2>Тренд за период</h2><p>Тренд за {} дней</p></section><section><h2>По ответственным</h2><pre>{}</pre></section><section><h2>Ответственные и эскалация</h2><pre>{}</pre></section><section><h2>По подразделениям</h2><pre>{}</pre></section><section><h2>Свежесть источников данных</h2><table><tbody>{}</tbody></table></section><p>Фильтр: {}</p></main></body></html>"#,
base_css(),
esc(payload["host"].as_str().unwrap_or("")),
esc(payload["report_date"].as_str().unwrap_or("")),
esc(payload["report_timezone"].as_str().unwrap_or("")),
esc(payload
.pointer("/executive/headline")
.and_then(Value::as_str)
.unwrap_or("")),
esc(payload["host"].as_str().unwrap_or("")),
esc(payload["host"].as_str().unwrap_or("")),
esc(payload["host"].as_str().unwrap_or("")),
esc(payload["host"].as_str().unwrap_or("")),
action_rows,
user_rows,
payload["trend"].as_array().map(|a| a.len()).unwrap_or(0),
esc(&payload["owner_rollups"].to_string()),
esc(&payload["owner_roster"].to_string()),
esc(&payload["department_rollups"].to_string()),
source_rows,
esc(&payload["filters"].to_string())
)
}
fn base_css() -> &'static str {
"body{margin:0;background:#f5f7fb;color:#172033;font:14px/1.45 'Segoe UI',Arial,sans-serif}main{max-width:1360px;margin:0 auto;padding:20px}.hero{background:#12324a;color:white;border-radius:8px;padding:18px 20px;margin-bottom:16px}.hero a{color:white;margin-right:12px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.card{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.18);border-radius:8px;padding:12px}.card b{display:block;color:#dbeafe}.card strong{font-size:22px}section{background:white;border:1px solid #dbe3ee;border-radius:8px;margin:14px 0;padding:14px;overflow:auto}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid #e5eaf2;padding:9px 10px;text-align:left;vertical-align:top}.good{color:#0f766e;font-weight:700}@media(max-width:800px){main{padding:10px}.grid{grid-template-columns:1fr}table{min-width:900px}}"
}
fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header")
}
fn respond(
request: Request,
status: u16,
data: Vec<u8>,
content_type: &str,
extra_headers: Vec<(String, String)>,
) {
let mut response = Response::new(
StatusCode(status),
vec![
header("Content-Type", content_type),
header("Content-Length", &data.len().to_string()),
],
Cursor::new(data),
None,
None,
);
for (key, value) in extra_headers {
response.add_header(header(&key, &value));
}
let _ = request.respond(response);
}
fn handle(app: &App, request: Request) {
if request.method() != &Method::Get {
respond(
request,
405,
Vec::new(),
"text/plain; charset=utf-8",
Vec::new(),
);
return;
}
let url = request.url().to_string();
let (path, query) = url.split_once('?').unwrap_or((&url, ""));
if path == "/health" || path == "/api/health" {
let payload = json!({
"ok": true,
"generated_at_utc": to_iso_utc(Utc::now()),
"report_timezone": "Europe/Moscow",
"default_host": app.config.default_host,
"aw_api_base": app.config.aw_api_base,
});
respond(
request,
200,
serde_json::to_vec_pretty(&payload).unwrap(),
"application/json; charset=utf-8",
Vec::new(),
);
return;
}
if path.starts_with("/dlp-ioc/") {
let name = path.rsplit('/').next().unwrap_or("");
if !matches!(
name,
"ioc_blacklist.json" | "ioc_blacklist.csv" | "ioc_blacklist.sql"
) {
respond(
request,
404,
Vec::new(),
"text/plain; charset=utf-8",
Vec::new(),
);
return;
}
let file = app.config.ioc_dir.join(name);
match fs::read(file) {
Ok(data) => {
let ctype = if name.ends_with(".json") {
"application/json; charset=utf-8"
} else if name.ends_with(".csv") {
"text/csv; charset=utf-8"
} else {
"text/plain; charset=utf-8"
};
respond(request, 200, data, ctype, Vec::new());
}
Err(_) => respond(
request,
404,
Vec::new(),
"text/plain; charset=utf-8",
Vec::new(),
),
}
return;
}
if path != "/reports/worktime/today" && path != "/reports/worktime/management" {
respond(
request,
404,
Vec::new(),
"text/plain; charset=utf-8",
Vec::new(),
);
return;
}
let params = Params::parse(query);
let accept = request
.headers()
.iter()
.find(|h| h.field.equiv("Accept"))
.map(|h| h.value.as_str())
.unwrap_or("");
let (data, content_type, headers) = app.report_response(path, &params, accept);
let status = if content_type.starts_with("application/json")
&& serde_json::from_slice::<Value>(&data)
.ok()
.and_then(|v| v.get("error").cloned())
.is_some()
{
503
} else {
200
};
respond(request, status, data, &content_type, headers);
}
fn main() -> Result<()> {
let _cli = Cli::parse();
let config = load_config();
let addr = format!("{}:{}", config.listen_host, config.listen_port);
let app = App::new(config)?;
let server = Server::http(&addr).map_err(|error| anyhow!("bind {addr}: {error}"))?;
println!("{} listening on {}", Local::now().format("%F %T"), addr);
for request in server.incoming_requests() {
let app = app.clone();
std::thread::spawn(move || handle(&app, request));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> Config {
load_config()
}
fn event(ts: &str, username: &str, sid: i64, active: bool, extra: Value) -> AwEvent {
let mut data = Map::new();
data.insert("username".into(), json!(username));
data.insert("userId".into(), json!(format!("WORKGROUP\\{username}")));
data.insert("sessionId".into(), json!(sid));
data.insert(
"state".into(),
json!(if active { "Активно" } else { "Диск" }),
);
data.insert("active".into(), json!(active));
if let Some(extra) = extra.as_object() {
for (k, v) in extra {
data.insert(k.clone(), v.clone());
}
}
AwEvent {
timestamp: Some(ts.into()),
duration: Some(0.0),
data: Value::Object(data),
}
}
#[test]
fn aggregate_rows_merges_overlap() {
let cfg = test_config();
let start = parse_iso_utc("2026-05-14T06:00:00Z").unwrap();
let end = parse_iso_utc("2026-05-14T06:59:59Z").unwrap();
let rows = aggregate_rows(
&cfg,
&[
event(
"2026-05-14T06:00:00Z",
"user5",
4,
true,
json!({"sampleSeconds":30}),
),
event(
"2026-05-14T06:00:30Z",
"user5",
4,
true,
json!({"sampleSeconds":30}),
),
event(
"2026-05-14T06:00:15Z",
"user5",
5,
true,
json!({"sampleSeconds":30}),
),
],
start,
end,
"HOST-EXAMPLE",
false,
);
assert_eq!(rows[0]["active_seconds"], 60);
assert_eq!(rows[0]["sessions_count"], 2);
}
#[test]
fn report_format_prefers_html_for_browser() {
assert_eq!(
resolve_report_format(&Params::default(), "text/html,application/xhtml+xml"),
"html"
);
assert_eq!(
resolve_report_format(&Params::parse("format=json"), "text/html"),
"json"
);
}
#[test]
fn true_active_apps_require_evidence() {
let cfg = test_config();
let start = parse_iso_utc("2026-05-14T06:00:00Z").unwrap();
let end = parse_iso_utc("2026-05-14T07:59:59Z").unwrap();
let windows = vec![
AwEvent {
timestamp: Some("2026-05-14T06:00:00Z".into()),
duration: Some(120.0),
data: json!({"app":"1cv8.exe","title":"ИНФОВЕСТ"}),
},
AwEvent {
timestamp: Some("2026-05-14T06:02:00Z".into()),
duration: Some(180.0),
data: json!({"app":"1cv8.exe","title":"Счета учета: Материалы"}),
},
];
let afk = vec![AwEvent {
timestamp: Some("2026-05-14T06:00:00Z".into()),
duration: Some(600.0),
data: json!({"status":"not-afk"}),
}];
let rows =
build_true_active_apps_from_events(&cfg, &windows, &afk, &HashMap::new(), start, end);
assert_eq!(rows[0]["application"], "1С");
assert_eq!(rows[0]["proved_work_seconds"], 300);
}
}