1use crate::cli::json_output::{JsonLogEntry, print_json};
2use crate::daemon_id::DaemonId;
3use crate::log_store::sqlite::LOG_STORE;
4use crate::log_store::{LogQuery, LogStore};
5use crate::pitchfork_toml::PitchforkToml;
6use crate::settings::settings;
7use crate::state_file::StateFile;
8use crate::ui::style::{edim, estyle, ndim};
9use crate::{Result, env};
10use chrono::{DateTime, Local, NaiveDateTime, NaiveTime, TimeZone};
11use console;
12use itertools::Itertools;
13use miette::IntoDiagnostic;
14use std::collections::BTreeSet;
15use std::io::{self, IsTerminal, Write};
16use std::process::{Child, Command, Stdio};
17use std::time::Duration;
18
19struct PagerConfig {
21 command: String,
22 args: Vec<String>,
23}
24
25impl PagerConfig {
26 fn new(start_at_end: bool) -> Self {
29 let command = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
30 let args = Self::build_args(&command, start_at_end);
31 Self { command, args }
32 }
33
34 fn build_args(pager: &str, start_at_end: bool) -> Vec<String> {
35 let mut args = vec![];
36 if pager == "less" {
37 args.push("-R".to_string());
38 if start_at_end {
39 args.push("+G".to_string());
40 }
41 }
42 args
43 }
44
45 fn spawn_piped(&self) -> io::Result<Child> {
47 Command::new(&self.command)
48 .args(&self.args)
49 .stdin(Stdio::piped())
50 .spawn()
51 }
52}
53
54fn format_log_line(
60 date: &str,
61 id: &str,
62 msg: &str,
63 single_daemon: bool,
64 id_width: usize,
65 strip_ansi: bool,
66 show_timestamp: bool,
67) -> String {
68 let msg = if strip_ansi {
69 console::strip_ansi_codes(msg).to_string()
70 } else {
71 msg.to_string()
72 };
73 if single_daemon {
74 if show_timestamp {
75 format!("{} {}", ndim(date), msg)
76 } else {
77 msg
78 }
79 } else {
80 let colors_on = !strip_ansi && console::colors_enabled();
81 let colored = dimmed_id(id, colors_on);
82 let padded = console::pad_str(&colored, id_width, console::Alignment::Left, None);
83 if show_timestamp {
84 format!("{} {} {}", padded, ndim(date), msg)
85 } else {
86 format!("{} {}", padded, msg)
87 }
88 }
89}
90
91fn dimmed_id(id: &str, colors_enabled: bool) -> String {
95 if !colors_enabled {
96 return id.to_string();
97 }
98 let colors = [
99 (180, 120, 120), (180, 160, 100), (120, 180, 120), (120, 180, 180), (180, 120, 180), (120, 160, 180), ];
106 let mut h: usize = 0x811C_9DC5; for b in id.bytes() {
108 h = h.wrapping_mul(0x0100_0193).wrapping_add(b as usize);
109 }
110 let (r, g, b) = colors[h % colors.len()];
111 format!("\x1b[2;38;2;{};{};{}m{}\x1b[0m", r, g, b, id)
112}
113
114pub fn colored_id_label(id: &str, colors_enabled: bool) -> String {
117 if !colors_enabled {
118 return format!("[{}]", id);
119 }
120 let colors: [u8; 4] = [34, 35, 36, 32]; let mut h: usize = 0x811C_9DC5; for b in id.bytes() {
125 h = h.wrapping_mul(0x0100_0193).wrapping_add(b as usize);
126 }
127 let color = colors[h % colors.len()];
128 format!("\x1b[{color}m[{id}]\x1b[0m")
129}
130
131#[derive(Debug, clap::Args)]
133#[clap(
134 visible_alias = "l",
135 verbatim_doc_comment,
136 long_about = "\
137Displays logs for daemon(s)
138
139Shows logs from managed daemons. Logs are stored in the pitchfork logs directory
140and include timestamps for filtering.
141
142Examples:
143 pitchfork logs api Show all logs for 'api' (paged if needed)
144 pitchfork logs api worker Show logs for multiple daemons
145 pitchfork logs Show logs for all daemons
146 pitchfork logs api -n 50 Show last 50 lines
147 pitchfork logs api --follow Follow logs in real-time
148 pitchfork logs api --since '2024-01-15 10:00:00'
149 Show logs since a specific time (forward)
150 pitchfork logs api --since '10:30:00'
151 Show logs since 10:30:00 today
152 pitchfork logs api --since '10:30' --until '12:00'
153 Show logs since 10:30:00 until 12:00:00 today
154 pitchfork logs api --since 5min Show logs from last 5 minutes
155 pitchfork logs api --raw Output raw log lines without formatting
156 pitchfork logs api --raw -n 100 Output last 100 raw log lines
157 pitchfork logs api --clear Delete logs for 'api'
158 pitchfork logs --clear Delete logs for all daemons"
159)]
160pub struct Logs {
161 id: Vec<String>,
163
164 #[clap(short, long)]
166 clear: bool,
167
168 #[clap(short)]
173 n: Option<usize>,
174
175 #[clap(short = 't', short_alias = 'f', long, visible_alias = "follow")]
177 tail: bool,
178
179 #[clap(short = 's', long)]
186 since: Option<String>,
187
188 #[clap(short = 'u', long)]
194 until: Option<String>,
195
196 #[clap(long)]
198 no_pager: bool,
199
200 #[clap(long)]
202 raw: bool,
203
204 #[clap(long, conflicts_with = "raw", conflicts_with = "tail")]
206 json: bool,
207
208 #[clap(long)]
210 no_timestamp: bool,
211}
212
213impl Logs {
214 pub async fn run(&self) -> Result<()> {
215 migrate_legacy_log_dirs();
216
217 let resolved_ids: Vec<DaemonId> = if self.id.is_empty() {
218 get_all_daemon_ids()?
219 } else {
220 PitchforkToml::resolve_ids(&self.id)?
221 };
222
223 if self.clear {
224 LOG_STORE.clear(&resolved_ids)?;
225 return Ok(());
226 }
227
228 let from = if let Some(since) = self.since.as_ref() {
229 Some(parse_time_input(since, true)?)
230 } else {
231 None
232 };
233 let to = if let Some(until) = self.until.as_ref() {
234 Some(parse_time_input(until, false)?)
235 } else {
236 None
237 };
238
239 if self.json {
240 return self.output_json(&resolved_ids, from, to);
241 }
242
243 let single_daemon = resolved_ids.len() == 1;
244 let show_timestamp = settings().logs.timestamp && !self.no_timestamp;
245 let log_lines = self.fetch_log_lines(&resolved_ids, from, to)?;
246 let has_time_filter = from.is_some() || to.is_some();
247 self.output_logs(
248 log_lines,
249 single_daemon,
250 has_time_filter,
251 self.tail,
252 show_timestamp,
253 )?;
254 if self.tail {
255 tail_logs(&resolved_ids, single_daemon, true, show_timestamp).await?;
256 }
257
258 Ok(())
259 }
260
261 fn fetch_log_lines(
262 &self,
263 resolved_ids: &[DaemonId],
264 from: Option<DateTime<Local>>,
265 to: Option<DateTime<Local>>,
266 ) -> Result<Vec<(String, String, String)>> {
267 let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
268 let has_time_filter = from.is_some() || to.is_some();
269
270 let opts = LogQuery {
271 daemon_ids: daemon_ids.clone(),
272 from,
273 to,
274 limit: if !has_time_filter { self.n } else { None },
275 order_desc: !has_time_filter,
276 after_id: None,
277 };
278 let entries = LOG_STORE.query(&opts)?;
279 let log_lines: Vec<(String, String, String)> = entries
280 .into_iter()
281 .map(|e| {
282 let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
283 (ts, e.daemon_id, e.message)
284 })
285 .collect();
286
287 let log_lines = if has_time_filter {
288 if let Some(n) = self.n {
289 let len = log_lines.len();
290 if len > n {
291 log_lines.into_iter().skip(len - n).collect_vec()
292 } else {
293 log_lines
294 }
295 } else {
296 log_lines
297 }
298 } else if let Some(n) = self.n {
299 let len = log_lines.len();
300 if len > n {
301 log_lines.into_iter().skip(len - n).rev().collect_vec()
302 } else {
303 log_lines.into_iter().rev().collect_vec()
304 }
305 } else {
306 log_lines.into_iter().rev().collect_vec()
307 };
308
309 Ok(log_lines)
310 }
311
312 fn output_json(
313 &self,
314 resolved_ids: &[DaemonId],
315 from: Option<DateTime<Local>>,
316 to: Option<DateTime<Local>>,
317 ) -> Result<()> {
318 let log_lines = self.fetch_log_lines(resolved_ids, from, to)?;
319
320 let json_entries: Vec<JsonLogEntry> = log_lines
321 .into_iter()
322 .map(|(timestamp, daemon_id, message)| JsonLogEntry {
323 timestamp,
324 daemon_id,
325 message: console::strip_ansi_codes(&message).to_string(),
326 })
327 .collect();
328
329 print_json(&json_entries)
330 }
331
332 fn output_logs(
333 &self,
334 log_lines: Vec<(String, String, String)>,
335 single_daemon: bool,
336 has_time_filter: bool,
337 force_no_pager: bool,
338 show_timestamp: bool,
339 ) -> Result<()> {
340 if log_lines.is_empty() {
341 return Ok(());
342 }
343
344 let id_width = log_lines
345 .iter()
346 .map(|(_, id, _)| id.len())
347 .max()
348 .unwrap_or(0);
349 let strip_ansi = self.raw || !console::colors_enabled();
350
351 if self.raw {
352 for (date, id, msg) in log_lines {
353 let line = format_log_line(
354 &date,
355 &id,
356 &msg,
357 single_daemon,
358 id_width,
359 strip_ansi,
360 show_timestamp,
361 );
362 println!("{line}");
363 }
364 return Ok(());
365 }
366
367 let use_pager = !force_no_pager && !self.no_pager && should_use_pager(log_lines.len());
368
369 if use_pager {
370 self.output_with_pager(
371 log_lines,
372 single_daemon,
373 id_width,
374 has_time_filter,
375 strip_ansi,
376 show_timestamp,
377 )?;
378 } else {
379 for (date, id, msg) in log_lines {
380 println!(
381 "{}",
382 format_log_line(
383 &date,
384 &id,
385 &msg,
386 single_daemon,
387 id_width,
388 strip_ansi,
389 show_timestamp,
390 )
391 );
392 }
393 }
394
395 Ok(())
396 }
397
398 fn output_with_pager(
399 &self,
400 log_lines: Vec<(String, String, String)>,
401 single_daemon: bool,
402 id_width: usize,
403 has_time_filter: bool,
404 strip_ansi: bool,
405 show_timestamp: bool,
406 ) -> Result<()> {
407 let pager_config = PagerConfig::new(!has_time_filter);
409
410 match pager_config.spawn_piped() {
411 Ok(mut child) => {
412 if let Some(stdin) = child.stdin.as_mut() {
413 for (date, id, msg) in log_lines {
414 let line = format!(
415 "{}\n",
416 format_log_line(
417 &date,
418 &id,
419 &msg,
420 single_daemon,
421 id_width,
422 strip_ansi,
423 show_timestamp,
424 )
425 );
426 if stdin.write_all(line.as_bytes()).is_err() {
427 break;
428 }
429 }
430 let _ = child.wait();
431 } else {
432 debug!("Failed to get pager stdin, falling back to direct output");
433 for (date, id, msg) in log_lines {
434 println!(
435 "{}",
436 format_log_line(
437 &date,
438 &id,
439 &msg,
440 single_daemon,
441 id_width,
442 strip_ansi,
443 show_timestamp,
444 )
445 );
446 }
447 }
448 }
449 Err(e) => {
450 debug!("Failed to spawn pager: {e}, falling back to direct output");
451 for (date, id, msg) in log_lines {
452 println!(
453 "{}",
454 format_log_line(
455 &date,
456 &id,
457 &msg,
458 single_daemon,
459 id_width,
460 strip_ansi,
461 show_timestamp,
462 )
463 );
464 }
465 }
466 }
467
468 Ok(())
469 }
470}
471
472fn should_use_pager(line_count: usize) -> bool {
473 if !io::stdout().is_terminal() {
474 return false;
475 }
476
477 let terminal_height = get_terminal_height().unwrap_or(24);
478 line_count > terminal_height
479}
480
481fn get_terminal_height() -> Option<usize> {
482 if let Ok(rows) = std::env::var("LINES")
483 && let Ok(h) = rows.parse::<usize>()
484 {
485 return Some(h);
486 }
487
488 crossterm::terminal::size().ok().map(|(_, h)| h as usize)
489}
490
491fn migrate_legacy_log_dirs() {
501 let known_safe_paths = known_daemon_safe_paths();
502 let dirs = match xx::file::ls(&*env::PITCHFORK_LOGS_DIR) {
503 Ok(d) => d,
504 Err(_) => return,
505 };
506 for dir in dirs {
507 if dir.starts_with(".") || !dir.is_dir() {
508 continue;
509 }
510 let name = match dir.file_name().map(|f| f.to_string_lossy().to_string()) {
511 Some(n) => n,
512 None => continue,
513 };
514 if name == "pitchfork" {
516 continue;
517 }
518 if name.contains("--") {
521 if DaemonId::from_safe_path(&name).is_ok() {
524 continue;
525 }
526 if known_safe_paths.contains(&name) {
529 continue;
530 }
531 warn!(
532 "Skipping invalid legacy log directory '{name}': contains '--' but is not a valid daemon safe-path"
533 );
534 continue;
535 }
536
537 let old_log = dir.join(format!("{name}.log"));
540 if !old_log.exists() {
541 continue;
542 }
543 if DaemonId::try_new("legacy", &name).is_err() {
544 warn!("Skipping invalid legacy log directory '{name}': not a valid daemon ID");
545 continue;
546 }
547
548 let new_name = format!("legacy--{name}");
549 let new_dir = env::PITCHFORK_LOGS_DIR.join(&new_name);
550 if new_dir.exists() {
552 continue;
553 }
554 if std::fs::rename(&dir, &new_dir).is_err() {
555 continue;
556 }
557 let old_log = new_dir.join(format!("{name}.log"));
559 let new_log = new_dir.join(format!("{new_name}.log"));
560 if old_log.exists() {
561 let _ = std::fs::rename(&old_log, &new_log);
562 }
563 debug!("Migrated legacy log dir '{name}' → '{new_name}'");
564 }
565}
566
567fn known_daemon_safe_paths() -> BTreeSet<String> {
568 let mut out = BTreeSet::new();
569
570 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
571 Ok(state) => {
572 for id in state.daemons.keys() {
573 out.insert(id.safe_path());
574 }
575 }
576 Err(e) => {
577 warn!("Failed to read state while checking known daemon IDs: {e}");
578 }
579 }
580
581 match PitchforkToml::all_merged() {
582 Ok(config) => {
583 for id in config.daemons.keys() {
584 out.insert(id.safe_path());
585 }
586 }
587 Err(e) => {
588 warn!("Failed to read config while checking known daemon IDs: {e}");
589 }
590 }
591
592 out
593}
594
595fn get_all_daemon_ids() -> Result<Vec<DaemonId>> {
596 let mut ids = BTreeSet::new();
597
598 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
599 Ok(state) => ids.extend(state.daemons.keys().cloned()),
600 Err(e) => warn!("Failed to read state for log daemon discovery: {e}"),
601 }
602
603 match PitchforkToml::all_merged() {
604 Ok(config) => ids.extend(config.daemons.keys().cloned()),
605 Err(e) => warn!("Failed to read config for log daemon discovery: {e}"),
606 }
607
608 let logged_ids: std::collections::HashSet<String> =
609 LOG_STORE.list_daemon_ids()?.into_iter().collect();
610 Ok(ids
611 .into_iter()
612 .filter(|id| logged_ids.contains(&id.qualified()))
613 .collect())
614}
615
616pub async fn tail_logs(
617 names: &[DaemonId],
618 single_daemon: bool,
619 start_from_end: bool,
620 show_timestamp: bool,
621) -> Result<()> {
622 let id_width = names
624 .iter()
625 .map(|id| id.qualified().len())
626 .max()
627 .unwrap_or(0);
628
629 let strip_ansi = !console::colors_enabled();
630
631 let mut states: std::collections::HashMap<String, i64> = names
632 .iter()
633 .map(|id| {
634 let since = if start_from_end {
635 match LOG_STORE.query(&LogQuery {
636 daemon_ids: vec![id.qualified()],
637 from: None,
638 to: None,
639 limit: Some(1),
640 order_desc: true,
641 after_id: None,
642 }) {
643 Ok(entries) => entries.first().map(|e| e.id).unwrap_or(0),
644 Err(_) => 0,
645 }
646 } else {
647 0
648 };
649 (id.qualified(), since)
650 })
651 .collect();
652
653 let interval = tokio::time::interval(Duration::from_millis(200));
654 tokio::pin!(interval);
655
656 loop {
657 interval.tick().await;
658
659 let mut out = vec![];
660 for id in names {
661 let after_id = states.get(&id.qualified()).copied();
662 match LOG_STORE.tail(id, after_id) {
663 Ok(entries) => {
664 for entry in &entries {
665 let ts = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
666 out.push((ts, entry.daemon_id.clone(), entry.message.clone()));
667 }
668 if let Some(last) = entries.last() {
669 states.insert(id.qualified(), last.id);
670 }
671 }
672 Err(e) => {
673 error!("Failed to tail logs for {}: {e}", id.qualified());
674 }
675 }
676 }
677
678 if !out.is_empty() {
679 let out = out
680 .into_iter()
681 .sorted_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)))
682 .collect_vec();
683 for (date, name, msg) in out {
684 println!(
685 "{}",
686 format_log_line(
687 &date,
688 &name,
689 &msg,
690 single_daemon,
691 id_width,
692 strip_ansi,
693 show_timestamp,
694 )
695 );
696 }
697 }
698 }
699}
700
701fn parse_datetime(s: &str) -> Result<DateTime<Local>> {
702 let naive_dt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").into_diagnostic()?;
703 Local
704 .from_local_datetime(&naive_dt)
705 .single()
706 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'. ", s))
707}
708
709fn parse_time_input(s: &str, is_since: bool) -> Result<DateTime<Local>> {
715 let s = s.trim();
716
717 if let Ok(dt) = parse_datetime(s) {
719 return Ok(dt);
720 }
721
722 if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M") {
724 return Local
725 .from_local_datetime(&naive_dt)
726 .single()
727 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s));
728 }
729
730 if let Ok(time) = parse_time_only(s) {
734 let now = Local::now();
735 let today = now.date_naive();
736 let mut naive_dt = NaiveDateTime::new(today, time);
737 let mut dt = Local
738 .from_local_datetime(&naive_dt)
739 .single()
740 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
741
742 if is_since
745 && dt > now
746 && let Some(yesterday) = today.pred_opt()
747 {
748 naive_dt = NaiveDateTime::new(yesterday, time);
749 dt = Local
750 .from_local_datetime(&naive_dt)
751 .single()
752 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
753 }
754 return Ok(dt);
755 }
756
757 if let Ok(duration) = humantime::parse_duration(s) {
758 let now = Local::now();
759 let target = now - chrono::Duration::from_std(duration).into_diagnostic()?;
760 return Ok(target);
761 }
762
763 Err(miette::miette!(
764 "Invalid time format: '{}'. Expected formats:\n\
765 - Full datetime: \"YYYY-MM-DD HH:MM:SS\" or \"YYYY-MM-DD HH:MM\"\n\
766 - Time only: \"HH:MM:SS\" or \"HH:MM\" (uses today's date)\n\
767 - Relative time: \"5min\", \"2h\", \"1d\" (e.g., last 5 minutes)",
768 s
769 ))
770}
771
772fn parse_time_only(s: &str) -> Result<NaiveTime> {
773 if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
774 return Ok(time);
775 }
776
777 if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M") {
778 return Ok(time);
779 }
780
781 Err(miette::miette!("Invalid time format: '{}'", s))
782}
783
784pub fn print_error_logs_block(log_lines: &[(String, String, String)]) {
794 if log_lines.is_empty() {
795 return;
796 }
797
798 let is_tty = std::io::stderr().is_terminal();
799 let format_msg = |msg: &str| -> String {
800 let stripped = strip_pty_controls(msg);
801 if is_tty {
802 stripped
803 } else {
804 console::strip_ansi_codes(&stripped).to_string()
805 }
806 };
807
808 let tag = estyle(" ERROR LOGS ").white().on_red();
809 eprintln!("\n{tag}");
810
811 let unique_ids: BTreeSet<&str> = log_lines.iter().map(|(_, id, _)| id.as_str()).collect();
813 let show_id = unique_ids.len() > 1;
814
815 if show_id {
816 let id_width = log_lines
817 .iter()
818 .map(|(_, id, _)| console::measure_text_width(id))
819 .max()
820 .unwrap_or(0);
821 for (date, id, msg) in log_lines {
822 let time = date.split(' ').nth(1).unwrap_or(date);
823 let colored = dimmed_id(id, is_tty && console::colors_enabled_stderr());
824 let padded = console::pad_str(&colored, id_width, console::Alignment::Left, None);
825 eprintln!(
826 "{} {} {}",
827 padded,
828 estyle(time).red().dim(),
829 format_msg(msg)
830 );
831 }
832 } else {
833 for (date, _, msg) in log_lines {
834 let time = date.split(' ').nth(1).unwrap_or(date);
835 eprintln!("{} {}", estyle(time).red().dim(), format_msg(msg));
836 }
837 }
838}
839
840pub enum ReadyCheckType {
842 Output(String),
843 Http(String),
844 Port(u16),
845 Cmd(String),
846 Delay(u64),
847 Default,
848}
849
850impl std::fmt::Display for ReadyCheckType {
851 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
852 match self {
853 ReadyCheckType::Output(pattern) => write!(f, "output matching '{pattern}'"),
854 ReadyCheckType::Http(url) => write!(f, "HTTP {url}"),
855 ReadyCheckType::Port(port) => write!(f, "TCP port {port}"),
856 ReadyCheckType::Cmd(cmd) => write!(f, "command '{cmd}'"),
857 ReadyCheckType::Delay(secs) => write!(f, "delay ({secs}s)"),
858 ReadyCheckType::Default => write!(f, "default readiness check"),
859 }
860 }
861}
862
863pub fn create_ready_check_job(
869 daemon_id: &DaemonId,
870 check_type: &ReadyCheckType,
871) -> std::sync::Arc<clx::progress::ProgressJob> {
872 use clx::progress::{ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
873
874 let is_tty = std::io::stderr().is_terminal();
875 let colors_enabled = is_tty && console::colors_enabled_stderr();
876 let id_label = colored_id_label(&daemon_id.qualified(), colors_enabled);
877 let show_ts = crate::settings::settings().general.startup_log_timestamps;
878
879 let prefix = if show_ts {
883 edim(chrono::Local::now().format("%H:%M:%S").to_string()).to_string()
886 } else {
887 "{{spinner()}}".to_string()
888 };
889
890 ProgressJobBuilder::new()
891 .body(format!(
892 "{} {} waiting for {{{{ check_type }}}}...",
893 prefix, id_label
894 ))
895 .prop("check_type", &check_type.to_string())
896 .status(ProgressStatus::Running)
897 .on_done(ProgressJobDoneBehavior::Keep)
898 .start()
899}
900
901pub fn collect_startup_logs(
906 daemon_id: &DaemonId,
907 from: DateTime<Local>,
908) -> Result<Vec<(String, String, String)>> {
909 let entries = LOG_STORE.query(&LogQuery {
910 daemon_ids: vec![daemon_id.qualified()],
911 from: Some(from),
912 to: None,
913 limit: None,
914 order_desc: false,
915 after_id: None,
916 })?;
917 let log_lines = entries
918 .into_iter()
919 .map(|e| {
920 let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
921 (ts, e.daemon_id, e.message)
922 })
923 .collect();
924
925 Ok(log_lines)
926}
927
928pub fn stream_startup_logs(
934 daemon_id: &DaemonId,
935 from: DateTime<Local>,
936 job: std::sync::Arc<clx::progress::ProgressJob>,
937) -> (
938 tokio::sync::watch::Sender<bool>,
939 tokio::task::JoinHandle<()>,
940) {
941 let (tx, mut rx) = tokio::sync::watch::channel(false);
942 let id = daemon_id.clone();
943
944 let show_ts = crate::settings::settings().general.startup_log_timestamps;
945
946 let handle = tokio::spawn(async move {
947 let is_tty = std::io::stderr().is_terminal();
948 let colors_enabled = is_tty && console::colors_enabled_stderr();
949 let id_label = colored_id_label(&id.qualified(), colors_enabled);
950 let prefix = if show_ts {
951 String::new()
952 } else {
953 edim("•").to_string()
954 };
955
956 let mut last_id: i64 = 0;
957
958 let initial_entries = LOG_STORE.query(&LogQuery {
960 daemon_ids: vec![id.qualified()],
961 from: Some(from),
962 to: None,
963 limit: None,
964 order_desc: false,
965 after_id: None,
966 });
967
968 if let Ok(entries) = initial_entries {
969 for entry in &entries {
970 let time = entry.timestamp.format("%H:%M:%S").to_string();
971 let msg = strip_pty_controls(&entry.message);
972 let msg = if is_tty {
973 msg
974 } else {
975 console::strip_ansi_codes(&msg).to_string()
976 };
977 let line_prefix = if show_ts {
978 edim(time).to_string()
979 } else {
980 prefix.clone()
981 };
982 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
983 }
984 if let Some(last) = entries.last() {
985 last_id = last.id;
986 }
987 }
988
989 loop {
990 tokio::select! {
991 _ = tokio::time::sleep(Duration::from_millis(200)) => {
992 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
993 for entry in &entries {
994 let time = entry.timestamp.format("%H:%M:%S").to_string();
995 let msg = strip_pty_controls(&entry.message);
996 let msg = if is_tty {
997 msg
998 } else {
999 console::strip_ansi_codes(&msg).to_string()
1000 };
1001 let line_prefix = if show_ts {
1002 edim(time).to_string()
1003 } else {
1004 prefix.clone()
1005 };
1006 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1007 }
1008 if let Some(last) = entries.last() {
1009 last_id = last.id;
1010 }
1011 }
1012 }
1013 _ = rx.changed() => {
1014 break;
1015 }
1016 }
1017 }
1018
1019 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1021 for entry in &entries {
1022 let time = entry.timestamp.format("%H:%M:%S").to_string();
1023 let msg = strip_pty_controls(&entry.message);
1024 let msg = if is_tty {
1025 msg
1026 } else {
1027 console::strip_ansi_codes(&msg).to_string()
1028 };
1029 let line_prefix = if show_ts {
1030 edim(time).to_string()
1031 } else {
1032 prefix.clone()
1033 };
1034 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1035 }
1036 }
1037 });
1038
1039 (tx, handle)
1040}
1041
1042fn strip_pty_controls(s: &str) -> String {
1047 struct Stripper {
1048 result: String,
1049 }
1050
1051 impl vte::Perform for Stripper {
1052 fn print(&mut self, c: char) {
1053 self.result.push(c);
1054 }
1055
1056 fn execute(&mut self, byte: u8) {
1057 if byte == b'\n' || byte == b'\t' {
1059 self.result.push(byte as char);
1060 }
1061 }
1062
1063 fn csi_dispatch(
1064 &mut self,
1065 params: &vte::Params,
1066 _intermediates: &[u8],
1067 _ignore: bool,
1068 action: char,
1069 ) {
1070 if action == 'm' {
1072 self.result.push_str("\x1b[");
1073 let mut first = true;
1074 for sub in params.iter() {
1075 if !first {
1076 self.result.push(';');
1077 }
1078 first = false;
1079 for (i, &p) in sub.iter().enumerate() {
1080 if i > 0 {
1081 self.result.push(':');
1082 }
1083 self.result.push_str(&p.to_string());
1084 }
1085 }
1086 self.result.push('m');
1087 }
1088 }
1090
1091 fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1092 }
1094
1095 fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1096 }
1098
1099 fn hook(
1100 &mut self,
1101 _params: &vte::Params,
1102 _intermediates: &[u8],
1103 _ignore: bool,
1104 _action: char,
1105 ) {
1106 }
1108
1109 fn put(&mut self, _byte: u8) {
1110 }
1112
1113 fn unhook(&mut self) {
1114 }
1116 }
1117
1118 let mut parser = vte::Parser::new();
1119 let mut stripper = Stripper {
1120 result: String::with_capacity(s.len()),
1121 };
1122 parser.advance(&mut stripper, s.as_bytes());
1123 stripper.result
1124}