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
{
"version": 1,
"label": "Example Tasks",
"tasks": {
"task.py": {
"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.
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
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
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
@@ -69,8 +70,8 @@ queued-run behavior.
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
dropdown. A successful review run offers a button that repeats the same saved
inputs using the execution value; it does not reuse a stale filesystem
mode selector attached to the submit button. A successful review run offers a
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.
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_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:
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)
@@ -104,12 +113,19 @@ class TaskCatalog:
or not group_path.is_dir()
):
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(
Group(
id=group_path.name,
path=group_path,
display_name=display_name(group_path.name),
display_name=manifest.label or display_name(group_path.name),
tasks=tasks,
)
)
@@ -140,7 +156,11 @@ class TaskCatalog:
tasks.extend(load_background_tasks(group_path))
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 = [
path
for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name)
@@ -151,10 +171,11 @@ class TaskCatalog:
or path.suffix != ".py"
)
]
declarations_by_filename = load_input_manifest(
group_path,
{path.name for path in task_paths},
)
if declarations_by_filename is None:
declarations_by_filename = load_input_manifest(
group_path,
{path.name for path in task_paths},
).declarations
paths_by_filename = {path.name: path for path in task_paths}
ordered_filenames = [
*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(
group_path: Path,
task_filenames: set[str],
) -> dict[str, "TaskDeclaration"]:
) -> GroupManifest:
"""Read the optional data-only input declaration for one task group."""
manifest_path = group_path / "task-inputs.json"
if not manifest_path.exists():
return {}
return GroupManifest(label=None, declarations={})
if manifest_path.is_symlink():
raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}")
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as 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(
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.")
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
if unknown_tasks:
names = ", ".join(sorted(unknown_tasks))
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
return {
filename: parse_task_declaration(manifest_path, filename, declaration)
for filename, declaration in data["tasks"].items()
}
return GroupManifest(
label=label,
declarations={
filename: parse_task_declaration(manifest_path, filename, declaration)
for filename, declaration in data["tasks"].items()
},
)
@dataclass(frozen=True)
+11
View File
@@ -51,6 +51,17 @@ for (const container of document.querySelectorAll("#run-artifacts")) {
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]")) {
const result = form.querySelector(".task-result");
const controls = [...form.querySelectorAll("input, select, button")];
+59 -5
View File
@@ -31,8 +31,7 @@ a {
padding: 1rem;
}
.group-heading,
.tasks li {
.group-heading {
align-items: center;
display: flex;
gap: 1rem;
@@ -47,6 +46,9 @@ a {
.tasks li {
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;
}
@@ -55,12 +57,17 @@ a {
margin: .2rem 0;
}
.tasks form {
min-width: 16rem;
.task-form {
align-items: end;
display: grid;
gap: .75rem;
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
min-width: 0;
}
.task-input {
margin: .5rem 0;
margin: 0;
min-width: 0;
}
.task-input label,
@@ -75,6 +82,11 @@ a {
max-width: 100%;
}
.task-input input:not([type="checkbox"]),
.task-input select {
width: 100%;
}
.task-input fieldset {
border: 0;
margin: 0;
@@ -97,6 +109,31 @@ button {
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 {
cursor: wait;
}
@@ -118,6 +155,7 @@ button:disabled {
}
.task-result {
grid-column: 1 / -1;
margin: .5rem 0 0;
}
@@ -133,6 +171,22 @@ button:disabled {
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 {
font-weight: 700;
}
+22 -8
View File
@@ -24,8 +24,8 @@
<ul class="tasks">
{% for task in group.tasks %}
{% set task_run = latest[("task", task.id)] %}
<li>
<div>
<li class="task">
<div class="task-details">
<h3>{{ task.display_name }}</h3>
{% if task.description %}<p>{{ task.description }}</p>{% endif %}
{% if task_run %}
@@ -37,10 +37,11 @@
</p>
{% endif %}
</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 errors = input_errors.get(task.id, {}) %}
{% for field in task.inputs %}
{% if not task.execution or field.name != task.execution.field.name %}
<div class="task-input">
{% if field.type == "file" %}
<label>{{ field.label }}{% if field.required %} (required){% endif %}
@@ -58,7 +59,7 @@
{% endfor %}
</fieldset>
{% elif field.type == "choice" %}
<label>{{ field.label }}{% if field.required %} (required){% endif %}
<label>{{ field.label }}
<select name="{{ field.name }}" {% if field.required %}required{% endif %}>
{% if not field.required %}<option value="">No selection</option>{% endif %}
{% set selected = values.get(field.name, field.default or "") %}
@@ -85,11 +86,24 @@
{% endif %}
{% if errors.get(field.name) %}<p class="input-error">{{ errors[field.name] }}</p>{% endif %}
</div>
{% endif %}
{% endfor %}
<button type="submit">
<span class="button-label">Run</span>
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %}
</button>
<div class="task-action">
{% if task.execution %}
{% set execution = task.execution.field %}
{% 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 %}
</form>
</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()
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.display_name for task in groups[0].tasks] == ["check one", "with-hyphen"]
assert groups[1].tasks[0].display_name == "already named"
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[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")
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"]
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:
root = tmp_path / "tasks"
group = root / "examples"
+2 -1
View File
@@ -20,7 +20,7 @@ def test_index_discovers_task_and_run_endpoint(settings) -> None:
page = client.get("/")
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
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("/")
assert b"Report only" 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")
run_id = int(response.headers["Location"].rsplit("/", 1)[1])
deadline = time.monotonic() + 3