Refine Python tools task interface
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 39s
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 53s
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 1m17s
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 39s
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 53s
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 1m17s
This commit is contained in:
+75
-37
@@ -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
|
line arguments, should write useful output, and must return a non-zero exit
|
||||||
code on failure.
|
code on failure.
|
||||||
|
|
||||||
## Task inputs
|
## Task definitions
|
||||||
|
|
||||||
An optional `task-inputs.json` beside a group's task files declares the fields
|
Each non-hidden directory directly below `/opt/tasks` is a task group. Its
|
||||||
for its web forms and its task order. It is parsed as data only and uses version
|
direct `*.py` files are the exposed tasks. Use an optional
|
||||||
`1`:
|
`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
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"label": "Example Tasks",
|
"title": "Example Tasks",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"task.py": {
|
"inspect.py": {
|
||||||
|
"title": "Inspect item",
|
||||||
|
"description": "Checks one item without changing it.",
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{"name": "identifier", "label": "Identifier", "type": "text", "required": true},
|
{"name": "identifier", "label": "Identifier", "type": "text", "required": true},
|
||||||
{"name": "attempts", "label": "Attempts", "type": "integer", "minimum": 1, "maximum": 5, "default": 2},
|
{"name": "attempts", "label": "Attempts", "type": "integer", "minimum": 1, "maximum": 5, "default": 2},
|
||||||
{"name": "mode", "label": "Mode", "type": "choice", "options": ["standard", "extended"]},
|
{"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`,
|
`title` is optional on a group and task. A group otherwise uses its directory
|
||||||
`multi_choice`, and `file`. Text may also define a regular-expression
|
name; a task otherwise uses its filename without `.py`, with underscores made
|
||||||
`pattern`; integer fields accept `minimum`, `maximum`, and `step`. Choices may
|
readable. `description` is optional on a task and otherwise comes from the
|
||||||
use strings or objects with `value` and `label`. File fields accept a list of
|
first non-empty line of its module docstring. The order of keys in `tasks`
|
||||||
lowercase filename extensions in `accept` and a byte limit in `maximum_bytes`.
|
controls the listed tasks' display order. Include a task as `{}` when only
|
||||||
An input can define `required` and `default`.
|
ordering is needed. Unlisted direct Python files follow alphabetically.
|
||||||
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.
|
|
||||||
|
|
||||||
For a task with a review-first operation, declare `execution` with the input
|
Each task declaration may contain `title`, `description`, `inputs`,
|
||||||
field and its review and execution values. The control panel generates the
|
`wait_for_result`, `download_artifacts`, and `execution`. Set
|
||||||
mode selector attached to the submit button. A successful review run offers a
|
`wait_for_result` for short tasks where the form should remain blocked until it
|
||||||
button that repeats the same saved inputs using the execution value; it does not reuse a stale filesystem
|
reports success or failure. The control panel disables its controls and warns
|
||||||
snapshot, so the task must still validate its inputs before changing anything.
|
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 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.
|
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
|
the input JSON and the files are removed when the task finishes, fails, or is
|
||||||
cancelled.
|
cancelled.
|
||||||
|
|
||||||
## Downloads
|
## Artifacts
|
||||||
|
|
||||||
Tasks can write downloadable files to the directory named by
|
Tasks can write files to the directory named by
|
||||||
`SERVER_MAINTENANCE_ARTIFACTS`. Set `download_artifacts` to `true` alongside
|
`SERVER_MAINTENANCE_ARTIFACTS`. Run-detail pages show them when the task
|
||||||
`wait_for_result` to start the first artifact download after a successful web
|
finishes. JSON artifacts offer an **Open** link that renders the JSON in a new
|
||||||
run. Run-detail pages also show available downloads. The server deletes an
|
tab and leaves the artifact available; other artifacts offer a download link.
|
||||||
artifact after its HTTP transfer ends; any undownloaded artifact is removed
|
The server deletes an artifact after its download transfer ends; any artifact
|
||||||
after 24 hours. A browser cannot confirm that a downloaded file was retained
|
not downloaded is removed after 24 hours. A browser cannot confirm that a
|
||||||
on the client device.
|
downloaded file was retained on the client device.
|
||||||
|
|
||||||
## Background tasks
|
## Background tasks
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,12 @@ class TaskCatalog:
|
|||||||
group_path,
|
group_path,
|
||||||
{
|
{
|
||||||
path.name for path in group_path.iterdir()
|
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))
|
tasks = tuple(self._tasks_in_group(group_path, manifest.declarations))
|
||||||
@@ -125,7 +130,7 @@ class TaskCatalog:
|
|||||||
Group(
|
Group(
|
||||||
id=group_path.name,
|
id=group_path.name,
|
||||||
path=group_path,
|
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,
|
tasks=tasks,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -191,8 +196,12 @@ class TaskCatalog:
|
|||||||
group_id=group_path.name,
|
group_id=group_path.name,
|
||||||
filename=path.name,
|
filename=path.name,
|
||||||
path=path,
|
path=path,
|
||||||
display_name=display_name(path.stem),
|
display_name=declaration.title if declaration and declaration.title else display_name(path.stem),
|
||||||
description=read_description(path),
|
description=(
|
||||||
|
declaration.description
|
||||||
|
if declaration and declaration.description is not None
|
||||||
|
else read_description(path)
|
||||||
|
),
|
||||||
inputs=(
|
inputs=(
|
||||||
(*declaration.inputs, declaration.execution.field)
|
(*declaration.inputs, declaration.execution.field)
|
||||||
if declaration and declaration.execution
|
if declaration and declaration.execution
|
||||||
@@ -287,7 +296,7 @@ def read_description(path: Path) -> str | None:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GroupManifest:
|
class GroupManifest:
|
||||||
label: str | None
|
title: str | None
|
||||||
declarations: dict[str, "TaskDeclaration"]
|
declarations: dict[str, "TaskDeclaration"]
|
||||||
|
|
||||||
|
|
||||||
@@ -299,22 +308,22 @@ def load_input_manifest(
|
|||||||
|
|
||||||
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 GroupManifest(label=None, declarations={})
|
return GroupManifest(title=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", "label", "tasks"}:
|
if not isinstance(data, dict) or set(data) - {"version", "title", "tasks"}:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Input manifest {manifest_path} has an invalid structure."
|
f"Input manifest {manifest_path} has an invalid structure."
|
||||||
)
|
)
|
||||||
if data.get("version") != 1 or not isinstance(data.get("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")
|
title = data.get("title")
|
||||||
if label is not None and (not isinstance(label, str) or not label.strip()):
|
if title is not None and (not isinstance(title, str) or not title.strip()):
|
||||||
raise ValueError(f"Input manifest {manifest_path} label must be non-empty text.")
|
raise ValueError(f"Input manifest {manifest_path} title 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:
|
||||||
@@ -322,7 +331,7 @@ def load_input_manifest(
|
|||||||
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
|
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
|
||||||
|
|
||||||
return GroupManifest(
|
return GroupManifest(
|
||||||
label=label,
|
title=title,
|
||||||
declarations={
|
declarations={
|
||||||
filename: parse_task_declaration(manifest_path, filename, declaration)
|
filename: parse_task_declaration(manifest_path, filename, declaration)
|
||||||
for filename, declaration in data["tasks"].items()
|
for filename, declaration in data["tasks"].items()
|
||||||
@@ -332,6 +341,8 @@ def load_input_manifest(
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TaskDeclaration:
|
class TaskDeclaration:
|
||||||
|
title: str | None
|
||||||
|
description: str | None
|
||||||
inputs: tuple[InputField, ...]
|
inputs: tuple[InputField, ...]
|
||||||
wait_for_result: bool
|
wait_for_result: bool
|
||||||
download_artifacts: bool
|
download_artifacts: bool
|
||||||
@@ -345,13 +356,19 @@ def parse_task_declaration(
|
|||||||
) -> TaskDeclaration:
|
) -> TaskDeclaration:
|
||||||
if (
|
if (
|
||||||
not isinstance(declaration, dict)
|
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.")
|
raise ValueError(f"{manifest_path} task {filename} has an invalid declaration.")
|
||||||
raw_inputs = declaration.get("inputs", [])
|
raw_inputs = declaration.get("inputs", [])
|
||||||
if not isinstance(raw_inputs, list):
|
if not isinstance(raw_inputs, list):
|
||||||
raise ValueError(f"{manifest_path} task {filename} inputs must be a 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)
|
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]
|
names = [field.name for field in fields]
|
||||||
if len(names) != len(set(names)):
|
if len(names) != len(set(names)):
|
||||||
raise ValueError(f"{manifest_path} task {filename} has duplicate input 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:
|
if download_artifacts and not wait_for_result:
|
||||||
raise ValueError(f"{manifest_path} task {filename} download_artifacts requires wait_for_result.")
|
raise ValueError(f"{manifest_path} task {filename} download_artifacts requires wait_for_result.")
|
||||||
return TaskDeclaration(
|
return TaskDeclaration(
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
inputs=fields,
|
inputs=fields,
|
||||||
wait_for_result=wait_for_result,
|
wait_for_result=wait_for_result,
|
||||||
download_artifacts=download_artifacts,
|
download_artifacts=download_artifacts,
|
||||||
@@ -381,7 +400,7 @@ def parse_execution_mode(
|
|||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
required_keys = {
|
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:
|
if not isinstance(raw, dict) or set(raw) != required_keys:
|
||||||
raise ValueError(f"{manifest_path} task {filename} has an invalid execution declaration.")
|
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.")
|
raise ValueError(f"{manifest_path} task {filename} execution values must differ.")
|
||||||
field = InputField(
|
field = InputField(
|
||||||
name=field_name,
|
name=field_name,
|
||||||
label=values["label"],
|
label="Run mode",
|
||||||
type="choice",
|
type="choice",
|
||||||
required=True,
|
required=True,
|
||||||
default=values["dry_run_value"],
|
default=values["dry_run_value"],
|
||||||
|
|||||||
+20
-9
@@ -210,15 +210,26 @@ def create_app(settings: Settings | None = None) -> Flask:
|
|||||||
run = store.get(run_id)
|
run = store.get(run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
return {
|
artifacts = []
|
||||||
"artifacts": [
|
for name in manager.artifacts(run.id):
|
||||||
{
|
artifact = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"url": url_for("download_artifact", run_id=run.id, filename=name),
|
"url": url_for("download_artifact", run_id=run.id, filename=name),
|
||||||
}
|
}
|
||||||
for name in manager.artifacts(run.id)
|
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/<int:run_id>/artifacts/<filename>/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/<int:run_id>/artifacts/<filename>")
|
@app.get("/runs/<int:run_id>/artifacts/<filename>")
|
||||||
def download_artifact(run_id: int, filename: str):
|
def download_artifact(run_id: int, filename: str):
|
||||||
|
|||||||
+32
-12
@@ -37,8 +37,15 @@ const refreshArtifacts = async (container) => {
|
|||||||
for (const artifact of artifacts) {
|
for (const artifact of artifacts) {
|
||||||
const item = document.createElement("li");
|
const item = document.createElement("li");
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = artifact.url;
|
if (artifact.view_url) {
|
||||||
link.textContent = artifact.name;
|
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);
|
item.append(link);
|
||||||
list.append(item);
|
list.append(item);
|
||||||
}
|
}
|
||||||
@@ -51,15 +58,24 @@ 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]")) {
|
for (const choice of document.querySelectorAll("button[data-execution-choice]")) {
|
||||||
const buttonLabel = control.closest("form")?.querySelector(".task-action .button-label");
|
choice.addEventListener("click", () => {
|
||||||
const updateButtonLabel = () => {
|
const control = choice.closest(".split-button");
|
||||||
if (buttonLabel && control.selectedOptions[0]) {
|
const value = control?.querySelector("input[data-execution-value]");
|
||||||
buttonLabel.textContent = control.selectedOptions[0].textContent;
|
const label = control?.querySelector(".button-label");
|
||||||
|
if (!control || !value || !label) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
value.value = choice.dataset.value || "";
|
||||||
control.addEventListener("change", updateButtonLabel);
|
label.textContent = choice.dataset.label || "Run";
|
||||||
updateButtonLabel();
|
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]")) {
|
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) => {
|
controls.forEach((control) => {
|
||||||
control.disabled = value;
|
control.disabled = value;
|
||||||
});
|
});
|
||||||
|
form.querySelectorAll("details.split-menu").forEach((menu) => {
|
||||||
|
menu.open = false;
|
||||||
|
});
|
||||||
if (value) {
|
if (value) {
|
||||||
window.addEventListener("beforeunload", warnBeforeLeaving);
|
window.addEventListener("beforeunload", warnBeforeLeaving);
|
||||||
} else {
|
} else {
|
||||||
@@ -103,11 +122,12 @@ for (const form of document.querySelectorAll("form[data-wait-for-result]")) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const artifacts = (await response.json()).artifacts;
|
const artifacts = (await response.json()).artifacts;
|
||||||
if (!artifacts.length) {
|
const artifact = artifacts.find((item) => !item.view_url);
|
||||||
|
if (!artifact) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = artifacts[0].url;
|
link.href = artifact.url;
|
||||||
link.download = "";
|
link.download = "";
|
||||||
document.body.append(link);
|
document.body.append(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="10" fill="#17372d"/>
|
||||||
|
<path d="M15 20 29 32 15 44" fill="none" stroke="#b7f0cd" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"/>
|
||||||
|
<path d="M35 43h14" fill="none" stroke="#b7f0cd" stroke-linecap="round" stroke-width="6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 343 B |
@@ -48,7 +48,7 @@ a {
|
|||||||
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
padding: .8rem 0;
|
padding: .8rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,19 +61,26 @@ a {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
grid-template-columns: minmax(20rem, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-options {
|
.task-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
|
justify-items: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input { margin: 0; }
|
.task-input { margin: 0; }
|
||||||
|
|
||||||
.task-input label,
|
.task-input label,
|
||||||
|
.task-input fieldset {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
.task-input fieldset label {
|
.task-input fieldset label {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -85,9 +92,16 @@ a {
|
|||||||
max-width: 100%;
|
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 {
|
.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 {
|
.task-input fieldset {
|
||||||
@@ -118,24 +132,63 @@ button {
|
|||||||
|
|
||||||
.split-button {
|
.split-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split-button select,
|
|
||||||
.split-button button,
|
.split-button button,
|
||||||
.task-action > button {
|
.task-action > button {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split-button select {
|
.split-primary {
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
border-right: 0;
|
|
||||||
border-top-right-radius: 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-bottom-left-radius: 0;
|
||||||
border-top-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 {
|
.visually-hidden {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Server Maintenance{% endblock %}</title>
|
<title>{% block title %}Server Maintenance{% endblock %}</title>
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -94,17 +94,22 @@
|
|||||||
{% if task.execution %}
|
{% if task.execution %}
|
||||||
<div class="split-button">
|
<div class="split-button">
|
||||||
{% set execution = task.execution.field %}
|
{% set execution = task.execution.field %}
|
||||||
{% set selected = values.get(execution.name, execution.default) %}
|
{% set submitted = values.get(execution.name) %}
|
||||||
<label class="visually-hidden" for="{{ task.id|replace('/', '-') }}-{{ execution.name }}">Run mode</label>
|
{% set selected = submitted[0] if submitted else execution.default %}
|
||||||
<select id="{{ task.id|replace('/', '-') }}-{{ execution.name }}" name="{{ execution.name }}" data-execution-mode>
|
{% set selected_option = execution.options|selectattr("value", "equalto", selected)|first %}
|
||||||
{% for option in execution.options %}
|
<input type="hidden" name="{{ execution.name }}" value="{{ selected }}" data-execution-value>
|
||||||
<option value="{{ option.value }}" {% if option.value == selected %}selected{% endif %}>{{ option.label }}</option>
|
<button class="split-primary" type="submit">
|
||||||
{% endfor %}
|
<span class="button-label">{{ selected_option.label if selected_option else execution.options[0].label }}</span>
|
||||||
</select>
|
|
||||||
<button type="submit">
|
|
||||||
<span class="button-label">{{ task.execution.field.options[0].label }}</span>
|
|
||||||
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %}
|
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
|
<details class="split-menu">
|
||||||
|
<summary aria-label="Choose run mode" title="Choose run mode">▾</summary>
|
||||||
|
<div class="split-menu-options">
|
||||||
|
{% for option in execution.options %}
|
||||||
|
<button type="button" data-execution-choice data-value="{{ option.value }}" data-label="{{ option.label }}"{% if option.value == selected %} aria-pressed="true"{% endif %}>{{ option.label }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<section id="run-artifacts" data-artifacts-url="{{ url_for('run_artifacts', run_id=run.id) }}" hidden>
|
<section id="run-artifacts" data-artifacts-url="{{ url_for('run_artifacts', run_id=run.id) }}" hidden>
|
||||||
<h2>Downloads</h2>
|
<h2>Artifacts</h2>
|
||||||
<ul></ul>
|
<ul></ul>
|
||||||
</section>
|
</section>
|
||||||
<h2>Output</h2>
|
<h2>Output</h2>
|
||||||
|
|||||||
@@ -119,12 +119,12 @@ 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:
|
def test_manifest_can_override_the_group_title(tmp_path: Path) -> None:
|
||||||
root = tmp_path / "tasks"
|
root = tmp_path / "tasks"
|
||||||
group = root / "service"
|
group = root / "service"
|
||||||
write_task(root, "service", "status.py", '"""Shows status."""\n')
|
write_task(root, "service", "status.py", '"""Shows status."""\n')
|
||||||
(group / "task-inputs.json").write_text(
|
(group / "task-inputs.json").write_text(
|
||||||
'''{"version":1,"label":"Service UI","tasks":{}}''',
|
'''{"version":1,"title":"Service UI","tasks":{}}''',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -144,6 +144,30 @@ def test_display_names_keep_common_acronyms_capitalized(tmp_path: Path) -> None:
|
|||||||
assert task.display_name == "Replace API URLs"
|
assert task.display_name == "Replace API URLs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_can_override_task_title_and_description(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "tasks"
|
||||||
|
group = root / "examples"
|
||||||
|
write_task(root, "examples", "inspect.py", '"""Inspects the default thing."""\n')
|
||||||
|
(group / "task-inputs.json").write_text(
|
||||||
|
'''{
|
||||||
|
"version": 1,
|
||||||
|
"tasks": {
|
||||||
|
"inspect.py": {
|
||||||
|
"title": "Inspect saved item",
|
||||||
|
"description": "Checks one saved item without changing it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}''',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
task = TaskCatalog(root).task("examples/inspect.py")
|
||||||
|
|
||||||
|
assert task is not None
|
||||||
|
assert task.display_name == "Inspect saved item"
|
||||||
|
assert task.description == "Checks one saved item without changing it."
|
||||||
|
|
||||||
|
|
||||||
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"
|
||||||
@@ -219,7 +243,6 @@ def test_execution_declaration_generates_an_action_choice(tmp_path: Path) -> Non
|
|||||||
"migrate.py": {
|
"migrate.py": {
|
||||||
"execution": {
|
"execution": {
|
||||||
"field": "mode",
|
"field": "mode",
|
||||||
"label": "Action",
|
|
||||||
"dry_run_value": "report",
|
"dry_run_value": "report",
|
||||||
"dry_run_label": "Report only",
|
"dry_run_label": "Report only",
|
||||||
"execute_value": "apply",
|
"execute_value": "apply",
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ print(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
|
|||||||
"migrate.py": {
|
"migrate.py": {
|
||||||
"execution": {
|
"execution": {
|
||||||
"field": "mode",
|
"field": "mode",
|
||||||
"label": "Action",
|
|
||||||
"dry_run_value": "report",
|
"dry_run_value": "report",
|
||||||
"dry_run_label": "Report only",
|
"dry_run_label": "Report only",
|
||||||
"execute_value": "apply",
|
"execute_value": "apply",
|
||||||
@@ -230,7 +229,8 @@ 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
|
assert b"data-execution-value" in page.data
|
||||||
|
assert b"data-execution-choice" in page.data
|
||||||
assert b'class="split-button"' in page.data
|
assert b'class="split-button"' 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])
|
||||||
@@ -294,6 +294,47 @@ print("archive created")
|
|||||||
assert app.extensions["run_manager"].artifact_path(run_id, "review.zip") is None
|
assert app.extensions["run_manager"].artifact_path(run_id, "review.zip") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_artifact_can_be_opened_without_deleting_it(settings) -> None:
|
||||||
|
write_task(
|
||||||
|
settings.task_root,
|
||||||
|
"exports",
|
||||||
|
"report.py",
|
||||||
|
'''"""Creates a report."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
artifact = Path(os.environ["SERVER_MAINTENANCE_ARTIFACTS"]) / "report.json"
|
||||||
|
artifact.write_text('{"status":"ok"}', encoding="utf-8")
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
response = client.post("/tasks/exports/report.py/run")
|
||||||
|
run_id = int(response.headers["Location"].rsplit("/", 1)[1])
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
run = app.extensions["run_store"].get(run_id)
|
||||||
|
assert run is not None
|
||||||
|
if run.status == "succeeded":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
raise AssertionError("JSON artifact task did not finish")
|
||||||
|
|
||||||
|
listed = client.get(f"/runs/{run_id}/artifacts").get_json()
|
||||||
|
artifact = listed["artifacts"][0]
|
||||||
|
assert artifact == {
|
||||||
|
"name": "report.json",
|
||||||
|
"url": f"/runs/{run_id}/artifacts/report.json",
|
||||||
|
"view_url": f"/runs/{run_id}/artifacts/report.json/view",
|
||||||
|
}
|
||||||
|
opened = client.get(artifact["view_url"])
|
||||||
|
assert opened.mimetype == "application/json"
|
||||||
|
assert opened.get_json() == {"status": "ok"}
|
||||||
|
assert app.extensions["run_manager"].artifact_path(run_id, "report.json") is not None
|
||||||
|
|
||||||
|
|
||||||
def test_file_input_rejects_the_wrong_extension(settings) -> None:
|
def test_file_input_rejects_the_wrong_extension(settings) -> None:
|
||||||
task = write_task(settings.task_root, "imports", "apply.py", '"""Applies a CSV."""\n')
|
task = write_task(settings.task_root, "imports", "apply.py", '"""Applies a CSV."""\n')
|
||||||
(task.parent / "task-inputs.json").write_text(
|
(task.parent / "task-inputs.json").write_text(
|
||||||
|
|||||||
Reference in New Issue
Block a user