1use crate::Result;
2use crate::daemon::Daemon;
3use crate::daemon_id::DaemonId;
4use crate::daemon_list::{DaemonListEntry, NamespaceFilter};
5use crate::ipc::client::IpcClient;
6use crate::log_store::LogStore;
7use crate::log_store::sqlite::LOG_STORE;
8use crate::pitchfork_toml::{
9 CronRetrigger, HealthCmd, HealthHttp, HealthPort, PitchforkToml, PitchforkTomlAuto,
10 PitchforkTomlCron, PitchforkTomlDaemon, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry,
11 RunCommand, namespace_from_path,
12};
13use crate::procs::{PROCS, ProcessStats};
14use crate::settings::settings;
15use fuzzy_matcher::FuzzyMatcher;
16use fuzzy_matcher::skim::SkimMatcherV2;
17use listeners::Listener;
18use std::collections::{HashMap, HashSet, VecDeque};
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::time::Instant;
22
23fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
25 s.char_indices()
26 .nth(char_idx)
27 .map(|(i, _)| i)
28 .unwrap_or(s.len())
29}
30
31#[derive(Debug, Clone, Copy)]
33pub struct StatsSnapshot {
34 pub cpu_percent: f32,
35 pub memory_bytes: u64,
36 pub disk_read_bytes: u64,
37 pub disk_write_bytes: u64,
38}
39
40impl From<&ProcessStats> for StatsSnapshot {
41 fn from(stats: &ProcessStats) -> Self {
42 Self {
43 cpu_percent: stats.cpu_percent,
44 memory_bytes: stats.memory_bytes,
45 disk_read_bytes: stats.disk_read_bytes,
46 disk_write_bytes: stats.disk_write_bytes,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default)]
53pub struct StatsHistory {
54 pub samples: VecDeque<StatsSnapshot>,
55}
56
57impl StatsHistory {
58 pub fn push(&mut self, snapshot: StatsSnapshot) {
59 self.samples.push_back(snapshot);
60 let max_history = settings().tui.stat_history.max(1) as usize;
61 while self.samples.len() > max_history {
62 self.samples.pop_front();
63 }
64 }
65
66 pub fn cpu_values(&self) -> Vec<f32> {
67 self.samples.iter().map(|s| s.cpu_percent).collect()
68 }
69
70 pub fn memory_values(&self) -> Vec<u64> {
71 self.samples.iter().map(|s| s.memory_bytes).collect()
72 }
73
74 pub fn disk_read_values(&self) -> Vec<u64> {
75 self.samples.iter().map(|s| s.disk_read_bytes).collect()
76 }
77
78 pub fn disk_write_values(&self) -> Vec<u64> {
79 self.samples.iter().map(|s| s.disk_write_bytes).collect()
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum View {
85 Dashboard,
86 Logs,
87 Network,
88 Help,
89 Confirm,
90 Details,
91 ConfigEditor,
92 ConfigFileSelect,
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub enum EditMode {
98 Create,
99 Edit { original_id: String },
100}
101
102#[derive(Debug, Clone)]
104pub enum FormFieldValue {
105 Text(String),
106 OptionalText(Option<String>),
107 Number(u32),
108 OptionalNumber(Option<u64>),
109 OptionalPort(Option<ReadyPort>),
110 #[allow(dead_code)]
111 Boolean(bool),
112 OptionalBoolean(Option<bool>),
113 AutoBehavior(Vec<PitchforkTomlAuto>),
114 Retrigger(CronRetrigger),
115 StringList(Vec<String>),
116}
117
118#[derive(Debug, Clone)]
120pub struct FormField {
121 pub name: &'static str,
122 pub label: &'static str,
123 pub value: FormFieldValue,
124 pub required: bool,
125 #[allow(dead_code)]
126 pub help_text: &'static str,
127 pub error: Option<String>,
128 pub editing: bool,
129 pub cursor: usize,
130}
131
132impl FormField {
133 fn text(name: &'static str, label: &'static str, help: &'static str, required: bool) -> Self {
134 Self {
135 name,
136 label,
137 value: FormFieldValue::Text(String::new()),
138 required,
139 help_text: help,
140 error: None,
141 editing: false,
142 cursor: 0,
143 }
144 }
145
146 fn optional_text(name: &'static str, label: &'static str, help: &'static str) -> Self {
147 Self {
148 name,
149 label,
150 value: FormFieldValue::OptionalText(None),
151 required: false,
152 help_text: help,
153 error: None,
154 editing: false,
155 cursor: 0,
156 }
157 }
158
159 fn number(name: &'static str, label: &'static str, help: &'static str, default: u32) -> Self {
160 Self {
161 name,
162 label,
163 value: FormFieldValue::Number(default),
164 required: false,
165 help_text: help,
166 error: None,
167 editing: false,
168 cursor: 0,
169 }
170 }
171
172 fn optional_number(name: &'static str, label: &'static str, help: &'static str) -> Self {
173 Self {
174 name,
175 label,
176 value: FormFieldValue::OptionalNumber(None),
177 required: false,
178 help_text: help,
179 error: None,
180 editing: false,
181 cursor: 0,
182 }
183 }
184
185 fn optional_port(name: &'static str, label: &'static str, help: &'static str) -> Self {
186 Self {
187 name,
188 label,
189 value: FormFieldValue::OptionalPort(None),
190 required: false,
191 help_text: help,
192 error: None,
193 editing: false,
194 cursor: 0,
195 }
196 }
197
198 fn optional_bool(name: &'static str, label: &'static str, help: &'static str) -> Self {
199 Self {
200 name,
201 label,
202 value: FormFieldValue::OptionalBoolean(None),
203 required: false,
204 help_text: help,
205 error: None,
206 editing: false,
207 cursor: 0,
208 }
209 }
210
211 fn auto_behavior(name: &'static str, label: &'static str, help: &'static str) -> Self {
212 Self {
213 name,
214 label,
215 value: FormFieldValue::AutoBehavior(vec![]),
216 required: false,
217 help_text: help,
218 error: None,
219 editing: false,
220 cursor: 0,
221 }
222 }
223
224 fn retrigger(name: &'static str, label: &'static str, help: &'static str) -> Self {
225 Self {
226 name,
227 label,
228 value: FormFieldValue::Retrigger(CronRetrigger::Finish),
229 required: false,
230 help_text: help,
231 error: None,
232 editing: false,
233 cursor: 0,
234 }
235 }
236
237 fn string_list(name: &'static str, label: &'static str, help: &'static str) -> Self {
238 Self {
239 name,
240 label,
241 value: FormFieldValue::StringList(vec![]),
242 required: false,
243 help_text: help,
244 error: None,
245 editing: false,
246 cursor: 0,
247 }
248 }
249
250 pub fn get_text(&self) -> String {
251 match &self.value {
252 FormFieldValue::Text(s) => s.clone(),
253 FormFieldValue::OptionalText(Some(s)) => s.clone(),
254 FormFieldValue::OptionalText(None) => String::new(),
255 FormFieldValue::Number(n) => n.to_string(),
256 FormFieldValue::OptionalNumber(Some(n)) => n.to_string(),
257 FormFieldValue::OptionalNumber(None) => String::new(),
258 FormFieldValue::OptionalPort(Some(p)) => p.to_string(),
259 FormFieldValue::OptionalPort(None) => String::new(),
260 FormFieldValue::StringList(v) => v.join(", "),
261 _ => String::new(),
262 }
263 }
264
265 pub fn set_text(&mut self, text: String) {
266 self.error = None;
269 match &mut self.value {
270 FormFieldValue::Text(s) => *s = text,
271 FormFieldValue::OptionalText(opt) => {
272 *opt = if text.is_empty() { None } else { Some(text) };
273 }
274 FormFieldValue::Number(n) => {
275 let trimmed = text.trim();
276 if trimmed.is_empty() {
277 *n = 0;
278 self.error = None;
279 } else {
280 match trimmed.parse() {
281 Ok(value) => {
282 *n = value;
283 self.error = None;
284 }
285 Err(_) => {
286 *n = 0;
287 self.error = Some("Invalid number".to_string());
288 }
289 }
290 }
291 }
292 FormFieldValue::OptionalNumber(opt) => {
293 *opt = text.parse().ok();
294 }
295 FormFieldValue::OptionalPort(opt) => {
296 let trimmed = text.trim();
297 if trimmed.is_empty() {
298 *opt = None;
299 self.error = None;
300 } else {
301 match trimmed.parse() {
302 Ok(value) => {
303 *opt = Some(value);
304 self.error = None;
305 }
306 Err(e) => {
307 *opt = None;
308 self.error = Some(e);
309 }
310 }
311 }
312 }
313 FormFieldValue::StringList(v) => {
314 *v = text
315 .split(',')
316 .map(|s| s.trim().to_string())
317 .filter(|s| !s.is_empty())
318 .collect();
319 }
320 _ => {}
321 }
322 }
323
324 pub fn is_text_editable(&self) -> bool {
325 matches!(
326 self.value,
327 FormFieldValue::Text(_)
328 | FormFieldValue::OptionalText(_)
329 | FormFieldValue::Number(_)
330 | FormFieldValue::OptionalNumber(_)
331 | FormFieldValue::OptionalPort(_)
332 | FormFieldValue::StringList(_)
333 )
334 }
335}
336
337fn argv_to_edit_text(argv: &[String]) -> String {
344 if cfg!(windows) {
345 argv.iter()
346 .map(|arg| windows_edit_quote(arg))
347 .collect::<Vec<_>>()
348 .join(" ")
349 } else {
350 shell_words::join(argv)
351 }
352}
353
354fn edit_text_to_argv(text: &str) -> Result<Vec<String>, String> {
356 if cfg!(windows) {
357 windows_edit_split(text)
358 } else {
359 shell_words::split(text).map_err(|e| e.to_string())
360 }
361}
362
363fn windows_edit_quote(arg: &str) -> String {
365 if !arg.is_empty() && !arg.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"') {
366 return arg.to_string();
367 }
368 let mut quoted = String::from("'");
371 for c in arg.chars() {
372 if c == '\'' {
373 quoted.push_str("'\"'\"'");
374 } else {
375 quoted.push(c);
376 }
377 }
378 quoted.push('\'');
379 quoted
380}
381
382fn windows_edit_split(text: &str) -> Result<Vec<String>, String> {
385 let mut words = Vec::new();
386 let mut word = String::new();
387 let mut in_word = false;
389 let mut chars = text.chars();
390 while let Some(c) = chars.next() {
391 match c {
392 '\'' | '"' => {
393 in_word = true;
394 loop {
395 match chars.next() {
396 Some(q) if q == c => break,
397 Some(other) => word.push(other),
398 None => return Err("unclosed quote".to_string()),
399 }
400 }
401 }
402 c if c.is_whitespace() => {
403 if in_word {
404 words.push(std::mem::take(&mut word));
405 in_word = false;
406 }
407 }
408 c => {
409 in_word = true;
410 word.push(c);
411 }
412 }
413 }
414 if in_word {
415 words.push(word);
416 }
417 Ok(words)
418}
419
420#[derive(Debug, Clone)]
422pub struct EditorState {
423 pub mode: EditMode,
424 pub daemon_id: String,
425 pub daemon_id_editing: bool,
426 pub daemon_id_cursor: usize,
427 pub daemon_id_error: Option<String>,
428 pub fields: Vec<FormField>,
429 pub focused_field: usize,
430 pub config_path: PathBuf,
431 pub unsaved_changes: bool,
432 #[allow(dead_code)]
433 pub scroll_offset: usize,
434 preserved_ready_cmd: Option<ReadyCmd>,
436 preserved_oneshot: Option<bool>,
440 preserved_run_argv: Option<RunCommand>,
444 preserved_ready_http_status: Option<Vec<u16>>,
446 preserved_ready_http_timeout: Option<std::time::Duration>,
448 preserved_ready_output_timeout: Option<std::time::Duration>,
450 preserved_health_cmd: Option<HealthCmd>,
452 preserved_health_http: Option<HealthHttp>,
454 preserved_health_port: Option<HealthPort>,
456}
457
458impl EditorState {
459 pub fn new_create(config_path: PathBuf) -> Self {
460 Self {
461 mode: EditMode::Create,
462 daemon_id: String::new(),
463 daemon_id_editing: true,
464 daemon_id_cursor: 0,
465 daemon_id_error: None,
466 fields: Self::default_fields(),
467 focused_field: 0,
468 config_path,
469 unsaved_changes: false,
470 scroll_offset: 0,
471 preserved_ready_cmd: None,
472 preserved_oneshot: None,
473 preserved_run_argv: None,
474 preserved_ready_http_status: None,
475 preserved_ready_http_timeout: None,
476 preserved_ready_output_timeout: None,
477 preserved_health_cmd: None,
478 preserved_health_http: None,
479 preserved_health_port: None,
480 }
481 }
482
483 pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
484 Self {
485 mode: EditMode::Edit {
486 original_id: daemon_id.clone(),
487 },
488 daemon_id,
489 daemon_id_editing: false,
490 daemon_id_cursor: 0,
491 daemon_id_error: None,
492 fields: Self::fields_from_config(config),
493 focused_field: 0,
494 config_path,
495 unsaved_changes: false,
496 scroll_offset: 0,
497 preserved_ready_cmd: config.ready_cmd.clone(),
498 preserved_oneshot: config.oneshot,
499 preserved_run_argv: config.run.is_argv().then(|| config.run.clone()),
500 preserved_ready_http_status: config
501 .ready_http
502 .as_ref()
503 .and_then(|h| (!h.status.is_empty()).then(|| h.status.clone())),
504 preserved_ready_http_timeout: config.ready_http.as_ref().and_then(|h| h.timeout),
505 preserved_ready_output_timeout: config.ready_output.as_ref().and_then(|o| o.timeout),
506 preserved_health_cmd: config.health_cmd.clone(),
507 preserved_health_http: config.health_http.clone(),
508 preserved_health_port: config.health_port.clone(),
509 }
510 }
511
512 fn default_fields() -> Vec<FormField> {
513 vec![
514 FormField::text(
515 "run",
516 "Run Command",
517 "Command to execute. Prepend 'exec' to avoid shell overhead.",
518 true,
519 ),
520 FormField::optional_text(
521 "dir",
522 "Working Directory",
523 "Working directory for the daemon. Relative to pitchfork.toml location.",
524 ),
525 FormField::string_list(
526 "env",
527 "Environment Variables",
528 "Comma-separated KEY=VALUE pairs (e.g., NODE_ENV=dev, PORT=3000).",
529 ),
530 FormField::auto_behavior(
531 "auto",
532 "Auto Behavior",
533 "Auto start/stop based on directory hooks.",
534 ),
535 FormField::number(
536 "retry",
537 "Retry Count",
538 "Number of retry attempts on failure (0 = no retries).",
539 0,
540 ),
541 FormField::optional_number(
542 "ready_delay",
543 "Ready Delay (ms)",
544 "Milliseconds to wait before considering daemon ready.",
545 ),
546 FormField::optional_text(
547 "ready_output",
548 "Ready Output Pattern",
549 "Regex pattern in ANSI-stripped stdout/stderr indicating readiness.",
550 ),
551 FormField::optional_text(
552 "ready_http",
553 "Ready HTTP URL",
554 "HTTP URL to poll for readiness (expects 2xx).",
555 ),
556 FormField::optional_port(
557 "ready_port",
558 "Ready Port",
559 "TCP port to check for readiness (1-65535).",
560 ),
561 FormField::optional_text(
562 "health_cmd",
563 "Health Command",
564 "Shell command that must exit 0 for the daemon to stay healthy.",
565 ),
566 FormField::optional_text(
567 "health_http",
568 "Health HTTP URL",
569 "HTTP URL to poll for health (expects 2xx).",
570 ),
571 FormField::optional_port(
572 "health_port",
573 "Health Port",
574 "TCP port that must accept connections for the daemon to stay healthy (1-65535).",
575 ),
576 FormField::optional_bool(
577 "boot_start",
578 "Start on Boot",
579 "Automatically start this daemon on system boot.",
580 ),
581 FormField::string_list(
582 "depends",
583 "Dependencies",
584 "Comma-separated daemon names that must start first.",
585 ),
586 FormField::string_list(
587 "watch",
588 "Watch Files",
589 "Comma-separated glob patterns to watch for auto-restart.",
590 ),
591 FormField::optional_text(
592 "cron_schedule",
593 "Cron Schedule",
594 "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
595 ),
596 FormField::retrigger(
597 "cron_retrigger",
598 "Cron Retrigger",
599 "Behavior when cron triggers while previous run is active.",
600 ),
601 FormField::optional_bool(
602 "cron_immediate",
603 "Cron Immediate",
604 "Trigger immediately on first check (default: false).",
605 ),
606 ]
607 }
608
609 fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
610 let mut fields = Self::default_fields();
611
612 for field in &mut fields {
613 match field.name {
614 "run" => {
615 field.value = FormFieldValue::Text(match &config.run {
616 RunCommand::Argv(argv) => argv_to_edit_text(argv),
617 RunCommand::Shell(run) => run.clone(),
618 })
619 }
620 "dir" => field.value = FormFieldValue::OptionalText(config.dir.clone()),
621 "env" => {
622 field.value = FormFieldValue::StringList(
623 config
624 .env
625 .as_ref()
626 .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
627 .unwrap_or_default(),
628 );
629 }
630 "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
631 "retry" => field.value = FormFieldValue::Number(config.retry.count()),
632 "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
633 "ready_output" => {
634 field.value = FormFieldValue::OptionalText(
635 config.ready_output.as_ref().map(|o| o.pattern.clone()),
636 )
637 }
638 "ready_http" => {
639 field.value = FormFieldValue::OptionalText(
640 config.ready_http.as_ref().map(|h| h.url.clone()),
641 )
642 }
643 "ready_port" => {
644 field.value = FormFieldValue::OptionalPort(config.ready_port.clone())
645 }
646 "health_cmd" => {
647 field.value = FormFieldValue::OptionalText(
648 config.health_cmd.as_ref().map(|c| c.run.clone()),
649 )
650 }
651 "health_http" => {
652 field.value = FormFieldValue::OptionalText(
653 config.health_http.as_ref().map(|h| h.url.clone()),
654 )
655 }
656 "health_port" => {
657 field.value =
658 FormFieldValue::OptionalPort(config.health_port.as_ref().map(|hp| {
659 ReadyPort {
660 port: hp.port,
661 template: hp.template.clone(),
662 timeout: None,
663 }
664 }))
665 }
666 "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
667 "depends" => {
668 field.value = FormFieldValue::StringList(
669 config
670 .depends
671 .iter()
672 .map(|d: &DaemonId| d.qualified())
673 .collect(),
674 )
675 }
676 "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
677 "cron_schedule" => {
678 field.value = FormFieldValue::OptionalText(
679 config.cron.as_ref().map(|c| c.schedule.clone()),
680 );
681 }
682 "cron_retrigger" => {
683 field.value = FormFieldValue::Retrigger(
684 config
685 .cron
686 .as_ref()
687 .map(|c| c.retrigger)
688 .unwrap_or(CronRetrigger::Finish),
689 );
690 }
691 "cron_immediate" => {
692 field.value =
693 FormFieldValue::OptionalBoolean(config.cron.as_ref().map(|c| c.immediate));
694 }
695 _ => {}
696 }
697 }
698
699 fields
700 }
701
702 pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
703 let mut config = PitchforkTomlDaemon {
704 ready_cmd: self.preserved_ready_cmd.clone(),
705 oneshot: self.preserved_oneshot,
706 path: Some(self.config_path.clone()),
707 ..PitchforkTomlDaemon::default()
708 };
709
710 let mut cron_schedule: Option<String> = None;
711 let mut cron_retrigger = CronRetrigger::Finish;
712 let mut cron_immediate = false;
713
714 for field in &self.fields {
715 match (field.name, &field.value) {
716 ("run", FormFieldValue::Text(s)) => {
717 config.run = match &self.preserved_run_argv {
718 Some(RunCommand::Argv(argv)) if argv_to_edit_text(argv) == *s => {
719 RunCommand::Argv(argv.clone())
720 }
721 Some(_) => match edit_text_to_argv(s) {
725 Ok(words) if !words.is_empty() => words.into(),
726 _ => s.clone().into(),
727 },
728 None => s.clone().into(),
729 };
730 }
731 ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
732 ("env", FormFieldValue::StringList(v)) => {
733 if v.is_empty() {
734 config.env = None;
735 } else {
736 let mut map = indexmap::IndexMap::new();
737 for entry in v {
738 if let Some((k, val)) = entry.split_once('=') {
739 map.insert(k.trim().to_string(), val.trim().to_string());
740 }
741 }
742 config.env = if map.is_empty() { None } else { Some(map) };
743 }
744 }
745 ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
746 ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
747 ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
748 ("ready_output", FormFieldValue::OptionalText(s)) => {
749 config.ready_output = s.clone().map(|pattern| ReadyOutput {
750 pattern,
751 timeout: self.preserved_ready_output_timeout,
752 })
753 }
754 ("ready_http", FormFieldValue::OptionalText(s)) => {
755 config.ready_http = s.clone().map(|url| ReadyHttp {
756 url,
757 status: self.preserved_ready_http_status.clone().unwrap_or_default(),
758 timeout: self.preserved_ready_http_timeout,
759 })
760 }
761 ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = p.clone(),
762 ("health_cmd", FormFieldValue::OptionalText(s)) => {
763 config.health_cmd = s.clone().map(|run| {
764 let mut cmd = self.preserved_health_cmd.clone().unwrap_or_default();
765 cmd.run = run;
766 cmd
767 })
768 }
769 ("health_http", FormFieldValue::OptionalText(s)) => {
770 config.health_http = s.clone().map(|url| {
771 let mut http = self.preserved_health_http.clone().unwrap_or_default();
772 http.url = url;
773 http
774 })
775 }
776 ("health_port", FormFieldValue::OptionalPort(p)) => {
777 config.health_port = p.clone().map(|port_value| {
778 let mut hp = self.preserved_health_port.clone().unwrap_or_default();
779 hp.port = port_value.port;
780 hp.template = port_value.template;
781 hp
782 })
783 }
784 ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
785 ("depends", FormFieldValue::StringList(v)) => {
786 config.depends = v.iter().filter_map(|s| DaemonId::parse(s).ok()).collect()
787 }
788 ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
789 ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
790 ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
791 ("cron_immediate", FormFieldValue::OptionalBoolean(b)) => {
792 cron_immediate = b.unwrap_or(false);
793 }
794 _ => {}
795 }
796 }
797
798 if let Some(schedule) = cron_schedule {
799 config.cron = Some(PitchforkTomlCron {
800 schedule,
801 retrigger: cron_retrigger,
802 immediate: cron_immediate,
803 });
804 }
805
806 config
807 }
808
809 pub fn next_field(&mut self) {
810 if let Some(field) = self.fields.get_mut(self.focused_field) {
812 field.editing = false;
813 }
814
815 if self.daemon_id_editing {
817 self.daemon_id_editing = false;
818 return;
819 }
820
821 if self.focused_field < self.fields.len() - 1 {
822 self.focused_field += 1;
823 }
824 }
825
826 pub fn prev_field(&mut self) {
827 if let Some(field) = self.fields.get_mut(self.focused_field) {
829 field.editing = false;
830 }
831 self.daemon_id_editing = false;
832
833 if self.focused_field > 0 {
834 self.focused_field -= 1;
835 }
836 }
837
838 pub fn toggle_current_field(&mut self) {
839 if let Some(field) = self.fields.get_mut(self.focused_field) {
840 let toggled = match &mut field.value {
841 FormFieldValue::Boolean(b) => {
842 *b = !*b;
843 true
844 }
845 FormFieldValue::OptionalBoolean(opt) => {
846 *opt = match opt {
847 None => Some(true),
848 Some(true) => Some(false),
849 Some(false) => None,
850 };
851 true
852 }
853 FormFieldValue::AutoBehavior(v) => {
854 let has_start = v.contains(&PitchforkTomlAuto::Start);
856 let has_stop = v.contains(&PitchforkTomlAuto::Stop);
857 *v = match (has_start, has_stop) {
858 (false, false) => vec![PitchforkTomlAuto::Start],
859 (true, false) => vec![PitchforkTomlAuto::Stop],
860 (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
861 (true, true) => vec![],
862 };
863 true
864 }
865 FormFieldValue::Retrigger(r) => {
866 *r = match r {
867 CronRetrigger::Finish => CronRetrigger::Always,
868 CronRetrigger::Always => CronRetrigger::Success,
869 CronRetrigger::Success => CronRetrigger::Fail,
870 CronRetrigger::Fail => CronRetrigger::Finish,
871 };
872 true
873 }
874 _ => false,
875 };
876 if toggled {
877 self.unsaved_changes = true;
878 }
879 }
880 }
881
882 pub fn start_editing(&mut self) {
883 if let Some(field) = self.fields.get_mut(self.focused_field) {
884 if field.is_text_editable() {
885 field.editing = true;
886 field.cursor = field.get_text().chars().count();
887 } else {
888 self.toggle_current_field();
890 }
891 }
892 }
893
894 pub fn stop_editing(&mut self) {
895 if let Some(field) = self.fields.get_mut(self.focused_field) {
896 field.editing = false;
897 }
898 self.daemon_id_editing = false;
899 }
900
901 pub fn is_editing(&self) -> bool {
902 self.daemon_id_editing
903 || self
904 .fields
905 .get(self.focused_field)
906 .map(|f| f.editing)
907 .unwrap_or(false)
908 }
909
910 pub fn text_push(&mut self, c: char) {
911 if self.daemon_id_editing {
912 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
913 self.daemon_id.insert(byte_idx, c);
914 self.daemon_id_cursor += 1;
915 self.unsaved_changes = true;
916 } else if let Some(field) = self.fields.get_mut(self.focused_field)
917 && field.editing
918 {
919 let mut text = field.get_text();
920 let byte_idx = char_to_byte_index(&text, field.cursor);
921 text.insert(byte_idx, c);
922 field.cursor += 1;
923 field.set_text(text);
924 self.unsaved_changes = true;
925 }
926 }
927
928 pub fn text_pop(&mut self) {
929 if self.daemon_id_editing && self.daemon_id_cursor > 0 {
930 self.daemon_id_cursor -= 1;
931 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
932 self.daemon_id.remove(byte_idx);
933 self.unsaved_changes = true;
934 } else if let Some(field) = self.fields.get_mut(self.focused_field)
935 && field.editing
936 && field.cursor > 0
937 {
938 let mut text = field.get_text();
939 field.cursor = field.cursor.min(text.chars().count());
942 if field.cursor == 0 {
943 return;
944 }
945 field.cursor -= 1;
946 let byte_idx = char_to_byte_index(&text, field.cursor);
947 text.remove(byte_idx);
948 field.set_text(text);
949 if matches!(field.value, FormFieldValue::Number(_)) {
952 field.cursor = field.get_text().chars().count();
953 }
954 self.unsaved_changes = true;
955 }
956 }
957
958 pub fn validate(&mut self) -> bool {
959 let mut valid = true;
960
961 self.daemon_id_error = None;
963 if self.daemon_id.is_empty() {
964 self.daemon_id_error = Some("Name is required".to_string());
965 valid = false;
966 } else if !self
967 .daemon_id
968 .chars()
969 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
970 {
971 self.daemon_id_error =
972 Some("Only letters, digits, hyphens, and underscores allowed".to_string());
973 valid = false;
974 }
975
976 let run_is_argv = self.preserved_run_argv.is_some();
978 for field in &mut self.fields {
979 if field.error.is_some() {
983 valid = false;
984 continue;
985 }
986
987 match (field.name, &field.value) {
988 ("run", FormFieldValue::Text(s)) if s.is_empty() => {
989 field.error = Some("Required".to_string());
990 valid = false;
991 }
992 ("run", FormFieldValue::Text(s)) if run_is_argv => {
993 match edit_text_to_argv(s).as_deref() {
994 Ok([]) => {
995 field.error = Some("Required".to_string());
996 valid = false;
997 }
998 Ok([program, ..]) if program.is_empty() => {
999 field.error = Some("The program name is empty".to_string());
1000 valid = false;
1001 }
1002 Ok([program, ..]) if program == "exec" => {
1003 field.error = Some(
1004 "Remove exec: this command starts the program without a shell"
1005 .to_string(),
1006 );
1007 valid = false;
1008 }
1009 Ok(_) => {}
1010 Err(_) => {
1011 field.error = Some("Unbalanced quotes".to_string());
1012 valid = false;
1013 }
1014 }
1015 }
1016 ("ready_http", FormFieldValue::OptionalText(Some(url)))
1017 if !(url.starts_with("http://") || url.starts_with("https://")) =>
1018 {
1019 field.error = Some("Must start with http:// or https://".to_string());
1020 valid = false;
1021 }
1022 ("health_http", FormFieldValue::OptionalText(Some(url)))
1023 if !(url.starts_with("http://") || url.starts_with("https://")) =>
1024 {
1025 field.error = Some("Must start with http:// or https://".to_string());
1026 valid = false;
1027 }
1028 _ => {}
1029 }
1030 }
1031
1032 valid
1033 }
1034}
1035
1036#[derive(Debug, Clone)]
1038pub struct ConfigFileSelector {
1039 pub files: Vec<PathBuf>,
1040 pub selected: usize,
1041}
1042
1043#[derive(Debug, Clone)]
1044pub enum PendingAction {
1045 Stop(DaemonId),
1046 Restart(DaemonId),
1047 Disable(DaemonId),
1048 BatchStop(Vec<DaemonId>),
1050 BatchRestart(Vec<DaemonId>),
1051 BatchDisable(Vec<DaemonId>),
1052 Clean(Vec<DaemonId>),
1053 DeleteDaemon { id: String, config_path: PathBuf },
1055 DiscardEditorChanges,
1056}
1057
1058#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1059pub enum SortColumn {
1060 #[default]
1061 Name,
1062 Status,
1063 Cpu,
1064 Memory,
1065 Uptime,
1066}
1067
1068impl SortColumn {
1069 pub fn next(self) -> Self {
1070 match self {
1071 Self::Name => Self::Status,
1072 Self::Status => Self::Cpu,
1073 Self::Cpu => Self::Memory,
1074 Self::Memory => Self::Uptime,
1075 Self::Uptime => Self::Name,
1076 }
1077 }
1078}
1079
1080#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1081pub enum SortOrder {
1082 #[default]
1083 Ascending,
1084 Descending,
1085}
1086
1087impl SortOrder {
1088 pub fn toggle(self) -> Self {
1089 match self {
1090 Self::Ascending => Self::Descending,
1091 Self::Descending => Self::Ascending,
1092 }
1093 }
1094
1095 pub fn indicator(self) -> &'static str {
1096 match self {
1097 Self::Ascending => "↑",
1098 Self::Descending => "↓",
1099 }
1100 }
1101}
1102
1103pub struct App {
1104 pub daemons: Vec<Daemon>,
1105 pub disabled: Vec<DaemonId>,
1106 pub selected: usize,
1107 pub view: View,
1108 pub prev_view: View,
1109 pub log_content: Vec<String>,
1110 pub log_daemon_id: Option<DaemonId>,
1111 pub log_scroll: usize,
1112 pub log_follow: bool, pub message: Option<String>,
1114 pub message_time: Option<Instant>,
1115 pub process_stats: HashMap<u32, ProcessStats>, pub stats_history: HashMap<DaemonId, StatsHistory>, pub pending_action: Option<PendingAction>,
1118 pub loading_text: Option<String>,
1119 pub search_query: String,
1120 pub search_active: bool,
1121 pub sort_column: SortColumn,
1123 pub sort_order: SortOrder,
1124 pub log_search_query: String,
1126 pub log_search_active: bool,
1127 pub log_search_matches: Vec<usize>, pub log_search_current: usize, pub details_daemon_id: Option<DaemonId>,
1131 pub logs_expanded: bool,
1133 pub multi_select: HashSet<DaemonId>,
1135 pub config_daemon_ids: HashSet<DaemonId>,
1137 pub show_available: bool,
1139 pub editor_state: Option<EditorState>,
1141 pub file_selector: Option<ConfigFileSelector>,
1143 pub network_listeners: Vec<Listener>,
1145 pub network_search_query: String,
1146 pub network_search_active: bool,
1147 pub network_selected: usize,
1148 pub network_scroll_offset: usize,
1149 pub network_selected_pid: Option<u32>, pub network_visible_rows: usize, pub namespace_filter: NamespaceFilter,
1154}
1155
1156impl App {
1157 pub fn new(namespace_filter: NamespaceFilter) -> Self {
1158 Self {
1159 namespace_filter,
1160 daemons: Vec::new(),
1161 disabled: Vec::new(),
1162 selected: 0,
1163 view: View::Dashboard,
1164 prev_view: View::Dashboard,
1165 log_content: Vec::new(),
1166 log_daemon_id: None,
1167 log_scroll: 1,
1168 log_follow: true,
1169 message: None,
1170 message_time: None,
1171 process_stats: HashMap::new(),
1172 stats_history: HashMap::new(),
1173 pending_action: None,
1174 loading_text: None,
1175 search_query: String::new(),
1176 search_active: false,
1177 sort_column: SortColumn::default(),
1178 sort_order: SortOrder::default(),
1179 log_search_query: String::new(),
1180 log_search_active: false,
1181 log_search_matches: Vec::new(),
1182 log_search_current: 0,
1183 details_daemon_id: None,
1184 logs_expanded: false,
1185 multi_select: HashSet::new(),
1186 config_daemon_ids: HashSet::new(),
1187 show_available: true, editor_state: None,
1189 file_selector: None,
1190 network_listeners: Vec::new(),
1191 network_search_query: String::new(),
1192 network_search_active: false,
1193 network_selected: 0,
1194 network_scroll_offset: 0,
1195 network_selected_pid: None,
1196 network_visible_rows: 20, }
1198 }
1199
1200 pub fn confirm_action(&mut self, action: PendingAction) {
1201 self.pending_action = Some(action);
1202 self.prev_view = self.view;
1203 self.view = View::Confirm;
1204 }
1205
1206 pub fn cancel_confirm(&mut self) {
1207 self.pending_action = None;
1208 self.view = self.prev_view;
1209 }
1210
1211 pub fn take_pending_action(&mut self) -> Option<PendingAction> {
1212 self.view = self.prev_view;
1213 self.pending_action.take()
1214 }
1215
1216 pub fn start_loading(&mut self, text: impl Into<String>) {
1217 self.loading_text = Some(text.into());
1218 }
1219
1220 pub fn stop_loading(&mut self) {
1221 self.loading_text = None;
1222 }
1223
1224 pub fn start_search(&mut self) {
1226 self.search_active = true;
1227 }
1228
1229 pub fn end_search(&mut self) {
1230 self.search_active = false;
1231 }
1232
1233 pub fn clear_search(&mut self) {
1234 self.search_query.clear();
1235 self.search_active = false;
1236 self.selected = 0;
1237 }
1238
1239 pub fn search_push(&mut self, c: char) {
1240 self.search_query.push(c);
1241 self.selected = 0;
1243 }
1244
1245 pub fn search_pop(&mut self) {
1246 self.search_query.pop();
1247 self.selected = 0;
1248 }
1249
1250 pub fn filtered_daemons(&self) -> Vec<&Daemon> {
1251 let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
1252 self.daemons.iter().collect()
1253 } else {
1254 let matcher = SkimMatcherV2::default();
1256 let mut scored: Vec<_> = self
1257 .daemons
1258 .iter()
1259 .filter_map(|d| {
1260 matcher
1261 .fuzzy_match(&d.id.qualified(), &self.search_query)
1262 .map(|score| (d, score))
1263 })
1264 .collect();
1265 scored.sort_by_key(|s| std::cmp::Reverse(s.1));
1267 scored.into_iter().map(|(d, _)| d).collect()
1268 };
1269
1270 filtered.sort_by(|a, b| {
1272 let cmp = match self.sort_column {
1273 SortColumn::Name => {
1274 a.id.to_string()
1275 .to_lowercase()
1276 .cmp(&b.id.to_string().to_lowercase())
1277 }
1278 SortColumn::Status => {
1279 let status_order = |d: &Daemon| match &d.status {
1280 crate::daemon_status::DaemonStatus::Running => 0,
1281 crate::daemon_status::DaemonStatus::Waiting => 1,
1282 crate::daemon_status::DaemonStatus::Stopping => 2,
1283 crate::daemon_status::DaemonStatus::Completed => 3,
1284 crate::daemon_status::DaemonStatus::Stopped => 4,
1285 crate::daemon_status::DaemonStatus::Errored(_) => 5,
1286 crate::daemon_status::DaemonStatus::Failed(_) => 6,
1287 };
1288 status_order(a).cmp(&status_order(b))
1289 }
1290 SortColumn::Cpu => {
1291 let cpu_a = a
1292 .pid
1293 .and_then(|p| self.get_stats(p))
1294 .map(|s| s.cpu_percent)
1295 .unwrap_or(0.0);
1296 let cpu_b = b
1297 .pid
1298 .and_then(|p| self.get_stats(p))
1299 .map(|s| s.cpu_percent)
1300 .unwrap_or(0.0);
1301 cpu_a
1302 .partial_cmp(&cpu_b)
1303 .unwrap_or(std::cmp::Ordering::Equal)
1304 }
1305 SortColumn::Memory => {
1306 let mem_a = a
1307 .pid
1308 .and_then(|p| self.get_stats(p))
1309 .map(|s| s.memory_bytes)
1310 .unwrap_or(0);
1311 let mem_b = b
1312 .pid
1313 .and_then(|p| self.get_stats(p))
1314 .map(|s| s.memory_bytes)
1315 .unwrap_or(0);
1316 mem_a.cmp(&mem_b)
1317 }
1318 SortColumn::Uptime => {
1319 let up_a = a
1320 .pid
1321 .and_then(|p| self.get_stats(p))
1322 .map(|s| s.uptime_secs)
1323 .unwrap_or(0);
1324 let up_b = b
1325 .pid
1326 .and_then(|p| self.get_stats(p))
1327 .map(|s| s.uptime_secs)
1328 .unwrap_or(0);
1329 up_a.cmp(&up_b)
1330 }
1331 };
1332 match self.sort_order {
1333 SortOrder::Ascending => cmp,
1334 SortOrder::Descending => cmp.reverse(),
1335 }
1336 });
1337
1338 filtered
1339 }
1340
1341 pub fn cycle_sort(&mut self) {
1343 self.sort_column = self.sort_column.next();
1345 self.selected = 0;
1346 }
1347
1348 pub fn toggle_sort_order(&mut self) {
1349 self.sort_order = self.sort_order.toggle();
1350 self.selected = 0;
1351 }
1352
1353 pub fn selected_daemon(&self) -> Option<&Daemon> {
1354 let filtered = self.filtered_daemons();
1355 filtered.get(self.selected).copied()
1356 }
1357
1358 pub fn select_next(&mut self) {
1359 let count = self.filtered_daemons().len();
1360 if count > 0 {
1361 self.selected = (self.selected + 1) % count;
1362 }
1363 }
1364
1365 pub fn select_prev(&mut self) {
1366 let count = self.filtered_daemons().len();
1367 if count > 0 {
1368 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1369 }
1370 }
1371
1372 pub fn toggle_log_follow(&mut self) {
1374 self.log_follow = !self.log_follow;
1375 if self.log_follow && !self.log_content.is_empty() {
1376 self.log_scroll = self.log_content.len();
1378 }
1379 }
1380
1381 pub fn toggle_logs_expanded(&mut self) {
1383 self.logs_expanded = !self.logs_expanded;
1384 }
1385
1386 pub fn toggle_select(&mut self) {
1388 if let Some(daemon) = self.selected_daemon() {
1389 let id = daemon.id.clone();
1390 if self.multi_select.contains(&id) {
1391 self.multi_select.remove(&id);
1392 } else {
1393 self.multi_select.insert(id);
1394 }
1395 }
1396 }
1397
1398 pub fn select_all_visible(&mut self) {
1399 let ids: Vec<DaemonId> = self
1401 .filtered_daemons()
1402 .iter()
1403 .map(|d| d.id.clone())
1404 .collect();
1405 for id in ids {
1406 self.multi_select.insert(id);
1407 }
1408 }
1409
1410 pub fn clear_selection(&mut self) {
1411 self.multi_select.clear();
1412 }
1413
1414 pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1415 self.multi_select.contains(daemon_id)
1416 }
1417
1418 pub fn has_selection(&self) -> bool {
1419 !self.multi_select.is_empty()
1420 }
1421
1422 pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1423 self.multi_select.iter().cloned().collect()
1424 }
1425
1426 pub fn set_message(&mut self, msg: impl Into<String>) {
1427 self.message = Some(msg.into());
1428 self.message_time = Some(Instant::now());
1429 }
1430
1431 pub fn clear_stale_message(&mut self) {
1432 let duration = settings().tui_message_duration();
1433 if let Some(time) = self.message_time
1434 && time.elapsed() >= duration
1435 {
1436 self.message = None;
1437 self.message_time = None;
1438 }
1439 }
1440
1441 pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1442 self.process_stats.get(&pid)
1443 }
1444
1445 fn refresh_process_stats(&mut self) {
1446 let pids: Vec<u32> = self.daemons.iter().filter_map(|d| d.pid).collect();
1447 if !pids.is_empty() {
1448 PROCS.refresh_if_stale();
1449 }
1450 self.process_stats.clear();
1451
1452 let stats_by_pid = PROCS.get_batch_tree_stats_map(&pids);
1453
1454 for daemon in &self.daemons {
1455 if let Some(pid) = daemon.pid
1456 && let Some(stats) = stats_by_pid.get(&pid).copied()
1457 {
1458 self.process_stats.insert(pid, stats);
1459 let history = self.stats_history.entry(daemon.id.clone()).or_default();
1461 history.push(StatsSnapshot::from(&stats));
1462 }
1463 }
1464 }
1465
1466 pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1468 self.stats_history.get(daemon_id)
1469 }
1470
1471 pub async fn fetch_daemon_data(
1473 client: &Arc<IpcClient>,
1474 namespace_filter: &NamespaceFilter,
1475 ) -> Result<Vec<DaemonListEntry>> {
1476 use crate::daemon_list::get_all_daemons;
1477 get_all_daemons(client, namespace_filter).await
1478 }
1479
1480 pub fn apply_refresh(&mut self, all_entries: Vec<DaemonListEntry>) {
1482 self.daemons.clear();
1484 self.disabled.clear();
1485 self.config_daemon_ids.clear();
1486
1487 for entry in all_entries {
1488 let daemon_id = entry.daemon.id.clone();
1489
1490 if entry.is_disabled {
1491 self.disabled.push(daemon_id.clone());
1492 }
1493
1494 if entry.is_available {
1495 self.config_daemon_ids.insert(daemon_id.clone());
1496 }
1497
1498 if !entry.is_available || self.show_available {
1499 self.daemons.push(entry.daemon);
1500 }
1501 }
1502
1503 self.refresh_process_stats();
1504 self.clear_stale_message();
1505
1506 let total_count = self.total_daemon_count();
1507 if total_count > 0 && self.selected >= total_count {
1508 self.selected = total_count - 1;
1509 }
1510
1511 if self.view == View::Logs
1512 && let Some(id) = self.log_daemon_id.clone()
1513 {
1514 self.load_logs(&id);
1515 }
1516 }
1517
1518 pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1519 let entries = Self::fetch_daemon_data(client, &self.namespace_filter).await?;
1520 self.apply_refresh(entries);
1521 Ok(())
1522 }
1523
1524 pub fn apply_network_refresh(&mut self, listeners: Vec<Listener>) {
1526 self.network_listeners = listeners;
1527
1528 let filtered_count = self.filtered_network_listeners().len();
1529
1530 if filtered_count > 0 && self.network_selected >= filtered_count {
1531 self.network_selected = filtered_count - 1;
1532 } else if filtered_count == 0 {
1533 self.network_selected = 0;
1534 }
1535
1536 let selected_pid = self
1537 .filtered_network_listeners()
1538 .get(self.network_selected)
1539 .map(|l| l.process.pid);
1540 self.network_selected_pid = selected_pid;
1541 }
1542
1543 pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1545 if self.network_search_query.is_empty() {
1546 return self.network_listeners.iter().collect();
1547 }
1548
1549 let matcher = SkimMatcherV2::default();
1550 let query = &self.network_search_query;
1551
1552 self.network_listeners
1553 .iter()
1554 .filter(|listener| {
1555 let search_text = format!(
1557 "{} {} {}",
1558 listener.process.name,
1559 listener.process.pid,
1560 listener.socket.port()
1561 );
1562 matcher.fuzzy_match(&search_text, query).is_some()
1563 })
1564 .collect()
1565 }
1566
1567 pub fn toggle_network_search(&mut self) {
1569 self.network_search_active = !self.network_search_active;
1570 if !self.network_search_active {
1571 self.network_search_query.clear();
1572 }
1573 self.network_selected = 0;
1575 self.network_scroll_offset = 0;
1576 let filtered = self.filtered_network_listeners();
1578 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1579 }
1580
1581 pub fn clear_network_search(&mut self) {
1583 self.network_search_query.clear();
1584 self.network_search_active = false;
1585 self.network_selected = 0;
1587 self.network_scroll_offset = 0;
1588 let filtered = self.filtered_network_listeners();
1590 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1591 }
1592
1593 pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1595 self.config_daemon_ids.contains(daemon_id)
1596 }
1597
1598 pub fn toggle_show_available(&mut self) {
1600 self.show_available = !self.show_available;
1601 }
1602
1603 fn total_daemon_count(&self) -> usize {
1605 self.filtered_daemons().len()
1606 }
1607
1608 pub fn scroll_logs_down(&mut self) {
1609 let max_scroll = self.log_content.len();
1610 self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1611 }
1612
1613 pub fn scroll_logs_up(&mut self) {
1614 self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1615 }
1616
1617 pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1619 let half_page = visible_lines / 2;
1620 let max_scroll = self.log_content.len();
1621 self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1622 }
1623
1624 pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1626 let half_page = visible_lines / 2;
1627 self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1628 }
1629
1630 pub fn start_log_search(&mut self) {
1632 self.log_search_active = true;
1633 self.log_search_query.clear();
1634 self.log_search_matches.clear();
1635 self.log_search_current = 0;
1636 }
1637
1638 pub fn end_log_search(&mut self) {
1639 self.log_search_active = false;
1640 }
1641
1642 pub fn clear_log_search(&mut self) {
1643 self.log_search_query.clear();
1644 self.log_search_active = false;
1645 self.log_search_matches.clear();
1646 self.log_search_current = 0;
1647 }
1648
1649 pub fn log_search_push(&mut self, c: char) {
1650 self.log_search_query.push(c);
1651 self.update_log_search_matches();
1652 }
1653
1654 pub fn log_search_pop(&mut self) {
1655 self.log_search_query.pop();
1656 self.update_log_search_matches();
1657 }
1658
1659 fn update_log_search_matches(&mut self) {
1660 self.log_search_matches.clear();
1661 if !self.log_search_query.is_empty() {
1662 let query = self.log_search_query.to_lowercase();
1663 for (i, line) in self.log_content.iter().enumerate() {
1664 if line.to_lowercase().contains(&query) {
1665 self.log_search_matches.push(i);
1666 }
1667 }
1668 if !self.log_search_matches.is_empty() {
1670 self.log_search_current = 0;
1671 self.jump_to_log_match();
1672 }
1673 }
1674 }
1675
1676 pub fn log_search_next(&mut self) {
1677 if !self.log_search_matches.is_empty() {
1678 self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1679 self.jump_to_log_match();
1680 }
1681 }
1682
1683 pub fn log_search_prev(&mut self) {
1684 if !self.log_search_matches.is_empty() {
1685 self.log_search_current = self
1686 .log_search_current
1687 .checked_sub(1)
1688 .unwrap_or(self.log_search_matches.len() - 1);
1689 self.jump_to_log_match();
1690 }
1691 }
1692
1693 fn jump_to_log_match(&mut self) {
1694 if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1695 let half_page = 10; self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1698 self.log_follow = false;
1699 }
1700 }
1701
1702 pub fn show_details(&mut self, daemon_id: &DaemonId) {
1704 self.details_daemon_id = Some(daemon_id.clone());
1705 self.prev_view = self.view;
1706 self.view = View::Details;
1707 }
1708
1709 pub fn hide_details(&mut self) {
1710 self.details_daemon_id = None;
1711 self.view = View::Dashboard;
1712 }
1713
1714 pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1716 self.log_daemon_id = Some(daemon_id.clone());
1717 self.logs_expanded = false; self.load_logs(daemon_id);
1719 self.view = View::Logs; }
1721
1722 fn load_logs(&mut self, daemon_id: &DaemonId) {
1723 const TUI_LOG_LIMIT: usize = 5000;
1724 let prev_len = self.log_content.len();
1725
1726 self.log_content = match LOG_STORE.query(&crate::log_store::LogQuery {
1727 daemon_ids: vec![daemon_id.qualified()],
1728 from: None,
1729 to: None,
1730 limit: Some(TUI_LOG_LIMIT),
1731 order_desc: true,
1732 after_id: None,
1733 before_id: None,
1734 message_filters: Vec::new(),
1735 field_filters: Vec::new(),
1736 include_structured: false,
1737 }) {
1738 Ok(entries) if !entries.is_empty() => {
1739 let mut lines: Vec<String> = entries
1740 .into_iter()
1741 .map(|e| {
1742 let ts = e.timestamp.format("%H:%M:%S").to_string();
1743 format!("{} {}", ts, e.message)
1744 })
1745 .collect();
1746 lines.reverse();
1747 lines
1748 }
1749 _ => vec!["No logs available".to_string()],
1750 };
1751
1752 if self.log_follow {
1754 self.log_scroll = self.log_content.len().max(1);
1755 } else if prev_len == 0 {
1756 self.log_scroll = self.log_content.len().max(1);
1758 }
1759 }
1761
1762 pub fn show_help(&mut self) {
1763 self.view = View::Help;
1764 }
1765
1766 pub fn back_to_dashboard(&mut self) {
1767 self.view = View::Dashboard;
1768 self.log_daemon_id = None;
1769 self.log_content.clear();
1770 self.log_scroll = 1;
1771 }
1772
1773 pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1775 let available = self.config_daemon_ids.len();
1776 let total = self.daemons.len();
1777 let running = self
1778 .daemons
1779 .iter()
1780 .filter(|d| d.status.is_running())
1781 .count();
1782 let stopped = self
1784 .daemons
1785 .iter()
1786 .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1787 .count();
1788 let errored = self
1789 .daemons
1790 .iter()
1791 .filter(|d| d.status.is_errored() || d.status.is_failed())
1792 .count();
1793 (total, running, stopped, errored, available)
1794 }
1795
1796 pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1797 self.disabled.contains(daemon_id)
1798 }
1799
1800 pub fn get_config_files(&self) -> Vec<PathBuf> {
1804 let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1805 .into_iter()
1806 .filter(|p| p.exists())
1807 .collect();
1808
1809 let cwd_config = crate::env::CWD.join("pitchfork.toml");
1811 if !files.contains(&cwd_config) {
1812 files.push(cwd_config);
1813 }
1814
1815 files
1816 }
1817
1818 pub fn open_file_selector(&mut self) {
1820 let files = self.get_config_files();
1821 self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1822 self.view = View::ConfigFileSelect;
1823 }
1824
1825 pub fn open_editor_create(&mut self, config_path: PathBuf) {
1827 self.editor_state = Some(EditorState::new_create(config_path));
1828 self.file_selector = None;
1829 self.view = View::ConfigEditor;
1830 }
1831
1832 pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1834 let config = match PitchforkToml::all_merged() {
1835 Ok(config) => config,
1836 Err(e) => {
1837 self.set_message(format!("Failed to load config: {e}"));
1838 return;
1839 }
1840 };
1841 if let Some(daemon_config) = config.daemons.get(daemon_id) {
1842 let config_path = daemon_config
1843 .path
1844 .clone()
1845 .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1846 self.editor_state = Some(EditorState::new_edit(
1847 daemon_id.to_string(),
1848 daemon_config,
1849 config_path,
1850 ));
1851 self.view = View::ConfigEditor;
1852 } else {
1853 self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1854 }
1855 }
1856
1857 pub fn close_editor(&mut self) {
1859 self.editor_state = None;
1860 self.file_selector = None;
1861 self.view = View::Dashboard;
1862 }
1863
1864 pub fn save_editor_config(&mut self) -> Result<bool> {
1867 let editor = self
1868 .editor_state
1869 .as_mut()
1870 .ok_or_else(|| miette::miette!("No editor state"))?;
1871
1872 if !editor.validate() {
1874 self.set_message("Please fix validation errors before saving");
1875 return Ok(false);
1876 }
1877
1878 let daemon_config = editor.to_daemon_config();
1880
1881 let daemon_id = DaemonId::parse(&editor.daemon_id)
1883 .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1884
1885 let mut config = PitchforkToml::read(&editor.config_path)?;
1887
1888 let is_duplicate = match &editor.mode {
1890 EditMode::Create => config.daemons.contains_key(&daemon_id),
1891 EditMode::Edit { original_id } => {
1892 let original_daemon_id = DaemonId::parse(original_id)
1894 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1895 original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1896 }
1897 };
1898
1899 if is_duplicate {
1900 self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1901 return Ok(false);
1902 }
1903
1904 if let EditMode::Edit { original_id } = &editor.mode {
1906 let original_daemon_id = DaemonId::parse(original_id)
1907 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1908 if original_daemon_id != daemon_id {
1909 config.daemons.shift_remove(&original_daemon_id);
1910 }
1911 }
1912
1913 config.daemons.insert(daemon_id, daemon_config);
1915
1916 config.write()?;
1918
1919 editor.unsaved_changes = false;
1920 let daemon_id = editor.daemon_id.clone();
1921 self.set_message(format!("Saved daemon '{daemon_id}'"));
1922
1923 Ok(true)
1924 }
1925
1926 pub fn delete_daemon_from_config(
1928 &mut self,
1929 id: &str,
1930 config_path: &std::path::Path,
1931 ) -> Result<bool> {
1932 let mut config = PitchforkToml::read(config_path)?;
1933
1934 let daemon_id = if id.contains('/') {
1937 DaemonId::parse(id)?
1938 } else {
1939 let ns = namespace_from_path(config_path)?;
1940 DaemonId::try_new(&ns, id)?
1941 };
1942
1943 if config.daemons.shift_remove(&daemon_id).is_some() {
1944 config.write()?;
1945 Ok(true)
1946 } else {
1947 Ok(false)
1948 }
1949 }
1950}
1951
1952impl Default for App {
1953 fn default() -> Self {
1954 Self::new(NamespaceFilter::default())
1955 }
1956}
1957
1958#[cfg(test)]
1959mod run_argv_editor_tests {
1960 use super::*;
1961
1962 fn argv(words: &[&str]) -> RunCommand {
1963 RunCommand::Argv(words.iter().map(|w| w.to_string()).collect())
1964 }
1965
1966 fn editor(run: RunCommand) -> EditorState {
1967 let config = PitchforkTomlDaemon {
1968 run,
1969 ..PitchforkTomlDaemon::default()
1970 };
1971 EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
1972 }
1973
1974 fn set_run(editor: &mut EditorState, text: &str) {
1975 let field = editor.fields.iter_mut().find(|f| f.name == "run").unwrap();
1976 field.set_text(text.to_string());
1977 }
1978
1979 #[test]
1980 fn an_untouched_array_is_saved_as_an_array() {
1981 let run = argv(&["node", "my server.js"]);
1982 let editor = editor(run.clone());
1983 assert_eq!(editor.to_daemon_config().run, run);
1984 }
1985
1986 #[test]
1987 fn an_edited_array_stays_an_array() {
1988 let mut editor = editor(argv(&["node", "my server.js"]));
1989 set_run(&mut editor, "node 'my server.js' --port 8080");
1990 assert!(editor.validate());
1991 assert_eq!(
1992 editor.to_daemon_config().run,
1993 argv(&["node", "my server.js", "--port", "8080"])
1994 );
1995 }
1996
1997 #[test]
1998 fn an_edited_array_must_still_parse() {
1999 let mut editor = editor(argv(&["node", "server.js"]));
2000 set_run(&mut editor, "node 'server.js");
2001 assert!(!editor.validate());
2002 set_run(&mut editor, "exec node server.js");
2003 assert!(!editor.validate());
2004 }
2005
2006 #[test]
2007 fn a_string_stays_a_string() {
2008 let mut editor = editor("exec node server.js".into());
2009 set_run(&mut editor, "exec node 'my server.js'");
2010 assert!(editor.validate());
2011 assert_eq!(editor.to_daemon_config().run, "exec node 'my server.js'");
2012 }
2013}
2014
2015#[cfg(test)]
2016mod run_argv_editor_review_tests {
2017 use super::*;
2018
2019 fn editor(run: RunCommand) -> EditorState {
2020 let config = PitchforkTomlDaemon {
2021 run,
2022 ..PitchforkTomlDaemon::default()
2023 };
2024 EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
2025 }
2026
2027 fn set_run(editor: &mut EditorState, text: &str) {
2028 let field = editor.fields.iter_mut().find(|f| f.name == "run").unwrap();
2029 field.set_text(text.to_string());
2030 }
2031
2032 #[test]
2033 fn windows_edit_text_round_trips_awkward_arguments() {
2034 let argv: Vec<String> = [
2035 r"C:\Program Files\node.exe",
2036 "",
2037 "it's",
2038 "say \"hi\"",
2039 "both ' and \"",
2040 r"trailing\",
2041 "a&b",
2042 ]
2043 .map(str::to_string)
2044 .to_vec();
2045 let text = argv
2046 .iter()
2047 .map(|a| windows_edit_quote(a))
2048 .collect::<Vec<_>>()
2049 .join(" ");
2050 assert_eq!(windows_edit_split(&text).unwrap(), argv, "text: {text}");
2051 }
2052
2053 #[test]
2054 fn windows_edit_split_keeps_backslashes_and_refuses_open_quotes() {
2055 assert_eq!(
2056 windows_edit_split(r#"C:\Tools\node.exe "my server.js" x"#).unwrap(),
2057 vec![r"C:\Tools\node.exe", "my server.js", "x"]
2058 );
2059 assert!(windows_edit_split("node 'server.js").is_err());
2060 }
2061
2062 #[test]
2063 fn an_empty_program_is_rejected() {
2064 let mut editor = editor(RunCommand::Argv(vec!["node".into()]));
2065 set_run(&mut editor, "'' node");
2066 assert!(!editor.validate());
2067 }
2068
2069 #[cfg(windows)]
2070 #[test]
2071 fn a_windows_path_keeps_its_backslashes() {
2072 let mut editor = editor(RunCommand::Argv(vec!["node".into()]));
2073 set_run(&mut editor, r"C:\Tools\node.exe --port 8080");
2074 assert!(editor.validate());
2075 assert_eq!(
2076 editor.to_daemon_config().run,
2077 RunCommand::Argv(vec![
2078 r"C:\Tools\node.exe".into(),
2079 "--port".into(),
2080 "8080".into()
2081 ])
2082 );
2083 }
2084}