From 6154ce1a03cb7d1ed773eb3060992b57d5328142 Mon Sep 17 00:00:00 2001 From: Edouard Durand Date: Sun, 27 Sep 2026 01:39:34 +0000 Subject: [PATCH] Floating monitor: magnet docking and admin memory actions The magnet docks the monitor bottom-right: beside the minimap in the graph, in the corner of the App view; dragging undocks it. Admins get Unload models and Free models & cache. /free now needs admin. --- CHANGELOG.md | 3 + docs/accounts.md | 4 +- rigshare_core/routes.py | 2 +- tests/python/helpers.py | 1 + tests/python/test_server.py | 11 ++++ web/rigshare/panel.js | 2 +- web/rigshare/server-widget.js | 110 +++++++++++++++++++++++++++++++--- web/rigshare/styles.js | 4 ++ 8 files changed, 126 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0b4a0..bb5d727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ ### Added +- 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 ### Changed +- Freeing models and cache (ComfyUI's `/free`) needs *Admin* instead of *Queue*: it affects everyone's runs - Other plugins' top-bar buttons (rgthree-comfy, Crystools...) are hidden without the *Manager* permission, like ComfyUI Manager's. Their settings kept on the server, which apply to everyone, need *Manager* too - Pixaroma's Workflows panel is hidden along with ComfyUI's Workflows and Apps tabs (admin setting now called *Hide other workflow browsers*), and its shortcut opens Files. Its server routes are refused to everyone but admins: they read the workflows folder straight from disk, private folders included diff --git a/docs/accounts.md b/docs/accounts.md index 2ad8812..e222a07 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -6,9 +6,9 @@ |---|---| | *(none)*, a viewer | Watch shared workflows, chat, follow people, open snapshots as private copies | | **Edit** | Change shared workflows, save or rename workflow files, take and restore snapshots, upload files | -| **Queue** | Queue prompts, stop or remove your own runs, free memory. Stopping or removing someone else's, or clearing the queue, needs *Admin* | +| **Queue** | Queue prompts, stop or remove your own runs. Stopping or removing someone else's, or clearing the queue, needs *Admin* | | **Manager** | Open ComfyUI Manager: install, update or remove custom nodes and models, snapshots, restart. Also other plugins' top-bar buttons and their server-wide settings (rgthree-comfy, Crystools) | -| **Admin** | Everything, plus deleting workflow files, accounts and server settings | +| **Admin** | Everything, plus deleting workflow files, freeing models and cache, accounts and server settings | Queueing, uploads, and saving or deleting workflow files are checked by the server too, when *Enforce permissions on the ComfyUI API* is on (the default). A viewer cannot get around it with a script. diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py index 593edf1..6a2194a 100644 --- a/rigshare_core/routes.py +++ b/rigshare_core/routes.py @@ -49,7 +49,7 @@ PROTECTED = [ ("POST", "/prompt", "queue"), ("POST", "/queue", "queue"), ("POST", "/interrupt", "queue"), - ("POST", "/free", "queue"), + ("POST", "/free", "admin"), # unloading models affects everyone's runs ("POST", "/upload/", "edit|queue"), ("POST", "/api/jobs", "queue"), ] diff --git a/tests/python/helpers.py b/tests/python/helpers.py index 353d405..ebbefc3 100644 --- a/tests/python/helpers.py +++ b/tests/python/helpers.py @@ -122,6 +122,7 @@ def comfy_routes(user_root, queue=None): table.route("*", prefix + "/prompt")(prompt) table.post(prefix + "/queue")(queue_route) table.post(prefix + "/interrupt")(ok) + table.post(prefix + "/free")(ok) table.route("*", prefix + "/manager/{tail:.*}")(ok) table.route("*", prefix + "/customnode/{tail:.*}")(ok) return table diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 0d07dbe..07dca3a 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -592,3 +592,14 @@ def test_manager_tab_option(run): assert (await sock.until("server"))["server"]["manager_tab"] is True await sock.close() run(scenario()) + + +def test_freeing_memory_is_admin_only(run): + async def scenario(): + async with rig_server() as rig: + admin = await rig.setup_admin() + queuer = await rig.add_user("queuer", queue=True, manager=True) + body = {"unload_models": True, "free_memory": True} + 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()) diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js index 29aae0a..6dba9a3 100644 --- a/web/rigshare/panel.js +++ b/web/rigshare/panel.js @@ -38,7 +38,7 @@ export class RigSharePanel { this.historyOpen = false; this.newKey = null; - this.widget = new ServerWidget(client, () => this.render()); + this.widget = new ServerWidget(client, () => this.render(), { inAppMode: () => sync.inAppMode }); this.header = h("div", { class: "rs-header" }); this.nav = h("div", { class: "rs-nav", role: "tablist" }); diff --git a/web/rigshare/server-widget.js b/web/rigshare/server-widget.js index d3964c2..a8ce7d0 100644 --- a/web/rigshare/server-widget.js +++ b/web/rigshare/server-widget.js @@ -1,9 +1,20 @@ // 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). 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 = ` + `; function mini(label, pct, text) { const p = Math.max(0, Math.min(100, pct || 0)); @@ -14,13 +25,26 @@ 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 { - constructor(client, onChange) { + constructor(client, onChange, { inAppMode } = {}) { 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(); } @@ -34,16 +58,25 @@ export class ServerWidget { 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.el = h("div", { class: "rs-float rs-panel-vars" }, head, this.body); + 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 */ } - this.place(pos?.x ?? window.innerWidth - 300, pos?.y ?? 70); 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() { @@ -51,13 +84,48 @@ export class ServerWidget { 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 }; + } + const minimap = box('[data-testid="minimap-container"]'); + if (minimap) return { x: minimap.left - w - GAP, y: minimap.bottom - hgt }; + // No minimap: the canvas corner, above ComfyUI's zoom controls. + const canvas = box("#graph-canvas-container") ?? box("#graph-canvas") ?? { right: window.innerWidth, bottom: window.innerHeight }; + const controls = box('[data-testid="toggle-minimap-button"]'); + const bottom = controls ? Math.min(canvas.bottom, controls.top) : canvas.bottom; + return { x: canvas.right - w - GAP, y: bottom - hgt - GAP }; + } + + 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 = `${x}px`; - this.el.style.top = `${y}px`; + this.el.style.left = `${Math.round(x)}px`; + this.el.style.top = `${Math.round(y)}px`; return { x, y }; } @@ -67,20 +135,48 @@ export class ServerWidget { e.preventDefault(); const startX = e.clientX - this.el.offsetLeft; const startY = e.clientY - this.el.offsetTop; - const move = (ev) => this.place(ev.clientX - startX, ev.clientY - startY); + 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); - storage.set(KEY_POS, JSON.stringify({ x: this.el.offsetLeft, y: this.el.offsetTop })); + if (moved) storage.set(KEY_POS, JSON.stringify({ x: this.el.offsetLeft, y: this.el.offsetTop })); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }); } + async free(options, done) { + try { + const res = 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) }); + } + } + render() { if (!this.el) return; const st = this.client.stats; + const admin = !!this.client.perms?.admin; + this.actions.style.display = admin ? "" : "none"; + if (admin && !this.actions.childElementCount) { + this.actions.append( + h("button", { + class: "rs-btn rs-btn-sm", title: "Unload the models ComfyUI is holding in memory", + onclick: () => this.free({ unload_models: true }, "Models unloaded"), + }, "Unload models"), + h("button", { + class: "rs-btn rs-btn-sm", title: "Unload the models and clear the node cache (the next run starts from scratch)", + onclick: () => this.free({ unload_models: true, free_memory: true }, "Models and cache freed"), + }, "Free models & cache")); + } if (!st) { this.body.replaceChildren(h("div", { class: "rs-muted rs-small" }, "Waiting for data…")); return; diff --git a/web/rigshare/styles.js b/web/rigshare/styles.js index c12678f..9b6dbc2 100644 --- a/web/rigshare/styles.js +++ b/web/rigshare/styles.js @@ -244,6 +244,10 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; } .rs-float-head:active { cursor: grabbing; } .rs-float-close { border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 2px; } .rs-float-close:hover { color: var(--rs-fg); } +.rs-float-magnet { display: grid; place-items: center; border-radius: 4px; } +.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-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); }