1use crate::Result;
2use crate::daemon::Daemon;
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 std::collections::{HashMap, HashSet, VecDeque};
12use std::fs;
13use std::path::PathBuf;
14use std::sync::Arc;
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 preserved_ready_cmd: Option<String>,
331}
332
333impl EditorState {
334 pub fn new_create(config_path: PathBuf) -> Self {
335 Self {
336 mode: EditMode::Create,
337 daemon_id: String::new(),
338 daemon_id_editing: true,
339 daemon_id_cursor: 0,
340 daemon_id_error: None,
341 fields: Self::default_fields(),
342 focused_field: 0,
343 config_path,
344 unsaved_changes: false,
345 scroll_offset: 0,
346 preserved_ready_cmd: None,
347 }
348 }
349
350 pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
351 Self {
352 mode: EditMode::Edit {
353 original_id: daemon_id.clone(),
354 },
355 daemon_id,
356 daemon_id_editing: false,
357 daemon_id_cursor: 0,
358 daemon_id_error: None,
359 fields: Self::fields_from_config(config),
360 focused_field: 0,
361 config_path,
362 unsaved_changes: false,
363 scroll_offset: 0,
364 preserved_ready_cmd: config.ready_cmd.clone(),
365 }
366 }
367
368 fn default_fields() -> Vec<FormField> {
369 vec![
370 FormField::text(
371 "run",
372 "Run Command",
373 "Command to execute. Prepend 'exec' to avoid shell overhead.",
374 true,
375 ),
376 FormField::optional_text(
377 "dir",
378 "Working Directory",
379 "Working directory for the daemon. Relative to pitchfork.toml location.",
380 ),
381 FormField::string_list(
382 "env",
383 "Environment Variables",
384 "Comma-separated KEY=VALUE pairs (e.g., NODE_ENV=dev, PORT=3000).",
385 ),
386 FormField::auto_behavior(
387 "auto",
388 "Auto Behavior",
389 "Auto start/stop based on directory hooks.",
390 ),
391 FormField::number(
392 "retry",
393 "Retry Count",
394 "Number of retry attempts on failure (0 = no retries).",
395 0,
396 ),
397 FormField::optional_number(
398 "ready_delay",
399 "Ready Delay (ms)",
400 "Milliseconds to wait before considering daemon ready.",
401 ),
402 FormField::optional_text(
403 "ready_output",
404 "Ready Output Pattern",
405 "Regex pattern in stdout/stderr indicating readiness.",
406 ),
407 FormField::optional_text(
408 "ready_http",
409 "Ready HTTP URL",
410 "HTTP URL to poll for readiness (expects 2xx).",
411 ),
412 FormField::optional_port(
413 "ready_port",
414 "Ready Port",
415 "TCP port to check for readiness (1-65535).",
416 ),
417 FormField::optional_bool(
418 "boot_start",
419 "Start on Boot",
420 "Automatically start this daemon on system boot.",
421 ),
422 FormField::string_list(
423 "depends",
424 "Dependencies",
425 "Comma-separated daemon names that must start first.",
426 ),
427 FormField::string_list(
428 "watch",
429 "Watch Files",
430 "Comma-separated glob patterns to watch for auto-restart.",
431 ),
432 FormField::optional_text(
433 "cron_schedule",
434 "Cron Schedule",
435 "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
436 ),
437 FormField::retrigger(
438 "cron_retrigger",
439 "Cron Retrigger",
440 "Behavior when cron triggers while previous run is active.",
441 ),
442 ]
443 }
444
445 fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
446 let mut fields = Self::default_fields();
447
448 for field in &mut fields {
449 match field.name {
450 "run" => field.value = FormFieldValue::Text(config.run.clone()),
451 "dir" => field.value = FormFieldValue::OptionalText(config.dir.clone()),
452 "env" => {
453 field.value = FormFieldValue::StringList(
454 config
455 .env
456 .as_ref()
457 .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
458 .unwrap_or_default(),
459 );
460 }
461 "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
462 "retry" => field.value = FormFieldValue::Number(config.retry.count()),
463 "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
464 "ready_output" => {
465 field.value = FormFieldValue::OptionalText(config.ready_output.clone())
466 }
467 "ready_http" => {
468 field.value = FormFieldValue::OptionalText(config.ready_http.clone())
469 }
470 "ready_port" => field.value = FormFieldValue::OptionalPort(config.ready_port),
471 "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
472 "depends" => field.value = FormFieldValue::StringList(config.depends.clone()),
473 "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
474 "cron_schedule" => {
475 field.value = FormFieldValue::OptionalText(
476 config.cron.as_ref().map(|c| c.schedule.clone()),
477 );
478 }
479 "cron_retrigger" => {
480 field.value = FormFieldValue::Retrigger(
481 config
482 .cron
483 .as_ref()
484 .map(|c| c.retrigger)
485 .unwrap_or(CronRetrigger::Finish),
486 );
487 }
488 _ => {}
489 }
490 }
491
492 fields
493 }
494
495 pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
496 let mut config = PitchforkTomlDaemon {
497 run: String::new(),
498 auto: vec![],
499 cron: None,
500 retry: Retry(0),
501 ready_delay: None,
502 ready_output: None,
503 ready_http: None,
504 ready_port: None,
505 ready_cmd: self.preserved_ready_cmd.clone(),
506 boot_start: None,
507 depends: vec![],
508 watch: vec![],
509 dir: None,
510 env: None,
511 path: Some(self.config_path.clone()),
512 };
513
514 let mut cron_schedule: Option<String> = None;
515 let mut cron_retrigger = CronRetrigger::Finish;
516
517 for field in &self.fields {
518 match (field.name, &field.value) {
519 ("run", FormFieldValue::Text(s)) => config.run = s.clone(),
520 ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
521 ("env", FormFieldValue::StringList(v)) => {
522 if v.is_empty() {
523 config.env = None;
524 } else {
525 let mut map = indexmap::IndexMap::new();
526 for entry in v {
527 if let Some((k, val)) = entry.split_once('=') {
528 map.insert(k.trim().to_string(), val.trim().to_string());
529 }
530 }
531 config.env = if map.is_empty() { None } else { Some(map) };
532 }
533 }
534 ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
535 ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
536 ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
537 ("ready_output", FormFieldValue::OptionalText(s)) => {
538 config.ready_output = s.clone()
539 }
540 ("ready_http", FormFieldValue::OptionalText(s)) => config.ready_http = s.clone(),
541 ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = *p,
542 ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
543 ("depends", FormFieldValue::StringList(v)) => config.depends = v.clone(),
544 ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
545 ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
546 ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
547 _ => {}
548 }
549 }
550
551 if let Some(schedule) = cron_schedule {
552 config.cron = Some(PitchforkTomlCron {
553 schedule,
554 retrigger: cron_retrigger,
555 });
556 }
557
558 config
559 }
560
561 pub fn next_field(&mut self) {
562 if let Some(field) = self.fields.get_mut(self.focused_field) {
564 field.editing = false;
565 }
566
567 if self.daemon_id_editing {
569 self.daemon_id_editing = false;
570 return;
571 }
572
573 if self.focused_field < self.fields.len() - 1 {
574 self.focused_field += 1;
575 }
576 }
577
578 pub fn prev_field(&mut self) {
579 if let Some(field) = self.fields.get_mut(self.focused_field) {
581 field.editing = false;
582 }
583 self.daemon_id_editing = false;
584
585 if self.focused_field > 0 {
586 self.focused_field -= 1;
587 }
588 }
589
590 pub fn toggle_current_field(&mut self) {
591 if let Some(field) = self.fields.get_mut(self.focused_field) {
592 let toggled = match &mut field.value {
593 FormFieldValue::Boolean(b) => {
594 *b = !*b;
595 true
596 }
597 FormFieldValue::OptionalBoolean(opt) => {
598 *opt = match opt {
599 None => Some(true),
600 Some(true) => Some(false),
601 Some(false) => None,
602 };
603 true
604 }
605 FormFieldValue::AutoBehavior(v) => {
606 let has_start = v.contains(&PitchforkTomlAuto::Start);
608 let has_stop = v.contains(&PitchforkTomlAuto::Stop);
609 *v = match (has_start, has_stop) {
610 (false, false) => vec![PitchforkTomlAuto::Start],
611 (true, false) => vec![PitchforkTomlAuto::Stop],
612 (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
613 (true, true) => vec![],
614 };
615 true
616 }
617 FormFieldValue::Retrigger(r) => {
618 *r = match r {
619 CronRetrigger::Finish => CronRetrigger::Always,
620 CronRetrigger::Always => CronRetrigger::Success,
621 CronRetrigger::Success => CronRetrigger::Fail,
622 CronRetrigger::Fail => CronRetrigger::Finish,
623 };
624 true
625 }
626 _ => false,
627 };
628 if toggled {
629 self.unsaved_changes = true;
630 }
631 }
632 }
633
634 pub fn start_editing(&mut self) {
635 if let Some(field) = self.fields.get_mut(self.focused_field) {
636 if field.is_text_editable() {
637 field.editing = true;
638 field.cursor = field.get_text().chars().count();
639 } else {
640 self.toggle_current_field();
642 }
643 }
644 }
645
646 pub fn stop_editing(&mut self) {
647 if let Some(field) = self.fields.get_mut(self.focused_field) {
648 field.editing = false;
649 }
650 self.daemon_id_editing = false;
651 }
652
653 pub fn is_editing(&self) -> bool {
654 self.daemon_id_editing
655 || self
656 .fields
657 .get(self.focused_field)
658 .map(|f| f.editing)
659 .unwrap_or(false)
660 }
661
662 pub fn text_push(&mut self, c: char) {
663 if self.daemon_id_editing {
664 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
665 self.daemon_id.insert(byte_idx, c);
666 self.daemon_id_cursor += 1;
667 self.unsaved_changes = true;
668 } else if let Some(field) = self.fields.get_mut(self.focused_field)
669 && field.editing
670 {
671 let mut text = field.get_text();
672 let byte_idx = char_to_byte_index(&text, field.cursor);
673 text.insert(byte_idx, c);
674 field.cursor += 1;
675 field.set_text(text);
676 self.unsaved_changes = true;
677 }
678 }
679
680 pub fn text_pop(&mut self) {
681 if self.daemon_id_editing && self.daemon_id_cursor > 0 {
682 self.daemon_id_cursor -= 1;
683 let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
684 self.daemon_id.remove(byte_idx);
685 self.unsaved_changes = true;
686 } else if let Some(field) = self.fields.get_mut(self.focused_field)
687 && field.editing
688 && field.cursor > 0
689 {
690 let mut text = field.get_text();
691 field.cursor -= 1;
692 let byte_idx = char_to_byte_index(&text, field.cursor);
693 text.remove(byte_idx);
694 field.set_text(text);
695 if matches!(field.value, FormFieldValue::Number(_)) {
698 field.cursor = field.get_text().chars().count();
699 }
700 self.unsaved_changes = true;
701 }
702 }
703
704 pub fn validate(&mut self) -> bool {
705 let mut valid = true;
706
707 self.daemon_id_error = None;
709 if self.daemon_id.is_empty() {
710 self.daemon_id_error = Some("Name is required".to_string());
711 valid = false;
712 } else if !self
713 .daemon_id
714 .chars()
715 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
716 {
717 self.daemon_id_error =
718 Some("Only letters, digits, hyphens, and underscores allowed".to_string());
719 valid = false;
720 }
721
722 for field in &mut self.fields {
724 field.error = None;
725
726 match (field.name, &field.value) {
727 ("run", FormFieldValue::Text(s)) if s.is_empty() => {
728 field.error = Some("Required".to_string());
729 valid = false;
730 }
731 ("ready_port", FormFieldValue::OptionalPort(Some(p))) if *p == 0 => {
732 field.error = Some("Port must be 1-65535".to_string());
733 valid = false;
734 }
735 ("ready_http", FormFieldValue::OptionalText(Some(url)))
736 if !(url.starts_with("http://") || url.starts_with("https://")) =>
737 {
738 field.error = Some("Must start with http:// or https://".to_string());
739 valid = false;
740 }
741 _ => {}
742 }
743 }
744
745 valid
746 }
747}
748
749#[derive(Debug, Clone)]
751pub struct ConfigFileSelector {
752 pub files: Vec<PathBuf>,
753 pub selected: usize,
754}
755
756#[derive(Debug, Clone)]
757pub enum PendingAction {
758 Stop(String),
759 Restart(String),
760 Disable(String),
761 BatchStop(Vec<String>),
763 BatchRestart(Vec<String>),
764 BatchDisable(Vec<String>),
765 DeleteDaemon { id: String, config_path: PathBuf },
767 DiscardEditorChanges,
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
771pub enum SortColumn {
772 #[default]
773 Name,
774 Status,
775 Cpu,
776 Memory,
777 Uptime,
778}
779
780impl SortColumn {
781 pub fn next(self) -> Self {
782 match self {
783 Self::Name => Self::Status,
784 Self::Status => Self::Cpu,
785 Self::Cpu => Self::Memory,
786 Self::Memory => Self::Uptime,
787 Self::Uptime => Self::Name,
788 }
789 }
790}
791
792#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
793pub enum SortOrder {
794 #[default]
795 Ascending,
796 Descending,
797}
798
799impl SortOrder {
800 pub fn toggle(self) -> Self {
801 match self {
802 Self::Ascending => Self::Descending,
803 Self::Descending => Self::Ascending,
804 }
805 }
806
807 pub fn indicator(self) -> &'static str {
808 match self {
809 Self::Ascending => "↑",
810 Self::Descending => "↓",
811 }
812 }
813}
814
815pub struct App {
816 pub daemons: Vec<Daemon>,
817 pub disabled: Vec<String>,
818 pub selected: usize,
819 pub view: View,
820 pub prev_view: View,
821 pub log_content: Vec<String>,
822 pub log_daemon_id: Option<String>,
823 pub log_scroll: usize,
824 pub log_follow: bool, pub message: Option<String>,
826 pub message_time: Option<Instant>,
827 pub process_stats: HashMap<u32, ProcessStats>, pub stats_history: HashMap<String, StatsHistory>, pub pending_action: Option<PendingAction>,
830 pub loading_text: Option<String>,
831 pub search_query: String,
832 pub search_active: bool,
833 pub sort_column: SortColumn,
835 pub sort_order: SortOrder,
836 pub log_search_query: String,
838 pub log_search_active: bool,
839 pub log_search_matches: Vec<usize>, pub log_search_current: usize, pub details_daemon_id: Option<String>,
843 pub logs_expanded: bool,
845 pub multi_select: HashSet<String>,
847 pub config_daemon_ids: HashSet<String>,
849 pub show_available: bool,
851 pub editor_state: Option<EditorState>,
853 pub file_selector: Option<ConfigFileSelector>,
855}
856
857impl App {
858 pub fn new() -> Self {
859 Self {
860 daemons: Vec::new(),
861 disabled: Vec::new(),
862 selected: 0,
863 view: View::Dashboard,
864 prev_view: View::Dashboard,
865 log_content: Vec::new(),
866 log_daemon_id: None,
867 log_scroll: 0,
868 log_follow: true,
869 message: None,
870 message_time: None,
871 process_stats: HashMap::new(),
872 stats_history: HashMap::new(),
873 pending_action: None,
874 loading_text: None,
875 search_query: String::new(),
876 search_active: false,
877 sort_column: SortColumn::default(),
878 sort_order: SortOrder::default(),
879 log_search_query: String::new(),
880 log_search_active: false,
881 log_search_matches: Vec::new(),
882 log_search_current: 0,
883 details_daemon_id: None,
884 logs_expanded: false,
885 multi_select: HashSet::new(),
886 config_daemon_ids: HashSet::new(),
887 show_available: true, editor_state: None,
889 file_selector: None,
890 }
891 }
892
893 pub fn confirm_action(&mut self, action: PendingAction) {
894 self.pending_action = Some(action);
895 self.prev_view = self.view;
896 self.view = View::Confirm;
897 }
898
899 pub fn cancel_confirm(&mut self) {
900 self.pending_action = None;
901 self.view = self.prev_view;
902 }
903
904 pub fn take_pending_action(&mut self) -> Option<PendingAction> {
905 self.view = self.prev_view;
906 self.pending_action.take()
907 }
908
909 pub fn start_loading(&mut self, text: impl Into<String>) {
910 self.prev_view = self.view;
911 self.loading_text = Some(text.into());
912 self.view = View::Loading;
913 }
914
915 pub fn stop_loading(&mut self) {
916 self.loading_text = None;
917 self.view = self.prev_view;
918 }
919
920 pub fn start_search(&mut self) {
922 self.search_active = true;
923 }
924
925 pub fn end_search(&mut self) {
926 self.search_active = false;
927 }
928
929 pub fn clear_search(&mut self) {
930 self.search_query.clear();
931 self.search_active = false;
932 self.selected = 0;
933 }
934
935 pub fn search_push(&mut self, c: char) {
936 self.search_query.push(c);
937 self.selected = 0;
939 }
940
941 pub fn search_pop(&mut self) {
942 self.search_query.pop();
943 self.selected = 0;
944 }
945
946 pub fn filtered_daemons(&self) -> Vec<&Daemon> {
947 let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
948 self.daemons.iter().collect()
949 } else {
950 let matcher = SkimMatcherV2::default();
952 let mut scored: Vec<_> = self
953 .daemons
954 .iter()
955 .filter_map(|d| {
956 matcher
957 .fuzzy_match(&d.id, &self.search_query)
958 .map(|score| (d, score))
959 })
960 .collect();
961 scored.sort_by(|a, b| b.1.cmp(&a.1));
963 scored.into_iter().map(|(d, _)| d).collect()
964 };
965
966 filtered.sort_by(|a, b| {
968 let cmp = match self.sort_column {
969 SortColumn::Name => a.id.to_lowercase().cmp(&b.id.to_lowercase()),
970 SortColumn::Status => {
971 let status_order = |d: &Daemon| match &d.status {
972 crate::daemon_status::DaemonStatus::Running => 0,
973 crate::daemon_status::DaemonStatus::Waiting => 1,
974 crate::daemon_status::DaemonStatus::Stopping => 2,
975 crate::daemon_status::DaemonStatus::Stopped => 3,
976 crate::daemon_status::DaemonStatus::Errored(_) => 4,
977 crate::daemon_status::DaemonStatus::Failed(_) => 5,
978 };
979 status_order(a).cmp(&status_order(b))
980 }
981 SortColumn::Cpu => {
982 let cpu_a = a
983 .pid
984 .and_then(|p| self.get_stats(p))
985 .map(|s| s.cpu_percent)
986 .unwrap_or(0.0);
987 let cpu_b = b
988 .pid
989 .and_then(|p| self.get_stats(p))
990 .map(|s| s.cpu_percent)
991 .unwrap_or(0.0);
992 cpu_a
993 .partial_cmp(&cpu_b)
994 .unwrap_or(std::cmp::Ordering::Equal)
995 }
996 SortColumn::Memory => {
997 let mem_a = a
998 .pid
999 .and_then(|p| self.get_stats(p))
1000 .map(|s| s.memory_bytes)
1001 .unwrap_or(0);
1002 let mem_b = b
1003 .pid
1004 .and_then(|p| self.get_stats(p))
1005 .map(|s| s.memory_bytes)
1006 .unwrap_or(0);
1007 mem_a.cmp(&mem_b)
1008 }
1009 SortColumn::Uptime => {
1010 let up_a = a
1011 .pid
1012 .and_then(|p| self.get_stats(p))
1013 .map(|s| s.uptime_secs)
1014 .unwrap_or(0);
1015 let up_b = b
1016 .pid
1017 .and_then(|p| self.get_stats(p))
1018 .map(|s| s.uptime_secs)
1019 .unwrap_or(0);
1020 up_a.cmp(&up_b)
1021 }
1022 };
1023 match self.sort_order {
1024 SortOrder::Ascending => cmp,
1025 SortOrder::Descending => cmp.reverse(),
1026 }
1027 });
1028
1029 filtered
1030 }
1031
1032 pub fn cycle_sort(&mut self) {
1034 self.sort_column = self.sort_column.next();
1036 self.selected = 0;
1037 }
1038
1039 pub fn toggle_sort_order(&mut self) {
1040 self.sort_order = self.sort_order.toggle();
1041 self.selected = 0;
1042 }
1043
1044 pub fn selected_daemon(&self) -> Option<&Daemon> {
1045 let filtered = self.filtered_daemons();
1046 filtered.get(self.selected).copied()
1047 }
1048
1049 pub fn select_next(&mut self) {
1050 let count = self.filtered_daemons().len();
1051 if count > 0 {
1052 self.selected = (self.selected + 1) % count;
1053 }
1054 }
1055
1056 pub fn select_prev(&mut self) {
1057 let count = self.filtered_daemons().len();
1058 if count > 0 {
1059 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1060 }
1061 }
1062
1063 pub fn toggle_log_follow(&mut self) {
1065 self.log_follow = !self.log_follow;
1066 if self.log_follow && !self.log_content.is_empty() {
1067 self.log_scroll = self.log_content.len().saturating_sub(20);
1069 }
1070 }
1071
1072 pub fn toggle_logs_expanded(&mut self) {
1074 self.logs_expanded = !self.logs_expanded;
1075 }
1076
1077 pub fn toggle_select(&mut self) {
1079 if let Some(daemon) = self.selected_daemon() {
1080 let id = daemon.id.clone();
1081 if self.multi_select.contains(&id) {
1082 self.multi_select.remove(&id);
1083 } else {
1084 self.multi_select.insert(id);
1085 }
1086 }
1087 }
1088
1089 pub fn select_all_visible(&mut self) {
1090 let ids: Vec<String> = self
1092 .filtered_daemons()
1093 .iter()
1094 .map(|d| d.id.clone())
1095 .collect();
1096 for id in ids {
1097 self.multi_select.insert(id);
1098 }
1099 }
1100
1101 pub fn clear_selection(&mut self) {
1102 self.multi_select.clear();
1103 }
1104
1105 pub fn is_selected(&self, daemon_id: &str) -> bool {
1106 self.multi_select.contains(daemon_id)
1107 }
1108
1109 pub fn has_selection(&self) -> bool {
1110 !self.multi_select.is_empty()
1111 }
1112
1113 pub fn selected_daemon_ids(&self) -> Vec<String> {
1114 self.multi_select.iter().cloned().collect()
1115 }
1116
1117 pub fn set_message(&mut self, msg: impl Into<String>) {
1118 self.message = Some(msg.into());
1119 self.message_time = Some(Instant::now());
1120 }
1121
1122 pub fn clear_stale_message(&mut self) {
1123 if let Some(time) = self.message_time
1124 && time.elapsed().as_secs() >= 3
1125 {
1126 self.message = None;
1127 self.message_time = None;
1128 }
1129 }
1130
1131 pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1132 self.process_stats.get(&pid)
1133 }
1134
1135 fn refresh_process_stats(&mut self) {
1136 PROCS.refresh_processes();
1137 self.process_stats.clear();
1138 for daemon in &self.daemons {
1139 if let Some(pid) = daemon.pid
1140 && let Some(stats) = PROCS.get_stats(pid)
1141 {
1142 self.process_stats.insert(pid, stats);
1143 let history = self.stats_history.entry(daemon.id.clone()).or_default();
1145 history.push(StatsSnapshot::from(&stats));
1146 }
1147 }
1148 }
1149
1150 pub fn get_stats_history(&self, daemon_id: &str) -> Option<&StatsHistory> {
1152 self.stats_history.get(daemon_id)
1153 }
1154
1155 pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1156 use crate::daemon_list::get_all_daemons;
1157
1158 let all_entries = get_all_daemons(client).await?;
1160
1161 self.daemons.clear();
1163 self.disabled.clear();
1164 self.config_daemon_ids.clear();
1165
1166 for entry in all_entries {
1168 if entry.is_disabled {
1170 self.disabled.push(entry.id.clone());
1171 }
1172
1173 if entry.is_available {
1175 self.config_daemon_ids.insert(entry.id.clone());
1176 }
1177
1178 if !entry.is_available || self.show_available {
1182 self.daemons.push(entry.daemon);
1183 }
1184 }
1185
1186 self.refresh_process_stats();
1188
1189 self.clear_stale_message();
1191
1192 let total_count = self.total_daemon_count();
1194 if total_count > 0 && self.selected >= total_count {
1195 self.selected = total_count - 1;
1196 }
1197
1198 if self.view == View::Logs
1200 && let Some(id) = self.log_daemon_id.clone()
1201 {
1202 self.load_logs(&id);
1203 }
1204
1205 Ok(())
1206 }
1207
1208 pub fn is_config_only(&self, daemon_id: &str) -> bool {
1210 self.config_daemon_ids.contains(daemon_id)
1211 }
1212
1213 pub fn toggle_show_available(&mut self) {
1215 self.show_available = !self.show_available;
1216 }
1217
1218 fn total_daemon_count(&self) -> usize {
1220 self.filtered_daemons().len()
1221 }
1222
1223 pub fn scroll_logs_down(&mut self) {
1224 if self.log_content.len() > 20 {
1225 let max_scroll = self.log_content.len().saturating_sub(20);
1226 self.log_scroll = (self.log_scroll + 1).min(max_scroll);
1227 }
1228 }
1229
1230 pub fn scroll_logs_up(&mut self) {
1231 self.log_scroll = self.log_scroll.saturating_sub(1);
1232 }
1233
1234 pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1236 let half_page = visible_lines / 2;
1237 if self.log_content.len() > visible_lines {
1238 let max_scroll = self.log_content.len().saturating_sub(visible_lines);
1239 self.log_scroll = (self.log_scroll + half_page).min(max_scroll);
1240 }
1241 }
1242
1243 pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1245 let half_page = visible_lines / 2;
1246 self.log_scroll = self.log_scroll.saturating_sub(half_page);
1247 }
1248
1249 pub fn start_log_search(&mut self) {
1251 self.log_search_active = true;
1252 self.log_search_query.clear();
1253 self.log_search_matches.clear();
1254 self.log_search_current = 0;
1255 }
1256
1257 pub fn end_log_search(&mut self) {
1258 self.log_search_active = false;
1259 }
1260
1261 pub fn clear_log_search(&mut self) {
1262 self.log_search_query.clear();
1263 self.log_search_active = false;
1264 self.log_search_matches.clear();
1265 self.log_search_current = 0;
1266 }
1267
1268 pub fn log_search_push(&mut self, c: char) {
1269 self.log_search_query.push(c);
1270 self.update_log_search_matches();
1271 }
1272
1273 pub fn log_search_pop(&mut self) {
1274 self.log_search_query.pop();
1275 self.update_log_search_matches();
1276 }
1277
1278 fn update_log_search_matches(&mut self) {
1279 self.log_search_matches.clear();
1280 if !self.log_search_query.is_empty() {
1281 let query = self.log_search_query.to_lowercase();
1282 for (i, line) in self.log_content.iter().enumerate() {
1283 if line.to_lowercase().contains(&query) {
1284 self.log_search_matches.push(i);
1285 }
1286 }
1287 if !self.log_search_matches.is_empty() {
1289 self.log_search_current = 0;
1290 self.jump_to_log_match();
1291 }
1292 }
1293 }
1294
1295 pub fn log_search_next(&mut self) {
1296 if !self.log_search_matches.is_empty() {
1297 self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1298 self.jump_to_log_match();
1299 }
1300 }
1301
1302 pub fn log_search_prev(&mut self) {
1303 if !self.log_search_matches.is_empty() {
1304 self.log_search_current = self
1305 .log_search_current
1306 .checked_sub(1)
1307 .unwrap_or(self.log_search_matches.len() - 1);
1308 self.jump_to_log_match();
1309 }
1310 }
1311
1312 fn jump_to_log_match(&mut self) {
1313 if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1314 let half_page = 10; self.log_scroll = line_idx.saturating_sub(half_page);
1317 self.log_follow = false;
1318 }
1319 }
1320
1321 pub fn show_details(&mut self, daemon_id: &str) {
1323 self.details_daemon_id = Some(daemon_id.to_string());
1324 self.prev_view = self.view;
1325 self.view = View::Details;
1326 }
1327
1328 pub fn hide_details(&mut self) {
1329 self.details_daemon_id = None;
1330 self.view = View::Dashboard;
1331 }
1332
1333 pub fn view_daemon_details(&mut self, daemon_id: &str) {
1335 self.log_daemon_id = Some(daemon_id.to_string());
1336 self.logs_expanded = false; self.load_logs(daemon_id);
1338 self.view = View::Logs; }
1340
1341 fn load_logs(&mut self, daemon_id: &str) {
1342 let log_path = Self::log_path(daemon_id);
1343 let prev_len = self.log_content.len();
1344
1345 self.log_content = if log_path.exists() {
1346 fs::read_to_string(&log_path)
1347 .unwrap_or_default()
1348 .lines()
1349 .map(String::from)
1350 .collect()
1351 } else {
1352 vec!["No logs available".to_string()]
1353 };
1354
1355 if self.log_follow {
1357 if self.log_content.len() > 20 {
1358 self.log_scroll = self.log_content.len().saturating_sub(20);
1359 } else {
1360 self.log_scroll = 0;
1361 }
1362 } else if prev_len == 0 {
1363 if self.log_content.len() > 20 {
1365 self.log_scroll = self.log_content.len().saturating_sub(20);
1366 }
1367 }
1368 }
1370
1371 fn log_path(daemon_id: &str) -> PathBuf {
1372 PITCHFORK_LOGS_DIR
1373 .join(daemon_id)
1374 .join(format!("{daemon_id}.log"))
1375 }
1376
1377 pub fn show_help(&mut self) {
1378 self.view = View::Help;
1379 }
1380
1381 pub fn back_to_dashboard(&mut self) {
1382 self.view = View::Dashboard;
1383 self.log_daemon_id = None;
1384 self.log_content.clear();
1385 self.log_scroll = 0;
1386 }
1387
1388 pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1390 let available = self.config_daemon_ids.len();
1391 let total = self.daemons.len();
1392 let running = self
1393 .daemons
1394 .iter()
1395 .filter(|d| d.status.is_running())
1396 .count();
1397 let stopped = self
1399 .daemons
1400 .iter()
1401 .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1402 .count();
1403 let errored = self
1404 .daemons
1405 .iter()
1406 .filter(|d| d.status.is_errored() || d.status.is_failed())
1407 .count();
1408 (total, running, stopped, errored, available)
1409 }
1410
1411 pub fn is_disabled(&self, daemon_id: &str) -> bool {
1412 self.disabled.contains(&daemon_id.to_string())
1413 }
1414
1415 pub fn get_config_files(&self) -> Vec<PathBuf> {
1419 let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1420 .into_iter()
1421 .filter(|p| p.exists())
1422 .collect();
1423
1424 let cwd_config = crate::env::CWD.join("pitchfork.toml");
1426 if !files.contains(&cwd_config) {
1427 files.push(cwd_config);
1428 }
1429
1430 files
1431 }
1432
1433 pub fn open_file_selector(&mut self) {
1435 let files = self.get_config_files();
1436 self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1437 self.view = View::ConfigFileSelect;
1438 }
1439
1440 pub fn open_editor_create(&mut self, config_path: PathBuf) {
1442 self.editor_state = Some(EditorState::new_create(config_path));
1443 self.file_selector = None;
1444 self.view = View::ConfigEditor;
1445 }
1446
1447 pub fn open_editor_edit(&mut self, daemon_id: &str) {
1449 let config = PitchforkToml::all_merged();
1450 if let Some(daemon_config) = config.daemons.get(daemon_id) {
1451 let config_path = daemon_config
1452 .path
1453 .clone()
1454 .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1455 self.editor_state = Some(EditorState::new_edit(
1456 daemon_id.to_string(),
1457 daemon_config,
1458 config_path,
1459 ));
1460 self.view = View::ConfigEditor;
1461 } else {
1462 self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1463 }
1464 }
1465
1466 pub fn close_editor(&mut self) {
1468 self.editor_state = None;
1469 self.file_selector = None;
1470 self.view = View::Dashboard;
1471 }
1472
1473 pub fn save_editor_config(&mut self) -> Result<bool> {
1476 let editor = self
1477 .editor_state
1478 .as_mut()
1479 .ok_or_else(|| miette::miette!("No editor state"))?;
1480
1481 if !editor.validate() {
1483 self.set_message("Please fix validation errors before saving");
1484 return Ok(false);
1485 }
1486
1487 let daemon_config = editor.to_daemon_config();
1489
1490 let mut config = PitchforkToml::read(&editor.config_path)?;
1492
1493 let is_duplicate = match &editor.mode {
1495 EditMode::Create => config.daemons.contains_key(&editor.daemon_id),
1496 EditMode::Edit { original_id } => {
1497 original_id != &editor.daemon_id && config.daemons.contains_key(&editor.daemon_id)
1499 }
1500 };
1501
1502 if is_duplicate {
1503 let daemon_id = editor.daemon_id.clone();
1504 self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1505 return Ok(false);
1506 }
1507
1508 if let EditMode::Edit { original_id } = &editor.mode
1510 && original_id != &editor.daemon_id
1511 {
1512 config.daemons.shift_remove(original_id);
1513 }
1514
1515 config
1517 .daemons
1518 .insert(editor.daemon_id.clone(), daemon_config);
1519
1520 config.write()?;
1522
1523 editor.unsaved_changes = false;
1524 let daemon_id = editor.daemon_id.clone();
1525 self.set_message(format!("Saved daemon '{daemon_id}'"));
1526
1527 Ok(true)
1528 }
1529
1530 pub fn delete_daemon_from_config(
1532 &mut self,
1533 id: &str,
1534 config_path: &std::path::Path,
1535 ) -> Result<bool> {
1536 let mut config = PitchforkToml::read(config_path)?;
1537
1538 if config.daemons.shift_remove(id).is_some() {
1539 config.write()?;
1540 Ok(true)
1541 } else {
1542 Ok(false)
1543 }
1544 }
1545}
1546
1547impl Default for App {
1548 fn default() -> Self {
1549 Self::new()
1550 }
1551}