Add unit view and commit info

This commit is contained in:
Luke Street
2024-10-01 18:52:55 -06:00
parent 194f7a1b54
commit f31ae74fe5
13 changed files with 454 additions and 279 deletions
+1 -1
View File
@@ -257,7 +257,7 @@ $unit-size: 0.75rem;
}
}
#graph {
#treemap {
width: 100%;
aspect-ratio: 2 / 1;
margin-bottom: var(--pico-spacing);
+52 -26
View File
@@ -38,9 +38,9 @@ const drawTooltip = (ctx: CanvasRenderingContext2D, unit: Unit, width: number, h
const bw = m.actualBoundingBoxRight + m.actualBoundingBoxLeft + PADDING_W * 2;
const bh = m.fontBoundingBoxAscent + m.fontBoundingBoxDescent + PADDING_H * 2;
const margin = isTouch ? MARGIN * 2 : MARGIN;
let bx = x + (w - bw) / 2 + PADDING_W;
let bx = x + (w - bw) / 2;
let by = y - bh - margin;
let invY = false;
let ay = y;
if (bx + bw > width) {
bx = width - bw;
}
@@ -48,22 +48,30 @@ const drawTooltip = (ctx: CanvasRenderingContext2D, unit: Unit, width: number, h
bx = 0;
}
if (by < 0) {
// Draw below the box
by = y + h + margin;
invY = true;
ay = y + h;
}
if (by + bh > height) {
// Draw inside the box
by = y + margin;
ay = y;
}
ctx.fillStyle = tooltipBackground;
ctx.beginPath();
ctx.roundRect(bx, by, bw, bh, BORDER_RADIUS);
// Arrow
const ax = x + w / 2;
if (invY) {
ctx.moveTo(ax, y + h);
ctx.lineTo(ax + margin, y + h + margin);
ctx.lineTo(ax - margin, y + h + margin);
if (ay < by) {
// Top
ctx.moveTo(ax, ay);
ctx.lineTo(ax + margin, by);
ctx.lineTo(ax - margin, by);
} else {
ctx.moveTo(ax, y);
ctx.lineTo(ax + margin, y - margin);
ctx.lineTo(ax - margin, y - margin);
// Bottom
ctx.moveTo(ax, ay);
ctx.lineTo(ax + margin, by + bh);
ctx.lineTo(ax - margin, by + bh);
}
ctx.fill();
ctx.fillStyle = tooltipColor;
@@ -136,7 +144,20 @@ const draw = (canvas: HTMLCanvasElement, units: Unit[]) => {
}
};
const drawGraph = (id: string, units: Unit[]) => {
const findUnit = (canvas: HTMLCanvasElement, units: Unit[], clientX: number, clientY: number): Unit | null => {
const {width, height, left, top} = canvas.getBoundingClientRect();
const mx = clientX - left;
const my = clientY - top;
for (const unit of units) {
const {x, y, w, h} = unitBounds(unit, width, height);
if (mx >= x && mx <= x + w && my >= y && my <= y + h) {
return unit;
}
}
return null;
}
const drawTreemap = (id: string, clickable: boolean, units: Unit[]) => {
const canvas = document.getElementById(id) as HTMLCanvasElement;
if (!canvas || !canvas.getContext) {
return;
@@ -148,21 +169,14 @@ const drawGraph = (id: string, units: Unit[]) => {
const resizeObserver = new ResizeObserver(queueDraw);
resizeObserver.observe(canvas);
const handleHover = ({clientX, clientY}: { clientX: number, clientY: number }) => {
const {width, height, left, top} = canvas.getBoundingClientRect();
const mx = clientX - left;
const my = clientY - top;
const prev = hovered;
hovered = null;
for (const unit of units) {
const {x, y, w, h} = unitBounds(unit, width, height);
if (mx >= x && mx <= x + w && my >= y && my <= y + h) {
hovered = unit;
break;
}
}
if (prev === hovered) {
const unit = findUnit(canvas, units, clientX, clientY);
if (unit === hovered) {
return;
}
if (clickable) {
canvas.style.cursor = unit ? "pointer" : "default";
}
hovered = unit;
dirty = true;
queueDraw();
}
@@ -170,6 +184,9 @@ const drawGraph = (id: string, units: Unit[]) => {
if (!hovered) {
return;
}
if (clickable) {
canvas.style.cursor = "default";
}
hovered = null;
dirty = true;
queueDraw();
@@ -184,12 +201,21 @@ const drawGraph = (id: string, units: Unit[]) => {
handleHover(e.touches[0]);
});
canvas.addEventListener("touchend", handleLeave);
canvas.addEventListener("click", ({clientX, clientY}) => {
const unit = findUnit(canvas, units, clientX, clientY);
if (!unit || !unit.name || !clickable) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("unit", unit.name);
window.location.href = url.toString();
});
draw(canvas, units);
};
// noinspection JSUnusedGlobalSymbols
interface Window {
drawGraph: (id: string, units: Unit[]) => void;
drawTreemap: (id: string, clickable: boolean, units: Unit[]) => void;
}
window.drawGraph = drawGraph;
window.drawTreemap = drawTreemap;
+1 -1
View File
@@ -29,7 +29,7 @@ struct ReportKey {
type UnitKey = [u8; 32];
impl Database {
pub async fn open(config: &AppConfig) -> Result<Self> {
pub async fn new(config: &AppConfig) -> Result<Self> {
if !Sqlite::database_exists(&config.db_url).await.unwrap_or(false) {
tracing::info!(db_url = %config.db_url, "Creating database");
Sqlite::create_database(&config.db_url).await.context("Failed to create database")?;
+62 -15
View File
@@ -5,11 +5,13 @@ use std::{
};
use anyhow::{anyhow, Context, Result};
use axum::http::StatusCode;
use moka::future::Cache;
use objdiff_core::bindings::report::Report;
use octocrab::{
models::{ArtifactId, RunId},
models::{repos::RepoCommitPage, ArtifactId, Author, RunId},
params::actions::ArchiveFormat,
Octocrab,
GitHubError, Octocrab,
};
use regex::Regex;
use tokio::{sync::Semaphore, task::JoinSet};
@@ -20,23 +22,64 @@ use crate::{
AppState,
};
pub type Client = Octocrab;
#[derive(Clone)]
pub struct GitHub {
pub client: Octocrab,
#[allow(dead_code)]
pub profile: Author,
commit_cache: Cache<GetCommit, Option<RepoCommitPage>>,
}
pub async fn create(config: &AppConfig) -> Result<Octocrab> {
let client = Octocrab::builder()
.personal_token(config.github_token.clone())
.build()
.context("Failed to create GitHub client")?;
octocrab::initialise(client.clone());
let profile = client.current().user().await.context("Failed to fetch current user")?;
tracing::info!("Logged in as {}", profile.login);
Ok(client)
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GetCommit {
owner: String,
repo: String,
sha: String,
}
impl GitHub {
pub async fn new(config: &AppConfig) -> Result<Self> {
let client = Octocrab::builder()
.personal_token(config.github_token.clone())
.build()
.context("Failed to create GitHub client")?;
octocrab::initialise(client.clone());
let profile = client.current().user().await.context("Failed to fetch current user")?;
tracing::info!("Logged in as {}", profile.login);
let commit_cache = Cache::builder().max_capacity(100).build();
Ok(Self { client, profile, commit_cache })
}
pub async fn get_commit(
&self,
owner: &str,
repo: &str,
sha: &str,
) -> Result<Option<RepoCommitPage>> {
let key =
GetCommit { owner: owner.to_string(), repo: repo.to_string(), sha: sha.to_string() };
if let Some(commit) = self.commit_cache.get(&key).await {
return Ok(commit);
}
let commit =
match self.client.repos(owner, repo).list_commits().sha(sha).per_page(1).send().await {
Ok(page) => page.items.into_iter().next().map(|c| c.commit),
Err(octocrab::Error::GitHub {
source: GitHubError { status_code: StatusCode::NOT_FOUND, .. },
..
}) => None,
Err(e) => return Err(e.into()),
};
self.commit_cache.insert(key, commit.clone()).await;
Ok(commit)
}
}
pub async fn run(state: &mut AppState, owner: &str, repo: &str, stop_run_id: u64) -> Result<()> {
tracing::info!("Refreshing project {}/{}", owner, repo);
let existing = state.db.get_project_info(owner, repo, None).await?;
let repo = state.github.repos(owner, repo).get().await.context("Failed to fetch repo")?;
let repo =
state.github.client.repos(owner, repo).get().await.context("Failed to fetch repo")?;
let branch = repo.default_branch.as_deref().unwrap_or("main");
let Some(owner) = repo.owner else {
return Err(anyhow!("Repo has no owner"));
@@ -56,6 +99,7 @@ pub async fn run(state: &mut AppState, owner: &str, repo: &str, stop_run_id: u64
'outer: loop {
let result = state
.github
.client
.workflows(&project.owner, &project.repo)
.list_runs("build.yml")
.branch(branch)
@@ -173,13 +217,15 @@ struct ProcessArtifactResult {
}
async fn process_workflow_run(
github: Client,
github: GitHub,
project: Project,
run_id: RunId,
) -> Result<ProcessWorkflowRunResult> {
let artifacts = github
.client
.all_pages(
github
.client
.actions()
.list_workflow_run_artifacts(&project.owner, &project.repo, run_id)
.send()
@@ -264,12 +310,13 @@ async fn process_workflow_run(
type DownloadArtifactResult = Result<Vec<(String, Arc<Report>)>>;
async fn download_artifact(
github: Client,
github: GitHub,
project: Project,
artifact_id: ArtifactId,
version: String,
) -> DownloadArtifactResult {
let bytes = github
.client
.actions()
.download_artifact(&project.owner, &project.repo, artifact_id, ArchiveFormat::Zip)
.await?;
+10 -18
View File
@@ -1,9 +1,9 @@
use anyhow::{anyhow, Result};
use image::ImageFormat;
use objdiff_core::bindings::report::{Measures, ReportCategory};
use objdiff_core::bindings::report::Measures;
use serde::{Deserialize, Serialize};
use crate::{models::ReportFile, svg};
use crate::svg;
#[derive(Deserialize, Default, Clone)]
#[serde(rename_all = "camelCase")]
@@ -30,25 +30,19 @@ pub struct ShieldResponse {
}
pub fn render(
report: &ReportFile,
measures: &Measures,
current_category: Option<&ReportCategory>,
default_label: &str,
params: &ShieldParams,
) -> Result<ShieldResponse> {
let label = if let Some(label) = params.label.clone() {
label
} else if let Some(category) = current_category {
category.name.clone()
} else {
report.project.short_name().to_string()
};
let label = params.label.clone().unwrap_or_else(|| default_label.to_string());
let message = if let Some(measure) = &params.measure {
match measure.as_str() {
"code" => format!("{:.2}%", measures.matched_code_percent),
"data" => format!("{:.2}%", measures.matched_data_percent),
"functions" => format!("{:.2}%", measures.matched_functions_percent),
"functions" => format!("{}/{}", measures.matched_functions, measures.total_functions),
"complete_code" => format!("{:.2}%", measures.complete_code_percent),
"complete_data" => format!("{:.2}%", measures.complete_data_percent),
"complete_units" => format!("{}/{}", measures.complete_units, measures.total_units),
_ => return Err(anyhow!("Unknown measure")),
}
} else {
@@ -65,12 +59,11 @@ pub fn render(
}
pub fn render_svg(
report: &ReportFile,
measures: &Measures,
current_category: Option<&ReportCategory>,
default_label: &str,
params: &ShieldParams,
) -> Result<String> {
let response = render(report, measures, current_category, params)?;
let response = render(measures, default_label, params)?;
let mut builder = badge_maker::BadgeBuilder::new();
builder.label(&response.label).message(&response.message);
if let Some(color) = &response.color {
@@ -87,12 +80,11 @@ pub fn render_svg(
}
pub fn render_image(
report: &ReportFile,
measures: &Measures,
current_category: Option<&ReportCategory>,
default_label: &str,
params: &ShieldParams,
format: ImageFormat,
) -> Result<Vec<u8>> {
let svg = render_svg(report, measures, current_category, params)?;
let svg = render_svg(measures, default_label, params)?;
svg::render_image(&svg, format)
}
+26 -3
View File
@@ -58,15 +58,38 @@ impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(err: E) -> Self { Self::Internal(err.into()) }
}
pub fn parse_accept(headers: &HeaderMap) -> Vec<Mime> {
headers
pub fn parse_accept(headers: &HeaderMap, ext: Option<&str>) -> Vec<Mime> {
// Explicit extension takes precedence
if let Some(ext) = ext {
return match ext.to_ascii_lowercase().as_str() {
"json" => vec![mime::APPLICATION_JSON],
"binpb" | "proto" => vec![Mime::from_str("application/x-protobuf").unwrap()],
"svg" => vec![mime::IMAGE_SVG],
_ => {
if let Some(format) = image::ImageFormat::from_extension(ext) {
vec![Mime::from_str(format.to_mime_type()).unwrap()]
} else {
// An unknown extension should be NOT_ACCEPTABLE, not */*.
vec![]
}
}
};
}
// Otherwise, parse the Accept header
let result = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
.iter()
.flat_map(|s| s.split(','))
.map(|s| s.trim())
.filter_map(|s| Mime::from_str(s).ok())
.collect()
.collect::<Vec<_>>();
if result.is_empty() {
// If no Accept header is present, use */*
vec![mime::STAR_STAR]
} else {
result
}
}
pub struct Protobuf<T: Message>(pub Arc<T>);
+205 -129
View File
@@ -1,4 +1,4 @@
use std::{iter, str::FromStr, time::Instant};
use std::{borrow::Cow, iter, time::Instant};
use anyhow::{Context, Result};
use axum::{
@@ -9,7 +9,7 @@ use axum::{
};
use image::ImageFormat;
use mime::Mime;
use objdiff_core::bindings::report::{Measures, ReportCategory};
use objdiff_core::bindings::report::{Measures, ReportCategory, ReportUnit};
use serde::{Deserialize, Serialize};
use url::Url;
@@ -41,6 +41,7 @@ pub struct ReportQuery {
h: Option<u32>,
#[serde(flatten)]
shield: badge::ShieldParams,
unit: Option<String>,
}
impl ReportQuery {
@@ -61,18 +62,25 @@ struct ReportTemplateContext<'a> {
measures: TemplateMeasures,
units: &'a [ReportTemplateUnit<'a>],
versions: &'a [ReportTemplateVersion<'a>],
prev_commit: Option<&'a str>,
next_commit: Option<&'a str>,
prev_commit_path: Option<&'a str>,
next_commit_path: Option<&'a str>,
latest_commit_path: Option<&'a str>,
categories: &'a [ReportCategoryItem<'a>],
current_category: &'a ReportCategoryItem<'a>,
canonical_path: &'a str,
canonical_url: &'a str,
image_url: &'a str,
current_unit: Option<&'a str>,
units_path: &'a str,
commit_message: Option<&'a str>,
commit_url: &'a str,
source_file_url: Option<&'a str>,
}
#[derive(Serialize)]
pub struct ReportTemplateUnit<'a> {
name: &'a str,
total_code: u64,
fuzzy_match_percent: f32,
color: String,
x: f32,
@@ -187,6 +195,11 @@ pub async fn get_report(
) -> Result<Response, AppError> {
let start = Instant::now();
let (params, ext) = extract_extension(params);
let acceptable = parse_accept(&headers, ext.as_deref());
if acceptable.is_empty() {
return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE));
}
let mut commit = params.commit.as_deref();
if matches!(commit, Some(c) if c.eq_ignore_ascii_case("latest")) {
commit = None;
@@ -212,70 +225,38 @@ pub async fn get_report(
return Err(AppError::Status(StatusCode::NOT_FOUND));
};
if let Some(mode) = query.mode.as_deref() {
match mode.to_ascii_lowercase().as_str() {
"shield" => mode_shield(report, query, headers, ext),
"report" => mode_report(report, project_info, &state, uri, query, headers, start, ext),
_ => Err(AppError::Status(StatusCode::BAD_REQUEST)),
}
} else {
mode_report(report, project_info, &state, uri, query, headers, start, ext)
let scope = apply_scope(&report, &project_info, &query)?;
match query.mode.as_deref().unwrap_or("report").to_ascii_lowercase().as_str() {
"shield" => mode_shield(&scope, query, &acceptable),
"report" => mode_report(&scope, &state, uri, query, start, &acceptable).await,
_ => Err(AppError::Status(StatusCode::BAD_REQUEST)),
}
}
fn mode_report(
report: ReportFile,
project_info: ProjectInfo,
#[allow(clippy::too_many_arguments)]
async fn mode_report(
scope: &Scope<'_>,
state: &AppState,
uri: Uri,
query: ReportQuery,
headers: HeaderMap,
start: Instant,
ext: Option<String>,
acceptable: &[Mime],
) -> Result<Response, AppError> {
let (measures, current_category, units) = apply_category(&report, &query)?;
let acceptable = if let Some(ext) = ext {
vec![match ext.to_ascii_lowercase().as_str() {
"json" => mime::APPLICATION_JSON,
"binpb" | "proto" => Mime::from_str("application/x-protobuf")?,
"svg" => mime::IMAGE_SVG,
_ => {
if let Some(format) = ImageFormat::from_extension(ext) {
Mime::from_str(format.to_mime_type())?
} else {
return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE));
}
}
}]
} else {
if !headers.contains_key(header::ACCEPT) {
return Ok(Json(report.report).into_response());
}
parse_accept(&headers)
};
for mime in acceptable {
if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR)
|| (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML)
{
let mut rendered = render_template(
&report,
&project_info,
measures,
current_category,
&units,
&state,
uri,
)?;
let mut rendered = render_template(scope, state, uri).await?;
let elapsed = start.elapsed();
rendered = rendered.replace("[[time]]", &format!("{}ms", elapsed.as_millis()));
return Ok(Html(rendered).into_response());
} else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON {
return Ok(Json(report.report).into_response());
return Ok(Json(scope.report.report.clone()).into_response());
} else if mime.type_() == mime::APPLICATION && mime.subtype() == PROTOBUF {
return Ok(Protobuf(report.report).into_response());
return Ok(Protobuf(scope.report.report.clone()).into_response());
} else if mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG {
let (w, h) = query.size();
let svg = treemap::render_svg(&units, w, h, &state)?;
let svg = treemap::render_svg(&scope.units, w, h, state)?;
return Ok(([(header::CONTENT_TYPE, mime::IMAGE_SVG.as_ref())], svg).into_response());
} else if mime.type_() == mime::IMAGE {
let format = if mime.subtype() == mime::STAR {
@@ -286,7 +267,7 @@ fn mode_report(
.ok_or_else(|| AppError::Status(StatusCode::NOT_ACCEPTABLE))?
};
let (w, h) = query.size();
let data = treemap::render_image(&units, w, h, &state, format)?;
let data = treemap::render_image(&scope.units, w, h, state, format)?;
return Ok(([(header::CONTENT_TYPE, format.to_mime_type())], data).into_response());
}
}
@@ -294,39 +275,20 @@ fn mode_report(
}
fn mode_shield(
report: ReportFile,
Scope { report, measures, label, .. }: &Scope<'_>,
query: ReportQuery,
headers: HeaderMap,
ext: Option<String>,
acceptable: &[Mime],
) -> Result<Response, AppError> {
let (measures, current_category, _units) = apply_category(&report, &query)?;
let acceptable = if let Some(ext) = ext {
vec![match ext.to_ascii_lowercase().as_str() {
"json" => mime::APPLICATION_JSON,
"svg" => mime::IMAGE_SVG,
_ => {
if let Some(format) = ImageFormat::from_extension(ext) {
Mime::from_str(format.to_mime_type())?
} else {
return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE));
}
}
}]
} else {
if !headers.contains_key(header::ACCEPT) {
return Ok(Json(report.report).into_response());
}
parse_accept(&headers)
};
let label = label.unwrap_or_else(|| report.project.short_name());
for mime in acceptable {
if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR)
|| (mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG)
|| (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML)
{
let data = badge::render_svg(&report, measures, current_category, &query.shield)?;
let data = badge::render_svg(measures, label, &query.shield)?;
return Ok(([(header::CONTENT_TYPE, mime::IMAGE_SVG.as_ref())], data).into_response());
} else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON {
let data = badge::render(&report, measures, current_category, &query.shield)?;
let data = badge::render(measures, label, &query.shield)?;
return Ok(Json(data).into_response());
} else if mime.type_() == mime::IMAGE {
let format = if mime.subtype() == mime::STAR {
@@ -336,8 +298,7 @@ fn mode_shield(
ImageFormat::from_mime_type(mime.essence_str())
.ok_or_else(|| AppError::Status(StatusCode::NOT_ACCEPTABLE))?
};
let data =
badge::render_image(&report, measures, current_category, &query.shield, format)?;
let data = badge::render_image(measures, label, &query.shield, format)?;
return Ok(([(header::CONTENT_TYPE, format.to_mime_type())], data).into_response());
}
}
@@ -363,10 +324,21 @@ const EMPTY_MEASURES: Measures = Measures {
complete_units: 0,
};
fn apply_category<'a>(
struct Scope<'a> {
report: &'a ReportFile,
project_info: &'a ProjectInfo,
measures: &'a Measures,
current_category: Option<&'a ReportCategory>,
current_unit: Option<&'a ReportUnit>,
units: Vec<ReportTemplateUnit<'a>>,
label: Option<&'a str>,
}
fn apply_scope<'a>(
report: &'a ReportFile,
project_info: &'a ProjectInfo,
query: &ReportQuery,
) -> Result<(&'a Measures, Option<&'a ReportCategory>, Vec<ReportTemplateUnit<'a>>)> {
) -> Result<Scope<'a>> {
let mut measures = report.report.measures.as_ref().unwrap_or(&EMPTY_MEASURES);
let mut current_category = None;
let mut category_id_filter = None;
@@ -377,53 +349,109 @@ fn apply_category<'a>(
current_category = Some(category);
category_id_filter = Some(category.id.clone());
}
let mut current_unit = None;
if let Some(unit) = query
.unit
.as_ref()
.and_then(|unit_name| report.report.units.iter().find(|u| u.name == *unit_name))
{
measures = unit.measures.as_ref().unwrap_or(&EMPTY_MEASURES);
current_unit = Some(unit);
}
let (w, h) = query.size();
let aspect = w as f32 / h as f32;
let units = treemap::layout_units(&report.report, w, h, |item| {
if let Some(category_id) = &category_id_filter {
item.metadata
.as_ref()
.map_or(false, |m| m.progress_categories.iter().any(|c| c == category_id))
let mut units =
if let Some(unit) = current_unit {
unit.functions
.iter()
.filter_map(|f| {
if f.size == 0 {
return None;
}
Some(ReportTemplateUnit {
name: f
.metadata
.as_ref()
.and_then(|m| m.demangled_name.as_deref())
.unwrap_or(&f.name),
total_code: f.size,
fuzzy_match_percent: f.fuzzy_match_percent,
color: treemap::unit_color(f.fuzzy_match_percent),
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
})
})
.collect::<Vec<_>>()
} else {
true
}
})
.into_iter()
.map(|item| {
let unit = item.unit();
let match_percent =
unit.measures.as_ref().map(|m| m.fuzzy_match_percent).unwrap_or_default();
let mut bounds = item.bounds;
if aspect > 1.0 {
bounds.y *= aspect;
bounds.h *= aspect;
} else {
bounds.x /= aspect;
bounds.w /= aspect;
}
ReportTemplateUnit {
name: &unit.name,
fuzzy_match_percent: match_percent,
color: treemap::unit_color(match_percent),
x: bounds.x,
y: bounds.y,
w: bounds.w,
h: bounds.h,
}
})
.collect();
Ok((measures, current_category, units))
report
.report
.units
.iter()
.filter_map(|unit| {
if let Some(category_id) = &category_id_filter {
if !unit.metadata.as_ref().map_or(false, |m| {
m.progress_categories.iter().any(|c| c == category_id)
}) {
return None;
}
}
let measures = unit.measures.as_ref()?;
if measures.total_code == 0 {
return None;
}
Some(ReportTemplateUnit {
name: &unit.name,
total_code: measures.total_code,
fuzzy_match_percent: measures.fuzzy_match_percent,
color: treemap::unit_color(measures.fuzzy_match_percent),
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
})
})
.collect::<Vec<_>>()
};
treemap::layout_units(
&mut units,
w as f32 / h as f32,
|i| i.total_code as f32,
|i, r| {
i.x = r.x;
i.y = r.y;
i.w = r.w;
i.h = r.h;
},
);
let label = current_unit
.as_ref()
.map(|u| u.name.rsplit_once('/').map_or(u.name.as_str(), |(_, name)| name))
.or_else(|| current_category.as_ref().map(|c| c.name.as_str()));
Ok(Scope { report, project_info, measures, current_category, current_unit, units, label })
}
fn render_template(
report: &ReportFile,
project_info: &ProjectInfo,
measures: &Measures,
current_category: Option<&ReportCategory>,
units: &[ReportTemplateUnit],
state: &AppState,
uri: Uri,
) -> Result<String> {
async fn render_template(scope: &Scope<'_>, state: &AppState, uri: Uri) -> Result<String> {
let Scope { report, project_info, measures, current_category, current_unit, units, label } =
scope;
let commit = match state
.github
.get_commit(&project_info.project.owner, &project_info.project.repo, &report.commit.sha)
.await
{
Ok(commit) => commit,
Err(e) => {
tracing::warn!(
"Failed to get commit {}/{}@{}: {}",
project_info.project.owner,
project_info.project.repo,
report.commit.sha,
e
);
None
}
};
let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?;
let project_base_path =
format!("/{}/{}", project_info.project.owner, project_info.project.repo);
@@ -445,41 +473,89 @@ fn render_template(
})
.collect::<Vec<_>>();
let all_url = canonical_url.set_query("category", None);
let all_url = canonical_url.query_param("category", None);
let all_category =
ReportCategoryItem { id: "all", name: "All", path: all_url.path_and_query().to_string() };
let current_category = current_category
.map(|c| {
let path =
canonical_url.set_query("category", Some(&c.id)).path_and_query().to_string();
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
ReportCategoryItem { id: &c.id, name: &c.name, path }
})
.unwrap_or_else(|| all_category.clone());
let categories = iter::once(all_category)
.chain(report.report.categories.iter().map(|c| {
let path =
canonical_url.set_query("category", Some(&c.id)).path_and_query().to_string();
canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string();
ReportCategoryItem { id: &c.id, name: &c.name, path }
}))
.collect::<Vec<_>>();
let prev_commit_path = project_info.prev_commit.as_deref().map(|commit| {
let url = request_url.with_path(&format!(
"/{}/{}/{}/{}",
project_info.project.owner, project_info.project.repo, report.version, commit
));
url.path_and_query().to_string()
});
let next_commit_path = project_info.next_commit.as_deref().map(|commit| {
let url = request_url.with_path(&format!(
"/{}/{}/{}/{}",
project_info.project.owner, project_info.project.repo, report.version, commit
));
url.path_and_query().to_string()
});
let latest_commit_path = project_info.next_commit.as_deref().map(|_| {
let url = request_url.with_path(&format!(
"/{}/{}/{}",
project_info.project.owner, project_info.project.repo, report.version
));
url.path_and_query().to_string()
});
let units_path = canonical_url.query_param("unit", None).path_and_query().to_string();
let commit_message = commit.as_ref().and_then(|c| c.message.lines().next());
let commit_url = format!("{}/commit/{}", project_info.project.repo_url(), report.commit.sha);
let source_file_url = current_unit
.and_then(|u| u.metadata.as_ref())
.and_then(|m| m.source_path.as_deref())
.map(|path| {
format!("{}/blob/{}/{}", project_info.project.repo_url(), report.commit.sha, path)
});
let project_name = if let Some(label) = label {
Cow::Owned(format!("{} ({})", project_info.project.name(), label))
} else {
project_info.project.name()
};
let project_short_name = if let Some(label) = label {
Cow::Owned(format!("{} ({})", project_info.project.short_name(), label))
} else {
Cow::Borrowed(project_info.project.short_name())
};
render(&state.templates, "report.html", ReportTemplateContext {
project: &report.project,
project_name: &report.project.name(),
project_short_name: report.project.short_name(),
project_name: project_name.as_ref(),
project_short_name: project_short_name.as_ref(),
project_url: &report.project.repo_url(),
project_path: &project_base_path,
commit: &report.commit.sha,
version: &report.version,
measures: TemplateMeasures::from(measures),
measures: TemplateMeasures::from(*measures),
units,
versions: &versions,
prev_commit: project_info.prev_commit.as_deref(),
next_commit: project_info.next_commit.as_deref(),
prev_commit_path: prev_commit_path.as_deref(),
next_commit_path: next_commit_path.as_deref(),
latest_commit_path: latest_commit_path.as_deref(),
categories: &categories,
current_category: &current_category,
canonical_path: canonical_url.path_and_query(),
canonical_url: canonical_url.as_ref(),
image_url: image_url.as_ref(),
current_unit: current_unit.map(|u| u.name.as_str()),
units_path: &units_path,
commit_message,
commit_url: &commit_url,
source_file_url: source_file_url.as_deref(),
})
}
+15 -42
View File
@@ -1,58 +1,31 @@
use anyhow::Result;
use image::ImageFormat;
use objdiff_core::bindings::report::{Report, ReportUnit};
use palette::{Mix, Srgb};
use serde::Serialize;
use streemap::Rect;
use crate::{handlers::report::ReportTemplateUnit, svg, templates::render, AppState};
#[derive(Clone)]
pub struct ReportUnitItem<'report> {
pub unit: &'report ReportUnit,
pub size: f32,
pub bounds: Rect<f32>,
}
impl<'report> ReportUnitItem<'report> {
pub fn with_size(unit: &'report ReportUnit, size: f32) -> ReportUnitItem<'report> {
ReportUnitItem { unit, size, bounds: Rect::from_size(0.0, 0.0) }
}
pub fn unit(&self) -> &'report ReportUnit { self.unit }
}
pub fn layout_units(
report: &Report,
w: u32,
h: u32,
mut predicate: impl FnMut(&ReportUnit) -> bool,
) -> Vec<ReportUnitItem> {
let aspect = w as f32 / h as f32;
pub fn layout_units<T, S, R>(items: &mut [T], aspect: f32, size_fn: S, mut set_rect_fn: R)
where
S: Fn(&T) -> f32,
R: FnMut(&mut T, Rect<f32>),
{
let rect = if aspect > 1.0 {
Rect::from_size(1.0, 1.0 / aspect)
} else {
Rect::from_size(aspect, 1.0)
};
let mut items = report
.units
.iter()
.filter_map(|unit| {
if !predicate(unit) {
return None;
}
let total_code = unit.measures.as_ref().unwrap().total_code;
if total_code == 0 {
return None;
}
Some(ReportUnitItem::with_size(
unit,
total_code as f32 / report.measures.as_ref().unwrap().total_code as f32,
))
})
.collect::<Vec<_>>();
streemap::ordered_pivot_by_middle(rect, &mut items, |i| i.size, |i, s| i.bounds = s);
items
streemap::ordered_pivot_by_middle(rect, items, size_fn, |item, mut rect| {
if aspect > 1.0 {
rect.y *= aspect;
rect.h *= aspect;
} else {
rect.x /= aspect;
rect.w /= aspect;
}
set_rect_fn(item, rect);
});
}
#[derive(Serialize)]
+25 -7
View File
@@ -19,28 +19,42 @@ use std::{
use axum::{http::header, Router};
use tokio::{net::TcpListener, signal};
use tower::ServiceBuilder;
use tower_http::{timeout::TimeoutLayer, ServiceBuilderExt};
use tower_http::{
timeout::TimeoutLayer,
trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
ServiceBuilderExt,
};
use tracing_subscriber::{filter::LevelFilter, EnvFilter};
use crate::{config::Config, db::Database, handlers::build_router, templates::Templates};
use crate::{
config::Config, db::Database, github::GitHub, handlers::build_router, templates::Templates,
};
#[derive(Clone)]
struct AppState {
config: Config,
db: Database,
github: github::Client,
github: GitHub,
templates: Templates,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
// Default to info level
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.init();
let config: Config = {
let file = BufReader::new(File::open("config.yml").expect("Failed to open config file"));
serde_yaml::from_reader(file).expect("Failed to parse config file")
};
let db = Database::open(&config.app).await.expect("Failed to open database");
let github = github::create(&config.app).await.expect("Failed to create GitHub client");
let db = Database::new(&config.app).await.expect("Failed to open database");
let github = GitHub::new(&config.app).await.expect("Failed to create GitHub client");
let templates = templates::create("templates");
let state = AppState { config, db: db.clone(), github, templates };
@@ -71,7 +85,11 @@ fn app(state: AppState) -> Router {
let middleware = ServiceBuilder::new()
.sensitive_request_headers(sensitive_headers.clone())
.sensitive_response_headers(sensitive_headers)
.trace_for_http()
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO))
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO)),
)
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.compression();
build_router().layer(middleware).with_state(state)
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn render_image(svg: &str, format: ImageFormat) -> Result<Vec<u8>> {
})
.clone();
let opt = Options { fontdb, ..Default::default() };
let tree = Tree::from_str(&svg, &opt).context("Failed to parse SVG")?;
let tree = Tree::from_str(svg, &opt).context("Failed to parse SVG")?;
let rect = tree.size().to_int_size();
let w = rect.width().clamp(1, 2048);
let h = rect.height().clamp(1, 2048);
+5 -2
View File
@@ -3,14 +3,14 @@ use std::path::{Path, PathBuf};
use url::Url;
pub trait UrlExt {
fn set_query(&self, key: &str, value: Option<&str>) -> Url;
fn query_param(&self, key: &str, value: Option<&str>) -> Url;
fn with_path(&self, path: &str) -> Url;
fn path_and_query(&self) -> &str;
}
impl UrlExt for Url {
#[inline]
fn set_query(&self, key: &str, value: Option<&str>) -> Url {
fn query_param(&self, key: &str, value: Option<&str>) -> Url {
let mut out = self.clone();
let mut pairs = out.query_pairs_mut();
pairs.clear();
@@ -37,6 +37,9 @@ impl UrlExt for Url {
}
}
drop(pairs);
if out.query() == Some("") {
out.set_query(None);
}
out
}
+4 -4
View File
@@ -6,12 +6,12 @@
<meta name="color-scheme" content="light dark">
<meta name="darkreader-lock">
<meta name="description" content="Decompilation progress reports">
<link rel="stylesheet" href="/css/main.min.css?5">
<link rel="stylesheet" href="/css/main.min.css?1">
<title>Projects • decomp.dev</title>
</head>
<body>
<header>
<nav data-hx-boost="true">
<nav>
<ul>
<li>
<a href="https://decomp.dev">
@@ -22,7 +22,7 @@
<a href="/">Projects</a>
</li>
<li>
<details class="dropdown" data-hx-boost="true">
<details class="dropdown">
<summary>{{ current_sort.name }}</summary>
<ul>
{% for sort in sort_options %}
@@ -45,7 +45,7 @@
<article class="project">
<div class="project-header">
<h3 class="project-title">
<a href="/{{ project.owner }}/{{ project.repo }}" data-hx-boost="true">
<a href="/{{ project.owner }}/{{ project.repo }}">
{{ project.name }}
</a>
</h3>
+47 -30
View File
@@ -5,21 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="darkreader-lock">
<meta name="description" content="Decompilation progress report for {{ project_name }}{% if current_category.id != 'all' %} ({{ current_category.name }}){% endif %}">
<title>{{ project_short_name }}{% if current_category.id != "all" %} ({{ current_category.name }}){% endif %} • Progress Report</title>
<link rel="stylesheet" href="/css/main.min.css?5">
<script src="https://unpkg.com/htmx.org@2.0.2"
integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ"
crossorigin="anonymous"></script>
<script src="/js/graph.min.js?6"></script>
<meta property="og:title" content="{{ project_short_name }}{% if current_category.id != 'all' %} ({{ current_category.name }}){% endif %} is {{ measures.matched_code_percent | round(2) }}% decompiled">
<meta property="og:description" content="Decompilation progress report for {{ project_name }}{% if current_category.id != 'all' %} ({{ current_category.name }}){% endif %}">
<meta name="description" content="Decompilation progress report for {{ project_name }}">
<title>{{ project_short_name }} • Progress Report</title>
<link rel="stylesheet" href="/css/main.min.css?1">
<script src="/js/treemap.min.js?1"></script>
<meta property="og:title" content="{{ project_short_name }} is {{ measures.matched_code_percent | round(2) }}% decompiled">
<meta property="og:description" content="Decompilation progress report for {{ project_name }}">
<meta property="og:image" content="{{ image_url | safe }}">
<meta property="og:url" content="{{ canonical_url | safe }}">
</head>
<body>
<header>
<nav data-hx-boost="false">
<nav>
<ul>
<li>
<a href="https://decomp.dev">
@@ -51,10 +48,13 @@
</nav>
</header>
<main>
<h3>
{{ project_short_name }}{% if current_category.id != "all" %} ({{ current_category.name }}){% endif %} is {{ measures.matched_code_percent | round(2) }}% decompiled
</h3>
<h3>{{ project_short_name }} is {{ measures.matched_code_percent | round(2) }}% decompiled</h3>
{% if not current_unit %}
<h4 class="muted">{{ measures.complete_code_percent | round(2) }}% fully linked</h4>
{% endif %}
{% if source_file_url %}
<h4 class="muted"><a href="{{ source_file_url | safe }}" target="_blank">View source file</a></h4>
{% endif %}
<h6 class="report-header">Code</h6>
<div class="progress-root code">
<div class="progress-section" style="width: {{ measures.complete_code_percent }}%"></div>
@@ -88,9 +88,38 @@
style="width: {{ measures.matched_data_percent - measures.complete_data_percent }}%"
data-tooltip="{{ measures.matched_data_percent | round(2) }}% perfect match"></div>
</div>
<h6 class="report-header">Commit</h6>
<div>
{% if commit_message %}
<pre><a href="{{ commit_url | safe }}" target="_blank">{{ commit[:7] }}</a> | {{ commit_message }}</pre>
{% endif %}
<div role="group">
{% if prev_commit_path %}
<a role="button" href="{{ prev_commit_path | safe }}" class="outline secondary">Previous</a>
{% else %}
<button class="outline secondary" disabled>Previous</button>
{% endif %}
{% if next_commit_path %}
<a role="button" href="{{ next_commit_path | safe }}" class="outline secondary">Next</a>
{% else %}
<button class="outline secondary" disabled>Next</button>
{% endif %}
{% if latest_commit_path %}
<a role="button" href="{{ latest_commit_path | safe }}" class="primary">Latest</a>
{% else %}
<button class="primary" disabled>Latest</button>
{% endif %}
</div>
</div>
{% if current_unit %}
<h6 class="report-header">Functions</h6>
<div role="group">
<a role="button" href="{{ units_path | safe }}">Back to units</a>
</div>
{% else %}
<h6 class="report-header">Units</h6>
{% if categories | length > 1 %}
<details class="dropdown" data-hx-boost="false">
<details class="dropdown">
<summary>{{ current_category.name }}</summary>
<ul>
{% for category in categories %}
@@ -99,26 +128,14 @@
</ul>
</details>
{% endif %}
{% endif %}
<script>
document.write('<canvas id="graph" width="100%" data-hx-preserve="true"></canvas>');
drawGraph("graph", {{ units | tojson }});
document.write('<canvas id="treemap" width="100%"></canvas>');
drawTreemap("treemap", {{ not current_unit }}, {{ units | tojson }});
</script>
<noscript>
<img id="graph" src="{{ image_url | safe }}" alt="Progress graph">
<img id="treemap" src="{{ image_url | safe }}" alt="Progress graph">
</noscript>
<h6 class="report-header">Links</h6>
<div>
<p>
<a href="{{ project_url | safe }}" target="_blank">GitHub</a> (<a
href="{{ project_url | safe }}/commit/{{ commit }}" target="_blank">{{ commit[:7] }}</a>)
</p>
{% if prev_commit %}
<p><a href="{{ project_path | safe }}/{{ version }}/{{ prev_commit }}">Previous commit</a></p>
{% endif %}
{% if next_commit %}
<p><a href="{{ project_path | safe }}/{{ version }}/{{ next_commit }}">Next commit</a></p>
{% endif %}
</div>
</main>
{% include 'fragments/footer.html' %}
</body>