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.
This commit is contained in:
Edouard Durand
2026-09-27 01:39:34 +00:00
parent 02dfd79523
commit 6154ce1a03
8 changed files with 126 additions and 11 deletions
+3
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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"),
]
+1
View File
@@ -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
+11
View File
@@ -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())
+1 -1
View File
@@ -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" });
+103 -7
View File
@@ -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 = `<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>`;
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;
+4
View File
@@ -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); }