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
|
||||
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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
+17
-6
@@ -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": [
|
||||
{
|
||||
artifacts = []
|
||||
for name in manager.artifacts(run.id):
|
||||
artifact = {
|
||||
"name": 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>")
|
||||
def download_artifact(run_id: int, filename: str):
|
||||
|
||||
+31
-11
@@ -37,8 +37,15 @@ const refreshArtifacts = async (container) => {
|
||||
for (const artifact of artifacts) {
|
||||
const item = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
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 = artifact.name;
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -94,17 +94,22 @@
|
||||
{% if task.execution %}
|
||||
<div class="split-button">
|
||||
{% 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>
|
||||
<button type="submit">
|
||||
<span class="button-label">{{ task.execution.field.options[0].label }}</span>
|
||||
{% set submitted = values.get(execution.name) %}
|
||||
{% set selected = submitted[0] if submitted else execution.default %}
|
||||
{% set selected_option = execution.options|selectattr("value", "equalto", selected)|first %}
|
||||
<input type="hidden" name="{{ execution.name }}" value="{{ selected }}" data-execution-value>
|
||||
<button class="split-primary" type="submit">
|
||||
<span class="button-label">{{ selected_option.label if selected_option else execution.options[0].label }}</span>
|
||||
{% if task.wait_for_result %}<span class="spinner" aria-hidden="true"></span>{% endif %}
|
||||
</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>
|
||||
{% else %}
|
||||
<button type="submit">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
<section id="run-artifacts" data-artifacts-url="{{ url_for('run_artifacts', run_id=run.id) }}" hidden>
|
||||
<h2>Downloads</h2>
|
||||
<h2>Artifacts</h2>
|
||||
<ul></ul>
|
||||
</section>
|
||||
<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"]
|
||||
|
||||
|
||||
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"
|
||||
group = root / "service"
|
||||
write_task(root, "service", "status.py", '"""Shows status."""\n')
|
||||
(group / "task-inputs.json").write_text(
|
||||
'''{"version":1,"label":"Service UI","tasks":{}}''',
|
||||
'''{"version":1,"title":"Service UI","tasks":{}}''',
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
root = tmp_path / "tasks"
|
||||
group = root / "examples"
|
||||
@@ -219,7 +243,6 @@ def test_execution_declaration_generates_an_action_choice(tmp_path: Path) -> Non
|
||||
"migrate.py": {
|
||||
"execution": {
|
||||
"field": "mode",
|
||||
"label": "Action",
|
||||
"dry_run_value": "report",
|
||||
"dry_run_label": "Report only",
|
||||
"execute_value": "apply",
|
||||
|
||||
@@ -213,7 +213,6 @@ print(Path(os.environ["SERVER_MAINTENANCE_INPUT"]).read_text())
|
||||
"migrate.py": {
|
||||
"execution": {
|
||||
"field": "mode",
|
||||
"label": "Action",
|
||||
"dry_run_value": "report",
|
||||
"dry_run_label": "Report only",
|
||||
"execute_value": "apply",
|
||||
@@ -230,7 +229,8 @@ 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
|
||||
assert b"data-execution-value" in page.data
|
||||
assert b"data-execution-choice" in page.data
|
||||
assert b'class="split-button"' in page.data
|
||||
response = client.post("/tasks/examples/migrate.py/run")
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
task = write_task(settings.task_root, "imports", "apply.py", '"""Applies a CSV."""\n')
|
||||
(task.parent / "task-inputs.json").write_text(
|
||||
|
||||
Reference in New Issue
Block a user