use crate::system_monitor::{MonitorData, SeriesData, TraceEvent};
use plotters::prelude::*;
use std::fmt::Write as _;
struct ChartSeries {
label: String,
color: RGBColor,
points: SeriesData<f64>,
}
impl ChartSeries {
fn new(label: &str, color: RGBColor, points: SeriesData<f64>) -> Self {
ChartSeries {
label: label.into(),
color,
points,
}
}
}
fn lane_start(e: &TraceEvent) -> u64 {
let dl_ms = e.download_end_ms.saturating_sub(e.download_start_ms);
if dl_ms >= 1000 {
e.download_start_ms
} else {
e.replay_start_ms
}
}
fn assign_lanes(events: &[TraceEvent]) -> (Vec<(usize, &TraceEvent)>, usize) {
let mut sorted: Vec<&TraceEvent> = events.iter().collect();
sorted.sort_by_key(|e| lane_start(e));
let mut lane_end: Vec<u64> = Vec::new();
let mut assigned: Vec<(usize, &TraceEvent)> = Vec::new();
for event in sorted {
let start = lane_start(event);
let lane = lane_end
.iter()
.position(|&end| end <= start)
.unwrap_or_else(|| {
lane_end.push(0);
lane_end.len() - 1
});
lane_end[lane] = event.replay_end_ms;
assigned.push((lane, event));
}
let num_lanes = lane_end.len().max(1);
(assigned, num_lanes)
}
fn build_swimlane_svg(events: &[TraceEvent], total_duration_ms: u64) -> String {
if events.is_empty() {
return String::new();
}
let (assigned, num_lanes) = assign_lanes(events);
let row_h: u32 = 24;
let axis_h: u32 = 30;
let top_padding: u32 = 10;
let total_w: u32 = 1000;
let left_offset: u32 = 60; let right_margin: u32 = 70; let chart_w: u32 = total_w - left_offset - right_margin;
let chart_h: u32 = num_lanes as u32 * row_h + axis_h + top_padding;
let total_ms = total_duration_ms.max(1) as f64;
let x_of = |ms: u64| -> f64 { left_offset as f64 + (ms as f64 / total_ms) * chart_w as f64 };
let y_of = |lane: usize| -> f64 { top_padding as f64 + lane as f64 * row_h as f64 };
let mut svg = String::new();
let _ = write!(
svg,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_w}\" height=\"{chart_h}\" \
style=\"display:block;max-width:100%\">"
);
let _ = write!(
svg,
"<rect width=\"100%\" height=\"100%\" fill=\"#f8f9fa\"/>"
);
let tick_count = 10u32;
for i in 0..=tick_count {
let ms = total_duration_ms * i as u64 / tick_count as u64;
let x = x_of(ms) as u32;
let y0 = top_padding;
let y1 = chart_h - axis_h;
let yt = chart_h - axis_h + 4 + 12;
let s = ms as f64 / 1000.0;
let _ = write!(
svg,
"<line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" \
stroke=\"#ccc\" stroke-width=\"1\"/>"
);
let _ = write!(
svg,
"<text x=\"{x}\" y=\"{yt}\" text-anchor=\"middle\" \
font-size=\"10\" fill=\"#666\">{s:.0}s</text>"
);
}
for (lane, event) in &assigned {
let dl_x = x_of(event.download_start_ms);
let dl_w = (x_of(event.download_end_ms) - x_of(event.download_start_ms)).max(1.0);
let rp_x = x_of(event.replay_start_ms);
let rp_w = (x_of(event.replay_end_ms) - x_of(event.replay_start_ms)).max(1.0);
let y = y_of(*lane) + 1.0;
let bar_h = (row_h - 2) as f64;
let replay_color = if event.passed { "#4c8c4a" } else { "#c0392b" };
let basename = event
.trace_name
.rsplit('/')
.next()
.unwrap_or(&event.trace_name);
let escaped_name = html_escape::encode_text(&event.trace_name);
let escaped_base = html_escape::encode_text(basename);
if event.download_wait_end_ms > event.download_start_ms {
let wait_w =
(x_of(event.download_wait_end_ms) - x_of(event.download_start_ms)).max(1.0);
let _ = write!(
svg,
"<rect x=\"{dl_x:.1}\" y=\"{y:.1}\" width=\"{wait_w:.1}\" height=\"{bar_h:.1}\" \
fill=\"#adb5bd\" opacity=\"0.7\" stroke=\"black\" stroke-width=\"1\" \
data-label=\"{escaped_name} (waiting for cache space)\">\
<title>{escaped_name} (waiting for cache space)</title></rect>"
);
let act_x = x_of(event.download_wait_end_ms);
let act_w = (x_of(event.download_end_ms) - x_of(event.download_wait_end_ms)).max(1.0);
let _ = write!(
svg,
"<rect x=\"{act_x:.1}\" y=\"{y:.1}\" width=\"{act_w:.1}\" height=\"{bar_h:.1}\" \
fill=\"#5b9bd5\" opacity=\"0.7\" stroke=\"black\" stroke-width=\"1\" \
data-label=\"{escaped_name} (download)\">\
<title>{escaped_name} (download)</title></rect>"
);
} else {
let _ = write!(
svg,
"<rect x=\"{dl_x:.1}\" y=\"{y:.1}\" width=\"{dl_w:.1}\" height=\"{bar_h:.1}\" \
fill=\"#5b9bd5\" opacity=\"0.7\" stroke=\"black\" stroke-width=\"1\" \
data-label=\"{escaped_name} (download)\">\
<title>{escaped_name} (download)</title></rect>"
);
}
let _ = write!(
svg,
"<rect x=\"{rp_x:.1}\" y=\"{y:.1}\" width=\"{rp_w:.1}\" height=\"{bar_h:.1}\" \
fill=\"{replay_color}\" stroke=\"black\" stroke-width=\"1\" \
data-label=\"{escaped_name}\">\
<title>{escaped_name}</title></rect>"
);
let clip_id = format!("c{lane}_{rp_x:.0}");
let tx = rp_x + 3.0;
let ty = y + bar_h / 2.0;
let _ = write!(
svg,
"<clipPath id=\"{clip_id}\"><rect x=\"{rp_x:.1}\" y=\"{y:.1}\" \
width=\"{rp_w:.1}\" height=\"{bar_h:.1}\"/></clipPath>"
);
let _ = write!(
svg,
"<text x=\"{tx:.1}\" y=\"{ty:.1}\" font-size=\"10\" fill=\"white\" \
clip-path=\"url(#{clip_id})\" dominant-baseline=\"middle\">{escaped_base}</text>"
);
}
svg.push_str("</svg>");
svg
}
struct CommonChartSetup {
width: u32,
height: u32,
x_max: f64,
}
#[derive(Default)]
struct ChartAxis<'a> {
label: &'a str,
max: f64,
series: Vec<ChartSeries>,
h_lines: Vec<(f64, RGBColor, String)>,
}
fn build_line_chart_svg(
title: &str,
setup: &CommonChartSetup,
first_axis: ChartAxis,
second_axis: ChartAxis,
) -> Result<String, Box<dyn std::error::Error>> {
let mut svg_str = String::new();
{
let root =
SVGBackend::with_string(&mut svg_str, (setup.width, setup.height)).into_drawing_area();
root.fill(&WHITE)?;
let (left_axis, right_axis) = if first_axis.series.iter().any(|s| !s.points.is_empty()) {
(first_axis, second_axis)
} else {
(second_axis, first_axis)
};
let left_y_max = left_axis
.series
.iter()
.flat_map(|s| s.points.iter_secs().map(|(_, v)| v))
.chain(left_axis.h_lines.iter().map(|(y, _, _)| *y))
.fold(left_axis.max.max(1.0), f64::max);
let has_right = right_axis.series.iter().any(|s| !s.points.is_empty())
|| !right_axis.h_lines.is_empty();
let right_y_max = right_axis
.series
.iter()
.flat_map(|s| s.points.iter_secs().map(|(_, v)| v))
.chain(right_axis.h_lines.iter().map(|(y, _, _)| *y))
.fold(right_axis.max.max(1.0), f64::max);
let mut chart = ChartBuilder::on(&root)
.caption(title, ("sans-serif", 18))
.margin(10)
.x_label_area_size(30)
.y_label_area_size(50)
.right_y_label_area_size(60)
.build_cartesian_2d(0.0f64..setup.x_max, 0.0f64..left_y_max)?
.set_secondary_coord(0.0f64..setup.x_max, 0.0f64..right_y_max);
chart
.configure_mesh()
.x_desc("Time (s)")
.y_desc(left_axis.label)
.draw()?;
if has_right {
chart
.configure_secondary_axes()
.y_desc(right_axis.label)
.draw()?;
}
for s in &left_axis.series {
if s.points.is_empty() {
continue;
}
chart
.draw_series(LineSeries::new(
s.points.iter_secs(),
s.color.stroke_width(2),
))?
.label(&s.label)
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], s.color.stroke_width(2))
});
}
for (y_val, color, label) in &left_axis.h_lines {
chart.draw_series(std::iter::once(PathElement::new(
vec![(0.0_f64, *y_val), (setup.x_max, *y_val)],
color.mix(0.8).stroke_width(1),
)))?;
chart.draw_series(std::iter::once(Text::new(
label.clone(),
(0.0_f64, *y_val),
("sans-serif", 10).into_font().color(color),
)))?;
}
if has_right {
for s in &right_axis.series {
if s.points.is_empty() {
continue;
}
chart
.draw_secondary_series(LineSeries::new(
s.points.iter_secs(),
s.color.stroke_width(2),
))?
.label(&s.label)
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], s.color.stroke_width(2))
});
}
for (y_val, color, label) in &right_axis.h_lines {
chart.draw_secondary_series(std::iter::once(PathElement::new(
vec![(0.0_f64, *y_val), (setup.x_max, *y_val)],
color.mix(0.8).stroke_width(1),
)))?;
chart.draw_secondary_series(std::iter::once(Text::new(
label.clone(),
(0.0_f64, *y_val),
("sans-serif", 10).into_font().color(color),
)))?;
}
}
chart
.configure_series_labels()
.background_style(WHITE.mix(0.8))
.border_style(BLACK)
.draw()?;
root.present()?;
}
Ok(svg_str)
}
fn build_freq_temp_svg(
data: &MonitorData,
setup: &CommonChartSetup,
) -> Result<String, Box<dyn std::error::Error>> {
let pts = &data.gpu_trip_points;
let n = pts.len();
let trip_lines: Vec<(f64, RGBColor, String)> = pts
.iter()
.enumerate()
.map(|(i, (temp, type_name))| {
let color = if i == n - 1 {
RGBColor(255, 0, 0)
} else {
let t = i as f64 / (n - 1) as f64;
RGBColor(255, (165.0 * (1.0 - t)) as u8, 0)
};
let label = format!("thermal trip {type_name} °C");
(*temp, color, label)
})
.collect();
build_line_chart_svg(
"GPU Frequency & Temperature",
setup,
ChartAxis {
label: "MHz",
max: data.gpu_freq_max_mhz.unwrap_or(1000) as f64,
series: vec![
ChartSeries::new("GFX MHz", RGBColor(41, 128, 185), data.gpu_freq_mhz.clone()),
ChartSeries::new(
"Mem MHz",
RGBColor(39, 174, 96),
data.gpu_mem_freq_mhz.clone(),
),
],
h_lines: vec![],
},
ChartAxis {
label: "°C",
max: 0.0,
series: vec![
ChartSeries::new("CPU °C", RGBColor(231, 76, 60), data.cpu_temp_c.clone()),
ChartSeries::new("GPU °C", RGBColor(142, 68, 173), data.gpu_temp_c.clone()),
],
h_lines: trip_lines,
},
)
}
pub fn build_graphs_html(data: &MonitorData, events: &[TraceEvent]) -> anyhow::Result<String> {
let duration_ms = data.duration_ms();
let swimlane_svg = build_swimlane_svg(events, duration_ms);
let setup = CommonChartSetup {
width: 1000,
height: 300,
x_max: duration_ms as f64 / 1000.0,
};
let system_svg = build_line_chart_svg(
"System Resources",
&setup,
ChartAxis {
label: "%",
max: 100.0,
series: vec![
ChartSeries::new("CPU %", RGBColor(231, 76, 60), data.cpu_pct.clone()),
ChartSeries::new("GPU %", RGBColor(243, 156, 18), data.gpu_busy_pct.clone()),
],
h_lines: vec![],
},
ChartAxis {
label: "Active",
max: 0.0,
series: vec![
ChartSeries::new(
"Active replays",
RGBColor(46, 204, 113),
data.active_replays.clone(),
),
ChartSeries::new(
"Active downloads",
RGBColor(52, 152, 219),
data.active_downloads.clone(),
),
],
h_lines: vec![],
},
)
.unwrap_or_default();
let mem_svg = build_line_chart_svg(
"Memory",
&setup,
ChartAxis {
label: "MiB",
max: 0.0,
series: vec![
ChartSeries::new(
"Available MiB",
RGBColor(39, 174, 96),
data.mem_available_mb.clone(),
),
ChartSeries::new(
"Swap used MiB",
RGBColor(230, 126, 34),
data.swap_used_mb.clone(),
),
ChartSeries::new(
"GPU used MiB",
RGBColor(142, 68, 173),
data.gpu_mem_mb.clone(),
),
],
h_lines: vec![],
},
ChartAxis::default(),
)
.unwrap_or_default();
let network_svg = if data
.download_mb_per_sec
.points
.iter()
.any(|x| x.data != 0.0)
{
build_line_chart_svg(
"Network",
&setup,
ChartAxis {
label: "MB/s",
max: 0.0,
series: vec![ChartSeries::new(
"MB/s",
RGBColor(52, 152, 219),
data.download_mb_per_sec.clone(),
)],
h_lines: vec![],
},
ChartAxis {
label: "Active downloads",
max: 0.0,
series: vec![ChartSeries::new(
"Active downloads",
RGBColor(46, 204, 113),
data.active_downloads.clone(),
)],
h_lines: vec![],
},
)
.unwrap_or_default()
} else {
String::new()
};
let has_gpu_freq = !data.gpu_freq_mhz.is_empty() || !data.gpu_mem_freq_mhz.is_empty();
let has_any_temp = !data.cpu_temp_c.is_empty() || !data.gpu_temp_c.is_empty();
let freq_temp_svg = if has_gpu_freq || has_any_temp {
build_freq_temp_svg(data, &setup).unwrap_or_default()
} else {
String::new()
};
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Replay Job Graphs</title>
<style>
body {{ font-family: sans-serif; max-width: 1100px; margin: 0 auto; padding: 1em; }}
h1 {{ font-size: 1.4em; }}
h2 {{ font-size: 1.1em; margin-top: 2em; }}
.chart {{ width: 100%; overflow-x: auto; margin-bottom: 1em; }}
.legend {{ font-size: 0.85em; color: #555; margin-bottom: 0.5em; }}
.legend span {{ display: inline-block; width: 14px; height: 14px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }}
#svg-tooltip {{ position: fixed; background: rgba(0,0,0,0.75); color: #fff; padding: 3px 7px;
border-radius: 4px; font-size: 0.8em; pointer-events: none; white-space: nowrap;
display: none; z-index: 100; }}
</style>
</head>
<body>
<h1>Replay Job Graphs</h1>
<h2>Trace Timeline</h2>
<div class="legend">
<span style="background:#5b9bd5"></span>Download
<span style="background:#4c8c4a"></span>Replay (pass)
<span style="background:#c0392b"></span>Replay (fail)
</div>
<div class="chart">{swimlane}</div>
<div class="chart">{system}</div>
{network_section}
<div class="chart">{mem}</div>
{freq_temp_section}
<div id="svg-tooltip"></div>
<script>
(function() {{
var tip = document.getElementById('svg-tooltip');
document.addEventListener('mouseover', function(e) {{
var el = e.target.closest('[data-label]');
if (el) {{ tip.textContent = el.dataset.label; tip.style.display = 'block'; }}
}});
document.addEventListener('mousemove', function(e) {{
tip.style.left = (e.clientX + 12) + 'px';
tip.style.top = (e.clientY + 12) + 'px';
}});
document.addEventListener('mouseout', function(e) {{
if (!e.target.closest('[data-label]')) tip.style.display = 'none';
}});
}})();
</script>
</body>
</html>"#,
swimlane = swimlane_svg,
system = system_svg,
network_section = if !network_svg.is_empty() {
format!("<div class=\"chart\">{network_svg}</div>")
} else {
String::new()
},
mem = mem_svg,
freq_temp_section = if has_gpu_freq || has_any_temp {
format!("<div class=\"chart\">{freq_temp_svg}</div>")
} else {
String::new()
},
);
Ok(html)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::system_monitor::{MonitorData, TraceEvent};
fn make_event(name: &str, dl_s: u64, dl_e: u64, rp_s: u64, rp_e: u64) -> TraceEvent {
TraceEvent {
trace_name: name.to_string(),
download_start_ms: dl_s * 1000,
download_wait_end_ms: dl_s * 1000,
download_end_ms: dl_e * 1000,
replay_start_ms: rp_s * 1000,
replay_end_ms: rp_e * 1000,
passed: true,
}
}
#[test]
fn test_assign_lanes_sequential() {
let events = vec![make_event("a", 0, 0, 0, 3), make_event("b", 0, 0, 4, 7)];
let (assigned, num_lanes) = assign_lanes(&events);
assert_eq!(num_lanes, 1);
assert!(assigned.iter().all(|(lane, _)| *lane == 0));
}
#[test]
fn test_assign_lanes_traces_db() {
let events = vec![
make_event("a", 0, 0, 0, 10),
make_event("b", 0, 0, 10, 20),
make_event("c", 0, 0, 20, 30),
];
let (_, num_lanes) = assign_lanes(&events);
assert_eq!(num_lanes, 1);
}
#[test]
fn test_assign_lanes_parallel() {
let events = vec![make_event("a", 0, 0, 0, 10), make_event("b", 0, 0, 0, 10)];
let (_, num_lanes) = assign_lanes(&events);
assert_eq!(num_lanes, 2);
}
#[test]
fn test_assign_lanes_pipelined_download() {
let events = vec![
make_event("a", 0, 30, 30, 33), make_event("b", 5, 7, 35, 60), ];
let (_, num_lanes) = assign_lanes(&events);
assert_eq!(num_lanes, 2);
}
#[test]
fn test_assign_lanes_sequential_after_real_download() {
let events = vec![
make_event("a", 0, 10, 10, 20), make_event("c", 25, 27, 27, 40), ];
let (_, num_lanes) = assign_lanes(&events);
assert_eq!(num_lanes, 1);
}
#[test]
fn test_build_graphs_html_empty() {
let html = build_graphs_html(&MonitorData::new(None), &[]).unwrap();
assert!(html.contains("<title>Replay Job Graphs</title>"));
}
#[test]
fn test_build_graphs_html_with_data() {
let events = vec![
make_event("game/trace-a.gfxr", 0, 2, 2, 8),
make_event("game/trace-b.gfxr", 0, 3, 3, 6),
];
let html = build_graphs_html(&MonitorData::new(None), &events).unwrap();
assert!(html.contains("trace-a.gfxr"));
assert!(html.contains("trace-b.gfxr"));
}
}