use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub(crate) const LOGS_PATH: &str = "/data/api/v1/logs";
pub(crate) const LOGS_DOWNLOAD_PATH: &str = "/data/api/v1/logs/download";
pub(crate) const LOGGERS_PATH: &str = "/data/api/v1/logs/loggers";
pub(crate) const LEVEL_RESET_PATH: &str = "/data/api/v1/logs/levelreset";
pub(crate) fn logger_set_path(logger: &str) -> String {
format!("/data/api/v1/logs/loggers/{logger}")
}
pub const DEFAULT_LOG_LIMIT: i64 = 200;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: i64,
#[serde(rename = "loggerName", alias = "logger_name")]
pub logger_name: String,
#[serde(default)]
pub level: String,
#[serde(default)]
pub message: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stack: Vec<String>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub mdc: serde_json::Map<String, serde_json::Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LogQuery {
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logger: Option<String>,
pub limit: i64,
pub offset: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_by: Option<String>,
}
impl Default for LogQuery {
fn default() -> Self {
Self {
start_time: None,
end_time: None,
min_level: None,
logger: None,
limit: DEFAULT_LOG_LIMIT,
offset: 0,
sort_by: None,
}
}
}
impl LogQuery {
pub fn to_query_pairs(&self) -> Vec<(String, String)> {
let mut pairs = Vec::with_capacity(7);
if let Some(start_time) = self.start_time {
pairs.push(("startTime".to_string(), start_time.to_string()));
}
if let Some(end_time) = self.end_time {
pairs.push(("endTime".to_string(), end_time.to_string()));
}
if let Some(min_level) = &self.min_level {
pairs.push(("minLevel".to_string(), min_level.clone()));
}
if let Some(logger) = &self.logger {
pairs.push(("logger".to_string(), logger.clone()));
}
pairs.push(("limit".to_string(), self.limit.to_string()));
pairs.push(("offset".to_string(), self.offset.to_string()));
if let Some(sort_by) = &self.sort_by {
pairs.push(("sortBy".to_string(), sort_by.clone()));
}
pairs
}
}
pub type LogPage = crate::client::query::ListEnvelope<LogEntry>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LoggerInfo {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub context: serde_json::Value,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct LogDownload {
pub bytes: Vec<u8>,
pub filename: Option<String>,
pub content_type: Option<String>,
}
pub fn filename_from_content_disposition(value: &str) -> Option<String> {
if let Some(start) = value.find("filename*=") {
let rest = &value[start + "filename*=".len()..];
let name = rest.split(';').next().unwrap_or(rest);
let decoded = match name.split_once('\'') {
Some((_, after_charset)) => after_charset
.split_once('\'')
.map(|(_, raw)| raw)
.unwrap_or(name),
None => name,
};
if !decoded.is_empty() {
return Some(decoded.to_string());
}
}
let start = value.find("filename=")? + "filename=".len();
let rest = value[start..].split(';').next().unwrap_or(&value[start..]);
let trimmed = rest.trim().trim_matches('"');
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_LOG_LIMIT, LogEntry, LogQuery, filename_from_content_disposition};
#[test]
fn log_entry_parses_the_live_capture_incl_stack() {
let entry: LogEntry = serde_json::from_value(serde_json::json!({
"timestamp": 1787346747022i64,
"loggerName": "Common.BasicExecutionEngine.Thread$",
"level": "ERROR",
"message": "Execution halted by exception",
"stack": [
"java.lang.RuntimeException: boom",
"\tat com.inductiveautomation.ignition.common.Sample.run(Sample.java:42)"
],
"mdc": {"thread": "Thread-12"}
}))
.expect("live capture shape must parse");
assert_eq!(entry.timestamp, 1787346747022, "epoch ms — the tail cursor");
assert_eq!(entry.logger_name, "Common.BasicExecutionEngine.Thread$");
assert_eq!(entry.level, "ERROR");
assert_eq!(entry.stack.len(), 2, "stack trace lines parse");
assert_eq!(entry.mdc["thread"], "Thread-12");
let round = serde_json::to_value(&entry).expect("serialize");
assert_eq!(round["loggerName"], "Common.BasicExecutionEngine.Thread$");
assert_eq!(round["stack"].as_array().unwrap().len(), 2);
let plain: LogEntry = serde_json::from_value(serde_json::json!({
"timestamp": 1787346747030i64,
"loggerName": "GatewayManager",
"level": "INFO",
"message": "Gateway started"
}))
.expect("plain entry (no stack, no mdc) must parse");
assert!(plain.stack.is_empty());
assert!(plain.mdc.is_empty());
let round = serde_json::to_value(&plain).expect("serialize");
assert!(
round.get("stack").is_none() && round.get("mdc").is_none(),
"empty stack/mdc stay absent on the way out"
);
}
#[test]
fn query_pairs_carry_explicit_limit_and_native_names() {
let pairs = LogQuery::default().to_query_pairs();
assert_eq!(
pairs,
vec![
("limit".to_string(), DEFAULT_LOG_LIMIT.to_string()),
("offset".to_string(), "0".to_string()),
],
"default = explicit limit 200 + offset 0 (Pitfall 9)"
);
let full = LogQuery {
start_time: Some(1787346747022),
end_time: Some(1787346757022),
min_level: Some("INFO".into()),
logger: Some("GatewayManager".into()),
limit: 50,
offset: 100,
sort_by: Some("desc(timestamp)".into()),
};
let pairs = full.to_query_pairs();
assert_eq!(
pairs,
vec![
("startTime".to_string(), "1787346747022".to_string()),
("endTime".to_string(), "1787346757022".to_string()),
("minLevel".to_string(), "INFO".to_string()),
("logger".to_string(), "GatewayManager".to_string()),
("limit".to_string(), "50".to_string()),
("offset".to_string(), "100".to_string()),
("sortBy".to_string(), "desc(timestamp)".to_string()),
],
"camelCase param names, declared order"
);
}
#[test]
fn content_disposition_filename_extraction() {
assert_eq!(
filename_from_content_disposition(
"attachment; filename=MyGateway_Ignition_logs_20260822-0307.idb"
),
Some("MyGateway_Ignition_logs_20260822-0307.idb".to_string())
);
assert_eq!(
filename_from_content_disposition("attachment; filename=\"quoted.idb\""),
Some("quoted.idb".to_string())
);
assert_eq!(
filename_from_content_disposition("attachment; filename*=UTF-8''encoded.idb"),
Some("encoded.idb".to_string())
);
assert_eq!(filename_from_content_disposition("attachment"), None);
assert_eq!(filename_from_content_disposition("inline"), None);
}
}