From 7e6dea8b24b0871a234f2ecf69f2602c6f759643 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 18 Aug 2026 04:21:11 +0000 Subject: [PATCH] Refine Python tools task interface --- python-tools/README.md | 112 ++++++++++++++++++--------- python-tools/app/discovery.py | 47 +++++++---- python-tools/app/web.py | 29 ++++--- python-tools/static/app.js | 44 ++++++++--- python-tools/static/favicon.svg | 5 ++ python-tools/static/style.css | 69 +++++++++++++++-- python-tools/templates/base.html | 1 + python-tools/templates/index.html | 23 +++--- python-tools/templates/run.html | 2 +- python-tools/tests/test_discovery.py | 29 ++++++- python-tools/tests/test_web.py | 45 ++++++++++- 11 files changed, 311 insertions(+), 95 deletions(-) create mode 100644 python-tools/static/favicon.svg diff --git a/python-tools/README.md b/python-tools/README.md index c7656c5..baeeb95 100644 --- a/python-tools/README.md +++ b/python-tools/README.md @@ -28,51 +28,89 @@ first line of their module docstring. A task must not require stdin or command line arguments, should write useful output, and must return a non-zero exit code on failure. -## Task inputs +## Task definitions -An optional `task-inputs.json` beside a group's task files declares the fields -for its web forms and its task order. It is parsed as data only and uses version -`1`: +Each non-hidden directory directly below `/opt/tasks` is a task group. Its +direct `*.py` files are the exposed tasks. Use an optional +`task-inputs.json` beside those files to define the group's title, task titles +and descriptions, task order, and web form fields. It is parsed as data only +and currently uses version `1`: ```json { "version": 1, - "label": "Example Tasks", + "title": "Example Tasks", "tasks": { - "task.py": { + "inspect.py": { + "title": "Inspect item", + "description": "Checks one item without changing it.", "inputs": [ {"name": "identifier", "label": "Identifier", "type": "text", "required": true}, {"name": "attempts", "label": "Attempts", "type": "integer", "minimum": 1, "maximum": 5, "default": 2}, {"name": "mode", "label": "Mode", "type": "choice", "options": ["standard", "extended"]}, - {"name": "alerts", "label": "Alerts", "type": "multi_choice", "options": ["email", "webhook"]} - ] + {"name": "alerts", "label": "Alerts", "type": "multi_choice", "options": ["email", "webhook"]}, + {"name": "review_csv", "label": "Review CSV", "type": "file", "accept": [".csv"], "maximum_bytes": 10485760} + ], + "wait_for_result": true, + "execution": { + "field": "mode", + "dry_run_value": "review", + "dry_run_label": "Review", + "execute_value": "apply", + "execute_label": "Execute" + } } } } ``` -Supported field types are `text`, `integer`, `date`, `datetime`, `choice`, -`multi_choice`, and `file`. Text may also define a regular-expression -`pattern`; integer fields accept `minimum`, `maximum`, and `step`. Choices may -use strings or objects with `value` and `label`. File fields accept a list of -lowercase filename extensions in `accept` and a byte limit in `maximum_bytes`. -An input can define `required` and `default`. -Set `sensitive` to `true` for a value or upload that must not appear in run -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. `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 -queued-run behavior. +`title` is optional on a group and task. A group otherwise uses its directory +name; a task otherwise uses its filename without `.py`, with underscores made +readable. `description` is optional on a task and otherwise comes from the +first non-empty line of its module docstring. The order of keys in `tasks` +controls the listed tasks' display order. Include a task as `{}` when only +ordering is needed. Unlisted direct Python files follow alphabetically. -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 -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. +Each task declaration may contain `title`, `description`, `inputs`, +`wait_for_result`, `download_artifacts`, and `execution`. Set +`wait_for_result` for short tasks where the form should remain blocked until it +reports success or failure. The control panel disables its controls and warns +before page unload while such a task runs. `download_artifacts` additionally +starts the first non-JSON artifact download after a successful browser run; +reserve it for files such as archives that the browser should download +automatically. + +Every input has `name`, `label`, and `type`; `required` defaults to `false`. +`default` is allowed for every type except `file`. + +- `text`: optional `pattern`, a Python-compatible regular expression. +- `integer`: optional `minimum`, `maximum`, and positive `step`. +- `date` and `datetime`: ISO-formatted browser date controls. +- `choice`: `options` is required, as strings or `{ "value", "label" }` objects. +- `multi_choice`: the same required `options`; its `default` is an array. +- `file`: optional lowercase extension list in `accept` and positive byte cap + in `maximum_bytes`. + +Set `sensitive` to `true` on text or file inputs that must not appear in run +history. Text is masked, the database records `[redacted]`, and the original +value is available only through a private execution-input file. Uploaded files +are likewise retained privately for the run and then removed. + +For review-first tasks, use `execution`. It produces one split button: click +the main part to use its current mode, or use the arrow to select the other +mode. A successful review run can be repeated with its saved inputs in the +execution mode from the run-detail page. The task must still revalidate the +live filesystem before changing anything. + +```json +{ + "field": "mode", + "dry_run_value": "review", + "dry_run_label": "Review", + "execute_value": "apply", + "execute_label": "Execute" +} +``` The server validates every submitted value before a run is queued. It records the validated object in the run database and writes it to a per-run file. @@ -84,15 +122,15 @@ are stored in a private per-run directory; their absolute paths are provided in the input JSON and the files are removed when the task finishes, fails, or is cancelled. -## Downloads +## Artifacts -Tasks can write downloadable files to the directory named by -`SERVER_MAINTENANCE_ARTIFACTS`. Set `download_artifacts` to `true` alongside -`wait_for_result` to start the first artifact download after a successful web -run. Run-detail pages also show available downloads. The server deletes an -artifact after its HTTP transfer ends; any undownloaded artifact is removed -after 24 hours. A browser cannot confirm that a downloaded file was retained -on the client device. +Tasks can write files to the directory named by +`SERVER_MAINTENANCE_ARTIFACTS`. Run-detail pages show them when the task +finishes. JSON artifacts offer an **Open** link that renders the JSON in a new +tab and leaves the artifact available; other artifacts offer a download link. +The server deletes an artifact after its download transfer ends; any artifact +not downloaded is removed after 24 hours. A browser cannot confirm that a +downloaded file was retained on the client device. ## Background tasks diff --git a/python-tools/app/discovery.py b/python-tools/app/discovery.py index f880df5..d02b093 100644 --- a/python-tools/app/discovery.py +++ b/python-tools/app/discovery.py @@ -117,7 +117,12 @@ class TaskCatalog: 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((".", "_")) + 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)) @@ -125,7 +130,7 @@ class TaskCatalog: Group( id=group_path.name, path=group_path, - display_name=manifest.label or display_name(group_path.name), + display_name=manifest.title or display_name(group_path.name), tasks=tasks, ) ) @@ -191,8 +196,12 @@ class TaskCatalog: group_id=group_path.name, filename=path.name, path=path, - display_name=display_name(path.stem), - description=read_description(path), + display_name=declaration.title if declaration and declaration.title else display_name(path.stem), + description=( + declaration.description + if declaration and declaration.description is not None + else read_description(path) + ), inputs=( (*declaration.inputs, declaration.execution.field) if declaration and declaration.execution @@ -287,7 +296,7 @@ def read_description(path: Path) -> str | None: @dataclass(frozen=True) class GroupManifest: - label: str | None + title: str | None declarations: dict[str, "TaskDeclaration"] @@ -299,22 +308,22 @@ def load_input_manifest( manifest_path = group_path / "task-inputs.json" if not manifest_path.exists(): - return GroupManifest(label=None, declarations={}) + return GroupManifest(title=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", "label", "tasks"}: + if not isinstance(data, dict) or set(data) - {"version", "title", "tasks"}: raise ValueError( f"Input manifest {manifest_path} has an invalid structure." ) 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.") + title = data.get("title") + if title is not None and (not isinstance(title, str) or not title.strip()): + raise ValueError(f"Input manifest {manifest_path} title must be non-empty text.") unknown_tasks = set(data["tasks"]) - task_filenames if unknown_tasks: @@ -322,7 +331,7 @@ def load_input_manifest( raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.") return GroupManifest( - label=label, + title=title, declarations={ filename: parse_task_declaration(manifest_path, filename, declaration) for filename, declaration in data["tasks"].items() @@ -332,6 +341,8 @@ def load_input_manifest( @dataclass(frozen=True) class TaskDeclaration: + title: str | None + description: str | None inputs: tuple[InputField, ...] wait_for_result: bool download_artifacts: bool @@ -345,13 +356,19 @@ def parse_task_declaration( ) -> TaskDeclaration: if ( not isinstance(declaration, dict) - or set(declaration) - {"inputs", "wait_for_result", "download_artifacts", "execution"} + or set(declaration) - {"title", "description", "inputs", "wait_for_result", "download_artifacts", "execution"} ): raise ValueError(f"{manifest_path} task {filename} has an invalid declaration.") raw_inputs = declaration.get("inputs", []) if not isinstance(raw_inputs, list): raise ValueError(f"{manifest_path} task {filename} inputs must be a list.") fields = tuple(parse_input_field(manifest_path, filename, raw) for raw in raw_inputs) + title = declaration.get("title") + if title is not None and (not isinstance(title, str) or not title.strip()): + raise ValueError(f"{manifest_path} task {filename} title must be non-empty text.") + description = declaration.get("description") + if description is not None and (not isinstance(description, str) or not description.strip()): + raise ValueError(f"{manifest_path} task {filename} description must be non-empty text.") names = [field.name for field in fields] if len(names) != len(set(names)): raise ValueError(f"{manifest_path} task {filename} has duplicate input names.") @@ -365,6 +382,8 @@ def parse_task_declaration( if download_artifacts and not wait_for_result: raise ValueError(f"{manifest_path} task {filename} download_artifacts requires wait_for_result.") return TaskDeclaration( + title=title, + description=description, inputs=fields, wait_for_result=wait_for_result, download_artifacts=download_artifacts, @@ -381,7 +400,7 @@ def parse_execution_mode( if raw is None: return None required_keys = { - "field", "label", "dry_run_value", "dry_run_label", "execute_value", "execute_label", + "field", "dry_run_value", "dry_run_label", "execute_value", "execute_label", } if not isinstance(raw, dict) or set(raw) != required_keys: raise ValueError(f"{manifest_path} task {filename} has an invalid execution declaration.") @@ -395,7 +414,7 @@ def parse_execution_mode( raise ValueError(f"{manifest_path} task {filename} execution values must differ.") field = InputField( name=field_name, - label=values["label"], + label="Run mode", type="choice", required=True, default=values["dry_run_value"], diff --git a/python-tools/app/web.py b/python-tools/app/web.py index 488d8e2..76dd47e 100644 --- a/python-tools/app/web.py +++ b/python-tools/app/web.py @@ -210,15 +210,26 @@ def create_app(settings: Settings | None = None) -> Flask: run = store.get(run_id) if run is None: abort(404) - return { - "artifacts": [ - { - "name": name, - "url": url_for("download_artifact", run_id=run.id, filename=name), - } - for name in manager.artifacts(run.id) - ] - } + artifacts = [] + for name in manager.artifacts(run.id): + artifact = { + "name": name, + "url": url_for("download_artifact", run_id=run.id, filename=name), + } + if name.casefold().endswith(".json"): + artifact["view_url"] = url_for("view_artifact", run_id=run.id, filename=name) + artifacts.append(artifact) + return {"artifacts": artifacts} + + @app.get("/runs//artifacts//view") + def view_artifact(run_id: int, filename: str): + run = store.get(run_id) + if run is None or run.status != "succeeded" or not filename.casefold().endswith(".json"): + abort(404) + artifact = manager.artifact_path(run.id, filename) + if artifact is None: + abort(404) + return send_file(artifact, mimetype="application/json", as_attachment=False, conditional=False) @app.get("/runs//artifacts/") def download_artifact(run_id: int, filename: str): diff --git a/python-tools/static/app.js b/python-tools/static/app.js index 61928d3..c5ed806 100644 --- a/python-tools/static/app.js +++ b/python-tools/static/app.js @@ -37,8 +37,15 @@ const refreshArtifacts = async (container) => { for (const artifact of artifacts) { const item = document.createElement("li"); const link = document.createElement("a"); - link.href = artifact.url; - link.textContent = artifact.name; + if (artifact.view_url) { + link.href = artifact.view_url; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = `Open ${artifact.name}`; + } else { + link.href = artifact.url; + link.textContent = `Download ${artifact.name}`; + } item.append(link); list.append(item); } @@ -51,15 +58,24 @@ 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; +for (const choice of document.querySelectorAll("button[data-execution-choice]")) { + choice.addEventListener("click", () => { + const control = choice.closest(".split-button"); + const value = control?.querySelector("input[data-execution-value]"); + const label = control?.querySelector(".button-label"); + if (!control || !value || !label) { + return; } - }; - control.addEventListener("change", updateButtonLabel); - updateButtonLabel(); + value.value = choice.dataset.value || ""; + label.textContent = choice.dataset.label || "Run"; + control.querySelectorAll("button[data-execution-choice]").forEach((option) => { + option.setAttribute("aria-pressed", String(option === choice)); + }); + const menu = control.querySelector("details"); + if (menu) { + menu.open = false; + } + }); } for (const form of document.querySelectorAll("form[data-wait-for-result]")) { @@ -78,6 +94,9 @@ for (const form of document.querySelectorAll("form[data-wait-for-result]")) { controls.forEach((control) => { control.disabled = value; }); + form.querySelectorAll("details.split-menu").forEach((menu) => { + menu.open = false; + }); if (value) { window.addEventListener("beforeunload", warnBeforeLeaving); } else { @@ -103,11 +122,12 @@ for (const form of document.querySelectorAll("form[data-wait-for-result]")) { return; } const artifacts = (await response.json()).artifacts; - if (!artifacts.length) { + const artifact = artifacts.find((item) => !item.view_url); + if (!artifact) { return; } const link = document.createElement("a"); - link.href = artifacts[0].url; + link.href = artifact.url; link.download = ""; document.body.append(link); link.click(); diff --git a/python-tools/static/favicon.svg b/python-tools/static/favicon.svg new file mode 100644 index 0000000..ff11e17 --- /dev/null +++ b/python-tools/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/python-tools/static/style.css b/python-tools/static/style.css index e4e3523..4a9e33c 100644 --- a/python-tools/static/style.css +++ b/python-tools/static/style.css @@ -48,7 +48,7 @@ a { border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); display: grid; gap: 1rem; - grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); padding: .8rem 0; } @@ -61,19 +61,26 @@ a { align-items: end; display: grid; gap: .75rem; - grid-template-columns: minmax(20rem, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto; min-width: 0; } .task-options { display: grid; gap: .75rem; + justify-items: start; min-width: 0; } .task-input { margin: 0; } .task-input label, +.task-input fieldset { + display: block; + max-width: 100%; + width: fit-content; +} + .task-input fieldset label { display: block; } @@ -85,9 +92,16 @@ a { max-width: 100%; } -.task-input input:not([type="checkbox"]), +.task-input input[type="date"], +.task-input input[type="datetime-local"], +.task-input input[type="number"], .task-input select { - width: 100%; + field-sizing: content; +} + +.task-input input[type="text"], +.task-input input[type="password"] { + width: min(15rem, 100%); } .task-input fieldset { @@ -118,24 +132,63 @@ button { .split-button { display: inline-flex; + position: relative; } -.split-button select, .split-button button, .task-action > button { margin: 0; white-space: nowrap; } -.split-button select { +.split-primary { border-bottom-right-radius: 0; - border-right: 0; border-top-right-radius: 0; } -.split-button button { +.split-menu summary { + align-items: center; + border: 1px solid currentColor; border-bottom-left-radius: 0; border-top-left-radius: 0; + box-sizing: border-box; + cursor: pointer; + display: flex; + height: 100%; + justify-content: center; + list-style: none; + min-width: 2.25rem; +} + +.split-menu summary::-webkit-details-marker { + display: none; +} + +.split-menu-options { + background: Canvas; + border: 1px solid currentColor; + display: grid; + gap: .2rem; + min-width: max-content; + padding: .25rem; + position: absolute; + right: 0; + top: calc(100% + .25rem); + z-index: 1; +} + +.split-menu:not([open]) .split-menu-options { + display: none; +} + +.split-menu-options button { + text-align: left; + width: 100%; +} + +.is-waiting .split-menu { + opacity: .6; + pointer-events: none; } .visually-hidden { diff --git a/python-tools/templates/base.html b/python-tools/templates/base.html index 6655286..d4d3174 100644 --- a/python-tools/templates/base.html +++ b/python-tools/templates/base.html @@ -4,6 +4,7 @@ {% block title %}Server Maintenance{% endblock %} + diff --git a/python-tools/templates/index.html b/python-tools/templates/index.html index d919028..dced5d4 100644 --- a/python-tools/templates/index.html +++ b/python-tools/templates/index.html @@ -94,17 +94,22 @@ {% if task.execution %}
{% set execution = task.execution.field %} - {% set selected = values.get(execution.name, execution.default) %} - - - +
+ +
+ {% for option in execution.options %} + + {% endfor %} +
+
{% else %}