eco web: task queues -- what runs and waits, scan progress, output, controls

- queues page: per queue the running task with its scan's step n/N, the
  waiting and the last finished tasks (errors), each task's output live
- controls with writes on (all items, since a queue moves whatever its
  tasks move): pause/resume the queue and the running scan, stop the
  running task, move a waiting task up/to the top, remove it, drop all
  waiting, stop everything -- nothing adds or repeats a task
- task lines name devices instead of their repr (a whole status table)
- demo: a paused queue with three simulated scans of stage.x
This commit is contained in:
Henrik Lemke committed 2026-11-02 14:52:05 +01:00
1 parent 0047189c8a
commit e7d377760e
13 files changed
+559 -10

No files matched your search

+6
View File
@@ -76,6 +76,12 @@ python -m eco.web --demo --writes
laptop), or use Zoom +/−, Wide and Home. *Settings* shows pan, tilt, zoom,
iris and focus as rows. All viewers share one connection to the camera; the
stream pauses while the page is in the background.
- **Queues** (start page): the session's task queues ({doc}`queues`): the
running task with the step its scan is at, the waiting tasks, the last
finished ones with their errors, and each task's output, live. With writes
on: pause/resume the queue, pause/resume the running scan at its next step,
stop the running task, move a waiting task up or remove it, drop all
waiting, stop everything. Nothing can be added to a queue from here.
- **Panel**: a device's schematic, refreshed every 3 s. Tapping a symbol
opens that device; the panel's commands are not run from the web app.
+5 -2
View File
@@ -51,10 +51,13 @@ def main(argv=None):
if args.demo:
from eco.web.demo import DemoNamespace
from eco.web.demo import demo_queues
namespace = DemoNamespace()
backend = SessionBackend(namespace, queues=demo_queues(namespace))
else:
namespace = scope_namespace(args.scope, args.lazy)
gateway = WebGateway(SessionBackend(namespace), host=args.host, port=args.port,
backend = SessionBackend(scope_namespace(args.scope, args.lazy))
gateway = WebGateway(backend, host=args.host, port=args.port,
token=args.token, writes=args.writes).start()
print(connection_help(gateway))
# a terminated server must still stop the jogs its clients hold
+10 -1
View File
@@ -40,8 +40,9 @@ def attach_ca_context():
class SessionBackend:
def __init__(self, namespace, label=None):
def __init__(self, namespace, label=None, queues=None):
self.namespace = namespace
self._queues = queues # default: the session's eco.utilities.task_queue.queues
self.label = label or getattr(namespace, "name", None) or "eco"
self._init_lock = threading.Lock()
self._index_lock = threading.Lock()
@@ -98,6 +99,14 @@ class SessionBackend:
obj = obj.__wrapped__
return obj
def queues(self):
"""{name: TaskQueue} of the session (eco.utilities.task_queue.queues)."""
if self._queues is not None:
return self._queues
from eco.utilities import task_queue
return task_queue.queues
def search_index(self):
"""[(full name, kind, object)] of every adjustable/detector in the
initialized components, rebuilt when that set changes. Shared by all
+37
View File
@@ -447,3 +447,40 @@ class ClientSession:
raise ProtocolError(f"unknown ptz action {action!r}")
logger.info("web client %s: %s ptz %s", self.client_id, name, action)
return {"done": action}
# -- task queues: what runs and waits, and the controls for it -----------------------------
def _queue(self, name):
queue = dict(self.backend.queues()).get(name) # a copy: queues[missing] would create one
if queue is None:
raise NotFound(f"no queue {name!r}")
return queue
def _op_queues(self):
"""Every queue of the session (eco.web.queues.queue_json)."""
from eco.web import queues
return {"queues": [queues.queue_json(q) for q in list(dict(self.backend.queues()).values())],
"now": time.time()}
def _op_queue_output(self, queue, task, since=0):
"""What task #`task` of `queue` printed after character `since`."""
from eco.web import queues
found = queues.find_task(self._queue(queue), task)
if found is None:
raise NotFound(f"no task #{task} in queue {queue!r}")
text, offset, clipped = queues.output_since(found, since)
return {"text": text, "offset": offset, "clipped": clipped, "task": queues.task_json(found)}
def _op_queue_action(self, queue, action, task=None):
"""Pause/resume/stop/clear a queue, its running scan, or move/remove a
waiting task (eco.web.queues.ACTIONS) -- writes must be on. Nothing
here adds or starts a new task."""
from eco.web import queues
if self.writes is not True: # not per item: a queue moves whatever its tasks move
raise WritesDisabled("queue controls need writes enabled for this web session")
result = queues.act(self._queue(queue), action, task)
logger.info("web client %s: queue %s %s %s", self.client_id, queue, action, task or "")
return {"done": result}
+65
View File
@@ -373,6 +373,71 @@ class Sample(Assembly):
self._append(Cryostat, name="cryo", is_display="recursive")
class SimScan:
"""The parts of eco.acquisition.scan.StepScan the queue view reads:
steps done/to do, pause/resume at the next step, stop."""
def __init__(self, positions):
self._values_todo = list(positions)
self._values_done = []
self._pause_event = threading.Event()
self._pause_event.set()
self._stop_requested = False
def pause(self):
self._pause_event.clear()
def resume(self):
self._pause_event.set()
def is_paused(self):
return not self._pause_event.is_set()
def request_stop(self):
self._stop_requested = True
self._pause_event.set()
def demo_scan(motor, detector, start, stop, steps, step_time=0.5):
"""A queued 'scan' of a simulated motor: registers a SimScan with its
task (as StepScan does), moves step by step, prints a line per step."""
from eco.utilities import task_queue
positions = [start + (stop - start) * i / (steps - 1) for i in range(steps)]
scan = SimScan(positions)
task = task_queue.current_task()
if task is not None:
task.scans.append(scan)
print(f"scan {motor.name} {start} .. {stop}, {steps} steps")
while scan._values_todo:
while not scan._pause_event.wait(0.1):
pass
if scan._stop_requested:
print("stopped on request")
return
value = scan._values_todo[0]
motor.set_target_value(value).wait()
time.sleep(step_time)
print(f"step {len(scan._values_done) + 1:3d}/{steps} {motor.name} = {motor.get_current_value():8.4f}"
f" {detector.name} = {detector.get_current_value():9.2f}")
scan._values_done.append(scan._values_todo.pop(0))
print("scan done")
def demo_queues(namespace):
"""A queue "default" with three scans waiting, paused (so they wait for
Resume in the web client), and an empty queue "align"."""
from eco.utilities.task_queue import TaskQueue
stage = namespace.resolve_item("stage")
default = TaskQueue("default", namespace={})
default.pause() # before the first append starts its worker
for start, stop, steps in ((-1, 1, 40), (-0.5, 0.5, 30), (0, 2, 20)):
default.append(demo_scan, stage.x, stage.diode, start, stop, steps, test=False,
task_description=f"x {start}..{stop}")
return {"default": default, "align": TaskQueue("align", namespace={})}
def _broken(name=None):
raise ConnectionError("simulated: IOC not reachable")
+152
View File
@@ -0,0 +1,152 @@
"""The session's task queues (eco.utilities.task_queue) for the web client:
what runs, what waits, how far the running scan is, what a task printed --
and the controls of the Qt queue panel (ScanQueuePanel) that act on what is
already queued. Nothing here adds, repeats or edits a task.
"""
import re
from eco.web.viewmodels import _clip
HISTORY = 10 # finished tasks listed per queue
OUTPUT_CHUNK = 20000 # characters of output per reply at most (the newest)
# action -> (needs a task id, what it calls)
ACTIONS = {
"pause": False, # don't start the next task
"resume": False,
"clear": False, # drop every waiting task
"stop": False, # stop the running task and drop the waiting ones
"pause_running": False, # pause the running scan at its next step
"resume_running": False,
"stop_running": False, # its scans stop at their next step, else KeyboardInterrupt
"remove": True, # drop one waiting task
"move_up": True,
"move_top": True,
}
def _scan(task):
scan = task.scan
if scan is None:
return None
try:
done = len(getattr(scan, "_values_done", []))
total = done + len(getattr(scan, "_values_todo", []))
paused = bool(scan.is_paused())
except Exception:
return None
return {"done": done, "total": total, "paused": paused,
"stopping": bool(getattr(scan, "_stop_requested", False))}
_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def _short(value):
"""An argument as the task line shows it: an eco item by its name (its
repr is a whole status table), plain values by repr, anything else by
its type."""
alias = getattr(value, "alias", None)
if alias is not None:
try:
return alias.get_full_name()
except Exception:
pass
if value is None or isinstance(value, (bool, int, float, complex, str, bytes)):
return repr(value)
if isinstance(value, (list, tuple)) and len(value) <= 8:
inner = ", ".join(_short(v) for v in value)
return f"[{inner}]" if isinstance(value, list) else f"({inner}{',' if len(value) == 1 else ''})"
if isinstance(value, dict) and len(value) <= 8:
return "{" + ", ".join(f"{k!r}: {_short(v)}" for k, v in value.items()) + "}"
return f"<{type(value).__name__}>"
def task_source(task):
"""Task.source, but with short arguments (see _short)."""
if isinstance(task.command, str):
return _ANSI.sub("", task.command.strip())
name = getattr(task.command, "__qualname__", None) or type(task.command).__name__
parts = [_short(a) for a in task.args] + [f"{k}={_short(v)}" for k, v in task.kwargs.items()]
return f"{name}({', '.join(parts)})"
def task_json(task):
out = {
"id": task.id,
"source": _clip(task_source(task)),
"description": _clip(str(task.description or "")),
"state": task.state,
"started": task.started,
"finished": task.finished,
}
if task.exception is not None:
out["error"] = _clip(f"{type(task.exception).__name__}: {task.exception}")
scan = _scan(task) if task.state == "running" else None
if scan is not None:
out["scan"] = scan
return out
def queue_json(queue):
current = queue.current
return {
"name": queue.name,
"paused": queue.paused,
"running": task_json(current) if current is not None else None,
"pending": [task_json(t) for t in queue.pending],
"history": [task_json(t) for t in reversed(queue.history[-HISTORY:])],
}
def find_task(queue, task_id):
return queue._find(int(task_id), everywhere=True)
def output_since(task, since=0):
"""(text, offset): what `task` printed after character `since`, at most
OUTPUT_CHUNK characters (the newest). The oldest output of a very chatty
task is dropped by the queue itself; an offset beyond the end starts over."""
text = task.output
since = int(since or 0)
if since > len(text):
since = 0
chunk = text[since:]
clipped = len(chunk) > OUTPUT_CHUNK
if clipped:
chunk = chunk[-OUTPUT_CHUNK:]
return chunk, len(text), clipped
def act(queue, action, task_id=None):
"""Run one allowed action on `queue`; returns a short description."""
if action not in ACTIONS:
raise ValueError(f"unknown queue action {action!r}")
if ACTIONS[action]:
if task_id is None:
raise ValueError(f"{action} needs a task")
task = queue._find(int(task_id))
if task is None:
raise ValueError(f"task #{task_id} is not waiting in queue {queue.name!r} (any more)")
if action == "remove":
queue.remove(task)
else:
pending = queue.pending
index = 0 if action == "move_top" else max(0, pending.index(task) - 1)
queue.move(task, index)
return f"task #{task.id}: {action}"
if action == "pause":
queue.pause()
elif action == "resume":
queue.resume()
elif action == "clear":
return f"dropped {len(queue.clear())} waiting tasks"
elif action == "stop":
dropped = queue.stop()
return f"stopping the running task, dropped {len(dropped)} waiting"
elif action in ("pause_running", "resume_running", "stop_running"):
method = {"pause_running": queue.pause_running_task, "resume_running": queue.resume_running_task,
"stop_running": queue.stop_running_task}[action]
if method() is None:
raise ValueError("nothing is running" if action == "stop_running" else "the running task has no scan to pause")
return action
+148 -1
View File
@@ -293,6 +293,8 @@ function route() {
else if (hash.startsWith("/tweak/")) showTweak(hash.slice(7));
else if (hash.startsWith("/jog/")) showJog(hash.slice(5));
else if (hash.startsWith("/cam/")) showCamera(hash.slice(5));
else if (hash === "/queues") showQueues();
else if (hash.startsWith("/qout/")) showTaskOutput(hash.slice(6));
else showHome();
}
@@ -342,7 +344,9 @@ async function showHome() {
autocapitalize: "off", autocomplete: "off", spellcheck: "false",
oninput: (e) => { S.filter = e.target.value; render(); } });
render();
setMain(search, sections);
const tools = el("div", { class: "toolbar" },
el("a", { class: "btn link-btn", href: "#/queues", text: "Queues" }));
setMain(tools, search, sections);
}
// ---- device: an assembly or a single item -----------------------------------------------------
@@ -1127,6 +1131,149 @@ async function showCamera(name) {
if (document.visibilityState === "visible") start();
}
// ---- task queues: what runs and waits, pause/resume/stop -----------------------------------------------
const QUEUE_POLL = 1000; // ms
function duration(seconds) {
if (seconds === null || seconds === undefined) return "";
const s = Math.max(0, Math.round(seconds));
return s < 60 ? `${s} s` : s < 3600 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min`;
}
async function showQueues() {
const view = newView("queues", "");
setTitle("Queues", true);
const box = el("div", { class: "queues" }, el("div", { class: "loading", text: "loading queues…" }));
setMain(box, el("p", { class: "doc", text: "The session's task queues (%queue, scans with queue=…). " +
"Controls act on what is already queued; nothing is added here." }));
const writes = !!S.hello.writes;
const act = (queue, action, task, question) => {
const go = () => request("queue_action", { queue, action, task }).then(load, (e) => toast(e.message, true));
question ? confirmDialog(question, "", go) : go();
};
const button = (text, onclick, cls = "") =>
el("button", { class: `btn small ${cls}`, text, onclick, disabled: !writes, title: writes ? "" : "writes are off in this web session" });
const taskLine = (q, t, kind) => {
const took = t.started ? duration((t.finished || serverNow()) - t.started) : "";
const head = el("div", { class: "task-head" },
el("span", { class: `task-state ${t.state}`, text: t.state }),
el("span", { class: "task-id", text: `#${t.id}` }),
t.description ? el("span", { class: "task-desc", text: t.description }) : null,
took ? el("span", { class: "task-time", text: took }) : null);
const node = el("div", { class: `task ${kind}` }, head, el("div", { class: "task-source", text: t.source }));
if (t.error) node.append(el("div", { class: "task-error", text: t.error }));
if (t.scan) {
const pct = t.scan.total ? (100 * t.scan.done) / t.scan.total : 0;
node.append(el("div", { class: "progress" }, el("div", { class: "bar", style: `width:${pct.toFixed(1)}%` })),
el("div", { class: "task-scan", text: `step ${t.scan.done} / ${t.scan.total}` +
(t.scan.stopping ? " · stopping" : t.scan.paused ? " · scan paused" : "") }));
}
const tools = el("div", { class: "task-tools" });
if (kind !== "pending") tools.append(el("a", { class: "btn small link-btn", href: `#/qout/${encodeURIComponent(q.name)}/${t.id}`, text: "Output" }));
if (kind === "running") {
if (t.scan) tools.append(t.scan.paused ? button("Resume scan", () => act(q.name, "resume_running"))
: button("Pause scan", () => act(q.name, "pause_running")));
tools.append(button("Stop task", () => act(q.name, "stop_running", undefined,
`Stop task #${t.id}? Its scan stops at the next step; the queue pauses.`), "stop"));
} else if (kind === "pending") {
tools.append(button("Top", () => act(q.name, "move_top", t.id)), button("↑", () => act(q.name, "move_up", t.id)),
button("Remove", () => act(q.name, "remove", t.id, `Remove task #${t.id} from queue ${q.name}?`), "stop"));
}
if (tools.childElementCount) node.append(tools);
return node;
};
const render = (data) => {
if (!data.queues.length) {
box.replaceChildren(el("div", { class: "empty", text: "No queues in this session yet." }));
return;
}
box.replaceChildren(...data.queues.map((q) => {
const state = q.running ? (q.paused ? "running, then paused" : "running") : q.paused ? "paused" : q.pending.length ? "starting" : "idle";
const card = el("section", { class: "queue-card" },
el("div", { class: "queue-head" },
el("h2", { text: q.name }), el("span", { class: `badge q-${q.paused ? "paused" : q.running ? "running" : "idle"}`, text: state }),
q.paused ? button("Resume queue", () => act(q.name, "resume", undefined, q.pending.length ? `Resume queue ${q.name}? ${q.pending.length} waiting task(s) will start.` : null))
: button("Pause queue", () => act(q.name, "pause"))));
if (q.running) card.append(taskLine(q, q.running, "running"));
if (q.pending.length) {
card.append(el("div", { class: "section-title", text: `Waiting (${q.pending.length})` }),
...q.pending.map((t) => taskLine(q, t, "pending")),
el("div", { class: "task-tools" },
button("Drop all waiting", () => act(q.name, "clear", undefined, `Drop all ${q.pending.length} waiting tasks of ${q.name}?`), "stop")));
}
if (q.running || q.pending.length) {
card.append(el("div", { class: "task-tools" }, button("Stop everything", () => act(q.name, "stop", undefined,
`Stop the running task of ${q.name} and drop everything waiting?`), "stop")));
}
if (q.history.length) {
const list = el("div", {}, ...q.history.map((t) => taskLine(q, t, "done")));
card.append(el("details", { class: "hidden-items" }, el("summary", { text: `Finished (${q.history.length})` }), list));
}
return card;
}));
};
let busy = false;
const open = new Set(); // keep "Finished" folds open across refreshes
const load = async () => {
if (busy || S.view !== view || document.visibilityState !== "visible") return;
busy = true;
try {
box.querySelectorAll("section.queue-card").forEach((c) => {
if (c.querySelector("details")?.open) open.add(c.querySelector("h2").textContent);
else open.delete(c.querySelector("h2")?.textContent);
});
const data = await request("queues");
if (S.view !== view) return;
render(data);
box.querySelectorAll("section.queue-card").forEach((c) => {
const d = c.querySelector("details");
if (d && open.has(c.querySelector("h2").textContent)) d.open = true;
});
} catch (e) {
if (S.view === view) box.replaceChildren(el("div", { class: "empty", text: `could not read the queues: ${e.message}` }));
} finally {
busy = false;
}
};
await load();
view.timers.push(setInterval(load, QUEUE_POLL));
}
async function showTaskOutput(path) {
const slash = path.lastIndexOf("/");
const queue = path.slice(0, slash), id = parseInt(path.slice(slash + 1), 10);
const view = newView("qout", path);
setTitle(`${queue} #${id}`, true);
const head = el("div", { class: "task-head" });
const pre = el("pre", { class: "task-output", text: "" });
const follow = el("label", { class: "follow" }, el("input", { type: "checkbox", checked: true }), " follow");
setMain(head, pre, follow);
let offset = 0, busy = false;
const load = async () => {
if (busy || S.view !== view || document.visibilityState !== "visible") return;
busy = true;
try {
const r = await request("queue_output", { queue, task: id, since: offset });
if (S.view !== view) return;
if (r.offset < offset || r.clipped) pre.textContent = r.clipped ? "… (older output not shown)\n" : "";
offset = r.offset;
if (r.text) pre.append(r.text);
head.replaceChildren(el("span", { class: `task-state ${r.task.state}`, text: r.task.state }),
el("span", { class: "task-source", text: r.task.source }));
if (follow.querySelector("input").checked && r.text) pre.scrollTop = pre.scrollHeight;
} catch (e) {
if (S.view === view) head.replaceChildren(el("span", { class: "task-error", text: e.message }));
} finally {
busy = false;
}
};
await load();
view.timers.push(setInterval(load, QUEUE_POLL));
}
// ---- SVG panels ------------------------------------------------------------------------------------
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
+26
View File
@@ -189,3 +189,29 @@ a.btn:not(.primary) { color: var(--link); }
.cam-box { display: none; position: absolute; border: 2px dashed #fff; background: rgba(255,255,255,.12); pointer-events: none; }
.cam-tools { margin-top: 8px; }
.btn.on { background: var(--busy); color: #000; border-color: var(--busy); }
/* task queues */
.queue-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin-bottom: 12px; }
.queue-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.queue-head h2 { margin: 0; font-size: 18px; flex: 1; }
.badge.q-running { background: var(--busy); color: #000; }
.badge.q-paused { background: var(--err); color: #fff; }
.badge.q-idle { background: var(--line); color: var(--text); }
.task { border-top: 1px solid var(--line); padding: 8px 0; }
.task-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
.task-state { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--muted); }
.task-state.running { color: var(--busy); }
.task-state.done { color: var(--ok); }
.task-state.error { color: var(--err); }
.task-id { color: var(--muted); font-size: 13px; }
.task-desc { font-weight: 600; }
.task-time { color: var(--muted); font-size: 13px; margin-left: auto; }
.task-source { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; overflow-wrap: anywhere; }
.task-error { color: var(--err); font-size: 13px; overflow-wrap: anywhere; }
.task-scan { color: var(--muted); font-size: 13px; }
.task-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.progress { height: 8px; border-radius: 4px; background: var(--line); overflow: hidden; margin-top: 6px; }
.progress .bar { height: 100%; background: var(--busy); }
.task-output { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 10px; max-height: 65vh; overflow: auto;
font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
.follow { display: inline-flex; gap: 6px; align-items: center; color: var(--muted); font-size: 14px; }
+1 -1
View File
@@ -3,7 +3,7 @@
// SVG panels or the websocket -- a stale beamline value shown as current
// would be worse than none. Network first, so a new client version is
// picked up as soon as the gateway is reachable.
const CACHE = "eco-shell-v4";
const CACHE = "eco-shell-v5";
const SHELL = ["/", "/static/app.js", "/static/strip.js", "/static/style.css", "/icon.svg", "/manifest.webmanifest"];
self.addEventListener("install", (event) => {
+99
View File
@@ -326,3 +326,102 @@ def test_ptz_needs_writes_and_passes_the_access_gate(client, writer, monkeypatch
writer.call("describe", name="cam_demo")
reply = writer.call("ptz", name="cam_demo", action="home")
assert not reply["ok"] and "not your camera" in reply["error"]["message"]
# -- task queues -------------------------------------------------------------------------
def _queue_client(writes):
from eco.web.demo import demo_queues
ns = DemoNamespace()
c = Client(writes=writes)
c.session.close()
queues = demo_queues(ns)
from eco.web.client_session import ClientSession
c.session = ClientSession(SessionBackend(ns, queues=queues), c.messages.put, writes=writes, poll_interval=0.05)
return c, queues
def _wait(predicate, seconds=5.0):
deadline = time.monotonic() + seconds
while not predicate() and time.monotonic() < deadline:
time.sleep(0.05)
return predicate()
def test_queues_list_running_waiting_and_scan_progress():
c, queues = _queue_client(writes=True)
try:
data = c.call("queues")["data"]["queues"]
default = next(q for q in data if q["name"] == "default")
assert default["paused"] and default["running"] is None and len(default["pending"]) == 3
assert default["pending"][0]["description"] == "x -1..1"
assert c.call("queue_action", queue="default", action="resume")["ok"]
assert _wait(lambda: (queues["default"].current is not None and queues["default"].current.scan is not None
and queues["default"].current.scan._values_done))
running = next(q for q in c.call("queues")["data"]["queues"] if q["name"] == "default")["running"]
assert running["state"] == "running" and running["scan"]["total"] == 40 and running["scan"]["done"] >= 1
# pause the scan, then stop the task: the queue pauses, the rest waits
assert c.call("queue_action", queue="default", action="pause_running")["ok"]
assert _wait(lambda: queues["default"].current.scan.is_paused())
assert c.call("queue_action", queue="default", action="stop_running")["ok"]
assert _wait(lambda: queues["default"].current is None)
q = next(q for q in c.call("queues")["data"]["queues"] if q["name"] == "default")
assert q["paused"] and len(q["pending"]) == 2 and q["history"][0]["state"] == "done"
out = c.call("queue_output", queue="default", task=q["history"][0]["id"])["data"]
assert "stopped on request" in out["text"] and out["offset"] == len(out["text"])
again = c.call("queue_output", queue="default", task=q["history"][0]["id"], since=out["offset"])["data"]
assert again["text"] == ""
finally:
for queue in queues.values():
queue.stop()
queue.close()
c.session.close()
def test_waiting_tasks_can_be_moved_and_removed():
c, queues = _queue_client(writes=True)
try:
ids = [t.id for t in queues["default"].pending]
assert c.call("queue_action", queue="default", action="move_top", task=ids[2])["ok"]
assert [t.id for t in queues["default"].pending] == [ids[2], ids[0], ids[1]]
assert c.call("queue_action", queue="default", action="move_up", task=ids[1])["ok"]
assert [t.id for t in queues["default"].pending] == [ids[2], ids[1], ids[0]]
assert c.call("queue_action", queue="default", action="remove", task=ids[1])["ok"]
assert [t.id for t in queues["default"].pending] == [ids[2], ids[0]]
assert c.call("queue_action", queue="default", action="clear")["ok"]
assert queues["default"].pending == []
reply = c.call("queue_action", queue="default", action="remove", task=ids[0])
assert not reply["ok"] and "not waiting" in reply["error"]["message"]
assert not c.call("queue_action", queue="default", action="append")["ok"]
assert not c.call("queue_action", queue="nope", action="pause")["ok"]
assert "nope" not in queues
finally:
for queue in queues.values():
queue.close()
c.session.close()
def test_queue_controls_need_writes():
c, queues = _queue_client(writes=False)
try:
assert c.call("queues")["ok"]
reply = c.call("queue_action", queue="default", action="resume")
assert not reply["ok"] and reply["error"]["type"] == "WritesDisabled"
assert queues["default"].paused
finally:
for queue in queues.values():
queue.close()
c.session.close()
def test_task_lines_name_devices_instead_of_their_status_tables():
from eco.utilities.task_queue import Task
from eco.web.queues import task_source
ns = DemoNamespace()
stage = ns.resolve_item("stage")
task = Task(lambda *a, **k: None, (stage.x, -1, [1, 2]), {"counters": [stage.diode], "step_info": None})
assert task_source(task) == "test_task_lines_name_devices_instead_of_their_status_tables.<locals>.<lambda>(stage.x, -1, [1, 2], counters=[stage.diode], step_info=None)"
+3 -1
View File
@@ -17,7 +17,9 @@ opened as a view; SVG panels; details (the grids' ⋯ menu); pinned live strip
charts with running average; a picker over the initialized components; a
tweak view (one axis); a **jog** view for hardware jogs (motor records:
hold to move, jog speed ÷2/×2/set, dead-man stop in the gateway); a live view
of MJPEG PTZ network cameras (cam_west & co.) with tap-to-center/drag-to-zoom. Started
of MJPEG PTZ network cameras (cam_west & co.) with tap-to-center/drag-to-zoom;
the task queues with scan progress, output and the queue panel's controls
(not the launcher). Started
with `eco web -s <scope>` or `eco.start_web()`.
## Tier 1 — core, port first
+3 -3
View File
@@ -49,9 +49,9 @@ The client never sends expressions.
| — | — | a camera's live stream is plain HTTP: `GET /api/mjpeg?name=…&quality=low|medium|full` returns `multipart/x-mixed-replace` JPEGs for an `<img>`; `describe` of such an item has `camera: {stream: "mjpeg", ptz, rotate, qualities}` |
| `jog_speed` | `{name, value}` | sets the jog speed of `name` (its `jog_speed` item); allowed whenever writing `name` is |
| — | — | SVG panels are plain HTTP: `GET /api/svg?name=…` returns the SVG, header `X-Eco-Prefix` is what click markers are relative to; the client re-fetches every 3 s |
| `queues` *planned* | `{}` | queue list with tasks (mode B) |
| `queue_call` *planned* | `{queue, action: "pause"|"resume"|"abort_current"|…}` | allow-listed subset of `TaskQueue` methods |
| `queue_follow` *planned* | `{queue, from_task?}` | backlog + live `ev: "queue_output"` |
| `queues` | `{}` | `{queues: [{name, paused, running: task?, pending: [task], history: [task] (newest first, 10)}], now}`; task = `{id, source, description, state, started, finished, error?, scan?: {done, total, paused, stopping}}` — the client polls it every second |
| `queue_action` | `{queue, action, task?}` | (writes on, all items) `pause`, `resume`, `clear`, `stop`, `pause_running`, `resume_running`, `stop_running`, and for a waiting task `remove`, `move_up`, `move_top` (`eco.web.queues.ACTIONS`); nothing adds or repeats a task |
| `queue_output` | `{queue, task, since}` | `{text, offset, clipped, task}` — what the task printed after character `since` (at most the newest 20 000 characters) |
| `tweak` *planned* | `{session_id, action, args}` | `TweakController` methods by allow-list |
## Events
+4 -1
View File
@@ -46,6 +46,9 @@ phone size):
(30 s .. 1 h) with optional running average, survive a reload
- picker: find any readable item of the initialized components by name
- tweak view: big step buttons, ×2/÷2, go to, back to start, stop, arrow keys
- task queues: running task with scan progress, waiting and finished tasks,
live task output; pause/resume queue and running scan, stop, move/remove a
waiting task, drop all (`eco/web/queues.py`; nothing adds or repeats a task)
- live view of MJPEG network cameras (AxisPTZ: cam_west, ...), relayed by the
gateway (`eco/web/camera.py`: one camera connection per quality, newest frame
per client), with tap-to-center, drag-to-zoom, zoom steps, wide, home. Tested
@@ -54,7 +57,7 @@ phone size):
- token auth + HttpOnly cookie, binds 127.0.0.1, ping/reconnect after network
drops, clean shutdown, service worker (app shell only), light/dark
Not yet: queues/scan progress, archiver history, memories, cam_server cameras, alarms,
Not yet: scan launcher (adding tasks), archiver history, memories, cam_server cameras, alarms,
status-server (shared, read-only) mode. See [PARITY.md](PARITY.md) for the order.
Points to keep in mind (found while wiring it in):