1use std::{
16 collections::{BTreeMap, HashMap},
17 sync::OnceLock,
18 time::{Duration as StdDuration, Instant},
19};
20
21use serde::Serialize;
22use serde_json::{Number, Value as JsonValue};
23use thiserror::Error;
24
25use crate::{
26 ast::{AlertAction, AlertRule, Duration, DurationUnit},
27 docker::MetricSample,
28 eval::{self, EvalError},
29 metrics::{MetricsCollector, MetricsError},
30};
31
32static ALERT_TIMEOUTS: OnceLock<AlertTimeouts> = OnceLock::new();
36
37struct AlertTimeouts {
38 webhook: StdDuration,
39 restart: StdDuration,
40}
41
42pub fn init_alert_timeouts(webhook_secs: u64, restart_secs: u64) {
44 let _ = ALERT_TIMEOUTS.set(AlertTimeouts {
45 webhook: StdDuration::from_secs(webhook_secs.max(1)),
46 restart: StdDuration::from_secs(restart_secs.max(1)),
47 });
48}
49
50fn webhook_timeout() -> StdDuration {
51 ALERT_TIMEOUTS
52 .get()
53 .map_or_else(|| StdDuration::from_secs(10), |t| t.webhook)
54}
55
56fn restart_timeout() -> StdDuration {
57 ALERT_TIMEOUTS
58 .get()
59 .map_or_else(|| StdDuration::from_secs(30), |t| t.restart)
60}
61
62#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
63pub struct AlertEvent {
64 pub container_id: String,
65 pub container_name: String,
66 pub message: String,
67 pub action: AlertActionPlan,
68}
69
70#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
71pub enum AlertActionPlan {
72 Print {
73 message: String,
74 },
75 Webhook {
76 url: String,
77 executed: bool,
78 },
79 Restart {
80 target: String,
81 executed: bool,
82 },
83 Slack {
84 url: String,
85 executed: bool,
86 },
87 Discord {
88 url: String,
89 executed: bool,
90 },
91 Email {
92 to: String,
93 subject: String,
94 executed: bool,
95 },
96}
97
98#[derive(Debug, Error)]
99pub enum AlertError {
100 #[error("{0}")]
101 Metrics(#[from] MetricsError),
102 #[error("{0}")]
103 Eval(#[from] EvalError),
104 #[error("HTTP request failed: {0}")]
105 Http(String),
106 #[error("Docker restart failed: {0}")]
107 Restart(String),
108}
109
110#[derive(Debug, Default)]
111pub struct AlertEvaluator {
112 active_since: HashMap<String, Instant>,
113}
114
115impl AlertEvaluator {
116 #[must_use]
117 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn evaluate_samples(
122 &mut self,
123 rule: &AlertRule,
124 samples: &[MetricSample],
125 now: Instant,
126 ) -> Result<Vec<AlertEvent>, AlertError> {
127 let mut events = Vec::new();
128
129 for sample in samples {
130 let key = sample_key(sample);
131 let row = sample_fields(sample);
132 let matches = eval::evaluate_expression(&row, &rule.condition)?;
133
134 if !matches {
135 self.active_since.remove(&key);
136 continue;
137 }
138
139 let since = *self.active_since.entry(key).or_insert(now);
140 let elapsed = now.saturating_duration_since(since);
141 let required = rule.duration.map(duration_to_std).unwrap_or_default();
142
143 if elapsed >= required {
144 events.push(alert_event(rule, sample));
145 }
146 }
147
148 Ok(events)
149 }
150}
151
152pub async fn evaluate_alert_once<C>(
153 rule: &AlertRule,
154 collector: &C,
155) -> Result<Vec<AlertEvent>, AlertError>
156where
157 C: MetricsCollector + ?Sized,
158{
159 let mut evaluator = AlertEvaluator::new();
160 let samples = collector.collect().await?;
161 evaluator.evaluate_samples(rule, &samples, Instant::now())
162}
163
164#[must_use]
165pub fn render_alert_event(event: &AlertEvent) -> String {
166 match &event.action {
167 AlertActionPlan::Print { message } => {
168 format!(
169 "{} [{}]: {}",
170 event.container_name, event.container_id, message
171 )
172 }
173 AlertActionPlan::Webhook { url, executed } => {
174 if *executed {
175 format!(
176 "{} [{}]: POSTED alert to {}",
177 event.container_name, event.container_id, url
178 )
179 } else {
180 format!(
181 "{} [{}]: FAILED to POST alert to {}",
182 event.container_name, event.container_id, url
183 )
184 }
185 }
186 AlertActionPlan::Restart { target, executed } => {
187 if *executed {
188 format!(
189 "{} [{}]: RESTARTED {}",
190 event.container_name, event.container_id, target
191 )
192 } else {
193 format!(
194 "{} [{}]: FAILED to restart {}",
195 event.container_name, event.container_id, target
196 )
197 }
198 }
199 AlertActionPlan::Slack { url, executed } => {
200 if *executed {
201 format!(
202 "{} [{}]: POSTED Slack alert to {}",
203 event.container_name, event.container_id, url
204 )
205 } else {
206 format!(
207 "{} [{}]: FAILED to POST Slack alert to {}",
208 event.container_name, event.container_id, url
209 )
210 }
211 }
212 AlertActionPlan::Discord { url, executed } => {
213 if *executed {
214 format!(
215 "{} [{}]: POSTED Discord alert to {}",
216 event.container_name, event.container_id, url
217 )
218 } else {
219 format!(
220 "{} [{}]: FAILED to POST Discord alert to {}",
221 event.container_name, event.container_id, url
222 )
223 }
224 }
225 AlertActionPlan::Email {
226 to,
227 subject,
228 executed,
229 } => {
230 if *executed {
231 format!(
232 "{} [{}]: SENT email \"{}\" to {}",
233 event.container_name, event.container_id, subject, to
234 )
235 } else {
236 format!(
237 "{} [{}]: FAILED to send email \"{}\" to {}",
238 event.container_name, event.container_id, subject, to
239 )
240 }
241 }
242 }
243}
244
245#[must_use]
246pub const fn duration_to_std(duration: Duration) -> StdDuration {
247 let seconds = match duration.unit {
248 DurationUnit::Seconds => duration.value,
249 DurationUnit::Minutes => duration.value * 60,
250 DurationUnit::Hours => duration.value * 60 * 60,
251 DurationUnit::Days => duration.value * 60 * 60 * 24,
252 };
253 StdDuration::from_secs(seconds)
254}
255
256fn alert_event(rule: &AlertRule, sample: &MetricSample) -> AlertEvent {
257 let action = match &rule.action {
258 AlertAction::Print(message) => AlertActionPlan::Print {
259 message: message.clone(),
260 },
261 AlertAction::Webhook(url) => {
262 let executed = execute_webhook(url);
263 AlertActionPlan::Webhook {
264 url: url.clone(),
265 executed,
266 }
267 }
268 AlertAction::Restart(target) => {
269 let target_str = format!("{:?} {}", target.kind, target.value);
270 let executed = execute_restart(&target_str);
271 AlertActionPlan::Restart {
272 target: target_str,
273 executed,
274 }
275 }
276 AlertAction::Slack(url) => {
277 let executed = execute_slack(url, &sample_key(sample));
278 AlertActionPlan::Slack {
279 url: url.clone(),
280 executed,
281 }
282 }
283 AlertAction::Discord(url) => {
284 let executed = execute_discord(url, &sample_key(sample));
285 AlertActionPlan::Discord {
286 url: url.clone(),
287 executed,
288 }
289 }
290 AlertAction::Email { to, subject } => {
291 let executed = execute_email(to, subject, &sample_key(sample));
292 AlertActionPlan::Email {
293 to: to.clone(),
294 subject: subject.clone(),
295 executed,
296 }
297 }
298 };
299 let message = render_action_message(&action);
300
301 AlertEvent {
302 container_id: sample.container_id.clone(),
303 container_name: sample.container_name.clone(),
304 message,
305 action,
306 }
307}
308
309fn execute_webhook(url: &str) -> bool {
310 execute_webhook_payload(url, &serde_json::json!({}))
311}
312
313fn execute_slack(url: &str, container: &str) -> bool {
314 let message = format!(
315 "[DockQL Alert] Container `{}` triggered an alert",
316 container
317 );
318 let payload = serde_json::json!({
319 "text": message,
320 "blocks": [
321 {
322 "type": "header",
323 "text": {
324 "type": "plain_text",
325 "text": "DockQL Alert"
326 }
327 },
328 {
329 "type": "section",
330 "fields": [
331 {
332 "type": "mrkdwn",
333 "text": format!("*Container:* {}", container)
334 },
335 {
336 "type": "mrkdwn",
337 "text": format!("*Time:* {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"))
338 }
339 ]
340 }
341 ]
342 });
343 execute_webhook_payload(url, &payload)
344}
345
346fn execute_discord(url: &str, container: &str) -> bool {
347 let payload = serde_json::json!({
348 "content": format!("DockQL Alert for `{}`", container),
349 "embeds": [
350 {
351 "title": "DockQL Alert",
352 "description": format!("Container **{}** triggered an alert", container),
353 "color": 0xFF0000,
354 "fields": [
355 {
356 "name": "Container",
357 "value": container,
358 "inline": true
359 },
360 {
361 "name": "Time",
362 "value": chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
363 "inline": true
364 }
365 ],
366 "footer": {
367 "text": "DockQL"
368 },
369 "timestamp": chrono::Utc::now().to_rfc3339()
370 }
371 ]
372 });
373 execute_webhook_payload(url, &payload)
374}
375
376fn execute_email(to: &str, subject: &str, container: &str) -> bool {
377 use lettre::{
378 AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
379 transport::smtp::authentication::Credentials,
380 };
381
382 let body = format!(
383 "DockQL Alert\n\nContainer: {}\nSubject: {}\nTime: {}\n\nThis is an automated alert from DockQL.\n",
384 container,
385 subject,
386 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
387 );
388
389 let email = match Message::builder()
390 .from(
391 "DockQL <noreply@dockql>"
392 .parse()
393 .expect("hardcoded DockQL noreply email address"),
394 )
395 .to(to.parse().expect("invalid recipient email address"))
396 .subject(subject)
397 .body(body)
398 {
399 Ok(msg) => msg,
400 Err(e) => {
401 eprintln!("Failed to build email: {e}");
402 return false;
403 }
404 };
405
406 let cfg = crate::config::DolConfig::load();
408 let smtp_host = cfg
409 .smtp_host
410 .clone()
411 .or_else(|| std::env::var("DOCKQL_SMTP_HOST").ok())
412 .unwrap_or_else(|| "localhost".to_owned());
413 let smtp_port: u16 = cfg
414 .smtp_port
415 .or_else(|| {
416 std::env::var("DOCKQL_SMTP_PORT")
417 .ok()
418 .and_then(|p| p.parse().ok())
419 })
420 .unwrap_or(25);
421 let smtp_user = cfg
422 .smtp_user
423 .clone()
424 .or_else(|| std::env::var("DOCKQL_SMTP_USER").ok());
425 let smtp_pass = cfg
426 .smtp_pass
427 .clone()
428 .or_else(|| std::env::var("DOCKQL_SMTP_PASS").ok());
429
430 async fn do_email(
431 email: Message,
432 host: String,
433 port: u16,
434 user: Option<String>,
435 pass: Option<String>,
436 ) -> Result<bool, String> {
437 let creds = match (user, pass) {
438 (Some(u), Some(p)) => Some(Credentials::new(u, p)),
439 _ => None,
440 };
441
442 let mailer = match creds {
443 Some(c) => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
444 .map_err(|e| format!("SMTP relay setup: {e}"))?
445 .port(port)
446 .credentials(c)
447 .build(),
448 None => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
449 .map_err(|e| format!("SMTP relay setup: {e}"))?
450 .port(port)
451 .build(),
452 };
453
454 tokio::time::timeout(std::time::Duration::from_secs(30), async {
455 mailer
456 .send(email)
457 .await
458 .map_err(|e| format!("SMTP send: {e}"))?;
459 Ok(true)
460 })
461 .await
462 .map_err(|_| "SMTP send timed out".to_owned())?
463 }
464
465 match std::thread::spawn(move || {
466 let rt = match tokio::runtime::Runtime::new() {
467 Ok(rt) => rt,
468 Err(e) => {
469 eprintln!("Failed to create runtime for email: {e}");
470 return Ok(false);
471 }
472 };
473 rt.block_on(do_email(email, smtp_host, smtp_port, smtp_user, smtp_pass))
474 })
475 .join()
476 {
477 Ok(Ok(success)) => success,
478 Ok(Err(e)) => {
479 eprintln!("Email send failed: {e}");
480 false
481 }
482 Err(_) => {
483 eprintln!("Email thread panicked");
484 false
485 }
486 }
487}
488
489fn execute_webhook_payload(url: &str, payload: &serde_json::Value) -> bool {
491 async fn do_post(
492 url: &str,
493 payload: &serde_json::Value,
494 timeout: StdDuration,
495 ) -> Result<bool, String> {
496 let client = reqwest::Client::builder()
497 .timeout(timeout)
498 .build()
499 .map_err(|e| format!("reqwest client: {e}"))?;
500 let resp = client
501 .post(url)
502 .json(payload)
503 .send()
504 .await
505 .map_err(|e| format!("request failed: {e}"))?;
506 Ok(resp.status().is_success())
507 }
508
509 let timeout = webhook_timeout();
510 let url = url.to_owned();
511 let payload = payload.clone();
512 match std::thread::spawn(move || match tokio::runtime::Runtime::new() {
513 Ok(rt) => rt.block_on(do_post(&url, &payload, timeout)),
514 Err(e) => {
515 eprintln!("Failed to create runtime: {e}");
516 Ok(false)
517 }
518 })
519 .join()
520 {
521 Ok(Ok(success)) => success,
522 Ok(Err(e)) => {
523 eprintln!("Webhook POST failed: {e}");
524 false
525 }
526 Err(_) => {
527 eprintln!("Webhook thread panicked");
528 false
529 }
530 }
531}
532
533fn execute_restart(target: &str) -> bool {
534 async fn do_restart(target: &str, timeout: StdDuration) -> Result<bool, String> {
535 let docker = bollard::Docker::connect_with_local_defaults()
536 .map_err(|e| format!("bollard connect: {e}"))?;
537 tokio::time::timeout(
538 timeout,
539 docker.restart_container(
540 target,
541 None::<bollard::query_parameters::RestartContainerOptions>,
542 ),
543 )
544 .await
545 .map_err(|_| format!("restart timed out after {}s", timeout.as_secs()))?
546 .map_err(|e| format!("restart failed: {e}"))?;
547 Ok(true)
548 }
549
550 let timeout = restart_timeout();
551 let target = target.to_owned();
552 match std::thread::spawn(move || match tokio::runtime::Runtime::new() {
553 Ok(rt) => rt.block_on(do_restart(&target, timeout)),
554 Err(e) => {
555 eprintln!("Failed to create runtime for restart: {e}");
556 Ok(false)
557 }
558 })
559 .join()
560 {
561 Ok(Ok(success)) => success,
562 Ok(Err(e)) => {
563 eprintln!("Container restart failed: {e}");
564 false
565 }
566 Err(_) => {
567 eprintln!("Restart thread panicked");
568 false
569 }
570 }
571}
572
573fn render_action_message(action: &AlertActionPlan) -> String {
574 match action {
575 AlertActionPlan::Print { message } => message.clone(),
576 AlertActionPlan::Webhook { url, executed } => {
577 if *executed {
578 format!("webhook alert sent to {url}")
579 } else {
580 format!("webhook alert FAILED for {url}")
581 }
582 }
583 AlertActionPlan::Restart { target, executed } => {
584 if *executed {
585 format!("restarted {target}")
586 } else {
587 format!("restart FAILED for {target}")
588 }
589 }
590 AlertActionPlan::Slack { url, executed } => {
591 if *executed {
592 format!("Slack alert sent to {url}")
593 } else {
594 format!("Slack alert FAILED for {url}")
595 }
596 }
597 AlertActionPlan::Discord { url, executed } => {
598 if *executed {
599 format!("Discord alert sent to {url}")
600 } else {
601 format!("Discord alert FAILED for {url}")
602 }
603 }
604 AlertActionPlan::Email {
605 to,
606 subject,
607 executed,
608 } => {
609 if *executed {
610 format!("email \"{subject}\" sent to {to}")
611 } else {
612 format!("email \"{subject}\" FAILED for {to}")
613 }
614 }
615 }
616}
617
618fn sample_key(sample: &MetricSample) -> String {
619 if sample.container_id.is_empty() {
620 sample.container_name.clone()
621 } else {
622 sample.container_id.clone()
623 }
624}
625
626fn sample_fields(sample: &MetricSample) -> BTreeMap<String, JsonValue> {
627 BTreeMap::from([
628 (
629 "container_id".to_owned(),
630 JsonValue::String(sample.container_id.clone()),
631 ),
632 (
633 "container_name".to_owned(),
634 JsonValue::String(sample.container_name.clone()),
635 ),
636 (
637 "name".to_owned(),
638 JsonValue::String(sample.container_name.clone()),
639 ),
640 (
641 "timestamp".to_owned(),
642 JsonValue::String(sample.timestamp.clone()),
643 ),
644 ("cpu".to_owned(), json_option_f64(sample.cpu_percent)),
645 (
646 "memory".to_owned(),
647 json_option_u64(sample.memory_usage_bytes),
648 ),
649 (
650 "memory_limit".to_owned(),
651 json_option_u64(sample.memory_limit_bytes),
652 ),
653 (
654 "network_rx".to_owned(),
655 json_option_u64(sample.network_rx_bytes),
656 ),
657 (
658 "network_tx".to_owned(),
659 json_option_u64(sample.network_tx_bytes),
660 ),
661 (
662 "disk_read".to_owned(),
663 json_option_u64(sample.disk_read_bytes),
664 ),
665 (
666 "disk_write".to_owned(),
667 json_option_u64(sample.disk_write_bytes),
668 ),
669 ])
670}
671
672fn json_option_f64(value: Option<f64>) -> JsonValue {
673 value
674 .and_then(Number::from_f64)
675 .map_or(JsonValue::Null, JsonValue::Number)
676}
677
678fn json_option_u64(value: Option<u64>) -> JsonValue {
679 value
680 .map(Number::from)
681 .map_or(JsonValue::Null, JsonValue::Number)
682}
683
684#[cfg(test)]
685mod tests {
686 use std::time::Duration as StdDuration;
687
688 use crate::{ast::DurationUnit, metrics::MockMetricsCollector, parser};
689
690 const JUST_PAST_TWO_MINUTES: StdDuration = StdDuration::from_secs(121);
692 const ONE_MINUTE: StdDuration = StdDuration::from_secs(60);
694
695 use super::*;
696
697 #[test]
698 fn fires_print_alert_without_duration() {
699 let rule = alert_rule("alert when cpu > 80% then print \"High CPU\"");
700 let collector = MockMetricsCollector {
701 samples: vec![sample("api", 87.5)],
702 };
703
704 let rt = tokio::runtime::Runtime::new().expect("runtime");
705 let events = rt
706 .block_on(evaluate_alert_once(&rule, &collector))
707 .expect("alert should evaluate");
708
709 assert_eq!(events.len(), 1);
710 assert_eq!(
711 events[0].action,
712 AlertActionPlan::Print {
713 message: "High CPU".to_owned()
714 }
715 );
716 }
717
718 #[test]
719 fn honors_duration_guard() {
720 let mut rule = alert_rule("alert when cpu > 80% for 2m then print \"High CPU\"");
721 rule.duration = Some(Duration {
722 value: 2,
723 unit: DurationUnit::Minutes,
724 });
725 let mut evaluator = AlertEvaluator::new();
726 let samples = vec![sample("api", 90.0)];
727 let start = Instant::now();
728
729 let first = evaluator
730 .evaluate_samples(&rule, &samples, start)
731 .expect("first evaluation should pass");
732 let second = evaluator
733 .evaluate_samples(&rule, &samples, start + JUST_PAST_TWO_MINUTES)
734 .expect("second evaluation should pass");
735
736 assert!(first.is_empty());
737 assert_eq!(second.len(), 1);
738 }
739
740 #[test]
741 fn clears_duration_guard_when_condition_recovers() {
742 let rule = alert_rule("alert when cpu > 80% for 2m then print \"High CPU\"");
743 let mut evaluator = AlertEvaluator::new();
744 let start = Instant::now();
745
746 evaluator
747 .evaluate_samples(&rule, &[sample("api", 90.0)], start)
748 .expect("evaluation should pass");
749 evaluator
750 .evaluate_samples(&rule, &[sample("api", 20.0)], start + ONE_MINUTE)
751 .expect("evaluation should pass");
752 let events = evaluator
753 .evaluate_samples(&rule, &[sample("api", 90.0)], start + JUST_PAST_TWO_MINUTES)
754 .expect("evaluation should pass");
755
756 assert!(events.is_empty());
757 }
758
759 #[test]
760 fn plans_webhook_and_restart_as_actions() {
761 let webhook = alert_rule("alert when cpu > 80% then webhook \"http://localhost/hook\"");
762 let restart = alert_rule("alert when cpu > 80% then restart container api");
763 let collector = MockMetricsCollector {
764 samples: vec![sample("api", 90.0)],
765 };
766
767 let rt = tokio::runtime::Runtime::new().expect("runtime");
768 let webhook_events = rt
769 .block_on(evaluate_alert_once(&webhook, &collector))
770 .expect("webhook");
771 let restart_events = rt
772 .block_on(evaluate_alert_once(&restart, &collector))
773 .expect("restart");
774
775 assert!(matches!(
776 webhook_events[0].action,
777 AlertActionPlan::Webhook { .. }
778 ));
779 assert!(matches!(
780 restart_events[0].action,
781 AlertActionPlan::Restart { .. }
782 ));
783 }
784
785 #[test]
786 fn plans_slack_discord_email_as_actions() {
787 let slack =
788 alert_rule(r#"alert when cpu > 80% then slack "https://hooks.slack.com/services/xxx""#);
789 let discord = alert_rule(
790 r#"alert when cpu > 80% then discord "https://discord.com/api/webhooks/xxx""#,
791 );
792 let email = alert_rule(r#"alert when cpu > 80% then email "admin@example.com""#);
793 let email_with_subject =
794 alert_rule(r#"alert when cpu > 80% then email "admin@example.com" subject "High CPU""#);
795 let collector = MockMetricsCollector {
796 samples: vec![sample("api", 90.0)],
797 };
798
799 let rt = tokio::runtime::Runtime::new().expect("runtime");
800 let slack_events = rt
801 .block_on(evaluate_alert_once(&slack, &collector))
802 .expect("slack");
803 let discord_events = rt
804 .block_on(evaluate_alert_once(&discord, &collector))
805 .expect("discord");
806 let email_events = rt
807 .block_on(evaluate_alert_once(&email, &collector))
808 .expect("email");
809 let email_subject_events = rt
810 .block_on(evaluate_alert_once(&email_with_subject, &collector))
811 .expect("email with subject");
812
813 assert!(
814 matches!(slack_events[0].action, AlertActionPlan::Slack { .. }),
815 "expected Slack action"
816 );
817 assert!(
818 matches!(discord_events[0].action, AlertActionPlan::Discord { .. }),
819 "expected Discord action"
820 );
821 assert!(
822 matches!(email_events[0].action, AlertActionPlan::Email { .. }),
823 "expected Email action"
824 );
825 assert!(
826 matches!(
827 email_subject_events[0].action,
828 AlertActionPlan::Email { .. }
829 ),
830 "expected Email action with custom subject"
831 );
832
833 if let AlertActionPlan::Email {
835 ref subject,
836 executed,
837 ..
838 } = email_subject_events[0].action
839 {
840 assert_eq!(subject, "High CPU");
841 assert!(!executed, "email should not actually send in tests");
842 }
843 }
844
845 fn alert_rule(source: &str) -> AlertRule {
846 let parsed = parser::parse(source).expect("alert should parse");
847 let crate::ast::Query::Alert(rule) = parsed.query else {
848 panic!("expected alert rule");
849 };
850 rule
851 }
852
853 fn sample(name: &str, cpu: f64) -> MetricSample {
854 MetricSample {
855 container_id: format!("{name}-id"),
856 container_name: name.to_owned(),
857 timestamp: "2026-05-31T02:00:00Z".to_owned(),
858 cpu_percent: Some(cpu),
859 memory_usage_bytes: Some(128),
860 memory_limit_bytes: Some(1024),
861 network_rx_bytes: Some(1),
862 network_tx_bytes: Some(2),
863 disk_read_bytes: Some(3),
864 disk_write_bytes: Some(4),
865 }
866 }
867}