use crate::system_monitor::{MonitorData, SeriesData, TraceEvent};
use plotters::prelude::*;
use std::fmt::Write as _;
struct ChartSeries {
label: String,
color: String,
points: SeriesData<f64>,
}
impl ChartSeries {
fn new(label: &str, color: &str, points: SeriesData<f64>) -> Self {
ChartSeries {
label: label.into(),
color: color.into(),
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 = 10;
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);
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
}
fn build_line_chart_svg(
title: &str,
series: &[ChartSeries],
y_label: &str,
y_max: f64,
width: u32,
height: u32,
) -> Result<String, Box<dyn std::error::Error>> {
let mut svg_str = String::new();
{
let root = SVGBackend::with_string(&mut svg_str, (width, height)).into_drawing_area();
root.fill(&WHITE)?;
let x_max = series
.iter()
.flat_map(|s| s.points.iter_secs().map(|(t, _)| t))
.fold(0.0f64, f64::max);
let mut chart = ChartBuilder::on(&root)
.caption(title, ("sans-serif", 14))
.margin(10)
.x_label_area_size(30)
.y_label_area_size(50)
.build_cartesian_2d(0.0f64..x_max.max(1.0), 0.0f64..y_max)?;
chart
.configure_mesh()
.x_desc("Time (s)")
.y_desc(y_label)
.draw()?;
for s in series {
let color = parse_hex_color(&s.color).unwrap_or(RED);
chart
.draw_series(LineSeries::new(s.points.iter_secs(), color.stroke_width(2)))?
.label(&s.label)
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
});
}
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,
width: u32,
height: u32,
) -> Result<String, Box<dyn std::error::Error>> {
let has_freq = !data.gpu_freq_mhz.is_empty();
let has_mem_freq = !data.gpu_mem_freq_mhz.is_empty();
let has_cpu_temp = !data.cpu_temp_c.is_empty();
let has_gpu_temp = !data.gpu_temp_c.is_empty();
let has_temp = has_cpu_temp || has_gpu_temp;
let freq_max = (data.gpu_freq_max_mhz.unwrap_or(0) as f64)
.max(data.gpu_freq_mhz.max_value())
.max(data.gpu_mem_freq_mhz.max_value())
.max(1000.0);
if (has_freq || has_mem_freq) && !has_temp {
let mut series: Vec<ChartSeries> = Vec::new();
if has_freq {
series.push(ChartSeries::new(
"GFX MHz",
"#2980b9",
data.gpu_freq_mhz.clone(),
));
}
if has_mem_freq {
series.push(ChartSeries::new(
"Mem MHz",
"#27ae60",
data.gpu_mem_freq_mhz.clone(),
));
}
return build_line_chart_svg(
"GPU Frequency",
&series,
"MHz",
freq_max * 1.05,
width,
height,
);
}
if !has_freq && has_temp {
let temp_max = data.cpu_temp_c.max_value().max(data.gpu_temp_c.max_value());
let mut series: Vec<ChartSeries> = Vec::new();
if has_cpu_temp {
series.push(ChartSeries::new(
"CPU \u{00b0}C",
"#e74c3c",
data.cpu_temp_c.clone(),
));
}
if has_gpu_temp {
series.push(ChartSeries::new(
"GPU \u{00b0}C",
"#8e44ad",
data.gpu_temp_c.clone(),
));
}
return build_line_chart_svg(
"Temperature",
&series,
"\u{00b0}C",
(temp_max * 1.1).max(50.0),
width,
height,
);
}
let mut svg_str = String::new();
{
let root = SVGBackend::with_string(&mut svg_str, (width, height)).into_drawing_area();
root.fill(&WHITE)?;
let x_max = [
&data.gpu_freq_mhz,
&data.gpu_mem_freq_mhz,
&data.cpu_temp_c,
&data.gpu_temp_c,
]
.iter()
.flat_map(|s| s.iter_secs().map(|(t, _)| t))
.fold(0.0f64, f64::max)
.max(1.0);
let temp_max =
(data.cpu_temp_c.max_value().max(data.gpu_temp_c.max_value()) * 1.1).max(50.0);
let chart = ChartBuilder::on(&root)
.caption("GPU Frequency & Temperature", ("sans-serif", 14))
.margin(10)
.x_label_area_size(30)
.y_label_area_size(50)
.right_y_label_area_size(60)
.build_cartesian_2d(0.0f64..x_max, 0.0f64..freq_max * 1.05)?;
let mut chart = chart.set_secondary_coord(0.0f64..x_max, 0.0f64..temp_max);
chart
.configure_mesh()
.x_desc("Time (s)")
.y_desc("MHz")
.draw()?;
chart
.configure_secondary_axes()
.y_desc("\u{00b0}C")
.draw()?;
if has_freq {
let freq_color = parse_hex_color("#2980b9").unwrap_or(BLUE);
chart
.draw_series(LineSeries::new(
data.gpu_freq_mhz.iter_secs(),
freq_color.stroke_width(2),
))?
.label("GFX MHz")
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], freq_color.stroke_width(2))
});
}
if has_mem_freq {
let mem_color = parse_hex_color("#27ae60").unwrap_or(GREEN);
chart
.draw_series(LineSeries::new(
data.gpu_mem_freq_mhz.iter_secs(),
mem_color.stroke_width(2),
))?
.label("Mem MHz")
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], mem_color.stroke_width(2))
});
}
if has_cpu_temp {
let color = parse_hex_color("#e74c3c").unwrap_or(RED);
chart
.draw_secondary_series(LineSeries::new(
data.cpu_temp_c.iter_secs(),
color.stroke_width(2),
))?
.label("CPU \u{00b0}C")
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
});
}
if has_gpu_temp {
let color = parse_hex_color("#8e44ad").unwrap_or(RED);
chart
.draw_secondary_series(LineSeries::new(
data.gpu_temp_c.iter_secs(),
color.stroke_width(2),
))?
.label("GPU \u{00b0}C")
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
});
}
chart
.configure_series_labels()
.background_style(WHITE.mix(0.8))
.border_style(BLACK)
.draw()?;
root.present()?;
}
Ok(svg_str)
}
fn parse_hex_color(hex: &str) -> Option<RGBColor> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(RGBColor(r, g, b))
}
pub fn build_graphs_html(data: &MonitorData, events: &[TraceEvent]) -> anyhow::Result<String> {
let swimlane_svg = build_swimlane_svg(events, data.duration_ms());
let max_active = data
.active_replays
.max_value()
.max(data.active_downloads.max_value());
let mut system_series: Vec<ChartSeries> = vec![
ChartSeries::new("CPU %", "#e74c3c", data.cpu_pct.clone()),
ChartSeries::new("Active replays", "#2ecc71", data.active_replays.clone()),
ChartSeries::new("Active downloads", "#3498db", data.active_downloads.clone()),
];
if !data.gpu_busy_pct.is_empty() {
system_series.push(ChartSeries::new(
"GPU %",
"#f39c12",
data.gpu_busy_pct.clone(),
));
}
let system_svg = build_line_chart_svg(
"System Resources",
&system_series,
"Value",
100.0f64.max(max_active + 1.0),
1000,
300,
)
.unwrap_or_default();
let mem_max = data.mem_available_mb.max_value().max(1.0);
let swap_max = data.swap_used_mb.max_value();
let gpu_mem_max = data.gpu_mem_mb.max_value();
let mut mem_series: Vec<ChartSeries> = vec![
ChartSeries::new("Available MiB", "#27ae60", data.mem_available_mb.clone()),
ChartSeries::new("Swap used MiB", "#e67e22", data.swap_used_mb.clone()),
];
if !data.gpu_mem_mb.is_empty() {
mem_series.push(ChartSeries::new(
"GPU used MiB",
"#8e44ad",
data.gpu_mem_mb.clone(),
));
}
let mem_svg = build_line_chart_svg(
"Memory",
&mem_series,
"MiB",
(mem_max + swap_max + gpu_mem_max + 1.0) * 1.05,
1000,
300,
)
.unwrap_or_default();
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, 1000, 300).unwrap_or_default()
} else {
String::new()
};
let freq_temp_section_title = match (has_gpu_freq, has_any_temp) {
(true, true) => "GPU Frequency & Temperature",
(true, false) => "GPU Frequency",
_ => "Temperature",
};
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>
<h2>System Resources</h2>
<div class="chart">{system}</div>
<h2>Memory</h2>
<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,
mem = mem_svg,
freq_temp_section = if has_gpu_freq || has_any_temp {
format!("<h2>{freq_temp_section_title}</h2><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_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"));
}
}