1use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27
28pub(crate) const LOGS_PATH: &str = "/data/api/v1/logs";
30
31pub(crate) const LOGS_DOWNLOAD_PATH: &str = "/data/api/v1/logs/download";
33
34pub(crate) const LOGGERS_PATH: &str = "/data/api/v1/logs/loggers";
36
37pub(crate) const LEVEL_RESET_PATH: &str = "/data/api/v1/logs/levelreset";
39
40pub(crate) fn logger_set_path(logger: &str) -> String {
42 format!("/data/api/v1/logs/loggers/{logger}")
43}
44
45pub const DEFAULT_LOG_LIMIT: i64 = 200;
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct LogEntry {
55 pub timestamp: i64,
57 #[serde(rename = "loggerName", alias = "logger_name")]
60 pub logger_name: String,
61 #[serde(default)]
64 pub level: String,
65 #[serde(default)]
67 pub message: String,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub stack: Vec<String>,
72 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
74 pub mdc: serde_json::Map<String, serde_json::Value>,
75 #[serde(flatten)]
77 pub extra: BTreeMap<String, serde_json::Value>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct LogQuery {
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub start_time: Option<i64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub end_time: Option<i64>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub min_level: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub logger: Option<String>,
99 pub limit: i64,
102 pub offset: i64,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub sort_by: Option<String>,
107}
108
109impl Default for LogQuery {
110 fn default() -> Self {
111 Self {
112 start_time: None,
113 end_time: None,
114 min_level: None,
115 logger: None,
116 limit: DEFAULT_LOG_LIMIT,
117 offset: 0,
118 sort_by: None,
119 }
120 }
121}
122
123impl LogQuery {
124 pub fn to_query_pairs(&self) -> Vec<(String, String)> {
127 let mut pairs = Vec::with_capacity(7);
128 if let Some(start_time) = self.start_time {
129 pairs.push(("startTime".to_string(), start_time.to_string()));
130 }
131 if let Some(end_time) = self.end_time {
132 pairs.push(("endTime".to_string(), end_time.to_string()));
133 }
134 if let Some(min_level) = &self.min_level {
135 pairs.push(("minLevel".to_string(), min_level.clone()));
136 }
137 if let Some(logger) = &self.logger {
138 pairs.push(("logger".to_string(), logger.clone()));
139 }
140 pairs.push(("limit".to_string(), self.limit.to_string()));
141 pairs.push(("offset".to_string(), self.offset.to_string()));
142 if let Some(sort_by) = &self.sort_by {
143 pairs.push(("sortBy".to_string(), sort_by.clone()));
144 }
145 pairs
146 }
147}
148
149pub type LogPage = crate::client::query::ListEnvelope<LogEntry>;
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct LoggerInfo {
157 pub name: String,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub level: Option<String>,
162 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
164 pub context: serde_json::Value,
165 #[serde(flatten)]
167 pub extra: BTreeMap<String, serde_json::Value>,
168}
169
170#[derive(Debug, Clone)]
175pub struct LogDownload {
176 pub bytes: Vec<u8>,
178 pub filename: Option<String>,
181 pub content_type: Option<String>,
183}
184
185pub fn filename_from_content_disposition(value: &str) -> Option<String> {
190 if let Some(start) = value.find("filename*=") {
192 let rest = &value[start + "filename*=".len()..];
193 let name = rest.split(';').next().unwrap_or(rest);
195 let decoded = match name.split_once('\'') {
196 Some((_, after_charset)) => after_charset
197 .split_once('\'')
198 .map(|(_, raw)| raw)
199 .unwrap_or(name),
200 None => name,
201 };
202 if !decoded.is_empty() {
203 return Some(decoded.to_string());
204 }
205 }
206 let start = value.find("filename=")? + "filename=".len();
207 let rest = value[start..].split(';').next().unwrap_or(&value[start..]);
208 let trimmed = rest.trim().trim_matches('"');
209 (!trimmed.is_empty()).then(|| trimmed.to_string())
210}
211
212#[cfg(test)]
213mod tests {
214 use super::{DEFAULT_LOG_LIMIT, LogEntry, LogQuery, filename_from_content_disposition};
215
216 #[test]
219 fn log_entry_parses_the_live_capture_incl_stack() {
220 let entry: LogEntry = serde_json::from_value(serde_json::json!({
221 "timestamp": 1787346747022i64,
222 "loggerName": "Common.BasicExecutionEngine.Thread$",
223 "level": "ERROR",
224 "message": "Execution halted by exception",
225 "stack": [
226 "java.lang.RuntimeException: boom",
227 "\tat com.inductiveautomation.ignition.common.Sample.run(Sample.java:42)"
228 ],
229 "mdc": {"thread": "Thread-12"}
230 }))
231 .expect("live capture shape must parse");
232 assert_eq!(entry.timestamp, 1787346747022, "epoch ms — the tail cursor");
233 assert_eq!(entry.logger_name, "Common.BasicExecutionEngine.Thread$");
234 assert_eq!(entry.level, "ERROR");
235 assert_eq!(entry.stack.len(), 2, "stack trace lines parse");
236 assert_eq!(entry.mdc["thread"], "Thread-12");
237
238 let round = serde_json::to_value(&entry).expect("serialize");
241 assert_eq!(round["loggerName"], "Common.BasicExecutionEngine.Thread$");
242 assert_eq!(round["stack"].as_array().unwrap().len(), 2);
243
244 let plain: LogEntry = serde_json::from_value(serde_json::json!({
245 "timestamp": 1787346747030i64,
246 "loggerName": "GatewayManager",
247 "level": "INFO",
248 "message": "Gateway started"
249 }))
250 .expect("plain entry (no stack, no mdc) must parse");
251 assert!(plain.stack.is_empty());
252 assert!(plain.mdc.is_empty());
253 let round = serde_json::to_value(&plain).expect("serialize");
254 assert!(
255 round.get("stack").is_none() && round.get("mdc").is_none(),
256 "empty stack/mdc stay absent on the way out"
257 );
258 }
259
260 #[test]
264 fn query_pairs_carry_explicit_limit_and_native_names() {
265 let pairs = LogQuery::default().to_query_pairs();
266 assert_eq!(
267 pairs,
268 vec![
269 ("limit".to_string(), DEFAULT_LOG_LIMIT.to_string()),
270 ("offset".to_string(), "0".to_string()),
271 ],
272 "default = explicit limit 200 + offset 0 (Pitfall 9)"
273 );
274
275 let full = LogQuery {
276 start_time: Some(1787346747022),
277 end_time: Some(1787346757022),
278 min_level: Some("INFO".into()),
279 logger: Some("GatewayManager".into()),
280 limit: 50,
281 offset: 100,
282 sort_by: Some("desc(timestamp)".into()),
283 };
284 let pairs = full.to_query_pairs();
285 assert_eq!(
286 pairs,
287 vec![
288 ("startTime".to_string(), "1787346747022".to_string()),
289 ("endTime".to_string(), "1787346757022".to_string()),
290 ("minLevel".to_string(), "INFO".to_string()),
291 ("logger".to_string(), "GatewayManager".to_string()),
292 ("limit".to_string(), "50".to_string()),
293 ("offset".to_string(), "100".to_string()),
294 ("sortBy".to_string(), "desc(timestamp)".to_string()),
295 ],
296 "camelCase param names, declared order"
297 );
298 }
299
300 #[test]
303 fn content_disposition_filename_extraction() {
304 assert_eq!(
305 filename_from_content_disposition(
306 "attachment; filename=MyGateway_Ignition_logs_20260822-0307.idb"
307 ),
308 Some("MyGateway_Ignition_logs_20260822-0307.idb".to_string())
309 );
310 assert_eq!(
311 filename_from_content_disposition("attachment; filename=\"quoted.idb\""),
312 Some("quoted.idb".to_string())
313 );
314 assert_eq!(
315 filename_from_content_disposition("attachment; filename*=UTF-8''encoded.idb"),
316 Some("encoded.idb".to_string())
317 );
318 assert_eq!(filename_from_content_disposition("attachment"), None);
319 assert_eq!(filename_from_content_disposition("inline"), None);
320 }
321}