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