labnotes 0.0.46__tar.gz → 0.0.48__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {labnotes-0.0.46 → labnotes-0.0.48}/PKG-INFO +1 -1
- {labnotes-0.0.46 → labnotes-0.0.48}/pyproject.toml +1 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/context.md +8 -6
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/create_app.py +1 -7
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/load_experiment.py +7 -5
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/execute_run.py +0 -2
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/run_experiment.py +10 -5
- labnotes-0.0.48/src/labnotes/static/experiment.js +21 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/static/labnotes.css +93 -105
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/open_tables.py +5 -2
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/path_glob.py +1 -1
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/path_pattern.py +3 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/read_experiment.py +9 -3
- labnotes-0.0.48/src/labnotes/templates/experiment.html +106 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tui/run_app.py +38 -26
- labnotes-0.0.48/testing-conventions.toml +17 -0
- labnotes-0.0.48/tests/integration/conftest.py +83 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/uv.lock +100 -0
- labnotes-0.0.46/src/labnotes/pivot_cells.py +0 -21
- labnotes-0.0.46/src/labnotes/runner/new_invocation_id.py +0 -6
- labnotes-0.0.46/src/labnotes/templates/experiment.html +0 -167
- labnotes-0.0.46/testing-conventions.toml +0 -5
- {labnotes-0.0.46 → labnotes-0.0.48}/.gitignore +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/CHANGELOG.md +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/MIGRATIONS.md +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/README.md +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/cell_progress.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/experiment_progress.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/experiments.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/init.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/models.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/read_config.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/read_path.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/describe_event.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/start_experiment.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/wait_for_group.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/server.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/stop_idle_kernels.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/templates/macros.html +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.46 → labnotes-0.0.48}/src/labnotes/tui/runs_table.py +0 -0
|
@@ -62,14 +62,16 @@ Each combination of `[params]` runs `repeats` times (1 if omitted), round-robin,
|
|
|
62
62
|
so running again fills in what failed or never ran. `mode` under
|
|
63
63
|
`[experiments]` in `.labnotes.toml` sets it for every experiment that doesn't
|
|
64
64
|
set its own.
|
|
65
|
-
Each run gets a new run folder
|
|
65
|
+
Each run gets a new run folder. In `fill` mode it is named for the UTC time it was planned
|
|
66
|
+
(`runs/20261020-000429-brave-fox`). In `adhoc` mode each `run` command gets one folder named
|
|
67
|
+
that way, and its runs nest inside it (`runs/20261020-000429-brave-fox/quiet-river`); the run id
|
|
68
|
+
is that path under `runs/`. Either way, folder listings sort by start. `command` runs in a shell from the root, with
|
|
66
69
|
`{run_dir}` replaced by the run folder's absolute path. Pass it to the
|
|
67
70
|
script so it writes its outputs there.
|
|
68
71
|
Each param is appended as `--name value`, with the name as written (use
|
|
69
72
|
`source-language` for a `--source-language` flag), booleans as 0 or 1, and lists
|
|
70
73
|
and tables as JSON. labnotes writes `run.json` (params, created, ended, exit
|
|
71
|
-
code,
|
|
72
|
-
query it as `run ->> 'invocation_id'`), `stdout.log` and `stderr.log` to the
|
|
74
|
+
code), `stdout.log` and `stderr.log` to the
|
|
73
75
|
run folder.
|
|
74
76
|
A run counts when it exited 0 and its `run.json` doesn't hold
|
|
75
77
|
`"excluded": true`, so excluding a run by hand means adding that key; the next
|
|
@@ -101,8 +103,8 @@ the end of the queue. `/` filters rows by params text; Enter keeps the filter,
|
|
|
101
103
|
Escape clears it. `D` deselects every pending run the filter shows. `x` stops the
|
|
102
104
|
running run under the cursor; `x` again kills it. `+` and
|
|
103
105
|
`-` change how many run at once; lowering it lets running runs
|
|
104
|
-
finish. Enter on a run follows its `stdout.log
|
|
105
|
-
|
|
106
|
+
finish. Enter on a run follows its `stdout.log` and `stderr.log` together, as
|
|
107
|
+
they're written, and Escape returns to the table. An `adhoc` experiment that
|
|
106
108
|
plans a single run opens straight on its output. `q` quits. Elsewhere, or with `--no-tui`, it prints a
|
|
107
109
|
line per event instead.
|
|
108
110
|
|
|
@@ -117,4 +119,4 @@ line per event instead.
|
|
|
117
119
|
- A second `run` of the same experiment fails while the first holds the lock.
|
|
118
120
|
- It exits 1 if any run failed.
|
|
119
121
|
|
|
120
|
-
`uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`.
|
|
122
|
+
`uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. An experiment's page shows its `experiment.toml` on one line, then a Runs tab with a table of its runs and a Notebooks tab with one tab per markdown file.
|
|
@@ -2,7 +2,6 @@ import asyncio
|
|
|
2
2
|
from collections.abc import AsyncIterator
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import cast
|
|
6
5
|
|
|
7
6
|
from starlette.applications import Starlette
|
|
8
7
|
from starlette.exceptions import HTTPException
|
|
@@ -20,7 +19,6 @@ from labnotes.list_experiments import list_experiments
|
|
|
20
19
|
from labnotes.load_experiment import load_experiment
|
|
21
20
|
from labnotes.models import LabnotesConfig
|
|
22
21
|
from labnotes.notebook_kernel import NotebookKernel
|
|
23
|
-
from labnotes.pivot_cells import pivot_cells
|
|
24
22
|
from labnotes.python_blocks import python_blocks
|
|
25
23
|
from labnotes.saved_outputs import saved_outputs
|
|
26
24
|
from labnotes.stop_idle_kernels import stop_idle_kernels
|
|
@@ -69,12 +67,8 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
|
|
|
69
67
|
)
|
|
70
68
|
if loaded is None:
|
|
71
69
|
raise HTTPException(404, "No such experiment")
|
|
72
|
-
varies = cast(dict[str, list[object]], loaded["varies"])
|
|
73
|
-
cells = cast(list[dict[str, object]], loaded["cells"])
|
|
74
70
|
return templates.TemplateResponse(
|
|
75
|
-
request,
|
|
76
|
-
"experiment.html",
|
|
77
|
-
{"experiment": loaded, "pivot": pivot_cells(varies, cells)},
|
|
71
|
+
request, "experiment.html", {"experiment": loaded}
|
|
78
72
|
)
|
|
79
73
|
|
|
80
74
|
async def note_content(request: Request) -> str:
|
|
@@ -5,9 +5,9 @@ from typing import cast
|
|
|
5
5
|
|
|
6
6
|
from dirsql import DirSQL
|
|
7
7
|
|
|
8
|
-
from labnotes.experiment_notes import experiment_notes
|
|
9
8
|
from labnotes.check_counts import check_counts
|
|
10
|
-
from labnotes.
|
|
9
|
+
from labnotes.count_runs import count_runs
|
|
10
|
+
from labnotes.experiment_notes import experiment_notes
|
|
11
11
|
from labnotes.experiment_runs import experiment_runs
|
|
12
12
|
from labnotes.list_runs import list_runs
|
|
13
13
|
from labnotes.parse_run_view import parse_run_view
|
|
@@ -31,7 +31,7 @@ async def load_experiment(
|
|
|
31
31
|
outputs_for: Callable[[str, str], Sequence[str]] = _no_outputs,
|
|
32
32
|
) -> dict[str, object] | None:
|
|
33
33
|
found = await tables.query(
|
|
34
|
-
"SELECT name, display_name, config FROM experiments"
|
|
34
|
+
"SELECT name, display_name, config, source FROM experiments"
|
|
35
35
|
f" WHERE name = {quote_string(name)}"
|
|
36
36
|
)
|
|
37
37
|
if not found:
|
|
@@ -42,15 +42,17 @@ async def load_experiment(
|
|
|
42
42
|
varies = cast(dict[str, list[object]], summary["varies"])
|
|
43
43
|
source = experiment_runs(name, list(varies))
|
|
44
44
|
summary = await check_counts(tables, source, summary)
|
|
45
|
-
progress = await experiment_progress(tables, source, summary)
|
|
46
45
|
shown = ["id", *(key for key in varies if key != "id")]
|
|
47
46
|
view = parse_run_view(params, shown)
|
|
47
|
+
[totals] = await count_runs(tables, source, str(summary["completed_run"]))
|
|
48
48
|
notes = await experiment_notes(tables, root, name)
|
|
49
49
|
return {
|
|
50
50
|
"name": row["name"],
|
|
51
51
|
"display_name": row["display_name"],
|
|
52
|
+
"config_source": row["source"],
|
|
53
|
+
"counts_set": "counts" in (config or {}),
|
|
52
54
|
**summary,
|
|
53
|
-
**
|
|
55
|
+
**totals,
|
|
54
56
|
"notes": [
|
|
55
57
|
{
|
|
56
58
|
"name": note["name"],
|
|
@@ -15,11 +15,9 @@ async def execute_run(
|
|
|
15
15
|
params: dict[str, object],
|
|
16
16
|
running: set[asyncio.subprocess.Process],
|
|
17
17
|
stopping: asyncio.Event,
|
|
18
|
-
invocation: str,
|
|
19
18
|
) -> dict[str, object]:
|
|
20
19
|
run: dict[str, object] = {
|
|
21
20
|
"params": params,
|
|
22
|
-
"invocation_id": invocation,
|
|
23
21
|
"created": iso_time(time.time()),
|
|
24
22
|
"ended": None,
|
|
25
23
|
"exit_code": None,
|
|
@@ -12,7 +12,7 @@ from labnotes.models import (
|
|
|
12
12
|
Report,
|
|
13
13
|
)
|
|
14
14
|
from labnotes.runner.execute_run import execute_run
|
|
15
|
-
from labnotes.runner.
|
|
15
|
+
from labnotes.runner.new_run_id import new_run_id
|
|
16
16
|
from labnotes.runner.new_stamped_run_id import new_stamped_run_id
|
|
17
17
|
from labnotes.runner.param_grid import param_grid
|
|
18
18
|
from labnotes.runner.plan_runs import plan_runs
|
|
@@ -44,12 +44,18 @@ async def run_experiment(
|
|
|
44
44
|
run in planned or run_folder(config.root, config.runs, name, run).exists()
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
group = new_stamped_run_id(taken) if experiment.mode == "adhoc" else None
|
|
48
|
+
|
|
49
|
+
def new_id() -> str:
|
|
50
|
+
if group is None:
|
|
51
|
+
return new_stamped_run_id(taken)
|
|
52
|
+
return f"{group}/" + new_run_id(lambda run: taken(f"{group}/{run}"))
|
|
53
|
+
|
|
48
54
|
plan: list[Entry] = []
|
|
49
55
|
for params in plan_runs(
|
|
50
56
|
param_grid(experiment.params), experiment.repeats, finished
|
|
51
57
|
):
|
|
52
|
-
run =
|
|
58
|
+
run = new_id()
|
|
53
59
|
planned.add(run)
|
|
54
60
|
plan.append((run, params))
|
|
55
61
|
runs = [{"run": run, "params": params} for run, params in plan]
|
|
@@ -131,7 +137,7 @@ async def run_experiment(
|
|
|
131
137
|
def retry(run: str) -> None:
|
|
132
138
|
if stopping.is_set() or run not in ended:
|
|
133
139
|
return
|
|
134
|
-
again =
|
|
140
|
+
again = new_id()
|
|
135
141
|
planned.add(again)
|
|
136
142
|
plan.append((again, ended[run]))
|
|
137
143
|
report({"event": "retried", "run": run, "retry": again, "params": ended[run]})
|
|
@@ -182,7 +188,6 @@ async def run_experiment(
|
|
|
182
188
|
params,
|
|
183
189
|
running[run],
|
|
184
190
|
stopping,
|
|
185
|
-
invocation,
|
|
186
191
|
)
|
|
187
192
|
del running[run]
|
|
188
193
|
report({"event": "ended", "run": run, **result})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const summary = document.querySelector(".config-summary");
|
|
2
|
+
const open = document.querySelector(".config-open");
|
|
3
|
+
const dialog = document.querySelector(".config-file");
|
|
4
|
+
|
|
5
|
+
if (summary) {
|
|
6
|
+
const fit = () => {
|
|
7
|
+
open.hidden = summary.scrollWidth <= summary.clientWidth;
|
|
8
|
+
};
|
|
9
|
+
new ResizeObserver(fit).observe(summary);
|
|
10
|
+
open.addEventListener("click", () => dialog.showModal());
|
|
11
|
+
dialog.querySelector(".config-close").addEventListener("click", () => dialog.close());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const row of document.querySelectorAll(".run-row")) {
|
|
15
|
+
row.addEventListener("click", () => {
|
|
16
|
+
if (getSelection().toString()) return;
|
|
17
|
+
const record = row.nextElementSibling;
|
|
18
|
+
record.hidden = !record.hidden;
|
|
19
|
+
row.setAttribute("aria-expanded", String(!record.hidden));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -112,10 +112,6 @@ h3 {
|
|
|
112
112
|
.study { font-family: var(--mono); font-size: 0.82rem; color: var(--ink-3); margin: 0.35rem 0 0; }
|
|
113
113
|
.intro .study { font-size: 0.9rem; margin-top: 0.6rem; }
|
|
114
114
|
|
|
115
|
-
.toc { display: flex; gap: 1.4rem; margin-top: 1.4rem; font-size: 0.88rem; }
|
|
116
|
-
.toc a { color: var(--ink-2); text-decoration: none; }
|
|
117
|
-
.toc a:hover { color: var(--accent); }
|
|
118
|
-
|
|
119
115
|
.empty, .notice { color: var(--ink-2); }
|
|
120
116
|
.notice {
|
|
121
117
|
margin-top: 1.25rem;
|
|
@@ -181,31 +177,6 @@ h3 {
|
|
|
181
177
|
.progress-line .num { text-align: right; }
|
|
182
178
|
.pct { color: var(--ink-2); }
|
|
183
179
|
|
|
184
|
-
/* Headline figures */
|
|
185
|
-
.figures {
|
|
186
|
-
display: grid;
|
|
187
|
-
grid-template-columns: 2fr 1fr 1fr;
|
|
188
|
-
gap: 1px;
|
|
189
|
-
background: var(--rule);
|
|
190
|
-
border: 1px solid var(--rule);
|
|
191
|
-
border-radius: 10px;
|
|
192
|
-
overflow: hidden;
|
|
193
|
-
margin-bottom: 3.5rem;
|
|
194
|
-
}
|
|
195
|
-
.figure { background: var(--surface); padding: 1.3rem 1.5rem 1.4rem; }
|
|
196
|
-
.figure-label { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
|
|
197
|
-
.figure-value { font-size: 2.4rem; font-weight: 500; letter-spacing: -0.03em; line-height: 1.15; margin: 0.35rem 0 0.6rem; font-variant-numeric: tabular-nums; }
|
|
198
|
-
.figure .meter { height: 6px; border-radius: 3px; margin-bottom: 0.7rem; }
|
|
199
|
-
.figure-note { color: var(--ink-2); font-size: 0.86rem; }
|
|
200
|
-
|
|
201
|
-
.block { margin-bottom: 3.5rem; }
|
|
202
|
-
|
|
203
|
-
/* Design */
|
|
204
|
-
.design-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 2.5rem; margin-bottom: 1.75rem; }
|
|
205
|
-
.factors { display: grid; grid-template-columns: max-content 1fr; gap: 0.6rem 1.25rem; margin: 0; align-items: baseline; }
|
|
206
|
-
.factors dt { font-family: var(--mono); font-size: 0.82rem; color: var(--ink-2); }
|
|
207
|
-
.factors dd { margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
|
208
|
-
|
|
209
180
|
.chip {
|
|
210
181
|
font-family: var(--mono);
|
|
211
182
|
font-size: 0.8rem;
|
|
@@ -217,30 +188,100 @@ h3 {
|
|
|
217
188
|
}
|
|
218
189
|
.chip.quiet { background: var(--chip); border-color: var(--rule); }
|
|
219
190
|
|
|
220
|
-
|
|
221
|
-
.
|
|
222
|
-
.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
.
|
|
233
|
-
.
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
|
|
191
|
+
/* experiment.toml line */
|
|
192
|
+
.config-line { display: flex; align-items: baseline; gap: 0.75rem; margin-top: 1.1rem; }
|
|
193
|
+
.config-summary {
|
|
194
|
+
flex: 1;
|
|
195
|
+
min-width: 0;
|
|
196
|
+
margin: 0;
|
|
197
|
+
overflow: hidden;
|
|
198
|
+
white-space: nowrap;
|
|
199
|
+
text-overflow: ellipsis;
|
|
200
|
+
font-size: 0.88rem;
|
|
201
|
+
color: var(--ink-3);
|
|
202
|
+
}
|
|
203
|
+
.setting { margin-left: 1.1rem; }
|
|
204
|
+
.setting b { font-weight: 600; color: var(--ink); }
|
|
205
|
+
.config-open, .config-close {
|
|
206
|
+
font: inherit;
|
|
207
|
+
font-family: var(--mono);
|
|
208
|
+
font-size: 0.75rem;
|
|
209
|
+
padding: 0.15rem 0.6rem;
|
|
210
|
+
border: 1px solid var(--rule-strong);
|
|
211
|
+
border-radius: 5px;
|
|
212
|
+
background: var(--surface);
|
|
213
|
+
color: var(--ink-2);
|
|
214
|
+
cursor: pointer;
|
|
215
|
+
white-space: nowrap;
|
|
216
|
+
}
|
|
217
|
+
.config-open:hover, .config-close:hover { color: var(--accent); border-color: var(--accent); }
|
|
218
|
+
.config-file {
|
|
219
|
+
width: min(48rem, 90vw);
|
|
220
|
+
padding: 0;
|
|
221
|
+
border: 1px solid var(--rule);
|
|
222
|
+
border-radius: 10px;
|
|
223
|
+
background: var(--surface);
|
|
224
|
+
color: var(--ink);
|
|
225
|
+
}
|
|
226
|
+
.config-file::backdrop { background: color-mix(in srgb, var(--ink) 30%, transparent); }
|
|
227
|
+
.config-file header {
|
|
228
|
+
display: flex;
|
|
229
|
+
justify-content: space-between;
|
|
230
|
+
align-items: center;
|
|
231
|
+
padding: 0.6rem 1.25rem;
|
|
232
|
+
border-bottom: 1px solid var(--rule);
|
|
233
|
+
font-family: var(--mono);
|
|
234
|
+
font-size: 0.78rem;
|
|
235
|
+
color: var(--ink-3);
|
|
236
|
+
}
|
|
237
|
+
.config-file pre { margin: 0; padding: 1rem 1.25rem; max-height: 70vh; overflow: auto; font-size: 0.82rem; }
|
|
238
|
+
|
|
239
|
+
/* Tabs */
|
|
240
|
+
.tabs { display: flex; gap: 1.6rem; border-bottom: 1px solid var(--rule); margin-bottom: 1.75rem; font-size: 0.95rem; }
|
|
241
|
+
.tabs a { color: var(--ink-2); text-decoration: none; padding: 0.5rem 0; margin-bottom: -1px; border-bottom: 2px solid transparent; }
|
|
242
|
+
.tabs a:hover { color: var(--ink); }
|
|
243
|
+
.tabs a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--accent); }
|
|
244
|
+
.tabs .count { font-size: 0.8rem; color: var(--ink-3); margin-left: 0.3rem; }
|
|
245
|
+
.tabs.sub { gap: 1.2rem; margin-top: -1rem; font-family: var(--mono); font-size: 0.8rem; }
|
|
246
|
+
|
|
247
|
+
/* Runs table */
|
|
248
|
+
.narrowed { color: var(--ink-2); font-size: 0.88rem; margin: 0 0 0.75rem; }
|
|
249
|
+
.narrowed .clear { margin-left: 0.8rem; color: var(--ink-3); }
|
|
250
|
+
.runs-table {
|
|
251
|
+
width: 100%;
|
|
252
|
+
border-collapse: separate;
|
|
253
|
+
border-spacing: 0;
|
|
237
254
|
background: var(--surface);
|
|
238
255
|
border: 1px solid var(--rule);
|
|
239
|
-
border-radius:
|
|
256
|
+
border-radius: 10px;
|
|
257
|
+
font-size: 0.86rem;
|
|
240
258
|
}
|
|
241
|
-
.
|
|
242
|
-
.
|
|
243
|
-
.
|
|
259
|
+
.runs-table th, .runs-table td { padding: 0.55rem 1rem; text-align: left; border-bottom: 1px solid var(--rule); }
|
|
260
|
+
.runs-table th { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); white-space: nowrap; }
|
|
261
|
+
.runs-table th:first-child, .runs-table .run-row td:first-child { width: 4rem; }
|
|
262
|
+
.runs-table .sort { color: inherit; text-decoration: none; }
|
|
263
|
+
.runs-table .sort:hover, .runs-table .sorted { color: var(--ink); }
|
|
264
|
+
.runs-table .arrow { margin-left: 0.3rem; color: var(--accent); }
|
|
265
|
+
.runs-table .filters td { padding-top: 0.4rem; padding-bottom: 0.4rem; background: var(--paper); }
|
|
266
|
+
.runs-table .filters input, .runs-table .filters select {
|
|
267
|
+
width: 100%;
|
|
268
|
+
min-width: 0;
|
|
269
|
+
font: inherit;
|
|
270
|
+
font-size: 0.82rem;
|
|
271
|
+
padding: 0.2rem 0.45rem;
|
|
272
|
+
color: var(--ink);
|
|
273
|
+
background: var(--surface);
|
|
274
|
+
border: 1px solid var(--rule);
|
|
275
|
+
border-radius: 5px;
|
|
276
|
+
}
|
|
277
|
+
.runs-table .filters select { padding: 0.2rem 0; }
|
|
278
|
+
.runs-table .filters input:focus, .runs-table .filters select:focus { outline: none; border-color: var(--accent); }
|
|
279
|
+
.runs-table tbody tr:last-child td { border-bottom: 0; }
|
|
280
|
+
.run-row { cursor: pointer; }
|
|
281
|
+
.run-row td { max-width: 24rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
282
|
+
.run-row:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
|
|
283
|
+
.run-row[aria-expanded="true"] { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
|
284
|
+
.record-row td { background: var(--paper); }
|
|
244
285
|
|
|
245
286
|
/* Notes */
|
|
246
287
|
.note {
|
|
@@ -288,56 +329,6 @@ h3 {
|
|
|
288
329
|
.prose th { font-weight: 600; border-bottom-color: var(--rule-strong); }
|
|
289
330
|
.prose hr { border: 0; border-top: 1px solid var(--rule); margin: 2em 0; }
|
|
290
331
|
|
|
291
|
-
/* Runs */
|
|
292
|
-
.runs {
|
|
293
|
-
--row: 2.75rem repeat(var(--columns), minmax(0, 1fr));
|
|
294
|
-
background: var(--surface);
|
|
295
|
-
border: 1px solid var(--rule);
|
|
296
|
-
border-radius: 10px;
|
|
297
|
-
overflow: hidden;
|
|
298
|
-
font-size: 0.86rem;
|
|
299
|
-
}
|
|
300
|
-
.run-head, .run summary, .run-filters {
|
|
301
|
-
display: grid;
|
|
302
|
-
grid-template-columns: var(--row);
|
|
303
|
-
gap: 1rem;
|
|
304
|
-
padding: 0.6rem 1.25rem;
|
|
305
|
-
align-items: center;
|
|
306
|
-
}
|
|
307
|
-
.run-head {
|
|
308
|
-
font-size: 0.72rem;
|
|
309
|
-
font-weight: 600;
|
|
310
|
-
letter-spacing: 0.08em;
|
|
311
|
-
text-transform: uppercase;
|
|
312
|
-
color: var(--ink-3);
|
|
313
|
-
border-bottom: 1px solid var(--rule);
|
|
314
|
-
}
|
|
315
|
-
.run-head .sort { color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
316
|
-
.run-head .sort:hover, .run-head .sorted { color: var(--ink); }
|
|
317
|
-
.run-head .arrow { margin-left: 0.3rem; color: var(--accent); }
|
|
318
|
-
.run-filters { padding-top: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--rule); background: var(--paper); }
|
|
319
|
-
.run-filters input, .run-filters select {
|
|
320
|
-
width: 100%;
|
|
321
|
-
min-width: 0;
|
|
322
|
-
font: inherit;
|
|
323
|
-
font-size: 0.82rem;
|
|
324
|
-
padding: 0.2rem 0.45rem;
|
|
325
|
-
color: var(--ink);
|
|
326
|
-
background: var(--surface);
|
|
327
|
-
border: 1px solid var(--rule);
|
|
328
|
-
border-radius: 5px;
|
|
329
|
-
}
|
|
330
|
-
.run-filters select { padding: 0.2rem 0; }
|
|
331
|
-
.run-filters input:focus, .run-filters select:focus { outline: none; border-color: var(--accent); }
|
|
332
|
-
.run-empty { margin: 0; padding: 1rem 1.25rem; }
|
|
333
|
-
h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; color: var(--ink-3); }
|
|
334
|
-
.run { border-bottom: 1px solid var(--rule); }
|
|
335
|
-
.run:last-child { border-bottom: 0; }
|
|
336
|
-
.run summary { cursor: pointer; list-style: none; }
|
|
337
|
-
.run summary::-webkit-details-marker { display: none; }
|
|
338
|
-
.run summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
339
|
-
.run summary:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
|
|
340
|
-
.run[open] summary { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
|
341
332
|
.status { font-size: 0.8rem; font-weight: 700; }
|
|
342
333
|
.status.good { color: var(--good); }
|
|
343
334
|
.status.bad { color: var(--bad); }
|
|
@@ -348,9 +339,7 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
|
|
|
348
339
|
grid-template-columns: max-content minmax(0, 1fr);
|
|
349
340
|
gap: 0.45rem 1.5rem;
|
|
350
341
|
margin: 0;
|
|
351
|
-
padding:
|
|
352
|
-
border-top: 1px dashed var(--rule);
|
|
353
|
-
background: var(--paper);
|
|
342
|
+
padding: 0.5rem 0 0.75rem 2.25rem;
|
|
354
343
|
}
|
|
355
344
|
.record dt { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-3); }
|
|
356
345
|
.record dd { margin: 0; min-width: 0; }
|
|
@@ -403,7 +392,6 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
|
|
|
403
392
|
@media (max-width: 52rem) {
|
|
404
393
|
main, .masthead { padding-left: 1.1rem; padding-right: 1.1rem; }
|
|
405
394
|
h1 { font-size: 2rem; }
|
|
406
|
-
.figures { grid-template-columns: 1fr; }
|
|
407
395
|
.ledger .design { display: none; }
|
|
408
396
|
.progress-col { width: auto; }
|
|
409
397
|
}
|
|
@@ -429,7 +417,7 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
|
|
|
429
417
|
}
|
|
430
418
|
.cell .run:hover, .note-name .restart:hover { color: var(--accent); border-color: var(--accent); }
|
|
431
419
|
.cell .run:disabled, .note-name .restart:disabled { cursor: progress; opacity: 0.6; }
|
|
432
|
-
.note-name:has(.restart) { display: flex; align-items: center; justify-content:
|
|
420
|
+
.note-name:has(.restart) { display: flex; align-items: center; justify-content: flex-end; padding-top: 0.45rem; padding-bottom: 0.45rem; }
|
|
433
421
|
.cell-output { display: block; }
|
|
434
422
|
.cell-output:not(:empty) { margin-top: 0.4rem; padding-left: 0.9rem; border-left: 2px solid var(--accent-soft); overflow-x: auto; }
|
|
435
423
|
.cell-output pre { background: none; border: 0; padding: 0.3rem 0; }
|
|
@@ -10,12 +10,15 @@ from labnotes.tables.read_run import read_run
|
|
|
10
10
|
|
|
11
11
|
# experiments is a view, so files and runs can't declare a foreign key to it.
|
|
12
12
|
EXPERIMENTS = """
|
|
13
|
-
CREATE TABLE experiment_configs (
|
|
13
|
+
CREATE TABLE experiment_configs (
|
|
14
|
+
experiment TEXT PRIMARY KEY, config TEXT, source TEXT
|
|
15
|
+
);
|
|
14
16
|
CREATE VIEW experiments AS
|
|
15
17
|
SELECT
|
|
16
18
|
names.name,
|
|
17
19
|
coalesce(experiment_configs.config ->> 'name', names.name) AS display_name,
|
|
18
|
-
experiment_configs.config
|
|
20
|
+
experiment_configs.config,
|
|
21
|
+
experiment_configs.source
|
|
19
22
|
FROM (
|
|
20
23
|
SELECT experiment AS name FROM experiment_configs
|
|
21
24
|
UNION SELECT experiment FROM files
|
|
@@ -6,6 +6,9 @@ TOKEN = re.compile(r"\{([^}]*)\}|\*\*|\*|\?|[^{*?]+")
|
|
|
6
6
|
def path_pattern(template: str) -> re.Pattern[str]:
|
|
7
7
|
parts = []
|
|
8
8
|
for found in TOKEN.finditer(template):
|
|
9
|
+
if found.group(1) == "run":
|
|
10
|
+
parts.append("(?P<run>[^/]+(?:/[^/]+)?)")
|
|
11
|
+
continue
|
|
9
12
|
if found.group(1) is not None:
|
|
10
13
|
parts.append(f"(?P<{found.group(1)}>[^/]+)")
|
|
11
14
|
continue
|
|
@@ -10,6 +10,12 @@ def read_experiment(root: Path, template: str, path: str) -> list[dict[str, obje
|
|
|
10
10
|
found = path_pattern(f"{template}/experiment.toml").fullmatch(relative)
|
|
11
11
|
if found is None:
|
|
12
12
|
return []
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return [
|
|
13
|
+
source = Path(path).read_text(encoding="utf-8")
|
|
14
|
+
config = tomllib.loads(source)
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
"experiment": found["experiment"],
|
|
18
|
+
"config": json.dumps(config),
|
|
19
|
+
"source": source,
|
|
20
|
+
}
|
|
21
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% set e = experiment %}
|
|
3
|
+
{% set tab = 'notebooks' if request.query_params.get('tab') == 'notebooks' else 'runs' %}
|
|
4
|
+
{% block title %}{{ e.display_name }} · labnotes{% endblock %}
|
|
5
|
+
{% block crumbs %}<span class="crumb"><a href="{{ url_for('index') }}">Experiments</a></span><span class="crumb">{{ e.display_name }}</span>{% endblock %}
|
|
6
|
+
{% block main %}
|
|
7
|
+
<section class="intro">
|
|
8
|
+
<h1>{{ e.display_name }}</h1>
|
|
9
|
+
{% if e.display_name != e.name %}<p class="study">{{ e.name }}</p>{% endif %}
|
|
10
|
+
{% if e.config_source is not none %}
|
|
11
|
+
<div class="config-line">
|
|
12
|
+
<p class="config-summary">
|
|
13
|
+
<span class="chip quiet">{{ e.mode }}</span>
|
|
14
|
+
{% for key, value in e.other.items() %}<span class="setting">{{ key }} <code>{{ value | value }}</code></span>{% endfor %}
|
|
15
|
+
{% if e.runs_per_cell %}<span class="setting">repeats <b class="num">{{ e.runs_per_cell }}</b></span>{% endif %}
|
|
16
|
+
{% for key, values in e.varies.items() %}<span class="setting">{{ key }}{% for value in values %} <span class="chip">{{ value | value }}</span>{% endfor %}</span>{% endfor %}
|
|
17
|
+
{% if e.counts_error %}<span class="setting counts-error">{{ e.counts_error }}</span>{% elif e.counts_set %}<span class="setting">counts <code>{{ e.completed_run }}</code></span>{% endif %}
|
|
18
|
+
</p>
|
|
19
|
+
<button type="button" class="config-open">experiment.toml</button>
|
|
20
|
+
</div>
|
|
21
|
+
<dialog class="config-file">
|
|
22
|
+
<header>experiment.toml<button type="button" class="config-close">Close</button></header>
|
|
23
|
+
<pre>{{ e.config_source }}</pre>
|
|
24
|
+
</dialog>
|
|
25
|
+
{% endif %}
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<nav class="tabs">
|
|
29
|
+
<a href="?"{% if tab == 'runs' %} aria-current="page"{% endif %}>Runs <span class="count num">{{ e.runs }}</span></a>
|
|
30
|
+
<a href="?tab=notebooks"{% if tab == 'notebooks' %} aria-current="page"{% endif %}>Notebooks <span class="count num">{{ e.notes | length }}</span></a>
|
|
31
|
+
</nav>
|
|
32
|
+
|
|
33
|
+
{% if tab == 'notebooks' %}
|
|
34
|
+
{% set chosen = request.query_params.get('note') %}
|
|
35
|
+
{% set note = (e.notes | selectattr('name', 'equalto', chosen) | first) or (e.notes | first) %}
|
|
36
|
+
{% if note %}
|
|
37
|
+
<nav class="tabs sub">
|
|
38
|
+
{% for each in e.notes %}<a href="?tab=notebooks&note={{ each.name | urlencode }}"{% if each.name == note.name %} aria-current="page"{% endif %}>{{ each.name }}</a>{% endfor %}
|
|
39
|
+
</nav>
|
|
40
|
+
<article class="note" data-run-url="{{ url_for('run_block', name=e.name, note=note.name) }}" data-restart-url="{{ url_for('restart_kernel', name=e.name, note=note.name) }}">
|
|
41
|
+
{% if note.runnable %}<header class="note-name"><button type="button" class="restart" title="Stop this note's Python kernel. The next Run starts a fresh one.">Restart</button></header>{% endif %}
|
|
42
|
+
<div class="prose">{{ note.html | safe }}</div>
|
|
43
|
+
</article>
|
|
44
|
+
<script src="{{ asset('run-cell.js') }}" defer></script>
|
|
45
|
+
{% else %}
|
|
46
|
+
<p class="empty">No notebooks yet.</p>
|
|
47
|
+
{% endif %}
|
|
48
|
+
{% else %}
|
|
49
|
+
<section id="runs">
|
|
50
|
+
{% set view = e.run_view %}
|
|
51
|
+
{% set narrowed = view.status or view.filters %}
|
|
52
|
+
{% if narrowed %}<p class="narrowed"><span class="num">{{ e.runs_list | length }}</span> of <span class="num">{{ e.runs }}</span> <a class="clear" href="{{ '?sort=' ~ (view.sort | urlencode) ~ ('&order=desc' if view.descending else '') if view.sort else '?' }}#runs">Clear filters</a></p>{% endif %}
|
|
53
|
+
{% if e.runs %}
|
|
54
|
+
<form id="run-filters" method="get" action="#runs">
|
|
55
|
+
{% if view.sort %}<input type="hidden" name="sort" value="{{ view.sort }}">{% endif %}
|
|
56
|
+
{% if view.descending %}<input type="hidden" name="order" value="desc">{% endif %}
|
|
57
|
+
<button type="submit" hidden>Filter</button>
|
|
58
|
+
</form>
|
|
59
|
+
<div class="scroll">
|
|
60
|
+
<table class="runs-table">
|
|
61
|
+
<thead>
|
|
62
|
+
<tr>
|
|
63
|
+
{% for column in ['status'] + e.run_columns %}
|
|
64
|
+
{% set sorted = view.sort == column %}
|
|
65
|
+
<th scope="col"{% if sorted %} aria-sort="{{ 'descending' if view.descending else 'ascending' }}"{% endif %}><a class="sort{{ ' sorted' if sorted }}" href="{{ e.sort_hrefs[column] }}" title="Sort by {{ column }}">{{ '' if column == 'status' else column }}{% if sorted %}<span class="arrow">{{ '↓' if view.descending else '↑' }}</span>{% endif %}</a></th>
|
|
66
|
+
{% endfor %}
|
|
67
|
+
</tr>
|
|
68
|
+
<tr class="filters">
|
|
69
|
+
<td><select form="run-filters" name="status" aria-label="Filter by status" onchange="this.form.requestSubmit()">
|
|
70
|
+
<option value=""{{ ' selected' if not view.status }}>all</option>
|
|
71
|
+
<option value="complete"{{ ' selected' if view.status == 'complete' }}>✓</option>
|
|
72
|
+
<option value="incomplete"{{ ' selected' if view.status == 'incomplete' }}>✕</option>
|
|
73
|
+
</select></td>
|
|
74
|
+
{% for column in e.run_columns %}<td><input form="run-filters" type="search" name="f.{{ column }}" value="{{ view.filters.get(column, '') }}" placeholder="filter" aria-label="Filter {{ column }}"></td>{% endfor %}
|
|
75
|
+
</tr>
|
|
76
|
+
</thead>
|
|
77
|
+
<tbody>
|
|
78
|
+
{% for run in e.runs_list %}
|
|
79
|
+
<tr class="run-row" aria-expanded="false">
|
|
80
|
+
<td><span class="status {{ 'good' if run.labnotes_completed else 'bad' }}" title="{{ 'complete' if run.labnotes_completed else 'not complete' }}">{{ '✓' if run.labnotes_completed else '✕' }}</span></td>
|
|
81
|
+
{% for column in e.run_columns %}<td class="{{ 'mono' if loop.first }}">{{ run[column] | value }}</td>{% endfor %}
|
|
82
|
+
</tr>
|
|
83
|
+
<tr class="record-row" hidden>
|
|
84
|
+
<td colspan="{{ e.run_columns | length + 1 }}">
|
|
85
|
+
<dl class="record">
|
|
86
|
+
{% for key, value in run.items() if key != 'labnotes_completed' %}
|
|
87
|
+
<dt>{{ key }}</dt>
|
|
88
|
+
{% set shown = value | value %}
|
|
89
|
+
<dd>{% if shown | length > 80 %}<pre>{{ shown }}</pre>{% else %}<code>{{ shown }}</code>{% endif %}</dd>
|
|
90
|
+
{% endfor %}
|
|
91
|
+
</dl>
|
|
92
|
+
</td>
|
|
93
|
+
</tr>
|
|
94
|
+
{% else %}
|
|
95
|
+
<tr><td class="empty" colspan="{{ e.run_columns | length + 1 }}">No runs match these filters.</td></tr>
|
|
96
|
+
{% endfor %}
|
|
97
|
+
</tbody>
|
|
98
|
+
</table>
|
|
99
|
+
</div>
|
|
100
|
+
{% else %}
|
|
101
|
+
<p class="empty">No runs recorded yet.</p>
|
|
102
|
+
{% endif %}
|
|
103
|
+
</section>
|
|
104
|
+
{% endif %}
|
|
105
|
+
<script src="{{ asset('experiment.js') }}" defer></script>
|
|
106
|
+
{% endblock %}
|