Create metric selector via JS

This commit is contained in:
Joscha 2023-10-22 01:21:32 +02:00
parent 8c7399725d
commit d82804e209
9 changed files with 187 additions and 192 deletions

View file

@ -1,3 +1,6 @@
import * as metrics from "./graph/metrics.js";
import { Requests } from "./graph/requests.js";
import uPlot from "./uPlot.js"; import uPlot from "./uPlot.js";
/* /*
@ -116,76 +119,19 @@ const COLORS = [
"#000000", // Black "#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<HTMLInputElement>('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 // Initialization
const plot_div = document.getElementById("plot")!; const plot_div = document.getElementById("plot")!;
const metrics_div = document.getElementById("metrics")!; const metrics_div = document.getElementById("metrics")!;
let plot: uPlot | null = null; let plot: uPlot | null = null;
for (const input of metrics_div.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) { let requests = new Requests();
input.addEventListener("change", update_plot);
}
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();

68
scripts/graph/metrics.ts Normal file
View file

@ -0,0 +1,68 @@
import { el } from "./util.js";
class Folder {
metric: string | null = null;
children: Map<string, Folder> = 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());
}

71
scripts/graph/requests.ts Normal file
View file

@ -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<MetricsResponse> | null = null;
#requesting_commits: Promise<CommitsResponse> | null = null;
#requesting_measurements: Map<string, Promise<MeasurementsResponse>> = new Map();
async #request_data<R>(url: string): Promise<R> {
let response = await fetch(url);
let data: R = await response.json();
return data;
}
async get_metrics(): Promise<MetricsResponse | null> {
if (this.#requesting_metrics !== null) {
try {
return await this.#requesting_metrics;
} catch (error) {
return null;
}
}
this.#requesting_metrics = this.#request_data<MetricsResponse>("metrics");
try {
return await this.#requesting_metrics;
} catch (error) {
console.error("Could not get metrics:", error);
return null;
} finally {
this.#requesting_metrics = null;
}
}
}

11
scripts/graph/util.ts Normal file
View file

@ -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;
}

View file

@ -26,7 +26,7 @@ use self::{
}, },
pages::{ pages::{
commit::get_commit_by_hash, commit::get_commit_by_hash,
graph::{get_graph, get_graph_data}, graph::{get_graph, get_graph_data, get_graph_metrics},
index::get_index, index::get_index,
queue::{get_queue, get_queue_delete, get_queue_inner}, queue::{get_queue, get_queue_delete, get_queue_inner},
run::get_run_by_id, 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_commit_by_hash)
.typed_get(get_graph) .typed_get(get_graph)
.typed_get(get_graph_data) .typed_get(get_graph_data)
.typed_get(get_graph_metrics)
.typed_get(get_index) .typed_get(get_index)
.typed_get(get_queue) .typed_get(get_queue)
.typed_get(get_queue_delete) .typed_get(get_queue_delete)

View file

@ -14,108 +14,39 @@ use crate::{
config::ServerConfig, config::ServerConfig,
server::web::{ server::web::{
base::{Base, Link, Tab}, base::{Base, Link, Tab},
paths::{PathGraph, PathGraphData}, paths::{PathGraph, PathGraphData, PathGraphMetrics},
r#static::{GRAPH_JS, UPLOT_CSS}, r#static::{GRAPH_JS, UPLOT_CSS},
}, },
somehow, somehow,
}; };
use self::util::MetricFolder;
#[derive(Template)]
#[template(
ext = "html",
source = "
{% match self %}
{% when MetricTree::File with { name, metric } %}
<label><input type=\"checkbox\" name=\"{{ metric }}\"> {{ name }}</label>
{% when MetricTree::Folder with { name, metric, children } %}
{% if children.trees.is_empty() %}
{% if let Some(metric) = metric %}
<label><input type=\"checkbox\" name=\"{{ metric }}\"> {{ name }}/</label>
{% endif %}
{% else if let Some(metric) = metric %}
<details>
<summary><input type=\"checkbox\" name=\"{{ metric }}\"> {{ name }}/</summary>
{{ children|safe }}
</details>
{% else %}
<details class=\"no-metric\">
<summary>{{ name }}/</summary>
{{ children|safe }}
</details>
{% endif %}
{% endmatch %}
"
)]
enum MetricTree {
File {
name: String,
metric: String,
},
Folder {
name: String,
metric: Option<String>,
children: MetricForest,
},
}
#[derive(Template)]
#[template(
ext = "html",
source = "
<ul>
{% for tree in trees %}
<li>{{ tree|safe }}</li>
{% endfor %}
</ul>
"
)]
struct MetricForest {
trees: Vec<MetricTree>,
}
impl MetricForest {
fn from_forest(children: HashMap<String, MetricFolder>) -> Self {
let mut children = children.into_iter().collect::<Vec<_>>();
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)] #[derive(Template)]
#[template(path = "pages/graph.html")] #[template(path = "pages/graph.html")]
struct Page { struct Page {
link_uplot_css: Link, link_uplot_css: Link,
link_graph_js: Link, link_graph_js: Link,
base: Base, base: Base,
metrics: MetricForest,
} }
pub async fn get_graph( pub async fn get_graph(
_path: PathGraph, _path: PathGraph,
State(config): State<&'static ServerConfig>, State(config): State<&'static ServerConfig>,
) -> somehow::Result<impl IntoResponse> {
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<String>,
}
pub async fn get_graph_metrics(
_path: PathGraphMetrics,
State(db): State<SqlitePool>, State(db): State<SqlitePool>,
) -> somehow::Result<impl IntoResponse> { ) -> somehow::Result<impl IntoResponse> {
let metrics = let metrics =
@ -123,18 +54,7 @@ pub async fn get_graph(
.fetch_all(&db) .fetch_all(&db)
.await?; .await?;
let metrics = MetricFolder::new(metrics); Ok(Json(MetricsResponse { 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,
})
} }
#[derive(Deserialize)] #[derive(Deserialize)]

View file

@ -1,29 +1,5 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub struct MetricFolder {
pub metric: Option<String>,
pub children: HashMap<String, MetricFolder>,
}
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<String>) -> Self {
let mut tree = Self::default();
for metric in metrics {
tree.insert(metric);
}
tree
}
}
/// Sort commits topologically such that parents come before their children. /// Sort commits topologically such that parents come before their children.
/// ///
/// Assumes that `parent_child_pairs` contains no duplicates and is in the /// Assumes that `parent_child_pairs` contains no duplicates and is in the

View file

@ -13,6 +13,10 @@ pub struct PathIndex {}
#[typed_path("/graph/")] #[typed_path("/graph/")]
pub struct PathGraph {} pub struct PathGraph {}
#[derive(Deserialize, TypedPath)]
#[typed_path("/graph/metrics")]
pub struct PathGraphMetrics {}
#[derive(Deserialize, TypedPath)] #[derive(Deserialize, TypedPath)]
#[typed_path("/graph/data")] #[typed_path("/graph/data")]
pub struct PathGraphData {} pub struct PathGraphData {}

View file

@ -13,9 +13,7 @@
<div class="graph-container"> <div class="graph-container">
<div id="plot"></div> <div id="plot"></div>
<div id="metrics" class="metrics-list"> <div id="metrics" class="metrics-list"></div>
{{ metrics|safe }}
</div>
</div> </div>
{% endblock %} {% endblock %}