Performance: own run log, History window, timer-only floating card with clear

Runs are timed from ComfyUI's execution events and kept in runs.json (last
500), so ETAs survive restarts and a workflow's timings can be cleared. The
Server tab shows the last 5 runs and a History window; the floating card is
a bigger timer with a clear button, docked to the left of the Server card.
This commit is contained in:
Edouard Durand
2026-09-27 05:15:18 +00:00
parent 4fdbc035f4
commit 69ad03e237
14 changed files with 260 additions and 112 deletions
+2 -1
View File
@@ -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
+2
View File
@@ -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=<seq>&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 |
+1
View File
@@ -53,6 +53,7 @@ The data folder is `<ComfyUI base path>/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.
+40 -18
View File
@@ -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:
+28 -2
View File
@@ -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
-32
View File
@@ -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
+27
View File
@@ -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.
+35 -24
View File
@@ -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())
+10
View File
@@ -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 });
+15 -8
View File
@@ -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) {
+8 -3
View File
@@ -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);
}
+47 -14
View File
@@ -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)));
}
}
+36 -7
View File
@@ -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()));
}
}
+9 -3
View File
@@ -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); }