Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ All notable changes to this project will be documented in this file.
* Example: `-o '!13335,!15169'` excludes elements from AS13335 AND AS15169
* Note: Cannot mix positive and negative values in the same filter
* Added validation for ASN format, prefix CIDR notation, and negation consistency
* **`--time-format`**: Added timestamp output format option to `parse` and `search` commands
* `--time-format unix` (default): Output timestamps as Unix epoch (integer/float)
* `--time-format rfc3339`: Output timestamps in ISO 8601/RFC3339 format (e.g., `2023-10-11T17:00:00+00:00`)
* Applies to non-JSON output formats (table, psv, markdown)
* JSON output always uses numeric Unix timestamps for backward compatibility
* Example: `monocle parse file.mrt --time-format rfc3339`
* Example: `monocle search -t 2024-01-01 -d 1h -p 1.1.1.0/24 --time-format rfc3339`

### Code Improvements

Expand Down
33 changes: 27 additions & 6 deletions src/bin/commands/elem_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! multiple output format support.

use bgpkit_parser::BgpElem;
use monocle::lens::utils::{OrderByField, OrderDirection, OutputFormat};
use monocle::lens::utils::{OrderByField, OrderDirection, OutputFormat, TimestampFormat};
use serde_json::json;
use tabled::builder::Builder;
use tabled::settings::Style;
Expand Down Expand Up @@ -67,7 +67,11 @@ pub const DEFAULT_FIELDS_SEARCH: &[&str] = &[
];

/// Format a collection of BgpElems as a tabled table with selected fields
pub fn format_elems_table(elems: &[(BgpElem, Option<String>)], fields: &[&str]) -> String {
pub fn format_elems_table(
elems: &[(BgpElem, Option<String>)],
fields: &[&str],
time_format: TimestampFormat,
) -> String {
let mut builder = Builder::default();

// Add header row
Expand All @@ -77,7 +81,7 @@ pub fn format_elems_table(elems: &[(BgpElem, Option<String>)], fields: &[&str])
for (elem, collector) in elems {
let row: Vec<String> = fields
.iter()
.map(|f| get_field_value(elem, f, collector.as_deref()))
.map(|f| get_field_value_with_time_format(elem, f, collector.as_deref(), time_format))
.collect();
builder.push_record(row);
}
Expand Down Expand Up @@ -144,7 +148,20 @@ pub fn parse_fields(

/// Get the value of a specific field from a BgpElem
/// For the "collector" field, pass the collector value via the `collector` parameter.
/// Uses default Unix timestamp format for backward compatibility.
#[allow(dead_code)]
pub fn get_field_value(elem: &BgpElem, field: &str, collector: Option<&str>) -> String {
get_field_value_with_time_format(elem, field, collector, TimestampFormat::Unix)
}

/// Get the value of a specific field from a BgpElem with configurable timestamp format
/// For the "collector" field, pass the collector value via the `collector` parameter.
pub fn get_field_value_with_time_format(
elem: &BgpElem,
field: &str,
collector: Option<&str>,
time_format: TimestampFormat,
) -> String {
match field {
"type" => {
if elem.elem_type == bgpkit_parser::models::ElemType::ANNOUNCE {
Expand All @@ -153,7 +170,7 @@ pub fn get_field_value(elem: &BgpElem, field: &str, collector: Option<&str>) ->
"W".to_string()
}
}
"timestamp" => elem.timestamp.to_string(),
"timestamp" => time_format.format_timestamp(elem.timestamp),
"peer_ip" => elem.peer_ip.to_string(),
"peer_asn" => elem.peer_asn.to_string(),
"prefix" => elem.prefix.to_string(),
Expand Down Expand Up @@ -206,28 +223,32 @@ pub fn get_field_value(elem: &BgpElem, field: &str, collector: Option<&str>) ->

/// Format a BgpElem according to the output format and selected fields.
/// The `collector` parameter provides the collector value for the "collector" field.
/// The `time_format` parameter controls how timestamps are displayed in non-JSON formats.
/// Note: For Table format, this returns an empty string - use format_elems_table() instead
/// after collecting all elements.
pub fn format_elem(
elem: &BgpElem,
output_format: OutputFormat,
fields: &[&str],
collector: Option<&str>,
time_format: TimestampFormat,
) -> Option<String> {
match output_format {
OutputFormat::Json | OutputFormat::JsonLine => {
// JSON always uses Unix timestamp as number for backward compatibility
let obj = build_json_object(elem, fields, collector);
Some(serde_json::to_string(&obj).unwrap_or_else(|_| elem.to_string()))
}
OutputFormat::JsonPretty => {
// JSON always uses Unix timestamp as number for backward compatibility
let obj = build_json_object(elem, fields, collector);
Some(serde_json::to_string_pretty(&obj).unwrap_or_else(|_| elem.to_string()))
}
OutputFormat::Psv => {
// Pipe-separated values (no header for backward compatibility)
let values: Vec<String> = fields
.iter()
.map(|f| get_field_value(elem, f, collector))
.map(|f| get_field_value_with_time_format(elem, f, collector, time_format))
.collect();
Some(values.join("|"))
}
Expand All @@ -240,7 +261,7 @@ pub fn format_elem(
// Markdown table row
let values: Vec<String> = fields
.iter()
.map(|f| get_field_value(elem, f, collector))
.map(|f| get_field_value_with_time_format(elem, f, collector, time_format))
.collect();
Some(format!("| {} |", values.join(" | ")))
}
Expand Down
23 changes: 17 additions & 6 deletions src/bin/commands/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use bgpkit_parser::BgpElem;
use clap::Args;

use monocle::lens::parse::{ParseFilters, ParseLens};
use monocle::lens::utils::{OrderByField, OrderDirection, OutputFormat};
use monocle::lens::utils::{OrderByField, OrderDirection, OutputFormat, TimestampFormat};

use super::elem_format::{
available_fields_help, format_elem, format_elems_table, get_header, parse_fields, sort_elems,
Expand Down Expand Up @@ -39,6 +39,10 @@ pub(crate) struct ParseArgs {
#[clap(long, value_enum, default_value = "asc")]
pub order: OrderDirection,

/// Timestamp output format for non-JSON output (unix or rfc3339)
#[clap(long, value_enum, default_value = "unix")]
pub time_format: TimestampFormat,

/// Filter by AS path regex string
#[clap(flatten)]
pub filters: ParseFilters,
Expand All @@ -52,6 +56,7 @@ pub fn run(args: ParseArgs, output_format: OutputFormat) {
fields: fields_arg,
order_by,
order,
time_format,
filters,
} = args;

Expand Down Expand Up @@ -116,7 +121,7 @@ pub fn run(args: ParseArgs, output_format: OutputFormat) {

// Output based on format
if output_format == OutputFormat::Table {
println!("{}", format_elems_table(&elems, &fields));
println!("{}", format_elems_table(&elems, &fields, time_format));
} else {
// Print header for markdown format
if let Some(header) = get_header(output_format, &fields) {
Expand All @@ -130,9 +135,13 @@ pub fn run(args: ParseArgs, output_format: OutputFormat) {

// Output sorted elements
for (elem, collector) in &elems {
if let Some(output_str) =
format_elem(elem, output_format, &fields, collector.as_deref())
{
if let Some(output_str) = format_elem(
elem,
output_format,
&fields,
collector.as_deref(),
time_format,
) {
if let Err(e) = writeln!(stdout, "{}", &output_str) {
if e.kind() != std::io::ErrorKind::BrokenPipe {
eprintln!("ERROR: {e}");
Expand All @@ -158,7 +167,9 @@ pub fn run(args: ParseArgs, output_format: OutputFormat) {

for elem in parser {
// output to stdout based on format
if let Some(output_str) = format_elem(&elem, output_format, &fields, None) {
if let Some(output_str) =
format_elem(&elem, output_format, &fields, None, time_format)
{
if let Err(e) = writeln!(stdout, "{}", &output_str) {
if e.kind() != std::io::ErrorKind::BrokenPipe {
eprintln!("ERROR: {e}");
Expand Down
25 changes: 20 additions & 5 deletions src/bin/commands/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use bgpkit_parser::BgpElem;
use clap::Args;
use monocle::database::MsgStore;
use monocle::lens::search::SearchFilters;
use monocle::lens::utils::{OrderByField, OrderDirection, OutputFormat};
use monocle::lens::utils::{OrderByField, OrderDirection, OutputFormat, TimestampFormat};
use rayon::prelude::*;
use tracing::{info, warn};

Expand Down Expand Up @@ -54,6 +54,10 @@ pub struct SearchArgs {
#[clap(long, value_enum, default_value = "asc")]
pub order: OrderDirection,

/// Timestamp output format for non-JSON output (unix or rfc3339)
#[clap(long, value_enum, default_value = "unix")]
pub time_format: TimestampFormat,

/// Filter by AS path regex string
#[clap(flatten)]
pub filters: SearchFilters,
Expand Down Expand Up @@ -129,6 +133,7 @@ pub fn run(args: SearchArgs, output_format: OutputFormat) {
fields: fields_arg,
order_by,
order,
time_format,
filters,
} = args;

Expand Down Expand Up @@ -259,6 +264,8 @@ pub fn run(args: SearchArgs, output_format: OutputFormat) {
// Clone ordering parameters for writer thread
let order_by_for_writer = order_by;
let order_for_writer = order;
// Clone time format for writer thread
let time_format_for_writer = time_format;

// dedicated thread for handling output of results
let writer_thread = thread::spawn(move || {
Expand Down Expand Up @@ -299,9 +306,13 @@ pub fn run(args: SearchArgs, output_format: OutputFormat) {
}
header_printed = true;
}
if let Some(output_str) =
format_elem(&elem, output_format, &fields_for_writer, Some(&collector))
{
if let Some(output_str) = format_elem(
&elem,
output_format,
&fields_for_writer,
Some(&collector),
time_format_for_writer,
) {
println!("{output_str}");
}
continue;
Expand Down Expand Up @@ -359,7 +370,10 @@ pub fn run(args: SearchArgs, output_format: OutputFormat) {

// Output based on format
if is_table_format {
println!("{}", format_elems_table(&output_buffer, &fields_for_writer));
println!(
"{}",
format_elems_table(&output_buffer, &fields_for_writer, time_format_for_writer)
);
} else {
// Print header for markdown format
if let Some(header) = get_header(output_format, &fields_for_writer) {
Expand All @@ -373,6 +387,7 @@ pub fn run(args: SearchArgs, output_format: OutputFormat) {
output_format,
&fields_for_writer,
collector.as_deref(),
time_format_for_writer,
) {
println!("{output_str}");
}
Expand Down
118 changes: 118 additions & 0 deletions src/lens/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,72 @@ impl FromStr for OrderDirection {
}
}

// =============================================================================
// Timestamp Format for BGP Elements
// =============================================================================

/// Format for timestamp output in parse and search commands
///
/// This enum controls how timestamps are displayed in non-JSON output formats
/// (table, psv, markdown). JSON output always uses Unix timestamps as numbers
/// for backward compatibility.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum TimestampFormat {
/// Unix timestamp (integer or float) - default for backward compatibility
#[default]
Unix,
/// RFC3339/ISO 8601 format (e.g., "2023-10-11T15:00:00Z")
Rfc3339,
}

impl TimestampFormat {
/// Get a list of all format names for help text
pub fn all_names() -> &'static [&'static str] {
&["unix", "rfc3339"]
}

/// Format a Unix timestamp (f64) according to this format
pub fn format_timestamp(&self, timestamp: f64) -> String {
match self {
Self::Unix => timestamp.to_string(),
Self::Rfc3339 => {
let secs = timestamp as i64;
let nsecs = ((timestamp.fract().abs()) * 1_000_000_000.0) as u32;
chrono::DateTime::from_timestamp(secs, nsecs)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| timestamp.to_string())
}
}
}
}

impl fmt::Display for TimestampFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unix => write!(f, "unix"),
Self::Rfc3339 => write!(f, "rfc3339"),
}
}
}

impl FromStr for TimestampFormat {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"unix" | "timestamp" | "ts" => Ok(Self::Unix),
"rfc3339" | "iso8601" | "iso" => Ok(Self::Rfc3339),
_ => Err(format!(
"Unknown timestamp format '{}'. Valid formats: {}",
s,
Self::all_names().join(", ")
)),
}
}
}

impl FromStr for OutputFormat {
type Err = String;

Expand Down Expand Up @@ -944,4 +1010,56 @@ mod tests {
assert_eq!(OrderDirection::Asc.to_string(), "asc");
assert_eq!(OrderDirection::Desc.to_string(), "desc");
}

#[test]
fn test_timestamp_format_from_str() {
assert_eq!(
TimestampFormat::from_str("unix").unwrap(),
TimestampFormat::Unix
);
assert_eq!(
TimestampFormat::from_str("ts").unwrap(),
TimestampFormat::Unix
);
assert_eq!(
TimestampFormat::from_str("rfc3339").unwrap(),
TimestampFormat::Rfc3339
);
assert_eq!(
TimestampFormat::from_str("iso8601").unwrap(),
TimestampFormat::Rfc3339
);
assert_eq!(
TimestampFormat::from_str("iso").unwrap(),
TimestampFormat::Rfc3339
);
assert!(TimestampFormat::from_str("invalid").is_err());
}

#[test]
fn test_timestamp_format_display() {
assert_eq!(TimestampFormat::Unix.to_string(), "unix");
assert_eq!(TimestampFormat::Rfc3339.to_string(), "rfc3339");
}

#[test]
fn test_timestamp_format_unix() {
let format = TimestampFormat::Unix;
assert_eq!(format.format_timestamp(1697043600.0), "1697043600");
assert_eq!(format.format_timestamp(1697043600.5), "1697043600.5");
}

#[test]
fn test_timestamp_format_rfc3339() {
let format = TimestampFormat::Rfc3339;
// 1697043600 = 2023-10-11T17:00:00Z (UTC)
let result = format.format_timestamp(1697043600.0);
assert!(result.starts_with("2023-10-11T17:00:00"));
assert!(result.ends_with("Z") || result.contains("+00:00"));
}

#[test]
fn test_timestamp_format_default() {
assert_eq!(TimestampFormat::default(), TimestampFormat::Unix);
}
}