diff --git a/CHANGELOG.md b/CHANGELOG.md index f399ccc..113b971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Added -- **Performance** in the Server tab: the run in progress against an ETA (the average of the same workflow's recent successful runs), and the last runs with who launched them and how long they took, from ComfyUI's own timestamps. It pops out as a floating card with the same magnet docking; docked cards stack in the corner +- **Performance** in the Server tab: the run in progress against an ETA (the average of the same workflow's last 5 successful runs), the last 5 runs with who launched them and how long they took, and a **History** window with every recorded run. Timings come from ComfyUI's own timestamps and are kept in `runs.json`, so they survive restarts +- Performance pops out as a floating card: just the timer, big, with the usual time of the workflow on screen when idle, and a button to clear that workflow's timings (after a confirmation; needs *Edit*). Docked cards sit side by side in the corner, Performance to the left of Server - Floating server monitor: a magnet docks it in the bottom-right corner and keeps it there, beside the minimap in the graph, in the corner of the App view. Dragging it undocks it - Floating server monitor: **Unload models** and **Free models & cache** buttons for admins, like ComfyUI Manager's - Admin option *ComfyUI Manager in its own sidebar tab*: a **Manager** tab in the left bar (Open Manager, Custom Nodes Manager) replaces the top-bar button, for accounts with the *Manager* permission diff --git a/docs/api.md b/docs/api.md index 0f4e368..16a226d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,6 +48,8 @@ The panel uses these; they take the same session cookie or API key. | `GET/POST /rigshare/api/me/keys`, `DELETE /rigshare/api/me/keys/{id}` | Your API keys | | `GET /rigshare/api/chat?before=&limit=50` | Older chat messages, oldest first, and whether more exist | | `DELETE /rigshare/api/chat` | Clear the whole chat history (admin) | +| `GET /rigshare/api/performance/history?limit=100&workflow=` | Recorded runs, newest first: duration, result, workflow, who | +| `DELETE /rigshare/api/performance?workflow=` | Forget a workflow's timings, so its ETA starts over (*Edit*) | | `GET /rigshare/api/rooms` | Live workflows | | `GET /rigshare/api/room?key=…` | One room, with its workflow JSON | | `GET /rigshare/api/snapshots?room=…` | History of a room | diff --git a/docs/install.md b/docs/install.md index ac78d73..50f5ffc 100644 --- a/docs/install.md +++ b/docs/install.md @@ -53,6 +53,7 @@ The data folder is `/rigshare`; point it elsewhere with `RIGS | `rooms/` | Latest live state of each workflow | | `snapshots/` | Workflow history | | `chat.jsonl` | The whole chat history, one message per line | +| `runs.json` | Timings of the last 500 runs (Performance) | Back up the folder to keep accounts and history. Deleting `secret.key` signs everyone out. diff --git a/rigshare_core/hub.py b/rigshare_core/hub.py index 8f4f364..8997ddc 100644 --- a/rigshare_core/hub.py +++ b/rigshare_core/hub.py @@ -109,7 +109,7 @@ class Hub: self._task = None self.workspace = None # set by __init__ (folders, private spaces) self.queue_owners = OrderedDict() # prompt_id -> {user, name, workflow, at} - self.run_started = {} # prompt_id -> time.time() of its execution_start (set by routes) + self.run_started = {} # prompt_id -> ms timestamp of its execution_start (set by routes) # ----- lifecycle ---------------------------------------------------- @@ -147,7 +147,7 @@ class Hub: try: self.stats = await loop.run_in_executor(None, collect_stats, self.server) self.stats["queue_items"] = self.with_owners(self.stats.get("queue_items")) - self.stats["performance"] = self.performance(self.stats.pop("run_history", None), self.stats["queue_items"]) + self.stats["performance"] = self.performance(self.stats["queue_items"]) await self.broadcast({"type": "stats", "stats": self.stats}) except Exception as e: log.debug(f"[RigShare] stats error: {e}") @@ -173,31 +173,53 @@ class Hub: return None return [{**it, **(self.queue_owners.get(it["id"]) or {})} for it in items] - def performance(self, history, items, recent=5): - """The run in progress against an ETA, and the last finished runs. + RESULTS = {"execution_success": "done", "execution_error": "error", "execution_interrupted": "stopped"} - ETA: the average of the recent successful runs of the same workflow. + def run_event(self, event, data): + """ComfyUI execution events (from routes' send_sync hook): time each run. + + Both events carry ComfyUI's own millisecond timestamp, so durations are exact. """ - if history is None: - return None - runs = self.with_owners(history) - now = time.time() + prompt_id = data.get("prompt_id") if isinstance(data, dict) else None + if not prompt_id: + return + stamp = data.get("timestamp") or int(time.time() * 1000) + if event == "execution_start": + self.run_started[prompt_id] = stamp + elif event in self.RESULTS and prompt_id in self.run_started: + start = self.run_started.pop(prompt_id) + owner = self.queue_owners.get(prompt_id) or {} + self.store.add_run({"id": prompt_id, "ms": max(0, stamp - start), "end": stamp, + "result": self.RESULTS[event], "workflow": owner.get("workflow"), + "user": owner.get("user"), "name": owner.get("name")}) + + def etas(self): + """Expected duration per workflow: the average of its last 5 successful runs.""" + done = {} + for run in reversed(self.store.runs()): + wf = run.get("workflow") + if wf and run.get("result") == "done" and len(done.setdefault(wf, [])) < 5: + done[wf].append(run["ms"]) + return {wf: {"ms": int(sum(v) / len(v)), "runs": len(v)} for wf, v in done.items() if v} + + def performance(self, items, recent=5): + """The run in progress against its workflow's ETA, the last runs, and every ETA.""" + now = int(time.time() * 1000) + etas = self.etas() running = [] for item in items or []: if item.get("status") != "running": continue started = self.run_started.setdefault(item["id"], now) - same = [r["ms"] for r in runs if r["result"] == "done" and item.get("workflow") - and r.get("workflow") == item["workflow"]][:5] + eta = etas.get(item.get("workflow")) or {} running.append({"id": item["id"], "workflow": item.get("workflow"), "user": item.get("user"), - "name": item.get("name"), "elapsed_ms": int((now - started) * 1000), - "eta_ms": int(sum(same) / len(same)) if same else None, "runs": len(same)}) + "name": item.get("name"), "elapsed_ms": max(0, now - started), + "eta_ms": eta.get("ms"), "runs": eta.get("runs", 0)}) live = {r["id"] for r in running} - for prompt_id in [k for k in self.run_started if k not in live]: - del self.run_started[prompt_id] # finished: its time is in ComfyUI's history now - keep = ("id", "ms", "end", "result", "workflow", "user", "name") - return {"running": running, "recent": [{k: r.get(k) for k in keep} for r in runs[:recent]], - "now": int(now * 1000)} + for prompt_id in [k for k in self.run_started if k not in live and now - self.run_started[k] > 86400000]: + del self.run_started[prompt_id] # never finished (server restarted mid-run...) + return {"running": running, "recent": list(reversed(self.store.runs()[-recent:])), + "etas": etas, "now": now} def running_prompt_ids(self): try: diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py index d6a5083..35a6571 100644 --- a/rigshare_core/routes.py +++ b/rigshare_core/routes.py @@ -391,6 +391,29 @@ def setup(server, store, hub): log.info(f"[RigShare] {admin['username']} cleared the chat history") return web.json_response({"ok": True}) + @routes.get("/rigshare/api/performance/history") + async def performance_history(request): + """Finished runs, newest first (?limit=, at most 500; ?workflow= to filter).""" + try: + limit = max(1, min(500, int(request.query.get("limit", 100)))) + except ValueError: + return error("limit must be a number") + workflow = request.query.get("workflow") + runs = [r for r in reversed(store.runs()) if not workflow or r.get("workflow") == workflow] + return web.json_response({"runs": runs[:limit], "total": len(runs)}) + + @routes.delete("/rigshare/api/performance") + async def performance_clear(request): + """Forget one workflow's timings, so its ETA starts over.""" + if not perms_for(request).get("edit"): + return error("You need the Edit permission to clear timings", 403) + workflow = request.query.get("workflow", "").strip() + if not workflow: + return error("Which workflow?") + gone = store.clear_runs(workflow) + log.info(f"[RigShare] {display(request)} cleared the timings of {workflow} ({gone} runs)") + return web.json_response({"ok": True, "cleared": gone}) + @routes.get("/rigshare/api/rooms") async def list_rooms(request): return web.json_response([r for r in hub.room_list() if room_access(request, hub.rooms[r["key"]])]) @@ -877,8 +900,11 @@ def setup(server, store, hub): original_send_sync = server.send_sync def send_sync(event, data, sid=None): - if event == "execution_start" and isinstance(data, dict) and data.get("prompt_id"): - hub.run_started[data["prompt_id"]] = time.time() # Performance: exact start of the run + if event in ("execution_start", "execution_success", "execution_error", "execution_interrupted"): + try: + hub.run_event(event, data) # Performance: time the run + except Exception as e: + log.debug(f"[RigShare] could not time the run: {e}") if sid is not None and store.config.get("broadcast_execution", True): if event in BROADCAST_EVENTS or isinstance(event, int): sid = None diff --git a/rigshare_core/stats.py b/rigshare_core/stats.py index a9edef3..4a3720e 100644 --- a/rigshare_core/stats.py +++ b/rigshare_core/stats.py @@ -68,37 +68,6 @@ def queue_items(server): return items -RESULTS = {"execution_success": "done", "execution_error": "error", "execution_interrupted": "stopped"} - - -def run_history(server, n=40): - """Finished runs from ComfyUI's history, newest first: [{id, ms, end, result}]. - - ComfyUI stamps execution_start and the final event with millisecond times, so - durations are exact. Only each entry's status is read (no outputs copied). - """ - try: - history = server.prompt_queue.get_history(max_items=n, map_function=lambda p: p.get("status")) - except Exception: - return None - runs = [] - for prompt_id, status in history.items(): - start = end = result = None - for message in (status or {}).get("messages") or []: - try: - event, data = message[0], message[1] or {} - except (IndexError, TypeError): - continue - if event == "execution_start": - start = data.get("timestamp") - elif event in RESULTS: - end, result = data.get("timestamp"), RESULTS[event] - if start and end and end >= start: - runs.append({"id": prompt_id, "ms": end - start, "end": end, "result": result}) - runs.sort(key=lambda r: r["end"], reverse=True) - return runs - - def _queue(server): try: running, pending = server.prompt_queue.get_current_queue_volatile() @@ -125,5 +94,4 @@ def collect_stats(server=None): if server is not None: stats["queue"] = _queue(server) stats["queue_items"] = queue_items(server) - stats["run_history"] = run_history(server) return stats diff --git a/rigshare_core/store.py b/rigshare_core/store.py index 5e74be9..f157fa4 100644 --- a/rigshare_core/store.py +++ b/rigshare_core/store.py @@ -446,6 +446,33 @@ class Store: items.append({"id": name[:-5], **meta}) return items + # ----- run log (Performance) ----------------------------------------- + + RUN_LOG_KEEP = 500 + + def runs(self): + """Finished runs, oldest first: [{id, ms, end, result, workflow, user, name}].""" + if not hasattr(self, "_runs"): + self._runs = self._read("runs.json", []) + return self._runs + + def add_run(self, run): + with self._lock: + runs = self.runs() + runs.append(run) + del runs[:-self.RUN_LOG_KEEP] + self._write("runs.json", runs) + + def clear_runs(self, workflow): + """Forget a workflow's timings (its ETA starts over); returns how many went.""" + with self._lock: + runs = self.runs() + kept = [r for r in runs if r.get("workflow") != workflow] + gone = len(runs) - len(kept) + self._runs = kept + self._write("runs.json", kept) + return gone + def add_snapshot(self, doc, author, room, room_name, label=None, kind=None): """``kind`` "save": taken when the file was saved; those rotate like automatic ones.""" # Sorted by name, newest first: include microseconds so saves in the same second keep their order. diff --git a/tests/python/test_server.py b/tests/python/test_server.py index e53e858..0b69d63 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -605,33 +605,44 @@ def test_freeing_memory_is_admin_only(run): run(scenario()) -def test_performance_eta_and_recent_runs(run): +def test_performance_run_log_eta_history_and_clear(run): async def scenario(): async with rig_server() as rig: - from rigshare_core.stats import queue_items, run_history - q = rig.ps.prompt_queue - - def finished(pid, start, ms, event="execution_success"): - q.history[pid] = {"status": {"status_str": "success", "completed": True, "messages": [ - ["execution_start", {"prompt_id": pid, "timestamp": start}], - [event, {"prompt_id": pid, "timestamp": start + ms}]]}} - + from rigshare_core.stats import queue_items + admin = await rig.setup_admin() + viewer = await rig.add_user("viewer") + hub, h = rig.hub, rig.http user = {"username": "alice", "display_name": "Alice"} - for i, (wf, ms, event) in enumerate([("face", 20000, "execution_success"), ("face", 30000, "execution_success"), - ("face", 5000, "execution_interrupted"), ("video", 90000, "execution_success")]): - pid = f"h{i}" - rig.hub.record_prompt(pid, user, wf) - finished(pid, 1_000_000 + i * 100_000, ms, event) - rig.hub.record_prompt("live", user, "face") - q.running.append((9, "live", {"1": {}}, {}, [])) - import time - rig.hub.run_started["live"] = time.time() - 12 - rig.hub.run_started["old"] = time.time() - 999 # finished long ago - perf = rig.hub.performance(run_history(rig.ps), rig.hub.with_owners(queue_items(rig.ps))) + def run(pid, wf, start, ms, end_event="execution_success"): + hub.record_prompt(pid, user, wf) + hub.run_event("execution_start", {"prompt_id": pid, "timestamp": start}) + hub.run_event(end_event, {"prompt_id": pid, "timestamp": start + ms}) + + run("a", "face", 1_000_000, 20000) + run("b", "face", 2_000_000, 30000) + run("c", "face", 3_000_000, 5000, "execution_interrupted") + run("d", "video", 4_000_000, 90000) + assert [(r["workflow"], r["ms"], r["result"]) for r in rig.store.runs()][-2:] == [("face", 5000, "stopped"), ("video", 90000, "done")] + + # The run in progress: its workflow's ETA from the successful runs only. + hub.record_prompt("live", user, "face") + hub.run_event("execution_start", {"prompt_id": "live", "timestamp": int(__import__("time").time() * 1000) - 12000}) + rig.ps.prompt_queue.running.append((9, "live", {"1": {}}, {}, [])) + perf = hub.performance(hub.with_owners(queue_items(rig.ps))) live = perf["running"][0] - assert live["workflow"] == "face" and live["eta_ms"] == 25000 and live["runs"] == 2, "ETA: this workflow's successful runs" - assert 11000 <= live["elapsed_ms"] <= 14000 - assert [(r["workflow"], r["ms"], r["result"]) for r in perf["recent"][:2]] == [("video", 90000, "done"), ("face", 5000, "stopped")] - assert "old" not in rig.hub.run_started, "finished runs are forgotten" + assert (live["workflow"], live["eta_ms"], live["runs"]) == ("face", 25000, 2) and 11000 <= live["elapsed_ms"] <= 15000 + assert perf["etas"] == {"face": {"ms": 25000, "runs": 2}, "video": {"ms": 90000, "runs": 1}} + assert [r["id"] for r in perf["recent"]] == ["d", "c", "b", "a"], "newest first" + + # Kept on disk, so it survives a restart. + from rigshare_core.store import Store + assert len(Store(rig.store.folder).runs()) == 4 + + hist = await (await h.get(rig.url("/rigshare/api/performance/history?workflow=face"), headers=viewer)).json() + assert hist["total"] == 3 and [r["id"] for r in hist["runs"]] == ["c", "b", "a"] + assert (await h.delete(rig.url("/rigshare/api/performance?workflow=face"), headers=viewer)).status == 403 + r = await h.delete(rig.url("/rigshare/api/performance?workflow=face"), headers=admin) + assert (await r.json())["cleared"] == 3 + assert hub.etas() == {"video": {"ms": 90000, "runs": 1}}, "the ETA starts over" run(scenario()) diff --git a/web/rigshare/files.js b/web/rigshare/files.js index 6ce1d8a..db42e0b 100644 --- a/web/rigshare/files.js +++ b/web/rigshare/files.js @@ -359,6 +359,16 @@ export function modal(title, icon, body, footer) { return { close, closed }; } +/** A yes/no question in RigShare's own modal. */ +export async function confirmModal(title, message, confirmLabel = "OK", danger = false) { + const ok = h("button", { class: `rs-btn ${danger ? "rs-btn-danger" : "rs-btn-primary"}` }, confirmLabel); + const m = modal(title, "pi-question-circle", h("p", { class: "rs-confirm-text" }, message), + [h("span", { class: "rs-grow" }), h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(false) }, "Cancel"), ok]); + ok.onclick = () => m.close(true); + setTimeout(() => ok.focus(), 30); + return !!(await m.closed); +} + /** A text prompt in RigShare's own modal, so it stacks above another RigShare modal. */ export async function promptModal(title, message, value = "") { const input = h("input", { class: "rs-input", value, maxLength: 120 }); diff --git a/web/rigshare/float.js b/web/rigshare/float.js index d55c66b..af0c4e5 100644 --- a/web/rigshare/float.js +++ b/web/rigshare/float.js @@ -1,6 +1,6 @@ // Floating cards over the canvas (the server monitor, performance): draggable, -// and with a magnet that docks them in the bottom-right corner. Docked cards -// stack upwards in the order they were created: beside the minimap and zoom +// and with a magnet that docks them in the bottom-right corner, side by side +// from right to left in the order they were created: beside the minimap and zoom // toolbar in the graph, in the corner of the App view. Dragging undocks a card. import { h, storage } from "./ui.js"; @@ -38,8 +38,9 @@ export class FloatCard { * key: storage prefix; title/icon: the header; inAppMode(): App view on screen. * Subclasses fill this.body (and this.actions) in render(). */ - constructor({ key, title, icon, inAppMode, onChange }) { + constructor({ key, title, icon, inAppMode, onChange, className = "" }) { this.key = key; + this.className = className; this.title = title; this.icon = icon; this.inAppMode = inAppMode ?? (() => false); @@ -73,9 +74,10 @@ export class FloatCard { this.magnetBtn.innerHTML = MAGNET_SVG; const head = h("div", { class: "rs-float-head" }, h("i", { class: `pi ${this.icon}` }), h("span", { class: "rs-grow" }, this.title), + ...this.headButtons(), this.magnetBtn, h("button", { class: "rs-float-close", title: "Close", onclick: () => this.toggle() }, h("i", { class: "pi pi-times" }))); - this.el = h("div", { class: "rs-float rs-panel-vars" }, head, this.body, this.actions); + this.el = h("div", { class: `rs-float rs-panel-vars ${this.className}` }, head, this.body, this.actions); let pos = null; try { pos = JSON.parse(storage.get(`${this.key}Pos`)); } catch { /* default */ } document.body.append(this.el); @@ -93,6 +95,11 @@ export class FloatCard { render() { /* subclasses */ } + /** Extra title-bar buttons, before the magnet (subclasses). */ + headButtons() { + return []; + } + setMagnet(on) { this.magnet = on; storage.set(`${this.key}Magnet`, on ? null : "0"); @@ -128,13 +135,13 @@ export class FloatCard { if (!this.el || !this.magnet) return; const a = FloatCard.anchor(this.inAppMode()); const gap = a.gap ?? GAP; - // Stack above the docked cards created before this one. - let bottom = a.bottom; + // Side by side: to the left of the docked cards created before this one. + let right = a.right; for (const c of cards) { if (c === this) break; - if (c.el && c.magnet) bottom -= c.el.offsetHeight + gap; + if (c.el && c.magnet) right -= c.el.offsetWidth + gap; } - this.place(a.right - this.el.offsetWidth, bottom - this.el.offsetHeight); + this.place(right - this.el.offsetWidth, a.bottom - this.el.offsetHeight); } place(x, y) { diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js index 434490a..993a552 100644 --- a/web/rigshare/panel.js +++ b/web/rigshare/panel.js @@ -3,7 +3,7 @@ import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js"; import { ServerWidget, PerfWidget } from "./server-widget.js"; -import { perfView } from "./perf.js"; +import { perfView, openHistory } from "./perf.js"; import { pickFolder } from "./files.js"; import { isPrivatePath } from "./sync.js"; @@ -40,7 +40,10 @@ export class RigSharePanel { this.newKey = null; this.widget = new ServerWidget(client, () => this.render(), { inAppMode: () => sync.inAppMode }); - this.perfWidget = new PerfWidget(client, () => this.render(), { inAppMode: () => sync.inAppMode }); + this.perfWidget = new PerfWidget(client, () => this.render(), { + inAppMode: () => sync.inAppMode, + workflow: () => { const wf = sync.store?.activeWorkflow; return wf ? sync.tabName(wf) : null; }, + }); this.statsAt = Date.now(); client.on("stats", () => { this.statsAt = Date.now(); }); // The Performance clock ticks between server updates (only that section redraws). @@ -647,7 +650,9 @@ export class RigSharePanel { onclick: () => this.perfWidget.toggle(), }, h("i", { class: "pi pi-window-maximize" }), perfOn ? "Floating: on" : "Pop out"); this.perfBody = h("div", { class: "rs-perf" }, ...perfView(st.performance, this.statsAt)); - return [this.section("pi-stopwatch", "Performance", this.perfBody, h("div", { class: "rs-actions" }, perfPop)), + const history = h("button", { class: "rs-btn", title: "Every recorded run", onclick: () => openHistory(this.client) }, + h("i", { class: "pi pi-history" }), "History"); + return [this.section("pi-stopwatch", "Performance", this.perfBody, h("div", { class: "rs-actions" }, history, perfPop)), this.section("pi-server", "Server", queue, ...items, h("div", { class: "rs-actions" }, pop)), gpus.length ? this.section("pi-bolt", "GPUs", ...gpus) : null].filter(Boolean); } diff --git a/web/rigshare/perf.js b/web/rigshare/perf.js index c3e0b1e..eafd78c 100644 --- a/web/rigshare/perf.js +++ b/web/rigshare/perf.js @@ -1,9 +1,10 @@ -// Performance: the run in progress against an ETA, and the last finished runs, +// Performance: the run in progress against its ETA, and the last finished runs, // server-wide. The server sends stats.performance every couple of seconds: -// {running: [{workflow, name, elapsed_ms, eta_ms}], recent: [{ms, end, result, -// workflow, name}], now}. Between updates the elapsed time keeps ticking here. +// {running: [{workflow, name, elapsed_ms, eta_ms, runs}], recent: [...], +// etas: {workflow: {ms, runs}}, now}. Between updates the clock keeps ticking here. import { h } from "./ui.js"; +import { modal } from "./files.js"; const MARKS = { done: ["✓", "ok"], stopped: ["■", "bad"], error: ["✕", "bad"] }; @@ -25,12 +26,12 @@ function ago(ms) { } /** The run in progress: clock, ETA, progress bar. `since`: ms since the stats arrived. */ -function runningBlock(run, since, compact) { +function runningBlock(run, since, { big = false } = {}) { const elapsed = run.elapsed_ms + since; const eta = run.eta_ms; const over = eta && elapsed > eta; const left = eta ? Math.round((eta - elapsed) / 1000) : null; - return h("div", { class: "rs-perf-run" }, + return h("div", { class: `rs-perf-run ${big ? "big" : ""}` }, h("div", { class: "rs-perf-top" }, h("span", { class: "rs-perf-clock" }, duration(elapsed), eta ? h("span", { class: "rs-perf-eta" }, ` / ${duration(eta, 0)}`) : null), h("span", { class: "rs-grow" }), @@ -39,29 +40,61 @@ function runningBlock(run, since, compact) { eta ? h("div", { class: `rs-perf-bar ${over ? "over" : ""}` }, h("div", { style: `width:${Math.min(100, (elapsed / eta) * 100)}%` })) : null, h("div", { class: "rs-muted rs-small rs-ellipsis" }, run.workflow || "Workflow", run.name ? ` · ${run.name}` : "", - !compact && eta ? ` · ETA from ${run.runs} run${run.runs > 1 ? "s" : ""}` : "")); + eta ? ` · ETA from ${run.runs} run${run.runs > 1 ? "s" : ""}` : "")); } -function recentRow(run, now) { +function runRow(run, now, { date = false } = {}) { const [mark, cls] = MARKS[run.result] || ["?", "bad"]; + const when = date ? new Date(run.end).toLocaleString([], { dateStyle: "short", timeStyle: "short" }) : ago(now - run.end); return h("div", { class: "rs-perf-row", title: [run.workflow, run.name, run.result].filter(Boolean).join(" · ") }, h("span", { class: `rs-perf-mark ${cls}` }, mark), h("span", { class: "rs-perf-name rs-ellipsis" }, run.workflow || "Workflow", run.name ? h("span", { class: "rs-muted" }, ` · ${run.name}`) : null), h("b", {}, duration(run.ms)), - h("span", { class: "rs-muted rs-perf-ago" }, ago(now - run.end))); + h("span", { class: "rs-muted rs-perf-ago" }, when)); } -/** Elements for the performance view. `receivedAt`: Date.now() when the stats arrived. */ -export function perfView(perf, receivedAt, { compact = false } = {}) { +/** Server tab: the run in progress and the last 5 runs. */ +export function perfView(perf, receivedAt) { if (!perf) return [h("p", { class: "rs-muted rs-small" }, "Waiting for the server…")]; const since = Date.now() - receivedAt; - const out = []; - if (perf.running.length) out.push(...perf.running.map((r) => runningBlock(r, since, compact))); - else out.push(h("div", { class: "rs-perf-idle rs-muted" }, "Idle: nothing running")); + const out = perf.running.length + ? perf.running.map((r) => runningBlock(r, since)) + : [h("div", { class: "rs-perf-idle rs-muted" }, "Idle: nothing running")]; if (perf.recent.length) { out.push(h("div", { class: "rs-perf-head rs-muted rs-small" }, "Last runs")); - out.push(...perf.recent.map((r) => recentRow(r, perf.now + since))); + out.push(...perf.recent.map((r) => runRow(r, perf.now + since))); } return out; } + +/** Floating card: only the timer. Idle, it shows how long the workflow on screen usually takes. */ +export function timerView(perf, receivedAt, workflow) { + if (!perf) return [h("p", { class: "rs-muted rs-small" }, "Waiting for the server…")]; + const since = Date.now() - receivedAt; + if (perf.running.length) return perf.running.map((r) => runningBlock(r, since, { big: true })); + const eta = workflow ? perf.etas?.[workflow] : null; + return [h("div", { class: "rs-perf-run big idle" }, + h("div", { class: "rs-perf-top" }, + h("span", { class: "rs-perf-clock rs-muted" }, eta ? duration(eta.ms) : "--:--"), + h("span", { class: "rs-grow" }), + h("span", { class: "rs-small rs-muted" }, "Idle")), + h("div", { class: "rs-muted rs-small rs-ellipsis" }, + workflow ? (eta ? `${workflow} usually takes this · ${eta.runs} run${eta.runs > 1 ? "s" : ""}` : `${workflow}: no timings yet`) + : "Open a workflow to see its usual time"))]; +} + +/** The full run history, in a window. */ +export async function openHistory(client) { + const list = h("div", { class: "rs-perf rs-perf-history" }, h("p", { class: "rs-muted" }, "Loading…")); + modal("Run history", "pi-history", list, null); + try { + const data = await client.request("GET", "/rigshare/api/performance/history?limit=200"); + list.replaceChildren(...(data.runs.length + ? [h("div", { class: "rs-muted rs-small" }, `${data.total} run${data.total === 1 ? "" : "s"}${data.total > data.runs.length ? `, the last ${data.runs.length} shown` : ""}`), + ...data.runs.map((r) => runRow(r, Date.now(), { date: true }))] + : [h("p", { class: "rs-muted" }, "No runs yet.")])); + } catch (e) { + list.replaceChildren(h("p", { class: "rs-muted" }, e.message || String(e))); + } +} diff --git a/web/rigshare/server-widget.js b/web/rigshare/server-widget.js index 233ad5d..653d7be 100644 --- a/web/rigshare/server-widget.js +++ b/web/rigshare/server-widget.js @@ -3,7 +3,8 @@ import { h } from "./ui.js"; import { FloatCard } from "./float.js"; -import { perfView } from "./perf.js"; +import { timerView } from "./perf.js"; +import { confirmModal } from "./files.js"; function mini(label, pct, text) { const p = Math.max(0, Math.min(100, pct || 0)); @@ -66,20 +67,48 @@ export class ServerWidget extends FloatCard { } } -/** Performance: the run in progress against its ETA, and the last runs. */ +/** Performance: only the timer (the history is in the Server tab). */ export class PerfWidget extends FloatCard { - constructor(client, onChange, { inAppMode } = {}) { - super({ key: "rigshare.perfFloat", title: "Performance", icon: "pi-stopwatch", inAppMode, onChange }); + constructor(client, onChange, { inAppMode, workflow } = {}) { + super({ key: "rigshare.perfFloat", title: "Performance", icon: "pi-stopwatch", inAppMode, onChange, className: "rs-float-perf" }); this.client = client; + this.workflow = workflow ?? (() => null); // name of the workflow on screen this.receivedAt = Date.now(); client.on("stats", () => { this.receivedAt = Date.now(); this.render(); }); - // The clock keeps ticking between server updates. - setInterval(() => { if (this.el && this.client.stats?.performance?.running?.length) this.render(); }, 250); + // The clock keeps ticking between server updates; the workflow on screen may change. + setInterval(() => { if (this.el) this.render(); }, 250); this.start(); } + headButtons() { + return [h("button", { + class: "rs-float-close", title: "Clear the timings of the workflow on screen", + onclick: () => this.clear(), + }, h("i", { class: "pi pi-eraser" }))]; + } + + async clear() { + const wf = this.workflow(); + if (!wf) return this.client.emit("toast", { severity: "info", summary: "RigShare", detail: "Open a workflow first." }); + const runs = this.client.stats?.performance?.etas?.[wf]?.runs; + const ok = await confirmModal("Clear timings", + `Forget every recorded run of "${wf}"? Its ETA starts over${runs ? ` (it is based on ${runs} run${runs > 1 ? "s" : ""})` : ""}.`, + "Clear", true); + if (!ok) return; + try { + const res = await this.client.request("DELETE", `/rigshare/api/performance?workflow=${encodeURIComponent(wf)}`); + this.client.emit("toast", { severity: "success", summary: "RigShare", detail: `Cleared ${res.cleared} run${res.cleared === 1 ? "" : "s"} of "${wf}"` }); + } catch (e) { + this.client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message || String(e) }); + } + } + render() { if (!this.el) return; - this.body.replaceChildren(...perfView(this.client.stats?.performance, this.receivedAt, { compact: true })); + const key = JSON.stringify([this.client.stats?.performance?.running, this.client.stats?.performance?.etas, this.workflow()]); + const running = this.client.stats?.performance?.running?.length; + if (!running && key === this.lastKey) return; // idle: nothing moves + this.lastKey = key; + this.body.replaceChildren(...timerView(this.client.stats?.performance, this.receivedAt, this.workflow())); } } diff --git a/web/rigshare/styles.js b/web/rigshare/styles.js index 33c2f5a..0b1286a 100644 --- a/web/rigshare/styles.js +++ b/web/rigshare/styles.js @@ -263,9 +263,15 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; } .rs-perf-row b { font-weight: 600; } .rs-perf-mark.ok { color: #22c55e; } .rs-perf-mark.bad { color: #f59e0b; } .rs-perf-ago { font-size: 11px; } -.rs-float .rs-perf-clock { font-size: 16px; } -.rs-float .rs-perf-row { font-size: 11px; grid-template-columns: 12px 1fr auto; } -.rs-float .rs-perf-ago { display: none; } +.rs-float-perf { width: 330px; } +.rs-perf-run.big .rs-perf-clock { font-size: 30px; } +.rs-perf-run.big .rs-perf-eta { font-size: 17px; } +.rs-perf-run.big .rs-perf-bar { height: 6px; border-radius: 3px; } +.rs-perf-run.idle .rs-perf-clock { color: var(--rs-muted); } +.rs-perf-history { max-height: 60vh; overflow-y: auto; gap: 3px; } +.rs-confirm-text { margin: 4px 0 8px; line-height: 1.5; } +.rs-btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } +.rs-btn-danger:hover { background: #b91c1c; } .rs-float-body { padding: 6px 8px 8px; display: flex; flex-direction: column; gap: 4px; } .rs-mini { display: grid; grid-template-columns: 46px 1fr 62px; align-items: center; gap: 6px; } .rs-mini-label { color: var(--rs-muted); }