diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb5d727..f399ccc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### 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
- 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/rigshare_core/hub.py b/rigshare_core/hub.py
index 0a06d74..8f4f364 100644
--- a/rigshare_core/hub.py
+++ b/rigshare_core/hub.py
@@ -109,6 +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)
# ----- lifecycle ----------------------------------------------------
@@ -146,6 +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"])
await self.broadcast({"type": "stats", "stats": self.stats})
except Exception as e:
log.debug(f"[RigShare] stats error: {e}")
@@ -171,6 +173,32 @@ 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.
+
+ ETA: the average of the recent successful runs of the same workflow.
+ """
+ if history is None:
+ return None
+ runs = self.with_owners(history)
+ now = time.time()
+ 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]
+ 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)})
+ 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)}
+
def running_prompt_ids(self):
try:
running, _ = self.server.prompt_queue.get_current_queue_volatile()
diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py
index 6a2194a..d6a5083 100644
--- a/rigshare_core/routes.py
+++ b/rigshare_core/routes.py
@@ -877,6 +877,8 @@ 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 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 4a3720e..a9edef3 100644
--- a/rigshare_core/stats.py
+++ b/rigshare_core/stats.py
@@ -68,6 +68,37 @@ 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()
@@ -94,4 +125,5 @@ 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/tests/python/helpers.py b/tests/python/helpers.py
index ebbefc3..85d4047 100644
--- a/tests/python/helpers.py
+++ b/tests/python/helpers.py
@@ -42,6 +42,12 @@ class FakeQueue:
def get_current_queue_volatile(self):
return list(self.running), list(self.pending)
+ history = {}
+
+ def get_history(self, prompt_id=None, max_items=None, offset=-1, map_function=None):
+ items = list(self.history.items())[-(max_items or len(self.history)):] if self.history else []
+ return {k: (map_function(v) if map_function else v) for k, v in items}
+
class FakePromptServer:
def __init__(self):
diff --git a/tests/python/test_server.py b/tests/python/test_server.py
index 07dca3a..e53e858 100644
--- a/tests/python/test_server.py
+++ b/tests/python/test_server.py
@@ -603,3 +603,35 @@ def test_freeing_memory_is_admin_only(run):
assert (await rig.http.post(rig.url("/api/free"), json=body, headers=queuer)).status == 403, "affects everyone's runs"
assert (await rig.http.post(rig.url("/api/free"), json=body, headers=admin)).status == 200
run(scenario())
+
+
+def test_performance_eta_and_recent_runs(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}]]}}
+
+ 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)))
+ 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"
+ run(scenario())
diff --git a/web/rigshare/float.js b/web/rigshare/float.js
new file mode 100644
index 0000000..d55c66b
--- /dev/null
+++ b/web/rigshare/float.js
@@ -0,0 +1,170 @@
+// 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
+// toolbar in the graph, in the corner of the App view. Dragging undocks a card.
+
+import { h, storage } from "./ui.js";
+
+const GAP = 10;
+
+// Lucide "magnet" (ISC), inline: neither PrimeIcons nor ComfyUI ships one.
+const MAGNET_SVG = ``;
+
+/** Visible element's box, or null. */
+function box(selector) {
+ const el = document.querySelector(selector);
+ if (!el) return null;
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0 ? r : null;
+}
+
+/** ComfyUI's zoom toolbar: the minimap toggle's first ancestor shaped like the bar. */
+function toolbarBox() {
+ let el = document.querySelector('[data-testid="toggle-minimap-button"]');
+ while (el && el !== document.body) {
+ const r = el.getBoundingClientRect();
+ if (r.width >= 120 && r.height > 0 && r.height < 120) return r;
+ el = el.parentElement;
+ }
+ return null;
+}
+
+const cards = []; // every card, bottom of the stack first
+
+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 }) {
+ this.key = key;
+ this.title = title;
+ this.icon = icon;
+ this.inAppMode = inAppMode ?? (() => false);
+ this.onChange = onChange;
+ this.enabled = storage.get(`${key}`) === "1";
+ this.magnet = storage.get(`${key}Magnet`) !== "0"; // docked unless the user moved it
+ this.el = null;
+ cards.push(this);
+ window.addEventListener("resize", () => this.dock());
+ setInterval(() => this.dock(), 500); // panels open and close, the minimap toggles...
+ }
+
+ start() {
+ if (this.enabled) this.show();
+ }
+
+ toggle() {
+ this.enabled = !this.enabled;
+ storage.set(this.key, this.enabled ? "1" : null);
+ this.enabled ? this.show() : this.hide();
+ this.onChange?.();
+ for (const c of cards) c.dock(); // the stack closes up or opens
+ }
+
+ show() {
+ if (this.el) return;
+ this.body = h("div", { class: "rs-float-body" });
+ this.actions = h("div", { class: "rs-float-actions" });
+ this.actions.style.display = "none";
+ this.magnetBtn = h("button", { class: "rs-float-close rs-float-magnet", onclick: () => this.setMagnet(!this.magnet) });
+ 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.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);
+ let pos = null;
+ try { pos = JSON.parse(storage.get(`${this.key}Pos`)); } catch { /* default */ }
+ document.body.append(this.el);
+ this.place(pos?.x ?? window.innerWidth - 300, pos?.y ?? 70);
+ this.drag(head);
+ this.render();
+ this.paintMagnet();
+ this.dock();
+ }
+
+ hide() {
+ this.el?.remove();
+ this.el = null;
+ }
+
+ render() { /* subclasses */ }
+
+ setMagnet(on) {
+ this.magnet = on;
+ storage.set(`${this.key}Magnet`, on ? null : "0");
+ this.paintMagnet();
+ for (const c of cards) c.dock();
+ }
+
+ paintMagnet() {
+ if (!this.magnetBtn) return;
+ this.magnetBtn.classList.toggle("active", this.magnet);
+ this.magnetBtn.title = this.magnet ? "Docked: drag to undock" : "Dock in the bottom-right corner";
+ }
+
+ /** Right edge and bottom of the docking corner. */
+ static anchor(inApp) {
+ if (inApp) {
+ const area = box("#linearCenterPanel") ?? { right: window.innerWidth, bottom: window.innerHeight };
+ return { right: area.right - GAP, bottom: area.bottom - GAP };
+ }
+ const minimap = box('[data-testid="minimap-container"]');
+ const toolbar = toolbarBox();
+ if (minimap || toolbar) {
+ // Beside the minimap and zoom toolbar, bottom-aligned with the toolbar, with ComfyUI's gap.
+ const gap = minimap && toolbar ? Math.max(4, Math.round(toolbar.top - minimap.bottom)) : GAP;
+ const left = Math.min(minimap?.left ?? Infinity, toolbar?.left ?? Infinity);
+ return { right: left - gap, bottom: toolbar?.bottom ?? minimap.bottom, gap };
+ }
+ const canvas = box("#graph-canvas-container") ?? box("#graph-canvas") ?? { right: window.innerWidth, bottom: window.innerHeight };
+ return { right: canvas.right - GAP, bottom: canvas.bottom - GAP };
+ }
+
+ dock() {
+ 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;
+ for (const c of cards) {
+ if (c === this) break;
+ if (c.el && c.magnet) bottom -= c.el.offsetHeight + gap;
+ }
+ this.place(a.right - this.el.offsetWidth, bottom - this.el.offsetHeight);
+ }
+
+ place(x, y) {
+ const w = this.el.offsetWidth || 260;
+ const hgt = this.el.offsetHeight || 120;
+ x = Math.max(4, Math.min(window.innerWidth - w - 4, x));
+ y = Math.max(4, Math.min(window.innerHeight - hgt - 4, y));
+ this.el.style.left = `${Math.round(x)}px`;
+ this.el.style.top = `${Math.round(y)}px`;
+ }
+
+ drag(handle) {
+ handle.addEventListener("pointerdown", (e) => {
+ if (e.target.closest("button")) return;
+ e.preventDefault();
+ const startX = e.clientX - this.el.offsetLeft;
+ const startY = e.clientY - this.el.offsetTop;
+ let moved = false;
+ const move = (ev) => {
+ if (!moved && this.magnet) this.setMagnet(false); // moving it by hand undocks it
+ moved = true;
+ this.place(ev.clientX - startX, ev.clientY - startY);
+ };
+ const up = () => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ if (moved) storage.set(`${this.key}Pos`, JSON.stringify({ x: this.el.offsetLeft, y: this.el.offsetTop }));
+ };
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ });
+ }
+}
diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js
index 6dba9a3..434490a 100644
--- a/web/rigshare/panel.js
+++ b/web/rigshare/panel.js
@@ -2,7 +2,8 @@
// account view (profile, password, API keys) behind the avatar button.
import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js";
-import { ServerWidget } from "./server-widget.js";
+import { ServerWidget, PerfWidget } from "./server-widget.js";
+import { perfView } from "./perf.js";
import { pickFolder } from "./files.js";
import { isPrivatePath } from "./sync.js";
@@ -39,6 +40,15 @@ 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.statsAt = Date.now();
+ client.on("stats", () => { this.statsAt = Date.now(); });
+ // The Performance clock ticks between server updates (only that section redraws).
+ setInterval(() => {
+ if (this.view === "server" && this.visible && this.perfBody?.isConnected && this.client.stats?.performance?.running?.length) {
+ this.perfBody.replaceChildren(...perfView(this.client.stats.performance, this.statsAt));
+ }
+ }, 250);
this.header = h("div", { class: "rs-header" });
this.nav = h("div", { class: "rs-nav", role: "tablist" });
@@ -631,7 +641,14 @@ export class RigSharePanel {
const q = st.queue;
const queue = q ? h("div", { class: "rs-queue-line" }, h("i", { class: "pi pi-list" }),
q.running || q.pending ? `${q.running} running · ${q.pending} queued` : "Queue idle") : null;
- return [this.section("pi-server", "Server", queue, ...items, h("div", { class: "rs-actions" }, pop)),
+ const perfOn = this.perfWidget.enabled;
+ const perfPop = h("button", {
+ class: `rs-btn ${perfOn ? "active" : ""}`, title: "Show the performance card over the canvas",
+ 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)),
+ 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
new file mode 100644
index 0000000..c3e0b1e
--- /dev/null
+++ b/web/rigshare/perf.js
@@ -0,0 +1,67 @@
+// Performance: the run in progress against an 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.
+
+import { h } from "./ui.js";
+
+const MARKS = { done: ["✓", "ok"], stopped: ["■", "bad"], error: ["✕", "bad"] };
+
+export function duration(ms, decimals = 1) {
+ const total = Math.max(0, ms) / 1000;
+ const hrs = Math.floor(total / 3600);
+ const min = Math.floor((total % 3600) / 60);
+ const sec = total % 60;
+ const s = decimals > 0 ? sec.toFixed(decimals).padStart(3 + decimals, "0") : String(Math.floor(sec)).padStart(2, "0");
+ return hrs ? `${hrs}:${String(min).padStart(2, "0")}:${s}` : `${String(min).padStart(2, "0")}:${s}`;
+}
+
+function ago(ms) {
+ const s = Math.max(0, ms / 1000);
+ if (s < 60) return "just now";
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
+ return `${Math.floor(s / 86400)} d ago`;
+}
+
+/** The run in progress: clock, ETA, progress bar. `since`: ms since the stats arrived. */
+function runningBlock(run, since, compact) {
+ 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" },
+ 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" }),
+ h("span", { class: `rs-small ${over ? "rs-perf-over" : "rs-muted"}` },
+ eta ? (left >= 0 ? `~${left} s left` : `${-left} s over`) : "no ETA yet")),
+ 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" : ""}` : ""));
+}
+
+function recentRow(run, now) {
+ const [mark, cls] = MARKS[run.result] || ["?", "bad"];
+ 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)));
+}
+
+/** Elements for the performance view. `receivedAt`: Date.now() when the stats arrived. */
+export function perfView(perf, receivedAt, { compact = false } = {}) {
+ 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"));
+ 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)));
+ }
+ return out;
+}
diff --git a/web/rigshare/server-widget.js b/web/rigshare/server-widget.js
index aca6a45..233ad5d 100644
--- a/web/rigshare/server-widget.js
+++ b/web/rigshare/server-widget.js
@@ -1,20 +1,9 @@
-// Optional floating server monitor: a small draggable card over the canvas.
-//
-// The 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.
-// Admins also get ComfyUI's memory actions (unload models, free models & cache).
+// Floating cards over the canvas: the server monitor and performance. Docking,
+// dragging and the magnet come from FloatCard (float.js).
-import { h, storage } from "./ui.js";
-
-const KEY_ON = "rigshare.serverFloat";
-const KEY_POS = "rigshare.serverFloatPos";
-const KEY_MAGNET = "rigshare.serverFloatMagnet";
-const GAP = 10;
-
-// Lucide "magnet" (ISC), inline: neither PrimeIcons nor ComfyUI ships one.
-const MAGNET_SVG = ``;
+import { h } from "./ui.js";
+import { FloatCard } from "./float.js";
+import { perfView } from "./perf.js";
function mini(label, pct, text) {
const p = Math.max(0, Math.min(100, pct || 0));
@@ -25,153 +14,20 @@ function mini(label, pct, text) {
h("span", { class: "rs-mini-text" }, text));
}
-/** Visible element's box, or null. */
-function box(selector) {
- const el = document.querySelector(selector);
- if (!el) return null;
- const r = el.getBoundingClientRect();
- return r.width > 0 && r.height > 0 ? r : null;
-}
-
-export class ServerWidget {
+/** Server monitor; admins also get ComfyUI's memory actions. */
+export class ServerWidget extends FloatCard {
constructor(client, onChange, { inAppMode } = {}) {
+ super({ key: "rigshare.serverFloat", title: "Server", icon: "pi-server", inAppMode, onChange });
this.client = client;
- this.onChange = onChange;
- this.inAppMode = inAppMode ?? (() => false);
- this.enabled = storage.get(KEY_ON) === "1";
- this.magnet = storage.get(KEY_MAGNET) !== "0"; // docked unless the user moved it
- this.el = null;
client.on("stats", () => this.render());
client.on("self", () => this.render());
- window.addEventListener("resize", () => this.dock());
- setInterval(() => this.dock(), 500); // panels open and close, the minimap toggles...
- if (this.enabled) this.show();
- }
-
- toggle() {
- this.enabled = !this.enabled;
- storage.set(KEY_ON, this.enabled ? "1" : null);
- this.enabled ? this.show() : this.hide();
- this.onChange?.();
- }
-
- show() {
- if (this.el) return;
- this.body = h("div", { class: "rs-float-body" });
- this.magnetBtn = h("button", {
- class: "rs-float-close rs-float-magnet", title: "Dock in the bottom-right corner",
- onclick: () => this.setMagnet(!this.magnet),
- });
- this.magnetBtn.innerHTML = MAGNET_SVG;
- const head = h("div", { class: "rs-float-head" },
- h("i", { class: "pi pi-server" }), h("span", { class: "rs-grow" }, "Server"),
- this.magnetBtn,
- h("button", { class: "rs-float-close", title: "Close", onclick: () => this.toggle() }, h("i", { class: "pi pi-times" })));
- this.actions = h("div", { class: "rs-float-actions" });
- this.el = h("div", { class: "rs-float rs-panel-vars" }, head, this.body, this.actions);
- let pos = null;
- try { pos = JSON.parse(storage.get(KEY_POS)); } catch { /* default */ }
- document.body.append(this.el);
- this.place(pos?.x ?? window.innerWidth - 300, pos?.y ?? 70);
- this.drag(head);
- this.render();
- this.paintMagnet();
- this.dock();
- }
-
- hide() {
- this.el?.remove();
- this.el = null;
- }
-
- setMagnet(on) {
- this.magnet = on;
- storage.set(KEY_MAGNET, on ? null : "0");
- this.paintMagnet();
- this.dock();
- }
-
- paintMagnet() {
- this.magnetBtn?.classList.toggle("active", this.magnet);
- if (this.magnetBtn) this.magnetBtn.title = this.magnet ? "Docked: drag to undock" : "Dock in the bottom-right corner";
- }
-
- /** The docked position: beside the minimap in the graph, the corner of the App view. */
- dockTarget() {
- const w = this.el.offsetWidth;
- const hgt = this.el.offsetHeight;
- if (this.inAppMode()) {
- const area = box("#linearCenterPanel") ?? { right: window.innerWidth, bottom: window.innerHeight };
- return { x: area.right - w - GAP, y: area.bottom - hgt - GAP };
- }
- // ComfyUI's bottom-right group: the minimap above the zoom toolbar. Sit to
- // their left, bottom-aligned with the toolbar, with the gap ComfyUI uses.
- const minimap = box('[data-testid="minimap-container"]');
- const toolbar = this.toolbarBox();
- if (minimap || toolbar) {
- const left = Math.min(minimap?.left ?? Infinity, toolbar?.left ?? Infinity);
- const bottom = toolbar?.bottom ?? minimap.bottom;
- const gap = minimap && toolbar ? Math.max(4, Math.round(toolbar.top - minimap.bottom)) : GAP;
- return { x: left - w - gap, y: bottom - hgt };
- }
- const canvas = box("#graph-canvas-container") ?? box("#graph-canvas") ?? { right: window.innerWidth, bottom: window.innerHeight };
- return { x: canvas.right - w - GAP, y: canvas.bottom - hgt - GAP };
- }
-
- /** The zoom toolbar holding the minimap toggle: the button's first ancestor wide enough to be the bar. */
- toolbarBox() {
- let el = document.querySelector('[data-testid="toggle-minimap-button"]');
- while (el && el !== document.body) {
- const r = el.getBoundingClientRect();
- if (r.width >= 120 && r.height > 0 && r.height < 120) return r;
- el = el.parentElement;
- }
- return null;
- }
-
- dock() {
- if (!this.el || !this.magnet) return;
- const { x, y } = this.dockTarget();
- this.place(x, y);
- }
-
- place(x, y) {
- const w = this.el.offsetWidth || 260;
- const hgt = this.el.offsetHeight || 120;
- x = Math.max(4, Math.min(window.innerWidth - w - 4, x));
- y = Math.max(4, Math.min(window.innerHeight - hgt - 4, y));
- this.el.style.left = `${Math.round(x)}px`;
- this.el.style.top = `${Math.round(y)}px`;
- return { x, y };
- }
-
- drag(handle) {
- handle.addEventListener("pointerdown", (e) => {
- if (e.target.closest("button")) return;
- e.preventDefault();
- const startX = e.clientX - this.el.offsetLeft;
- const startY = e.clientY - this.el.offsetTop;
- let moved = false;
- const move = (ev) => {
- if (!moved && this.magnet) this.setMagnet(false); // moving it by hand undocks it
- moved = true;
- this.place(ev.clientX - startX, ev.clientY - startY);
- };
- const up = () => {
- window.removeEventListener("pointermove", move);
- window.removeEventListener("pointerup", up);
- if (moved) storage.set(KEY_POS, JSON.stringify({ x: this.el.offsetLeft, y: this.el.offsetTop }));
- };
- window.addEventListener("pointermove", move);
- window.addEventListener("pointerup", up);
- });
+ this.start();
}
async free(options, done) {
try {
- const res = await this.client.request("POST", "/api/free", options);
+ await this.client.request("POST", "/api/free", options);
this.client.emit("toast", { severity: "success", summary: "RigShare", detail: done });
- return res;
} catch (e) {
this.client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message || String(e) });
}
@@ -209,3 +65,21 @@ export class ServerWidget {
this.body.replaceChildren(...rows);
}
}
+
+/** Performance: the run in progress against its ETA, and the last runs. */
+export class PerfWidget extends FloatCard {
+ constructor(client, onChange, { inAppMode } = {}) {
+ super({ key: "rigshare.perfFloat", title: "Performance", icon: "pi-stopwatch", inAppMode, onChange });
+ this.client = client;
+ 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);
+ this.start();
+ }
+
+ render() {
+ if (!this.el) return;
+ this.body.replaceChildren(...perfView(this.client.stats?.performance, this.receivedAt, { compact: true }));
+ }
+}
diff --git a/web/rigshare/styles.js b/web/rigshare/styles.js
index 9b6dbc2..33c2f5a 100644
--- a/web/rigshare/styles.js
+++ b/web/rigshare/styles.js
@@ -248,6 +248,24 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-float-magnet.active { color: var(--rs-accent); }
.rs-float-actions { display: flex; gap: 4px; padding: 0 8px 8px; flex-wrap: wrap; }
.rs-float-actions .rs-btn { flex: 1; justify-content: center; font-size: 11px; padding: 3px 6px; }
+.rs-perf { display: flex; flex-direction: column; gap: 4px; }
+.rs-perf-run { display: flex; flex-direction: column; gap: 4px; padding: 6px 8px; border-radius: 8px; background: var(--rs-bg2); }
+.rs-perf-top { display: flex; align-items: baseline; gap: 6px; }
+.rs-perf-clock { font: 600 20px/1.1 ui-monospace, "SF Mono", Consolas, monospace; font-variant-numeric: tabular-nums; color: #60a5fa; }
+.rs-perf-eta { font-size: 13px; font-weight: 500; color: var(--rs-muted); }
+.rs-perf-over { color: #f59e0b; }
+.rs-perf-bar { height: 4px; border-radius: 2px; background: var(--rs-bg); overflow: hidden; }
+.rs-perf-bar > div { height: 100%; background: #60a5fa; transition: width .25s linear; }
+.rs-perf-bar.over > div { background: #f59e0b; }
+.rs-perf-idle { padding: 4px 0; font-size: 12px; }
+.rs-perf-head { margin-top: 4px; }
+.rs-perf-row { display: grid; grid-template-columns: 14px 1fr auto auto; gap: 6px; align-items: baseline; font-size: 12px; font-variant-numeric: tabular-nums; }
+.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-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); }