Skip to main content

netget/state/
client.rs

1//! Client instance management
2
3use crate::utils::clock::Instant;
4use std::collections::HashMap;
5use std::net::SocketAddr;
6use std::path::PathBuf;
7
8/// Unique identifier for a client instance
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
10pub struct ClientId(u32);
11
12impl ClientId {
13    /// Create a new client ID from a u32
14    pub fn new(id: u32) -> Self {
15        Self(id)
16    }
17
18    /// Get the raw ID value
19    pub fn as_u32(&self) -> u32 {
20        self.0
21    }
22
23    /// Parse from string (expects format "client-123" or just "123")
24    pub fn from_string(s: &str) -> Option<Self> {
25        let s = s.trim();
26        let id_str = s.strip_prefix("client-").unwrap_or(s);
27        id_str.parse::<u32>().ok().map(Self)
28    }
29}
30
31impl std::fmt::Display for ClientId {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        write!(f, "client-{}", self.0)
34    }
35}
36
37/// Status of a client connection
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ClientStatus {
40    /// Client is connecting
41    Connecting,
42    /// Client is connected and active
43    Connected,
44    /// Client connection has been closed
45    Disconnected,
46    /// Client encountered an error
47    Error(String),
48}
49
50impl ClientStatus {
51    pub fn as_str(&self) -> &str {
52        match self {
53            Self::Connecting => "Connecting",
54            Self::Connected => "Connected",
55            Self::Disconnected => "Disconnected",
56            Self::Error(_) => "Error",
57        }
58    }
59}
60
61impl std::fmt::Display for ClientStatus {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::Connecting => write!(f, "Connecting"),
65            Self::Connected => write!(f, "Connected"),
66            Self::Disconnected => write!(f, "Disconnected"),
67            Self::Error(msg) => write!(f, "Error: {}", msg),
68        }
69    }
70}
71
72/// Client connection state
73#[derive(Debug, Clone)]
74pub struct ClientConnectionState {
75    /// Client ID
76    pub id: ClientId,
77    /// Remote server address (hostname:port or IP:port)
78    pub remote_addr: String,
79    /// Actual connected socket address (after DNS resolution)
80    pub connected_addr: Option<SocketAddr>,
81    /// Local address (our side of the connection)
82    pub local_addr: Option<SocketAddr>,
83    /// Bytes sent
84    pub bytes_sent: u64,
85    /// Bytes received
86    pub bytes_received: u64,
87    /// Packets sent
88    pub packets_sent: u64,
89    /// Packets received
90    pub packets_received: u64,
91    /// Last activity timestamp
92    pub last_activity: Instant,
93    /// Connection status (Connecting/Connected/Disconnected)
94    pub status: ClientStatus,
95    /// When status last changed
96    pub status_changed_at: Instant,
97    /// Protocol-specific information (flexible JSON storage)
98    pub protocol_info: super::server::ProtocolConnectionInfo,
99}
100
101/// How many connection attempts a client remembers for the "recent
102/// connections" view.
103pub const CLIENT_CONNECTION_HISTORY_CAPACITY: usize = 20;
104
105/// One connection attempt in a client's lifetime, recorded on status
106/// transitions (see `AppState::update_client_status`). Wall-clock timestamps
107/// so the struct is serializable and stable across a UI session.
108#[derive(Debug, Clone, serde::Serialize)]
109pub struct ClientConnectionAttempt {
110    pub remote_addr: String,
111    pub started_unix_ms: u64,
112    /// None while the attempt is still live (Connecting/Connected).
113    pub ended_unix_ms: Option<u64>,
114    /// "connecting" | "connected" | "disconnected" | "error: …"
115    pub outcome: String,
116}
117
118fn now_unix_ms() -> u64 {
119    crate::utils::clock::SystemTime::now()
120        .duration_since(crate::utils::clock::UNIX_EPOCH)
121        .map(|d| d.as_millis() as u64)
122        .unwrap_or(0)
123}
124
125/// A client instance with its own connection, state, and configuration
126///
127/// `Clone` is derived; see [`crate::state::server::ServerInstance`] for why the hand-written
128/// copies it replaces existed.
129#[derive(Debug, Clone)]
130pub struct ClientInstance {
131    /// Unique client ID
132    pub id: ClientId,
133    /// Remote server address (hostname:port or IP:port)
134    pub remote_addr: String,
135    /// Protocol name (e.g., "HTTP", "SSH", "Redis")
136    pub protocol_name: String,
137    /// User instructions for this client
138    pub instruction: String,
139    /// LLM memory for this client
140    pub memory: String,
141    /// Client connection status
142    pub status: ClientStatus,
143    /// Connection state (if connected)
144    pub connection: Option<ClientConnectionState>,
145    /// When the client was created
146    pub created_at: Instant,
147    /// When the client status last changed
148    pub status_changed_at: Instant,
149    /// Protocol-specific startup parameters
150    pub startup_params: Option<serde_json::Value>,
151    /// Event handler configuration for handling protocol events
152    pub event_handler_config: Option<crate::scripting::EventHandlerConfig>,
153    /// Protocol-specific client data (flexible storage)
154    ///
155    /// This replaces protocol-specific feature-gated fields.
156    /// Each protocol can store any data structure here by serializing to JSON.
157    /// Use get_protocol_data() and set_protocol_data() helper methods.
158    pub protocol_data: serde_json::Value,
159    /// Log file paths (output_name -> log_file_path)
160    pub log_files: HashMap<String, PathBuf>,
161    /// Feedback instructions for automatic client adjustment
162    /// When set, server responses can provide feedback that triggers LLM-based client adjustments
163    pub feedback_instructions: Option<String>,
164    /// Accumulated feedback buffer (cleared after processing)
165    /// Each entry is a JSON value containing feedback data
166    pub feedback_buffer: Vec<serde_json::Value>,
167    /// Last time feedback was processed (for debouncing)
168    pub last_feedback_processed: Option<Instant>,
169    /// Connection attempts, newest last, capped at
170    /// [`CLIENT_CONNECTION_HISTORY_CAPACITY`]. Maintained by
171    /// `AppState::update_client_status` via [`Self::record_status_transition`].
172    pub connection_history: std::collections::VecDeque<ClientConnectionAttempt>,
173}
174
175impl ClientInstance {
176    /// Create a new client instance
177    pub fn new(
178        id: ClientId,
179        remote_addr: String,
180        protocol_name: String,
181        instruction: String,
182    ) -> Self {
183        let now = Instant::now();
184        Self {
185            id,
186            remote_addr,
187            protocol_name,
188            instruction,
189            memory: String::new(),
190            status: ClientStatus::Connecting,
191            connection: None,
192            created_at: now,
193            status_changed_at: now,
194            startup_params: None,
195            event_handler_config: None,
196            protocol_data: serde_json::Value::Object(serde_json::Map::new()),
197            log_files: HashMap::new(),
198            feedback_instructions: None,
199            feedback_buffer: Vec::new(),
200            last_feedback_processed: None,
201            connection_history: std::collections::VecDeque::new(),
202        }
203    }
204
205    /// Fold a status transition into [`Self::connection_history`]: a
206    /// `Connecting` opens a new attempt, `Connected` updates the live one, and
207    /// `Disconnected`/`Error` close it with an outcome.
208    pub fn record_status_transition(&mut self, status: &ClientStatus) {
209        let live_tail = self
210            .connection_history
211            .back_mut()
212            .filter(|attempt| attempt.ended_unix_ms.is_none());
213        match status {
214            ClientStatus::Connecting => {
215                // A reconnect while a previous attempt is still open closes it
216                // first so history never holds two live attempts.
217                if let Some(attempt) = live_tail {
218                    attempt.ended_unix_ms = Some(now_unix_ms());
219                }
220                self.connection_history.push_back(ClientConnectionAttempt {
221                    remote_addr: self.remote_addr.clone(),
222                    started_unix_ms: now_unix_ms(),
223                    ended_unix_ms: None,
224                    outcome: "connecting".to_string(),
225                });
226                let excess = self
227                    .connection_history
228                    .len()
229                    .saturating_sub(CLIENT_CONNECTION_HISTORY_CAPACITY);
230                for _ in 0..excess {
231                    self.connection_history.pop_front();
232                }
233            }
234            ClientStatus::Connected => {
235                if let Some(attempt) = live_tail {
236                    attempt.outcome = "connected".to_string();
237                } else {
238                    // Connected without a recorded Connecting (direct literal
239                    // construction starts at Connecting but never transitioned
240                    // through update_client_status): open-and-mark in one step.
241                    self.connection_history.push_back(ClientConnectionAttempt {
242                        remote_addr: self.remote_addr.clone(),
243                        started_unix_ms: now_unix_ms(),
244                        ended_unix_ms: None,
245                        outcome: "connected".to_string(),
246                    });
247                }
248            }
249            ClientStatus::Disconnected => {
250                if let Some(attempt) = live_tail {
251                    attempt.ended_unix_ms = Some(now_unix_ms());
252                    attempt.outcome = "disconnected".to_string();
253                }
254            }
255            ClientStatus::Error(message) => {
256                if let Some(attempt) = live_tail {
257                    attempt.ended_unix_ms = Some(now_unix_ms());
258                    attempt.outcome = format!("error: {message}");
259                }
260            }
261        }
262    }
263
264    /// Get protocol-specific data
265    pub fn get_protocol_data<T: serde::de::DeserializeOwned>(
266        &self,
267    ) -> Result<T, serde_json::Error> {
268        serde_json::from_value(self.protocol_data.clone())
269    }
270
271    /// Set protocol-specific data
272    pub fn set_protocol_data<T: serde::Serialize>(
273        &mut self,
274        data: T,
275    ) -> Result<(), serde_json::Error> {
276        self.protocol_data = serde_json::to_value(data)?;
277        Ok(())
278    }
279
280    /// Get a field from protocol data
281    pub fn get_protocol_field(&self, key: &str) -> Option<&serde_json::Value> {
282        self.protocol_data.get(key)
283    }
284
285    /// Set a field in protocol data
286    pub fn set_protocol_field(&mut self, key: String, value: serde_json::Value) {
287        if let Some(obj) = self.protocol_data.as_object_mut() {
288            obj.insert(key, value);
289        } else {
290            // Initialize as object if not already
291            let mut map = serde_json::Map::new();
292            map.insert(key, value);
293            self.protocol_data = serde_json::Value::Object(map);
294        }
295    }
296
297    /// Get or create a log file path for the given output name
298    /// Returns the path to the log file with format: netget_<output_name>_<timestamp>.log
299    /// The timestamp is based on when the client was created
300    ///
301    /// `output_name` is model-authored and becomes part of a filename; see
302    /// `server::validate_log_output_name`.
303    pub fn get_or_create_log_path(&mut self, output_name: &str) -> anyhow::Result<PathBuf> {
304        crate::state::server::validate_log_output_name(output_name)?;
305        if let Some(path) = self.log_files.get(output_name) {
306            return Ok(path.clone());
307        }
308
309        // Calculate the absolute time when the client was created
310        // by subtracting the elapsed time from now
311        let now = crate::utils::clock::SystemTime::now();
312        let elapsed = self.created_at.elapsed();
313        let created_system_time = now - elapsed;
314
315        // Convert to DateTime for formatting
316        let timestamp = crate::utils::clock::to_local_datetime(created_system_time);
317        let timestamp_str = timestamp.format("%Y_%m_%d_%H_%M_%S").to_string();
318
319        let log_filename = format!("netget_{}_{}.log", output_name, timestamp_str);
320        let log_path = PathBuf::from(log_filename);
321
322        self.log_files
323            .insert(output_name.to_string(), log_path.clone());
324        Ok(log_path)
325    }
326
327    /// Get a summary for display
328    pub fn summary(&self) -> String {
329        let duration = self.created_at.elapsed();
330        let duration_str = if duration.as_secs() < 60 {
331            format!("{}s", duration.as_secs())
332        } else if duration.as_secs() < 3600 {
333            format!("{}m", duration.as_secs() / 60)
334        } else {
335            format!("{}h", duration.as_secs() / 3600)
336        };
337
338        format!(
339            "#{} {} → {} ({}) - {}",
340            self.id.as_u32(),
341            self.protocol_name,
342            self.remote_addr,
343            self.status.as_str(),
344            duration_str
345        )
346    }
347}