Reformat everything

This commit is contained in:
Joscha 2024-05-11 18:32:36 +02:00
parent 93663fff8c
commit 36ce75b43d
12 changed files with 560 additions and 526 deletions

View file

@ -1,3 +1,3 @@
{ {
"trailingComma": "all" "trailingComma": "all"
} }

142
DESIGN.md
View file

@ -11,57 +11,57 @@ think them through.
- Locally, tablejohn should just work™ without custom config. - Locally, tablejohn should just work™ without custom config.
- However, some cli args might need to be specified for full functionality. - However, some cli args might need to be specified for full functionality.
- The db contains... - The db contains...
- Commits and their relationships - Commits and their relationships
- Branches and whether they're tracked - Branches and whether they're tracked
- Runs and their measurements - Runs and their measurements
- Queue of commits - Queue of commits
- The in-memory state also contains... - The in-memory state also contains...
- Connected workers and their state - Connected workers and their state
- From this follows the list of in-progress runs - From this follows the list of in-progress runs
- Workers... - Workers...
- Should be robust - Should be robust
- Noone wants to lose a run a few hours in, for any reason - Noone wants to lose a run a few hours in, for any reason
- Explicitly design for loss of connection, server restarts - Explicitly design for loss of connection, server restarts
- Also design for bench script failures - Also design for bench script failures
- Can connect to more than one tablejohn instance - Can connect to more than one tablejohn instance
- Use task runtime-based approach to fairness - Use task runtime-based approach to fairness
- Steal tasks based on time already spen on task - Steal tasks based on time already spen on task
- Use plain old http requests (with BASIC auth) to communicate with server - Use plain old http requests (with BASIC auth) to communicate with server
- Store no data permanently - Store no data permanently
- Nice-to-have but not critical - Nice-to-have but not critical
- Statically checked links - Statically checked links
- Statically checked paths for static files - Statically checked paths for static files
## Web pages ## Web pages
- GET `/` - GET `/`
- Tracked and untracked refs - Tracked and untracked refs
- Recent significant changes? - Recent significant changes?
- "What's the state of the repo?" - "What's the state of the repo?"
- GET `/graph/` - GET `/graph/`
- Interactive graph - Interactive graph
- Change scope interactively - Change scope interactively
- Change metrics interactively - Change metrics interactively
- GET `/queue/` - GET `/queue/`
- List of workers and their state - List of workers and their state
- List of unfinished runs - List of unfinished runs
- "What's the state of the infrastructure?" - "What's the state of the infrastructure?"
- GET `/commit/<hash>/` - GET `/commit/<hash>/`
- Show details of a commit - Show details of a commit
- Link to parents, chilren, runs in chronological order - Link to parents, chilren, runs in chronological order
- Resolve refs and branch names to commit hashes -> redirect - Resolve refs and branch names to commit hashes -> redirect
- GET `/run/<rid>/` - GET `/run/<rid>/`
- Show details of a run - Show details of a run
- Link to commit, other runs in chronological order - Link to commit, other runs in chronological order
- Links to compare against previous run, closest tracked ancestors? - Links to compare against previous run, closest tracked ancestors?
- Resolve refs, branch names and commits to their latest runs -> redirect - Resolve refs, branch names and commits to their latest runs -> redirect
- GET `/compare/<rid1>/` - GET `/compare/<rid1>/`
- Select/search run to compare against? - Select/search run to compare against?
- Enter commit hash or run id - Enter commit hash or run id
- Resolve refs, branch names and commits to their latest runs -> redirect - Resolve refs, branch names and commits to their latest runs -> redirect
- GET `/compare/rid1/<rid2>/` - GET `/compare/rid1/<rid2>/`
- Show changes from rid2 to rid1 - Show changes from rid2 to rid1
- Resolve refs, branch names and commits to their latest runs -> redirect - Resolve refs, branch names and commits to their latest runs -> redirect
## Worker interaction ## Worker interaction
@ -76,34 +76,34 @@ This allows more human-readable and permanent links to workers than something
like session ids. like session ids.
- POST `/api/worker/status` - POST `/api/worker/status`
- Main endpoint for worker/server coordination - Main endpoint for worker/server coordination
- Worker periodically sends current status to server - Worker periodically sends current status to server
- Includes a secret randomly chosen by the worker - Includes a secret randomly chosen by the worker
- Subsequent requests must include exactly the same secret - Subsequent requests must include exactly the same secret
- Protects against the case where multiple workers share the same name - Protects against the case where multiple workers share the same name
- Worker may include request for new work - Worker may include request for new work
- If so, server may respond with a commit hash and bench method - If so, server may respond with a commit hash and bench method
- Worker may include current work - Worker may include current work
- If so, server may respond with request to abort the work - If so, server may respond with request to abort the work
- GET `/api/worker/repo/<hash>/tree.tar.gz` - GET `/api/worker/repo/<hash>/tree.tar.gz`
- Get tar-ed commit from the server's repo, if any exists - Get tar-ed commit from the server's repo, if any exists
- GET `/api/worker/bench-repo/<hash>/tree.tar.gz` - GET `/api/worker/bench-repo/<hash>/tree.tar.gz`
- Get tar-ed commit from the server's bench repo, if any exist - Get tar-ed commit from the server's bench repo, if any exist
## CLI Args ## CLI Args
tablejohn can be run in one of two modes: Server mode, and worker mode. tablejohn can be run in one of two modes: Server mode, and worker mode.
- server - server
- Run a web server that serves the contents of a db - Run a web server that serves the contents of a db
- Optionally, specify repo to update the db from - Optionally, specify repo to update the db from
- Optionally, launch local worker (only if repo is specified) - Optionally, launch local worker (only if repo is specified)
- When local worker is enabled, it ignores the worker section of the config - When local worker is enabled, it ignores the worker section of the config
- Instead, a worker section is generated from the server config - Instead, a worker section is generated from the server config
- This approach should make `--local-worker` more fool-proof - This approach should make `--local-worker` more fool-proof
- worker - worker
- Run only as worker (when using external machine for workers) - Run only as worker (when using external machine for workers)
- Same config file format as server, just uses different parts - Same config file format as server, just uses different parts
## Config file and options ## Config file and options
@ -111,19 +111,19 @@ Regardless of the mode, the config file is always loaded the same way and has
the same format. It is split into these chunks: the same format. It is split into these chunks:
- web (ignored in worker mode) - web (ignored in worker mode)
- Everything to do with the web server - Everything to do with the web server
- What address and port to bind on - What address and port to bind on
- What url the site is being served under - What url the site is being served under
- repo (ignored in worker mode) - repo (ignored in worker mode)
- Everything to do with the repo the server is inspecting - Everything to do with the repo the server is inspecting
- Name (derived from repo path if not specified here) - Name (derived from repo path if not specified here)
- How frequently to update the db from the repo - How frequently to update the db from the repo
- A remote URL to update the repo from - A remote URL to update the repo from
- Whether to clone the repo if it doesn't yet exist - Whether to clone the repo if it doesn't yet exist
- worker (ignored in server mode) - worker (ignored in server mode)
- Name (uses system name by default) - Name (uses system name by default)
- Custom bench dir path (creates temporary dir by default) - Custom bench dir path (creates temporary dir by default)
- List of servers, each of which has... - List of servers, each of which has...
- Token to authenticate with - Token to authenticate with
- Base url to contact - Base url to contact
- Weight to prioritize with (by total run time + overhead?) - Weight to prioritize with (by total run time + overhead?)

View file

@ -5,11 +5,13 @@ Run benchmarks against commits in a git repo and present their results.
## Building from source ## Building from source
The following tools are required: The following tools are required:
- `cargo` and `rustc` (best installed via [rustup](https://rustup.rs/)) - `cargo` and `rustc` (best installed via [rustup](https://rustup.rs/))
- `tsc`, the [typescript](https://www.typescriptlang.org/) compiler - `tsc`, the [typescript](https://www.typescriptlang.org/) compiler
Once you have installed these tools, run the following command to install or Once you have installed these tools, run the following command to install or
update tablejohn to `~/.cargo/bin/tablejohn`: update tablejohn to `~/.cargo/bin/tablejohn`:
``` ```
cargo install --force --git https://github.com/Garmelon/tablejohn cargo install --force --git https://github.com/Garmelon/tablejohn
``` ```

View file

@ -116,28 +116,28 @@ ordering for both display modes, so this is also relevant to the normal mode.
// https://sashamaps.net/docs/resources/20-colors/ // https://sashamaps.net/docs/resources/20-colors/
// Related: https://en.wikipedia.org/wiki/Help:Distinguishable_colors // Related: https://en.wikipedia.org/wiki/Help:Distinguishable_colors
const COLORS = [ const COLORS = [
"#e6194B", // Red "#e6194B", // Red
"#3cb44b", // Green "#3cb44b", // Green
"#ffe119", // Yellow "#ffe119", // Yellow
"#4363d8", // Blue "#4363d8", // Blue
"#f58231", // Orange "#f58231", // Orange
// "#911eb4", // Purple // "#911eb4", // Purple
"#42d4f4", // Cyan "#42d4f4", // Cyan
"#f032e6", // Magenta "#f032e6", // Magenta
// "#bfef45", // Lime // "#bfef45", // Lime
// "#fabed4", // Pink // "#fabed4", // Pink
"#469990", // Teal "#469990", // Teal
// "#dcbeff", // Lavender // "#dcbeff", // Lavender
"#9A6324", // Brown "#9A6324", // Brown
// "#fffac8", // Beige // "#fffac8", // Beige
"#800000", // Maroon "#800000", // Maroon
// "#aaffc3", // Mint // "#aaffc3", // Mint
// "#808000", // Olive // "#808000", // Olive
// "#ffd8b1", // Apricot // "#ffd8b1", // Apricot
"#000075", // Navy "#000075", // Navy
"#a9a9a9", // Grey "#a9a9a9", // Grey
// "#ffffff", // White // "#ffffff", // White
"#000000", // Black "#000000", // Black
]; ];
// Initialization // Initialization

View file

@ -2,156 +2,161 @@ import { CommitsResponse } from "./requests.js";
import { SECONDS_PER_DAY } from "./util.js"; import { SECONDS_PER_DAY } from "./util.js";
type Commit = { type Commit = {
indexByHash: number; indexByHash: number;
indexByGraph: number; indexByGraph: number;
hash: string; hash: string;
parents: Commit[]; parents: Commit[];
children: Commit[]; children: Commit[];
author: string; author: string;
committerDate: number; committerDate: number;
summary: string; summary: string;
}; };
export class Commits { export class Commits {
#graphId: number | null = null; #graphId: number | null = null;
#commitsByGraph: Commit[] = []; #commitsByGraph: Commit[] = [];
#committerDatesNormal: Date[] = []; #committerDatesNormal: Date[] = [];
#committerDatesDayEquidistant: Date[] = []; #committerDatesDayEquidistant: Date[] = [];
requiresUpdate(graphId: number): boolean { requiresUpdate(graphId: number): boolean {
return this.#graphId === null || this.#graphId < graphId; return this.#graphId === null || this.#graphId < graphId;
}
update(response: CommitsResponse) {
console.assert(response.hashByHash.length == response.authorByHash.length);
console.assert(
response.hashByHash.length == response.committerDateByHash.length,
);
console.assert(response.hashByHash.length == response.summaryByHash.length);
let commits = this.#loadCommits(response);
commits = this.#sortCommitsTopologically(commits);
this.#sortCommitsByCommitterDate(commits);
// Fill in indexes - "later" is now
for (const [idx, commit] of commits.entries()) {
commit.indexByGraph = idx;
} }
update(response: CommitsResponse) { const committerDatesNormal = commits.map((c) => c.committerDate);
console.assert(response.hashByHash.length == response.authorByHash.length); const committerDatesDayEquidistant =
console.assert(response.hashByHash.length == response.committerDateByHash.length); this.#makeDayEquidistant(committerDatesNormal);
console.assert(response.hashByHash.length == response.summaryByHash.length);
let commits = this.#loadCommits(response); // To prevent exceptions and other weirdness from messing up our state,
commits = this.#sortCommitsTopologically(commits); // we update everything in one go.
this.#sortCommitsByCommitterDate(commits); this.#graphId = response.graphId;
this.#commitsByGraph = commits;
this.#committerDatesNormal = this.#epochTimesToDates(committerDatesNormal);
this.#committerDatesDayEquidistant = this.#epochTimesToDates(
committerDatesDayEquidistant,
);
}
// Fill in indexes - "later" is now #loadCommits(response: CommitsResponse): Commit[] {
for (const [idx, commit] of commits.entries()) { const commits = new Map<string, Commit>();
commit.indexByGraph = idx; const commitsByHash = [];
for (const [idx, hash] of response.hashByHash.entries()) {
const commit = {
indexByHash: idx,
indexByGraph: NaN, // Filled in later
hash,
parents: [],
children: [],
author: response.authorByHash[idx]!,
committerDate: response.committerDateByHash[idx]!,
summary: response.summaryByHash[idx]!,
};
commits.set(hash, commit);
commitsByHash.push(commit);
}
// Fill in parents and children
for (const [childIdx, parentIdx] of response.childParentIndexPairs) {
const childHash = response.hashByHash[childIdx]!;
const parentHash = response.hashByHash[parentIdx]!;
const child = commits.get(childHash)!;
const parent = commits.get(parentHash)!;
child.parents.push(parent);
parent.children.push(child);
}
return commitsByHash;
}
#sortCommitsByCommitterDate(commits: Commit[]) {
commits.sort((a, b) => a.committerDate - b.committerDate);
}
/**
* Sort commits topologically such that parents come before their children.
*
* Assumes that there are no duplicated commits anywhere.
*
* A reverse post-order DFS is a topological sort, so that is what this
* function implements.
*/
#sortCommitsTopologically(commits: Commit[]): Commit[] {
const visited: Set<string> = new Set();
const visiting: Commit[] = commits.filter((c) => c.parents.length == 0);
const sorted: Commit[] = [];
while (visiting.length > 0) {
const commit = visiting.at(-1)!;
if (visited.has(commit.hash)) {
visiting.pop();
sorted.push(commit);
continue;
}
for (const child of commit.children) {
if (!visited.has(child.hash)) {
visiting.push(child);
} }
}
const committerDatesNormal = commits.map(c => c.committerDate); visited.add(commit.hash);
const committerDatesDayEquidistant = this.#makeDayEquidistant(committerDatesNormal);
// To prevent exceptions and other weirdness from messing up our state,
// we update everything in one go.
this.#graphId = response.graphId;
this.#commitsByGraph = commits;
this.#committerDatesNormal = this.#epochTimesToDates(committerDatesNormal);
this.#committerDatesDayEquidistant = this.#epochTimesToDates(committerDatesDayEquidistant);
} }
#loadCommits(response: CommitsResponse): Commit[] { sorted.reverse();
const commits = new Map<string, Commit>();
const commitsByHash = [];
for (const [idx, hash] of response.hashByHash.entries()) { console.assert(visited.size === commits.length);
const commit = { console.assert(visiting.length === 0);
indexByHash: idx, console.assert(sorted.length === commits.length);
indexByGraph: NaN, // Filled in later return sorted;
hash, }
parents: [],
children: [],
author: response.authorByHash[idx]!,
committerDate: response.committerDateByHash[idx]!,
summary: response.summaryByHash[idx]!,
};
commits.set(hash, commit);
commitsByHash.push(commit);
}
// Fill in parents and children /**
for (const [childIdx, parentIdx] of response.childParentIndexPairs) { * Assumes the times are sorted.
const childHash = response.hashByHash[childIdx]!; */
const parentHash = response.hashByHash[parentIdx]!; #makeDayEquidistant(times: number[]): number[] {
const days: { day: number; amount: number }[] = [];
const child = commits.get(childHash)!; for (const time of times) {
const parent = commits.get(parentHash)!; const day = time % SECONDS_PER_DAY;
const prev = days.at(-1);
child.parents.push(parent); if (prev === undefined || prev.day !== day) {
parent.children.push(child); days.push({ day, amount: 1 });
} } else {
prev.amount++;
return commitsByHash; }
} }
#sortCommitsByCommitterDate(commits: Commit[]) { const result: number[] = [];
commits.sort((a, b) => a.committerDate - b.committerDate); for (const day of days) {
const secondsPerCommit = SECONDS_PER_DAY / day.amount;
for (let i = 0; i < day.amount; i++) {
const time = day.day * SECONDS_PER_DAY + secondsPerCommit * (i + 0.5);
result.push(time);
}
} }
/** return result;
* Sort commits topologically such that parents come before their children. }
*
* Assumes that there are no duplicated commits anywhere.
*
* A reverse post-order DFS is a topological sort, so that is what this
* function implements.
*/
#sortCommitsTopologically(commits: Commit[]): Commit[] {
const visited: Set<string> = new Set();
const visiting: Commit[] = commits.filter(c => c.parents.length == 0);
const sorted: Commit[] = []; #epochTimesToDates(times: number[]): Date[] {
return times.map((t) => new Date(1000 * t));
while (visiting.length > 0) { }
const commit = visiting.at(-1)!;
if (visited.has(commit.hash)) {
visiting.pop();
sorted.push(commit);
continue;
}
for (const child of commit.children) {
if (!visited.has(child.hash)) {
visiting.push(child);
}
}
visited.add(commit.hash);
}
sorted.reverse();
console.assert(visited.size === commits.length);
console.assert(visiting.length === 0);
console.assert(sorted.length === commits.length);
return sorted;
}
/**
* Assumes the times are sorted.
*/
#makeDayEquidistant(times: number[]): number[] {
const days: { day: number, amount: number; }[] = [];
for (const time of times) {
const day = time % SECONDS_PER_DAY;
const prev = days.at(-1);
if (prev === undefined || prev.day !== day) {
days.push({ day, amount: 1 });
} else {
prev.amount++;
}
}
const result: number[] = [];
for (const day of days) {
const secondsPerCommit = SECONDS_PER_DAY / day.amount;
for (let i = 0; i < day.amount; i++) {
const time = day.day * SECONDS_PER_DAY + secondsPerCommit * (i + 0.5);
result.push(time);
}
}
return result;
}
#epochTimesToDates(times: number[]): Date[] {
return times.map(t => new Date(1000 * t));
}
} }

View file

@ -2,110 +2,126 @@ import { MetricsResponse } from "./requests.js";
import { el } from "./util.js"; import { el } from "./util.js";
class Folder { class Folder {
metric: string | null = null; metric: string | null = null;
children: Map<string, Folder> = new Map(); children: Map<string, Folder> = new Map();
getOrCreateChild(name: string): Folder { getOrCreateChild(name: string): Folder {
let child = this.children.get(name); let child = this.children.get(name);
if (child === undefined) { if (child === undefined) {
child = new Folder(); child = new Folder();
this.children.set(name, child); this.children.set(name, child);
}
return child;
} }
return child;
}
add(metric: string) { add(metric: string) {
let current: Folder = this; let current: Folder = this;
for (let segment of metric.split("/")) { for (let segment of metric.split("/")) {
current = current.getOrCreateChild(segment); current = current.getOrCreateChild(segment);
}
current.metric = metric;
} }
current.metric = metric;
}
toHtmlElement(name: string): HTMLElement { toHtmlElement(name: string): HTMLElement {
if (this.children.size > 0) { // Folder if (this.children.size > 0) {
name = `${name}/`; // Folder
if (this.metric === null) { // Folder without metric name = `${name}/`;
return el("details", { "class": "no-metric" }, if (this.metric === null) {
el("summary", {}, name), // Folder without metric
this.childrenToHtmlElements(), return el(
); "details",
} else { // Folder with metric { class: "no-metric" },
return el("details", {}, el("summary", {}, name),
el("summary", {}, this.childrenToHtmlElements(),
el("input", { "type": "checkbox", "name": this.metric }), );
" ", name, } else {
), // Folder with metric
this.childrenToHtmlElements(), return el(
); "details",
} {},
} else if (this.metric !== null) { // Normal metric el(
return el("label", {}, "summary",
el("input", { "type": "checkbox", "name": this.metric }), {},
" ", name, el("input", { type: "checkbox", name: this.metric }),
); " ",
} else { // Metric without metric, should never happen name,
return el("label", {}, 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 { childrenToHtmlElements(): HTMLElement {
let result: HTMLElement = el("ul", {}); let result: HTMLElement = el("ul", {});
for (let [name, folder] of this.children.entries()) { for (let [name, folder] of this.children.entries()) {
result.append(el("li", {}, folder.toHtmlElement(name))); result.append(el("li", {}, folder.toHtmlElement(name)));
}
return result;
} }
return result;
}
} }
export class Metrics { export class Metrics {
#div: HTMLElement; #div: HTMLElement;
#dataId: number | null = null; #dataId: number | null = null;
constructor(div: HTMLElement) { constructor(div: HTMLElement) {
this.#div = div; this.#div = div;
}
getSelected(): Set<string> {
const selected = new Set<string>();
const checkedInputs =
this.#div.querySelectorAll<HTMLInputElement>("input:checked");
for (const input of checkedInputs) {
selected.add(input.name);
} }
getSelected(): Set<string> { return selected;
const selected = new Set<string>(); }
const checkedInputs = this.#div.querySelectorAll<HTMLInputElement>("input:checked"); requiresUpdate(dataId: number): boolean {
for (const input of checkedInputs) { // At the moment, updating the metrics results in all <details> tags
selected.add(input.name); // closing again. To prevent this (as it can be frustrating if you've
} // navigated deep into the metrics hierarchy), we never require updates
// after the initial update.
return this.#dataId === null;
// return this.#dataId === null || this.#dataId < dataId;
}
return selected; update(response: MetricsResponse) {
const selected = this.getSelected();
const folder = new Folder();
for (const metric of response.metrics) {
folder.add(metric);
} }
requiresUpdate(dataId: number): boolean { this.#div.textContent = ""; // Remove children
// At the moment, updating the metrics results in all <details> tags if (folder.children.size == 0) {
// closing again. To prevent this (as it can be frustrating if you've this.#div.append("There aren't yet any metrics");
// navigated deep into the metrics hierarchy), we never require updates } else {
// after the initial update. this.#div.append(folder.childrenToHtmlElements());
return this.#dataId === null;
// return this.#dataId === null || this.#dataId < dataId;
} }
update(response: MetricsResponse) { const inputs = this.#div.querySelectorAll<HTMLInputElement>("input");
const selected = this.getSelected(); for (const input of inputs) {
input.checked = selected.has(input.name);
const folder = new Folder();
for (const metric of response.metrics) {
folder.add(metric);
}
this.#div.textContent = ""; // Remove children
if (folder.children.size == 0) {
this.#div.append("There aren't yet any metrics");
} else {
this.#div.append(folder.childrenToHtmlElements());
}
const inputs = this.#div.querySelectorAll<HTMLInputElement>("input");
for (const input of inputs) {
input.checked = selected.has(input.name);
}
this.#dataId = response.dataId;
} }
this.#dataId = response.dataId;
}
} }

View file

@ -2,46 +2,48 @@
* `/graph/metrics` response data. * `/graph/metrics` response data.
*/ */
export type MetricsResponse = { export type MetricsResponse = {
dataId: number; dataId: number;
metrics: string[]; metrics: string[];
}; };
/** /**
* `/graph/commits` response data. * `/graph/commits` response data.
*/ */
export type CommitsResponse = { export type CommitsResponse = {
graphId: number; graphId: number;
hashByHash: string[]; hashByHash: string[];
authorByHash: string[]; authorByHash: string[];
committerDateByHash: number[]; committerDateByHash: number[];
summaryByHash: string[]; summaryByHash: string[];
childParentIndexPairs: [number, number][]; childParentIndexPairs: [number, number][];
}; };
/** /**
* `/graph/measurements` response data. * `/graph/measurements` response data.
*/ */
export type MeasurementsResponse = { export type MeasurementsResponse = {
graphId: number; graphId: number;
dataId: number; dataId: number;
measurements: { [key: string]: (number | null)[]; }; measurements: { [key: string]: (number | null)[] };
}; };
async function getData<R>(url: string): Promise<R> { async function getData<R>(url: string): Promise<R> {
const response = await fetch(url); const response = await fetch(url);
const data: R = await response.json(); const data: R = await response.json();
return data; return data;
} }
export async function getMetrics(): Promise<MetricsResponse> { export async function getMetrics(): Promise<MetricsResponse> {
return getData("metrics"); return getData("metrics");
} }
export async function getCommits(): Promise<CommitsResponse> { export async function getCommits(): Promise<CommitsResponse> {
return getData("commits"); return getData("commits");
} }
export async function getMeasurements(metrics: string[]): Promise<MeasurementsResponse> { export async function getMeasurements(
const params = new URLSearchParams(metrics.map(m => ["metric", m])); metrics: string[],
return getData(`measurements?${params}`); ): Promise<MeasurementsResponse> {
const params = new URLSearchParams(metrics.map((m) => ["metric", m]));
return getData(`measurements?${params}`);
} }

View file

@ -3,85 +3,85 @@ import { Metrics } from "./metrics.js";
import { getCommits, getMetrics } from "./requests.js"; import { getCommits, getMetrics } from "./requests.js";
export class State { export class State {
#latestGraphId: number = -Infinity; #latestGraphId: number = -Infinity;
#latestDataId: number = -Infinity; #latestDataId: number = -Infinity;
#metrics: Metrics; #metrics: Metrics;
#commits: Commits = new Commits(); #commits: Commits = new Commits();
#requestingMetrics: boolean = false; #requestingMetrics: boolean = false;
#requestingCommits: boolean = false; #requestingCommits: boolean = false;
// raw measurements (with graph id and data id) // raw measurements (with graph id and data id)
// processed measurements (with graph id and data id) // processed measurements (with graph id and data id)
constructor(metrics: Metrics) { constructor(metrics: Metrics) {
this.#metrics = metrics; this.#metrics = metrics;
}
/**
* Update state and plot and request new data if necessary. Tries to match
* the user's wishes as closely as possible.
*
* This function is idempotent.
*/
update() {
// TODO Invalidate and update data
// TODO Update graph
this.#requestDataWhereNecessary();
}
//////////////////////////////////
// Requesting and updating data //
//////////////////////////////////
#updateDataId(dataId: number) {
if (dataId > this.#latestDataId) {
this.#latestDataId = dataId;
}
}
#updateGraphId(graphId: number) {
if (graphId > this.#latestGraphId) {
this.#latestGraphId = graphId;
}
}
#requestDataWhereNecessary() {
if (this.#metrics.requiresUpdate(this.#latestDataId)) {
this.#requestMetrics();
} }
/** if (this.#commits.requiresUpdate(this.#latestGraphId)) {
* Update state and plot and request new data if necessary. Tries to match this.#requestCommits();
* the user's wishes as closely as possible.
*
* This function is idempotent.
*/
update() {
// TODO Invalidate and update data
// TODO Update graph
this.#requestDataWhereNecessary();
} }
}
////////////////////////////////// async #requestMetrics() {
// Requesting and updating data // if (this.#requestingMetrics) return;
////////////////////////////////// console.log("Requesting metrics");
try {
#updateDataId(dataId: number) { this.#requestingMetrics = true;
if (dataId > this.#latestDataId) { const response = await getMetrics();
this.#latestDataId = dataId; this.#updateDataId(response.dataId);
} this.#metrics.update(response);
this.update();
} finally {
this.#requestingMetrics = false;
} }
}
#updateGraphId(graphId: number) { async #requestCommits() {
if (graphId > this.#latestGraphId) { if (this.#requestingCommits) return;
this.#latestGraphId = graphId; console.log("Requesting commits");
} try {
} this.#requestingCommits = true;
const response = await getCommits();
#requestDataWhereNecessary() { this.#updateGraphId(response.graphId);
if (this.#metrics.requiresUpdate(this.#latestDataId)) { this.#commits.update(response);
this.#requestMetrics(); this.update();
} } finally {
this.#requestingCommits = false;
if (this.#commits.requiresUpdate(this.#latestGraphId)) {
this.#requestCommits();
}
}
async #requestMetrics() {
if (this.#requestingMetrics) return;
console.log("Requesting metrics");
try {
this.#requestingMetrics = true;
const response = await getMetrics();
this.#updateDataId(response.dataId);
this.#metrics.update(response);
this.update();
} finally {
this.#requestingMetrics = false;
}
}
async #requestCommits() {
if (this.#requestingCommits) return;
console.log("Requesting commits");
try {
this.#requestingCommits = true;
const response = await getCommits();
this.#updateGraphId(response.graphId);
this.#commits.update(response);
this.update();
} finally {
this.#requestingCommits = false;
}
} }
}
} }

View file

@ -1,13 +1,17 @@
/** /**
* Create an {@link HTMLElement}. * Create an {@link HTMLElement}.
*/ */
export function el(name: string, attributes: { [key: string]: string; }, ...children: (string | Node)[]) { export function el(
let element = document.createElement(name); name: string,
for (let [name, value] of Object.entries(attributes)) { attributes: { [key: string]: string },
element.setAttribute(name, value); ...children: (string | Node)[]
} ) {
element.append(...children); let element = document.createElement(name);
return element; for (let [name, value] of Object.entries(attributes)) {
element.setAttribute(name, value);
}
element.append(...children);
return element;
} }
export const SECONDS_PER_DAY = 24 * 60 * 60; export const SECONDS_PER_DAY = 24 * 60 * 60;

View file

@ -2,13 +2,16 @@ const INNER = document.getElementById("inner")!;
const REFRESH_SECONDS = 10; const REFRESH_SECONDS = 10;
function update() { function update() {
fetch("inner") fetch("inner")
.then(response => response.text()) .then((response) => response.text())
.then(text => { .then((text) => {
INNER.innerHTML = text; INNER.innerHTML = text;
let count = document.getElementById("queue")?.dataset["count"]!; let count = document.getElementById("queue")?.dataset["count"]!;
document.title = document.title.replace(/^queue \(\S+\)/, `queue (${count})`); document.title = document.title.replace(
}); /^queue \(\S+\)/,
`queue (${count})`,
);
});
} }
setInterval(update, REFRESH_SECONDS * 1000); setInterval(update, REFRESH_SECONDS * 1000);

View file

@ -1,13 +1,13 @@
* { * {
font-family: monospace; font-family: monospace;
font-size: inherit; font-size: inherit;
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
margin: .7em; margin: 0.7em;
} }
details, details,
@ -15,251 +15,252 @@ ol,
p, p,
table, table,
ul { ul {
margin: 1em 0; margin: 1em 0;
} }
h2 { h2 {
font-size: 1.7em; font-size: 1.7em;
margin: 1em 0 .5em; margin: 1em 0 0.5em;
} }
a { a {
text-decoration: underline; text-decoration: underline;
color: black; color: black;
} }
a:hover { a:hover {
font-weight: bold; font-weight: bold;
} }
button { button {
padding: 0 .5ch; padding: 0 0.5ch;
} }
button.linkish { button.linkish {
background-color: unset; background-color: unset;
border: unset; border: unset;
text-decoration: underline; text-decoration: underline;
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
} }
details { details {
padding: .3em 1ch; padding: 0.3em 1ch;
background-color: #ddd; background-color: #ddd;
} }
details>summary { details > summary {
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
list-style: "> " inside; list-style: "> " inside;
} }
details[open]>summary { details[open] > summary {
list-style: "v " inside; list-style: "v " inside;
} }
ul, ul,
ol { ol {
list-style: "- " outside; list-style: "- " outside;
margin-left: 2ch; margin-left: 2ch;
} }
dl { dl {
margin: 1em 0; margin: 1em 0;
} }
dt { dt {
margin-top: 1em; margin-top: 1em;
} }
dd { dd {
margin-left: 4ch; margin-left: 4ch;
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
} }
thead tr, thead tr,
tbody tr:hover { tbody tr:hover {
background-color: #ddd; background-color: #ddd;
} }
th, th,
td { td {
padding: .1em 0; padding: 0.1em 0;
} }
th+th, th + th,
td+td { td + td {
padding-left: 2ch; padding-left: 2ch;
} }
/* Nav bar */ /* Nav bar */
nav { nav {
font-size: 1.5em; font-size: 1.5em;
line-height: 1.2em; line-height: 1.2em;
padding: 0.3em; padding: 0.3em;
background-color: #bdf; background-color: #bdf;
border-radius: 0.3em; border-radius: 0.3em;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: baseline;
} }
nav>* { nav > * {
padding: 0 1ch; padding: 0 1ch;
border-right: 0.1em solid black; border-right: 0.1em solid black;
} }
nav>:first-child img { nav > :first-child img {
width: 1.2em; width: 1.2em;
height: 1.2em; height: 1.2em;
vertical-align: bottom; vertical-align: bottom;
padding-right: 0.3em; padding-right: 0.3em;
} }
nav>:first-child { nav > :first-child {
padding-left: 0; padding-left: 0;
} }
nav>:last-child { nav > :last-child {
padding-right: 0; padding-right: 0;
border: none; border: none;
} }
nav>.current { nav > .current {
font-weight: bold; font-weight: bold;
} }
nav a { nav a {
text-decoration: none; text-decoration: none;
} }
nav a:hover { nav a:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Commit status */ /* Commit status */
.commit-reachable { .commit-reachable {
color: #777; color: #777;
} }
.commit-orphaned { .commit-orphaned {
color: #a33; color: #a33;
} }
/* Index */ /* Index */
.refs-list dl { .refs-list dl {
margin-bottom: 0; margin-bottom: 0;
} }
/* Graph */ /* Graph */
.graph-container { .graph-container {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
align-items: flex-start; align-items: flex-start;
} }
.graph-container #plot { .graph-container #plot {
margin-right: 1em; margin-right: 1em;
margin-bottom: 1em; margin-bottom: 1em;
box-shadow: 0 0 .5em black; box-shadow: 0 0 0.5em black;
} }
.graph-container #metrics { .graph-container #metrics {
flex: 0 50ch; flex: 0 50ch;
box-shadow: 0 0 .5em black; box-shadow: 0 0 0.5em black;
} }
.metrics-list { .metrics-list {
background-color: #ddd; background-color: #ddd;
padding: .3em 1ch; padding: 0.3em 1ch;
} }
.metrics-list * { .metrics-list * {
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: unset; background-color: unset;
list-style: none; list-style: none;
} }
.metrics-list details[open]>summary { .metrics-list details[open] > summary {
list-style: none; list-style: none;
} }
.metrics-list input[type="checkbox"] { .metrics-list input[type="checkbox"] {
width: 2ch; width: 2ch;
} }
.metrics-list details.no-metric>summary { .metrics-list details.no-metric > summary {
list-style: "-> " outside; list-style: "-> " outside;
margin-left: 3ch; margin-left: 3ch;
} }
.metrics-list summary~* { .metrics-list summary ~ * {
border-left: .1ch solid black; border-left: 0.1ch solid black;
margin-left: .9ch; margin-left: 0.9ch;
padding-left: 1ch; padding-left: 1ch;
} }
/* Queue */ /* Queue */
.queue-commits form { .queue-commits form {
display: inline; display: inline;
} }
.queue-commits button:hover { .queue-commits button:hover {
font-weight: bold; font-weight: bold;
} }
.queue-commits td:nth-child(2), .queue-commits td:nth-child(2),
.queue-commits td:nth-child(3) { .queue-commits td:nth-child(3) {
text-align: right; text-align: right;
} }
.queue-commits .odd { .queue-commits .odd {
background-color: #eee; background-color: #eee;
} }
.queue-commits .odd:hover { .queue-commits .odd:hover {
background-color: #ddd; background-color: #ddd;
} }
/* Commit-like entities */ /* Commit-like entities */
.commit-like dl { .commit-like dl {
display: grid; display: grid;
grid: auto-flow / max-content 1fr; grid: auto-flow / max-content 1fr;
column-gap: 1ch; column-gap: 1ch;
} }
.commit-like dl, .commit-like dt, .commit-like dd { .commit-like dl,
margin: 0; .commit-like dt,
.commit-like dd {
margin: 0;
} }
.commit-like pre { .commit-like pre {
margin: 1em 0ch 1em 4ch; margin: 1em 0ch 1em 4ch;
white-space: pre-wrap; white-space: pre-wrap;
} }
.commit-like.commit .title { .commit-like.commit .title {
color: #b70; color: #b70;
font-weight: bold; font-weight: bold;
} }
.commit-like.run .title { .commit-like.run .title {
color: #07e; color: #07e;
font-weight: bold; font-weight: bold;
} }
.commit-like.worker .title { .commit-like.worker .title {
color: #380; color: #380;
font-weight: bold; font-weight: bold;
} }

View file

@ -1,5 +1,6 @@
{ // See also https://aka.ms/tsconfig {
"include": [ "scripts/**/*" ], // See also https://aka.ms/tsconfig
"include": ["scripts/**/*"],
"compilerOptions": { "compilerOptions": {
"target": "ES2022", // Should be fine according to caniuse.com "target": "ES2022", // Should be fine according to caniuse.com
@ -19,6 +20,6 @@
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"strict": true, "strict": true
} }
} }