grafos_profile/
timeline.rs1use alloc::collections::BTreeMap;
7use alloc::string::String;
8use alloc::vec::Vec;
9
10use grafos_observe::event::ResourceType;
11
12use crate::recording::ProfileRecording;
13
14#[derive(Debug, Clone)]
16pub struct LeaseTimelineEntry {
17 pub lease_id: u128,
19 pub resource_type: Option<ResourceType>,
21 pub acquired_at: u64,
23 pub released_at: u64,
25 pub capacity_approx: u64,
27 pub peak_usage: u64,
29 pub total_ops: u64,
31 pub total_acquire_wait_us: u64,
33 pub lease_cost_byte_secs: u64,
35}
36
37impl LeaseTimelineEntry {
38 pub fn utilization_pct(&self) -> f64 {
40 if self.capacity_approx == 0 {
41 0.0
42 } else {
43 (self.peak_usage as f64 / self.capacity_approx as f64) * 100.0
44 }
45 }
46
47 pub fn duration_us(&self) -> u64 {
49 self.released_at.saturating_sub(self.acquired_at)
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct LeaseTimeline {
56 pub entries: Vec<LeaseTimelineEntry>,
58 pub start_time: u64,
60 pub end_time: u64,
62}
63
64impl LeaseTimeline {
65 pub fn from_recording(rec: &ProfileRecording) -> Self {
67 let mut lease_map: BTreeMap<u128, LeaseTimelineEntry> = BTreeMap::new();
68
69 for span in &rec.spans {
70 for &lease_id in &span.lease_ids {
71 let entry = lease_map
72 .entry(lease_id)
73 .or_insert_with(|| LeaseTimelineEntry {
74 lease_id,
75 resource_type: None,
76 acquired_at: span.start_time_unix_us,
77 released_at: span.end_time_unix_us,
78 capacity_approx: 0,
79 peak_usage: 0,
80 total_ops: 0,
81 total_acquire_wait_us: 0,
82 lease_cost_byte_secs: 0,
83 });
84
85 if span.start_time_unix_us < entry.acquired_at {
87 entry.acquired_at = span.start_time_unix_us;
88 }
89 if span.end_time_unix_us > entry.released_at {
90 entry.released_at = span.end_time_unix_us;
91 }
92
93 let span_usage = span.bytes_read + span.bytes_written;
95 if span_usage > entry.peak_usage {
96 entry.peak_usage = span_usage;
97 }
98
99 let span_ops: u64 = span.ops.iter().map(|(_, c)| c).sum();
101 entry.total_ops += span_ops;
102
103 if !span.lease_ids.is_empty() {
105 let share = span.lease_cost_byte_secs / span.lease_ids.len() as u64;
106 entry.lease_cost_byte_secs += share;
107 }
108
109 if !span.lease_ids.is_empty() {
111 let wait_share = span.lease_acquire_wait_us / span.lease_ids.len() as u64;
112 entry.total_acquire_wait_us += wait_share;
113 }
114
115 if entry.resource_type.is_none() && !span.ops.is_empty() {
117 let mut type_counts: BTreeMap<String, u64> = BTreeMap::new();
119 for &(ref op_key, count) in &span.ops {
120 let key = alloc::format!("{}", op_key.resource_type);
121 *type_counts.entry(key).or_insert(0) += count;
122 }
123 if let Some((key, _)) = type_counts.iter().max_by_key(|(_, &v)| v) {
124 entry.resource_type = match key.as_str() {
125 "mem" => Some(ResourceType::Mem),
126 "block" => Some(ResourceType::Block),
127 "gpu" => Some(ResourceType::Gpu),
128 "cpu" => Some(ResourceType::Cpu),
129 _ => None,
130 };
131 }
132 }
133 }
134 }
135
136 for entry in lease_map.values_mut() {
138 let duration_secs = entry.duration_us() as f64 / 1_000_000.0;
139 if duration_secs > 0.0 {
140 entry.capacity_approx = (entry.lease_cost_byte_secs as f64 / duration_secs) as u64;
141 }
142 }
143
144 let mut entries: Vec<LeaseTimelineEntry> = lease_map.into_values().collect();
145 entries.sort_by_key(|e| e.acquired_at);
146
147 let start_time = entries.first().map(|e| e.acquired_at).unwrap_or(0);
148 let end_time = entries.last().map(|e| e.released_at).unwrap_or(0);
149
150 LeaseTimeline {
151 entries,
152 start_time,
153 end_time,
154 }
155 }
156
157 #[cfg(feature = "html")]
159 pub fn render_html(&self) -> String {
160 let mut html = String::new();
161 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
162 html.push_str("<meta charset=\"utf-8\">\n");
163 html.push_str("<title>grafOS Lease Timeline</title>\n");
164 html.push_str("<style>\n");
165 html.push_str("body { font-family: monospace; margin: 0; padding: 20px; background: #1a1a2e; color: #eee; }\n");
166 html.push_str("h1 { font-size: 18px; }\n");
167 html.push_str(".timeline { position: relative; margin-top: 20px; }\n");
168 html.push_str(".lease-row { position: relative; height: 28px; margin-bottom: 4px; }\n");
169 html.push_str(".lease-label { position: absolute; left: 0; width: 120px; font-size: 11px; line-height: 28px; }\n");
170 html.push_str(".lease-bar-bg { position: absolute; left: 130px; height: 24px; top: 2px; border-radius: 3px; opacity: 0.4; }\n");
171 html.push_str(".lease-bar-fg { position: absolute; left: 130px; height: 24px; top: 2px; border-radius: 3px; }\n");
172 html.push_str(
173 ".tooltip { position: fixed; background: #333; color: #fff; padding: 8px 12px; ",
174 );
175 html.push_str("border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 100; white-space: pre-wrap; }\n");
176 html.push_str("</style>\n</head>\n<body>\n");
177 html.push_str("<h1>grafOS Lease Timeline</h1>\n");
178
179 let total_duration = self.end_time.saturating_sub(self.start_time);
180 let chart_width = 800.0; html.push_str(&alloc::format!(
183 "<p>Duration: {} us | {} leases</p>\n",
184 total_duration,
185 self.entries.len()
186 ));
187
188 html.push_str("<div class=\"timeline\">\n");
189
190 for entry in &self.entries {
191 let offset_frac = if total_duration > 0 {
192 (entry.acquired_at - self.start_time) as f64 / total_duration as f64
193 } else {
194 0.0
195 };
196 let width_frac = if total_duration > 0 {
197 entry.duration_us() as f64 / total_duration as f64
198 } else {
199 1.0
200 };
201 let util_frac = if entry.capacity_approx > 0 {
202 (entry.peak_usage as f64 / entry.capacity_approx as f64).min(1.0)
203 } else {
204 0.0
205 };
206
207 let bg_color = resource_type_color(entry.resource_type);
208 let x = 130.0 + offset_frac * chart_width;
209 let w_bg = (width_frac * chart_width).max(2.0);
210 let w_fg = w_bg * util_frac;
211
212 let lease_hex = alloc::format!("{:x}", entry.lease_id);
213 let short_id = if lease_hex.len() > 8 {
214 &lease_hex[..8]
215 } else {
216 &lease_hex
217 };
218
219 html.push_str("<div class=\"lease-row\">\n");
220 html.push_str(&alloc::format!(
221 " <span class=\"lease-label\">{}</span>\n",
222 short_id
223 ));
224 html.push_str(&alloc::format!(
225 " <div class=\"lease-bar-bg\" style=\"left:{x:.1}px;width:{w_bg:.1}px;background:{bg_color}\" \
226 data-id=\"{lease_hex}\" data-util=\"{:.1}\" data-ops=\"{}\" data-cost=\"{}\"></div>\n",
227 entry.utilization_pct(),
228 entry.total_ops,
229 entry.lease_cost_byte_secs,
230 ));
231 html.push_str(&alloc::format!(
232 " <div class=\"lease-bar-fg\" style=\"left:{x:.1}px;width:{w_fg:.1}px;background:{bg_color}\"></div>\n",
233 ));
234 html.push_str("</div>\n");
235 }
236
237 html.push_str("</div>\n");
238
239 html.push_str("<div class=\"tooltip\" id=\"tip\" style=\"display:none\"></div>\n");
241 html.push_str("<script>\n");
242 html.push_str("document.querySelectorAll('.lease-bar-bg').forEach(b => {\n");
243 html.push_str(" b.addEventListener('mouseenter', e => {\n");
244 html.push_str(" const t = document.getElementById('tip');\n");
245 html.push_str(" t.textContent = `Lease: ${b.dataset.id}\\nUtilization: ${b.dataset.util}%\\nOps: ${b.dataset.ops}\\nCost: ${b.dataset.cost} byte-secs`;\n");
246 html.push_str(" t.style.display = 'block';\n");
247 html.push_str(" });\n");
248 html.push_str(" b.addEventListener('mouseleave', () => document.getElementById('tip').style.display = 'none');\n");
249 html.push_str(" b.addEventListener('mousemove', e => {\n");
250 html.push_str(" const t = document.getElementById('tip');\n");
251 html.push_str(
252 " t.style.left = (e.clientX + 10) + 'px'; t.style.top = (e.clientY + 10) + 'px';\n",
253 );
254 html.push_str(" });\n");
255 html.push_str("});\n");
256 html.push_str("</script>\n");
257 html.push_str("</body>\n</html>\n");
258
259 html
260 }
261}
262
263fn resource_type_color(rt: Option<ResourceType>) -> &'static str {
264 match rt {
265 Some(ResourceType::Mem) => "#4a90d9",
266 Some(ResourceType::Block) => "#50c878",
267 Some(ResourceType::Gpu) => "#ff8c42",
268 Some(ResourceType::Cpu) => "#9b59b6",
269 Some(ResourceType::Net) => "#e74c3c",
270 None => "#999",
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use grafos_observe::event::{OpType, ResourceType};
278 use grafos_observe::span::ResourceSpan;
279 use grafos_observe::trace::TraceContext;
280
281 fn test_ctx() -> TraceContext {
282 let mut bytes = [0u8; 24];
283 for (i, b) in bytes.iter_mut().enumerate() {
284 *b = (i as u8).wrapping_add(0x42);
285 }
286 TraceContext::new_root(&bytes)
287 }
288
289 #[test]
290 fn timeline_three_overlapping_leases() {
291 let ctx = test_ctx();
292 let c1 = ctx.child(&[0xAA; 8]);
293 let c2 = ctx.child(&[0xBB; 8]);
294
295 let mut s1 = ResourceSpan::new("op_a", ctx);
297 s1.start_time_unix_us = 1_000_000;
298 s1.end_time_unix_us = 3_000_000;
299 s1.add_lease_id(1);
300 s1.add_lease_id(2);
301 s1.bytes_read = 4096;
302 s1.lease_cost_byte_secs = 200;
303 s1.record_op(ResourceType::Mem, OpType::Read, 10);
304
305 let mut s2 = ResourceSpan::new("op_b", c1);
307 s2.start_time_unix_us = 2_000_000;
308 s2.end_time_unix_us = 4_000_000;
309 s2.add_lease_id(2);
310 s2.add_lease_id(3);
311 s2.bytes_written = 8192;
312 s2.lease_cost_byte_secs = 300;
313 s2.record_op(ResourceType::Block, OpType::WriteBlock, 5);
314
315 let mut s3 = ResourceSpan::new("op_c", c2);
317 s3.start_time_unix_us = 3_000_000;
318 s3.end_time_unix_us = 5_000_000;
319 s3.add_lease_id(3);
320 s3.bytes_read = 2048;
321 s3.lease_cost_byte_secs = 100;
322 s3.record_op(ResourceType::Mem, OpType::Read, 3);
323
324 let rec = ProfileRecording::from_spans(vec![s1, s2, s3]);
325 let tl = LeaseTimeline::from_recording(&rec);
326
327 assert_eq!(tl.entries.len(), 3);
328
329 let l1 = tl.entries.iter().find(|e| e.lease_id == 1).unwrap();
331 assert_eq!(l1.acquired_at, 1_000_000);
332 assert_eq!(l1.released_at, 3_000_000);
333
334 let l2 = tl.entries.iter().find(|e| e.lease_id == 2).unwrap();
336 assert_eq!(l2.acquired_at, 1_000_000);
337 assert_eq!(l2.released_at, 4_000_000);
338
339 let l3 = tl.entries.iter().find(|e| e.lease_id == 3).unwrap();
341 assert_eq!(l3.acquired_at, 2_000_000);
342 assert_eq!(l3.released_at, 5_000_000);
343
344 assert_eq!(tl.start_time, 1_000_000);
346 assert_eq!(tl.end_time, 5_000_000);
347 }
348
349 #[cfg(feature = "html")]
350 #[test]
351 fn timeline_html_structure() {
352 let ctx = test_ctx();
353 let mut span = ResourceSpan::new("test", ctx);
354 span.start_time_unix_us = 0;
355 span.end_time_unix_us = 1_000_000;
356 span.add_lease_id(42);
357 span.lease_cost_byte_secs = 100;
358
359 let rec = ProfileRecording::from_spans(vec![span]);
360 let tl = LeaseTimeline::from_recording(&rec);
361 let html = tl.render_html();
362
363 assert!(html.contains("<!DOCTYPE html>"));
364 assert!(html.contains("Lease Timeline"));
365 assert!(html.contains("</html>"));
366 }
367}