1use std::fs::{File, OpenOptions};
7use std::io::{BufRead, BufReader, Write as _};
8use std::path::PathBuf;
9use tracing::{debug, warn};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
13pub enum LogLevel {
14 Error,
16 Warn,
18 #[default]
20 Info,
21 Debug,
23 Trace,
25}
26
27impl LogLevel {
28 pub fn parse(s: &str) -> Option<Self> {
29 match s.to_lowercase().as_str() {
30 "error" => Some(LogLevel::Error),
31 "warn" => Some(LogLevel::Warn),
32 "info" | "transactional" => Some(LogLevel::Info),
33 "debug" => Some(LogLevel::Debug),
34 "trace" | "verbose" => Some(LogLevel::Trace),
35 _ => None,
36 }
37 }
38
39 pub fn as_str(&self) -> &'static str {
40 match self {
41 LogLevel::Error => "ERROR",
42 LogLevel::Warn => "WARN",
43 LogLevel::Info => "INFO",
44 LogLevel::Debug => "DEBUG",
45 LogLevel::Trace => "TRACE",
46 }
47 }
48
49 pub fn cycle(&self) -> Self {
51 match self {
52 LogLevel::Error => LogLevel::Info,
53 LogLevel::Info => LogLevel::Trace,
54 LogLevel::Trace => LogLevel::Error,
55 LogLevel::Warn => LogLevel::Info,
57 LogLevel::Debug => LogLevel::Trace,
58 }
59 }
60
61 pub fn color(&self) -> crossterm::style::Color {
63 match self {
64 LogLevel::Error => crossterm::style::Color::Red,
65 LogLevel::Warn => crossterm::style::Color::Yellow,
66 LogLevel::Info => crossterm::style::Color::Green,
67 LogLevel::Debug => crossterm::style::Color::Cyan,
68 LogLevel::Trace => crossterm::style::Color::Blue,
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct ServerDisplayInfo {
76 pub id: String,
77 pub protocol: String,
78 pub port: u16,
79 pub status: String,
80 pub connections: usize,
81}
82
83#[derive(Debug, Clone)]
85pub struct ClientDisplayInfo {
86 pub id: String,
87 pub protocol: String,
88 pub remote_addr: String,
89 pub status: String,
90}
91
92#[derive(Debug, Clone)]
94pub struct ConnectionDisplayInfo {
95 pub id: u32,
96 pub server_id: String,
97 pub address: String,
98 pub state: String,
99}
100
101#[derive(Debug, Clone)]
103pub struct TaskDisplayInfo {
104 pub id: String,
105 pub name: String,
106 pub scope: String,
107 pub status: String,
108 pub task_type: String,
109}
110
111#[derive(Default, Clone)]
113pub struct ConnectionInfo {
114 pub mode: String,
115 pub protocol: String,
116 pub model: String,
117 pub local_addr: Option<String>,
118 pub remote_addr: Option<String>,
119 pub state: String,
120}
121
122#[derive(Default, Clone)]
124pub struct PacketStats {
125 pub packets_received: u64,
126 pub packets_sent: u64,
127}
128
129pub struct App {
131 pub command_history: Vec<String>,
133 pub history_position: Option<usize>,
135 pub history_temp_input: Option<String>,
137 pub connection_info: ConnectionInfo,
139 pub packet_stats: PacketStats,
141 pub log_level: LogLevel,
143 pub slash_suggestions: Vec<String>,
145 pub servers: Vec<ServerDisplayInfo>,
147 pub clients: Vec<ClientDisplayInfo>,
149 pub connections: Vec<ConnectionDisplayInfo>,
151 pub tasks: Vec<TaskDisplayInfo>,
153 pub expand_all_connections: bool,
155 pub next_global_connection_id: u32,
157 pub connection_id_map: std::collections::HashMap<String, u32>,
159 pub system_capabilities: crate::privilege::SystemCapabilities,
161 pub conversations: Vec<crate::state::app_state::ConversationInfo>,
163 pub show_usage_stats: bool,
165 pub system_stats: crate::system_stats::SystemStats,
167 pub active_form: Option<crate::cli::management::InteractiveForm>,
171}
172
173impl Default for App {
174 fn default() -> Self {
175 Self {
176 command_history: Vec::new(),
177 history_position: None,
178 history_temp_input: None,
179 connection_info: ConnectionInfo::default(),
180 packet_stats: PacketStats::default(),
181 log_level: LogLevel::default(), slash_suggestions: Vec::new(),
183 servers: Vec::new(),
184 clients: Vec::new(),
185 connections: Vec::new(),
186 tasks: Vec::new(),
187 expand_all_connections: false,
188 next_global_connection_id: 1,
189 connection_id_map: std::collections::HashMap::new(),
190 system_capabilities: crate::privilege::SystemCapabilities::detect(),
191 conversations: Vec::new(),
192 show_usage_stats: false,
193 system_stats: crate::system_stats::SystemStats::default(),
194 active_form: None,
195 }
196 }
197}
198
199impl App {
200 pub fn new(system_capabilities: crate::privilege::SystemCapabilities) -> Self {
202 let mut app = Self {
203 system_capabilities,
204 ..Default::default()
205 };
206 app.command_history = Self::load_history();
207 app
208 }
209
210 fn history_file_path() -> Option<PathBuf> {
212 dirs::home_dir().map(|mut path| {
213 path.push(".netget_history");
214 path
215 })
216 }
217
218 fn escape_for_storage(s: &str) -> String {
220 s.replace('\\', "\\\\")
221 .replace('\n', "\\n")
222 .replace('\r', "\\r")
223 .replace('\t', "\\t")
224 }
225
226 fn unescape_from_storage(s: &str) -> String {
228 let mut result = String::with_capacity(s.len());
229 let mut chars = s.chars();
230
231 while let Some(ch) = chars.next() {
232 if ch == '\\' {
233 match chars.next() {
234 Some('n') => result.push('\n'),
235 Some('r') => result.push('\r'),
236 Some('t') => result.push('\t'),
237 Some('\\') => result.push('\\'),
238 Some(c) => {
239 result.push('\\');
240 result.push(c);
241 }
242 None => result.push('\\'),
243 }
244 } else {
245 result.push(ch);
246 }
247 }
248
249 result
250 }
251
252 fn load_history() -> Vec<String> {
254 let Some(path) = Self::history_file_path() else {
255 warn!("Could not determine home directory for history file");
256 return Vec::new();
257 };
258
259 if !path.exists() {
260 debug!("History file does not exist yet: {:?}", path);
261 return Vec::new();
262 }
263
264 match File::open(&path) {
265 Ok(file) => {
266 let reader = BufReader::new(file);
267 let history: Vec<String> = reader
268 .lines()
269 .map_while(Result::ok)
270 .filter(|line| !line.trim().is_empty())
271 .map(|line| Self::unescape_from_storage(&line))
272 .collect();
273 debug!("Loaded {} commands from history", history.len());
274 history
275 }
276 Err(e) => {
277 warn!("Failed to open history file: {}", e);
278 Vec::new()
279 }
280 }
281 }
282
283 pub fn save_history(&self) -> std::io::Result<()> {
285 let Some(path) = Self::history_file_path() else {
286 return Ok(()); };
288
289 let mut file = OpenOptions::new()
290 .create(true)
291 .write(true)
292 .truncate(true)
293 .open(&path)?;
294
295 for command in &self.command_history {
296 writeln!(file, "{}", Self::escape_for_storage(command))?;
297 }
298
299 debug!("Saved {} commands to history", self.command_history.len());
300 Ok(())
301 }
302
303 pub fn add_to_history(&mut self, command: String) {
305 if !command.trim().is_empty()
306 && (self.command_history.is_empty() || self.command_history.last() != Some(&command))
307 {
308 self.command_history.push(command);
309 }
310 self.history_position = None;
312 self.history_temp_input = None;
313 }
314
315 pub fn get_or_allocate_connection_id(&mut self, network_conn_id: String) -> u32 {
317 if let Some(&id) = self.connection_id_map.get(&network_conn_id) {
318 id
319 } else {
320 let id = self.next_global_connection_id;
321 self.next_global_connection_id += 1;
322 self.connection_id_map.insert(network_conn_id, id);
323 id
324 }
325 }
326
327 pub fn remove_connection_id(&mut self, network_conn_id: &str) {
329 self.connection_id_map.remove(network_conn_id);
330 }
331
332 pub fn update_slash_suggestions(&mut self, input: &str) {
334 if !input.starts_with('/') {
336 self.slash_suggestions.clear();
337 return;
338 }
339
340 let all_commands = vec![
342 "/exit - Exit the application",
343 "/model [<name>] - List/select a model",
344 "/log [<level>] - Show/set log level (error, warn, info, debug, trace)",
345 "/script [<env>] - Show/set scripting environment (llm, python, javascript, go)",
346 "/web [on/off/ask] - Show/set web search mode",
347 "/test_ask - Test web search approval prompt",
348 "/backend [ollama|openai <url>] - Show/switch LLM backend",
349 "/docs [<protocol>] - Show protocol documentation",
350 "/stability - List protocols grouped by development state",
351 "/manage - List running servers/clients and show create/update shapes",
352 "/create <protocol> [--client] - Interactive prefilled create form",
353 "/edit <id> - Interactive form prefilled with a running instance's config",
354 "/update <id> <instruction> - Update a running server/client in place",
355 ];
356
357 let input_lower = input.to_lowercase();
359 self.slash_suggestions = all_commands
360 .into_iter()
361 .filter(|cmd| cmd.to_lowercase().starts_with(&input_lower))
362 .map(|s| s.to_string())
363 .collect();
364 }
365
366 pub fn set_log_level(&mut self, level: LogLevel) {
368 self.log_level = level;
369 }
370
371 pub fn toggle_expand_all(&mut self) {
373 self.expand_all_connections = !self.expand_all_connections;
374 }
375
376 pub fn toggle_usage_stats(&mut self) {
378 self.show_usage_stats = !self.show_usage_stats;
379 }
380
381 pub fn add_llm_message(&mut self, _message: String) {
383 }
386}