1use crate::Result;
2use crate::daemon::Daemon;
3use crate::daemon_id::DaemonId;
4use crate::ipc::client::IpcClient;
5use crate::pitchfork_toml::{
6 CronRetrigger, PitchforkToml, PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlDaemon, Retry,
7 namespace_from_path,
8};
9use crate::procs::{PROCS, ProcessStats};
10use crate::settings::settings;
11use fuzzy_matcher::FuzzyMatcher;
12use fuzzy_matcher::skim::SkimMatcherV2;
13use listeners::Listener;
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::fs;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::time::Instant;
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 let max_history = settings().tui.stat_history.max(1) as usize;
58 while self.samples.len() > max_history {
59 self.samples.pop_front();
60 }
61 }
62
63 pub fn cpu_values(&self) -> Vec<f32> {
64 self.samples.iter().map(|s| s.cpu_percent).collect()
65 }
66
67 pub fn memory_values(&self) -> Vec<u64> {
68 self.samples.iter().map(|s| s.memory_bytes).collect()
69 }
70
71 pub fn disk_read_values(&self) -> Vec<u64> {
72 self.samples.iter().map(|s| s.disk_read_bytes).collect()
73 }
74
75 pub fn disk_write_values(&self) -> Vec<u64> {
76 self.samples.iter().map(|s| s.disk_write_bytes).collect()
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum View {
82 Dashboard,
83 Logs,
84 Network,
85 Help,
86 Confirm,
87 Loading,
88 Details,
89 ConfigEditor,
90 ConfigFileSelect,
91}
92
93#[derive(Debug, Clone, PartialEq)]
95pub enum EditMode {
96 Create,
97 Edit { original_id: String },
98}
99
100#[derive(Debug, Clone)]
102pub enum FormFieldValue {
103 Text(String),
104 OptionalText(Option<String>),
105 Number(u32),
106 OptionalNumber(Option<u64>),
107 OptionalPort(Option<u16>),
108 #[allow(dead_code)]
109 Boolean(bool),
110 OptionalBoolean(Option<bool>),
111 AutoBehavior(Vec<PitchforkTomlAuto>),
112 Retrigger(CronRetrigger),
113 StringList(Vec<String>),
114}
115
116#[derive(Debug, Clone)]
118pub struct FormField {
119 pub name: &'static str,
120 pub label: &'static str,
121 pub value: FormFieldValue,
122 pub required: bool,
123 #[allow(dead_code)]
124 pub help_text: &'static str,
125 pub error: Option<String>,
126 pub editing: bool,
127 pub cursor: usize,
128}
129
130impl FormField {
131 fn text(name: &'static str, label: &'static str, help: &'static str, required: bool) -> Self {
132 Self {
133 name,
134 label,
135 value: FormFieldValue::Text(String::new()),
136 required,
137 help_text: help,
138 error: None,
139 editing: false,
140 cursor: 0,
141 }
142 }
143
144 fn optional_text(name: &'static str, label: &'static str, help: &'static str) -> Self {
145 Self {
146 name,
147 label,
148 value: FormFieldValue::OptionalText(None),
149 required: false,
150 help_text: help,
151 error: None,
152 editing: false,
153 cursor: 0,
154 }
155 }
156
157 fn number(name: &'static str, label: &'static str, help: &'static str, default: u32) -> Self {
158 Self {
159 name,
160 label,
161 value: FormFieldValue::Number(default),
162 required: false,
163 help_text: help,
164 error: None,
165 editing: false,
166 cursor: 0,
167 }
168 }
169
170 fn optional_number(name: &'static str, label: &'static str, help: &'static str) -> Self {
171 Self {
172 name,
173 label,
174 value: FormFieldValue::OptionalNumber(None),
175 required: false,
176 help_text: help,
177 error: None,
178 editing: false,
179 cursor: 0,
180 }
181 }
182
183 fn optional_port(name: &'static str, label: &'static str, help: &'static str) -> Self {
184 Self {
185 name,
186 label,
187 value: FormFieldValue::OptionalPort(None),
188 required: false,
189 help_text: help,
190 error: None,
191 editing: false,
192 cursor: 0,
193 }
194 }
195
196 fn optional_bool(name: &'static str, label: &'static str, help: &'static str) -> Self {
197 Self {
198 name,
199 label,
200 value: FormFieldValue::OptionalBoolean(None),
201 required: false,
202 help_text: help,
203 error: None,
204 editing: false,
205 cursor: 0,
206 }
207 }
208
209 fn auto_behavior(name: &'static str, label: &'static str, help: &'static str) -> Self {
210 Self {
211 name,
212 label,
213 value: FormFieldValue::AutoBehavior(vec![]),
214 required: false,
215 help_text: help,
216 error: None,
217 editing: false,
218 cursor: 0,
219 }
220 }
221
222 fn retrigger(name: &'static str, label: &'static str, help: &'static str) -> Self {
223 Self {
224 name,
225 label,
226 value: FormFieldValue::Retrigger(CronRetrigger::Finish),
227 required: false,
228 help_text: help,
229 error: None,
230 editing: false,
231 cursor: 0,
232 }
233 }
234
235 fn string_list(name: &'static str, label: &'static str, help: &'static str) -> Self {
236 Self {
237 name,
238 label,
239 value: FormFieldValue::StringList(vec![]),
240 required: false,
241 help_text: help,
242 error: None,
243 editing: false,
244 cursor: 0,
245 }
246 }
247
248 pub fn get_text(&self) -> String {
249 match &self.value {
250 FormFieldValue::Text(s) => s.clone(),
251 FormFieldValue::OptionalText(Some(s)) => s.clone(),
252 FormFieldValue::OptionalText(None) => String::new(),
253 FormFieldValue::Number(n) => n.to_string(),
254 FormFieldValue::OptionalNumber(Some(n)) => n.to_string(),
255 FormFieldValue::OptionalNumber(None) => String::new(),
256 FormFieldValue::OptionalPort(Some(p)) => p.to_string(),
257 FormFieldValue::OptionalPort(None) => String::new(),
258 FormFieldValue::StringList(v) => v.join(", "),
259 _ => String::new(),
260 }
261 }
262
263 pub fn set_text(&mut self, text: String) {
264 match &mut self.value {
265 FormFieldValue::Text(s) => *s = text,
266 FormFieldValue::OptionalText(opt) => {
267 *opt = if text.is_empty() { None } else { Some(text) };
268 }
269 FormFieldValue::Number(n) => {
270 let trimmed = text.trim();
271 if trimmed.is_empty() {
272 *n = 0;
273 self.error = None;
274 } else {
275 match trimmed.parse() {
276 Ok(value) => {
277 *n = value;
278 self.error = None;
279 }
280 Err(_) => {
281 *n = 0;
282 self.error = Some("Invalid number".to_string());
283 }
284 }
285 }
286 }
287 FormFieldValue::OptionalNumber(opt) => {
288 *opt = text.parse().ok();
289 }
290 FormFieldValue::OptionalPort(opt) => {
291 *opt = text.parse().ok();
292 }
293 FormFieldValue::StringList(v) => {
294 *v = text
295 .split(',')
296 .map(|s| s.trim().to_string())
297 .filter(|s| !s.is_empty())
298 .collect();
299 }
300 _ => {}
301 }
302 }
303
304 pub fn is_text_editable(&self) -> bool {
305 matches!(
306 self.value,
307 FormFieldValue::Text(_)
308 | FormFieldValue::OptionalText(_)
309 | FormFieldValue::Number(_)
310 | FormFieldValue::OptionalNumber(_)
311 | FormFieldValue::OptionalPort(_)
312 | FormFieldValue::StringList(_)
313 )
314 }
315}
316
317#[derive(Debug, Clone)]
319pub struct EditorState {
320 pub mode: EditMode,
321 pub daemon_id: String,
322 pub daemon_id_editing: bool,
323 pub daemon_id_cursor: usize,
324 pub daemon_id_error: Option<String>,
325 pub fields: Vec<FormField>,
326 pub focused_field: usize,
327 pub config_path: PathBuf,
328 pub unsaved_changes: bool,
329 #[allow(dead_code)]
330 pub scroll_offset: usize,
331 preserved_ready_cmd: Option<String>,
333}
334
335impl EditorState {
336 pub fn new_create(config_path: PathBuf) -> Self {
337 Self {
338 mode: EditMode::Create,
339 daemon_id: String::new(),
340 daemon_id_editing: true,
341 daemon_id_cursor: 0,
342 daemon_id_error: None,
343 fields: Self::default_fields(),
344 focused_field: 0,
345 config_path,
346 unsaved_changes: false,
347 scroll_offset: 0,
348 preserved_ready_cmd: None,
349 }
350 }
351
352 pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
353 Self {
354 mode: EditMode::Edit {
355 original_id: daemon_id.clone(),
356 },
357 daemon_id,
358 daemon_id_editing: false,
359 daemon_id_cursor: 0,
360 daemon_id_error: None,
361 fields: Self::fields_from_config(config),
362 focused_field: 0,
363 config_path,
364 unsaved_changes: false,
365 scroll_offset: 0,
366 preserved_ready_cmd: config.ready_cmd.clone(),
367 }
368 }
369
370 fn default_fields() -> Vec<FormField> {
371 vec![
372 FormField::text(
373 "run",
374 "Run Command",
375 "Command to execute. Prepend 'exec' to avoid shell overhead.",
376 true,
377 ),
378 FormField::optional_text(
379 "dir",
380 "Working Directory",
381 "Working directory for the daemon. Relative to pitchfork.toml location.",
382 ),
383 FormField::string_list(
384 "env",
385 "Environment Variables",
386 "Comma-separated KEY=VALUE pairs (e.g., NODE_ENV=dev, PORT=3000).",
387 ),
388 FormField::auto_behavior(
389 "auto",
390 "Auto Behavior",
391 "Auto start/stop based on directory hooks.",
392 ),
393 FormField::number(
394 "retry",
395 "Retry Count",
396 "Number of retry attempts on failure (0 = no retries).",
397 0,
398 ),
399 FormField::optional_number(
400 "ready_delay",
401 "Ready Delay (ms)",
402 "Milliseconds to wait before considering daemon ready.",
403 ),
404 FormField::optional_text(
405 "ready_output",
406 "Ready Output Pattern",
407 "Regex pattern in stdout/stderr indicating readiness.",
408 ),
409 FormField::optional_text(
410 "ready_http",
411 "Ready HTTP URL",
412 "HTTP URL to poll for readiness (expects 2xx).",
413 ),
414 FormField::optional_port(
415 "ready_port",
416 "Ready Port",
417 "TCP port to check for readiness (1-65535).",
418 ),
419 FormField::optional_bool(
420 "boot_start",
421 "Start on Boot",
422 "Automatically start this daemon on system boot.",
423 ),
424 FormField::string_list(
425 "depends",
426 "Dependencies",
427 "Comma-separated daemon names that must start first.",
428 ),
429 FormField::string_list(
430 "watch",
431 "Watch Files",
432 "Comma-separated glob patterns to watch for auto-restart.",
433 ),
434 FormField::optional_text(
435 "cron_schedule",
436 "Cron Schedule",
437 "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
438 ),
439 FormField::retrigger(
440 "cron_retrigger",
441 "Cron Retrigger",
442 "Behavior when cron triggers while previous run is active.",
443 ),
444 ]
445 }
446
447 fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
448 let mut fields = Self::default_fields();
449
450 for field in &mut fields {
451 match field.name {
452 "run" => field.value = FormFieldValue::Text(config.run.clone()),
453 "dir" => field.value = FormFieldValue::OptionalText(config.dir.clone()),
454 "env" => {
455 field.value = FormFieldValue::StringList(
456 config
457 .env
458 .as_ref()
459 .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
460 .unwrap_or_default(),
461 );
462 }
463 "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
464 "retry" => field.value = FormFieldValue::Number(config.retry.count()),
465 "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
466 "ready_output" => {
467 field.value = FormFieldValue::OptionalText(config.ready_output.clone())
468 }
469 "ready_http" => {
470 field.value = FormFieldValue::OptionalText(config.ready_http.clone())
471 }
472 "ready_port" => field.value = FormFieldValue::OptionalPort(config.ready_port),
473 "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
474 "depends" => {
475 field.value = FormFieldValue::StringList(
476 config
477 .depends
478 .iter()
479 .map(|d: &DaemonId| d.qualified())
480 .collect(),
481 )
482 }
483 "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
484 "cron_schedule" => {
485 field.value = FormFieldValue::OptionalText(
486 config.cron.as_ref().map(|c| c.schedule.clone()),
487 );
488 }
489 "cron_retrigger" => {
490 field.value = FormFieldValue::Retrigger(
491 config
492 .cron
493 .as_ref()
494 .map(|c| c.retrigger)
495 .unwrap_or(CronRetrigger::Finish),
496 );
497 }
498 _ => {}
499 }
500 }
501
502 fields
503 }
504
505 pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
506 let mut config = PitchforkTomlDaemon {
507 ready_cmd: self.preserved_ready_cmd.clone(),
508 path: Some(self.config_path.clone()),
509 ..PitchforkTomlDaemon::default()
510 };
511
512 let mut cron_schedule: Option<String> = None;
513 let mut cron_retrigger = CronRetrigger::Finish;
514
515 for field in &self.fields {
516 match (field.name, &field.value) {
517 ("run", FormFieldValue::Text(s)) => config.run = s.clone(),
518 ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
519 ("env", FormFieldValue::StringList(v)) => {
520 if v.is_empty() {
521 config.env = None;
522 } else {
523 let mut map = indexmap::IndexMap::new();
524 for entry in v {
525 if let Some((k, val)) = entry.split_once('=') {
526 map.insert(k.trim().to_string(), val.trim().to_string());
527 }
528 }
529 config.env = if map.is_empty() { None } else { Some(map) };
530 }
531 }
532 ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
533 ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
534 ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
535 ("ready_output", FormFieldValue::OptionalText(s)) => {
536 config.ready_output = s.clone()
537 }
538 ("ready_http", FormFieldValue::OptionalText(s)) => config.ready_http = s.clone(),
539 ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = *p,
540 ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
541 ("depends", FormFieldValue::StringList(v)) => {
542 config.depends = v.iter().filter_map(|s| DaemonId::parse(s).ok()).collect()
543 }
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(DaemonId),
759 Restart(DaemonId),
760 Disable(DaemonId),
761 BatchStop(Vec<DaemonId>),
763 BatchRestart(Vec<DaemonId>),
764 BatchDisable(Vec<DaemonId>),
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<DaemonId>,
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<DaemonId>,
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<DaemonId, 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<DaemonId>,
843 pub logs_expanded: bool,
845 pub multi_select: HashSet<DaemonId>,
847 pub config_daemon_ids: HashSet<DaemonId>,
849 pub show_available: bool,
851 pub editor_state: Option<EditorState>,
853 pub file_selector: Option<ConfigFileSelector>,
855 pub network_listeners: Vec<Listener>,
857 pub network_search_query: String,
858 pub network_search_active: bool,
859 pub network_selected: usize,
860 pub network_scroll_offset: usize,
861 pub network_selected_pid: Option<u32>, pub network_visible_rows: usize, }
864
865impl App {
866 pub fn new() -> Self {
867 Self {
868 daemons: Vec::new(),
869 disabled: Vec::new(),
870 selected: 0,
871 view: View::Dashboard,
872 prev_view: View::Dashboard,
873 log_content: Vec::new(),
874 log_daemon_id: None,
875 log_scroll: 1,
876 log_follow: true,
877 message: None,
878 message_time: None,
879 process_stats: HashMap::new(),
880 stats_history: HashMap::new(),
881 pending_action: None,
882 loading_text: None,
883 search_query: String::new(),
884 search_active: false,
885 sort_column: SortColumn::default(),
886 sort_order: SortOrder::default(),
887 log_search_query: String::new(),
888 log_search_active: false,
889 log_search_matches: Vec::new(),
890 log_search_current: 0,
891 details_daemon_id: None,
892 logs_expanded: false,
893 multi_select: HashSet::new(),
894 config_daemon_ids: HashSet::new(),
895 show_available: true, editor_state: None,
897 file_selector: None,
898 network_listeners: Vec::new(),
899 network_search_query: String::new(),
900 network_search_active: false,
901 network_selected: 0,
902 network_scroll_offset: 0,
903 network_selected_pid: None,
904 network_visible_rows: 20, }
906 }
907
908 pub fn confirm_action(&mut self, action: PendingAction) {
909 self.pending_action = Some(action);
910 self.prev_view = self.view;
911 self.view = View::Confirm;
912 }
913
914 pub fn cancel_confirm(&mut self) {
915 self.pending_action = None;
916 self.view = self.prev_view;
917 }
918
919 pub fn take_pending_action(&mut self) -> Option<PendingAction> {
920 self.view = self.prev_view;
921 self.pending_action.take()
922 }
923
924 pub fn start_loading(&mut self, text: impl Into<String>) {
925 self.prev_view = self.view;
926 self.loading_text = Some(text.into());
927 self.view = View::Loading;
928 }
929
930 pub fn stop_loading(&mut self) {
931 self.loading_text = None;
932 self.view = self.prev_view;
933 }
934
935 pub fn start_search(&mut self) {
937 self.search_active = true;
938 }
939
940 pub fn end_search(&mut self) {
941 self.search_active = false;
942 }
943
944 pub fn clear_search(&mut self) {
945 self.search_query.clear();
946 self.search_active = false;
947 self.selected = 0;
948 }
949
950 pub fn search_push(&mut self, c: char) {
951 self.search_query.push(c);
952 self.selected = 0;
954 }
955
956 pub fn search_pop(&mut self) {
957 self.search_query.pop();
958 self.selected = 0;
959 }
960
961 pub fn filtered_daemons(&self) -> Vec<&Daemon> {
962 let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
963 self.daemons.iter().collect()
964 } else {
965 let matcher = SkimMatcherV2::default();
967 let mut scored: Vec<_> = self
968 .daemons
969 .iter()
970 .filter_map(|d| {
971 matcher
972 .fuzzy_match(&d.id.qualified(), &self.search_query)
973 .map(|score| (d, score))
974 })
975 .collect();
976 scored.sort_by_key(|s| std::cmp::Reverse(s.1));
978 scored.into_iter().map(|(d, _)| d).collect()
979 };
980
981 filtered.sort_by(|a, b| {
983 let cmp = match self.sort_column {
984 SortColumn::Name => {
985 a.id.to_string()
986 .to_lowercase()
987 .cmp(&b.id.to_string().to_lowercase())
988 }
989 SortColumn::Status => {
990 let status_order = |d: &Daemon| match &d.status {
991 crate::daemon_status::DaemonStatus::Running => 0,
992 crate::daemon_status::DaemonStatus::Waiting => 1,
993 crate::daemon_status::DaemonStatus::Stopping => 2,
994 crate::daemon_status::DaemonStatus::Stopped => 3,
995 crate::daemon_status::DaemonStatus::Errored(_) => 4,
996 crate::daemon_status::DaemonStatus::Failed(_) => 5,
997 };
998 status_order(a).cmp(&status_order(b))
999 }
1000 SortColumn::Cpu => {
1001 let cpu_a = a
1002 .pid
1003 .and_then(|p| self.get_stats(p))
1004 .map(|s| s.cpu_percent)
1005 .unwrap_or(0.0);
1006 let cpu_b = b
1007 .pid
1008 .and_then(|p| self.get_stats(p))
1009 .map(|s| s.cpu_percent)
1010 .unwrap_or(0.0);
1011 cpu_a
1012 .partial_cmp(&cpu_b)
1013 .unwrap_or(std::cmp::Ordering::Equal)
1014 }
1015 SortColumn::Memory => {
1016 let mem_a = a
1017 .pid
1018 .and_then(|p| self.get_stats(p))
1019 .map(|s| s.memory_bytes)
1020 .unwrap_or(0);
1021 let mem_b = b
1022 .pid
1023 .and_then(|p| self.get_stats(p))
1024 .map(|s| s.memory_bytes)
1025 .unwrap_or(0);
1026 mem_a.cmp(&mem_b)
1027 }
1028 SortColumn::Uptime => {
1029 let up_a = a
1030 .pid
1031 .and_then(|p| self.get_stats(p))
1032 .map(|s| s.uptime_secs)
1033 .unwrap_or(0);
1034 let up_b = b
1035 .pid
1036 .and_then(|p| self.get_stats(p))
1037 .map(|s| s.uptime_secs)
1038 .unwrap_or(0);
1039 up_a.cmp(&up_b)
1040 }
1041 };
1042 match self.sort_order {
1043 SortOrder::Ascending => cmp,
1044 SortOrder::Descending => cmp.reverse(),
1045 }
1046 });
1047
1048 filtered
1049 }
1050
1051 pub fn cycle_sort(&mut self) {
1053 self.sort_column = self.sort_column.next();
1055 self.selected = 0;
1056 }
1057
1058 pub fn toggle_sort_order(&mut self) {
1059 self.sort_order = self.sort_order.toggle();
1060 self.selected = 0;
1061 }
1062
1063 pub fn selected_daemon(&self) -> Option<&Daemon> {
1064 let filtered = self.filtered_daemons();
1065 filtered.get(self.selected).copied()
1066 }
1067
1068 pub fn select_next(&mut self) {
1069 let count = self.filtered_daemons().len();
1070 if count > 0 {
1071 self.selected = (self.selected + 1) % count;
1072 }
1073 }
1074
1075 pub fn select_prev(&mut self) {
1076 let count = self.filtered_daemons().len();
1077 if count > 0 {
1078 self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1079 }
1080 }
1081
1082 pub fn toggle_log_follow(&mut self) {
1084 self.log_follow = !self.log_follow;
1085 if self.log_follow && !self.log_content.is_empty() {
1086 self.log_scroll = self.log_content.len();
1088 }
1089 }
1090
1091 pub fn toggle_logs_expanded(&mut self) {
1093 self.logs_expanded = !self.logs_expanded;
1094 }
1095
1096 pub fn toggle_select(&mut self) {
1098 if let Some(daemon) = self.selected_daemon() {
1099 let id = daemon.id.clone();
1100 if self.multi_select.contains(&id) {
1101 self.multi_select.remove(&id);
1102 } else {
1103 self.multi_select.insert(id);
1104 }
1105 }
1106 }
1107
1108 pub fn select_all_visible(&mut self) {
1109 let ids: Vec<DaemonId> = self
1111 .filtered_daemons()
1112 .iter()
1113 .map(|d| d.id.clone())
1114 .collect();
1115 for id in ids {
1116 self.multi_select.insert(id);
1117 }
1118 }
1119
1120 pub fn clear_selection(&mut self) {
1121 self.multi_select.clear();
1122 }
1123
1124 pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1125 self.multi_select.contains(daemon_id)
1126 }
1127
1128 pub fn has_selection(&self) -> bool {
1129 !self.multi_select.is_empty()
1130 }
1131
1132 pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1133 self.multi_select.iter().cloned().collect()
1134 }
1135
1136 pub fn set_message(&mut self, msg: impl Into<String>) {
1137 self.message = Some(msg.into());
1138 self.message_time = Some(Instant::now());
1139 }
1140
1141 pub fn clear_stale_message(&mut self) {
1142 let duration = settings().tui_message_duration();
1143 if let Some(time) = self.message_time
1144 && time.elapsed() >= duration
1145 {
1146 self.message = None;
1147 self.message_time = None;
1148 }
1149 }
1150
1151 pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1152 self.process_stats.get(&pid)
1153 }
1154
1155 fn refresh_process_stats(&mut self) {
1156 PROCS.refresh_processes();
1157 self.process_stats.clear();
1158 for daemon in &self.daemons {
1159 if let Some(pid) = daemon.pid
1160 && let Some(stats) = PROCS.get_stats(pid)
1161 {
1162 self.process_stats.insert(pid, stats);
1163 let history = self.stats_history.entry(daemon.id.clone()).or_default();
1165 history.push(StatsSnapshot::from(&stats));
1166 }
1167 }
1168 }
1169
1170 pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1172 self.stats_history.get(daemon_id)
1173 }
1174
1175 pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1176 use crate::daemon_list::get_all_daemons;
1177
1178 let all_entries = get_all_daemons(client).await?;
1180
1181 self.daemons.clear();
1183 self.disabled.clear();
1184 self.config_daemon_ids.clear();
1185
1186 for entry in all_entries {
1188 let daemon_id = entry.daemon.id.clone();
1190
1191 if entry.is_disabled {
1193 self.disabled.push(daemon_id.clone());
1194 }
1195
1196 if entry.is_available {
1198 self.config_daemon_ids.insert(daemon_id.clone());
1199 }
1200
1201 if !entry.is_available || self.show_available {
1205 self.daemons.push(entry.daemon);
1206 }
1207 }
1208
1209 self.refresh_process_stats();
1211
1212 self.clear_stale_message();
1214
1215 let total_count = self.total_daemon_count();
1217 if total_count > 0 && self.selected >= total_count {
1218 self.selected = total_count - 1;
1219 }
1220
1221 if self.view == View::Logs
1223 && let Some(id) = self.log_daemon_id.clone()
1224 {
1225 self.load_logs(&id);
1226 }
1227
1228 Ok(())
1229 }
1230
1231 pub async fn refresh_network(&mut self) {
1233 let listeners: Vec<Listener> = tokio::task::spawn_blocking(|| {
1235 listeners::get_all()
1236 .map(|set| set.into_iter().collect::<Vec<_>>())
1237 .unwrap_or_default()
1238 })
1239 .await
1240 .unwrap_or_default();
1241
1242 self.network_listeners = listeners;
1243
1244 let filtered_count = self.filtered_network_listeners().len();
1246
1247 if filtered_count > 0 && self.network_selected >= filtered_count {
1249 self.network_selected = filtered_count - 1;
1250 } else if filtered_count == 0 {
1251 self.network_selected = 0;
1252 }
1253
1254 let selected_pid = self
1256 .filtered_network_listeners()
1257 .get(self.network_selected)
1258 .map(|l| l.process.pid);
1259 self.network_selected_pid = selected_pid;
1260 }
1261
1262 pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1264 if self.network_search_query.is_empty() {
1265 return self.network_listeners.iter().collect();
1266 }
1267
1268 let matcher = SkimMatcherV2::default();
1269 let query = &self.network_search_query;
1270
1271 self.network_listeners
1272 .iter()
1273 .filter(|listener| {
1274 let search_text = format!(
1276 "{} {} {}",
1277 listener.process.name,
1278 listener.process.pid,
1279 listener.socket.port()
1280 );
1281 matcher.fuzzy_match(&search_text, query).is_some()
1282 })
1283 .collect()
1284 }
1285
1286 pub fn toggle_network_search(&mut self) {
1288 self.network_search_active = !self.network_search_active;
1289 if !self.network_search_active {
1290 self.network_search_query.clear();
1291 }
1292 self.network_selected = 0;
1294 self.network_scroll_offset = 0;
1295 let filtered = self.filtered_network_listeners();
1297 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1298 }
1299
1300 pub fn clear_network_search(&mut self) {
1302 self.network_search_query.clear();
1303 self.network_search_active = false;
1304 self.network_selected = 0;
1306 self.network_scroll_offset = 0;
1307 let filtered = self.filtered_network_listeners();
1309 self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1310 }
1311
1312 pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1314 self.config_daemon_ids.contains(daemon_id)
1315 }
1316
1317 pub fn toggle_show_available(&mut self) {
1319 self.show_available = !self.show_available;
1320 }
1321
1322 fn total_daemon_count(&self) -> usize {
1324 self.filtered_daemons().len()
1325 }
1326
1327 pub fn scroll_logs_down(&mut self) {
1328 let max_scroll = self.log_content.len();
1329 self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1330 }
1331
1332 pub fn scroll_logs_up(&mut self) {
1333 self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1334 }
1335
1336 pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1338 let half_page = visible_lines / 2;
1339 let max_scroll = self.log_content.len();
1340 self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1341 }
1342
1343 pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1345 let half_page = visible_lines / 2;
1346 self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1347 }
1348
1349 pub fn start_log_search(&mut self) {
1351 self.log_search_active = true;
1352 self.log_search_query.clear();
1353 self.log_search_matches.clear();
1354 self.log_search_current = 0;
1355 }
1356
1357 pub fn end_log_search(&mut self) {
1358 self.log_search_active = false;
1359 }
1360
1361 pub fn clear_log_search(&mut self) {
1362 self.log_search_query.clear();
1363 self.log_search_active = false;
1364 self.log_search_matches.clear();
1365 self.log_search_current = 0;
1366 }
1367
1368 pub fn log_search_push(&mut self, c: char) {
1369 self.log_search_query.push(c);
1370 self.update_log_search_matches();
1371 }
1372
1373 pub fn log_search_pop(&mut self) {
1374 self.log_search_query.pop();
1375 self.update_log_search_matches();
1376 }
1377
1378 fn update_log_search_matches(&mut self) {
1379 self.log_search_matches.clear();
1380 if !self.log_search_query.is_empty() {
1381 let query = self.log_search_query.to_lowercase();
1382 for (i, line) in self.log_content.iter().enumerate() {
1383 if line.to_lowercase().contains(&query) {
1384 self.log_search_matches.push(i);
1385 }
1386 }
1387 if !self.log_search_matches.is_empty() {
1389 self.log_search_current = 0;
1390 self.jump_to_log_match();
1391 }
1392 }
1393 }
1394
1395 pub fn log_search_next(&mut self) {
1396 if !self.log_search_matches.is_empty() {
1397 self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1398 self.jump_to_log_match();
1399 }
1400 }
1401
1402 pub fn log_search_prev(&mut self) {
1403 if !self.log_search_matches.is_empty() {
1404 self.log_search_current = self
1405 .log_search_current
1406 .checked_sub(1)
1407 .unwrap_or(self.log_search_matches.len() - 1);
1408 self.jump_to_log_match();
1409 }
1410 }
1411
1412 fn jump_to_log_match(&mut self) {
1413 if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1414 let half_page = 10; self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1417 self.log_follow = false;
1418 }
1419 }
1420
1421 pub fn show_details(&mut self, daemon_id: &DaemonId) {
1423 self.details_daemon_id = Some(daemon_id.clone());
1424 self.prev_view = self.view;
1425 self.view = View::Details;
1426 }
1427
1428 pub fn hide_details(&mut self) {
1429 self.details_daemon_id = None;
1430 self.view = View::Dashboard;
1431 }
1432
1433 pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1435 self.log_daemon_id = Some(daemon_id.clone());
1436 self.logs_expanded = false; self.load_logs(daemon_id);
1438 self.view = View::Logs; }
1440
1441 fn load_logs(&mut self, daemon_id: &DaemonId) {
1442 let log_path = daemon_id.log_path();
1443 let prev_len = self.log_content.len();
1444
1445 self.log_content = if log_path.exists() {
1446 fs::read_to_string(&log_path)
1447 .unwrap_or_default()
1448 .lines()
1449 .map(String::from)
1450 .collect()
1451 } else {
1452 vec!["No logs available".to_string()]
1453 };
1454
1455 if self.log_follow {
1457 self.log_scroll = self.log_content.len().max(1);
1458 } else if prev_len == 0 {
1459 self.log_scroll = self.log_content.len().max(1);
1461 }
1462 }
1464
1465 pub fn show_help(&mut self) {
1466 self.view = View::Help;
1467 }
1468
1469 pub fn back_to_dashboard(&mut self) {
1470 self.view = View::Dashboard;
1471 self.log_daemon_id = None;
1472 self.log_content.clear();
1473 self.log_scroll = 1;
1474 }
1475
1476 pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1478 let available = self.config_daemon_ids.len();
1479 let total = self.daemons.len();
1480 let running = self
1481 .daemons
1482 .iter()
1483 .filter(|d| d.status.is_running())
1484 .count();
1485 let stopped = self
1487 .daemons
1488 .iter()
1489 .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1490 .count();
1491 let errored = self
1492 .daemons
1493 .iter()
1494 .filter(|d| d.status.is_errored() || d.status.is_failed())
1495 .count();
1496 (total, running, stopped, errored, available)
1497 }
1498
1499 pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1500 self.disabled.contains(daemon_id)
1501 }
1502
1503 pub fn get_config_files(&self) -> Vec<PathBuf> {
1507 let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1508 .into_iter()
1509 .filter(|p| p.exists())
1510 .collect();
1511
1512 let cwd_config = crate::env::CWD.join("pitchfork.toml");
1514 if !files.contains(&cwd_config) {
1515 files.push(cwd_config);
1516 }
1517
1518 files
1519 }
1520
1521 pub fn open_file_selector(&mut self) {
1523 let files = self.get_config_files();
1524 self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1525 self.view = View::ConfigFileSelect;
1526 }
1527
1528 pub fn open_editor_create(&mut self, config_path: PathBuf) {
1530 self.editor_state = Some(EditorState::new_create(config_path));
1531 self.file_selector = None;
1532 self.view = View::ConfigEditor;
1533 }
1534
1535 pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1537 let config = match PitchforkToml::all_merged() {
1538 Ok(config) => config,
1539 Err(e) => {
1540 self.set_message(format!("Failed to load config: {e}"));
1541 return;
1542 }
1543 };
1544 if let Some(daemon_config) = config.daemons.get(daemon_id) {
1545 let config_path = daemon_config
1546 .path
1547 .clone()
1548 .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1549 self.editor_state = Some(EditorState::new_edit(
1550 daemon_id.to_string(),
1551 daemon_config,
1552 config_path,
1553 ));
1554 self.view = View::ConfigEditor;
1555 } else {
1556 self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1557 }
1558 }
1559
1560 pub fn close_editor(&mut self) {
1562 self.editor_state = None;
1563 self.file_selector = None;
1564 self.view = View::Dashboard;
1565 }
1566
1567 pub fn save_editor_config(&mut self) -> Result<bool> {
1570 let editor = self
1571 .editor_state
1572 .as_mut()
1573 .ok_or_else(|| miette::miette!("No editor state"))?;
1574
1575 if !editor.validate() {
1577 self.set_message("Please fix validation errors before saving");
1578 return Ok(false);
1579 }
1580
1581 let daemon_config = editor.to_daemon_config();
1583
1584 let daemon_id = DaemonId::parse(&editor.daemon_id)
1586 .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1587
1588 let mut config = PitchforkToml::read(&editor.config_path)?;
1590
1591 let is_duplicate = match &editor.mode {
1593 EditMode::Create => config.daemons.contains_key(&daemon_id),
1594 EditMode::Edit { original_id } => {
1595 let original_daemon_id = DaemonId::parse(original_id)
1597 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1598 original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1599 }
1600 };
1601
1602 if is_duplicate {
1603 self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1604 return Ok(false);
1605 }
1606
1607 if let EditMode::Edit { original_id } = &editor.mode {
1609 let original_daemon_id = DaemonId::parse(original_id)
1610 .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1611 if original_daemon_id != daemon_id {
1612 config.daemons.shift_remove(&original_daemon_id);
1613 }
1614 }
1615
1616 config.daemons.insert(daemon_id, daemon_config);
1618
1619 config.write()?;
1621
1622 editor.unsaved_changes = false;
1623 let daemon_id = editor.daemon_id.clone();
1624 self.set_message(format!("Saved daemon '{daemon_id}'"));
1625
1626 Ok(true)
1627 }
1628
1629 pub fn delete_daemon_from_config(
1631 &mut self,
1632 id: &str,
1633 config_path: &std::path::Path,
1634 ) -> Result<bool> {
1635 let mut config = PitchforkToml::read(config_path)?;
1636
1637 let daemon_id = if id.contains('/') {
1640 DaemonId::parse(id)?
1641 } else {
1642 let ns = namespace_from_path(config_path)?;
1643 DaemonId::try_new(&ns, id)?
1644 };
1645
1646 if config.daemons.shift_remove(&daemon_id).is_some() {
1647 config.write()?;
1648 Ok(true)
1649 } else {
1650 Ok(false)
1651 }
1652 }
1653}
1654
1655impl Default for App {
1656 fn default() -> Self {
1657 Self::new()
1658 }
1659}