Create metric selector via JS
This commit is contained in:
parent
8c7399725d
commit
d82804e209
9 changed files with 187 additions and 192 deletions
|
|
@ -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
68
scripts/graph/metrics.ts
Normal 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
71
scripts/graph/requests.ts
Normal 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
11
scripts/graph/util.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue