Improve task control panel layout
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 47s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m40s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 1m18s

This commit is contained in:
ajp_anton
2026-08-18 01:40:27 +00:00
parent b9c886a5ea
commit c1146cfcf5
7 changed files with 177 additions and 38 deletions
+4 -3
View File
@@ -37,6 +37,7 @@ for its web forms and its task order. It is parsed as data only and uses version
```json ```json
{ {
"version": 1, "version": 1,
"label": "Example Tasks",
"tasks": { "tasks": {
"task.py": { "task.py": {
"inputs": [ "inputs": [
@@ -61,7 +62,7 @@ history. The form masks sensitive text, the database records `[redacted]`, and
the original value is available only through a private execution input file. the original value is available only through a private execution input file.
Tasks listed in the manifest appear in JSON order. A listed task may use `{}` Tasks listed in the manifest appear in JSON order. A listed task may use `{}`
when it only needs ordering; unlisted scripts appear afterward in alphabetical when it only needs ordering; unlisted scripts appear afterward in alphabetical
order. order. `label` is optional and overrides the group name displayed in the UI.
Set `wait_for_result` to `true` for a short task whose form should wait for its Set `wait_for_result` to `true` for a short task whose form should wait for its
final success or failure status. This disables the form and warns before final success or failure status. This disables the form and warns before
leaving the page while the task is running; tasks without it keep the normal leaving the page while the task is running; tasks without it keep the normal
@@ -69,8 +70,8 @@ queued-run behavior.
For a task with a review-first operation, declare `execution` with the input For a task with a review-first operation, declare `execution` with the input
field and its review and execution values. The control panel generates the field and its review and execution values. The control panel generates the
dropdown. A successful review run offers a button that repeats the same saved mode selector attached to the submit button. A successful review run offers a
inputs using the execution value; it does not reuse a stale filesystem button that repeats the same saved inputs using the execution value; it does not reuse a stale filesystem
snapshot, so the task must still validate its inputs before changing anything. snapshot, so the task must still validate its inputs before changing anything.
The server validates every submitted value before a run is queued. It records The server validates every submitted value before a run is queued. It records
+50 -17
View File
@@ -12,10 +12,19 @@ from typing import Any
FIELD_NAME = re.compile(r"[a-z][a-z0-9_]*\Z") FIELD_NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
FIELD_TYPES = frozenset({"text", "integer", "date", "datetime", "choice", "multi_choice", "file"}) FIELD_TYPES = frozenset({"text", "integer", "date", "datetime", "choice", "multi_choice", "file"})
DISPLAY_ACRONYMS = {
"api": "API", "csv": "CSV", "iata": "IATA", "icao": "ICAO", "id": "ID",
"ids": "IDs", "url": "URL", "urls": "URLs", "uuid": "UUID",
}
def display_name(value: str) -> str: def display_name(value: str) -> str:
return value.replace("_", " ") titled = value.replace("_", " ").title()
return re.sub(
r"\b[A-Za-z]+\b",
lambda match: DISPLAY_ACRONYMS.get(match.group().casefold(), match.group()),
titled,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -104,12 +113,19 @@ class TaskCatalog:
or not group_path.is_dir() or not group_path.is_dir()
): ):
continue continue
tasks = tuple(self._tasks_in_group(group_path)) manifest = load_input_manifest(
group_path,
{
path.name for path in group_path.iterdir()
if path.is_file() and not path.is_symlink() and path.suffix == ".py" and not path.name.startswith((".", "_"))
},
)
tasks = tuple(self._tasks_in_group(group_path, manifest.declarations))
groups.append( groups.append(
Group( Group(
id=group_path.name, id=group_path.name,
path=group_path, path=group_path,
display_name=display_name(group_path.name), display_name=manifest.label or display_name(group_path.name),
tasks=tasks, tasks=tasks,
) )
) )
@@ -140,7 +156,11 @@ class TaskCatalog:
tasks.extend(load_background_tasks(group_path)) tasks.extend(load_background_tasks(group_path))
return tuple(tasks) return tuple(tasks)
def _tasks_in_group(self, group_path: Path) -> list[Task]: def _tasks_in_group(
self,
group_path: Path,
declarations_by_filename: dict[str, "TaskDeclaration"] | None = None,
) -> list[Task]:
task_paths = [ task_paths = [
path path
for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name) for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name)
@@ -151,10 +171,11 @@ class TaskCatalog:
or path.suffix != ".py" or path.suffix != ".py"
) )
] ]
declarations_by_filename = load_input_manifest( if declarations_by_filename is None:
group_path, declarations_by_filename = load_input_manifest(
{path.name for path in task_paths}, group_path,
) {path.name for path in task_paths},
).declarations
paths_by_filename = {path.name: path for path in task_paths} paths_by_filename = {path.name: path for path in task_paths}
ordered_filenames = [ ordered_filenames = [
*declarations_by_filename, *declarations_by_filename,
@@ -264,37 +285,49 @@ def read_description(path: Path) -> str | None:
) )
@dataclass(frozen=True)
class GroupManifest:
label: str | None
declarations: dict[str, "TaskDeclaration"]
def load_input_manifest( def load_input_manifest(
group_path: Path, group_path: Path,
task_filenames: set[str], task_filenames: set[str],
) -> dict[str, "TaskDeclaration"]: ) -> GroupManifest:
"""Read the optional data-only input declaration for one task group.""" """Read the optional data-only input declaration for one task group."""
manifest_path = group_path / "task-inputs.json" manifest_path = group_path / "task-inputs.json"
if not manifest_path.exists(): if not manifest_path.exists():
return {} return GroupManifest(label=None, declarations={})
if manifest_path.is_symlink(): if manifest_path.is_symlink():
raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}") raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}")
try: try:
data = json.loads(manifest_path.read_text(encoding="utf-8")) data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"Could not read input manifest {manifest_path}: {error}") from error raise ValueError(f"Could not read input manifest {manifest_path}: {error}") from error
if not isinstance(data, dict) or set(data) != {"version", "tasks"}: if not isinstance(data, dict) or set(data) - {"version", "label", "tasks"}:
raise ValueError( raise ValueError(
f"Input manifest {manifest_path} must contain only version and tasks." f"Input manifest {manifest_path} has an invalid structure."
) )
if data["version"] != 1 or not isinstance(data["tasks"], dict): if data.get("version") != 1 or not isinstance(data.get("tasks"), dict):
raise ValueError(f"Input manifest {manifest_path} has an unsupported structure.") raise ValueError(f"Input manifest {manifest_path} has an unsupported structure.")
label = data.get("label")
if label is not None and (not isinstance(label, str) or not label.strip()):
raise ValueError(f"Input manifest {manifest_path} label must be non-empty text.")
unknown_tasks = set(data["tasks"]) - task_filenames unknown_tasks = set(data["tasks"]) - task_filenames
if unknown_tasks: if unknown_tasks:
names = ", ".join(sorted(unknown_tasks)) names = ", ".join(sorted(unknown_tasks))
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.") raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
return { return GroupManifest(
filename: parse_task_declaration(manifest_path, filename, declaration) label=label,
for filename, declaration in data["tasks"].items() declarations={
} filename: parse_task_declaration(manifest_path, filename, declaration)
for filename, declaration in data["tasks"].items()
},
)
@dataclass(frozen=True) @dataclass(frozen=True)
+11
View File
@@ -51,6 +51,17 @@ for (const container of document.querySelectorAll("#run-artifacts")) {
window.setInterval(() => refreshArtifacts(container), 2000); window.setInterval(() => refreshArtifacts(container), 2000);
} }
for (const control of document.querySelectorAll("select[data-execution-mode]")) {
const buttonLabel = control.closest("form")?.querySelector(".task-action .button-label");
const updateButtonLabel = () => {
if (buttonLabel && control.selectedOptions[0]) {
buttonLabel.textContent = control.selectedOptions[0].textContent;
}
};
control.addEventListener("change", updateButtonLabel);
updateButtonLabel();
}
for (const form of document.querySelectorAll("form[data-wait-for-result]")) { for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
const result = form.querySelector(".task-result"); const result = form.querySelector(".task-result");
const controls = [...form.querySelectorAll("input, select, button")]; const controls = [...form.querySelectorAll("input, select, button")];
+59 -5
View File
@@ -31,8 +31,7 @@ a {
padding: 1rem; padding: 1rem;
} }
.group-heading, .group-heading {
.tasks li {
align-items: center; align-items: center;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -47,6 +46,9 @@ a {
.tasks li { .tasks li {
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
display: grid;
gap: 1rem;
grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr);
padding: .8rem 0; padding: .8rem 0;
} }
@@ -55,12 +57,17 @@ a {
margin: .2rem 0; margin: .2rem 0;
} }
.tasks form { .task-form {
min-width: 16rem; align-items: end;
display: grid;
gap: .75rem;
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
min-width: 0;
} }
.task-input { .task-input {
margin: .5rem 0; margin: 0;
min-width: 0;
} }
.task-input label, .task-input label,
@@ -75,6 +82,11 @@ a {
max-width: 100%; max-width: 100%;
} }
.task-input input:not([type="checkbox"]),
.task-input select {
width: 100%;
}
.task-input fieldset { .task-input fieldset {
border: 0; border: 0;
margin: 0; margin: 0;
@@ -97,6 +109,31 @@ button {
padding: .4rem .7rem; padding: .4rem .7rem;
} }
.task-action {
align-items: stretch;
border-left: 1px solid color-mix(in srgb, currentColor 15%, transparent);
display: flex;
gap: .4rem;
grid-column: -1;
grid-row: 1;
margin-left: .25rem;
padding-left: .75rem;
}
.task-action select,
.task-action button {
margin: 0;
white-space: nowrap;
}
.visually-hidden {
height: 1px;
margin: -1px;
overflow: hidden;
position: absolute;
width: 1px;
}
button:disabled { button:disabled {
cursor: wait; cursor: wait;
} }
@@ -118,6 +155,7 @@ button:disabled {
} }
.task-result { .task-result {
grid-column: 1 / -1;
margin: .5rem 0 0; margin: .5rem 0 0;
} }
@@ -133,6 +171,22 @@ button:disabled {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@media (max-width: 58rem) {
.tasks li {
grid-template-columns: 1fr;
}
}
@media (max-width: 42rem) {
.task-form {
grid-template-columns: minmax(0, 1fr) auto;
}
.task-action {
grid-column: 2;
}
}
dt { dt {
font-weight: 700; font-weight: 700;
} }
+22 -8
View File
@@ -24,8 +24,8 @@
<ul class="tasks"> <ul class="tasks">
{% for task in group.tasks %} {% for task in group.tasks %}
{% set task_run = latest[("task", task.id)] %} {% set task_run = latest[("task", task.id)] %}
<li> <li class="task">
<div> <div class="task-details">
<h3>{{ task.display_name }}</h3> <h3>{{ task.display_name }}</h3>
{% if task.description %}<p>{{ task.description }}</p>{% endif %} {% if task.description %}<p>{{ task.description }}</p>{% endif %}
{% if task_run %} {% if task_run %}
@@ -37,10 +37,11 @@
</p> </p>
{% endif %} {% endif %}
</div> </div>
<form method="post" action="{{ url_for('run_task', task_id=task.id) }}" enctype="multipart/form-data"{% if task.wait_for_result %} data-wait-for-result{% endif %}{% if task.download_artifacts %} data-download-artifacts{% endif %}> <form class="task-form" method="post" action="{{ url_for('run_task', task_id=task.id) }}" enctype="multipart/form-data"{% if task.wait_for_result %} data-wait-for-result{% endif %}{% if task.download_artifacts %} data-download-artifacts{% endif %}>
{% set values = form_values.get(task.id, {}) %} {% set values = form_values.get(task.id, {}) %}
{% set errors = input_errors.get(task.id, {}) %} {% set errors = input_errors.get(task.id, {}) %}
{% for field in task.inputs %} {% for field in task.inputs %}
{% if not task.execution or field.name != task.execution.field.name %}
<div class="task-input"> <div class="task-input">
{% if field.type == "file" %} {% if field.type == "file" %}
<label>{{ field.label }}{% if field.required %} (required){% endif %} <label>{{ field.label }}{% if field.required %} (required){% endif %}
@@ -58,7 +59,7 @@
{% endfor %} {% endfor %}
</fieldset> </fieldset>
{% elif field.type == "choice" %} {% elif field.type == "choice" %}
<label>{{ field.label }}{% if field.required %} (required){% endif %} <label>{{ field.label }}
<select name="{{ field.name }}" {% if field.required %}required{% endif %}> <select name="{{ field.name }}" {% if field.required %}required{% endif %}>
{% if not field.required %}<option value="">No selection</option>{% endif %} {% if not field.required %}<option value="">No selection</option>{% endif %}
{% set selected = values.get(field.name, field.default or "") %} {% set selected = values.get(field.name, field.default or "") %}
@@ -85,11 +86,24 @@
{% endif %} {% endif %}
{% if errors.get(field.name) %}<p class="input-error">{{ errors[field.name] }}</p>{% endif %} {% if errors.get(field.name) %}<p class="input-error">{{ errors[field.name] }}</p>{% endif %}
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
<button type="submit"> <div class="task-action">
<span class="button-label">Run</span> {% if task.execution %}
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %} {% set execution = task.execution.field %}
</button> {% set selected = values.get(execution.name, execution.default) %}
<label class="visually-hidden" for="{{ task.id|replace('/', '-') }}-{{ execution.name }}">Run mode</label>
<select id="{{ task.id|replace('/', '-') }}-{{ execution.name }}" name="{{ execution.name }}" data-execution-mode>
{% for option in execution.options %}
<option value="{{ option.value }}" {% if option.value == selected %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
{% endif %}
<button type="submit">
<span class="button-label">{{ task.execution.field.options[0].label if task.execution else "Run" }}</span>
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %}
</button>
</div>
{% if task.wait_for_result %}<p class="task-result" aria-live="polite"></p>{% endif %} {% if task.wait_for_result %}<p class="task-result" aria-live="polite"></p>{% endif %}
</form> </form>
</li> </li>
+29 -4
View File
@@ -24,10 +24,10 @@ def test_discovers_only_direct_non_hidden_python_tasks(tmp_path: Path) -> None:
groups = catalog.groups() groups = catalog.groups()
assert [group.id for group in groups] == ["media_tasks", "space group"] assert [group.id for group in groups] == ["media_tasks", "space group"]
assert groups[0].display_name == "media tasks" assert groups[0].display_name == "Media Tasks"
assert [task.filename for task in groups[0].tasks] == ["check_one.py", "with-hyphen.py"] assert [task.filename for task in groups[0].tasks] == ["check_one.py", "with-hyphen.py"]
assert [task.display_name for task in groups[0].tasks] == ["check one", "with-hyphen"] assert [task.display_name for task in groups[0].tasks] == ["Check One", "With-Hyphen"]
assert groups[1].tasks[0].display_name == "already named" assert groups[1].tasks[0].display_name == "Already Named"
assert groups[0].tasks[0].description == "Checks one thing." assert groups[0].tasks[0].description == "Checks one thing."
@@ -42,7 +42,7 @@ def test_ignores_symlinked_groups_and_tasks(tmp_path: Path) -> None:
(group / "linked.py").symlink_to(external / "outside" / "outside.py") (group / "linked.py").symlink_to(external / "outside" / "outside.py")
assert TaskCatalog(root).groups() == ( assert TaskCatalog(root).groups() == (
Group(id="safe", path=group, display_name="safe", tasks=()), Group(id="safe", path=group, display_name="Safe", tasks=()),
) )
@@ -119,6 +119,31 @@ def test_manifest_orders_listed_tasks_and_allows_empty_declarations(tmp_path: Pa
assert [task.filename for task in tasks.tasks] == ["review.py", "apply.py", "status.py"] assert [task.filename for task in tasks.tasks] == ["review.py", "apply.py", "status.py"]
def test_manifest_can_override_the_group_display_name(tmp_path: Path) -> None:
root = tmp_path / "tasks"
group = root / "service"
write_task(root, "service", "status.py", '"""Shows status."""\n')
(group / "task-inputs.json").write_text(
'''{"version":1,"label":"Service UI","tasks":{}}''',
encoding="utf-8",
)
discovered = TaskCatalog(root).group("service")
assert discovered is not None
assert discovered.display_name == "Service UI"
def test_display_names_keep_common_acronyms_capitalized(tmp_path: Path) -> None:
root = tmp_path / "tasks"
write_task(root, "tools", "replace_api_urls.py", '"""Updates URLs."""\n')
task = TaskCatalog(root).task("tools/replace_api_urls.py")
assert task is not None
assert task.display_name == "Replace API URLs"
def test_rejects_manifest_for_unknown_task(tmp_path: Path) -> None: def test_rejects_manifest_for_unknown_task(tmp_path: Path) -> None:
root = tmp_path / "tasks" root = tmp_path / "tasks"
group = root / "examples" group = root / "examples"
+2 -1
View File
@@ -20,7 +20,7 @@ def test_index_discovers_task_and_run_endpoint(settings) -> None:
page = client.get("/") page = client.get("/")
assert page.status_code == 200 assert page.status_code == 200
assert b"status checks" in page.data assert b"Status Checks" in page.data
assert b"Shows status." in page.data assert b"Shows status." in page.data
response = client.post("/tasks/status_checks/show_status.py/run") response = client.post("/tasks/status_checks/show_status.py/run")
@@ -230,6 +230,7 @@ print(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
page = client.get("/") page = client.get("/")
assert b"Report only" in page.data assert b"Report only" in page.data
assert b"Apply changes" in page.data assert b"Apply changes" in page.data
assert b"data-execution-mode" in page.data
response = client.post("/tasks/examples/migrate.py/run") response = client.post("/tasks/examples/migrate.py/run")
run_id = int(response.headers["Location"].rsplit("/", 1)[1]) run_id = int(response.headers["Location"].rsplit("/", 1)[1])
deadline = time.monotonic() + 3 deadline = time.monotonic() + 3