616 lines
18 KiB
Rust
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,
|
|
}
|
|
}
|
|
}
|