1use crate::Result;
2use crate::daemon::{Daemon, RunOptions};
3use crate::env::PITCHFORK_LOGS_DIR;
4use crate::ipc::client::IpcClient;
5use crate::pitchfork_toml::{
6 CronRetrigger, PitchforkToml, PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlDaemon, Retry,
7};
8use crate::procs::{PROCS, ProcessStats};
9use fuzzy_matcher::FuzzyMatcher;
10use fuzzy_matcher::skim::SkimMatcherV2;
11use miette::bail;
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::fs;
14use std::path::PathBuf;
15use std::time::Instant;
16
17const MAX_STAT_HISTORY: usize = 60;
19
20fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
22 s.char_indices()
23 .nth(char_idx)
24 .map(|(i, _)| i)
25 .unwrap_or(s.len())
26}
27
28#[derive(Debug, Clone, Copy)]
30pub struct StatsSnapshot {
31 pub cpu_percent: f32,
32 pub memory_bytes: u64,
33 pub disk_read_bytes: u64,
34 pub disk_write_bytes: u64,
35}
36
37impl From<&ProcessStats> for StatsSnapshot {
38 fn from(stats: &ProcessStats) -> Self {
39 Self {
40 cpu_percent: stats.cpu_percent,
41 memory_bytes: stats.memory_bytes,
42 disk_read_bytes: stats.disk_read_bytes,
43 disk_write_bytes: stats.disk_write_bytes,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Default)]
50pub struct StatsHistory {
51 pub samples: VecDeque<StatsSnapshot>,
52}
53
54impl StatsHistory {
55 pub fn push(&mut self, snapshot: StatsSnapshot) {
56 self.samples.push_back(snapshot);
57 while self.samples.len() > MAX_STAT_HISTORY {
58 self.samples.pop_front();
59 }
60 }
61
62 pub fn cpu_values(&self) -> Vec<f32> {
63 self.samples.iter().map(|s| s.cpu_percent).collect()
64 }
65
66 pub fn memory_values(&self) -> Vec<u64> {
67 self.samples.iter().map(|s| s.memory_bytes).collect()
68 }
69
70 pub fn disk_read_values(&self) -> Vec<u64> {
71 self.samples.iter().map(|s| s.disk_read_bytes).collect()
72 }
73
74 pub fn disk_write_values(&self) -> Vec<u64> {
75 self.samples.iter().map(|s| s.disk_write_bytes).collect()
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum View {
81 Dashboard,
82 Logs,
83 Help,
84 Confirm,
85 Loading,
86 Details,
87 ConfigEditor,
88 ConfigFileSelect,
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub enum EditMode {
94 Create,
95 Edit { original_id: String },
96}
97
98#[derive(Debug, Clone)]
100pub enum FormFieldValue {
101 Text(String),
102 OptionalText(Option<String>),
103 Number(u32),
104 OptionalNumber(Option<u64>),
105 OptionalPort(Option<u16>),
106 #[allow(dead_code)]
107 Boolean(bool),
108 OptionalBoolean(Option<bool>),
109 AutoBehavior(Vec<PitchforkTomlAuto>),
110 Retrigger(CronRetrigger),
111 StringList(Vec<String>),
112}
113
114#[derive(Debug, Clone)]
116pub struct FormField {
117 pub name: &'static str,
118 pub label: &'static str,
119 pub value: FormFieldValue,
120 pub required: bool,
121 #[allow(dead_code)]
122 pub help_text: &'static str,
123 pub error: Option<String>,
124 pub editing: bool,
125 pub cursor: usize,
126}
127
128impl FormField {
129 fn text(name: &'static str, label: &'static str, help: &'static str, required: bool) -> Self {
130 Self {
131 name,
132 label,
133 value: FormFieldValue::Text(String::new()),
134 required,
135 help_text: help,
136 error: None,
137 editing: false,
138 cursor: 0,
139 }
140 }
141
142 fn optional_text(name: &'static str, label: &'static str, help: &'static str) -> Self {
143 Self {
144 name,
145 label,
146 value: FormFieldValue::OptionalText(None),
147 required: false,
148 help_text: help,
149 error: None,
150 editing: false,
151 cursor: 0,
152 }
153 }
154
155 fn number(name: &'static str, label: &'static str, help: &'static str, default: u32) -> Self {
156 Self {
157 name,
158 label,
159 value: FormFieldValue::Number(default),
160 required: false,
161 help_text: help,
162 error: None,
163 editing: false,
164 cursor: 0,
165 }
166 }
167
168 fn optional_number(name: &'static str, label: &'static str, help: &'static str) -> Self {
169 Self {
170 name,
171 label,
172 value: FormFieldValue::OptionalNumber(None),
173 required: false,
174 help_text: help,
175 error: None,
176 editing: false,
177 cursor: 0,
178 }
179 }
180
181 fn optional_port(name: &'static str, label: &'static str, help: &'static str) -> Self {
182 Self {
183 name,
184 label,
185 value: FormFieldValue::OptionalPort(None),
186 required: false,
187 help_text: help,
188 error: None,
189 editing: false,
190 cursor: 0,
191 }
192 }
193
194 fn optional_bool(name: &'static str, label: &'static str, help: &'static str) -> Self {
195 Self {
196 name,
197 label,
198 value: FormFieldValue::OptionalBoolean(None),
199 required: false,
200 help_text: help,
201 error: None,
202 editing: false,
203 cursor: 0,
204 }
205 }
206
207 fn auto_behavior(name: &'static str, label: &'static str, help: &'static str) -> Self {
208 Self {
209 name,
210 label,
211 value: FormFieldValue::AutoBehavior(vec![]),
212 required: false,
213 help_text: help,
214 error: None,
215 editing: false,
216 cursor: 0,
217 }
218 }
219
220 fn retrigger(name: &'static str, label: &'static str, help: &'static str) -> Self {
221 Self {
222 name,
223 label,
224 value: FormFieldValue::Retrigger(CronRetrigger::Finish),
225 required: false,
226 help_text: help,
227 error: None,
228 editing: false,
229 cursor: 0,
230 }
231 }
232
233 fn string_list(name: &'static str, label: &'static str, help: &'static str) -> Self {
234 Self {
235 name,
236 label,
237 value: FormFieldValue::StringList(vec![]),
238 required: false,
239 help_text: help,
240 error: None,
241 editing: false,
242 cursor: 0,
243 }
244 }
245
246 pub fn get_text(&self) -> String {
247 match &self.value {
248 FormFieldValue::Text(s) => s.clone(),
249 FormFieldValue::OptionalText(Some(s)) => s.clone(),
250 FormFieldValue::OptionalText(None) => String::new(),
251 FormFieldValue::Number(n) => n.to_string(),
252 FormFieldValue::OptionalNumber(Some(n)) => n.to_string(),
253 FormFieldValue::OptionalNumber(None) => String::new(),
254 FormFieldValue::OptionalPort(Some(p)) => p.to_string(),
255 FormFieldValue::OptionalPort(None) => String::new(),
256 FormFieldValue::StringList(v) => v.join(", "),
257 _ => String::new(),
258 }
259 }
260
261 pub fn set_text(&mut self, text: String) {
262 match &mut self.value {
263 FormFieldValue::Text(s) => *s = text,
264 FormFieldValue::OptionalText(opt) => {
265 *opt = if text.is_empty() { None } else { Some(text) };
266 }
267 FormFieldValue::Number(n) => {
268 let trimmed = text.trim();
269 if trimmed.is_empty() {
270 *n = 0;
271 self.error = None;
272 } else {
273 match trimmed.parse() {
274 Ok(value) => {
275 *n = value;
276 self.error = None;
277 }
278 Err(_) => {
279 *n = 0;
280 self.error = Some("Invalid number".to_string());
281 }
282 }
283 }
284 }
285 FormFieldValue::OptionalNumber(opt) => {
286 *opt = text.parse().ok();
287 }
288 FormFieldValue::OptionalPort(opt) => {
289 *opt = text.parse().ok();
290 }
291 FormFieldValue::StringList(v) => {
292 *v = text
293 .split(',')
294 .map(|s| s.trim().to_string())
295 .filter(|s| !s.is_empty())
296 .collect();
297 }
298 _ => {}
299 }
300 }
301
302 pub fn is_text_editable(&self) -> bool {
303 matches!(
304 self.value,
305 FormFieldValue::Text(_)
306 | FormFieldValue::OptionalText(_)
307 | FormFieldValue::Number(_)
308 | FormFieldValue::OptionalNumber(_)
309 | FormFieldValue::OptionalPort(_)
310 | FormFieldValue::StringList(_)
311 )
312 }
313}
314
315#[derive(Debug, Clone)]
317pub struct EditorState {
318 pub mode: EditMode,
319 pub daemon_id: String,
320 pub daemon_id_editing: bool,
321 pub daemon_id_cursor: usize,
322 pub daemon_id_error: Option<String>,
323 pub fields: Vec<FormField>,
324 pub focused_field: usize,
325 pub config_path: PathBuf,
326 pub unsaved_changes: bool,
327 #[allow(dead_code)]
328 pub scroll_offset: usize,
329}
330
331impl EditorState {
332 pub fn new_create(config_path: PathBuf) -> Self {
333 Self {
334 mode: EditMode::Create,
335 daemon_id: String::new(),
336 daemon_id_editing: true,
337 daemon_id_cursor: 0,
338 daemon_id_error: None,
339 fields: Self::default_fields(),
340 focused_field: 0,
341 config_path,
342 unsaved_changes: false,
343 scroll_offset: 0,
344 }
345 }
346
347 pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
348 Self {
349 mode: EditMode::Edit {
350 original_id: daemon_id.clone(),
351 },
352 daemon_id,
353 daemon_id_editing: false,
354 daemon_id_cursor: 0,
355 daemon_id_error: None,
356 fields: Self::fields_from_config(config),
357 focused_field: 0,
358 config_path,
359 unsaved_changes: false,
360 scroll_offset: 0,
361 }
362 }
363
364 fn default_fields() -> Vec<FormField> {
365 vec![
366 FormField::text(
367 "run",
368 "Run Command",
369 "Command to execute. Prepend 'exec' to avoid shell overhead.",
370 true,
371 ),
372 FormField::auto_behavior(
373 "auto",
374 "Auto Behavior",
375 "Auto start/stop based on directory hooks.",
376 ),
377 FormField::number(
378 "retry",
379 "Retry Count",
380 "Number of retry attempts on failure (0 = no retries).",
381 0,
382 ),
383 FormField::optional_number(
384 "ready_delay",
385 "Ready Delay (ms)",
386 "Milliseconds to wait before considering daemon ready.",
387 ),
388 FormField::optional_text(
389 "ready_output",
390 "Ready Output Pattern",
391 "Regex pattern in stdout/stderr indicating readiness.",
392 ),
393 FormField::optional_text(
394 "ready_http",
395 "Ready HTTP URL",
396 "HTTP URL to poll for readiness (expects 2xx).",
397 ),
398 FormField::optional_port(
399 "ready_port",
400 "Ready Port",
401 "TCP port to check for readiness (1-65535).",
402 ),
403 FormField::optional_bool(
404 "boot_start",
405 "Start on Boot",
406 "Automatically start this daemon on system boot.",
407 ),
408 FormField::string_list(
409 "depends",
410 "Dependencies",
411 "Comma-separated daemon names that must start first.",
412 ),
413 FormField::string_list(
414 "watch",
415 "Watch Files",
416 "Comma-separated glob patterns to watch for auto-restart.",
417 ),
418 FormField::optional_text(
419 "cron_schedule",
420 "Cron Schedule",
421 "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
422 ),
423 FormField::retrigger(
424 "cron_retrigger",
425 "Cron Retrigger",
426 "Behavior when cron triggers while previous run is active.",
427 ),
428 ]
429 }
430
431 fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
432 let mut fields = Self::default_fields();
433
434 for field in &mut fields {
435 match field.name {
436 "run" => field.value = FormFieldValue::Text(config.run.clone()),
437 "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
438 "retry" => field.value = FormFieldValue::Number(config.retry.count()),
439 "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
440 "ready_output" => {
441 field.value = FormFieldValue::OptionalText(config.ready_output.clone())
442 }
443 "ready_http" => {
444 field.value = FormFieldValue::OptionalText(config.ready_http.clone())
445 }
446 "ready_port" => field.value = FormFieldValue::OptionalPort(config.ready_port),
447 "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
448 "depends" => field.value = FormFieldValue::StringList(config.depends.clone()),
449 "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
450 "cron_schedule" => {
451 field.value = FormFieldValue::OptionalText(
452 config.cron.as_ref().map(|c| c.schedule.clone()),
453 );
454 }
455 "cron_retrigger" => {
456 field.value = FormFieldValue::Retrigger(
457 config
458 .cron
459 .as_ref()
460 .map(|c| c.retrigger)
461 .unwrap_or(CronRetrigger::Finish),
462 );
463 }
464 _ => {}
465 }
466 }
467
468 fields
469 }
470
471 pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
472 let mut config = PitchforkTomlDaemon {
473 run: String::new(),
474 auto: vec![],
475 cron: None,
476 retry: Retry(0),
477 ready_delay: None,
478 ready_output: None,
479 ready_http: None,
480 ready_port: None,
481 boot_start: None,
482 depends: vec![],
483 watch: vec![],
484 path: Some(self.config_path.clone()),
485 };
486
487 let mut cron_schedule: Option<String> = None;
488 let mut cron_retrigger = CronRetrigger::Finish;
489
490 for field in &self.fields {
491 match (field.name, &field.value) {
492 ("run", FormFieldValue::Text(s)) => config.run = s.clone(),
493 ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
494 ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
495 ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
496 ("ready_output", FormFieldValue::OptionalText(s)) => {
497 config.ready_output = s.clone()
498 }
499 ("ready_http", FormFieldValue::OptionalText(s)) => config.ready_http = s.clone(),
500 ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = *p,
501 ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
502 ("depends", FormFieldValue::StringList(v)) => config.depends = v.clone(),
503 ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
504 ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
505 ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
506 _ => {}
507 }
508 }
509
510 if let Some(schedule) = cron_schedule {
511 config.cron = Some(PitchforkTomlCron {
512 schedule,
513 retrigger: cron_retrigger,
514 });
515 }
516
517 config
518 }
519
520 pub fn next_field(&mut self) {
521 if let Some(field) = self.fields.get_mut(self.focused_field) {
523 field.editing = false;
524 }
525
526 if self.daemon_id_editing {
528 self.daemon_id_editing = false;
529 return;
530 }
531
532 if self.focused_field < self.fields.len() - 1 {
533 self.focused_field += 1;
534 }
535 }
536
537 pub fn prev_field(&mut self) {
538 if let Some(field) = self.fields.get_mut(self.focused_field) {
540 field.editing = false;
541 }
542 self.daemon_id_editing = false;
543
544 if self.focused_field > 0 {
545 self.focused_field -= 1;
546 }
547 }
548
549 pub fn toggle_current_field(&mut self) {
550 if let Some(field) = self.fields.get_mut(self.focused_field) {
551 let toggled = match &mut field.value {
552 FormFieldValue::Boolean(b) => {
553 *b = !*b;
554 true
555 }
556 FormFieldValue::OptionalBoolean(opt) => {
557 *opt = match opt {
558 None => Some(true),
559 Some(true) => Some(false),
560 Some(false) => None,
561 };
562 true
563 }
564 FormFieldValue::AutoBehavior(v) => {
565 let has_start = v.contains(&PitchforkTomlAuto::Start);
567 let has_stop = v.contains(&PitchforkTomlAuto::Stop);
568 *v = match (has_start, has_stop) {
569 (false, false) => vec![PitchforkTomlAuto::Start],
570 (true, false) => vec![PitchforkTomlAuto::Stop],
571 (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
572 (true, true) => vec![],
573 };
574 true
575 }
576 FormFieldValue::Retrigger(r) => {
577 *r = match r {
578 CronRetrigger::Finish => CronRetrigger::Always,
579 CronRetrigger::Always => CronRetrigger::Success,
580 CronRetrigger::Success => CronRetrigger::Fail,
581 CronRetrigger::Fail => CronRetrigger::Finish,
582 };
583 true
584 }
585 _ => false,
586 };
587 if toggled {
588 self.unsaved_changes = true;
589 }
590 }
591 }
592
593 pub fn start_editing(&mut self) {
594 if let Some(field) = self.fields.get_mut(self.focused_field) {
595 if field.is_text_editable() {
596 field.editing = true;
597 field.cursor = field.get_text().chars().count();
598 } else {
599 self.toggle_current_field();
601 }
602 }
603 }
604
605 pub fn stop_editing(&mut self) {
606 if let Some(field) = self.fields.get_mut(self.focused_field) {
607 field.editing = false;
608 }
609 self.daemon_id_editing = false;
610 }
611
612 pub fn is_editing(&self) -> bool {
613 self.daemon_id_editing
614 || self
615 .fields
616 .get(self.focused_field)
617 .map(|f| f.editing)
618 .unwrap_or(false)
619 }
620
621 pub fn text_push(&mut self, c: char) {
622 if self.daemon_id_editing {
623 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
624 self.daemon_id.insert(byte_idx, c);
625 self.daemon_id_cursor += 1;
626 self.unsaved_changes = true;
627 } else if let Some(field) = self.fields.get_mut(self.focused_field)
628 && field.editing
629 {
630 let mut text = field.get_text();
631 let byte_idx = char_to_byte_index(&text, field.cursor);
632 text.insert(byte_idx, c);
633 field.cursor += 1;
634 field.set_text(text);
635 self.unsaved_changes = true;
636 }
637 }
638
639 pub fn text_pop(&mut self) {
640 if self.daemon_id_editing && self.daemon_id_cursor > 0 {
641 self.daemon_id_cursor -= 1;
642 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
643 self.daemon_id.remove(byte_idx);
644 self.unsaved_changes = true;
645 } else if let Some(field) = self.fields.get_mut(self.focused_field)
646 && field.editing
647 && field.cursor > 0
648 {
649 let mut text = field.get_text();
650 field.cursor -= 1;
651 let byte_idx = char_to_byte_index(&text, field.cursor);
652 text.remove(byte_idx);
653 field.set_text(text);
654 if matches!(field.value, FormFieldValue::Number(_)) {
657 field.cursor = field.get_text().chars().count();
658 }
659 self.unsaved_changes = true;
660 }
661 }
662
663 pub fn validate(&mut self) -> bool {
664 let mut valid = true;
665
666 self.daemon_id_error = None;
668 if self.daemon_id.is_empty() {
669 self.daemon_id_error = Some("Name is required".to_string());
670 valid = false;
671 } else if !self
672 .daemon_id
673 .chars()
674 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
675 {
676 self.daemon_id_error =
677 Some("Only letters, digits, hyphens, and underscores allowed".to_string());
678 valid = false;
679 }
680
681 for field in &mut self.fields {
683 field.error = None;
684
685 match (field.name, &field.value) {
686 ("run", FormFieldValue::Text(s)) if s.is_empty() => {
687 field.error = Some("Required".to_string());
688 valid = false;
689 }
690 ("ready_port", FormFieldValue::OptionalPort(Some(p))) if *p == 0 => {
691 field.error = Some("Port must be 1-65535".to_string());
692 valid = false;
693 }
694 ("ready_http", FormFieldValue::OptionalText(Some(url)))
695 if !(url.starts_with("http://") || url.starts_with("https://")) =>
696 {
697 field.error = Some("Must start with http:// or https://".to_string());
698 valid = false;
699 }
700 _ => {}
701 }
702 }
703
704 valid
705 }
706}
707
708#[derive(Debug, Clone)]
710pub struct ConfigFileSelector {
711 pub files: Vec<PathBuf>,
712 pub selected: usize,
713}
714
715#[derive(Debug, Clone)]
716pub enum PendingAction {
717 Stop(String),
718 Restart(String),
719 Disable(String),
720 BatchStop(Vec<String>),
722 BatchRestart(Vec<String>),
723 BatchDisable(Vec<String>),
724 DeleteDaemon { id: String, config_path: PathBuf },
726 DiscardEditorChanges,
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
730pub enum SortColumn {
731 #[default]
732 Name,
733 Status,
734 Cpu,
735 Memory,
736 Uptime,
737}
738
739impl SortColumn {
740 pub fn next(self) -> Self {
741 match self {
742 Self::Name => Self::Status,
743 Self::Status => Self::Cpu,
744 Self::Cpu => Self::Memory,
745 Self::Memory => Self::Uptime,
746 Self::Uptime => Self::Name,
747 }
748 }
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
752pub enum SortOrder {
753 #[default]
754 Ascending,
755 Descending,
756}
757
758impl SortOrder {
759 pub fn toggle(self) -> Self {
760 match self {
761 Self::Ascending => Self::Descending,
762 Self::Descending => Self::Ascending,
763 }
764 }
765
766 pub fn indicator(self) -> &'static str {
767 match self {
768 Self::Ascending => "↑",
769 Self::Descending => "↓",
770 }
771 }
772}
773
774pub struct App {
775 pub daemons: Vec<Daemon>,
776 pub disabled: Vec<String>,
777 pub selected: usize,
778 pub view: View,
779 pub prev_view: View,
780 pub log_content: Vec<String>,
781 pub log_daemon_id: Option<String>,
782 pub log_scroll: usize,
783 pub log_follow: bool, pub message: Option<String>,
785 pub message_time: Option<Instant>,
786 pub process_stats: HashMap<u32, ProcessStats>, pub stats_history: HashMap<String, StatsHistory>, pub pending_action: Option<PendingAction>,
789 pub loading_text: Option<String>,
790 pub search_query: String,
791 pub search_active: bool,
792 pub sort_column: SortColumn,
794 pub sort_order: SortOrder,
795 pub log_search_query: String,
797 pub log_search_active: bool,
798 pub log_search_matches: Vec<usize>, pub log_search_current: usize, pub details_daemon_id: Option<String>,
802 pub logs_expanded: bool,
804 pub multi_select: HashSet<String>,
806 pub config_daemon_ids: HashSet<String>,
808 pub show_available: bool,
810 pub editor_state: Option<EditorState>,
812 pub file_selector: Option<ConfigFileSelector>,
814}
815
816impl App {
817 pub fn new() -> Self {
818 Self {
819 daemons: Vec::new(),
820 disabled: Vec::new(),
821 selected: 0,
822 view: View::Dashboard,
823 prev_view: View::Dashboard,
824 log_content: Vec::new(),
825 log_daemon_id: None,
826 log_scroll: 0,
827 log_follow: true,
828 message: None,
829 message_time: None,
830 process_stats: HashMap::new(),
831 stats_history: HashMap::new(),
832 pending_action: None,
833 loading_text: None,
834 search_query: String::new(),
835 search_active: false,
836 sort_column: SortColumn::default(),
837 sort_order: SortOrder::default(),
838 log_search_query: String::new(),
839 log_search_active: false,
840 log_search_matches: Vec::new(),
841 log_search_current: 0,
842 details_daemon_id: None,
843 logs_expanded: false,
844 multi_select: HashSet::new(),
845 config_daemon_ids: HashSet::new(),
846 show_available: true, editor_state: None,
848 file_selector: None,
849 }
850 }
851
852 pub fn confirm_action(&mut self, action: PendingAction) {
853 self.pending_action = Some(action);
854 self.prev_view = self.view;
855 self.view = View::Confirm;
856 }
857
858 pub fn cancel_confirm(&mut self) {
859 self.pending_action = None;
860 self.view = self.prev_view;
861 }
862
863 pub fn take_pending_action(&mut self) -> Option<PendingAction> {
864 self.view = self.prev_view;
865 self.pending_action.take()
866 }
867
868 pub fn start_loading(&mut self, text: impl Into<String>) {
869 self.prev_view = self.view;
870 self.loading_text = Some(text.into());
871 self.view = View::Loading;
872 }
873
874 pub fn stop_loading(&mut self) {
875 self.loading_text = None;
876 self.view = self.prev_view;
877 }
878
879 pub fn start_search(&mut self) {
881 self.search_active = true;
882 }
883
884 pub fn end_search(&mut self) {
885 self.search_active = false;
886 }
887
888 pub fn clear_search(&mut self) {
889 self.search_query.clear();
890 self.search_active = false;
891 self.selected = 0;
892 }
893
894 pub fn search_push(&mut self, c: char) {
895 self.search_query.push(c);
896 self.selected = 0;
898 }
899
900 pub fn search_pop(&mut self) {
901 self.search_query.pop();
902 self.selected = 0;
903 }
904
905 pub fn filtered_daemons(&self) -> Vec<&Daemon> {
906 let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
907 self.daemons.iter().collect()
908 } else {
909 let matcher = SkimMatcherV2::default();
911 let mut scored: Vec<_> = self
912 .daemons
913 .iter()
914 .filter_map(|d| {
915 matcher
916 .fuzzy_match(&d.id, &self.search_query)
917 .map(|score| (d, score))
918 })
919 .collect();
920 scored.sort_by(|a, b| b.1.cmp(&a.1));
922 scored.into_iter().map(|(d, _)| d).collect()
923 };
924
925 filtered.sort_by(|a, b| {
927 let cmp = match self.sort_column {
928 SortColumn::Name => a.id.to_lowercase().cmp(&b.id.to_lowercase()),
929 SortColumn::Status => {
930 let status_order = |d: &Daemon| match &d.status {
931 crate::daemon_status::DaemonStatus::Running => 0,
932 crate::daemon_status::DaemonStatus::Waiting => 1,
933 crate::daemon_status::DaemonStatus::Stopping => 2,
934 crate::daemon_status::DaemonStatus::Stopped => 3,
935 crate::daemon_status::DaemonStatus::Errored(_) => 4,
936 crate::daemon_status::DaemonStatus::Failed(_) => 5,
937 };
938 status_order(a).cmp(&status_order(b))
939 }
940 SortColumn::Cpu => {
941 let cpu_a = a
942 .pid
943 .and_then(|p| self.get_stats(p))
944 .map(|s| s.cpu_percent)
945 .unwrap_or(0.0);
946 let cpu_b = b
947 .pid
948 .and_then(|p| self.get_stats(p))
949 .map(|s| s.cpu_percent)
950 .unwrap_or(0.0);
951 cpu_a
952 .partial_cmp(&cpu_b)
953 .unwrap_or(std::cmp::Ordering::Equal)
954 }
955 SortColumn::Memory => {
956 let mem_a = a
957 .pid
958 .and_then(|p| self.get_stats(p))
959 .map(|s| s.memory_bytes)
960 .unwrap_or(0);
961 let mem_b = b
962 .pid
963 .and_then(|p| self.get_stats(p))
964 .map(|s| s.memory_bytes)
965 .unwrap_or(0);
966 mem_a.cmp(&mem_b)
967 }
968 SortColumn::Uptime => {
969 let up_a = a
970 .pid
971 .and_then(|p| self.get_stats(p))
972 .map(|s| s.uptime_secs)
973 .unwrap_or(0);
974 let up_b = b
975 .pid
976 .and_then(|p| self.get_stats(p))
977 .map(|s| s.uptime_secs)
978 .unwrap_or(0);
979 up_a.cmp(&up_b)
980 }
981 };
982 match self.sort_order {
983 SortOrder::Ascending => cmp,
984 SortOrder::Descending => cmp.reverse(),
985 }
986 });
987
988 filtered
989 }
990
991 pub fn cycle_sort(&mut self) {
993 self.sort_column = self.sort_column.next();
995 self.selected = 0;
996 }
997
998 pub fn toggle_sort_order(&mut self) {
999 self.sort_order = self.sort_order.toggle();
1000 self.selected = 0;
1001 }
1002
1003 pub fn selected_daemon(&self) -> Option<&Daemon> {
1004 let filtered = self.filtered_daemons();
1005 filtered.get(self.selected).copied()
1006 }
1007
1008 pub fn select_next(&mut self) {
1009 let count = self.filtered_daemons().len();
1010 if count > 0 {
1011 self.selected = (self.selected + 1) % count;
1012 }
1013 }
1014
1015 pub fn select_prev(&mut self) {
1016 let count = self.filtered_daemons().len();
1017 if count > 0 {
1018 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1019 }
1020 }
1021
1022 pub fn toggle_log_follow(&mut self) {
1024 self.log_follow = !self.log_follow;
1025 if self.log_follow && !self.log_content.is_empty() {
1026 self.log_scroll = self.log_content.len().saturating_sub(20);
1028 }
1029 }
1030
1031 pub fn toggle_logs_expanded(&mut self) {
1033 self.logs_expanded = !self.logs_expanded;
1034 }
1035
1036 pub fn toggle_select(&mut self) {
1038 if let Some(daemon) = self.selected_daemon() {
1039 let id = daemon.id.clone();
1040 if self.multi_select.contains(&id) {
1041 self.multi_select.remove(&id);
1042 } else {
1043 self.multi_select.insert(id);
1044 }
1045 }
1046 }
1047
1048 pub fn select_all_visible(&mut self) {
1049 let ids: Vec<String> = self
1051 .filtered_daemons()
1052 .iter()
1053 .map(|d| d.id.clone())
1054 .collect();
1055 for id in ids {
1056 self.multi_select.insert(id);
1057 }
1058 }
1059
1060 pub fn clear_selection(&mut self) {
1061 self.multi_select.clear();
1062 }
1063
1064 pub fn is_selected(&self, daemon_id: &str) -> bool {
1065 self.multi_select.contains(daemon_id)
1066 }
1067
1068 pub fn has_selection(&self) -> bool {
1069 !self.multi_select.is_empty()
1070 }
1071
1072 pub fn selected_daemon_ids(&self) -> Vec<String> {
1073 self.multi_select.iter().cloned().collect()
1074 }
1075
1076 pub fn set_message(&mut self, msg: impl Into<String>) {
1077 self.message = Some(msg.into());
1078 self.message_time = Some(Instant::now());
1079 }
1080
1081 pub fn clear_stale_message(&mut self) {
1082 if let Some(time) = self.message_time
1083 && time.elapsed().as_secs() >= 3
1084 {
1085 self.message = None;
1086 self.message_time = None;
1087 }
1088 }
1089
1090 pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1091 self.process_stats.get(&pid)
1092 }
1093
1094 fn refresh_process_stats(&mut self) {
1095 PROCS.refresh_processes();
1096 self.process_stats.clear();
1097 for daemon in &self.daemons {
1098 if let Some(pid) = daemon.pid
1099 && let Some(stats) = PROCS.get_stats(pid)
1100 {
1101 self.process_stats.insert(pid, stats);
1102 let history = self.stats_history.entry(daemon.id.clone()).or_default();
1104 history.push(StatsSnapshot::from(&stats));
1105 }
1106 }
1107 }
1108
1109 pub fn get_stats_history(&self, daemon_id: &str) -> Option<&StatsHistory> {
1111 self.stats_history.get(daemon_id)
1112 }
1113
1114 pub async fn refresh(&mut self, client: &IpcClient) -> Result<()> {
1115 self.daemons = client.active_daemons().await?;
1116 self.daemons.retain(|d| d.id != "pitchfork");
1118 self.disabled = client.get_disabled_daemons().await?;
1119
1120 self.refresh_config_daemons();
1122
1123 self.refresh_process_stats();
1125
1126 self.clear_stale_message();
1128
1129 let total_count = self.total_daemon_count();
1131 if total_count > 0 && self.selected >= total_count {
1132 self.selected = total_count - 1;
1133 }
1134
1135 if self.view == View::Logs
1137 && let Some(id) = self.log_daemon_id.clone()
1138 {
1139 self.load_logs(&id);
1140 }
1141
1142 Ok(())
1143 }
1144
1145 fn refresh_config_daemons(&mut self) {
1146 use crate::daemon_status::DaemonStatus;
1147
1148 let config = PitchforkToml::all_merged();
1149 let active_ids: HashSet<String> = self.daemons.iter().map(|d| d.id.clone()).collect();
1150
1151 self.config_daemon_ids.clear();
1153 for daemon_id in config.daemons.keys() {
1154 if !active_ids.contains(daemon_id) && daemon_id != "pitchfork" {
1155 self.config_daemon_ids.insert(daemon_id.clone());
1156
1157 if self.show_available {
1159 let placeholder = Daemon {
1160 id: daemon_id.clone(),
1161 title: None,
1162 pid: None,
1163 shell_pid: None,
1164 status: DaemonStatus::Stopped,
1165 dir: None,
1166 autostop: false,
1167 cron_schedule: None,
1168 cron_retrigger: None,
1169 last_cron_triggered: None,
1170 last_exit_success: None,
1171 retry: 0,
1172 retry_count: 0,
1173 ready_delay: None,
1174 ready_output: None,
1175 ready_http: None,
1176 ready_port: None,
1177 depends: vec![],
1178 };
1179 self.daemons.push(placeholder);
1180 }
1181 }
1182 }
1183 }
1184
1185 pub fn is_config_only(&self, daemon_id: &str) -> bool {
1187 self.config_daemon_ids.contains(daemon_id)
1188 }
1189
1190 pub fn toggle_show_available(&mut self) {
1192 self.show_available = !self.show_available;
1193 }
1194
1195 fn total_daemon_count(&self) -> usize {
1197 self.filtered_daemons().len()
1198 }
1199
1200 pub fn scroll_logs_down(&mut self) {
1201 if self.log_content.len() > 20 {
1202 let max_scroll = self.log_content.len().saturating_sub(20);
1203 self.log_scroll = (self.log_scroll + 1).min(max_scroll);
1204 }
1205 }
1206
1207 pub fn scroll_logs_up(&mut self) {
1208 self.log_scroll = self.log_scroll.saturating_sub(1);
1209 }
1210
1211 pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1213 let half_page = visible_lines / 2;
1214 if self.log_content.len() > visible_lines {
1215 let max_scroll = self.log_content.len().saturating_sub(visible_lines);
1216 self.log_scroll = (self.log_scroll + half_page).min(max_scroll);
1217 }
1218 }
1219
1220 pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1222 let half_page = visible_lines / 2;
1223 self.log_scroll = self.log_scroll.saturating_sub(half_page);
1224 }
1225
1226 pub fn start_log_search(&mut self) {
1228 self.log_search_active = true;
1229 self.log_search_query.clear();
1230 self.log_search_matches.clear();
1231 self.log_search_current = 0;
1232 }
1233
1234 pub fn end_log_search(&mut self) {
1235 self.log_search_active = false;
1236 }
1237
1238 pub fn clear_log_search(&mut self) {
1239 self.log_search_query.clear();
1240 self.log_search_active = false;
1241 self.log_search_matches.clear();
1242 self.log_search_current = 0;
1243 }
1244
1245 pub fn log_search_push(&mut self, c: char) {
1246 self.log_search_query.push(c);
1247 self.update_log_search_matches();
1248 }
1249
1250 pub fn log_search_pop(&mut self) {
1251 self.log_search_query.pop();
1252 self.update_log_search_matches();
1253 }
1254
1255 fn update_log_search_matches(&mut self) {
1256 self.log_search_matches.clear();
1257 if !self.log_search_query.is_empty() {
1258 let query = self.log_search_query.to_lowercase();
1259 for (i, line) in self.log_content.iter().enumerate() {
1260 if line.to_lowercase().contains(&query) {
1261 self.log_search_matches.push(i);
1262 }
1263 }
1264 if !self.log_search_matches.is_empty() {
1266 self.log_search_current = 0;
1267 self.jump_to_log_match();
1268 }
1269 }
1270 }
1271
1272 pub fn log_search_next(&mut self) {
1273 if !self.log_search_matches.is_empty() {
1274 self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1275 self.jump_to_log_match();
1276 }
1277 }
1278
1279 pub fn log_search_prev(&mut self) {
1280 if !self.log_search_matches.is_empty() {
1281 self.log_search_current = self
1282 .log_search_current
1283 .checked_sub(1)
1284 .unwrap_or(self.log_search_matches.len() - 1);
1285 self.jump_to_log_match();
1286 }
1287 }
1288
1289 fn jump_to_log_match(&mut self) {
1290 if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1291 let half_page = 10; self.log_scroll = line_idx.saturating_sub(half_page);
1294 self.log_follow = false;
1295 }
1296 }
1297
1298 pub fn show_details(&mut self, daemon_id: &str) {
1300 self.details_daemon_id = Some(daemon_id.to_string());
1301 self.prev_view = self.view;
1302 self.view = View::Details;
1303 }
1304
1305 pub fn hide_details(&mut self) {
1306 self.details_daemon_id = None;
1307 self.view = View::Dashboard;
1308 }
1309
1310 pub fn view_daemon_details(&mut self, daemon_id: &str) {
1312 self.log_daemon_id = Some(daemon_id.to_string());
1313 self.logs_expanded = false; self.load_logs(daemon_id);
1315 self.view = View::Logs; }
1317
1318 fn load_logs(&mut self, daemon_id: &str) {
1319 let log_path = Self::log_path(daemon_id);
1320 let prev_len = self.log_content.len();
1321
1322 self.log_content = if log_path.exists() {
1323 fs::read_to_string(&log_path)
1324 .unwrap_or_default()
1325 .lines()
1326 .map(String::from)
1327 .collect()
1328 } else {
1329 vec!["No logs available".to_string()]
1330 };
1331
1332 if self.log_follow {
1334 if self.log_content.len() > 20 {
1335 self.log_scroll = self.log_content.len().saturating_sub(20);
1336 } else {
1337 self.log_scroll = 0;
1338 }
1339 } else if prev_len == 0 {
1340 if self.log_content.len() > 20 {
1342 self.log_scroll = self.log_content.len().saturating_sub(20);
1343 }
1344 }
1345 }
1347
1348 fn log_path(daemon_id: &str) -> PathBuf {
1349 PITCHFORK_LOGS_DIR
1350 .join(daemon_id)
1351 .join(format!("{daemon_id}.log"))
1352 }
1353
1354 pub fn show_help(&mut self) {
1355 self.view = View::Help;
1356 }
1357
1358 pub fn back_to_dashboard(&mut self) {
1359 self.view = View::Dashboard;
1360 self.log_daemon_id = None;
1361 self.log_content.clear();
1362 self.log_scroll = 0;
1363 }
1364
1365 pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1367 let available = self.config_daemon_ids.len();
1368 let total = self.daemons.len();
1369 let running = self
1370 .daemons
1371 .iter()
1372 .filter(|d| d.status.is_running())
1373 .count();
1374 let stopped = self
1376 .daemons
1377 .iter()
1378 .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1379 .count();
1380 let errored = self
1381 .daemons
1382 .iter()
1383 .filter(|d| d.status.is_errored() || d.status.is_failed())
1384 .count();
1385 (total, running, stopped, errored, available)
1386 }
1387
1388 pub fn is_disabled(&self, daemon_id: &str) -> bool {
1389 self.disabled.contains(&daemon_id.to_string())
1390 }
1391
1392 pub async fn start_daemon(&mut self, client: &IpcClient, daemon_id: &str) -> Result<()> {
1393 let config = PitchforkToml::all_merged();
1395 let daemon_config = config
1396 .daemons
1397 .get(daemon_id)
1398 .ok_or_else(|| miette::miette!("Daemon '{}' not found in config", daemon_id))?;
1399
1400 let cmd = shell_words::split(&daemon_config.run)
1401 .map_err(|e| miette::miette!("Failed to parse command: {}", e))?;
1402
1403 if cmd.is_empty() {
1404 bail!("Daemon '{}' has empty run command", daemon_id);
1405 }
1406
1407 let (cron_schedule, cron_retrigger) = daemon_config
1408 .cron
1409 .as_ref()
1410 .map(|c| (Some(c.schedule.clone()), Some(c.retrigger)))
1411 .unwrap_or((None, None));
1412
1413 let opts = RunOptions {
1414 id: daemon_id.to_string(),
1415 cmd,
1416 force: false,
1417 shell_pid: None,
1418 dir: std::env::current_dir().unwrap_or_default(),
1419 autostop: false,
1420 cron_schedule,
1421 cron_retrigger,
1422 retry: daemon_config.retry.count(),
1423 retry_count: 0,
1424 ready_delay: daemon_config.ready_delay,
1425 ready_output: daemon_config.ready_output.clone(),
1426 ready_http: daemon_config.ready_http.clone(),
1427 ready_port: daemon_config.ready_port,
1428 wait_ready: false,
1429 depends: daemon_config.depends.clone(),
1430 };
1431
1432 client.run(opts).await?;
1433 self.set_message(format!("Started {}", daemon_id));
1434 Ok(())
1435 }
1436
1437 pub fn get_config_files(&self) -> Vec<PathBuf> {
1441 let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1442 .into_iter()
1443 .filter(|p| p.exists())
1444 .collect();
1445
1446 let cwd_config = crate::env::CWD.join("pitchfork.toml");
1448 if !files.contains(&cwd_config) {
1449 files.push(cwd_config);
1450 }
1451
1452 files
1453 }
1454
1455 pub fn open_file_selector(&mut self) {
1457 let files = self.get_config_files();
1458 self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1459 self.view = View::ConfigFileSelect;
1460 }
1461
1462 pub fn open_editor_create(&mut self, config_path: PathBuf) {
1464 self.editor_state = Some(EditorState::new_create(config_path));
1465 self.file_selector = None;
1466 self.view = View::ConfigEditor;
1467 }
1468
1469 pub fn open_editor_edit(&mut self, daemon_id: &str) {
1471 let config = PitchforkToml::all_merged();
1472 if let Some(daemon_config) = config.daemons.get(daemon_id) {
1473 let config_path = daemon_config
1474 .path
1475 .clone()
1476 .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1477 self.editor_state = Some(EditorState::new_edit(
1478 daemon_id.to_string(),
1479 daemon_config,
1480 config_path,
1481 ));
1482 self.view = View::ConfigEditor;
1483 } else {
1484 self.set_message(format!("Daemon '{}' not found in config", daemon_id));
1485 }
1486 }
1487
1488 pub fn close_editor(&mut self) {
1490 self.editor_state = None;
1491 self.file_selector = None;
1492 self.view = View::Dashboard;
1493 }
1494
1495 pub fn save_editor_config(&mut self) -> Result<bool> {
1498 let editor = self
1499 .editor_state
1500 .as_mut()
1501 .ok_or_else(|| miette::miette!("No editor state"))?;
1502
1503 if !editor.validate() {
1505 self.set_message("Please fix validation errors before saving");
1506 return Ok(false);
1507 }
1508
1509 let daemon_config = editor.to_daemon_config();
1511
1512 let mut config = PitchforkToml::read(&editor.config_path)?;
1514
1515 let is_duplicate = match &editor.mode {
1517 EditMode::Create => config.daemons.contains_key(&editor.daemon_id),
1518 EditMode::Edit { original_id } => {
1519 original_id != &editor.daemon_id && config.daemons.contains_key(&editor.daemon_id)
1521 }
1522 };
1523
1524 if is_duplicate {
1525 let daemon_id = editor.daemon_id.clone();
1526 self.set_message(format!("A daemon named '{}' already exists", daemon_id));
1527 return Ok(false);
1528 }
1529
1530 if let EditMode::Edit { original_id } = &editor.mode
1532 && original_id != &editor.daemon_id
1533 {
1534 config.daemons.shift_remove(original_id);
1535 }
1536
1537 config
1539 .daemons
1540 .insert(editor.daemon_id.clone(), daemon_config);
1541
1542 config.write()?;
1544
1545 editor.unsaved_changes = false;
1546 let daemon_id = editor.daemon_id.clone();
1547 self.set_message(format!("Saved daemon '{}'", daemon_id));
1548
1549 Ok(true)
1550 }
1551
1552 pub fn delete_daemon_from_config(
1554 &mut self,
1555 id: &str,
1556 config_path: &std::path::Path,
1557 ) -> Result<bool> {
1558 let mut config = PitchforkToml::read(config_path)?;
1559
1560 if config.daemons.shift_remove(id).is_some() {
1561 config.write()?;
1562 Ok(true)
1563 } else {
1564 Ok(false)
1565 }
1566 }
1567}
1568
1569impl Default for App {
1570 fn default() -> Self {
1571 Self::new()
1572 }
1573}