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 argv_edit_error(text: &str) -> Option<String> {
356 match edit_text_to_argv(text).as_deref() {
357 Ok([]) => Some("Required".to_string()),
358 Ok([program, ..]) if program.is_empty() => Some("The program name is empty".to_string()),
359 Ok([program, ..]) if program == "exec" => {
360 Some("Remove exec: this command starts the program without a shell".to_string())
361 }
362 Ok(_) => None,
363 Err(_) => Some("Unbalanced quotes".to_string()),
364 }
365}
366
367fn edit_text_to_argv(text: &str) -> Result<Vec<String>, String> {
369 if cfg!(windows) {
370 windows_edit_split(text)
371 } else {
372 shell_words::split(text).map_err(|e| e.to_string())
373 }
374}
375
376fn windows_edit_quote(arg: &str) -> String {
378 if !arg.is_empty() && !arg.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"') {
379 return arg.to_string();
380 }
381 let mut quoted = String::from("'");
384 for c in arg.chars() {
385 if c == '\'' {
386 quoted.push_str("'\"'\"'");
387 } else {
388 quoted.push(c);
389 }
390 }
391 quoted.push('\'');
392 quoted
393}
394
395fn windows_edit_split(text: &str) -> Result<Vec<String>, String> {
398 let mut words = Vec::new();
399 let mut word = String::new();
400 let mut in_word = false;
402 let mut chars = text.chars();
403 while let Some(c) = chars.next() {
404 match c {
405 '\'' | '"' => {
406 in_word = true;
407 loop {
408 match chars.next() {
409 Some(q) if q == c => break,
410 Some(other) => word.push(other),
411 None => return Err("unclosed quote".to_string()),
412 }
413 }
414 }
415 c if c.is_whitespace() => {
416 if in_word {
417 words.push(std::mem::take(&mut word));
418 in_word = false;
419 }
420 }
421 c => {
422 in_word = true;
423 word.push(c);
424 }
425 }
426 }
427 if in_word {
428 words.push(word);
429 }
430 Ok(words)
431}
432
433#[derive(Debug, Clone)]
435pub struct EditorState {
436 pub mode: EditMode,
437 pub daemon_id: String,
438 pub daemon_id_editing: bool,
439 pub daemon_id_cursor: usize,
440 pub daemon_id_error: Option<String>,
441 pub fields: Vec<FormField>,
442 pub focused_field: usize,
443 pub config_path: PathBuf,
444 pub unsaved_changes: bool,
445 #[allow(dead_code)]
446 pub scroll_offset: usize,
447 preserved_ready_cmd: Option<ReadyCmd>,
449 preserved_oneshot: Option<bool>,
453 preserved_run_argv: Option<RunCommand>,
457 preserved_ready_http_status: Option<Vec<u16>>,
459 preserved_ready_http_timeout: Option<std::time::Duration>,
461 preserved_ready_output_timeout: Option<std::time::Duration>,
463 preserved_health_cmd: Option<HealthCmd>,
465 preserved_health_http: Option<HealthHttp>,
467 preserved_health_port: Option<HealthPort>,
469}
470
471impl EditorState {
472 pub fn new_create(config_path: PathBuf) -> Self {
473 Self {
474 mode: EditMode::Create,
475 daemon_id: String::new(),
476 daemon_id_editing: true,
477 daemon_id_cursor: 0,
478 daemon_id_error: None,
479 fields: Self::default_fields(),
480 focused_field: 0,
481 config_path,
482 unsaved_changes: false,
483 scroll_offset: 0,
484 preserved_ready_cmd: None,
485 preserved_oneshot: None,
486 preserved_run_argv: None,
487 preserved_ready_http_status: None,
488 preserved_ready_http_timeout: None,
489 preserved_ready_output_timeout: None,
490 preserved_health_cmd: None,
491 preserved_health_http: None,
492 preserved_health_port: None,
493 }
494 }
495
496 pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
497 Self {
498 mode: EditMode::Edit {
499 original_id: daemon_id.clone(),
500 },
501 daemon_id,
502 daemon_id_editing: false,
503 daemon_id_cursor: 0,
504 daemon_id_error: None,
505 fields: Self::fields_from_config(config),
506 focused_field: 0,
507 config_path,
508 unsaved_changes: false,
509 scroll_offset: 0,
510 preserved_ready_cmd: config.ready_cmd.clone(),
511 preserved_oneshot: config.oneshot,
512 preserved_run_argv: config.run.is_argv().then(|| config.run.clone()),
513 preserved_ready_http_status: config
514 .ready_http
515 .as_ref()
516 .and_then(|h| (!h.status.is_empty()).then(|| h.status.clone())),
517 preserved_ready_http_timeout: config.ready_http.as_ref().and_then(|h| h.timeout),
518 preserved_ready_output_timeout: config.ready_output.as_ref().and_then(|o| o.timeout),
519 preserved_health_cmd: config.health_cmd.clone(),
520 preserved_health_http: config.health_http.clone(),
521 preserved_health_port: config.health_port.clone(),
522 }
523 }
524
525 fn default_fields() -> Vec<FormField> {
526 vec![
527 FormField::text(
528 "run",
529 "Run Command",
530 "Command to execute. Prepend 'exec' to avoid shell overhead.",
531 true,
532 ),
533 FormField::optional_text(
534 "dir",
535 "Working Directory",
536 "Working directory for the daemon. Relative to pitchfork.toml location.",
537 ),
538 FormField::string_list(
539 "env",
540 "Environment Variables",
541 "Comma-separated KEY=VALUE pairs (e.g., NODE_ENV=dev, PORT=3000).",
542 ),
543 FormField::auto_behavior(
544 "auto",
545 "Auto Behavior",
546 "Auto start/stop based on directory hooks.",
547 ),
548 FormField::number(
549 "retry",
550 "Retry Count",
551 "Number of retry attempts on failure (0 = no retries).",
552 0,
553 ),
554 FormField::optional_number(
555 "ready_delay",
556 "Ready Delay (ms)",
557 "Milliseconds to wait before considering daemon ready.",
558 ),
559 FormField::optional_text(
560 "ready_output",
561 "Ready Output Pattern",
562 "Regex pattern in ANSI-stripped stdout/stderr indicating readiness.",
563 ),
564 FormField::optional_text(
565 "ready_http",
566 "Ready HTTP URL",
567 "HTTP URL to poll for readiness (expects 2xx).",
568 ),
569 FormField::optional_port(
570 "ready_port",
571 "Ready Port",
572 "TCP port to check for readiness (1-65535).",
573 ),
574 FormField::optional_text(
575 "health_cmd",
576 "Health Command",
577 "Shell command that must exit 0 for the daemon to stay healthy.",
578 ),
579 FormField::optional_text(
580 "health_http",
581 "Health HTTP URL",
582 "HTTP URL to poll for health (expects 2xx).",
583 ),
584 FormField::optional_port(
585 "health_port",
586 "Health Port",
587 "TCP port that must accept connections for the daemon to stay healthy (1-65535).",
588 ),
589 FormField::optional_bool(
590 "boot_start",
591 "Start on Boot",
592 "Automatically start this daemon on system boot.",
593 ),
594 FormField::string_list(
595 "depends",
596 "Dependencies",
597 "Comma-separated daemon names that must start first.",
598 ),
599 FormField::string_list(
600 "watch",
601 "Watch Files",
602 "Comma-separated glob patterns to watch for auto-restart.",
603 ),
604 FormField::optional_text(
605 "cron_schedule",
606 "Cron Schedule",
607 "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
608 ),
609 FormField::retrigger(
610 "cron_retrigger",
611 "Cron Retrigger",
612 "Behavior when cron triggers while previous run is active.",
613 ),
614 FormField::optional_bool(
615 "cron_immediate",
616 "Cron Immediate",
617 "Trigger immediately on first check (default: false).",
618 ),
619 ]
620 }
621
622 fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
623 let mut fields = Self::default_fields();
624
625 for field in &mut fields {
626 match field.name {
627 "run" => {
628 field.value = FormFieldValue::Text(match &config.run {
629 RunCommand::Argv(argv) => argv_to_edit_text(argv),
630 RunCommand::Shell(run) => run.clone(),
631 })
632 }
633 "dir" => field.value = FormFieldValue::OptionalText(config.dir.clone()),
634 "env" => {
635 field.value = FormFieldValue::StringList(
636 config
637 .env
638 .as_ref()
639 .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
640 .unwrap_or_default(),
641 );
642 }
643 "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
644 "retry" => field.value = FormFieldValue::Number(config.retry.count()),
645 "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
646 "ready_output" => {
647 field.value = FormFieldValue::OptionalText(
648 config.ready_output.as_ref().map(|o| o.pattern.clone()),
649 )
650 }
651 "ready_http" => {
652 field.value = FormFieldValue::OptionalText(
653 config.ready_http.as_ref().map(|h| h.url.clone()),
654 )
655 }
656 "ready_port" => {
657 field.value = FormFieldValue::OptionalPort(config.ready_port.clone())
658 }
659 "health_cmd" => {
660 field.value = FormFieldValue::OptionalText(config.health_cmd.as_ref().map(
661 |c| match &c.run {
662 RunCommand::Argv(argv) => argv_to_edit_text(argv),
663 RunCommand::Shell(run) => run.clone(),
664 },
665 ))
666 }
667 "health_http" => {
668 field.value = FormFieldValue::OptionalText(
669 config.health_http.as_ref().map(|h| h.url.clone()),
670 )
671 }
672 "health_port" => {
673 field.value =
674 FormFieldValue::OptionalPort(config.health_port.as_ref().map(|hp| {
675 ReadyPort {
676 port: hp.port,
677 template: hp.template.clone(),
678 timeout: None,
679 }
680 }))
681 }
682 "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
683 "depends" => {
684 field.value = FormFieldValue::StringList(
685 config
686 .depends
687 .iter()
688 .map(|d: &DaemonId| d.qualified())
689 .collect(),
690 )
691 }
692 "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
693 "cron_schedule" => {
694 field.value = FormFieldValue::OptionalText(
695 config.cron.as_ref().map(|c| c.schedule.clone()),
696 );
697 }
698 "cron_retrigger" => {
699 field.value = FormFieldValue::Retrigger(
700 config
701 .cron
702 .as_ref()
703 .map(|c| c.retrigger)
704 .unwrap_or(CronRetrigger::Finish),
705 );
706 }
707 "cron_immediate" => {
708 field.value =
709 FormFieldValue::OptionalBoolean(config.cron.as_ref().map(|c| c.immediate));
710 }
711 _ => {}
712 }
713 }
714
715 fields
716 }
717
718 pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
719 let mut config = PitchforkTomlDaemon {
720 ready_cmd: self.preserved_ready_cmd.clone(),
721 oneshot: self.preserved_oneshot,
722 path: Some(self.config_path.clone()),
723 ..PitchforkTomlDaemon::default()
724 };
725
726 let mut cron_schedule: Option<String> = None;
727 let mut cron_retrigger = CronRetrigger::Finish;
728 let mut cron_immediate = false;
729
730 for field in &self.fields {
731 match (field.name, &field.value) {
732 ("run", FormFieldValue::Text(s)) => {
733 config.run = match &self.preserved_run_argv {
734 Some(RunCommand::Argv(argv)) if argv_to_edit_text(argv) == *s => {
735 RunCommand::Argv(argv.clone())
736 }
737 Some(_) => match edit_text_to_argv(s) {
741 Ok(words) if !words.is_empty() => words.into(),
742 _ => s.clone().into(),
743 },
744 None => s.clone().into(),
745 };
746 }
747 ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
748 ("env", FormFieldValue::StringList(v)) => {
749 if v.is_empty() {
750 config.env = None;
751 } else {
752 let mut map = indexmap::IndexMap::new();
753 for entry in v {
754 if let Some((k, val)) = entry.split_once('=') {
755 map.insert(k.trim().to_string(), val.trim().to_string());
756 }
757 }
758 config.env = if map.is_empty() { None } else { Some(map) };
759 }
760 }
761 ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
762 ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
763 ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
764 ("ready_output", FormFieldValue::OptionalText(s)) => {
765 config.ready_output = s.clone().map(|pattern| ReadyOutput {
766 pattern,
767 timeout: self.preserved_ready_output_timeout,
768 })
769 }
770 ("ready_http", FormFieldValue::OptionalText(s)) => {
771 config.ready_http = s.clone().map(|url| ReadyHttp {
772 url,
773 status: self.preserved_ready_http_status.clone().unwrap_or_default(),
774 timeout: self.preserved_ready_http_timeout,
775 })
776 }
777 ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = p.clone(),
778 ("health_cmd", FormFieldValue::OptionalText(s)) => {
779 config.health_cmd = s.clone().map(|text| {
780 let mut cmd = self.preserved_health_cmd.clone().unwrap_or_default();
781 cmd.run = match &cmd.run {
783 RunCommand::Argv(argv) if argv_to_edit_text(argv) == text => {
784 RunCommand::Argv(argv.clone())
785 }
786 RunCommand::Argv(_) => match edit_text_to_argv(&text) {
787 Ok(words) if !words.is_empty() => words.into(),
788 _ => text.into(),
789 },
790 RunCommand::Shell(_) => text.into(),
791 };
792 cmd
793 })
794 }
795 ("health_http", FormFieldValue::OptionalText(s)) => {
796 config.health_http = s.clone().map(|url| {
797 let mut http = self.preserved_health_http.clone().unwrap_or_default();
798 http.url = url;
799 http
800 })
801 }
802 ("health_port", FormFieldValue::OptionalPort(p)) => {
803 config.health_port = p.clone().map(|port_value| {
804 let mut hp = self.preserved_health_port.clone().unwrap_or_default();
805 hp.port = port_value.port;
806 hp.template = port_value.template;
807 hp
808 })
809 }
810 ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
811 ("depends", FormFieldValue::StringList(v)) => {
812 config.depends = v.iter().filter_map(|s| DaemonId::parse(s).ok()).collect()
813 }
814 ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
815 ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
816 ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
817 ("cron_immediate", FormFieldValue::OptionalBoolean(b)) => {
818 cron_immediate = b.unwrap_or(false);
819 }
820 _ => {}
821 }
822 }
823
824 if let Some(schedule) = cron_schedule {
825 config.cron = Some(PitchforkTomlCron {
826 schedule,
827 retrigger: cron_retrigger,
828 immediate: cron_immediate,
829 });
830 }
831
832 config
833 }
834
835 pub fn next_field(&mut self) {
836 if let Some(field) = self.fields.get_mut(self.focused_field) {
838 field.editing = false;
839 }
840
841 if self.daemon_id_editing {
843 self.daemon_id_editing = false;
844 return;
845 }
846
847 if self.focused_field < self.fields.len() - 1 {
848 self.focused_field += 1;
849 }
850 }
851
852 pub fn prev_field(&mut self) {
853 if let Some(field) = self.fields.get_mut(self.focused_field) {
855 field.editing = false;
856 }
857 self.daemon_id_editing = false;
858
859 if self.focused_field > 0 {
860 self.focused_field -= 1;
861 }
862 }
863
864 pub fn toggle_current_field(&mut self) {
865 if let Some(field) = self.fields.get_mut(self.focused_field) {
866 let toggled = match &mut field.value {
867 FormFieldValue::Boolean(b) => {
868 *b = !*b;
869 true
870 }
871 FormFieldValue::OptionalBoolean(opt) => {
872 *opt = match opt {
873 None => Some(true),
874 Some(true) => Some(false),
875 Some(false) => None,
876 };
877 true
878 }
879 FormFieldValue::AutoBehavior(v) => {
880 let has_start = v.contains(&PitchforkTomlAuto::Start);
882 let has_stop = v.contains(&PitchforkTomlAuto::Stop);
883 *v = match (has_start, has_stop) {
884 (false, false) => vec![PitchforkTomlAuto::Start],
885 (true, false) => vec![PitchforkTomlAuto::Stop],
886 (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
887 (true, true) => vec![],
888 };
889 true
890 }
891 FormFieldValue::Retrigger(r) => {
892 *r = match r {
893 CronRetrigger::Finish => CronRetrigger::Always,
894 CronRetrigger::Always => CronRetrigger::Success,
895 CronRetrigger::Success => CronRetrigger::Fail,
896 CronRetrigger::Fail => CronRetrigger::Finish,
897 };
898 true
899 }
900 _ => false,
901 };
902 if toggled {
903 self.unsaved_changes = true;
904 }
905 }
906 }
907
908 pub fn start_editing(&mut self) {
909 if let Some(field) = self.fields.get_mut(self.focused_field) {
910 if field.is_text_editable() {
911 field.editing = true;
912 field.cursor = field.get_text().chars().count();
913 } else {
914 self.toggle_current_field();
916 }
917 }
918 }
919
920 pub fn stop_editing(&mut self) {
921 if let Some(field) = self.fields.get_mut(self.focused_field) {
922 field.editing = false;
923 }
924 self.daemon_id_editing = false;
925 }
926
927 pub fn is_editing(&self) -> bool {
928 self.daemon_id_editing
929 || self
930 .fields
931 .get(self.focused_field)
932 .map(|f| f.editing)
933 .unwrap_or(false)
934 }
935
936 pub fn text_push(&mut self, c: char) {
937 if self.daemon_id_editing {
938 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
939 self.daemon_id.insert(byte_idx, c);
940 self.daemon_id_cursor += 1;
941 self.unsaved_changes = true;
942 } else if let Some(field) = self.fields.get_mut(self.focused_field)
943 && field.editing
944 {
945 let mut text = field.get_text();
946 let byte_idx = char_to_byte_index(&text, field.cursor);
947 text.insert(byte_idx, c);
948 field.cursor += 1;
949 field.set_text(text);
950 self.unsaved_changes = true;
951 }
952 }
953
954 pub fn text_pop(&mut self) {
955 if self.daemon_id_editing && self.daemon_id_cursor > 0 {
956 self.daemon_id_cursor -= 1;
957 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
958 self.daemon_id.remove(byte_idx);
959 self.unsaved_changes = true;
960 } else if let Some(field) = self.fields.get_mut(self.focused_field)
961 && field.editing
962 && field.cursor > 0
963 {
964 let mut text = field.get_text();
965 field.cursor = field.cursor.min(text.chars().count());
968 if field.cursor == 0 {
969 return;
970 }
971 field.cursor -= 1;
972 let byte_idx = char_to_byte_index(&text, field.cursor);
973 text.remove(byte_idx);
974 field.set_text(text);
975 if matches!(field.value, FormFieldValue::Number(_)) {
978 field.cursor = field.get_text().chars().count();
979 }
980 self.unsaved_changes = true;
981 }
982 }
983
984 pub fn validate(&mut self) -> bool {
985 let mut valid = true;
986
987 self.daemon_id_error = None;
989 if self.daemon_id.is_empty() {
990 self.daemon_id_error = Some("Name is required".to_string());
991 valid = false;
992 } else if !self
993 .daemon_id
994 .chars()
995 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
996 {
997 self.daemon_id_error =
998 Some("Only letters, digits, hyphens, and underscores allowed".to_string());
999 valid = false;
1000 }
1001
1002 let run_is_argv = self.preserved_run_argv.is_some();
1004 let health_cmd_is_argv = self
1005 .preserved_health_cmd
1006 .as_ref()
1007 .is_some_and(|c| c.run.is_argv());
1008 for field in &mut self.fields {
1009 if field.error.is_some() {
1013 valid = false;
1014 continue;
1015 }
1016
1017 match (field.name, &field.value) {
1018 ("run", FormFieldValue::Text(s)) if s.is_empty() => {
1019 field.error = Some("Required".to_string());
1020 valid = false;
1021 }
1022 ("run", FormFieldValue::Text(s)) if run_is_argv => {
1023 if let Some(error) = argv_edit_error(s) {
1024 field.error = Some(error);
1025 valid = false;
1026 }
1027 }
1028 ("health_cmd", FormFieldValue::OptionalText(Some(s))) if health_cmd_is_argv => {
1029 if let Some(error) = argv_edit_error(s) {
1030 field.error = Some(error);
1031 valid = false;
1032 }
1033 }
1034 ("ready_http", FormFieldValue::OptionalText(Some(url)))
1035 if !(url.starts_with("http://") || url.starts_with("https://")) =>
1036 {
1037 field.error = Some("Must start with http:// or https://".to_string());
1038 valid = false;
1039 }
1040 ("health_http", FormFieldValue::OptionalText(Some(url)))
1041 if !(url.starts_with("http://") || url.starts_with("https://")) =>
1042 {
1043 field.error = Some("Must start with http:// or https://".to_string());
1044 valid = false;
1045 }
1046 _ => {}
1047 }
1048 }
1049
1050 valid
1051 }
1052}
1053
1054#[derive(Debug, Clone)]
1056pub struct ConfigFileSelector {
1057 pub files: Vec<PathBuf>,
1058 pub selected: usize,
1059}
1060
1061#[derive(Debug, Clone)]
1062pub enum PendingAction {
1063 Stop(DaemonId),
1064 Restart(DaemonId),
1065 Disable(DaemonId),
1066 BatchStop(Vec<DaemonId>),
1068 BatchRestart(Vec<DaemonId>),
1069 BatchDisable(Vec<DaemonId>),
1070 Clean(Vec<DaemonId>),
1071 DeleteDaemon { id: String, config_path: PathBuf },
1073 DiscardEditorChanges,
1074}
1075
1076#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1077pub enum SortColumn {
1078 #[default]
1079 Name,
1080 Status,
1081 Cpu,
1082 Memory,
1083 Uptime,
1084}
1085
1086impl SortColumn {
1087 pub fn next(self) -> Self {
1088 match self {
1089 Self::Name => Self::Status,
1090 Self::Status => Self::Cpu,
1091 Self::Cpu => Self::Memory,
1092 Self::Memory => Self::Uptime,
1093 Self::Uptime => Self::Name,
1094 }
1095 }
1096}
1097
1098#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1099pub enum SortOrder {
1100 #[default]
1101 Ascending,
1102 Descending,
1103}
1104
1105impl SortOrder {
1106 pub fn toggle(self) -> Self {
1107 match self {
1108 Self::Ascending => Self::Descending,
1109 Self::Descending => Self::Ascending,
1110 }
1111 }
1112
1113 pub fn indicator(self) -> &'static str {
1114 match self {
1115 Self::Ascending => "↑",
1116 Self::Descending => "↓",
1117 }
1118 }
1119}
1120
1121pub struct App {
1122 pub daemons: Vec<Daemon>,
1123 pub disabled: Vec<DaemonId>,
1124 pub selected: usize,
1125 pub view: View,
1126 pub prev_view: View,
1127 pub log_content: Vec<String>,
1128 pub log_daemon_id: Option<DaemonId>,
1129 pub log_scroll: usize,
1130 pub log_follow: bool, pub message: Option<String>,
1132 pub message_time: Option<Instant>,
1133 pub process_stats: HashMap<u32, ProcessStats>, pub stats_history: HashMap<DaemonId, StatsHistory>, pub pending_action: Option<PendingAction>,
1136 pub loading_text: Option<String>,
1137 pub search_query: String,
1138 pub search_active: bool,
1139 pub sort_column: SortColumn,
1141 pub sort_order: SortOrder,
1142 pub log_search_query: String,
1144 pub log_search_active: bool,
1145 pub log_search_matches: Vec<usize>, pub log_search_current: usize, pub details_daemon_id: Option<DaemonId>,
1149 pub logs_expanded: bool,
1151 pub multi_select: HashSet<DaemonId>,
1153 pub config_daemon_ids: HashSet<DaemonId>,
1155 pub show_available: bool,
1157 pub editor_state: Option<EditorState>,
1159 pub file_selector: Option<ConfigFileSelector>,
1161 pub network_listeners: Vec<Listener>,
1163 pub network_search_query: String,
1164 pub network_search_active: bool,
1165 pub network_selected: usize,
1166 pub network_scroll_offset: usize,
1167 pub network_selected_pid: Option<u32>, pub network_visible_rows: usize, pub namespace_filter: NamespaceFilter,
1172}
1173
1174impl App {
1175 pub fn new(namespace_filter: NamespaceFilter) -> Self {
1176 Self {
1177 namespace_filter,
1178 daemons: Vec::new(),
1179 disabled: Vec::new(),
1180 selected: 0,
1181 view: View::Dashboard,
1182 prev_view: View::Dashboard,
1183 log_content: Vec::new(),
1184 log_daemon_id: None,
1185 log_scroll: 1,
1186 log_follow: true,
1187 message: None,
1188 message_time: None,
1189 process_stats: HashMap::new(),
1190 stats_history: HashMap::new(),
1191 pending_action: None,
1192 loading_text: None,
1193 search_query: String::new(),
1194 search_active: false,
1195 sort_column: SortColumn::default(),
1196 sort_order: SortOrder::default(),
1197 log_search_query: String::new(),
1198 log_search_active: false,
1199 log_search_matches: Vec::new(),
1200 log_search_current: 0,
1201 details_daemon_id: None,
1202 logs_expanded: false,
1203 multi_select: HashSet::new(),
1204 config_daemon_ids: HashSet::new(),
1205 show_available: true, editor_state: None,
1207 file_selector: None,
1208 network_listeners: Vec::new(),
1209 network_search_query: String::new(),
1210 network_search_active: false,
1211 network_selected: 0,
1212 network_scroll_offset: 0,
1213 network_selected_pid: None,
1214 network_visible_rows: 20, }
1216 }
1217
1218 pub fn confirm_action(&mut self, action: PendingAction) {
1219 self.pending_action = Some(action);
1220 self.prev_view = self.view;
1221 self.view = View::Confirm;
1222 }
1223
1224 pub fn cancel_confirm(&mut self) {
1225 self.pending_action = None;
1226 self.view = self.prev_view;
1227 }
1228
1229 pub fn take_pending_action(&mut self) -> Option<PendingAction> {
1230 self.view = self.prev_view;
1231 self.pending_action.take()
1232 }
1233
1234 pub fn start_loading(&mut self, text: impl Into<String>) {
1235 self.loading_text = Some(text.into());
1236 }
1237
1238 pub fn stop_loading(&mut self) {
1239 self.loading_text = None;
1240 }
1241
1242 pub fn start_search(&mut self) {
1244 self.search_active = true;
1245 }
1246
1247 pub fn end_search(&mut self) {
1248 self.search_active = false;
1249 }
1250
1251 pub fn clear_search(&mut self) {
1252 self.search_query.clear();
1253 self.search_active = false;
1254 self.selected = 0;
1255 }
1256
1257 pub fn search_push(&mut self, c: char) {
1258 self.search_query.push(c);
1259 self.selected = 0;
1261 }
1262
1263 pub fn search_pop(&mut self) {
1264 self.search_query.pop();
1265 self.selected = 0;
1266 }
1267
1268 pub fn filtered_daemons(&self) -> Vec<&Daemon> {
1269 let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
1270 self.daemons.iter().collect()
1271 } else {
1272 let matcher = SkimMatcherV2::default();
1274 let mut scored: Vec<_> = self
1275 .daemons
1276 .iter()
1277 .filter_map(|d| {
1278 matcher
1279 .fuzzy_match(&d.id.qualified(), &self.search_query)
1280 .map(|score| (d, score))
1281 })
1282 .collect();
1283 scored.sort_by_key(|s| std::cmp::Reverse(s.1));
1285 scored.into_iter().map(|(d, _)| d).collect()
1286 };
1287
1288 filtered.sort_by(|a, b| {
1290 let cmp = match self.sort_column {
1291 SortColumn::Name => {
1292 a.id.to_string()
1293 .to_lowercase()
1294 .cmp(&b.id.to_string().to_lowercase())
1295 }
1296 SortColumn::Status => {
1297 let status_order = |d: &Daemon| match &d.status {
1298 crate::daemon_status::DaemonStatus::Running => 0,
1299 crate::daemon_status::DaemonStatus::Waiting => 1,
1300 crate::daemon_status::DaemonStatus::Stopping
1301 | crate::daemon_status::DaemonStatus::Restarting => 2,
1302 crate::daemon_status::DaemonStatus::Completed => 3,
1303 crate::daemon_status::DaemonStatus::Stopped => 4,
1304 crate::daemon_status::DaemonStatus::Errored(_) => 5,
1305 crate::daemon_status::DaemonStatus::Failed(_) => 6,
1306 };
1307 status_order(a).cmp(&status_order(b))
1308 }
1309 SortColumn::Cpu => {
1310 let cpu_a = a
1311 .pid
1312 .and_then(|p| self.get_stats(p))
1313 .map(|s| s.cpu_percent)
1314 .unwrap_or(0.0);
1315 let cpu_b = b
1316 .pid
1317 .and_then(|p| self.get_stats(p))
1318 .map(|s| s.cpu_percent)
1319 .unwrap_or(0.0);
1320 cpu_a
1321 .partial_cmp(&cpu_b)
1322 .unwrap_or(std::cmp::Ordering::Equal)
1323 }
1324 SortColumn::Memory => {
1325 let mem_a = a
1326 .pid
1327 .and_then(|p| self.get_stats(p))
1328 .map(|s| s.memory_bytes)
1329 .unwrap_or(0);
1330 let mem_b = b
1331 .pid
1332 .and_then(|p| self.get_stats(p))
1333 .map(|s| s.memory_bytes)
1334 .unwrap_or(0);
1335 mem_a.cmp(&mem_b)
1336 }
1337 SortColumn::Uptime => {
1338 let up_a = a
1339 .pid
1340 .and_then(|p| self.get_stats(p))
1341 .map(|s| s.uptime_secs)
1342 .unwrap_or(0);
1343 let up_b = b
1344 .pid
1345 .and_then(|p| self.get_stats(p))
1346 .map(|s| s.uptime_secs)
1347 .unwrap_or(0);
1348 up_a.cmp(&up_b)
1349 }
1350 };
1351 match self.sort_order {
1352 SortOrder::Ascending => cmp,
1353 SortOrder::Descending => cmp.reverse(),
1354 }
1355 });
1356
1357 filtered
1358 }
1359
1360 pub fn cycle_sort(&mut self) {
1362 self.sort_column = self.sort_column.next();
1364 self.selected = 0;
1365 }
1366
1367 pub fn toggle_sort_order(&mut self) {
1368 self.sort_order = self.sort_order.toggle();
1369 self.selected = 0;
1370 }
1371
1372 pub fn selected_daemon(&self) -> Option<&Daemon> {
1373 let filtered = self.filtered_daemons();
1374 filtered.get(self.selected).copied()
1375 }
1376
1377 pub fn select_next(&mut self) {
1378 let count = self.filtered_daemons().len();
1379 if count > 0 {
1380 self.selected = (self.selected + 1) % count;
1381 }
1382 }
1383
1384 pub fn select_prev(&mut self) {
1385 let count = self.filtered_daemons().len();
1386 if count > 0 {
1387 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1388 }
1389 }
1390
1391 pub fn toggle_log_follow(&mut self) {
1393 self.log_follow = !self.log_follow;
1394 if self.log_follow && !self.log_content.is_empty() {
1395 self.log_scroll = self.log_content.len();
1397 }
1398 }
1399
1400 pub fn toggle_logs_expanded(&mut self) {
1402 self.logs_expanded = !self.logs_expanded;
1403 }
1404
1405 pub fn toggle_select(&mut self) {
1407 if let Some(daemon) = self.selected_daemon() {
1408 let id = daemon.id.clone();
1409 if self.multi_select.contains(&id) {
1410 self.multi_select.remove(&id);
1411 } else {
1412 self.multi_select.insert(id);
1413 }
1414 }
1415 }
1416
1417 pub fn select_all_visible(&mut self) {
1418 let ids: Vec<DaemonId> = self
1420 .filtered_daemons()
1421 .iter()
1422 .map(|d| d.id.clone())
1423 .collect();
1424 for id in ids {
1425 self.multi_select.insert(id);
1426 }
1427 }
1428
1429 pub fn clear_selection(&mut self) {
1430 self.multi_select.clear();
1431 }
1432
1433 pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1434 self.multi_select.contains(daemon_id)
1435 }
1436
1437 pub fn has_selection(&self) -> bool {
1438 !self.multi_select.is_empty()
1439 }
1440
1441 pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1442 self.multi_select.iter().cloned().collect()
1443 }
1444
1445 pub fn set_message(&mut self, msg: impl Into<String>) {
1446 self.message = Some(msg.into());
1447 self.message_time = Some(Instant::now());
1448 }
1449
1450 pub fn clear_stale_message(&mut self) {
1451 let duration = settings().tui_message_duration();
1452 if let Some(time) = self.message_time
1453 && time.elapsed() >= duration
1454 {
1455 self.message = None;
1456 self.message_time = None;
1457 }
1458 }
1459
1460 pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1461 self.process_stats.get(&pid)
1462 }
1463
1464 fn refresh_process_stats(&mut self) {
1465 let pids: Vec<u32> = self.daemons.iter().filter_map(|d| d.pid).collect();
1466 if !pids.is_empty() {
1467 PROCS.refresh_if_stale();
1468 }
1469 self.process_stats.clear();
1470
1471 let stats_by_pid = PROCS.get_batch_tree_stats_map(&pids);
1472
1473 for daemon in &self.daemons {
1474 if let Some(pid) = daemon.pid
1475 && let Some(stats) = stats_by_pid.get(&pid).copied()
1476 {
1477 self.process_stats.insert(pid, stats);
1478 let history = self.stats_history.entry(daemon.id.clone()).or_default();
1480 history.push(StatsSnapshot::from(&stats));
1481 }
1482 }
1483 }
1484
1485 pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1487 self.stats_history.get(daemon_id)
1488 }
1489
1490 pub async fn fetch_daemon_data(
1492 client: &Arc<IpcClient>,
1493 namespace_filter: &NamespaceFilter,
1494 ) -> Result<Vec<DaemonListEntry>> {
1495 use crate::daemon_list::get_all_daemons;
1496 get_all_daemons(client, namespace_filter).await
1497 }
1498
1499 pub fn apply_refresh(&mut self, all_entries: Vec<DaemonListEntry>) {
1501 self.daemons.clear();
1503 self.disabled.clear();
1504 self.config_daemon_ids.clear();
1505
1506 for entry in all_entries {
1507 let daemon_id = entry.daemon.id.clone();
1508
1509 if entry.is_disabled {
1510 self.disabled.push(daemon_id.clone());
1511 }
1512
1513 if entry.is_available {
1514 self.config_daemon_ids.insert(daemon_id.clone());
1515 }
1516
1517 if !entry.is_available || self.show_available {
1518 self.daemons.push(entry.daemon);
1519 }
1520 }
1521
1522 self.refresh_process_stats();
1523 self.clear_stale_message();
1524
1525 let total_count = self.total_daemon_count();
1526 if total_count > 0 && self.selected >= total_count {
1527 self.selected = total_count - 1;
1528 }
1529
1530 if self.view == View::Logs
1531 && let Some(id) = self.log_daemon_id.clone()
1532 {
1533 self.load_logs(&id);
1534 }
1535 }
1536
1537 pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1538 let entries = Self::fetch_daemon_data(client, &self.namespace_filter).await?;
1539 self.apply_refresh(entries);
1540 Ok(())
1541 }
1542
1543 pub fn apply_network_refresh(&mut self, listeners: Vec<Listener>) {
1545 self.network_listeners = listeners;
1546
1547 let filtered_count = self.filtered_network_listeners().len();
1548
1549 if filtered_count > 0 && self.network_selected >= filtered_count {
1550 self.network_selected = filtered_count - 1;
1551 } else if filtered_count == 0 {
1552 self.network_selected = 0;
1553 }
1554
1555 let selected_pid = self
1556 .filtered_network_listeners()
1557 .get(self.network_selected)
1558 .map(|l| l.process.pid);
1559 self.network_selected_pid = selected_pid;
1560 }
1561
1562 pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1564 if self.network_search_query.is_empty() {
1565 return self.network_listeners.iter().collect();
1566 }
1567
1568 let matcher = SkimMatcherV2::default();
1569 let query = &self.network_search_query;
1570
1571 self.network_listeners
1572 .iter()
1573 .filter(|listener| {
1574 let search_text = format!(
1576 "{} {} {}",
1577 listener.process.name,
1578 listener.process.pid,
1579 listener.socket.port()
1580 );
1581 matcher.fuzzy_match(&search_text, query).is_some()
1582 })
1583 .collect()
1584 }
1585
1586 pub fn toggle_network_search(&mut self) {
1588 self.network_search_active = !self.network_search_active;
1589 if !self.network_search_active {
1590 self.network_search_query.clear();
1591 }
1592 self.network_selected = 0;
1594 self.network_scroll_offset = 0;
1595 let filtered = self.filtered_network_listeners();
1597 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1598 }
1599
1600 pub fn clear_network_search(&mut self) {
1602 self.network_search_query.clear();
1603 self.network_search_active = false;
1604 self.network_selected = 0;
1606 self.network_scroll_offset = 0;
1607 let filtered = self.filtered_network_listeners();
1609 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1610 }
1611
1612 pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1614 self.config_daemon_ids.contains(daemon_id)
1615 }
1616
1617 pub fn toggle_show_available(&mut self) {
1619 self.show_available = !self.show_available;
1620 }
1621
1622 fn total_daemon_count(&self) -> usize {
1624 self.filtered_daemons().len()
1625 }
1626
1627 pub fn scroll_logs_down(&mut self) {
1628 let max_scroll = self.log_content.len();
1629 self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1630 }
1631
1632 pub fn scroll_logs_up(&mut self) {
1633 self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1634 }
1635
1636 pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1638 let half_page = visible_lines / 2;
1639 let max_scroll = self.log_content.len();
1640 self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1641 }
1642
1643 pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1645 let half_page = visible_lines / 2;
1646 self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1647 }
1648
1649 pub fn start_log_search(&mut self) {
1651 self.log_search_active = true;
1652 self.log_search_query.clear();
1653 self.log_search_matches.clear();
1654 self.log_search_current = 0;
1655 }
1656
1657 pub fn end_log_search(&mut self) {
1658 self.log_search_active = false;
1659 }
1660
1661 pub fn clear_log_search(&mut self) {
1662 self.log_search_query.clear();
1663 self.log_search_active = false;
1664 self.log_search_matches.clear();
1665 self.log_search_current = 0;
1666 }
1667
1668 pub fn log_search_push(&mut self, c: char) {
1669 self.log_search_query.push(c);
1670 self.update_log_search_matches();
1671 }
1672
1673 pub fn log_search_pop(&mut self) {
1674 self.log_search_query.pop();
1675 self.update_log_search_matches();
1676 }
1677
1678 fn update_log_search_matches(&mut self) {
1679 self.log_search_matches.clear();
1680 if !self.log_search_query.is_empty() {
1681 let query = self.log_search_query.to_lowercase();
1682 for (i, line) in self.log_content.iter().enumerate() {
1683 if line.to_lowercase().contains(&query) {
1684 self.log_search_matches.push(i);
1685 }
1686 }
1687 if !self.log_search_matches.is_empty() {
1689 self.log_search_current = 0;
1690 self.jump_to_log_match();
1691 }
1692 }
1693 }
1694
1695 pub fn log_search_next(&mut self) {
1696 if !self.log_search_matches.is_empty() {
1697 self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1698 self.jump_to_log_match();
1699 }
1700 }
1701
1702 pub fn log_search_prev(&mut self) {
1703 if !self.log_search_matches.is_empty() {
1704 self.log_search_current = self
1705 .log_search_current
1706 .checked_sub(1)
1707 .unwrap_or(self.log_search_matches.len() - 1);
1708 self.jump_to_log_match();
1709 }
1710 }
1711
1712 fn jump_to_log_match(&mut self) {
1713 if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1714 let half_page = 10; self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1717 self.log_follow = false;
1718 }
1719 }
1720
1721 pub fn show_details(&mut self, daemon_id: &DaemonId) {
1723 self.details_daemon_id = Some(daemon_id.clone());
1724 self.prev_view = self.view;
1725 self.view = View::Details;
1726 }
1727
1728 pub fn hide_details(&mut self) {
1729 self.details_daemon_id = None;
1730 self.view = View::Dashboard;
1731 }
1732
1733 pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1735 self.log_daemon_id = Some(daemon_id.clone());
1736 self.logs_expanded = false; self.load_logs(daemon_id);
1738 self.view = View::Logs; }
1740
1741 fn load_logs(&mut self, daemon_id: &DaemonId) {
1742 const TUI_LOG_LIMIT: usize = 5000;
1743 let prev_len = self.log_content.len();
1744
1745 self.log_content = match LOG_STORE.query(&crate::log_store::LogQuery {
1746 daemon_ids: vec![daemon_id.qualified()],
1747 from: None,
1748 to: None,
1749 limit: Some(TUI_LOG_LIMIT),
1750 order_desc: true,
1751 after_id: None,
1752 before_id: None,
1753 message_filters: Vec::new(),
1754 field_filters: Vec::new(),
1755 include_structured: false,
1756 }) {
1757 Ok(entries) if !entries.is_empty() => {
1758 let mut lines: Vec<String> = entries
1759 .into_iter()
1760 .map(|e| {
1761 let ts = e.timestamp.format("%H:%M:%S").to_string();
1762 format!("{} {}", ts, e.message)
1763 })
1764 .collect();
1765 lines.reverse();
1766 lines
1767 }
1768 _ => vec!["No logs available".to_string()],
1769 };
1770
1771 if self.log_follow {
1773 self.log_scroll = self.log_content.len().max(1);
1774 } else if prev_len == 0 {
1775 self.log_scroll = self.log_content.len().max(1);
1777 }
1778 }
1780
1781 pub fn show_help(&mut self) {
1782 self.view = View::Help;
1783 }
1784
1785 pub fn back_to_dashboard(&mut self) {
1786 self.view = View::Dashboard;
1787 self.log_daemon_id = None;
1788 self.log_content.clear();
1789 self.log_scroll = 1;
1790 }
1791
1792 pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1794 let available = self.config_daemon_ids.len();
1795 let total = self.daemons.len();
1796 let running = self
1797 .daemons
1798 .iter()
1799 .filter(|d| d.status.is_running())
1800 .count();
1801 let stopped = self
1803 .daemons
1804 .iter()
1805 .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1806 .count();
1807 let errored = self
1808 .daemons
1809 .iter()
1810 .filter(|d| d.status.is_errored() || d.status.is_failed())
1811 .count();
1812 (total, running, stopped, errored, available)
1813 }
1814
1815 pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1816 self.disabled.contains(daemon_id)
1817 }
1818
1819 pub fn get_config_files(&self) -> Vec<PathBuf> {
1823 let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1824 .into_iter()
1825 .filter(|p| p.exists())
1826 .collect();
1827
1828 let cwd_config = crate::env::CWD.join("pitchfork.toml");
1830 if !files.contains(&cwd_config) {
1831 files.push(cwd_config);
1832 }
1833
1834 files
1835 }
1836
1837 pub fn open_file_selector(&mut self) {
1839 let files = self.get_config_files();
1840 self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1841 self.view = View::ConfigFileSelect;
1842 }
1843
1844 pub fn open_editor_create(&mut self, config_path: PathBuf) {
1846 self.editor_state = Some(EditorState::new_create(config_path));
1847 self.file_selector = None;
1848 self.view = View::ConfigEditor;
1849 }
1850
1851 pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1853 let config = match PitchforkToml::all_merged() {
1854 Ok(config) => config,
1855 Err(e) => {
1856 self.set_message(format!("Failed to load config: {e}"));
1857 return;
1858 }
1859 };
1860 if let Some(daemon_config) = config.daemons.get(daemon_id) {
1861 let config_path = daemon_config
1862 .path
1863 .clone()
1864 .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1865 self.editor_state = Some(EditorState::new_edit(
1866 daemon_id.to_string(),
1867 daemon_config,
1868 config_path,
1869 ));
1870 self.view = View::ConfigEditor;
1871 } else {
1872 self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1873 }
1874 }
1875
1876 pub fn close_editor(&mut self) {
1878 self.editor_state = None;
1879 self.file_selector = None;
1880 self.view = View::Dashboard;
1881 }
1882
1883 pub fn save_editor_config(&mut self) -> Result<bool> {
1886 let editor = self
1887 .editor_state
1888 .as_mut()
1889 .ok_or_else(|| miette::miette!("No editor state"))?;
1890
1891 if !editor.validate() {
1893 self.set_message("Please fix validation errors before saving");
1894 return Ok(false);
1895 }
1896
1897 let daemon_config = editor.to_daemon_config();
1899
1900 let daemon_id = DaemonId::parse(&editor.daemon_id)
1902 .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1903
1904 let mut config = PitchforkToml::read(&editor.config_path)?;
1906
1907 let is_duplicate = match &editor.mode {
1909 EditMode::Create => config.daemons.contains_key(&daemon_id),
1910 EditMode::Edit { original_id } => {
1911 let original_daemon_id = DaemonId::parse(original_id)
1913 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1914 original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1915 }
1916 };
1917
1918 if is_duplicate {
1919 self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1920 return Ok(false);
1921 }
1922
1923 if let EditMode::Edit { original_id } = &editor.mode {
1925 let original_daemon_id = DaemonId::parse(original_id)
1926 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1927 if original_daemon_id != daemon_id {
1928 config.daemons.shift_remove(&original_daemon_id);
1929 }
1930 }
1931
1932 config.daemons.insert(daemon_id, daemon_config);
1934
1935 config.write()?;
1937
1938 editor.unsaved_changes = false;
1939 let daemon_id = editor.daemon_id.clone();
1940 self.set_message(format!("Saved daemon '{daemon_id}'"));
1941
1942 Ok(true)
1943 }
1944
1945 pub fn delete_daemon_from_config(
1947 &mut self,
1948 id: &str,
1949 config_path: &std::path::Path,
1950 ) -> Result<bool> {
1951 let mut config = PitchforkToml::read(config_path)?;
1952
1953 let daemon_id = if id.contains('/') {
1956 DaemonId::parse(id)?
1957 } else {
1958 let ns = namespace_from_path(config_path)?;
1959 DaemonId::try_new(&ns, id)?
1960 };
1961
1962 if config.daemons.shift_remove(&daemon_id).is_some() {
1963 config.write()?;
1964 Ok(true)
1965 } else {
1966 Ok(false)
1967 }
1968 }
1969}
1970
1971impl Default for App {
1972 fn default() -> Self {
1973 Self::new(NamespaceFilter::default())
1974 }
1975}
1976
1977#[cfg(test)]
1978mod run_argv_editor_tests {
1979 use super::*;
1980
1981 fn argv(words: &[&str]) -> RunCommand {
1982 RunCommand::Argv(words.iter().map(|w| w.to_string()).collect())
1983 }
1984
1985 fn editor(run: RunCommand) -> EditorState {
1986 let config = PitchforkTomlDaemon {
1987 run,
1988 ..PitchforkTomlDaemon::default()
1989 };
1990 EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
1991 }
1992
1993 fn set_run(editor: &mut EditorState, text: &str) {
1994 let field = editor.fields.iter_mut().find(|f| f.name == "run").unwrap();
1995 field.set_text(text.to_string());
1996 }
1997
1998 #[test]
1999 fn an_untouched_array_is_saved_as_an_array() {
2000 let run = argv(&["node", "my server.js"]);
2001 let editor = editor(run.clone());
2002 assert_eq!(editor.to_daemon_config().run, run);
2003 }
2004
2005 #[test]
2006 fn an_edited_array_stays_an_array() {
2007 let mut editor = editor(argv(&["node", "my server.js"]));
2008 set_run(&mut editor, "node 'my server.js' --port 8080");
2009 assert!(editor.validate());
2010 assert_eq!(
2011 editor.to_daemon_config().run,
2012 argv(&["node", "my server.js", "--port", "8080"])
2013 );
2014 }
2015
2016 #[test]
2017 fn an_edited_array_must_still_parse() {
2018 let mut editor = editor(argv(&["node", "server.js"]));
2019 set_run(&mut editor, "node 'server.js");
2020 assert!(!editor.validate());
2021 set_run(&mut editor, "exec node server.js");
2022 assert!(!editor.validate());
2023 }
2024
2025 #[test]
2026 fn a_string_stays_a_string() {
2027 let mut editor = editor("exec node server.js".into());
2028 set_run(&mut editor, "exec node 'my server.js'");
2029 assert!(editor.validate());
2030 assert_eq!(editor.to_daemon_config().run, "exec node 'my server.js'");
2031 }
2032}
2033
2034#[cfg(test)]
2035mod run_argv_editor_review_tests {
2036 use super::*;
2037
2038 fn editor(run: RunCommand) -> EditorState {
2039 let config = PitchforkTomlDaemon {
2040 run,
2041 ..PitchforkTomlDaemon::default()
2042 };
2043 EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
2044 }
2045
2046 fn set_run(editor: &mut EditorState, text: &str) {
2047 let field = editor.fields.iter_mut().find(|f| f.name == "run").unwrap();
2048 field.set_text(text.to_string());
2049 }
2050
2051 #[test]
2052 fn windows_edit_text_round_trips_awkward_arguments() {
2053 let argv: Vec<String> = [
2054 r"C:\Program Files\node.exe",
2055 "",
2056 "it's",
2057 "say \"hi\"",
2058 "both ' and \"",
2059 r"trailing\",
2060 "a&b",
2061 ]
2062 .map(str::to_string)
2063 .to_vec();
2064 let text = argv
2065 .iter()
2066 .map(|a| windows_edit_quote(a))
2067 .collect::<Vec<_>>()
2068 .join(" ");
2069 assert_eq!(windows_edit_split(&text).unwrap(), argv, "text: {text}");
2070 }
2071
2072 #[test]
2073 fn windows_edit_split_keeps_backslashes_and_refuses_open_quotes() {
2074 assert_eq!(
2075 windows_edit_split(r#"C:\Tools\node.exe "my server.js" x"#).unwrap(),
2076 vec![r"C:\Tools\node.exe", "my server.js", "x"]
2077 );
2078 assert!(windows_edit_split("node 'server.js").is_err());
2079 }
2080
2081 #[test]
2082 fn an_empty_program_is_rejected() {
2083 let mut editor = editor(RunCommand::Argv(vec!["node".into()]));
2084 set_run(&mut editor, "'' node");
2085 assert!(!editor.validate());
2086 }
2087
2088 #[cfg(windows)]
2089 #[test]
2090 fn a_windows_path_keeps_its_backslashes() {
2091 let mut editor = editor(RunCommand::Argv(vec!["node".into()]));
2092 set_run(&mut editor, r"C:\Tools\node.exe --port 8080");
2093 assert!(editor.validate());
2094 assert_eq!(
2095 editor.to_daemon_config().run,
2096 RunCommand::Argv(vec![
2097 r"C:\Tools\node.exe".into(),
2098 "--port".into(),
2099 "8080".into()
2100 ])
2101 );
2102 }
2103}
2104
2105#[cfg(test)]
2106mod health_cmd_argv_editor_tests {
2107 use super::*;
2108 use crate::config_types::HealthCmd;
2109
2110 fn argv(words: &[&str]) -> RunCommand {
2111 RunCommand::Argv(words.iter().map(|w| w.to_string()).collect())
2112 }
2113
2114 fn editor(health_cmd: HealthCmd) -> EditorState {
2115 let config = PitchforkTomlDaemon {
2116 run: "sleep 60".into(),
2117 health_cmd: Some(health_cmd),
2118 ..PitchforkTomlDaemon::default()
2119 };
2120 EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
2121 }
2122
2123 fn set_health_cmd(editor: &mut EditorState, text: &str) {
2124 let field = editor
2125 .fields
2126 .iter_mut()
2127 .find(|f| f.name == "health_cmd")
2128 .unwrap();
2129 field.set_text(text.to_string());
2130 }
2131
2132 #[test]
2133 fn an_untouched_array_is_saved_as_an_array_with_its_settings() {
2134 let health_cmd = HealthCmd {
2135 run: argv(&["pg_isready", "-h", "my host"]),
2136 interval: Some(std::time::Duration::from_secs(5)),
2137 ..HealthCmd::default()
2138 };
2139 let editor = editor(health_cmd.clone());
2140 assert_eq!(editor.to_daemon_config().health_cmd, Some(health_cmd));
2141 }
2142
2143 #[test]
2144 fn an_edited_array_stays_an_array() {
2145 let mut editor = editor(HealthCmd::new(argv(&["pg_isready"])));
2146 set_health_cmd(&mut editor, "pg_isready -h 'my host'");
2147 assert!(editor.validate());
2148 assert_eq!(
2149 editor.to_daemon_config().health_cmd.unwrap().run,
2150 argv(&["pg_isready", "-h", "my host"])
2151 );
2152 }
2153
2154 #[test]
2155 fn an_edited_array_must_still_parse() {
2156 let mut editor = editor(HealthCmd::new(argv(&["pg_isready"])));
2157 set_health_cmd(&mut editor, "pg_isready -h 'my host");
2158 assert!(!editor.validate());
2159 set_health_cmd(&mut editor, "exec pg_isready");
2160 assert!(!editor.validate());
2161 }
2162
2163 #[test]
2164 fn a_string_stays_a_string() {
2165 let mut editor = editor(HealthCmd::new("curl -f http://localhost"));
2166 set_health_cmd(&mut editor, "curl -f 'http://localhost/a b'");
2167 assert!(editor.validate());
2168 assert_eq!(
2169 editor.to_daemon_config().health_cmd.unwrap().run,
2170 "curl -f 'http://localhost/a b'"
2171 );
2172 }
2173}