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
1233const READY_SPINNER_DELAY: Duration = Duration::from_millis(400);
1238
1239fn spinner_due(elapsed: Duration) -> bool {
1241 elapsed >= READY_SPINNER_DELAY
1242}
1243
1244fn reveal_spinner_if_due(
1246 job: &clx::progress::ProgressJob,
1247 started: std::time::Instant,
1248 revealed: &mut bool,
1249) {
1250 if !*revealed && spinner_due(started.elapsed()) {
1251 *revealed = true;
1252 job.set_status(clx::progress::ProgressStatus::Running);
1253 }
1254}
1255
1256pub fn create_ready_check_job(
1263 daemon_id: &DaemonId,
1264 check_type: &ReadyCheckType,
1265) -> std::sync::Arc<clx::progress::ProgressJob> {
1266 use clx::progress::{ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
1267
1268 let is_tty = std::io::stderr().is_terminal();
1269 let colors_enabled = is_tty && console::colors_enabled_stderr();
1270 let id_label = colored_id_label(&daemon_id.qualified(), colors_enabled);
1271 let show_ts = crate::settings::settings().general.startup_log_timestamps;
1272
1273 let prefix = if show_ts {
1277 edim(chrono::Local::now().format("%H:%M:%S").to_string()).to_string()
1280 } else {
1281 "{{spinner()}}".to_string()
1282 };
1283
1284 ProgressJobBuilder::new()
1285 .body(format!(
1286 "{} pitchfork: {} waiting for {{{{ check_type }}}}...",
1287 prefix, id_label
1288 ))
1289 .prop("check_type", &check_type.to_string())
1290 .status(ProgressStatus::Hide)
1291 .on_done(ProgressJobDoneBehavior::Keep)
1292 .start()
1293}
1294
1295pub fn collect_startup_logs(
1300 daemon_id: &DaemonId,
1301 from: DateTime<Local>,
1302) -> Result<Vec<(String, String, String)>> {
1303 let entries = LOG_STORE.query(&LogQuery {
1304 daemon_ids: vec![daemon_id.qualified()],
1305 from: Some(from),
1306 to: None,
1307 limit: None,
1308 order_desc: false,
1309 after_id: None,
1310 before_id: None,
1311 message_filters: Vec::new(),
1312 field_filters: Vec::new(),
1313 include_structured: false,
1314 })?;
1315 let log_lines = entries
1316 .into_iter()
1317 .map(|e| {
1318 let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
1319 (ts, e.daemon_id, e.message)
1320 })
1321 .collect();
1322
1323 Ok(log_lines)
1324}
1325
1326pub fn stream_startup_logs(
1332 daemon_id: &DaemonId,
1333 job: std::sync::Arc<clx::progress::ProgressJob>,
1334) -> (
1335 tokio::sync::watch::Sender<bool>,
1336 tokio::task::JoinHandle<()>,
1337) {
1338 let (tx, mut rx) = tokio::sync::watch::channel(false);
1339 let id = daemon_id.clone();
1340
1341 let show_ts = crate::settings::settings().general.startup_log_timestamps;
1342
1343 let anchor_id: i64 = LOG_STORE
1348 .query(&LogQuery {
1349 daemon_ids: vec![id.qualified()],
1350 limit: Some(1),
1351 order_desc: true,
1352 ..Default::default()
1353 })
1354 .ok()
1355 .and_then(|entries| entries.last().map(|e| e.id))
1356 .unwrap_or(0);
1357
1358 let started = std::time::Instant::now();
1359 let handle = tokio::spawn(async move {
1360 let mut revealed = false;
1361 let is_tty = std::io::stderr().is_terminal();
1362 let colors_enabled = is_tty && console::colors_enabled_stderr();
1363 let id_label = colored_id_label(&id.qualified(), colors_enabled);
1364 let prefix = if show_ts {
1365 String::new()
1366 } else {
1367 edim("•").to_string()
1368 };
1369
1370 let mut last_id = anchor_id;
1371
1372 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1374 for entry in &entries {
1375 let time = entry.timestamp.format("%H:%M:%S").to_string();
1376 let msg = strip_pty_controls(&entry.message);
1377 let msg = if is_tty {
1378 msg
1379 } else {
1380 console::strip_ansi_codes(&msg).to_string()
1381 };
1382 let line_prefix = if show_ts {
1383 edim(time).to_string()
1384 } else {
1385 prefix.clone()
1386 };
1387 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1388 }
1389 if let Some(last) = entries.last() {
1390 last_id = last.id;
1391 }
1392 }
1393
1394 loop {
1395 tokio::select! {
1396 _ = tokio::time::sleep(Duration::from_millis(200)) => {
1397 reveal_spinner_if_due(&job, started, &mut revealed);
1398 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1399 for entry in &entries {
1400 let time = entry.timestamp.format("%H:%M:%S").to_string();
1401 let msg = strip_pty_controls(&entry.message);
1402 let msg = if is_tty {
1403 msg
1404 } else {
1405 console::strip_ansi_codes(&msg).to_string()
1406 };
1407 let line_prefix = if show_ts {
1408 edim(time).to_string()
1409 } else {
1410 prefix.clone()
1411 };
1412 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1413 }
1414 if let Some(last) = entries.last() {
1415 last_id = last.id;
1416 }
1417 }
1418 }
1419 _ = rx.changed() => {
1420 break;
1421 }
1422 }
1423 }
1424
1425 if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1427 for entry in &entries {
1428 let time = entry.timestamp.format("%H:%M:%S").to_string();
1429 let msg = strip_pty_controls(&entry.message);
1430 let msg = if is_tty {
1431 msg
1432 } else {
1433 console::strip_ansi_codes(&msg).to_string()
1434 };
1435 let line_prefix = if show_ts {
1436 edim(time).to_string()
1437 } else {
1438 prefix.clone()
1439 };
1440 job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1441 }
1442 }
1443 });
1444
1445 (tx, handle)
1446}
1447
1448fn strip_pty_controls(s: &str) -> String {
1453 struct Stripper {
1454 result: String,
1455 }
1456
1457 impl vte::Perform for Stripper {
1458 fn print(&mut self, c: char) {
1459 self.result.push(c);
1460 }
1461
1462 fn execute(&mut self, byte: u8) {
1463 if byte == b'\n' || byte == b'\t' {
1465 self.result.push(byte as char);
1466 }
1467 }
1468
1469 fn csi_dispatch(
1470 &mut self,
1471 params: &vte::Params,
1472 _intermediates: &[u8],
1473 _ignore: bool,
1474 action: char,
1475 ) {
1476 if action == 'm' {
1478 self.result.push_str("\x1b[");
1479 let mut first = true;
1480 for sub in params.iter() {
1481 if !first {
1482 self.result.push(';');
1483 }
1484 first = false;
1485 for (i, &p) in sub.iter().enumerate() {
1486 if i > 0 {
1487 self.result.push(':');
1488 }
1489 self.result.push_str(&p.to_string());
1490 }
1491 }
1492 self.result.push('m');
1493 }
1494 }
1496
1497 fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1498 }
1500
1501 fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1502 }
1504
1505 fn hook(
1506 &mut self,
1507 _params: &vte::Params,
1508 _intermediates: &[u8],
1509 _ignore: bool,
1510 _action: char,
1511 ) {
1512 }
1514
1515 fn put(&mut self, _byte: u8) {
1516 }
1518
1519 fn unhook(&mut self) {
1520 }
1522 }
1523
1524 let mut parser = vte::Parser::new();
1525 let mut stripper = Stripper {
1526 result: String::with_capacity(s.len()),
1527 };
1528 parser.advance(&mut stripper, s.as_bytes());
1529 stripper.result
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535
1536 #[test]
1537 fn spinner_waits_for_threshold() {
1538 assert!(!spinner_due(Duration::ZERO));
1539 assert!(!spinner_due(READY_SPINNER_DELAY - Duration::from_millis(1)));
1540 assert!(spinner_due(READY_SPINNER_DELAY));
1541 }
1542
1543 #[test]
1544 fn ready_spinner_starts_hidden_and_reveals_after_threshold() {
1545 let id = DaemonId::new("spinner-test", "ready-check");
1546 let job = create_ready_check_job(&id, &ReadyCheckType::Delay(0));
1547 assert!(!job.is_running(), "job must start hidden");
1548
1549 let mut revealed = false;
1550 let now = std::time::Instant::now();
1551 reveal_spinner_if_due(&job, now, &mut revealed);
1552 assert!(
1553 !revealed && !job.is_running(),
1554 "hidden before the threshold"
1555 );
1556
1557 let past = now - READY_SPINNER_DELAY;
1558 reveal_spinner_if_due(&job, past, &mut revealed);
1559 assert!(revealed && job.is_running(), "visible after the threshold");
1560 job.remove();
1561 }
1562
1563 fn fetch_from(rows: &[u32], reads: &mut Vec<usize>) -> impl FnMut(usize) -> Result<Vec<u32>> {
1565 move |window| {
1566 reads.push(window);
1567 Ok(rows.iter().copied().take(window).collect())
1568 }
1569 }
1570
1571 #[test]
1572 fn last_matches_reads_one_window_when_the_newest_rows_match() {
1573 let rows: Vec<u32> = (0..100_000).collect();
1574 let mut reads = Vec::new();
1575 let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1576 r.into_iter().filter(|i| i % 10 == 0).collect()
1577 })
1578 .unwrap();
1579 assert_eq!(found, vec![0, 10]);
1580 assert_eq!(reads, vec![MIN_MATCH_WINDOW]);
1581 }
1582
1583 #[test]
1584 fn last_matches_widens_the_window_until_it_holds_n_matches() {
1585 let rows: Vec<u32> = (0..100_000).collect();
1586 let mut reads = Vec::new();
1587 let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1588 r.into_iter().filter(|i| *i >= 3000).collect()
1589 })
1590 .unwrap();
1591 assert_eq!(found, vec![3000, 3001]);
1592 assert_eq!(reads, vec![1000, 2000, 4000]);
1593 }
1594
1595 #[test]
1596 fn last_matches_stops_once_every_row_is_read() {
1597 let rows: Vec<u32> = (0..2500).collect();
1598 let mut reads = Vec::new();
1599 let found = last_matches(5, fetch_from(&rows, &mut reads), |r| {
1600 r.into_iter().filter(|i| *i == 2400).collect()
1601 })
1602 .unwrap();
1603 assert_eq!(found, vec![2400]);
1604 assert_eq!(reads, vec![1000, 2000, 4000]);
1605 }
1606}