//! In-memory client state registry — the "who has contacted us" table the //! web UI displays. Not persisted: PXE sessions are ephemeral by nature. use crate::arch::ClientArch; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::IpAddr; use std::sync::Arc; use time::OffsetDateTime; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ClientEvent { DhcpDiscover, DhcpRequest, PxeBootServerRequest, TftpRead { file: String }, HttpScriptFetch { target: String }, HttpIsoAsset { file: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientSnapshot { pub mac: String, pub last_ip: Option, pub arch: Option, pub hostname: Option, #[serde(with = "time::serde::rfc3339")] pub first_seen: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub last_seen: OffsetDateTime, // Events are left with default serialization (9-tuple) — they're // diagnostic only and not consumed by the UI today. pub events: Vec<(OffsetDateTime, ClientEvent)>, /// The boot target (ISO id) last selected via the iPXE menu, if any. pub selected_target: Option, } #[derive(Debug, Default)] pub struct ClientRegistry { inner: RwLock>, } impl ClientRegistry { #[must_use] pub fn new() -> Arc { Arc::new(Self::default()) } pub fn record( &self, mac: &str, ip: Option, arch: Option, event: ClientEvent, ) { let mut guard = self.inner.write(); let now = OffsetDateTime::now_utc(); let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot { mac: mac.to_string(), last_ip: ip, arch, hostname: None, first_seen: now, last_seen: now, events: Vec::new(), selected_target: None, }); entry.last_seen = now; if ip.is_some() { entry.last_ip = ip; } if arch.is_some() { entry.arch = arch; } entry.events.push((now, event)); // Cap event history per client to keep memory bounded. const MAX_EVENTS: usize = 64; if entry.events.len() > MAX_EVENTS { let drop_n = entry.events.len() - MAX_EVENTS; entry.events.drain(..drop_n); } } pub fn set_selected_target(&self, mac: &str, target: Option) { let mut guard = self.inner.write(); if let Some(c) = guard.get_mut(mac) { c.selected_target = target; } } #[must_use] pub fn list(&self) -> Vec { let guard = self.inner.read(); let mut v: Vec<_> = guard.values().cloned().collect(); v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); v } #[must_use] pub fn get(&self, mac: &str) -> Option { self.inner.read().get(mac).cloned() } }