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

616 lines
18 KiB
Rust

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<String>,
#[arg(long)]
from: Option<NaiveDate>,
#[arg(long)]
to: Option<NaiveDate>,
}
#[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<String>,
#[serde(default)]
duration: Option<f64>,
#[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<ReportRow>,
}
#[derive(Debug, Default)]
struct UserAggregate {
user: String,
user_id: String,
sessions: BTreeSet<String>,
samples_count: i64,
active_samples: i64,
intervals: Vec<(DateTime<Utc>, DateTime<Utc>)>,
}
#[derive(Debug, Clone)]
struct Sample {
ts: DateTime<Utc>,
duration: Option<f64>,
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::<Value>(
&client,
&format!("{}/buckets/{bucket_id}", config.aw_base_url),
)
.with_context(|| format!("Bucket not found: {bucket_id}"))?;
let events: Vec<AwEvent> = 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<T: serde::de::DeserializeOwned>(client: &Client, url: &str) -> Result<T> {
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<AwEvent>,
host: &str,
from: NaiveDate,
to: NaiveDate,
default_sample_seconds: f64,
max_sample_seconds: f64,
) -> Result<Vec<ReportRow>> {
let start = day_start_utc(from)?;
let end = day_end_utc(to)?;
let mut by_identity: BTreeMap<(String, String), Vec<Sample>> = 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<String, UserAggregate> = 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<DateTime<Utc>> {
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<DateTime<Utc>> {
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<Utc>> {
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<DateTime<Utc>>,
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<f64> {
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<Utc>, DateTime<Utc>)>,
) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
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<Utc>) -> 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<f64>, data: Value) -> AwEvent {
AwEvent {
timestamp: Some(timestamp.to_string()),
duration,
data,
}
}
}