Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1befdca2e7 | ||
|
|
d68307cde4 | ||
|
|
13b9350a50 | ||
|
|
3797fdf57e | ||
|
|
70e0af8493 | ||
|
|
77575fa6b2 | ||
|
|
69ad03e237 | ||
|
|
4fdbc035f4 | ||
|
|
b9aadf496b | ||
|
|
6154ce1a03 | ||
|
|
02dfd79523 | ||
|
|
27e4fd37f1 |
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.1 (2026-10-08)
|
||||
|
||||
Security hardening of the workspace folder routes, mainly for Windows servers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Folder rename, move, delete and create (and the workspace listing) could be pointed outside the workflows folder with a Windows drive prefix (`C:`), which let `rmtree` / `rename` act on folders such as models or custom_nodes. Every path is now checked before it touches the disk: segments with a colon, backslash tricks or `..` are refused, and the resolved path must stay inside the workflows folder
|
||||
- On Windows, case and aliases of a path were not seen by the access checks: `Users/alice`, `users./alice` or `USERS~1/alice` opened Alice's private folder as if it were common, and `Shared/<restricted folder>` skipped its access list. Paths are now matched to the name on disk and trailing dots, spaces and 8.3 short names are dropped before any permission check
|
||||
- New usernames and folder names can no longer end in a dot or space, start with a dot, or be a Windows device name (`CON`, `NUL`, `COM1`...). Usernames that differ only by case count as the same name, since they share one folder on Windows. Existing accounts and folders are untouched
|
||||
|
||||
### Added
|
||||
|
||||
- Tests for the JWT module, the server stats, and the folder routes refusing paths outside the workspace
|
||||
|
||||
## 1.3.0 (2026-10-04)
|
||||
|
||||
A Performance panel, a floating monitor that docks, and Manager in its own tab.
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
### 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deleting a workflow (or a folder) from the Files tab now closes its open tabs; before, the tab stayed open as an orphan
|
||||
|
||||
## 1.2.3 (2026-09-27)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -15,6 +15,14 @@ A custom node that turns a single ComfyUI server into a shared workspace. Everyo
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="docs/images/live-cursors.gif" width="900" alt="Two people editing the same workflow at once, each seeing the other's named cursor and selection">
|
||||
|
||||
<sub>Two accounts, one workflow: each person sees the other's cursor, selection and edits live.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
## What it does
|
||||
|
||||
- asks everyone to sign in before ComfyUI loads, and creates the admin account on first run
|
||||
|
||||
+3
-3
@@ -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* |
|
||||
| **Manager** | Open ComfyUI Manager: install, update or remove custom nodes and models, snapshots, restart |
|
||||
| **Admin** | Everything, plus deleting workflow files, accounts and server settings |
|
||||
| **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, 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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
|
||||
| `guest_permissions` | `{"edit": false, "queue": false}` | What guests may do |
|
||||
| `broadcast_execution` | `true` | Send progress and previews to everyone, not only whoever queued |
|
||||
| `hide_comfy_account` | `false` | Hide ComfyUI's Comfy.org sign-in button and dialog |
|
||||
| `hide_comfy_file_tabs` | `true` | Hide ComfyUI's own Workflows and Apps sidebar tabs; RigShare's Files tab replaces them and their shortcuts open it |
|
||||
| `hide_comfy_file_tabs` | `true` | Hide other workflow browsers: ComfyUI's Workflows and Apps sidebar tabs and Pixaroma's Workflows panel. RigShare's Files tab replaces them and their shortcuts open it |
|
||||
| `manager_tab` | `false` | Show ComfyUI Manager as a Manager tab in the left sidebar instead of a top-bar button (only for accounts with *Manager*) |
|
||||
| `hide_api_templates` | `false` | Remove templates that need paid API nodes from the template browser |
|
||||
| `api_protection.enabled` | `true` | Server-side checks on queue, interrupt, free and upload calls |
|
||||
| `api_protection.trusted_ips` | `["127.0.0.1", "::1"]` | Addresses or networks that skip login and checks |
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-rigshare"
|
||||
description = "Turn one ComfyUI server into a shared team workspace. Sign-in with accounts, permissions (edit, queue, Manager, admin) and personal API keys, enforced by the server. A private folder for each person plus shared folders with access lists, with Save asking where. Live co-editing of every shared workflow, with cursors, follow mode and conflict-free merges, in the graph or App view. Per-workflow history with a snapshot on every save, team chat with its whole history, and a CPU, RAM, GPU and queue monitor."
|
||||
version = "1.2.3"
|
||||
version = "1.3.1"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
@@ -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 -> ms timestamp 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["queue_items"])
|
||||
await self.broadcast({"type": "stats", "stats": self.stats})
|
||||
except Exception as e:
|
||||
log.debug(f"[RigShare] stats error: {e}")
|
||||
@@ -171,6 +173,54 @@ class Hub:
|
||||
return None
|
||||
return [{**it, **(self.queue_owners.get(it["id"]) or {})} for it in items]
|
||||
|
||||
RESULTS = {"execution_success": "done", "execution_error": "error", "execution_interrupted": "stopped"}
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
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": 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 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:
|
||||
running, _ = self.server.prompt_queue.get_current_queue_volatile()
|
||||
@@ -417,6 +467,7 @@ class Hub:
|
||||
return {"name": cfg.get("server_name"), "allow_guests": cfg.get("allow_guests"),
|
||||
"hide_comfy_account": bool(cfg.get("hide_comfy_account")),
|
||||
"hide_comfy_file_tabs": bool(cfg.get("hide_comfy_file_tabs", True)),
|
||||
"manager_tab": bool(cfg.get("manager_tab")),
|
||||
"hide_api_templates": bool(cfg.get("hide_api_templates"))}
|
||||
|
||||
async def broadcast_server(self):
|
||||
|
||||
+45
-1
@@ -33,12 +33,23 @@ LOGIN_WATCH = """<script id="rigshare-login-watch">(() => {
|
||||
};
|
||||
})();</script>"""
|
||||
|
||||
# Other plugins' server routes RigShare guards: (method or None, path prefix,
|
||||
# permission, why). Settings they keep on the server apply to everyone, like
|
||||
# ComfyUI Manager, so they need the same "manager" permission; Pixaroma's workflow
|
||||
# browser reads the workflows folder straight from disk, private folders included.
|
||||
PLUGIN_RULES = [
|
||||
("POST", "/rgthree/api/", "manager", "changing rgthree-comfy's settings needs the 'manager' permission"),
|
||||
("POST", "/crystools/", "manager", "changing Crystools' settings needs the 'manager' permission"),
|
||||
(None, "/pixaroma/api/workflows", "admin",
|
||||
"Pixaroma's workflow browser shows every folder, private ones included; use RigShare's Files tab"),
|
||||
]
|
||||
|
||||
# (method, path) prefixes that need a permission when api_protection is on.
|
||||
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"),
|
||||
]
|
||||
@@ -380,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"]])])
|
||||
@@ -798,6 +832,11 @@ def setup(server, store, hub):
|
||||
"details": "", "extra_info": {}}, "node_errors": {}},
|
||||
status=403)
|
||||
break
|
||||
for method, prefix, need, why in PLUGIN_RULES:
|
||||
if (method is None or request.method == method) and api_path(request).startswith(prefix):
|
||||
if not trusted(request.remote or "") and not perms_for(request).get(need) and not perms_for(request).get("admin"):
|
||||
return web.json_response({"error": f"RigShare: {why}"}, status=403)
|
||||
break
|
||||
refusal = await check_queue_ownership(request)
|
||||
if refusal:
|
||||
return web.json_response({"error": f"RigShare: {refusal}"}, status=403)
|
||||
@@ -861,6 +900,11 @@ def setup(server, store, hub):
|
||||
original_send_sync = server.send_sync
|
||||
|
||||
def send_sync(event, data, sid=None):
|
||||
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
|
||||
|
||||
+36
-3
@@ -24,7 +24,7 @@ SESSION_TTL = 30 * 24 * 3600 # a login lasts at most this long
|
||||
TOKEN_TTL = 12 * 3600 # each JWT is short-lived...
|
||||
TOKEN_REFRESH = 6 * 3600 # ...and re-issued while in use once older than this
|
||||
ISSUER = "rigshare"
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{2,32}$")
|
||||
USERNAME_RE = re.compile(r"^(?!(?i:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))[A-Za-z0-9_][A-Za-z0-9_.-]{0,30}[A-Za-z0-9_-]\Z") # no leading/trailing dot, no Windows device names
|
||||
PERMS = ("edit", "queue", "manager", "admin")
|
||||
API_KEY_PREFIX = "rs_"
|
||||
|
||||
@@ -49,6 +49,8 @@ DEFAULT_CONFIG = {
|
||||
"hide_comfy_account": False,
|
||||
# ComfyUI's own Workflows and Apps sidebar tabs duplicate RigShare's Files tab.
|
||||
"hide_comfy_file_tabs": True,
|
||||
# ComfyUI Manager in its own sidebar tab instead of the top-bar button.
|
||||
"manager_tab": False,
|
||||
# Remove templates that need paid API nodes from the template browser.
|
||||
"hide_api_templates": False,
|
||||
# Rooms nobody opened for this many days are forgotten.
|
||||
@@ -214,10 +216,14 @@ class Store:
|
||||
_verify_password(password, _hash_password("x", b"0" * 16))
|
||||
return None
|
||||
|
||||
def _name_taken(self, name, ignore=None):
|
||||
"""Names differing only by case share one folder on Windows, so they count as the same."""
|
||||
return any(u.casefold() == name.casefold() for u in self.users if u != ignore)
|
||||
|
||||
def create_user(self, username, password, display_name=None, perms=None):
|
||||
if not USERNAME_RE.match(username or ""):
|
||||
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
|
||||
if username in self.users:
|
||||
if self._name_taken(username):
|
||||
raise ValueError("Username already exists")
|
||||
if len(password or "") < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
@@ -237,7 +243,7 @@ class Store:
|
||||
raise KeyError(username)
|
||||
if not USERNAME_RE.match(new_username):
|
||||
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
|
||||
if new_username in self.users:
|
||||
if self._name_taken(new_username, ignore=username):
|
||||
raise ValueError("Username already exists")
|
||||
with self._lock:
|
||||
user = self.users.pop(username)
|
||||
@@ -444,6 +450,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.
|
||||
|
||||
@@ -20,7 +20,7 @@ import time
|
||||
from urllib.parse import unquote
|
||||
|
||||
WORKFLOWS = "workflows"
|
||||
FOLDER_NAME_RE = re.compile(r"^[\w][\w .()-]{0,63}$")
|
||||
FOLDER_NAME_RE = re.compile(r"^(?!(?i:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))\w(?:[\w .()-]{0,62}[\w()-])?\Z") # no trailing dot/space, no Windows device names
|
||||
ROLE_RANK = {None: 0, "view": 1, "edit": 2}
|
||||
|
||||
|
||||
@@ -35,10 +35,60 @@ def full_unquote(value):
|
||||
return value
|
||||
|
||||
|
||||
_WIN = os.name == "nt"
|
||||
_SHORT_NAME_RE = re.compile(r"~\d") # Windows 8.3 alias of a long name (USERS~1)
|
||||
_case_root = None # workflows directory used to restore on-disk casing (Windows only)
|
||||
|
||||
|
||||
def _on_disk_case(parts, base):
|
||||
"""Windows ignores case: map each segment to the casing it has on disk, so 'Users/Bob' is
|
||||
judged as the same path it will open."""
|
||||
out = []
|
||||
for part in parts:
|
||||
try:
|
||||
names = os.listdir(base) if base else []
|
||||
except OSError:
|
||||
names = []
|
||||
match = part if part in names else next((n for n in names if n.casefold() == part.casefold()), part)
|
||||
out.append(match)
|
||||
base = os.path.join(base, match) if base and match in names else None
|
||||
return out
|
||||
|
||||
|
||||
def norm(path):
|
||||
"""Decode and normalise a userdata-relative path ('' for the root)."""
|
||||
"""Decode and normalise a userdata-relative path ('' for the root).
|
||||
|
||||
Segments Windows would read as something else are dropped, like '..': a colon (drive prefix
|
||||
'C:', NTFS stream), a 8.3 short name, and trailing dots or spaces (Windows strips them, so
|
||||
'users./bob' would open 'users/bob'). Casing is restored from disk on Windows. Anything that
|
||||
still reaches the disk goes through safe_join().
|
||||
"""
|
||||
path = full_unquote(str(path or "")).replace("\\", "/")
|
||||
return posixpath.normpath("/" + path).lstrip("/") if path else ""
|
||||
if not path:
|
||||
return ""
|
||||
clean = posixpath.normpath("/" + path).lstrip("/")
|
||||
parts = [p for p in clean.split("/")
|
||||
if p and ":" not in p and not _SHORT_NAME_RE.search(p) and (not _WIN or p == p.rstrip(" ."))]
|
||||
if _WIN and _case_root and parts:
|
||||
lead = [WORKFLOWS] if parts[0].casefold() == WORKFLOWS else []
|
||||
parts = lead + _on_disk_case(parts[len(lead):], _case_root)
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def safe_join(root, *parts):
|
||||
"""Join relative parts under root; refuse anything that resolves outside it."""
|
||||
segments = [p for part in parts for p in str(part).replace("\\", "/").split("/") if p]
|
||||
if any(":" in p or p in (".", "..") or (_WIN and p != p.rstrip(" .")) for p in segments):
|
||||
raise PermissionError("Invalid path")
|
||||
full = os.path.join(root, *segments)
|
||||
base = os.path.realpath(root)
|
||||
try:
|
||||
inside = os.path.commonpath([base, os.path.realpath(full)]) == base
|
||||
except ValueError: # different drives
|
||||
inside = False
|
||||
if not inside:
|
||||
raise PermissionError("Invalid path")
|
||||
return full
|
||||
|
||||
|
||||
def has_app(doc):
|
||||
@@ -56,6 +106,8 @@ class Workspace:
|
||||
def __init__(self, store, root):
|
||||
self.store = store
|
||||
self.root = root # absolute path of the workflows directory
|
||||
global _case_root
|
||||
_case_root = root
|
||||
self.folders = store._read("folders.json", {}) # "shared/<name>" -> {owner, acl, created}
|
||||
os.makedirs(os.path.join(root, "users"), exist_ok=True)
|
||||
os.makedirs(os.path.join(root, "shared"), exist_ok=True)
|
||||
@@ -147,7 +199,7 @@ class Workspace:
|
||||
return self.role(rel, kind, key, perms) is not None
|
||||
|
||||
def _files(self, rel_dir):
|
||||
base = os.path.join(self.root, *rel_dir.split("/")) if rel_dir else self.root
|
||||
base = safe_join(self.root, rel_dir) if rel_dir else self.root
|
||||
out = []
|
||||
if not os.path.isdir(base):
|
||||
return out
|
||||
@@ -198,7 +250,7 @@ class Workspace:
|
||||
# ----- browsing (Files tab) -------------------------------------------
|
||||
|
||||
def _abs(self, rel):
|
||||
return os.path.join(self.root, *[p for p in rel.split("/") if p])
|
||||
return safe_join(self.root, rel)
|
||||
|
||||
def _file_has_app(self, full):
|
||||
"""Read a workflow file only when it changed since the last check."""
|
||||
@@ -372,7 +424,7 @@ class Workspace:
|
||||
name = (name or "").strip()
|
||||
if not FOLDER_NAME_RE.match(name) or name in (".", ".."):
|
||||
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
|
||||
path = os.path.join(self.root, "shared", name)
|
||||
path = safe_join(self.root, "shared", name)
|
||||
if os.path.exists(path):
|
||||
raise ValueError("A folder with that name already exists")
|
||||
os.makedirs(path)
|
||||
@@ -385,8 +437,8 @@ class Workspace:
|
||||
if not FOLDER_NAME_RE.match(new_name):
|
||||
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
|
||||
new_folder = f"shared/{new_name}"
|
||||
src = os.path.join(self.root, *folder.split("/"))
|
||||
dst = os.path.join(self.root, "shared", new_name)
|
||||
src = safe_join(self.root, folder)
|
||||
dst = safe_join(self.root, "shared", new_name)
|
||||
if os.path.exists(dst):
|
||||
raise ValueError("A folder with that name already exists")
|
||||
os.rename(src, dst)
|
||||
@@ -395,7 +447,7 @@ class Workspace:
|
||||
return new_folder
|
||||
|
||||
def delete_folder(self, folder):
|
||||
path = os.path.join(self.root, *folder.split("/"))
|
||||
path = safe_join(self.root, folder)
|
||||
if self._files(folder):
|
||||
raise ValueError("Move or delete the workflows inside it first")
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
@@ -128,3 +128,37 @@ test("a file renamed by someone else re-points the open tab and rejoins its room
|
||||
sync.tick();
|
||||
assert.equal(sent.at(-1).room, null, "moved out of reach: the tab leaves the room");
|
||||
});
|
||||
|
||||
test("deleting a folder closes its tabs, the active one through ComfyUI's close", async () => {
|
||||
const a = { path: "workflows/team/a.json", isTemporary: false, isModified: true };
|
||||
const b = { path: "workflows/team/sub/b.json", isTemporary: false };
|
||||
const other = { path: "workflows/other.json", isTemporary: false };
|
||||
const store = {
|
||||
activeWorkflow: a, openWorkflows: [a, b, other],
|
||||
closeWorkflow(wf) { this.openWorkflows = this.openWorkflows.filter((w) => w !== wf); },
|
||||
};
|
||||
const commands = [];
|
||||
const client = Object.assign(new EventTarget(), {
|
||||
online: true, perms: { edit: true }, rooms: [], users: [],
|
||||
on(type, fn) { this.addEventListener(type, (e) => fn(e.detail)); },
|
||||
emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); },
|
||||
send() { return true; },
|
||||
});
|
||||
const app = {
|
||||
extensionManager: { workflow: store, command: { async execute(id) {
|
||||
commands.push([id, store.activeWorkflow.isModified]);
|
||||
store.closeWorkflow(store.activeWorkflow);
|
||||
store.activeWorkflow = store.openWorkflows[0];
|
||||
} } },
|
||||
canvas: null, loadGraphData: async () => {},
|
||||
rootGraph: { serialize: () => ({ nodes: [], links: [], extra: {} }), extra: {}, getNodeById: () => null },
|
||||
};
|
||||
const sync = new RoomSync(app, new EventTarget(), client);
|
||||
const toasts = [];
|
||||
client.on("toast", (t) => toasts.push(t));
|
||||
await sync.closeTabsUnder("workflows/team");
|
||||
assert.deepEqual(store.openWorkflows, [other]);
|
||||
assert.deepEqual(commands, [["Workspace.CloseWorkflow", false]], "no unsaved-changes prompt");
|
||||
client.emit("room_closed", { room: "file:workflows/team/a.json", name: "a" });
|
||||
assert.equal(toasts.length, 0, "our own delete: no private-copy notice");
|
||||
});
|
||||
|
||||
@@ -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):
|
||||
@@ -122,6 +128,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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""HS256 tokens: round trip, tampering, expiry, issuer, algorithm confusion."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from rigshare_core import jwt
|
||||
|
||||
SECRET = b"s3cret"
|
||||
|
||||
|
||||
def claims(**extra):
|
||||
return {"iss": "rig", "exp": time.time() + 60, **extra}
|
||||
|
||||
|
||||
def b64(obj):
|
||||
return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def test_round_trip():
|
||||
token = jwt.encode(claims(sub="bob"), SECRET)
|
||||
assert jwt.decode(token, SECRET, "rig")["sub"] == "bob"
|
||||
|
||||
|
||||
def test_wrong_secret_and_tampered_payload():
|
||||
token = jwt.encode(claims(sub="bob"), SECRET)
|
||||
with pytest.raises(jwt.InvalidToken):
|
||||
jwt.decode(token, b"other", "rig")
|
||||
head, _, sig = token.split(".")
|
||||
forged = f"{head}.{b64(claims(sub='admin'))}.{sig}"
|
||||
with pytest.raises(jwt.InvalidToken):
|
||||
jwt.decode(forged, SECRET, "rig")
|
||||
|
||||
|
||||
def test_expiry_leeway_and_not_before():
|
||||
with pytest.raises(jwt.InvalidToken):
|
||||
jwt.decode(jwt.encode({"iss": "rig", "exp": time.time() - 3600}, SECRET), SECRET, "rig")
|
||||
jwt.decode(jwt.encode({"iss": "rig", "exp": time.time() - 5}, SECRET), SECRET, "rig") # inside leeway
|
||||
assert jwt.decode(jwt.encode({"iss": "rig", "exp": time.time() - 3600}, SECRET), SECRET, "rig", verify_exp=False)
|
||||
with pytest.raises(jwt.InvalidToken):
|
||||
jwt.decode(jwt.encode(claims(nbf=time.time() + 3600), SECRET), SECRET, "rig")
|
||||
|
||||
|
||||
def test_wrong_issuer():
|
||||
with pytest.raises(jwt.InvalidToken):
|
||||
jwt.decode(jwt.encode(claims(), SECRET), SECRET, "someone-else")
|
||||
|
||||
|
||||
def test_alg_none_is_refused():
|
||||
token = f"{b64({'alg': 'none', 'typ': 'JWT'})}.{b64(claims())}."
|
||||
with pytest.raises(jwt.InvalidToken):
|
||||
jwt.decode(token, SECRET, "rig")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("junk", ["", "a.b", "a.b.c.d", "!!!.???.***", "e30.e30.e30"])
|
||||
def test_malformed_tokens(junk):
|
||||
with pytest.raises(jwt.InvalidToken):
|
||||
jwt.decode(junk, SECRET, "rig")
|
||||
@@ -546,3 +546,123 @@ def test_file_changes_reach_who_can_see_them(run):
|
||||
assert await changed(b), "moves"
|
||||
await a.close(); await b.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_other_plugins_server_routes(run):
|
||||
async def scenario():
|
||||
async def ok(request):
|
||||
from aiohttp import web
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
def routes(app):
|
||||
for path in ("/rgthree/api/config", "/crystools/monitor/switch", "/pixaroma/api/workflows/index",
|
||||
"/pixaroma/api/workflows/folder", "/pixaroma/api/fonts/list"):
|
||||
app.router.add_route("*", path, ok)
|
||||
|
||||
async with rig_server(setup=routes) as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True, queue=True)
|
||||
manager = await rig.add_user("mgr", manager=True)
|
||||
h = rig.http
|
||||
|
||||
async def status(method, path, who):
|
||||
return (await h.request(method, rig.url(path), headers=who)).status
|
||||
|
||||
# Server-wide plugin settings: like ComfyUI Manager.
|
||||
assert await status("POST", "/rgthree/api/config", alice) == 403
|
||||
assert await status("POST", "/crystools/monitor/switch", alice) == 403
|
||||
assert await status("POST", "/rgthree/api/config", manager) == 200
|
||||
assert await status("GET", "/rgthree/api/config", alice) == 200, "reading is fine"
|
||||
# Pixaroma's workflow browser reads every folder from disk: admins only.
|
||||
assert await status("GET", "/pixaroma/api/workflows/index", alice) == 403
|
||||
assert await status("POST", "/pixaroma/api/workflows/folder", manager) == 403
|
||||
assert await status("GET", "/pixaroma/api/workflows/index", admin) == 200
|
||||
assert await status("GET", "/pixaroma/api/fonts/list", alice) == 200, "the rest of Pixaroma works"
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_manager_tab_option(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
sock = await rig.ws("boss")
|
||||
assert rig.hub.server_info()["manager_tab"] is False, "off by default"
|
||||
r = await rig.http.patch(rig.url("/rigshare/api/config"), json={"manager_tab": True}, headers=admin)
|
||||
assert r.status == 200
|
||||
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())
|
||||
|
||||
|
||||
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
|
||||
admin = await rig.setup_admin()
|
||||
viewer = await rig.add_user("viewer")
|
||||
hub, h = rig.hub, rig.http
|
||||
user = {"username": "alice", "display_name": "Alice"}
|
||||
|
||||
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"], 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())
|
||||
|
||||
|
||||
def test_folder_routes_refuse_paths_outside_the_workspace(run, tmp_path):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
await rig.add_user("alice", edit=True)
|
||||
outside = tmp_path / "models"
|
||||
outside.mkdir()
|
||||
(outside / "keep.bin").write_bytes(b"x")
|
||||
H, h = rig.headers, rig.http
|
||||
targets = [str(outside), "C:" + str(outside)[2:], r"..\..\models", "shared/C:/evil"]
|
||||
for route, extra in (("delete-folder", {}), ("rename-folder", {"name": "x"}), ("move-folder", {"dest": "@shared"})):
|
||||
for target in targets:
|
||||
r = await h.post(rig.url(f"/rigshare/api/workspace/{route}"), json={"path": target, **extra}, headers=H["alice"])
|
||||
assert r.status in (400, 403, 404), (route, target, r.status)
|
||||
r = await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "C:/", "name": "x"}, headers=H["alice"])
|
||||
assert r.status in (400, 403, 404)
|
||||
assert (outside / "keep.bin").exists()
|
||||
run(scenario())
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Server stats: every metric is optional and never raises."""
|
||||
|
||||
from rigshare_core import stats
|
||||
|
||||
|
||||
class Queue:
|
||||
def __init__(self, running, pending):
|
||||
self.running, self.pending = running, pending
|
||||
|
||||
def get_current_queue_volatile(self):
|
||||
return self.running, self.pending
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(self, queue):
|
||||
self.prompt_queue = queue
|
||||
|
||||
|
||||
def test_queue_items_order_and_node_count():
|
||||
server = Server(Queue([(1, "r1", {"1": {}, "2": {}})], [(5, "p5", {"1": {}}), (3, "p3", {})]))
|
||||
items = stats.queue_items(server)
|
||||
assert [(i["id"], i["status"], i["nodes"]) for i in items] == [("r1", "running", 2), ("p3", "pending", 0), ("p5", "pending", 1)]
|
||||
|
||||
|
||||
def test_queue_items_survive_odd_entries_and_missing_queue():
|
||||
assert stats.queue_items(Server(Queue([(1,)], [(9,)]))) == []
|
||||
assert stats.queue_items(object()) is None
|
||||
|
||||
|
||||
def test_queue_counts_with_fallback():
|
||||
assert stats._queue(Server(Queue([1], [2, 3]))) == {"running": 1, "pending": 2}
|
||||
|
||||
class Old:
|
||||
def get_tasks_remaining(self):
|
||||
return 4
|
||||
assert stats._queue(Server(Old())) == {"running": 0, "pending": 4}
|
||||
assert stats._queue(object()) is None
|
||||
|
||||
|
||||
def test_collect_stats_without_psutil_or_gpu(monkeypatch):
|
||||
monkeypatch.setattr(stats, "psutil", None)
|
||||
monkeypatch.setattr(stats, "pynvml", None)
|
||||
out = stats.collect_stats(Server(Queue([], [])))
|
||||
assert out["gpus"] == [] and "cpu" not in out and out["queue"] == {"running": 0, "pending": 0}
|
||||
assert stats.collect_stats() == {"gpus": []}
|
||||
@@ -131,3 +131,62 @@ def test_rename_user_moves_private_folder(ws):
|
||||
ws.rename_user("alice", "alicia")
|
||||
assert os.path.exists(os.path.join(ws.root, "users", "alicia", "keep.json"))
|
||||
assert ws.folders["shared/Team"]["owner"] == "alicia"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["C:/models", "C:", "a/D:x/b", r"..\..\x", "c%3A%5Cmodels"])
|
||||
def test_norm_drops_drive_segments(raw):
|
||||
assert ":" not in norm(raw) and ".." not in norm(raw).split("/")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["C:/models", "C:", "..", "a/../..", "x:y"])
|
||||
def test_safe_join_refuses_escapes(ws, bad):
|
||||
from rigshare_core.workspace import safe_join
|
||||
with pytest.raises(PermissionError):
|
||||
safe_join(ws.root, bad)
|
||||
|
||||
|
||||
def test_folder_ops_cannot_leave_the_workspace(ws, tmp_path):
|
||||
outside = tmp_path / "models"
|
||||
outside.mkdir()
|
||||
(outside / "keep.txt").write_text("x")
|
||||
# A colon segment (drive prefix) is refused outright; a plain absolute path is only ever
|
||||
# read relative to the workflows root (a leading slash is dropped), so it can't reach `outside`.
|
||||
for bad in ("C:" + str(outside)[2:], "shared/C:evil"):
|
||||
with pytest.raises(PermissionError):
|
||||
ws.delete_folder(bad)
|
||||
with pytest.raises(PermissionError):
|
||||
ws.rename_folder(bad, "renamed")
|
||||
for plain in (str(outside), str(outside).replace("\\", "/")):
|
||||
try:
|
||||
ws.delete_folder(plain)
|
||||
except (PermissionError, ValueError):
|
||||
pass
|
||||
assert (outside / "keep.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["users./bob/a.json", "users /bob", "USERS~1/bob", "users/bob./a.json"])
|
||||
def test_norm_drops_windows_aliases(raw):
|
||||
if os.name != "nt" and ("." in raw.split("/")[0] or " " in raw.split("/")[0]):
|
||||
pytest.skip("trailing dots and spaces only alias on Windows")
|
||||
assert "~1" not in norm(raw)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="case-insensitive filesystem")
|
||||
def test_case_variants_cannot_bypass_private_folders(ws):
|
||||
touch(ws, "users/alice/a.json")
|
||||
assert norm("Users/Alice/a.json") == "users/alice/a.json"
|
||||
assert norm("Workflows/USERS/ALICE") == "workflows/users/alice"
|
||||
assert ws.role(norm("USERS/alice/a.json"), "user", "bob", EDITOR) is None
|
||||
|
||||
|
||||
def test_name_rules():
|
||||
from rigshare_core.workspace import FOLDER_NAME_RE
|
||||
from rigshare_core.store import USERNAME_RE
|
||||
for bad in ("con", "NUL", "com1.txt", "art.", "art ", "bad\n"):
|
||||
assert not FOLDER_NAME_RE.match(bad), bad
|
||||
for bad in ("..", "bob.", ".bob", "con", "aux", "ab\n", "a"):
|
||||
assert not USERNAME_RE.match(bad), bad
|
||||
for ok in ("Art (v2)", "a", "my-folder"):
|
||||
assert FOLDER_NAME_RE.match(ok), ok
|
||||
for ok in ("bob", "al.ice", "x_y-z", "ab"):
|
||||
assert USERNAME_RE.match(ok), ok
|
||||
|
||||
+18
-2
@@ -310,10 +310,15 @@ export class FilesBrowser {
|
||||
async remove(e) {
|
||||
const what = e.type === "folder" ? `the folder "${e.name}"` : `"${e.name}"`;
|
||||
if (!(await this.confirm("Delete", `Delete ${what}? This cannot be undone.`))) return;
|
||||
// Its open tabs close first (ComfyUI would otherwise keep them as orphans).
|
||||
const path = `workflows/${e.path}`;
|
||||
const closing = this.sync.tabsUnder(path).map((wf) => `file:${wf.path}`);
|
||||
await this.sync.closeTabsUnder(path);
|
||||
let done;
|
||||
if (e.type === "folder") {
|
||||
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/delete-folder", { path: e.path }), "Deleted");
|
||||
done = await this.run(() => this.client.request("POST", "/rigshare/api/workspace/delete-folder", { path: e.path }), "Deleted");
|
||||
} else {
|
||||
await this.run(async () => {
|
||||
done = await this.run(async () => {
|
||||
const store = this.app.extensionManager.workflow;
|
||||
await store.syncWorkflows?.();
|
||||
const wf = store.getWorkflowByPath?.(`workflows/${e.path}`);
|
||||
@@ -325,6 +330,7 @@ export class FilesBrowser {
|
||||
}
|
||||
}, "Deleted");
|
||||
}
|
||||
if (!done) for (const key of closing) this.sync.denied.delete(key); // still there: reopening it rejoins
|
||||
await this.refreshComfy();
|
||||
this.reload();
|
||||
}
|
||||
@@ -359,6 +365,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 });
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Floating cards over the canvas (the server monitor, performance): draggable,
|
||||
// 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";
|
||||
|
||||
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, className = "" }) {
|
||||
this.key = key;
|
||||
this.className = className;
|
||||
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.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 ${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);
|
||||
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 */ }
|
||||
|
||||
/** Extra title-bar buttons, before the magnet (subclasses). */
|
||||
headButtons() {
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
// 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) right -= c.el.offsetWidth + gap;
|
||||
}
|
||||
this.place(right - this.el.offsetWidth, a.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,61 @@ function installManagerGuard(client) {
|
||||
client.on("self", apply);
|
||||
client.on("status", apply);
|
||||
setTimeout(apply, 2000); // Manager registers its commands during setup
|
||||
// Other plugins put their buttons in the same place as ComfyUI Manager (just
|
||||
// before ComfyUI's settings group in the top bar): tag them so they are hidden
|
||||
// the same way without the "manager" permission. Their server-side settings
|
||||
// are guarded too (PLUGIN_RULES on the server).
|
||||
const tagPluginButtons = () => {
|
||||
const settings = app.menu?.settingsGroup?.element;
|
||||
const bar = settings?.parentElement;
|
||||
if (!bar) return;
|
||||
for (const group of bar.children) {
|
||||
if (group !== settings && group.classList.contains("comfyui-button-group")) group.classList.add("rs-plugin-bar");
|
||||
}
|
||||
};
|
||||
setInterval(tagPluginButtons, 1000);
|
||||
}
|
||||
|
||||
// ComfyUI Manager in its own sidebar tab (admin option): its menu and the custom
|
||||
// nodes manager open from there, and the top-bar button goes away.
|
||||
const MANAGER_TAB_ID = "rigshare-manager";
|
||||
function installManagerTab(client) {
|
||||
let registered = false;
|
||||
const run = (id) => app.extensionManager?.command?.execute(id);
|
||||
const panel = h("div", { class: "rs-panel rs-manager-panel" },
|
||||
h("div", { class: "rs-header" },
|
||||
h("div", { class: "rs-title" }, h("i", { class: "pi pi-th-large rs-files-logo" }),
|
||||
h("div", { class: "rs-title-text" }, h("div", { class: "rs-title-name" }, "ComfyUI Manager"),
|
||||
h("div", { class: "rs-status" }, "Custom nodes, models, updates")))),
|
||||
h("div", { class: "rs-body" },
|
||||
h("section", { class: "rs-section" },
|
||||
h("button", { class: "rs-btn rs-btn-primary rs-block-btn", onclick: () => run("Comfy.Manager.Menu.ToggleVisibility") },
|
||||
h("i", { class: "pi pi-th-large" }), "Open Manager"),
|
||||
h("p", { class: "rs-muted rs-small" }, "Install missing nodes, update everything, the model manager, snapshots and restart."),
|
||||
h("button", { class: "rs-btn rs-block-btn", onclick: () => run("Comfy.Manager.CustomNodesManager.ToggleVisibility") },
|
||||
h("i", { class: "pi pi-server" }), "Custom Nodes Manager"),
|
||||
h("p", { class: "rs-muted rs-small" }, "Browse, install, update, disable or remove custom nodes."))));
|
||||
const apply = () => {
|
||||
const manager = app.extensionManager;
|
||||
const hasManager = (manager?.command?.commands ?? []).some((c) => c.id === "Comfy.Manager.Menu.ToggleVisibility");
|
||||
const want = !!client.server?.manager_tab && client.online && !!client.perms.manager && hasManager;
|
||||
document.body.classList.toggle("rs-manager-tab", !!client.server?.manager_tab);
|
||||
if (want && !registered) {
|
||||
manager.registerSidebarTab({
|
||||
id: MANAGER_TAB_ID, icon: "pi pi-th-large", title: "Manager", tooltip: "ComfyUI Manager",
|
||||
label: "Manager", type: "custom",
|
||||
render: (el) => { el.classList.add("rs-host"); el.replaceChildren(panel); },
|
||||
});
|
||||
registered = true;
|
||||
} else if (!want && registered) {
|
||||
(manager.unregisterSidebarTab ?? manager.sidebarTab?.unregisterSidebarTab)?.(MANAGER_TAB_ID);
|
||||
registered = false;
|
||||
}
|
||||
};
|
||||
client.on("server", apply);
|
||||
client.on("self", apply);
|
||||
client.on("status", apply);
|
||||
setTimeout(apply, 2500); // Manager registers its commands during setup
|
||||
}
|
||||
|
||||
// Comfy.org account: hide the top-bar sign-in button and refuse the sign-in
|
||||
@@ -139,6 +194,15 @@ function installFileTabsGuard(client) {
|
||||
const apply = () => {
|
||||
if (!client.server) return; // wait for the server's settings
|
||||
const hide = hidden();
|
||||
document.body.classList.toggle("rs-hide-file-browsers", hide);
|
||||
// Pixaroma's workflow browser lists every folder from disk, private ones
|
||||
// included (the server refuses it to non-admins): its shortcut opens Files.
|
||||
const pix = (manager()?.command?.commands ?? []).find((c) => c.id === "Pixaroma.OpenWorkflowBrowser");
|
||||
if (pix && !pix.__rigshare) {
|
||||
const original = pix.function;
|
||||
pix.function = (...args) => (hidden() ? openFiles() : original(...args));
|
||||
pix.__rigshare = true;
|
||||
}
|
||||
for (const id of COMFY_FILE_TABS) {
|
||||
const tab = tabs().find((t) => t.id === id);
|
||||
// Open workflow tabs can be shown in the Workflows sidebar tab: keep it then.
|
||||
@@ -308,6 +372,7 @@ app.registerExtension({
|
||||
installManagerGuard(client);
|
||||
installComfyAccountGuard(client);
|
||||
installFileTabsGuard(client);
|
||||
installManagerTab(client);
|
||||
installSaveDialog(app, api, client, sync);
|
||||
presence.install();
|
||||
|
||||
|
||||
+29
-4
@@ -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, openHistory } from "./perf.js";
|
||||
import { pickFolder } from "./files.js";
|
||||
import { isPrivatePath } from "./sync.js";
|
||||
|
||||
@@ -38,7 +39,19 @@ 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.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).
|
||||
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, this.perfWidget.workflow()));
|
||||
}
|
||||
}, 250);
|
||||
|
||||
this.header = h("div", { class: "rs-header" });
|
||||
this.nav = h("div", { class: "rs-nav", role: "tablist" });
|
||||
@@ -631,7 +644,16 @@ 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, this.perfWidget.workflow()));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -834,6 +856,7 @@ export class RigSharePanel {
|
||||
protect: box(cfg.api_protection?.enabled),
|
||||
hideAccount: box(cfg.hide_comfy_account),
|
||||
hideFileTabs: box(cfg.hide_comfy_file_tabs ?? true),
|
||||
managerTab: box(cfg.manager_tab),
|
||||
hideApiTemplates: box(cfg.hide_api_templates),
|
||||
trusted: text((cfg.api_protection?.trusted_ips || []).join(", "), "127.0.0.1, 172.17.0.0/16"),
|
||||
interval: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: Math.round((cfg.snapshot_interval_sec ?? 300) / 60) }),
|
||||
@@ -852,6 +875,7 @@ export class RigSharePanel {
|
||||
broadcast_execution: f.broadcast.checked,
|
||||
hide_comfy_account: f.hideAccount.checked,
|
||||
hide_comfy_file_tabs: f.hideFileTabs.checked,
|
||||
manager_tab: f.managerTab.checked,
|
||||
hide_api_templates: f.hideApiTemplates.checked,
|
||||
api_protection: { enabled: f.protect.checked, trusted_ips: f.trusted.value.split(/[\s,]+/).filter(Boolean) },
|
||||
snapshot_interval_sec: Math.max(0, +f.interval.value || 0) * 60,
|
||||
@@ -866,7 +890,8 @@ export class RigSharePanel {
|
||||
line(f.broadcast, "Share execution progress", "Everyone sees progress and previews, not only whoever queued"),
|
||||
line(f.protect, "Enforce permissions on the ComfyUI API", "Blocks queueing, interrupting and uploads for users without permission"),
|
||||
line(f.hideAccount, "Hide the Comfy.org account login", "Removes ComfyUI's own sign-in button and dialog (used for paid API nodes)"),
|
||||
line(f.hideFileTabs, "Hide ComfyUI's Workflows and Apps tabs", "RigShare's Files tab replaces them. Their shortcuts open Files instead"),
|
||||
line(f.managerTab, "ComfyUI Manager in its own sidebar tab", "A Manager tab in the left bar replaces the top-bar button, for accounts with the Manager permission"),
|
||||
line(f.hideFileTabs, "Hide other workflow browsers", "ComfyUI's Workflows and Apps tabs, and Pixaroma's Workflows panel: RigShare's Files tab replaces them and their shortcuts open Files"),
|
||||
line(f.hideApiTemplates, "Hide API templates", "Removes templates that need paid API nodes from the template browser"),
|
||||
h("label", { class: "rs-label" }, "Trusted IPs / networks (skip login and checks)"), f.trusted,
|
||||
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Auto snapshot every (min, 0 = off)"), f.interval),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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, 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"] };
|
||||
|
||||
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, { big = false, workflow = null } = {}) {
|
||||
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;
|
||||
const mine = !!workflow && run.workflow === workflow; // the workflow on screen: yellow clock
|
||||
return h("div", { class: `rs-perf-run ${big ? "big" : ""} ${mine ? "mine" : ""}` },
|
||||
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}` : "",
|
||||
eta ? ` · ETA from ${run.runs} run${run.runs > 1 ? "s" : ""}` : ""));
|
||||
}
|
||||
|
||||
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" }, when));
|
||||
}
|
||||
|
||||
/** Server tab: the run in progress and the last 5 runs. */
|
||||
export function perfView(perf, receivedAt, workflow = null) {
|
||||
if (!perf) return [h("p", { class: "rs-muted rs-small" }, "Waiting for the server…")];
|
||||
const since = Date.now() - receivedAt;
|
||||
const out = perf.running.length
|
||||
? perf.running.map((r) => runningBlock(r, since, { workflow }))
|
||||
: [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) => 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, workflow }));
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// Optional floating server monitor: a small draggable card over the canvas.
|
||||
// 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";
|
||||
import { h } from "./ui.js";
|
||||
import { FloatCard } from "./float.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));
|
||||
@@ -14,73 +15,41 @@ function mini(label, pct, text) {
|
||||
h("span", { class: "rs-mini-text" }, text));
|
||||
}
|
||||
|
||||
export class ServerWidget {
|
||||
constructor(client, onChange) {
|
||||
/** 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.enabled = storage.get(KEY_ON) === "1";
|
||||
this.el = null;
|
||||
client.on("stats", () => this.render());
|
||||
if (this.enabled) this.show();
|
||||
client.on("self", () => this.render());
|
||||
this.start();
|
||||
}
|
||||
|
||||
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" });
|
||||
const head = h("div", { class: "rs-float-head" },
|
||||
h("i", { class: "pi pi-server" }), h("span", { class: "rs-grow" }, "Server"),
|
||||
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);
|
||||
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.drag(head);
|
||||
this.render();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
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`;
|
||||
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;
|
||||
const move = (ev) => 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 }));
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
async free(options, done) {
|
||||
try {
|
||||
await this.client.request("POST", "/api/free", options);
|
||||
this.client.emit("toast", { severity: "success", summary: "RigShare", detail: done });
|
||||
} 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;
|
||||
@@ -97,3 +66,49 @@ export class ServerWidget {
|
||||
this.body.replaceChildren(...rows);
|
||||
}
|
||||
}
|
||||
|
||||
/** Performance: only the timer (the history is in the Server tab). */
|
||||
export class PerfWidget extends FloatCard {
|
||||
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; 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;
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--
|
||||
/* Comfy.org account sign-in hidden by the admin */
|
||||
body.rs-hide-comfy-account [data-testid="login-button"],
|
||||
body.rs-hide-comfy-account [data-testid="login-button-popover"] { display: none !important; }
|
||||
/* Other plugins' top-bar buttons (rgthree-comfy, Crystools...) hidden like ComfyUI Manager */
|
||||
body.rs-no-manager .rs-plugin-bar { display: none !important; }
|
||||
/* Pixaroma's workflow browser button: RigShare's Files tab replaces it */
|
||||
body.rs-hide-file-browsers .pixwb-group-btn { display: none !important; }
|
||||
/* ComfyUI Manager moved to its own sidebar tab (admin option): no top-bar button */
|
||||
body.rs-manager-tab .comfyui-button-group:has(> [title="ComfyUI Manager"]),
|
||||
body.rs-manager-tab [title="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-block-btn { width: 100%; justify-content: center; padding: 9px 12px; margin-top: 4px; }
|
||||
/* ComfyUI Manager hidden for accounts without the "manager" permission */
|
||||
body.rs-no-manager .comfyui-button-group:has(> [title="ComfyUI Manager"]),
|
||||
body.rs-no-manager [title="ComfyUI Manager"],
|
||||
@@ -236,6 +244,36 @@ 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-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-run.mine .rs-perf-clock { color: #eab308; }
|
||||
.rs-perf-run.mine .rs-perf-bar:not(.over) > div { background: #eab308; }
|
||||
.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-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); }
|
||||
|
||||
@@ -141,6 +141,7 @@ export class RoomSync extends EventTarget {
|
||||
});
|
||||
client.on("room_closed", (msg) => {
|
||||
// The file was deleted: the tab stays open as a private copy.
|
||||
if (this.denied.has(msg.room) && !this.findTab({ key: msg.room })) return; // we deleted it and closed the tab
|
||||
this.denied.add(msg.room);
|
||||
if (msg.room === this.targetKey) {
|
||||
this.ready = false;
|
||||
@@ -524,6 +525,27 @@ export class RoomSync extends EventTarget {
|
||||
!wf.isTemporary && (wf.path === path || wf.path?.startsWith(path + "/")));
|
||||
}
|
||||
|
||||
/**
|
||||
* We are deleting a file (or folder): close its tabs first, without the
|
||||
* "unsaved changes" prompt (the user already confirmed the delete). The
|
||||
* active tab goes through ComfyUI's own close, which opens the next tab.
|
||||
*/
|
||||
async closeTabsUnder(path) {
|
||||
const store = this.store;
|
||||
const tabs = this.tabsUnder(path);
|
||||
if (!tabs.length) return;
|
||||
for (const wf of tabs) {
|
||||
this.denied.add(`file:${wf.path}`); // our own delete: no "private copy" notice
|
||||
if (wf === store.activeWorkflow) continue;
|
||||
store.closeWorkflow?.(wf);
|
||||
}
|
||||
const active = store.activeWorkflow;
|
||||
if (active && tabs.includes(active)) {
|
||||
active.isModified = false;
|
||||
await this.app.extensionManager?.command?.execute("Workspace.CloseWorkflow");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Someone renamed or moved a file (or folder) we have open: point our tabs
|
||||
* at the new path. The file already moved on the server, so ComfyUI's own
|
||||
|
||||
Reference in New Issue
Block a user