feat(detmir): add rust-first operations tooling
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_SERVER: &str = "http://10.10.10.13:5600";
|
||||
const DEFAULT_HOST: &str = "SHARKON2025";
|
||||
const DEFAULT_RDP_HOST: &str = "192.168.100.18";
|
||||
const BUCKETS: &[(&str, &str)] = &[
|
||||
("aw-watcher-afk", "AFK watcher"),
|
||||
("aw-watcher-window", "Window watcher"),
|
||||
("aw-worktime-sessions", "Worktime sessions"),
|
||||
("aw-session-events", "Session events"),
|
||||
("aw-dlp-endpoint-signals", "DLP signals"),
|
||||
("aw-dlp-incidents", "DLP incidents"),
|
||||
("aw-dlp-review", "DLP review"),
|
||||
("aw-dlp-rules", "DLP rules"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Full read-only ActivityWatch check for server, buckets, and RDP host")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = DEFAULT_SERVER)]
|
||||
server: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_HOST)]
|
||||
host: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_RDP_HOST)]
|
||||
rdp_host: String,
|
||||
|
||||
#[arg(long, default_value_t = 15)]
|
||||
timeout_seconds: u64,
|
||||
|
||||
#[arg(long)]
|
||||
no_color: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Colors {
|
||||
red: &'static str,
|
||||
green: &'static str,
|
||||
yellow: &'static str,
|
||||
cyan: &'static str,
|
||||
reset: &'static str,
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
fn new(enabled: bool) -> Self {
|
||||
if enabled {
|
||||
Self {
|
||||
red: "\u{1b}[0;31m",
|
||||
green: "\u{1b}[0;32m",
|
||||
yellow: "\u{1b}[1;33m",
|
||||
cyan: "\u{1b}[0;36m",
|
||||
reset: "\u{1b}[0m",
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
red: "",
|
||||
green: "",
|
||||
yellow: "",
|
||||
cyan: "",
|
||||
reset: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, color: &str, text: &str) -> String {
|
||||
format!("{color}{text}{}", self.reset)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ContextState {
|
||||
host_inactive: bool,
|
||||
guard_healthy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BucketStatus {
|
||||
Fresh,
|
||||
Stale,
|
||||
Dead,
|
||||
Empty,
|
||||
EventDriven,
|
||||
Inactive,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BucketRow {
|
||||
label: &'static str,
|
||||
last_id: String,
|
||||
age: String,
|
||||
status: BucketStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Summary {
|
||||
fresh: usize,
|
||||
stale: usize,
|
||||
dead: usize,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let server = cli.server.trim_end_matches('/').to_string();
|
||||
let colors = Colors::new(!cli.no_color && std::env::var_os("NO_COLOR").is_none());
|
||||
let timeout = Duration::from_secs(cli.timeout_seconds.max(1));
|
||||
let client = Client::builder()
|
||||
.timeout(timeout)
|
||||
.no_proxy()
|
||||
.pool_max_idle_per_host(0)
|
||||
.build()
|
||||
.context("build HTTP client")?;
|
||||
let now = Utc::now();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
colors.paint(
|
||||
colors.cyan,
|
||||
&format!("=== ActivityWatch Full Check: {} ===", cli.host)
|
||||
)
|
||||
);
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
colors.paint(colors.cyan, &format!("--- 1. AW Server ({server}) ---"))
|
||||
);
|
||||
print!(" Connectivity... ");
|
||||
let info = match get_json(&client, &format!("{server}/api/0/info")) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
println!(" {}", colors.paint(colors.red, "FAILED"));
|
||||
return Ok(1);
|
||||
}
|
||||
};
|
||||
let Some(version) = info.get("version").and_then(Value::as_str) else {
|
||||
println!(" {}", colors.paint(colors.red, "FAILED"));
|
||||
return Ok(1);
|
||||
};
|
||||
println!(
|
||||
" {} (aw-server {version})",
|
||||
colors.paint(colors.green, "OK")
|
||||
);
|
||||
|
||||
print!(" CORS... ");
|
||||
let cors_code = check_cors(&client, &server);
|
||||
if cors_code == 200 {
|
||||
println!("{}", colors.paint(colors.green, "OK"));
|
||||
} else {
|
||||
println!("{} (HTTP {cors_code})", colors.paint(colors.red, "FAIL"));
|
||||
}
|
||||
println!();
|
||||
|
||||
let context = read_context(&client, &server, &cli.host, now);
|
||||
println!("{}", colors.paint(colors.cyan, "--- 2. Data Buckets ---"));
|
||||
println!(
|
||||
" {:<42} {:<8} {:<20} STATUS",
|
||||
"BUCKET", "EVENTS", "LAST EVENT"
|
||||
);
|
||||
println!(
|
||||
" {:<42} {:<8} {:<20} ------",
|
||||
"------------------------------------------", "--------", "--------------------"
|
||||
);
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for (bucket, label) in BUCKETS {
|
||||
let row = read_bucket_row(&client, &server, &cli.host, bucket, label, now, &context);
|
||||
println!(
|
||||
" {:<42} {:<8} {:<20} {}",
|
||||
row.label,
|
||||
row.last_id,
|
||||
row.age,
|
||||
render_status(&colors, row.status)
|
||||
);
|
||||
rows.push(row);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
colors.paint(
|
||||
colors.cyan,
|
||||
&format!("--- 3. RDP Host ({}) ---", cli.rdp_host)
|
||||
)
|
||||
);
|
||||
print!(" WinRM (5985)... ");
|
||||
if tcp_open(&cli.rdp_host, 5985, Duration::from_secs(5)) {
|
||||
println!("{}", colors.paint(colors.green, "OK"));
|
||||
} else {
|
||||
println!("{}", colors.paint(colors.red, "UNREACHABLE"));
|
||||
}
|
||||
print!(" SSH (22)... ");
|
||||
if tcp_open(&cli.rdp_host, 22, Duration::from_secs(5)) {
|
||||
println!("{}", colors.paint(colors.green, "OK"));
|
||||
} else {
|
||||
println!(
|
||||
"{} (normal for Windows)",
|
||||
colors.paint(colors.yellow, "CLOSED")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
let summary = summarize(&rows);
|
||||
println!("{}", colors.paint(colors.cyan, "--- 4. Summary ---"));
|
||||
println!(
|
||||
" FRESH: {}",
|
||||
colors.paint(colors.green, &summary.fresh.to_string())
|
||||
);
|
||||
println!(
|
||||
" STALE: {}",
|
||||
colors.paint(colors.yellow, &summary.stale.to_string())
|
||||
);
|
||||
println!(
|
||||
" DEAD: {}",
|
||||
colors.paint(colors.red, &summary.dead.to_string())
|
||||
);
|
||||
|
||||
if summary.dead > 0 || summary.stale > 0 {
|
||||
println!();
|
||||
println!(
|
||||
" {} Some collectors may need restart on RDP host",
|
||||
colors.paint(colors.red, "WARNING:")
|
||||
);
|
||||
println!(
|
||||
" Run: {}",
|
||||
colors.paint(
|
||||
colors.cyan,
|
||||
"ansible -i ansible/inventory.ini rdp-prod -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Recovery\"'"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", colors.paint(colors.cyan, "=== Check Complete ==="));
|
||||
println!(" Timestamp: {}", now.format("%Y-%m-%d %H:%M:%S UTC"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn read_context(client: &Client, server: &str, host: &str, now: DateTime<Utc>) -> ContextState {
|
||||
let mut state = ContextState::default();
|
||||
if let Ok(Some(event)) = latest_event(client, server, &format!("aw-worktime-sessions_{host}")) {
|
||||
if let Some(ts) = event_timestamp(&event) {
|
||||
let age = (now - ts).num_seconds();
|
||||
let active = event
|
||||
.pointer("/data/active")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if (0..900).contains(&age) && !active {
|
||||
state.host_inactive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(Some(event)) = latest_event(client, server, &format!("aw-rus-collector-guard_{host}"))
|
||||
{
|
||||
if let Some(ts) = event_timestamp(&event) {
|
||||
let age = (now - ts).num_seconds();
|
||||
let status = event
|
||||
.pointer("/data/status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let problems = event
|
||||
.pointer("/data/problems")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
if (0..300).contains(&age) && status == "ok" && problems == 0 {
|
||||
state.guard_healthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn read_bucket_row(
|
||||
client: &Client,
|
||||
server: &str,
|
||||
host: &str,
|
||||
bucket: &str,
|
||||
label: &'static str,
|
||||
now: DateTime<Utc>,
|
||||
context: &ContextState,
|
||||
) -> BucketRow {
|
||||
let bucket_full = format!("{bucket}_{host}");
|
||||
let event = latest_event(client, server, &bucket_full).ok().flatten();
|
||||
let Some(event) = event else {
|
||||
return BucketRow {
|
||||
label,
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: classify_bucket_no_events(bucket, context),
|
||||
};
|
||||
};
|
||||
|
||||
let last_id = event
|
||||
.get("id")
|
||||
.map(json_value_to_string)
|
||||
.unwrap_or_else(|| "0".to_string());
|
||||
let Some(ts_raw) = event.get("timestamp").and_then(Value::as_str) else {
|
||||
return BucketRow {
|
||||
label,
|
||||
last_id,
|
||||
age: "?".to_string(),
|
||||
status: BucketStatus::Unknown,
|
||||
};
|
||||
};
|
||||
let Some(ts) = parse_ts(ts_raw) else {
|
||||
return BucketRow {
|
||||
label,
|
||||
last_id,
|
||||
age: "?".to_string(),
|
||||
status: BucketStatus::Unknown,
|
||||
};
|
||||
};
|
||||
let age_sec = (now - ts).num_seconds().max(0);
|
||||
BucketRow {
|
||||
label,
|
||||
last_id,
|
||||
age: format_age(age_sec),
|
||||
status: classify_bucket_age(bucket, age_sec, context),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_bucket_age(bucket: &str, age_sec: i64, context: &ContextState) -> BucketStatus {
|
||||
match bucket {
|
||||
"aw-watcher-window" if context.host_inactive => return BucketStatus::Inactive,
|
||||
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
|
||||
return BucketStatus::Inactive;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match bucket {
|
||||
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
|
||||
if age_sec < 86_400 {
|
||||
BucketStatus::Fresh
|
||||
} else {
|
||||
BucketStatus::EventDriven
|
||||
}
|
||||
}
|
||||
_ if age_sec < 3_600 => BucketStatus::Fresh,
|
||||
_ if age_sec < 86_400 => BucketStatus::Stale,
|
||||
_ => BucketStatus::Dead,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_bucket_no_events(bucket: &str, context: &ContextState) -> BucketStatus {
|
||||
match bucket {
|
||||
"aw-watcher-window" if context.host_inactive => BucketStatus::Inactive,
|
||||
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
|
||||
BucketStatus::Inactive
|
||||
}
|
||||
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
|
||||
BucketStatus::EventDriven
|
||||
}
|
||||
_ => BucketStatus::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize(rows: &[BucketRow]) -> Summary {
|
||||
let mut summary = Summary::default();
|
||||
for row in rows {
|
||||
match row.status {
|
||||
BucketStatus::Fresh | BucketStatus::EventDriven | BucketStatus::Inactive => {
|
||||
summary.fresh += 1
|
||||
}
|
||||
BucketStatus::Stale => summary.stale += 1,
|
||||
BucketStatus::Dead | BucketStatus::Empty | BucketStatus::Unknown => summary.dead += 1,
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn render_status(colors: &Colors, status: BucketStatus) -> String {
|
||||
match status {
|
||||
BucketStatus::Fresh => colors.paint(colors.green, "FRESH"),
|
||||
BucketStatus::Stale => colors.paint(colors.yellow, "STALE"),
|
||||
BucketStatus::Dead => colors.paint(colors.red, "DEAD"),
|
||||
BucketStatus::Empty => colors.paint(colors.red, "EMPTY"),
|
||||
BucketStatus::EventDriven => colors.paint(colors.cyan, "EVENT-DRIVEN"),
|
||||
BucketStatus::Inactive => colors.paint(colors.cyan, "INACTIVE"),
|
||||
BucketStatus::Unknown => colors.paint(colors.red, "?"),
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_event(client: &Client, server: &str, bucket: &str) -> Result<Option<Value>> {
|
||||
let url = format!("{server}/api/0/buckets/{bucket}/events?limit=1");
|
||||
let value = get_json(client, &url)?;
|
||||
Ok(value.as_array().and_then(|items| items.first()).cloned())
|
||||
}
|
||||
|
||||
fn get_json(client: &Client, url: &str) -> Result<Value> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Connection", "close")
|
||||
.send()
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("GET {url} status"))?
|
||||
.json::<Value>()
|
||||
.with_context(|| format!("decode JSON from {url}"))
|
||||
}
|
||||
|
||||
fn check_cors(client: &Client, server: &str) -> u16 {
|
||||
let url = format!("{server}/api/0/settings/");
|
||||
client
|
||||
.get(&url)
|
||||
.header("Origin", "http://10.10.10.13:5600")
|
||||
.send()
|
||||
.map(|response| response.status().as_u16())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
|
||||
let Ok(mut addrs) = (host, port).to_socket_addrs() else {
|
||||
return false;
|
||||
};
|
||||
addrs.any(|addr| TcpStream::connect_timeout(&addr, timeout).is_ok())
|
||||
}
|
||||
|
||||
fn event_timestamp(event: &Value) -> Option<DateTime<Utc>> {
|
||||
event
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_ts)
|
||||
}
|
||||
|
||||
fn parse_ts(value: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00"))
|
||||
.map(|ts| ts.with_timezone(&Utc))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn format_age(age_sec: i64) -> String {
|
||||
if age_sec < 3_600 {
|
||||
format!("{}m", age_sec / 60)
|
||||
} else if age_sec < 86_400 {
|
||||
format!("{}h", age_sec / 3_600)
|
||||
} else {
|
||||
format!("{}d", age_sec / 86_400)
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(value) => value.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn event_driven_bucket_is_not_dead_when_old() {
|
||||
assert_eq!(
|
||||
classify_bucket_age("aw-session-events", 100_000, &ContextState::default()),
|
||||
BucketStatus::EventDriven
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_context_suppresses_expected_idle_buckets() {
|
||||
let context = ContextState {
|
||||
host_inactive: true,
|
||||
guard_healthy: true,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_bucket_age("aw-watcher-window", 100_000, &context),
|
||||
BucketStatus::Inactive
|
||||
);
|
||||
assert_eq!(
|
||||
classify_bucket_no_events("aw-dlp-endpoint-signals", &context),
|
||||
BucketStatus::Inactive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_treats_event_and_inactive_as_fresh_class() {
|
||||
let rows = vec![
|
||||
BucketRow {
|
||||
label: "a",
|
||||
last_id: "0".to_string(),
|
||||
age: "1m".to_string(),
|
||||
status: BucketStatus::Fresh,
|
||||
},
|
||||
BucketRow {
|
||||
label: "b",
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: BucketStatus::EventDriven,
|
||||
},
|
||||
BucketRow {
|
||||
label: "c",
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: BucketStatus::Inactive,
|
||||
},
|
||||
BucketRow {
|
||||
label: "d",
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: BucketStatus::Empty,
|
||||
},
|
||||
];
|
||||
let summary = summarize(&rows);
|
||||
assert_eq!(summary.fresh, 3);
|
||||
assert_eq!(summary.stale, 0);
|
||||
assert_eq!(summary.dead, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_age_like_legacy_full_check() {
|
||||
assert_eq!(format_age(59), "0m");
|
||||
assert_eq!(format_age(3_600), "1h");
|
||||
assert_eq!(format_age(86_400), "1d");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user