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 job: std::sync::Arc<clx::progress::ProgressJob>,
936) -> (
937 tokio::sync::watch::Sender<bool>,
938 tokio::task::JoinHandle<()>,
939) {
940 let (tx, mut rx) = tokio::sync::watch::channel(false);
941 let id = daemon_id.clone();
942
943 let show_ts = crate::settings::settings().general.startup_log_timestamps;
944
945 let anchor_id: i64 = LOG_STORE
950 .query(&LogQuery {
951 daemon_ids: vec![id.qualified()],
952 limit: Some(1),
953 order_desc: true,
954 ..Default::default()
955 })
956 .ok()
957 .and_then(|entries| entries.last().map(|e| e.id))
958 .unwrap_or(0);
959
960 let handle = tokio::spawn(async move {
961 let is_tty = std::io::stderr().is_terminal();
962 let colors_enabled = is_tty && console::colors_enabled_stderr();
963 let id_label = colored_id_label(&id.qualified(), colors_enabled);
964 let prefix = if show_ts {
965 String::new()
966 } else {
967 edim("•").to_string()
968 };
969
970 let mut last_id = anchor_id;
971
972 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
974 for entry in &entries {
975 let time = entry.timestamp.format("%H:%M:%S").to_string();
976 let msg = strip_pty_controls(&entry.message);
977 let msg = if is_tty {
978 msg
979 } else {
980 console::strip_ansi_codes(&msg).to_string()
981 };
982 let line_prefix = if show_ts {
983 edim(time).to_string()
984 } else {
985 prefix.clone()
986 };
987 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
988 }
989 if let Some(last) = entries.last() {
990 last_id = last.id;
991 }
992 }
993
994 loop {
995 tokio::select! {
996 _ = tokio::time::sleep(Duration::from_millis(200)) => {
997 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
998 for entry in &entries {
999 let time = entry.timestamp.format("%H:%M:%S").to_string();
1000 let msg = strip_pty_controls(&entry.message);
1001 let msg = if is_tty {
1002 msg
1003 } else {
1004 console::strip_ansi_codes(&msg).to_string()
1005 };
1006 let line_prefix = if show_ts {
1007 edim(time).to_string()
1008 } else {
1009 prefix.clone()
1010 };
1011 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1012 }
1013 if let Some(last) = entries.last() {
1014 last_id = last.id;
1015 }
1016 }
1017 }
1018 _ = rx.changed() => {
1019 break;
1020 }
1021 }
1022 }
1023
1024 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1026 for entry in &entries {
1027 let time = entry.timestamp.format("%H:%M:%S").to_string();
1028 let msg = strip_pty_controls(&entry.message);
1029 let msg = if is_tty {
1030 msg
1031 } else {
1032 console::strip_ansi_codes(&msg).to_string()
1033 };
1034 let line_prefix = if show_ts {
1035 edim(time).to_string()
1036 } else {
1037 prefix.clone()
1038 };
1039 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1040 }
1041 }
1042 });
1043
1044 (tx, handle)
1045}
1046
1047fn strip_pty_controls(s: &str) -> String {
1052 struct Stripper {
1053 result: String,
1054 }
1055
1056 impl vte::Perform for Stripper {
1057 fn print(&mut self, c: char) {
1058 self.result.push(c);
1059 }
1060
1061 fn execute(&mut self, byte: u8) {
1062 if byte == b'\n' || byte == b'\t' {
1064 self.result.push(byte as char);
1065 }
1066 }
1067
1068 fn csi_dispatch(
1069 &mut self,
1070 params: &vte::Params,
1071 _intermediates: &[u8],
1072 _ignore: bool,
1073 action: char,
1074 ) {
1075 if action == 'm' {
1077 self.result.push_str("\x1b[");
1078 let mut first = true;
1079 for sub in params.iter() {
1080 if !first {
1081 self.result.push(';');
1082 }
1083 first = false;
1084 for (i, &p) in sub.iter().enumerate() {
1085 if i > 0 {
1086 self.result.push(':');
1087 }
1088 self.result.push_str(&p.to_string());
1089 }
1090 }
1091 self.result.push('m');
1092 }
1093 }
1095
1096 fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1097 }
1099
1100 fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1101 }
1103
1104 fn hook(
1105 &mut self,
1106 _params: &vte::Params,
1107 _intermediates: &[u8],
1108 _ignore: bool,
1109 _action: char,
1110 ) {
1111 }
1113
1114 fn put(&mut self, _byte: u8) {
1115 }
1117
1118 fn unhook(&mut self) {
1119 }
1121 }
1122
1123 let mut parser = vte::Parser::new();
1124 let mut stripper = Stripper {
1125 result: String::with_capacity(s.len()),
1126 };
1127 parser.advance(&mut stripper, s.as_bytes());
1128 stripper.result
1129}