use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Days, Local, NaiveDate, TimeDelta, TimeZone, Utc}; use clap::Parser; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; const DEFAULT_AW_BASE_URL: &str = "http://192.0.2.13:5600/api/0"; const DEFAULT_HOST: &str = "HOST-EXAMPLE"; const DEFAULT_SAMPLE_SECONDS: f64 = 30.0; const DEFAULT_MAX_SAMPLE_SECONDS: f64 = 300.0; const DEFAULT_OUT_DIR: &str = "reports"; #[derive(Debug, Parser)] #[command(about = "Build per-user RDP worktime CSV/JSON report from AW session samples")] struct Cli { #[arg(long)] day: Option, #[arg(long)] from: Option, #[arg(long)] to: Option, } #[derive(Debug, Clone)] struct Config { aw_base_url: String, host: String, default_sample_seconds: f64, max_sample_seconds: f64, out_dir: PathBuf, } #[derive(Debug, Clone, Deserialize)] struct AwEvent { timestamp: Option, #[serde(default)] duration: Option, #[serde(default)] data: Value, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] struct ReportRow { user: String, user_id: String, active_seconds: i64, active_hhmm: String, first_activity: String, last_activity: String, idle_seconds: i64, sessions_count: usize, samples_count: i64, active_samples: i64, } #[derive(Debug, Serialize)] struct JsonReport { host: String, bucket_id: String, from: String, to: String, generated_at_utc: String, rows: Vec, } #[derive(Debug, Default)] struct UserAggregate { user: String, user_id: String, sessions: BTreeSet, samples_count: i64, active_samples: i64, intervals: Vec<(DateTime, DateTime)>, } #[derive(Debug, Clone)] struct Sample { ts: DateTime, duration: Option, data: Value, } fn main() { let code = match run() { Ok(()) => 0, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result<()> { let cli = Cli::parse(); let (from, to) = resolve_range(&cli)?; let config = load_config(); fs::create_dir_all(&config.out_dir) .with_context(|| format!("create {}", config.out_dir.display()))?; let bucket_id = format!("aw-worktime-sessions_{}", config.host); let client = Client::builder() .no_proxy() .timeout(Duration::from_secs(30)) .build() .context("build HTTP client")?; get_json::( &client, &format!("{}/buckets/{bucket_id}", config.aw_base_url), ) .with_context(|| format!("Bucket not found: {bucket_id}"))?; let events: Vec = get_json( &client, &format!( "{}/buckets/{bucket_id}/events?limit=50000", config.aw_base_url ), )?; let rows = build_rows( events, &config.host, from, to, config.default_sample_seconds, config.max_sample_seconds, )?; let csv_out = config .out_dir .join(format!("rdp-worktime-{}_{}.csv", from, to)); let json_out = config .out_dir .join(format!("rdp-worktime-{}_{}.json", from, to)); write_csv(&csv_out, &rows)?; write_json(&json_out, &config.host, &bucket_id, from, to, &rows)?; println!("{}", csv_out.display()); println!("{}", json_out.display()); println!("CSV: {}", csv_out.display()); println!("JSON: {}", json_out.display()); Ok(()) } fn load_config() -> Config { let default_sample_seconds = env_f64("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", DEFAULT_SAMPLE_SECONDS).max(1.0); let max_sample_seconds = env_f64("AW_WORKTIME_MAX_SAMPLE_SECONDS", DEFAULT_MAX_SAMPLE_SECONDS) .max(default_sample_seconds); Config { aw_base_url: normalize_aw_base(&env_string("AW_BASE_URL", DEFAULT_AW_BASE_URL)), host: env_string("AW_WORKTIME_HOST", DEFAULT_HOST), default_sample_seconds, max_sample_seconds, out_dir: PathBuf::from(env_string("OUT_DIR", DEFAULT_OUT_DIR)), } } fn env_string(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()) .unwrap_or(fallback) } fn normalize_aw_base(raw: &str) -> String { let base = raw.trim().trim_end_matches('/'); if base.ends_with("/api/0") { base.to_string() } else { format!("{base}/api/0") } } fn resolve_range(cli: &Cli) -> Result<(NaiveDate, NaiveDate)> { if let Some(day) = &cli.day { let today = Local::now().date_naive(); return match day.as_str() { "today" => Ok((today, today)), "yesterday" => { let yesterday = today .checked_sub_days(Days::new(1)) .context("calculate yesterday")?; Ok((yesterday, yesterday)) } _ => bail!("Invalid --day: {day}"), }; } let Some(from) = cli.from else { bail!( "Usage: rdp-worktime-report --day today|yesterday OR --from YYYY-MM-DD --to YYYY-MM-DD" ); }; let Some(to) = cli.to else { bail!( "Usage: rdp-worktime-report --day today|yesterday OR --from YYYY-MM-DD --to YYYY-MM-DD" ); }; Ok((from, to)) } fn get_json(client: &Client, url: &str) -> Result { let response = client .get(url) .send() .with_context(|| format!("GET {url}"))? .error_for_status() .with_context(|| format!("GET {url} status"))?; response .json() .with_context(|| format!("decode JSON from {url}")) } fn build_rows( events: Vec, host: &str, from: NaiveDate, to: NaiveDate, default_sample_seconds: f64, max_sample_seconds: f64, ) -> Result> { let start = day_start_utc(from)?; let end = day_end_utc(to)?; let mut by_identity: BTreeMap<(String, String), Vec> = BTreeMap::new(); for event in events { let Some(ts) = event.timestamp.as_deref().and_then(parse_ts) else { continue; }; if ts < start || ts > end { continue; } let user = value_string(&event.data, "username").trim().to_string(); if user.is_empty() { continue; } let session_id = value_string(&event.data, "sessionId"); let session_id = if session_id.trim().is_empty() { "unknown".to_string() } else { session_id.trim().to_string() }; by_identity .entry((user, session_id)) .or_default() .push(Sample { ts, duration: event.duration, data: event.data, }); } let full_range = (end - start).num_seconds() + 1; let mut by_user: BTreeMap = BTreeMap::new(); for ((user, session_id), mut samples) in by_identity { samples.sort_by_key(|sample| sample.ts); for idx in 0..samples.len() { let sample = &samples[idx]; let rec = by_user .entry(user.clone()) .or_insert_with(|| UserAggregate { user: user.clone(), user_id: normalize_user_id(&sample.data, host, &user), ..UserAggregate::default() }); rec.sessions.insert(session_id.clone()); rec.samples_count += 1; if !is_active(&sample.data) { continue; } rec.active_samples += 1; let sample_seconds = sample_seconds( sample, samples.get(idx + 1).map(|next| next.ts), default_sample_seconds, max_sample_seconds, ); let interval_end = std::cmp::min( sample.ts + TimeDelta::milliseconds((sample_seconds * 1000.0).round() as i64), end + TimeDelta::seconds(1), ); if interval_end > sample.ts { rec.intervals.push((sample.ts, interval_end)); } } } let mut rows = Vec::new(); for (_, rec) in by_user { let merged = merge_intervals(rec.intervals); let mut active: i64 = merged .iter() .map(|(begin, finish)| (*finish - *begin).num_seconds()) .sum(); active = active.min(full_range); let idle_seconds = (full_range - active).max(0); rows.push(ReportRow { user: rec.user, user_id: rec.user_id, active_seconds: active, active_hhmm: format!("{:02}:{:02}", active / 3600, (active % 3600) / 60), first_activity: merged .first() .map(|(begin, _)| format_ts(*begin)) .unwrap_or_default(), last_activity: merged .last() .map(|(_, finish)| format_ts(*finish)) .unwrap_or_default(), idle_seconds, sessions_count: rec.sessions.len(), samples_count: rec.samples_count, active_samples: rec.active_samples, }); } Ok(rows) } fn day_start_utc(day: NaiveDate) -> Result> { Utc.from_local_datetime( &day.and_hms_opt(0, 0, 0) .with_context(|| format!("invalid start date {day}"))?, ) .single() .context("resolve UTC start") } fn day_end_utc(day: NaiveDate) -> Result> { Utc.from_local_datetime( &day.and_hms_opt(23, 59, 59) .with_context(|| format!("invalid end date {day}"))?, ) .single() .context("resolve UTC end") } fn parse_ts(value: &str) -> Option> { DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) .ok() .map(|dt| dt.with_timezone(&Utc)) } fn value_string(data: &Value, key: &str) -> String { match data.get(key) { Some(Value::String(value)) => value.clone(), Some(Value::Number(value)) => value.to_string(), Some(Value::Bool(value)) => value.to_string(), _ => String::new(), } } fn is_active(data: &Value) -> bool { if data.get("active").and_then(Value::as_bool) == Some(true) { return true; } let state = value_string(data, "state").trim().to_lowercase(); state.contains("актив") || state == "active" } fn normalize_user_id(data: &Value, host: &str, username: &str) -> String { let raw = value_string(data, "userId").trim().to_string(); if let Some((_, right)) = raw.split_once('\\') { return format!("{host}\\{right}"); } if !raw.is_empty() { return raw; } format!("{host}\\{username}") } fn sample_seconds( sample: &Sample, next_ts: Option>, default_sample_seconds: f64, max_sample_seconds: f64, ) -> f64 { for key in ["sampleSeconds", "pollSeconds"] { if let Some(value) = value_f64(&sample.data, key).filter(|value| *value > 0.0) { return clamp_seconds(value, default_sample_seconds, max_sample_seconds); } } if let Some(duration) = sample.duration.filter(|value| *value > 0.0) { return clamp_seconds(duration, default_sample_seconds, max_sample_seconds); } if let Some(next_ts) = next_ts { return clamp_seconds( (next_ts - sample.ts).num_milliseconds() as f64 / 1000.0, default_sample_seconds, max_sample_seconds, ); } clamp_seconds( default_sample_seconds, default_sample_seconds, max_sample_seconds, ) } fn value_f64(data: &Value, key: &str) -> Option { match data.get(key) { Some(Value::Number(value)) => value.as_f64(), Some(Value::String(value)) => value.parse().ok(), _ => None, } } fn clamp_seconds(value: f64, fallback: f64, max_sample_seconds: f64) -> f64 { let seconds = if value <= 0.0 { fallback } else { value }; seconds.min(max_sample_seconds) } fn merge_intervals( mut intervals: Vec<(DateTime, DateTime)>, ) -> Vec<(DateTime, DateTime)> { if intervals.is_empty() { return Vec::new(); } intervals.sort_by_key(|(start, _)| *start); let mut merged = vec![intervals[0]]; for (start, end) in intervals.into_iter().skip(1) { let last = merged.last_mut().expect("merged non-empty"); if start <= last.1 { if end > last.1 { last.1 = end; } } else { merged.push((start, end)); } } merged } fn write_csv(path: &Path, rows: &[ReportRow]) -> Result<()> { let fields = [ "user", "user_id", "active_seconds", "active_hhmm", "first_activity", "last_activity", "idle_seconds", "sessions_count", "samples_count", "active_samples", ]; let mut out = String::new(); out.push_str(&fields.join(",")); out.push('\n'); for row in rows { out.push_str(&csv_escape(&row.user)); out.push(','); out.push_str(&csv_escape(&row.user_id)); out.push(','); out.push_str(&row.active_seconds.to_string()); out.push(','); out.push_str(&csv_escape(&row.active_hhmm)); out.push(','); out.push_str(&csv_escape(&row.first_activity)); out.push(','); out.push_str(&csv_escape(&row.last_activity)); out.push(','); out.push_str(&row.idle_seconds.to_string()); out.push(','); out.push_str(&row.sessions_count.to_string()); out.push(','); out.push_str(&row.samples_count.to_string()); out.push(','); out.push_str(&row.active_samples.to_string()); out.push('\n'); } fs::write(path, out).with_context(|| format!("write {}", path.display()))?; Ok(()) } fn csv_escape(value: &str) -> String { if value.contains([',', '"', '\n', '\r']) { format!("\"{}\"", value.replace('"', "\"\"")) } else { value.to_string() } } fn write_json( path: &Path, host: &str, bucket_id: &str, from: NaiveDate, to: NaiveDate, rows: &[ReportRow], ) -> Result<()> { let report = JsonReport { host: host.to_string(), bucket_id: bucket_id.to_string(), from: from.to_string(), to: to.to_string(), generated_at_utc: format_ts(Utc::now()), rows: rows.to_vec(), }; fs::write(path, serde_json::to_string_pretty(&report)?) .with_context(|| format!("write {}", path.display()))?; Ok(()) } fn format_ts(value: DateTime) -> String { value.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string() } #[cfg(test)] mod tests { use chrono::NaiveDate; use serde_json::json; use super::*; #[test] fn computes_active_intervals_and_merges_overlap() { let events = vec![ event( "2026-06-01T10:00:00Z", None, json!({"username":"user5","userId":"HOST\\user5","sessionId":2,"active":true,"sampleSeconds":60}), ), event( "2026-06-01T10:00:30Z", None, json!({"username":"user5","userId":"HOST\\user5","sessionId":2,"state":"Активно","sampleSeconds":60}), ), event( "2026-06-01T11:00:00Z", None, json!({"username":"user5","sessionId":3,"state":"Disc","sampleSeconds":60}), ), ]; let rows = build_rows( events, "HOST-EXAMPLE", NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), 30.0, 300.0, ) .unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].active_seconds, 90); assert_eq!(rows[0].active_hhmm, "00:01"); assert_eq!(rows[0].sessions_count, 2); assert_eq!(rows[0].samples_count, 3); assert_eq!(rows[0].active_samples, 2); assert_eq!(rows[0].user_id, "HOST-EXAMPLE\\user5"); } #[test] fn uses_next_timestamp_when_duration_missing() { let events = vec![ event( "2026-06-01T10:00:00Z", None, json!({"username":"admin","sessionId":1,"state":"active"}), ), event( "2026-06-01T10:02:00Z", None, json!({"username":"admin","sessionId":1,"state":"active"}), ), ]; let rows = build_rows( events, "HOST", NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), 30.0, 300.0, ) .unwrap(); assert_eq!(rows[0].active_seconds, 150); assert_eq!(rows[0].active_samples, 2); } #[test] fn normalizes_base_url() { assert_eq!( normalize_aw_base("http://127.0.0.1:5600"), "http://127.0.0.1:5600/api/0" ); assert_eq!( normalize_aw_base("http://127.0.0.1:5600/api/0/"), "http://127.0.0.1:5600/api/0" ); } fn event(timestamp: &str, duration: Option, data: Value) -> AwEvent { AwEvent { timestamp: Some(timestamp.to_string()), duration, data, } } }