1use crate::cli::json_output::{JsonLogEntry, print_json};
2use crate::daemon_id::DaemonId;
3use crate::log_store::sqlite::LOG_STORE;
4use crate::log_store::{FieldFilter, LogEntry, LogQuery, LogStore, MessageFilter};
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::fmt::Write as _;
16use std::io::{self, IsTerminal, Write};
17use std::process::{Child, Command, Stdio};
18use std::time::Duration;
19
20struct PagerConfig {
22 command: String,
23 args: Vec<String>,
24}
25
26impl PagerConfig {
27 fn new(start_at_end: bool) -> Self {
30 let command = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
31 let args = Self::build_args(&command, start_at_end);
32 Self { command, args }
33 }
34
35 fn build_args(pager: &str, start_at_end: bool) -> Vec<String> {
36 let mut args = vec![];
37 if pager == "less" {
38 args.push("-R".to_string());
39 if start_at_end {
40 args.push("+G".to_string());
41 }
42 }
43 args
44 }
45
46 fn spawn_piped(&self) -> io::Result<Child> {
48 Command::new(&self.command)
49 .args(&self.args)
50 .stdin(Stdio::piped())
51 .spawn()
52 }
53}
54
55const KNOWN_FIELD_KEYS: &[&str] = &[
58 "level",
59 "severity",
60 "lvl",
61 "PRIORITY",
62 "@level",
63 "msg",
64 "message",
65 "event",
66 "@message",
67 "logger",
68 "name",
69 "component",
70 "module",
71 "timestamp",
72 "ts",
73 "time",
74 "@timestamp",
75];
76
77fn level_badge(level: &str) -> String {
82 let label = match level {
83 "error" => console::style("ERR").red().bold(),
84 "warn" => console::style("WRN").yellow().bold(),
85 "info" => console::style("INF").cyan().bold(),
86 "debug" => console::style("DBG").magenta().bold(),
87 "trace" => console::style("TRC").dim().bold(),
88 _ => return String::new(),
89 };
90 format!("{}{}{}", ndim("["), label, ndim("]"))
91}
92
93fn format_field_value(value: &serde_json::Value) -> String {
99 match value {
100 serde_json::Value::String(s) => {
101 let needs_quotes = s.is_empty()
102 || s.contains(' ')
103 || s.contains('=')
104 || s.contains('\t')
105 || s == "true"
106 || s == "false"
107 || s == "null"
108 || s.parse::<f64>().is_ok();
109 if needs_quotes {
110 format!("\"{s}\"")
111 } else {
112 s.clone()
113 }
114 }
115 serde_json::Value::Number(n) => console::style(n).green().to_string(),
116 serde_json::Value::Bool(true) => console::style("true").yellow().to_string(),
117 serde_json::Value::Bool(false) => console::style("false").red().to_string(),
118 serde_json::Value::Null => console::style("null").dim().to_string(),
119 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
120 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
121 }
122 }
123}
124
125fn format_fields(fields_json: &str) -> String {
127 let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(fields_json) else {
128 return String::new();
129 };
130 let mut parts = Vec::new();
131 for (key, value) in &obj {
132 if KNOWN_FIELD_KEYS.contains(&key.as_str()) {
133 continue;
134 }
135 let key_styled = console::style(key.as_str()).blue().to_string();
136 let value_styled = format_field_value(value);
137 parts.push(format!("{key_styled}={value_styled}"));
138 }
139 parts.join(" ")
140}
141
142#[allow(clippy::too_many_arguments)]
154fn write_formatted_log(
155 w: &mut dyn Write,
156 entry: &LogEntry,
157 date: &str,
158 single_daemon: bool,
159 strip_ansi: bool,
160 show_timestamp: bool,
161 raw: bool,
162) -> io::Result<()> {
163 let clean_msg = strip_pty_controls(&entry.message);
167 let raw_msg: std::borrow::Cow<'_, str> = if strip_ansi {
168 console::strip_ansi_codes(&clean_msg)
169 } else {
170 std::borrow::Cow::Owned(clean_msg)
171 };
172
173 let mut out = String::with_capacity(256);
174
175 if raw {
176 if show_timestamp {
180 out.push_str(&ndim(date).to_string());
181 out.push(' ');
182 }
183 if !single_daemon {
184 let colors_on = !strip_ansi && console::colors_enabled();
185 out.push_str(&colored_id_label(&entry.daemon_id, colors_on));
186 out.push(' ');
187 }
188 out.push_str(&raw_msg);
189 } else if entry.fields_json.is_some() {
190 let level = entry.level.as_deref();
192 let accent = match level {
193 Some("error") => "error",
194 Some("warn") => "warn",
195 _ => "",
196 };
197
198 if show_timestamp {
200 let ts = match accent {
201 "error" => console::style(date).red().to_string(),
202 "warn" => console::style(date).yellow().to_string(),
203 _ => ndim(date).to_string(),
204 };
205 out.push_str(&ts);
206 out.push(' ');
207 }
208
209 if !single_daemon {
211 let colors_on = !strip_ansi && console::colors_enabled();
212 out.push_str(&colored_id_label(&entry.daemon_id, colors_on));
213 out.push(' ');
214 }
215
216 let mut need_sep = false;
217
218 if let Some(lvl) = level {
220 let badge = level_badge(lvl);
221 if !badge.is_empty() {
222 out.push_str(&badge);
223 need_sep = true;
224 }
225 }
226
227 let sep = ndim(":").to_string();
229 let arrow = ndim(" > ").to_string();
230
231 if let Some(logger) = &entry.logger {
233 if need_sep {
234 out.push(' ');
235 }
236 out.push_str(&console::style(logger).italic().dim().to_string());
237 out.push_str(&sep);
238 need_sep = true;
239 }
240
241 let msg_cow = entry.msg.as_deref().filter(|s| !s.is_empty()).map(|s| {
243 let cleaned = strip_pty_controls(s);
244 if strip_ansi {
245 std::borrow::Cow::<str>::Owned(console::strip_ansi_codes(&cleaned).to_string())
246 } else {
247 std::borrow::Cow::<str>::Owned(cleaned)
248 }
249 });
250 let fields_str = entry
251 .fields_json
252 .as_deref()
253 .map(format_fields)
254 .filter(|s| !s.is_empty());
255
256 if msg_cow.is_some() || fields_str.is_some() {
257 if need_sep {
258 out.push(' ');
259 }
260 if let Some(msg) = &msg_cow {
261 let styled = match accent {
262 "error" => console::style(msg.as_ref()).red().bold().to_string(),
263 "warn" => console::style(msg.as_ref()).yellow().bold().to_string(),
264 _ => console::style(msg.as_ref()).bold().to_string(),
265 };
266 out.push_str(&styled);
267 if fields_str.is_some() {
268 out.push_str(&arrow);
269 }
270 }
271 if let Some(fields) = &fields_str {
272 out.push_str(fields);
273 }
274 } else if !need_sep {
275 out.push_str(&raw_msg);
278 }
279 } else {
280 if show_timestamp {
282 out.push_str(&ndim(date).to_string());
283 out.push(' ');
284 }
285 if !single_daemon {
286 let colors_on = !strip_ansi && console::colors_enabled();
287 out.push_str(&colored_id_label(&entry.daemon_id, colors_on));
288 out.push(' ');
289 }
290 out.push_str(&raw_msg);
291 }
292
293 out.push('\n');
294 w.write_all(out.as_bytes())
295}
296
297pub fn colored_id_label(id: &str, colors_enabled: bool) -> String {
300 if !colors_enabled {
301 return format!("[{}]", id);
302 }
303 let colors: [u8; 4] = [34, 35, 36, 32]; let mut h: usize = 0x811C_9DC5; for b in id.bytes() {
308 h = h.wrapping_mul(0x0100_0193).wrapping_add(b as usize);
309 }
310 let color = colors[h % colors.len()];
311 format!("\x1b[{color}m[{id}]\x1b[0m")
312}
313
314#[derive(Debug, usage_rs::Args)]
316#[usage(
317 verbatim_doc_comment,
318 long_about = "\
319Displays logs for daemon(s)
320
321Shows logs from managed daemons. Logs are stored in the pitchfork logs directory
322and include timestamps for filtering.
323
324Examples:
325
326 pitchfork logs api Show all logs for 'api' (paged if needed)
327 pitchfork logs api worker Show logs for multiple daemons
328 pitchfork logs Show logs for all daemons
329 pitchfork logs api -n 50 Show last 50 lines
330 pitchfork logs api --follow Follow logs in real-time
331 pitchfork logs api --since '2024-01-15 10:00:00'
332 Show logs since a specific time (forward)
333 pitchfork logs api --since '10:30:00'
334 Show logs since 10:30:00 today
335 pitchfork logs api --since '10:30' --until '12:00'
336 Show logs since 10:30:00 until 12:00:00 today
337 pitchfork logs api --since 5min Show logs from last 5 minutes
338 pitchfork logs api --raw Output raw log lines without formatting
339 pitchfork logs api --raw -n 100 Output last 100 raw log lines
340 pitchfork logs api --clear Delete logs for 'api'
341 pitchfork logs --clear Delete logs for all daemons"
342)]
343pub struct Logs {
344 id: Vec<String>,
346
347 #[usage(short, long, effect = "destructive")]
349 clear: bool,
350
351 #[usage(short)]
356 n: Option<usize>,
357
358 #[usage(short = 't', short = 'f', long, visible_alias = "follow")]
360 tail: bool,
361
362 #[usage(short = 's', long)]
369 since: Option<String>,
370
371 #[usage(short = 'u', long)]
377 until: Option<String>,
378
379 #[usage(long)]
381 no_pager: bool,
382
383 #[usage(long)]
385 raw: bool,
386
387 #[usage(long, conflicts = "raw", conflicts = "tail")]
389 json: bool,
390
391 #[usage(long)]
395 grep: Vec<String>,
396
397 #[usage(long)]
399 regex: Option<String>,
400
401 #[usage(long)]
403 case_sensitive: bool,
404
405 #[usage(long)]
411 level: Option<String>,
412
413 #[usage(long, value_name = "KEY=VALUE")]
418 field: Vec<String>,
419
420 #[usage(long, value_name = "EXPR")]
426 jq: Option<String>,
427
428 #[usage(long)]
430 no_timestamp: bool,
431}
432
433impl Logs {
434 pub async fn run(&self) -> Result<()> {
435 migrate_legacy_log_dirs();
436
437 let resolved_ids: Vec<DaemonId> = if self.id.is_empty() {
438 get_all_daemon_ids()?
439 } else {
440 PitchforkToml::resolve_ids(&self.id)?
441 };
442
443 if self.clear {
444 LOG_STORE.clear(&resolved_ids)?;
445 return Ok(());
446 }
447
448 let from = if let Some(since) = self.since.as_ref() {
449 Some(parse_time_input(since, true)?)
450 } else {
451 None
452 };
453 let to = if let Some(until) = self.until.as_ref() {
454 Some(parse_time_input(until, false)?)
455 } else {
456 None
457 };
458
459 let message_filters = self.build_message_filters()?;
460 let field_filters = self.build_field_filters()?;
461
462 let jq_filter = match self.jq.as_deref() {
464 Some(expr) => Some(crate::log_jq::JqFilter::new(expr)?),
465 None => None,
466 };
467
468 if self.json {
469 return self.output_json(
470 &resolved_ids,
471 from,
472 to,
473 message_filters,
474 field_filters,
475 jq_filter.as_ref(),
476 );
477 }
478
479 let single_daemon = resolved_ids.len() == 1 && !self.id.is_empty();
484 let show_timestamp = settings().logs.timestamp && !self.no_timestamp && !self.raw;
485 let has_time_filter = from.is_some() || to.is_some();
486
487 self.query_and_output(
488 &resolved_ids,
489 from,
490 to,
491 message_filters.clone(),
492 field_filters.clone(),
493 jq_filter.as_ref(),
494 single_daemon,
495 has_time_filter,
496 show_timestamp,
497 )?;
498
499 if self.tail {
500 tail_logs(
501 &resolved_ids,
502 single_daemon,
503 true,
504 message_filters,
505 field_filters,
506 jq_filter.as_ref(),
507 show_timestamp,
508 self.raw,
509 )
510 .await?;
511 }
512
513 Ok(())
514 }
515
516 fn query_limit(&self, has_time_filter: bool, has_jq_filter: bool) -> Option<usize> {
522 if has_time_filter || has_jq_filter {
523 None
524 } else {
525 self.n
526 }
527 }
528
529 fn query_entries(
536 &self,
537 mut opts: LogQuery,
538 jq_filter: Option<&crate::log_jq::JqFilter>,
539 has_time_filter: bool,
540 ) -> Result<Vec<LogEntry>> {
541 let Some(jq) = jq_filter else {
542 return LOG_STORE.query(&opts);
543 };
544 match self.n {
545 Some(n) if !has_time_filter => last_matches(
546 n,
547 |window| {
548 opts.limit = Some(window);
549 LOG_STORE.query(&opts)
550 },
551 |rows| jq.filter(rows),
552 ),
553 _ => Ok(jq.filter(LOG_STORE.query(&opts)?)),
554 }
555 }
556
557 fn build_message_filters(&self) -> Result<Vec<MessageFilter>> {
558 if self.case_sensitive && self.grep.is_empty() {
559 warn!("--case-sensitive has no effect without --grep");
560 }
561 let mut filters = Vec::new();
562 for pattern in &self.grep {
563 filters.push(MessageFilter::Contains {
564 pattern: pattern.clone(),
565 case_sensitive: self.case_sensitive,
566 });
567 }
568 if let Some(pattern) = self.regex.as_ref() {
569 let _ = regex::Regex::new(pattern)
572 .into_diagnostic()
573 .map_err(|e| miette::miette!("invalid regex pattern: {e}"))?;
574 filters.push(MessageFilter::Regex {
575 pattern: pattern.clone(),
576 });
577 }
578 Ok(filters)
579 }
580
581 fn build_field_filters(&self) -> Result<Vec<FieldFilter>> {
582 let mut filters = Vec::new();
583 if let Some(level) = self.level.as_ref() {
584 let normalized = crate::log_parse::normalize_level_str(level).ok_or_else(|| {
587 miette::miette!(
588 "invalid level '{level}'; expected one of: \
589 error, err, fatal, critical, panic, alert, emerg, \
590 warn, warning, info, inf, information, notice, \
591 debug, dbg, trace, trc"
592 )
593 })?;
594 filters.push(FieldFilter::LevelMin(normalized));
595 }
596 for pair in &self.field {
597 let (key, value) = pair
598 .split_once('=')
599 .ok_or_else(|| miette::miette!("--field expects KEY=VALUE, got: {pair}"))?;
600 if key.is_empty() {
601 miette::bail!("--field key cannot be empty: {pair}");
602 }
603 if !key
606 .chars()
607 .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
608 {
609 miette::bail!(
610 "--field key may only contain alphanumeric, underscore, and dot; got: {key}"
611 );
612 }
613 filters.push(FieldFilter::FieldEq {
614 key: key.to_string(),
615 value: value.to_string(),
616 });
617 }
618 Ok(filters)
619 }
620
621 #[allow(clippy::too_many_arguments)]
627 fn query_and_output(
628 &self,
629 resolved_ids: &[DaemonId],
630 from: Option<DateTime<Local>>,
631 to: Option<DateTime<Local>>,
632 message_filters: Vec<MessageFilter>,
633 field_filters: Vec<FieldFilter>,
634 jq_filter: Option<&crate::log_jq::JqFilter>,
635 single_daemon: bool,
636 has_time_filter: bool,
637 show_timestamp: bool,
638 ) -> Result<()> {
639 let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
640
641 let opts = LogQuery {
642 daemon_ids,
643 from,
644 to,
645 limit: self.query_limit(has_time_filter, jq_filter.is_some()),
646 order_desc: !has_time_filter,
647 after_id: None,
648 before_id: None,
649 message_filters,
650 field_filters,
651 include_structured: jq_filter.is_some() || !self.raw,
652 };
653 let mut entries = self.query_entries(opts, jq_filter, has_time_filter)?;
654
655 if has_time_filter {
657 if let Some(n) = self.n {
658 let len = entries.len();
659 if len > n {
660 entries = entries.split_off(len - n);
661 }
662 }
663 } else {
664 entries.reverse();
665 }
666
667 if entries.is_empty() {
668 return Ok(());
669 }
670
671 let strip_ansi = self.raw || !console::colors_enabled();
672 let use_pager = !self.tail && !self.no_pager && should_use_pager(entries.len());
673 let ts_format = &settings().logs.timestamp_format;
674
675 let mut date_buf = String::with_capacity(ts_format.len() + 6);
677
678 let mut write_entries = |w: &mut dyn Write| -> io::Result<()> {
679 for entry in &entries {
680 date_buf.clear();
681 write!(date_buf, "{}", entry.timestamp.format(ts_format))
682 .map_err(io::Error::other)?;
683 write_formatted_log(
684 w,
685 entry,
686 &date_buf,
687 single_daemon,
688 strip_ansi,
689 show_timestamp,
690 self.raw,
691 )?;
692 }
693 Ok(())
694 };
695
696 if use_pager {
697 let pager_config = PagerConfig::new(!has_time_filter);
698 match pager_config.spawn_piped() {
699 Ok(mut child) => {
700 if let Some(stdin) = child.stdin.as_mut() {
701 if let Err(e) = write_entries(stdin) {
702 debug!("pager write error: {e}");
703 }
704 let _ = child.wait();
705 } else {
706 debug!("Failed to get pager stdin, falling back to direct output");
707 let stdout = io::stdout();
708 let mut buf = io::BufWriter::new(stdout.lock());
709 write_entries(&mut buf).into_diagnostic()?;
710 }
711 }
712 Err(e) => {
713 debug!("Failed to spawn pager: {e}, falling back to direct output");
714 let stdout = io::stdout();
715 let mut buf = io::BufWriter::new(stdout.lock());
716 write_entries(&mut buf).into_diagnostic()?;
717 }
718 }
719 } else {
720 let stdout = io::stdout();
721 let mut buf = io::BufWriter::new(stdout.lock());
722 write_entries(&mut buf).into_diagnostic()?;
723 }
724
725 Ok(())
726 }
727
728 fn output_json(
729 &self,
730 resolved_ids: &[DaemonId],
731 from: Option<DateTime<Local>>,
732 to: Option<DateTime<Local>>,
733 message_filters: Vec<MessageFilter>,
734 field_filters: Vec<FieldFilter>,
735 jq_filter: Option<&crate::log_jq::JqFilter>,
736 ) -> Result<()> {
737 let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
738 let has_time_filter = from.is_some() || to.is_some();
739
740 let opts = LogQuery {
741 daemon_ids,
742 from,
743 to,
744 limit: self.query_limit(has_time_filter, jq_filter.is_some()),
745 order_desc: !has_time_filter,
746 after_id: None,
747 before_id: None,
748 message_filters,
749 field_filters,
750 include_structured: true,
751 };
752 let entries = self.query_entries(opts, jq_filter, has_time_filter)?;
753
754 let mut entries: Vec<_> = if has_time_filter {
758 entries
759 } else {
760 entries.into_iter().rev().collect()
761 };
762
763 if has_time_filter
766 && let Some(n) = self.n
767 && entries.len() > n
768 {
769 entries = entries.split_off(entries.len() - n);
770 }
771
772 let json_entries: Vec<JsonLogEntry> = entries.into_iter().map(Into::into).collect();
773
774 print_json(&json_entries)
775 }
776}
777
778const MIN_MATCH_WINDOW: usize = 1000;
780
781fn last_matches<T>(
788 n: usize,
789 mut fetch: impl FnMut(usize) -> Result<Vec<T>>,
790 filter: impl Fn(Vec<T>) -> Vec<T>,
791) -> Result<Vec<T>> {
792 let mut window = n.max(MIN_MATCH_WINDOW);
793 loop {
794 let rows = fetch(window)?;
795 let exhausted = rows.len() < window;
796 let mut matches = filter(rows);
797 if matches.len() >= n || exhausted {
798 matches.truncate(n);
799 return Ok(matches);
800 }
801 window = window.saturating_mul(2);
802 }
803}
804
805fn should_use_pager(line_count: usize) -> bool {
806 if !io::stdout().is_terminal() {
807 return false;
808 }
809
810 let terminal_height = get_terminal_height().unwrap_or(24);
811 line_count > terminal_height
812}
813
814fn get_terminal_height() -> Option<usize> {
815 if let Ok(rows) = std::env::var("LINES")
816 && let Ok(h) = rows.parse::<usize>()
817 {
818 return Some(h);
819 }
820
821 crossterm::terminal::size().ok().map(|(_, h)| h as usize)
822}
823
824fn migrate_legacy_log_dirs() {
834 let known_safe_paths = known_daemon_safe_paths();
835 let dirs = match xx::file::ls(&*env::PITCHFORK_LOGS_DIR) {
836 Ok(d) => d,
837 Err(_) => return,
838 };
839 for dir in dirs {
840 if dir.starts_with(".") || !dir.is_dir() {
841 continue;
842 }
843 let name = match dir.file_name().map(|f| f.to_string_lossy().to_string()) {
844 Some(n) => n,
845 None => continue,
846 };
847 if name == "pitchfork" {
849 continue;
850 }
851 if name.contains("--") {
854 if DaemonId::from_safe_path(&name).is_ok() {
857 continue;
858 }
859 if known_safe_paths.contains(&name) {
862 continue;
863 }
864 warn!(
865 "Skipping invalid legacy log directory '{name}': contains '--' but is not a valid daemon safe-path"
866 );
867 continue;
868 }
869
870 let old_log = dir.join(format!("{name}.log"));
873 if !old_log.exists() {
874 continue;
875 }
876 if DaemonId::try_new("legacy", &name).is_err() {
877 warn!("Skipping invalid legacy log directory '{name}': not a valid daemon ID");
878 continue;
879 }
880
881 let new_name = format!("legacy--{name}");
882 let new_dir = env::PITCHFORK_LOGS_DIR.join(&new_name);
883 if new_dir.exists() {
885 continue;
886 }
887 if std::fs::rename(&dir, &new_dir).is_err() {
888 continue;
889 }
890 let old_log = new_dir.join(format!("{name}.log"));
892 let new_log = new_dir.join(format!("{new_name}.log"));
893 if old_log.exists() {
894 let _ = std::fs::rename(&old_log, &new_log);
895 }
896 debug!("Migrated legacy log dir '{name}' → '{new_name}'");
897 }
898}
899
900fn known_daemon_safe_paths() -> BTreeSet<String> {
901 let mut out = BTreeSet::new();
902
903 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
904 Ok(state) => {
905 for id in state.daemons.keys() {
906 out.insert(id.safe_path());
907 }
908 }
909 Err(e) => {
910 warn!("Failed to read state while checking known daemon IDs: {e}");
911 }
912 }
913
914 match PitchforkToml::all_merged() {
915 Ok(config) => {
916 for id in config.daemons.keys() {
917 out.insert(id.safe_path());
918 }
919 }
920 Err(e) => {
921 warn!("Failed to read config while checking known daemon IDs: {e}");
922 }
923 }
924
925 out
926}
927
928fn get_all_daemon_ids() -> Result<Vec<DaemonId>> {
929 let mut ids = BTreeSet::new();
930
931 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
932 Ok(state) => ids.extend(state.daemons.keys().cloned()),
933 Err(e) => warn!("Failed to read state for log daemon discovery: {e}"),
934 }
935
936 match PitchforkToml::all_merged() {
937 Ok(config) => ids.extend(config.daemons.keys().cloned()),
938 Err(e) => warn!("Failed to read config for log daemon discovery: {e}"),
939 }
940
941 let logged_ids: std::collections::HashSet<String> =
942 LOG_STORE.list_daemon_ids()?.into_iter().collect();
943 Ok(ids
944 .into_iter()
945 .filter(|id| logged_ids.contains(&id.qualified()))
946 .collect())
947}
948
949#[allow(clippy::too_many_arguments)]
950pub async fn tail_logs(
951 names: &[DaemonId],
952 single_daemon: bool,
953 start_from_end: bool,
954 message_filters: Vec<MessageFilter>,
955 field_filters: Vec<FieldFilter>,
956 jq_filter: Option<&crate::log_jq::JqFilter>,
957 show_timestamp: bool,
958 raw: bool,
959) -> Result<()> {
960 let strip_ansi = raw || !console::colors_enabled();
962
963 let mut states: std::collections::HashMap<String, i64> = names
964 .iter()
965 .map(|id| {
966 let since = if start_from_end {
967 LOG_STORE.last_id(id).unwrap_or(None).unwrap_or(0)
971 } else {
972 0
973 };
974 (id.qualified(), since)
975 })
976 .collect();
977
978 let interval = tokio::time::interval(Duration::from_millis(200));
979 tokio::pin!(interval);
980
981 loop {
982 interval.tick().await;
983
984 let mut out = vec![];
985 for id in names {
986 let after_id = states.get(&id.qualified()).copied();
987 match LOG_STORE.query(&LogQuery {
988 daemon_ids: vec![id.qualified()],
989 from: None,
990 to: None,
991 limit: None,
992 order_desc: false,
993 after_id,
994 before_id: None,
995 message_filters: message_filters.clone(),
996 field_filters: field_filters.clone(),
997 include_structured: jq_filter.is_some() || !raw,
998 }) {
999 Ok(raw_entries) => {
1000 let last_raw_id = raw_entries.last().map(|e| e.id);
1004
1005 let entries = match jq_filter {
1007 Some(jq) => jq.filter(raw_entries),
1008 None => raw_entries,
1009 };
1010 out.extend(entries);
1011 let has_sql_filter = !message_filters.is_empty() || !field_filters.is_empty();
1023 let new_cursor = if let Some(id) = last_raw_id {
1024 Some(id)
1025 } else if has_sql_filter || jq_filter.is_some() {
1026 LOG_STORE.last_id(id).ok().flatten()
1027 } else {
1028 None
1029 };
1030 if let Some(last_id) = new_cursor {
1031 states.insert(id.qualified(), last_id);
1032 }
1033 }
1034 Err(e) => {
1035 error!("Failed to tail logs for {}: {e}", id.qualified());
1036 }
1037 }
1038 }
1039
1040 if !out.is_empty() {
1041 let out: Vec<LogEntry> = if single_daemon {
1044 out
1045 } else {
1046 out.into_iter()
1047 .sorted_by(|a, b| a.timestamp.cmp(&b.timestamp).then(a.id.cmp(&b.id)))
1048 .collect()
1049 };
1050 let stdout = io::stdout();
1051 let mut buf = io::BufWriter::new(stdout.lock());
1052 let ts_format = &settings().logs.timestamp_format;
1053 let mut date_buf = String::with_capacity(ts_format.len() + 6);
1054 for entry in &out {
1055 date_buf.clear();
1056 write!(date_buf, "{}", entry.timestamp.format(ts_format))
1057 .map_err(io::Error::other)
1058 .into_diagnostic()?;
1059 write_formatted_log(
1060 &mut buf,
1061 entry,
1062 &date_buf,
1063 single_daemon,
1064 strip_ansi,
1065 show_timestamp,
1066 raw,
1067 )
1068 .into_diagnostic()?;
1069 }
1070 }
1071 }
1072}
1073
1074fn parse_datetime(s: &str) -> Result<DateTime<Local>> {
1075 let naive_dt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").into_diagnostic()?;
1076 Local
1077 .from_local_datetime(&naive_dt)
1078 .single()
1079 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'. ", s))
1080}
1081
1082fn parse_time_input(s: &str, is_since: bool) -> Result<DateTime<Local>> {
1088 let s = s.trim();
1089
1090 if let Ok(dt) = parse_datetime(s) {
1092 return Ok(dt);
1093 }
1094
1095 if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M") {
1097 return Local
1098 .from_local_datetime(&naive_dt)
1099 .single()
1100 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s));
1101 }
1102
1103 if let Ok(time) = parse_time_only(s) {
1107 let now = Local::now();
1108 let today = now.date_naive();
1109 let mut naive_dt = NaiveDateTime::new(today, time);
1110 let mut dt = Local
1111 .from_local_datetime(&naive_dt)
1112 .single()
1113 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
1114
1115 if is_since
1118 && dt > now
1119 && let Some(yesterday) = today.pred_opt()
1120 {
1121 naive_dt = NaiveDateTime::new(yesterday, time);
1122 dt = Local
1123 .from_local_datetime(&naive_dt)
1124 .single()
1125 .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
1126 }
1127 return Ok(dt);
1128 }
1129
1130 if let Ok(duration) = humantime::parse_duration(s) {
1131 let now = Local::now();
1132 let target = now - chrono::Duration::from_std(duration).into_diagnostic()?;
1133 return Ok(target);
1134 }
1135
1136 Err(miette::miette!(
1137 "Invalid time format: '{}'. Expected formats:\n\
1138 - Full datetime: \"YYYY-MM-DD HH:MM:SS\" or \"YYYY-MM-DD HH:MM\"\n\
1139 - Time only: \"HH:MM:SS\" or \"HH:MM\" (uses today's date)\n\
1140 - Relative time: \"5min\", \"2h\", \"1d\" (e.g., last 5 minutes)",
1141 s
1142 ))
1143}
1144
1145fn parse_time_only(s: &str) -> Result<NaiveTime> {
1146 if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
1147 return Ok(time);
1148 }
1149
1150 if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M") {
1151 return Ok(time);
1152 }
1153
1154 Err(miette::miette!("Invalid time format: '{}'", s))
1155}
1156
1157pub fn print_error_logs_block(log_lines: &[(String, String, String)]) {
1167 if log_lines.is_empty() {
1168 return;
1169 }
1170
1171 let is_tty = std::io::stderr().is_terminal();
1172 let format_msg = |msg: &str| -> String {
1173 let stripped = strip_pty_controls(msg);
1174 if is_tty {
1175 stripped
1176 } else {
1177 console::strip_ansi_codes(&stripped).to_string()
1178 }
1179 };
1180
1181 let tag = estyle(" ERROR LOGS ").white().on_red();
1182 eprintln!("\n{tag}");
1183
1184 let unique_ids: BTreeSet<&str> = log_lines.iter().map(|(_, id, _)| id.as_str()).collect();
1186 let show_id = unique_ids.len() > 1;
1187
1188 if show_id {
1189 for (date, id, msg) in log_lines {
1190 let time = date.split(' ').nth(1).unwrap_or(date);
1191 let colored = colored_id_label(id, is_tty && console::colors_enabled_stderr());
1192 eprintln!(
1193 "{} {} {}",
1194 estyle(time).red().dim(),
1195 colored,
1196 format_msg(msg)
1197 );
1198 }
1199 } else {
1200 for (date, _, msg) in log_lines {
1201 let time = date.split(' ').nth(1).unwrap_or(date);
1202 eprintln!("{} {}", estyle(time).red().dim(), format_msg(msg));
1203 }
1204 }
1205}
1206
1207pub enum ReadyCheckType {
1209 Output(String),
1210 Http(String),
1211 Port(u16),
1212 Cmd(String),
1213 Delay(u64),
1214 Completion,
1216 Default,
1217}
1218
1219impl std::fmt::Display for ReadyCheckType {
1220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1221 match self {
1222 ReadyCheckType::Output(pattern) => write!(f, "output matching '{pattern}'"),
1223 ReadyCheckType::Http(url) => write!(f, "HTTP {url}"),
1224 ReadyCheckType::Port(port) => write!(f, "TCP port {port}"),
1225 ReadyCheckType::Cmd(cmd) => write!(f, "command '{cmd}'"),
1226 ReadyCheckType::Delay(secs) => write!(f, "delay ({secs}s)"),
1227 ReadyCheckType::Completion => write!(f, "completion"),
1228 ReadyCheckType::Default => write!(f, "default readiness check"),
1229 }
1230 }
1231}
1232
1233pub fn create_ready_check_job(
1239 daemon_id: &DaemonId,
1240 check_type: &ReadyCheckType,
1241) -> std::sync::Arc<clx::progress::ProgressJob> {
1242 use clx::progress::{ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
1243
1244 let is_tty = std::io::stderr().is_terminal();
1245 let colors_enabled = is_tty && console::colors_enabled_stderr();
1246 let id_label = colored_id_label(&daemon_id.qualified(), colors_enabled);
1247 let show_ts = crate::settings::settings().general.startup_log_timestamps;
1248
1249 let prefix = if show_ts {
1253 edim(chrono::Local::now().format("%H:%M:%S").to_string()).to_string()
1256 } else {
1257 "{{spinner()}}".to_string()
1258 };
1259
1260 ProgressJobBuilder::new()
1261 .body(format!(
1262 "{} {} waiting for {{{{ check_type }}}}...",
1263 prefix, id_label
1264 ))
1265 .prop("check_type", &check_type.to_string())
1266 .status(ProgressStatus::Running)
1267 .on_done(ProgressJobDoneBehavior::Keep)
1268 .start()
1269}
1270
1271pub fn collect_startup_logs(
1276 daemon_id: &DaemonId,
1277 from: DateTime<Local>,
1278) -> Result<Vec<(String, String, String)>> {
1279 let entries = LOG_STORE.query(&LogQuery {
1280 daemon_ids: vec![daemon_id.qualified()],
1281 from: Some(from),
1282 to: None,
1283 limit: None,
1284 order_desc: false,
1285 after_id: None,
1286 before_id: None,
1287 message_filters: Vec::new(),
1288 field_filters: Vec::new(),
1289 include_structured: false,
1290 })?;
1291 let log_lines = entries
1292 .into_iter()
1293 .map(|e| {
1294 let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
1295 (ts, e.daemon_id, e.message)
1296 })
1297 .collect();
1298
1299 Ok(log_lines)
1300}
1301
1302pub fn stream_startup_logs(
1308 daemon_id: &DaemonId,
1309 job: std::sync::Arc<clx::progress::ProgressJob>,
1310) -> (
1311 tokio::sync::watch::Sender<bool>,
1312 tokio::task::JoinHandle<()>,
1313) {
1314 let (tx, mut rx) = tokio::sync::watch::channel(false);
1315 let id = daemon_id.clone();
1316
1317 let show_ts = crate::settings::settings().general.startup_log_timestamps;
1318
1319 let anchor_id: i64 = LOG_STORE
1324 .query(&LogQuery {
1325 daemon_ids: vec![id.qualified()],
1326 limit: Some(1),
1327 order_desc: true,
1328 ..Default::default()
1329 })
1330 .ok()
1331 .and_then(|entries| entries.last().map(|e| e.id))
1332 .unwrap_or(0);
1333
1334 let handle = tokio::spawn(async move {
1335 let is_tty = std::io::stderr().is_terminal();
1336 let colors_enabled = is_tty && console::colors_enabled_stderr();
1337 let id_label = colored_id_label(&id.qualified(), colors_enabled);
1338 let prefix = if show_ts {
1339 String::new()
1340 } else {
1341 edim("•").to_string()
1342 };
1343
1344 let mut last_id = anchor_id;
1345
1346 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1348 for entry in &entries {
1349 let time = entry.timestamp.format("%H:%M:%S").to_string();
1350 let msg = strip_pty_controls(&entry.message);
1351 let msg = if is_tty {
1352 msg
1353 } else {
1354 console::strip_ansi_codes(&msg).to_string()
1355 };
1356 let line_prefix = if show_ts {
1357 edim(time).to_string()
1358 } else {
1359 prefix.clone()
1360 };
1361 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1362 }
1363 if let Some(last) = entries.last() {
1364 last_id = last.id;
1365 }
1366 }
1367
1368 loop {
1369 tokio::select! {
1370 _ = tokio::time::sleep(Duration::from_millis(200)) => {
1371 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1372 for entry in &entries {
1373 let time = entry.timestamp.format("%H:%M:%S").to_string();
1374 let msg = strip_pty_controls(&entry.message);
1375 let msg = if is_tty {
1376 msg
1377 } else {
1378 console::strip_ansi_codes(&msg).to_string()
1379 };
1380 let line_prefix = if show_ts {
1381 edim(time).to_string()
1382 } else {
1383 prefix.clone()
1384 };
1385 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1386 }
1387 if let Some(last) = entries.last() {
1388 last_id = last.id;
1389 }
1390 }
1391 }
1392 _ = rx.changed() => {
1393 break;
1394 }
1395 }
1396 }
1397
1398 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1400 for entry in &entries {
1401 let time = entry.timestamp.format("%H:%M:%S").to_string();
1402 let msg = strip_pty_controls(&entry.message);
1403 let msg = if is_tty {
1404 msg
1405 } else {
1406 console::strip_ansi_codes(&msg).to_string()
1407 };
1408 let line_prefix = if show_ts {
1409 edim(time).to_string()
1410 } else {
1411 prefix.clone()
1412 };
1413 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1414 }
1415 }
1416 });
1417
1418 (tx, handle)
1419}
1420
1421fn strip_pty_controls(s: &str) -> String {
1426 struct Stripper {
1427 result: String,
1428 }
1429
1430 impl vte::Perform for Stripper {
1431 fn print(&mut self, c: char) {
1432 self.result.push(c);
1433 }
1434
1435 fn execute(&mut self, byte: u8) {
1436 if byte == b'\n' || byte == b'\t' {
1438 self.result.push(byte as char);
1439 }
1440 }
1441
1442 fn csi_dispatch(
1443 &mut self,
1444 params: &vte::Params,
1445 _intermediates: &[u8],
1446 _ignore: bool,
1447 action: char,
1448 ) {
1449 if action == 'm' {
1451 self.result.push_str("\x1b[");
1452 let mut first = true;
1453 for sub in params.iter() {
1454 if !first {
1455 self.result.push(';');
1456 }
1457 first = false;
1458 for (i, &p) in sub.iter().enumerate() {
1459 if i > 0 {
1460 self.result.push(':');
1461 }
1462 self.result.push_str(&p.to_string());
1463 }
1464 }
1465 self.result.push('m');
1466 }
1467 }
1469
1470 fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1471 }
1473
1474 fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1475 }
1477
1478 fn hook(
1479 &mut self,
1480 _params: &vte::Params,
1481 _intermediates: &[u8],
1482 _ignore: bool,
1483 _action: char,
1484 ) {
1485 }
1487
1488 fn put(&mut self, _byte: u8) {
1489 }
1491
1492 fn unhook(&mut self) {
1493 }
1495 }
1496
1497 let mut parser = vte::Parser::new();
1498 let mut stripper = Stripper {
1499 result: String::with_capacity(s.len()),
1500 };
1501 parser.advance(&mut stripper, s.as_bytes());
1502 stripper.result
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508
1509 fn fetch_from(rows: &[u32], reads: &mut Vec<usize>) -> impl FnMut(usize) -> Result<Vec<u32>> {
1511 move |window| {
1512 reads.push(window);
1513 Ok(rows.iter().copied().take(window).collect())
1514 }
1515 }
1516
1517 #[test]
1518 fn last_matches_reads_one_window_when_the_newest_rows_match() {
1519 let rows: Vec<u32> = (0..100_000).collect();
1520 let mut reads = Vec::new();
1521 let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1522 r.into_iter().filter(|i| i % 10 == 0).collect()
1523 })
1524 .unwrap();
1525 assert_eq!(found, vec![0, 10]);
1526 assert_eq!(reads, vec![MIN_MATCH_WINDOW]);
1527 }
1528
1529 #[test]
1530 fn last_matches_widens_the_window_until_it_holds_n_matches() {
1531 let rows: Vec<u32> = (0..100_000).collect();
1532 let mut reads = Vec::new();
1533 let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1534 r.into_iter().filter(|i| *i >= 3000).collect()
1535 })
1536 .unwrap();
1537 assert_eq!(found, vec![3000, 3001]);
1538 assert_eq!(reads, vec![1000, 2000, 4000]);
1539 }
1540
1541 #[test]
1542 fn last_matches_stops_once_every_row_is_read() {
1543 let rows: Vec<u32> = (0..2500).collect();
1544 let mut reads = Vec::new();
1545 let found = last_matches(5, fetch_from(&rows, &mut reads), |r| {
1546 r.into_iter().filter(|i| *i == 2400).collect()
1547 })
1548 .unwrap();
1549 assert_eq!(found, vec![2400]);
1550 assert_eq!(reads, vec![1000, 2000, 4000]);
1551 }
1552}