Performance: run in progress vs ETA and last runs, in the Server tab and as a floating card
Floating cards share one base (float.js) with magnet docking; docked cards stack in the corner. Durations come from ComfyUI's history timestamps, the start of the current run from the execution_start event.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"><path d="m6 15-4-4 6.75-6.77a7.79 7.79 0 0 1 11 11L13 22l-4-4 6.39-6.36a2.14 2.14 0 0 0-3-3L6 15"/>
|
||||
<path d="m5 8 4 4"/><path d="m12 15 4 4"/></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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+28
-154
@@ -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 = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"><path d="m6 15-4-4 6.75-6.77a7.79 7.79 0 0 1 11 11L13 22l-4-4 6.39-6.36a2.14 2.14 0 0 0-3-3L6 15"/>
|
||||
<path d="m5 8 4 4"/><path d="m12 15 4 4"/></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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user