grafos_profile/
timeline.rs

1//! Lease timeline — Gantt chart of all leases held over time.
2//!
3//! Reconstructs per-lease lifecycles from [`ResourceSpan`] data and renders
4//! as an interactive HTML Gantt chart with utilization overlay.
5
6use alloc::collections::BTreeMap;
7use alloc::string::String;
8use alloc::vec::Vec;
9
10use grafos_observe::event::ResourceType;
11
12use crate::recording::ProfileRecording;
13
14/// A single lease's lifecycle entry for the timeline.
15#[derive(Debug, Clone)]
16pub struct LeaseTimelineEntry {
17    /// Lease ID.
18    pub lease_id: u128,
19    /// Dominant resource type (inferred from operations).
20    pub resource_type: Option<ResourceType>,
21    /// First time this lease was observed (Unix microseconds).
22    pub acquired_at: u64,
23    /// Last time this lease was observed (Unix microseconds).
24    pub released_at: u64,
25    /// Total bytes associated with this lease (from lease_cost / duration approximation).
26    pub capacity_approx: u64,
27    /// Peak bytes used (max of bytes_read + bytes_written across spans).
28    pub peak_usage: u64,
29    /// Total operations on this lease.
30    pub total_ops: u64,
31    /// Total acquire wait time across all spans touching this lease.
32    pub total_acquire_wait_us: u64,
33    /// Lease cost in byte-seconds.
34    pub lease_cost_byte_secs: u64,
35}
36
37impl LeaseTimelineEntry {
38    /// Utilization percentage: peak_usage / capacity_approx.
39    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    /// Duration in microseconds.
48    pub fn duration_us(&self) -> u64 {
49        self.released_at.saturating_sub(self.acquired_at)
50    }
51}
52
53/// Lease timeline (Gantt chart) computed from a recording.
54#[derive(Debug, Clone)]
55pub struct LeaseTimeline {
56    /// Per-lease lifecycle entries, sorted by acquired_at.
57    pub entries: Vec<LeaseTimelineEntry>,
58    /// Overall start time (Unix microseconds).
59    pub start_time: u64,
60    /// Overall end time (Unix microseconds).
61    pub end_time: u64,
62}
63
64impl LeaseTimeline {
65    /// Build a timeline from a profile recording.
66    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                // Update time bounds
86                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                // Track peak usage
94                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                // Accumulate ops
100                let span_ops: u64 = span.ops.iter().map(|(_, c)| c).sum();
101                entry.total_ops += span_ops;
102
103                // Accumulate cost (proportional to this lease's share)
104                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                // Accumulate acquire wait
110                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                // Infer resource type from ops
116                if entry.resource_type.is_none() && !span.ops.is_empty() {
117                    // Use the resource type with the most ops
118                    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        // Approximate capacity from cost / duration
137        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    /// Render as self-contained HTML Gantt chart.
158    #[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; // pixels for the bar area
181
182        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        // Tooltip JS
240        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        // Span 1 touches lease 1 and 2
296        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        // Span 2 touches lease 2 and 3
306        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        // Span 3 touches lease 3 only
316        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        // Lease 1: only in s1
330        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        // Lease 2: in s1 and s2
335        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        // Lease 3: in s2 and s3
340        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        // Timeline bounds
345        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}