Reformat everything
This commit is contained in:
parent
93663fff8c
commit
36ce75b43d
12 changed files with 560 additions and 526 deletions
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
DESIGN.md
142
DESIGN.md
|
|
@ -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?)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
217
static/base.css
217
static/base.css
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue