about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/ci/citool/Cargo.lock67
-rw-r--r--src/ci/citool/Cargo.toml1
-rw-r--r--src/ci/citool/src/analysis.rs13
-rw-r--r--src/ci/citool/src/main.rs34
-rw-r--r--src/ci/citool/src/metrics.rs23
-rw-r--r--src/ci/citool/src/test_dashboard.rs216
-rw-r--r--src/ci/citool/src/utils.rs6
-rw-r--r--src/ci/citool/templates/layout.askama22
-rw-r--r--src/ci/citool/templates/test_group.askama42
-rw-r--r--src/ci/citool/templates/test_suites.askama108
10 files changed, 511 insertions, 21 deletions
diff --git a/src/ci/citool/Cargo.lock b/src/ci/citool/Cargo.lock
index 2fe219f368b..43321d12caf 100644
--- a/src/ci/citool/Cargo.lock
+++ b/src/ci/citool/Cargo.lock
@@ -65,12 +65,63 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
 
 [[package]]
+name = "askama"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
+dependencies = [
+ "askama_derive",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
+dependencies = [
+ "memchr",
+ "serde",
+ "serde_derive",
+ "winnow",
+]
+
+[[package]]
 name = "base64"
 version = "0.22.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
 
 [[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
 name = "build_helper"
 version = "0.1.0"
 dependencies = [
@@ -104,6 +155,7 @@ name = "citool"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "askama",
  "build_helper",
  "clap",
  "csv",
@@ -647,6 +699,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+[[package]]
 name = "rustls"
 version = "0.23.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1027,6 +1085,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
 [[package]]
+name = "winnow"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "write16"
 version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/src/ci/citool/Cargo.toml b/src/ci/citool/Cargo.toml
index f18436a1263..0e2aba3b9e3 100644
--- a/src/ci/citool/Cargo.toml
+++ b/src/ci/citool/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2021"
 
 [dependencies]
 anyhow = "1"
+askama = "0.13"
 clap = { version = "4.5", features = ["derive"] }
 csv = "1"
 diff = "0.1"
diff --git a/src/ci/citool/src/analysis.rs b/src/ci/citool/src/analysis.rs
index 9fc7c309bfb..62974be2dbe 100644
--- a/src/ci/citool/src/analysis.rs
+++ b/src/ci/citool/src/analysis.rs
@@ -8,9 +8,9 @@ use build_helper::metrics::{
 };
 
 use crate::github::JobInfoResolver;
-use crate::metrics;
 use crate::metrics::{JobMetrics, JobName, get_test_suites};
 use crate::utils::{output_details, pluralize};
+use crate::{metrics, utils};
 
 /// Outputs durations of individual bootstrap steps from the gathered bootstrap invocations,
 /// and also a table with summarized information about executed tests.
@@ -394,18 +394,17 @@ fn aggregate_tests(metrics: &JsonRoot) -> TestSuiteData {
             // Poor man's detection of doctests based on the "(line XYZ)" suffix
             let is_doctest = matches!(suite.metadata, TestSuiteMetadata::CargoPackage { .. })
                 && test.name.contains("(line");
-            let test_entry = Test { name: generate_test_name(&test.name), stage, is_doctest };
+            let test_entry = Test {
+                name: utils::normalize_path_delimiters(&test.name).to_string(),
+                stage,
+                is_doctest,
+            };
             tests.insert(test_entry, test.outcome.clone());
         }
     }
     TestSuiteData { tests }
 }
 
-/// Normalizes Windows-style path delimiters to Unix-style paths.
-fn generate_test_name(name: &str) -> String {
-    name.replace('\\', "/")
-}
-
 /// Prints test changes in Markdown format to stdout.
 fn report_test_diffs(
     diff: AggregatedTestDiffs,
diff --git a/src/ci/citool/src/main.rs b/src/ci/citool/src/main.rs
index a1956da352f..f4e671b609f 100644
--- a/src/ci/citool/src/main.rs
+++ b/src/ci/citool/src/main.rs
@@ -4,6 +4,7 @@ mod datadog;
 mod github;
 mod jobs;
 mod metrics;
+mod test_dashboard;
 mod utils;
 
 use std::collections::{BTreeMap, HashMap};
@@ -22,7 +23,8 @@ use crate::datadog::upload_datadog_metric;
 use crate::github::JobInfoResolver;
 use crate::jobs::RunType;
 use crate::metrics::{JobMetrics, download_auto_job_metrics, download_job_metrics, load_metrics};
-use crate::utils::load_env_var;
+use crate::test_dashboard::generate_test_dashboard;
+use crate::utils::{load_env_var, output_details};
 
 const CI_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
 const DOCKER_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../docker");
@@ -180,12 +182,26 @@ fn postprocess_metrics(
 }
 
 fn post_merge_report(db: JobDatabase, current: String, parent: String) -> anyhow::Result<()> {
-    let metrics = download_auto_job_metrics(&db, &parent, &current)?;
+    let metrics = download_auto_job_metrics(&db, Some(&parent), &current)?;
 
     println!("\nComparing {parent} (parent) -> {current} (this PR)\n");
 
     let mut job_info_resolver = JobInfoResolver::new();
     output_test_diffs(&metrics, &mut job_info_resolver);
+
+    output_details("Test dashboard", || {
+        println!(
+            r#"\nRun
+
+```bash
+cargo run --manifest-path src/ci/citool/Cargo.toml -- \
+    test-dashboard {current} --output-dir test-dashboard
+```
+And then open `test-dashboard/index.html` in your browser to see an overview of all executed tests.
+"#
+        );
+    });
+
     output_largest_duration_changes(&metrics, &mut job_info_resolver);
 
     Ok(())
@@ -234,6 +250,14 @@ enum Args {
         /// Current commit that will be compared to `parent`.
         current: String,
     },
+    /// Generate a directory containing a HTML dashboard of test results from a CI run.
+    TestDashboard {
+        /// Commit SHA that was tested on CI to analyze.
+        current: String,
+        /// Output path for the HTML directory.
+        #[clap(long)]
+        output_dir: PathBuf,
+    },
 }
 
 #[derive(clap::ValueEnum, Clone)]
@@ -275,7 +299,11 @@ fn main() -> anyhow::Result<()> {
             postprocess_metrics(metrics_path, parent, job_name)?;
         }
         Args::PostMergeReport { current, parent } => {
-            post_merge_report(load_db(default_jobs_file)?, current, parent)?;
+            post_merge_report(load_db(&default_jobs_file)?, current, parent)?;
+        }
+        Args::TestDashboard { current, output_dir } => {
+            let db = load_db(&default_jobs_file)?;
+            generate_test_dashboard(db, &current, &output_dir)?;
         }
     }
 
diff --git a/src/ci/citool/src/metrics.rs b/src/ci/citool/src/metrics.rs
index a816fb3c4f1..3d8b1ad84cf 100644
--- a/src/ci/citool/src/metrics.rs
+++ b/src/ci/citool/src/metrics.rs
@@ -46,24 +46,25 @@ pub struct JobMetrics {
 /// `parent` and `current` should be commit SHAs.
 pub fn download_auto_job_metrics(
     job_db: &JobDatabase,
-    parent: &str,
+    parent: Option<&str>,
     current: &str,
 ) -> anyhow::Result<HashMap<JobName, JobMetrics>> {
     let mut jobs = HashMap::default();
 
     for job in &job_db.auto_jobs {
         eprintln!("Downloading metrics of job {}", job.name);
-        let metrics_parent = match download_job_metrics(&job.name, parent) {
-            Ok(metrics) => Some(metrics),
-            Err(error) => {
-                eprintln!(
-                    r#"Did not find metrics for job `{}` at `{parent}`: {error:?}.
+        let metrics_parent =
+            parent.and_then(|parent| match download_job_metrics(&job.name, parent) {
+                Ok(metrics) => Some(metrics),
+                Err(error) => {
+                    eprintln!(
+                        r#"Did not find metrics for job `{}` at `{parent}`: {error:?}.
 Maybe it was newly added?"#,
-                    job.name
-                );
-                None
-            }
-        };
+                        job.name
+                    );
+                    None
+                }
+            });
         let metrics_current = download_job_metrics(&job.name, current)?;
         jobs.insert(
             job.name.clone(),
diff --git a/src/ci/citool/src/test_dashboard.rs b/src/ci/citool/src/test_dashboard.rs
new file mode 100644
index 00000000000..8fbd0d3f200
--- /dev/null
+++ b/src/ci/citool/src/test_dashboard.rs
@@ -0,0 +1,216 @@
+use std::collections::{BTreeMap, HashMap};
+use std::fs::File;
+use std::io::BufWriter;
+use std::path::{Path, PathBuf};
+
+use askama::Template;
+use build_helper::metrics::{TestOutcome, TestSuiteMetadata};
+
+use crate::jobs::JobDatabase;
+use crate::metrics::{JobMetrics, JobName, download_auto_job_metrics, get_test_suites};
+use crate::utils::normalize_path_delimiters;
+
+/// Generate a set of HTML files into a directory that contain a dashboard of test results.
+pub fn generate_test_dashboard(
+    db: JobDatabase,
+    current: &str,
+    output_dir: &Path,
+) -> anyhow::Result<()> {
+    let metrics = download_auto_job_metrics(&db, None, current)?;
+    let suites = gather_test_suites(&metrics);
+
+    std::fs::create_dir_all(output_dir)?;
+
+    let test_count = suites.test_count();
+    write_page(output_dir, "index.html", &TestSuitesPage { suites, test_count })?;
+
+    Ok(())
+}
+
+fn write_page<T: Template>(dir: &Path, name: &str, template: &T) -> anyhow::Result<()> {
+    let mut file = BufWriter::new(File::create(dir.join(name))?);
+    Template::write_into(template, &mut file)?;
+    Ok(())
+}
+
+fn gather_test_suites(job_metrics: &HashMap<JobName, JobMetrics>) -> TestSuites {
+    struct CoarseTestSuite<'a> {
+        tests: BTreeMap<String, Test<'a>>,
+    }
+
+    let mut suites: HashMap<String, CoarseTestSuite> = HashMap::new();
+
+    // First, gather tests from all jobs, stages and targets, and aggregate them per suite
+    // Only work with compiletest suites.
+    for (job, metrics) in job_metrics {
+        let test_suites = get_test_suites(&metrics.current);
+        for suite in test_suites {
+            let (suite_name, stage, target) = match &suite.metadata {
+                TestSuiteMetadata::CargoPackage { .. } => {
+                    continue;
+                }
+                TestSuiteMetadata::Compiletest { suite, stage, target, .. } => {
+                    (suite.clone(), *stage, target)
+                }
+            };
+            let suite_entry = suites
+                .entry(suite_name.clone())
+                .or_insert_with(|| CoarseTestSuite { tests: Default::default() });
+            let test_metadata = TestMetadata { job, stage, target };
+
+            for test in &suite.tests {
+                let test_name = normalize_test_name(&test.name, &suite_name);
+                let (test_name, variant_name) = match test_name.rsplit_once('#') {
+                    Some((name, variant)) => (name.to_string(), variant.to_string()),
+                    None => (test_name, "".to_string()),
+                };
+                let test_entry = suite_entry
+                    .tests
+                    .entry(test_name.clone())
+                    .or_insert_with(|| Test { revisions: Default::default() });
+                let variant_entry = test_entry
+                    .revisions
+                    .entry(variant_name)
+                    .or_insert_with(|| TestResults { passed: vec![], ignored: vec![] });
+
+                match test.outcome {
+                    TestOutcome::Passed => {
+                        variant_entry.passed.push(test_metadata);
+                    }
+                    TestOutcome::Ignored { ignore_reason: _ } => {
+                        variant_entry.ignored.push(test_metadata);
+                    }
+                    TestOutcome::Failed => {
+                        eprintln!("Warning: failed test {test_name}");
+                    }
+                }
+            }
+        }
+    }
+
+    // Then, split the suites per directory
+    let mut suites = suites.into_iter().collect::<Vec<_>>();
+    suites.sort_by(|a, b| a.0.cmp(&b.0));
+
+    let suites = suites
+        .into_iter()
+        .map(|(suite_name, suite)| TestSuite { group: build_test_group(&suite_name, suite.tests) })
+        .collect();
+
+    TestSuites { suites }
+}
+
+/// Recursively expand a test group based on filesystem hierarchy.
+fn build_test_group<'a>(name: &str, tests: BTreeMap<String, Test<'a>>) -> TestGroup<'a> {
+    let mut root_tests = vec![];
+    let mut subdirs: BTreeMap<String, BTreeMap<String, Test<'a>>> = Default::default();
+
+    // Split tests into root tests and tests located in subdirectories
+    for (name, test) in tests {
+        let mut components = Path::new(&name).components().peekable();
+        let subdir = components.next().unwrap();
+
+        if components.peek().is_none() {
+            // This is a root test
+            root_tests.push((name, test));
+        } else {
+            // This is a test in a nested directory
+            let subdir_tests =
+                subdirs.entry(subdir.as_os_str().to_str().unwrap().to_string()).or_default();
+            let test_name =
+                components.into_iter().collect::<PathBuf>().to_str().unwrap().to_string();
+            subdir_tests.insert(test_name, test);
+        }
+    }
+    let dirs = subdirs
+        .into_iter()
+        .map(|(name, tests)| {
+            let group = build_test_group(&name, tests);
+            (name, group)
+        })
+        .collect();
+
+    TestGroup { name: name.to_string(), root_tests, groups: dirs }
+}
+
+/// Compiletest tests start with `[suite] tests/[suite]/a/b/c...`.
+/// Remove the `[suite] tests/[suite]/` prefix so that we can find the filesystem path.
+/// Also normalizes path delimiters.
+fn normalize_test_name(name: &str, suite_name: &str) -> String {
+    let name = normalize_path_delimiters(name);
+    let name = name.as_ref();
+    let name = name.strip_prefix(&format!("[{suite_name}]")).unwrap_or(name).trim();
+    let name = name.strip_prefix("tests/").unwrap_or(name);
+    let name = name.strip_prefix(suite_name).unwrap_or(name);
+    name.trim_start_matches("/").to_string()
+}
+
+struct TestSuites<'a> {
+    suites: Vec<TestSuite<'a>>,
+}
+
+impl<'a> TestSuites<'a> {
+    fn test_count(&self) -> u64 {
+        self.suites.iter().map(|suite| suite.group.test_count()).sum::<u64>()
+    }
+}
+
+struct TestSuite<'a> {
+    group: TestGroup<'a>,
+}
+
+struct TestResults<'a> {
+    passed: Vec<TestMetadata<'a>>,
+    ignored: Vec<TestMetadata<'a>>,
+}
+
+struct Test<'a> {
+    revisions: BTreeMap<String, TestResults<'a>>,
+}
+
+impl<'a> Test<'a> {
+    /// If this is a test without revisions, it will have a single entry in `revisions` with
+    /// an empty string as the revision name.
+    fn single_test(&self) -> Option<&TestResults<'a>> {
+        if self.revisions.len() == 1 {
+            self.revisions.iter().next().take_if(|e| e.0.is_empty()).map(|e| e.1)
+        } else {
+            None
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+#[allow(dead_code)]
+struct TestMetadata<'a> {
+    job: &'a str,
+    stage: u32,
+    target: &'a str,
+}
+
+// We have to use a template for the TestGroup instead of a macro, because
+// macros cannot be recursive in askama at the moment.
+#[derive(Template)]
+#[template(path = "test_group.askama")]
+/// Represents a group of tests
+struct TestGroup<'a> {
+    name: String,
+    /// Tests located directly in this directory
+    root_tests: Vec<(String, Test<'a>)>,
+    /// Nested directories with additional tests
+    groups: Vec<(String, TestGroup<'a>)>,
+}
+
+impl<'a> TestGroup<'a> {
+    fn test_count(&self) -> u64 {
+        let root = self.root_tests.len() as u64;
+        self.groups.iter().map(|(_, group)| group.test_count()).sum::<u64>() + root
+    }
+}
+
+#[derive(Template)]
+#[template(path = "test_suites.askama")]
+struct TestSuitesPage<'a> {
+    suites: TestSuites<'a>,
+    test_count: u64,
+}
diff --git a/src/ci/citool/src/utils.rs b/src/ci/citool/src/utils.rs
index a4c6ff85ef7..0367d349a1e 100644
--- a/src/ci/citool/src/utils.rs
+++ b/src/ci/citool/src/utils.rs
@@ -1,3 +1,4 @@
+use std::borrow::Cow;
 use std::path::Path;
 
 use anyhow::Context;
@@ -28,3 +29,8 @@ where
     func();
     println!("</details>\n");
 }
+
+/// Normalizes Windows-style path delimiters to Unix-style paths.
+pub fn normalize_path_delimiters(name: &str) -> Cow<str> {
+    if name.contains("\\") { name.replace('\\', "/").into() } else { name.into() }
+}
diff --git a/src/ci/citool/templates/layout.askama b/src/ci/citool/templates/layout.askama
new file mode 100644
index 00000000000..3b3b6f23741
--- /dev/null
+++ b/src/ci/citool/templates/layout.askama
@@ -0,0 +1,22 @@
+<html>
+<head>
+  <meta charset="UTF-8">
+  <title>Rust CI Test Dashboard</title>
+  <style>
+    body {
+        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+        line-height: 1.6;
+        max-width: 1500px;
+        margin: 0 auto;
+        padding: 20px;
+        background: #F5F5F5;
+    }
+    {% block styles %}{% endblock %}
+  </style>
+</head>
+
+<body>
+{% block content %}{% endblock %}
+{% block scripts %}{% endblock %}
+</body>
+</html>
diff --git a/src/ci/citool/templates/test_group.askama b/src/ci/citool/templates/test_group.askama
new file mode 100644
index 00000000000..95731103f3b
--- /dev/null
+++ b/src/ci/citool/templates/test_group.askama
@@ -0,0 +1,42 @@
+{% macro test_result(r) -%}
+passed: {{ r.passed.len() }}, ignored: {{ r.ignored.len() }}
+{%- endmacro %}
+
+<li>
+<details>
+<summary>{{ name }} ({{ test_count() }} test{{ test_count() | pluralize }}{% if !root_tests.is_empty() && root_tests.len() as u64 != test_count() -%}
+    , {{ root_tests.len() }} root test{{ root_tests.len() | pluralize }}
+{%- endif %}{% if !groups.is_empty() -%}
+    , {{ groups.len() }} subdir{{ groups.len() | pluralize }}
+{%- endif %})
+</summary>
+
+{% if !groups.is_empty() %}
+<ul>
+    {% for (dir_name, subgroup) in groups %}
+    {{ subgroup|safe }}
+    {% endfor %}
+</ul>
+{% endif %}
+
+{% if !root_tests.is_empty() %}
+<ul>
+    {% for (name, test) in root_tests %}
+        <li>
+        {% if let Some(result) = test.single_test() %}
+            <b>{{ name }}</b> ({% call test_result(result) %})
+        {% else %}
+            <b>{{ name }}</b> ({{ test.revisions.len() }} revision{{ test.revisions.len() | pluralize }})
+            <ul>
+            {% for (revision, result) in test.revisions %}
+                <li>#<i>{{ revision }}</i> ({% call test_result(result) %})</li>
+            {% endfor %}
+            </ul>
+        {% endif %}
+        </li>
+    {% endfor %}
+</ul>
+{% endif %}
+
+</details>
+</li>
diff --git a/src/ci/citool/templates/test_suites.askama b/src/ci/citool/templates/test_suites.askama
new file mode 100644
index 00000000000..4997f6a3f1c
--- /dev/null
+++ b/src/ci/citool/templates/test_suites.askama
@@ -0,0 +1,108 @@
+{% extends "layout.askama" %}
+
+{% block content %}
+<h1>Rust CI test dashboard</h1>
+<div>
+Here's how to interpret the "passed" and "ignored" counts:
+the count includes all combinations of "stage" x "target" x "CI job where the test was executed or ignored".
+</div>
+<div class="test-suites">
+    <div class="summary">
+        <div>
+            <div class="test-count">Total tests: {{ test_count }}</div>
+            <div>
+                To find tests that haven't been executed anywhere, click on "Open all" and search for "passed: 0".
+            </div>
+        </div>
+        <div>
+            <button onclick="openAll()">Open all</button>
+            <button onclick="closeAll()">Close all</button>
+        </div>
+    </div>
+
+    <ul>
+    {% for suite in suites.suites %}
+        {{ suite.group|safe }}
+    {% endfor %}
+    </ul>
+</div>
+{% endblock %}
+
+{% block styles %}
+h1 {
+    text-align: center;
+    color: #333333;
+    margin-bottom: 30px;
+}
+
+.summary {
+    display: flex;
+    justify-content: space-between;
+}
+
+.test-count {
+    font-size: 1.2em;
+}
+
+.test-suites {
+    background: white;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+    padding: 20px;
+}
+
+ul {
+    padding-left: 0;
+}
+
+li {
+    list-style: none;
+    padding-left: 20px;
+}
+summary {
+    margin-bottom: 5px;
+    padding: 6px;
+    background-color: #F4F4F4;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+    cursor: pointer;
+}
+summary:hover {
+    background-color: #CFCFCF;
+}
+
+/* Style the disclosure triangles */
+details > summary {
+    list-style: none;
+    position: relative;
+}
+
+details > summary::before {
+    content: "▶";
+    position: absolute;
+    left: -15px;
+    transform: rotate(0);
+    transition: transform 0.2s;
+}
+
+details[open] > summary::before {
+    transform: rotate(90deg);
+}
+{% endblock %}
+
+{% block scripts %}
+<script type="text/javascript">
+function openAll() {
+    const details = document.getElementsByTagName("details");
+    for (const elem of details) {
+        elem.open = true;
+    }
+}
+function closeAll() {
+    const details = document.getElementsByTagName("details");
+    for (const elem of details) {
+        elem.open = false;
+    }
+}
+</script>
+{% endblock %}