From d82804e209a08a48297e5e7f2d6459ae9838b367 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 22 Oct 2023 01:21:32 +0200 Subject: [PATCH] Create metric selector via JS --- scripts/graph.ts | 78 +++---------------- scripts/graph/metrics.ts | 68 +++++++++++++++++ scripts/graph/requests.ts | 71 ++++++++++++++++++ scripts/graph/util.ts | 11 +++ src/server/web.rs | 3 +- src/server/web/pages/graph.rs | 116 +++++------------------------ src/server/web/pages/graph/util.rs | 24 ------ src/server/web/paths.rs | 4 + templates/pages/graph.html | 4 +- 9 files changed, 187 insertions(+), 192 deletions(-) create mode 100644 scripts/graph/metrics.ts create mode 100644 scripts/graph/requests.ts create mode 100644 scripts/graph/util.ts diff --git a/scripts/graph.ts b/scripts/graph.ts index 2458041..ff4fff4 100644 --- a/scripts/graph.ts +++ b/scripts/graph.ts @@ -1,3 +1,6 @@ +import * as metrics from "./graph/metrics.js"; +import { Requests } from "./graph/requests.js"; + import uPlot from "./uPlot.js"; /* @@ -116,76 +119,19 @@ const COLORS = [ "#000000", // Black ]; -interface GraphData { - hashes: string[]; - times: number[]; - measurements: { [key: string]: (number | null)[]; }; -} - -function update_plot_with_data(data: GraphData) { - let series: uPlot.Series[] = [{}]; - let values: uPlot.AlignedData = [data.times]; - - for (const [i, metric] of Object.keys(data.measurements).sort().entries()) { - series.push({ - label: metric, - spanGaps: true, - stroke: COLORS[i % COLORS.length], - }); - values.push(data.measurements[metric]!); - } - - const opts: uPlot.Options = { - title: "Measurements", - width: 600, - height: 400, - series, - }; - - plot?.destroy(); - plot = new uPlot(opts, values, plot_div); -} - -async function update_plot_with_metrics(metrics: string[]) { - const url = "data?" + new URLSearchParams(metrics.map(m => ["metric", m])); - const response = await fetch(url); - const data: GraphData = await response.json(); - update_plot_with_data(data); -} - -function find_selected_metrics(): string[] { - const inputs = metrics_div.querySelectorAll('input[type="checkbox"]'); - - let metrics: string[] = []; - for (const input of inputs) { - if (input.checked) { - metrics.push(input.name); - } - } - return metrics; -}; - -async function update_plot() { - const metrics = find_selected_metrics(); - if (metrics.length > 0) { - await update_plot_with_metrics(metrics); - } else { - update_plot_with_data({ - hashes: [], - times: [], - measurements: {}, - }); - } -} - // Initialization const plot_div = document.getElementById("plot")!; const metrics_div = document.getElementById("metrics")!; let plot: uPlot | null = null; -for (const input of metrics_div.querySelectorAll('input[type="checkbox"]')) { - input.addEventListener("change", update_plot); -} +let requests = new Requests(); -update_plot(); +async function init() { + let response = await requests.get_metrics(); + console.log("Metrics:", response); + if (response !== null) { + metrics.update(metrics_div, response.metrics); + } +} +init(); diff --git a/scripts/graph/metrics.ts b/scripts/graph/metrics.ts new file mode 100644 index 0000000..8db1cec --- /dev/null +++ b/scripts/graph/metrics.ts @@ -0,0 +1,68 @@ +import { el } from "./util.js"; + +class Folder { + metric: string | null = null; + children: Map = new Map(); + + getOrCreateChild(name: string): Folder { + let child = this.children.get(name); + if (child === undefined) { + child = new Folder(); + this.children.set(name, child); + } + return child; + } + + add(metric: string) { + let current: Folder = this; + for (let segment of metric.split("/")) { + current = current.getOrCreateChild(segment); + } + current.metric = metric; + } + + toHtmlElement(name: string): HTMLElement { + if (this.children.size > 0) { // Folder + name = `${name}/`; + if (this.metric === null) { // Folder without metric + return el("details", { "class": "no-metric" }, + el("summary", {}, name), + this.childrenToHtmlElements(), + ); + } else { // Folder with metric + return el("details", {}, + el("summary", {}, + el("input", { "type": "checkbox", "name": this.metric }), + " ", name, + ), + this.childrenToHtmlElements(), + ); + } + } else if (this.metric !== null) { // Normal metric + return el("label", {}, + el("input", { "type": "checkbox", "name": this.metric }), + " ", name, + ); + } else { // Metric without metric, should never happen + return el("label", {}, name); + } + } + + childrenToHtmlElements(): HTMLElement { + let result: HTMLElement = el("ul", {}); + for (let [name, folder] of this.children.entries()) { + result.append(el("li", {}, folder.toHtmlElement(name))); + } + return result; + } +} + +export function update(div: HTMLElement, metrics: string[]) { + let folder = new Folder(); + for (let metric of metrics) { + folder.add(metric); + } + + div.textContent = ""; // Remove children + div.append(folder.childrenToHtmlElements()); +} diff --git a/scripts/graph/requests.ts b/scripts/graph/requests.ts new file mode 100644 index 0000000..4eca6d1 --- /dev/null +++ b/scripts/graph/requests.ts @@ -0,0 +1,71 @@ +/** + * `/graph/metrics` response data. + */ +export type MetricsResponse = { + // data_id: number; // TODO Uncomment + + metrics: string[]; +}; + +/** + * `/graph/commits` response data. + */ +export type CommitsResponse = { + // graph_id: number; // TODO Uncomment + + hash_by_hash: string[]; + author_by_hash: number[]; + committer_date_by_hash: string[]; + message_by_hash: string[]; + parents: [string, string][]; +}; + +/** + * `/graph/measurements` response data. + */ +export type MeasurementsResponse = { + // graph_id: number; // TODO Uncomment + // data_id: number; // TODO Uncomment + + measurements: { [key: string]: (number | null)[]; }; +}; + +/** + * Request different kinds of data from the server. + * + * This class has two main purposes: + * + * 1. Providing a nice interface for requesting data from the server + * 2. Preventing sending the same request again while still waiting for the server + */ +export class Requests { + #requesting_metrics: Promise | null = null; + #requesting_commits: Promise | null = null; + #requesting_measurements: Map> = new Map(); + + async #request_data(url: string): Promise { + let response = await fetch(url); + let data: R = await response.json(); + return data; + } + + async get_metrics(): Promise { + if (this.#requesting_metrics !== null) { + try { + return await this.#requesting_metrics; + } catch (error) { + return null; + } + } + + this.#requesting_metrics = this.#request_data("metrics"); + try { + return await this.#requesting_metrics; + } catch (error) { + console.error("Could not get metrics:", error); + return null; + } finally { + this.#requesting_metrics = null; + } + } +} diff --git a/scripts/graph/util.ts b/scripts/graph/util.ts new file mode 100644 index 0000000..4cd27ea --- /dev/null +++ b/scripts/graph/util.ts @@ -0,0 +1,11 @@ +/** + * Create an {@link HTMLElement}. + */ +export function el(name: string, attributes: { [key: string]: string; }, ...children: (string | Node)[]) { + let element = document.createElement(name); + for (let [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value); + } + element.append(...children); + return element; +} diff --git a/src/server/web.rs b/src/server/web.rs index 0e2badd..2721292 100644 --- a/src/server/web.rs +++ b/src/server/web.rs @@ -26,7 +26,7 @@ use self::{ }, pages::{ commit::get_commit_by_hash, - graph::{get_graph, get_graph_data}, + graph::{get_graph, get_graph_data, get_graph_metrics}, index::get_index, queue::{get_queue, get_queue_delete, get_queue_inner}, run::get_run_by_id, @@ -49,6 +49,7 @@ pub async fn run(server: Server) -> somehow::Result<()> { .typed_get(get_commit_by_hash) .typed_get(get_graph) .typed_get(get_graph_data) + .typed_get(get_graph_metrics) .typed_get(get_index) .typed_get(get_queue) .typed_get(get_queue_delete) diff --git a/src/server/web/pages/graph.rs b/src/server/web/pages/graph.rs index b5d0a19..c7e1a41 100644 --- a/src/server/web/pages/graph.rs +++ b/src/server/web/pages/graph.rs @@ -14,108 +14,39 @@ use crate::{ config::ServerConfig, server::web::{ base::{Base, Link, Tab}, - paths::{PathGraph, PathGraphData}, + paths::{PathGraph, PathGraphData, PathGraphMetrics}, r#static::{GRAPH_JS, UPLOT_CSS}, }, somehow, }; -use self::util::MetricFolder; - -#[derive(Template)] -#[template( - ext = "html", - source = " -{% match self %} - {% when MetricTree::File with { name, metric } %} - - {% when MetricTree::Folder with { name, metric, children } %} - {% if children.trees.is_empty() %} - {% if let Some(metric) = metric %} - - {% endif %} - {% else if let Some(metric) = metric %} -
- {{ name }}/ - {{ children|safe }} -
- {% else %} -
- {{ name }}/ - {{ children|safe }} -
- {% endif %} -{% endmatch %} -" -)] -enum MetricTree { - File { - name: String, - metric: String, - }, - Folder { - name: String, - metric: Option, - children: MetricForest, - }, -} - -#[derive(Template)] -#[template( - ext = "html", - source = " -
    - {% for tree in trees %} -
  • {{ tree|safe }}
  • - {% endfor %} -
-" -)] -struct MetricForest { - trees: Vec, -} - -impl MetricForest { - fn from_forest(children: HashMap) -> Self { - let mut children = children.into_iter().collect::>(); - children.sort_unstable_by(|a, b| a.0.cmp(&b.0)); - - let mut trees = vec![]; - for (name, mut folder) in children { - if let Some(file_metric) = folder.metric { - trees.push(MetricTree::File { - name: name.clone(), - metric: file_metric, - }); - } - - let is_folder = !folder.children.is_empty(); - let folder_metric = folder.children.remove("").and_then(|f| f.metric); - if is_folder { - trees.push(MetricTree::Folder { - name, - metric: folder_metric, - children: Self::from_forest(folder.children), - }) - } - } - Self { trees } - } -} - #[derive(Template)] #[template(path = "pages/graph.html")] struct Page { link_uplot_css: Link, link_graph_js: Link, base: Base, - - metrics: MetricForest, } pub async fn get_graph( _path: PathGraph, State(config): State<&'static ServerConfig>, +) -> somehow::Result { + let base = Base::new(config, Tab::Graph); + Ok(Page { + link_uplot_css: base.link(UPLOT_CSS), + link_graph_js: base.link(GRAPH_JS), + base, + }) +} + +#[derive(Serialize)] +struct MetricsResponse { + metrics: Vec, +} + +pub async fn get_graph_metrics( + _path: PathGraphMetrics, State(db): State, ) -> somehow::Result { let metrics = @@ -123,18 +54,7 @@ pub async fn get_graph( .fetch_all(&db) .await?; - let metrics = MetricFolder::new(metrics); - assert!(metrics.metric.is_none()); - let metrics = MetricForest::from_forest(metrics.children); - - let base = Base::new(config, Tab::Graph); - Ok(Page { - link_uplot_css: base.link(UPLOT_CSS), - link_graph_js: base.link(GRAPH_JS), - base, - - metrics, - }) + Ok(Json(MetricsResponse { metrics })) } #[derive(Deserialize)] diff --git a/src/server/web/pages/graph/util.rs b/src/server/web/pages/graph/util.rs index 07eb53a..87740c9 100644 --- a/src/server/web/pages/graph/util.rs +++ b/src/server/web/pages/graph/util.rs @@ -1,29 +1,5 @@ use std::collections::{HashMap, HashSet}; -#[derive(Default)] -pub struct MetricFolder { - pub metric: Option, - pub children: HashMap, -} - -impl MetricFolder { - fn insert(&mut self, metric: String) { - let mut current = self; - for segment in metric.split('/') { - current = current.children.entry(segment.to_string()).or_default(); - } - current.metric = Some(metric); - } - - pub fn new(metrics: Vec) -> Self { - let mut tree = Self::default(); - for metric in metrics { - tree.insert(metric); - } - tree - } -} - /// Sort commits topologically such that parents come before their children. /// /// Assumes that `parent_child_pairs` contains no duplicates and is in the diff --git a/src/server/web/paths.rs b/src/server/web/paths.rs index 672116d..3f10e94 100644 --- a/src/server/web/paths.rs +++ b/src/server/web/paths.rs @@ -13,6 +13,10 @@ pub struct PathIndex {} #[typed_path("/graph/")] pub struct PathGraph {} +#[derive(Deserialize, TypedPath)] +#[typed_path("/graph/metrics")] +pub struct PathGraphMetrics {} + #[derive(Deserialize, TypedPath)] #[typed_path("/graph/data")] pub struct PathGraphData {} diff --git a/templates/pages/graph.html b/templates/pages/graph.html index 1bba742..8e8c299 100644 --- a/templates/pages/graph.html +++ b/templates/pages/graph.html @@ -13,9 +13,7 @@
-
- {{ metrics|safe }} -
+
{% endblock %}