Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6041a1d72a | ||
|
|
3de3cc32e2 | ||
|
|
0b315f6647 | ||
|
|
1befdca2e7 | ||
|
|
d68307cde4 | ||
|
|
13b9350a50 | ||
|
|
3797fdf57e | ||
|
|
70e0af8493 | ||
|
|
77575fa6b2 | ||
|
|
69ad03e237 | ||
|
|
4fdbc035f4 | ||
|
|
b9aadf496b | ||
|
|
6154ce1a03 | ||
|
|
02dfd79523 | ||
|
|
27e4fd37f1 | ||
|
|
e123dc9279 | ||
|
|
a75443d8bb | ||
|
|
6e2324a7c3 | ||
|
|
137051a135 | ||
|
|
78415ebf25 | ||
|
|
c94b90133a | ||
|
|
5373c85b86 | ||
|
|
1f38a22535 | ||
|
|
531fec2ec9 | ||
|
|
91f3383e86 | ||
|
|
2b1b3b11ce | ||
|
|
7e10db82df | ||
|
|
5e943f3e60 | ||
|
|
f4131ac6f5 | ||
|
|
d3cef4a6ca |
@@ -0,0 +1,74 @@
|
||||
"""Print a version's CHANGELOG.md section as plain text for the Comfy Registry.
|
||||
|
||||
The registry shows a version's changelog as one paragraph: line breaks and
|
||||
Markdown are not rendered. So headings become "Added:", "Fixed:"..., list
|
||||
items are joined with "; ", and Markdown markup is dropped.
|
||||
|
||||
python .github/scripts/registry_changelog.py 1.2.2
|
||||
python .github/scripts/registry_changelog.py --all # JSON {version: text}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def sections(path="CHANGELOG.md"):
|
||||
out, current = {}, None
|
||||
for line in open(path, encoding="utf-8").read().splitlines():
|
||||
m = re.match(r"^## (\d+\.\d+\.\d+)\b", line)
|
||||
if m:
|
||||
current = m.group(1)
|
||||
out[current] = []
|
||||
elif line.startswith("## "):
|
||||
current = None
|
||||
elif current:
|
||||
out[current].append(line)
|
||||
return out
|
||||
|
||||
|
||||
def plain(text):
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # links: keep the text
|
||||
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
||||
text = re.sub(r"(?<!\w)\*([^*]+)\*(?!\w)", r"\1", text)
|
||||
return text.replace("`", "").strip()
|
||||
|
||||
|
||||
def registry_text(lines):
|
||||
parts, heading, items, intro = [], None, [], []
|
||||
|
||||
def flush():
|
||||
if items:
|
||||
body = "; ".join(i.rstrip(".") for i in items) + "."
|
||||
parts.append(f"{heading}: {body}" if heading else body)
|
||||
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("### "):
|
||||
flush()
|
||||
heading, items = plain(line[4:]), []
|
||||
elif line.startswith("- "):
|
||||
items.append(plain(line[2:]))
|
||||
elif items:
|
||||
items[-1] += " " + plain(line) # a wrapped list item
|
||||
else:
|
||||
intro.append(plain(line))
|
||||
flush()
|
||||
return " ".join(intro + parts).strip()
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
all_sections = sections()
|
||||
if sys.argv[1:] == ["--all"]:
|
||||
print(json.dumps({v: registry_text(lines) for v, lines in all_sections.items()}, ensure_ascii=False))
|
||||
elif len(sys.argv) == 2:
|
||||
print(registry_text(all_sections.get(sys.argv[1], [])))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,7 +14,25 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
- name: Changelog for this version
|
||||
# comfy-cli sends COMFY_NODE_CHANGELOG as the version's changelog (the
|
||||
# registry's Updates section), as plain text: the registry shows one paragraph.
|
||||
run: |
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1)
|
||||
notes=$(python3 .github/scripts/registry_changelog.py "$version")
|
||||
if [ -z "$notes" ]; then
|
||||
echo "::warning::No CHANGELOG.md section for $version"
|
||||
exit 0
|
||||
fi
|
||||
delimiter="EOF_$(date +%s%N)"
|
||||
{
|
||||
echo "COMFY_NODE_CHANGELOG<<$delimiter"
|
||||
echo "$notes"
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "Changelog for $version: $notes"
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
skip_checkout: "true"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Update registry changelogs
|
||||
# Fills in the Comfy Registry changelog of versions already published, from the
|
||||
# matching CHANGELOG.md sections. Run it by hand (Actions -> Run workflow).
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
versions:
|
||||
description: "Versions to update, space separated (empty: every version in CHANGELOG.md)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Update changelogs
|
||||
env:
|
||||
# The registry's edit endpoint takes a registry account key; the publishing
|
||||
# token is tried when no REGISTRY_UPDATE_TOKEN secret is set.
|
||||
TOKEN: ${{ secrets.REGISTRY_UPDATE_TOKEN || secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
VERSIONS: ${{ inputs.versions }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, re, sys, urllib.error, urllib.request
|
||||
|
||||
API = "https://api.comfy.org"
|
||||
publisher = re.search(r'^PublisherId\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
|
||||
node = re.search(r'^name\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
|
||||
|
||||
import subprocess
|
||||
# Plain text: the registry shows the changelog as one paragraph.
|
||||
notes = json.loads(subprocess.check_output(
|
||||
[sys.executable, ".github/scripts/registry_changelog.py", "--all"], text=True, encoding="utf-8"))
|
||||
|
||||
with urllib.request.urlopen(f"{API}/nodes/{node}/versions") as r:
|
||||
published = json.load(r)
|
||||
ids = {v["version"]: v["id"] for v in published}
|
||||
wanted = os.environ.get("VERSIONS", "").split() or sorted(notes)
|
||||
|
||||
failed = False
|
||||
for version in wanted:
|
||||
if version not in ids or not notes.get(version):
|
||||
print(f"skip {version}: {'not published' if version not in ids else 'no CHANGELOG section'}")
|
||||
continue
|
||||
req = urllib.request.Request(
|
||||
f"{API}/publishers/{publisher}/nodes/{node}/versions/{ids[version]}",
|
||||
data=json.dumps({"changelog": notes[version]}).encode(), method="PUT",
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {os.environ['TOKEN']}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
print(f"updated {version}: HTTP {r.status}")
|
||||
except urllib.error.HTTPError as e:
|
||||
failed = True
|
||||
print(f"::error::{version}: HTTP {e.code} {e.read()[:300]!r}")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -1,5 +1,112 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.0 (2026-10-25)
|
||||
|
||||
Models can leave the GPU on their own once the server is idle.
|
||||
|
||||
### Added
|
||||
|
||||
- Admin option *Unload models after idle*: once nothing has run for that many minutes, RigShare unloads the models, as the *Unload models* button does, so the VRAM is free for other programs. Off by default (`auto_unload_minutes`: `0`). A run or a waiting prompt restarts the clock, and it unloads once per idle period
|
||||
- With *Also clear the cache when unloading* (`auto_unload_cache`), it clears ComfyUI's cache too, like *Free models & cache*: RAM is freed as well, but the next run computes every node again
|
||||
|
||||
## 1.3.3 (2026-10-08)
|
||||
|
||||
Login and request hardening, mainly for servers behind a reverse proxy.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A reverse proxy on the same machine (nginx, Caddy, a tunnel) made every visitor look like `127.0.0.1`, which is a trusted address by default, so login and API protection were skipped for everyone. Behind a trusted proxy, trust now follows the visitor's own address (the last `X-Forwarded-For` / `X-Real-IP` entry), and a `Forwarded` header RigShare cannot read means no trust
|
||||
- Login throttling counted only the address, so users behind one proxy locked each other out, one account could be guessed from many addresses, and the counters never shrank. It now limits per visitor address (5 failures a minute) and per account (10 a minute across all addresses), answers with `Retry-After`, and forgets old entries. People already signed in are never affected. Failed logins are logged
|
||||
|
||||
### Added
|
||||
|
||||
- Requests that carry the session cookie and an `Origin` from another site are refused: writes and the live connection (hostnames compared, so a proxy that drops the port or sets `X-Forwarded-Host` still works). API clients without a cookie are not affected
|
||||
- The login page is sent with `X-Frame-Options: DENY`, a `frame-ancestors 'none'` policy, `nosniff` and a same-origin referrer policy, so another site cannot frame it
|
||||
|
||||
## 1.3.2 (2026-10-08)
|
||||
|
||||
Access rules tightened, following the review of the ComfyUI Manager listing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Snapshots of a workflow could be listed and read by an account with no access to its folder whenever nobody had that workflow open. Snapshot access now follows the workflow's folder (private and restricted shared folders included), open or not
|
||||
- Joining a live workflow seeded a new room from the caller's document before the access check. Access is checked first, and only someone who can edit the workflow can open a new room
|
||||
- Windows path segments that end in a dot or space, or use an 8.3 short name, are now refused instead of being silently dropped, so a path can no longer be classified as a different folder than the one Windows opens
|
||||
- After signing in, the login page only follows its `next` redirect when it resolves to the same origin
|
||||
|
||||
## 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
|
||||
|
||||
- The Files tab didn't show a workflow you had just saved into the folder on screen until you left the folder and came back. It now refreshes whenever workflows are saved, moved, renamed or deleted, or folders change, including by other people (each person is only told about folders they can open)
|
||||
- When the session had ended (logged out elsewhere, expired, cookie cleared), a ComfyUI page that was already open showed "API key missing" errors instead of going back to the login screen. It now returns to the login screen as soon as a request says login is required, even if ComfyUI had not finished loading
|
||||
|
||||
## 1.2.2 (2026-09-26)
|
||||
|
||||
A Queue tab showing who launched what, and chat clean-up for admins.
|
||||
|
||||
### Added
|
||||
|
||||
- **Queue** tab: the run in progress and the ones waiting, each with the workflow's name, who queued it and when. Stop or remove your own runs from there; admins can do it for anyone's. The server enforces it too: stopping or removing someone else's prompt, or clearing the whole queue, needs *Admin*
|
||||
- Admins can clear the whole chat history (*Admin → Chat*, or `DELETE /rigshare/api/chat`). Everyone's chat empties at once, followed by a notice saying who cleared it
|
||||
|
||||
### Changed
|
||||
|
||||
- Panel tabs in a new order: Live, Chat, People, Queue, Server, Admin
|
||||
- A fuller description on the Comfy Registry
|
||||
|
||||
### Fixed
|
||||
|
||||
- Entering the App view closed an open RigShare or Files panel (going back to the graph kept it open). It now stays open both ways
|
||||
- The Comfy Registry showed no changelog: the publish workflow now sends the matching `CHANGELOG.md` section
|
||||
- The registry logo was a clipped render with only one rounded corner
|
||||
|
||||
## 1.2.1 (2026-09-26)
|
||||
|
||||
### Changed
|
||||
|
||||
- App mode: the **RigShare** and **Files** buttons open their tabs in ComfyUI's docked side panel, like Assets and Apps, instead of a floating drawer
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching between the App view and the graph with the RigShare or Files panel open left the panel blank
|
||||
|
||||
## 1.2.0 (2026-09-26)
|
||||
|
||||
ComfyUI's App mode works with RigShare, and every save keeps a snapshot.
|
||||
|
||||
@@ -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
|
||||
@@ -25,13 +33,14 @@ A custom node that turns a single ComfyUI server into a shared workspace. Everyo
|
||||
- lets you switch tabs freely, because only the tab on screen syncs, and keeps open tabs pointed at a workflow when someone renames or moves it
|
||||
- shows who is online and which workflow they are on, plus live cursors and selections
|
||||
- lets you follow someone, jumping to their tab and view
|
||||
- keeps the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound
|
||||
- keeps the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound; admins can clear it
|
||||
- shows the queue: what is running and waiting, and who launched each run, with a button to stop or remove your own
|
||||
- keeps per-workflow history: a snapshot on every save and every few minutes, named snapshots, one-click restore
|
||||
- works in ComfyUI's App mode: everyone picks their own graph or App view, App inputs sync live, and you can see who is in which view
|
||||
- gives each account permissions to edit, queue, use ComfyUI Manager or administer, enforced by the server and not just hidden in the UI
|
||||
- keeps simultaneous edits apart: two people adding or connecting nodes at the same moment both keep their work
|
||||
- issues personal API keys, so scripts and other apps can call ComfyUI with their owner's permissions
|
||||
- shows CPU, RAM, and for each GPU the load, VRAM, temperature and power, plus the queue, in a panel tab or a floating widget
|
||||
- shows CPU, RAM, and for each GPU the load, VRAM, temperature and power, in a panel tab or a floating widget
|
||||
- sends execution progress and previews to everyone, not only whoever queued
|
||||
|
||||
What this fork adds over Nexus:
|
||||
@@ -55,9 +64,14 @@ What this fork adds over Nexus:
|
||||
<td align="center"><img src="docs/images/access.png" width="260" alt="Access list of a shared folder"><br><sub>Who can open a folder</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="docs/images/queue.png" width="260" alt="Queue tab"><br><sub>Queue: who launched what</sub></td>
|
||||
<td align="center"><img src="docs/images/server.png" width="260" alt="Server tab"><br><sub>Server monitor</sub></td>
|
||||
<td align="center"><img src="docs/images/account.png" width="260" alt="Account and API keys"><br><sub>Account and API keys</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="docs/images/admin.png" width="260" alt="Admin tab"><br><sub>Accounts and server settings</sub></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -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, interrupt, free memory |
|
||||
| **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.
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ The panel uses these; they take the same session cookie or API key.
|
||||
| `GET /rigshare/api/me` | The current account |
|
||||
| `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 |
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
@@ -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.
|
||||
|
||||
|
||||
@@ -27,14 +27,17 @@ 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 |
|
||||
| `room_expiry_days` | `14` | Forget the live state of workflows nobody opened for this long |
|
||||
| `snapshot_interval_sec` | `300` | Automatic snapshot interval while editing; `0` turns it off |
|
||||
| `snapshot_keep` | `30` | Automatic snapshots kept per workflow, and as many on-save ones; named ones are always kept |
|
||||
| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup |
|
||||
| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup. Admins can also clear it under *Admin → Chat* |
|
||||
| `auto_unload_minutes` | `0` | Unload models from VRAM once nothing has run for this many minutes, as the *Unload models* button does; `0` turns it off. A run or a waiting prompt restarts the clock |
|
||||
| `auto_unload_cache` | `false` | When unloading, also clear ComfyUI's cache (*Free models & cache*): frees RAM too, but the next run computes every node again |
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
|
||||
|
||||
**Live** shows the tab on screen: whether it is live, who else is in it, and buttons for **Snapshot** and **History**. For a private or unsaved tab it says why it is not live. Below that is the list of **live workflows**: those someone has open right now, with their folder and the people in each one. Open any of them with the arrow. The Files tab marks live files with a green dot.
|
||||
|
||||
**Queue** shows the run in progress and the ones waiting, each with the workflow's name, who queued it and when. The button on a row stops or removes it: your own runs, or anyone's for an admin.
|
||||
|
||||
**People** lists who is online and which workflow they are on. The eye button *follows* someone: it jumps to their tab and keeps your view on theirs until you click or scroll the canvas.
|
||||
|
||||
## What syncs, and how
|
||||
@@ -29,7 +31,7 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
|
||||
- Two people editing the same node at once: the last change wins.
|
||||
- Two people adding or connecting nodes at the same moment both keep their work. If both picked the same node or link id, the server renumbers the later one and quietly resyncs that person. Links and groups merge one by one, so a connection made by one person never erases another's.
|
||||
|
||||
**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. The Live tab and People show which view each person is in, using ComfyUI's Graph and App icons. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open the panels in a drawer beside it.
|
||||
**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. The Live tab and People show which view each person is in, using ComfyUI's Graph and App icons. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open in the same docked side panel as ComfyUI's own tabs.
|
||||
|
||||
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-rigshare"
|
||||
description = "Multi-user collaboration for ComfyUI: accounts and login, live-shared workflows, cursors, chat, snapshots and a server monitor."
|
||||
version = "1.2.0"
|
||||
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.4.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ class ChatLog:
|
||||
os.replace(tmp, self.path)
|
||||
self._index()
|
||||
|
||||
def clear(self):
|
||||
"""Delete the whole history; numbering starts again at 0."""
|
||||
with self._lock:
|
||||
with open(self.path, "w", encoding="utf-8"):
|
||||
pass
|
||||
self.offsets = []
|
||||
|
||||
def __len__(self):
|
||||
return len(self.offsets)
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
import secrets
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from aiohttp import web, WSMsgType
|
||||
|
||||
@@ -106,6 +108,10 @@ class Hub:
|
||||
self.stats = None
|
||||
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)
|
||||
self.last_activity = time.time() # last run event or busy queue, for auto unload
|
||||
self.models_idle = True # nothing to unload until a run loads models
|
||||
|
||||
# ----- lifecycle ----------------------------------------------------
|
||||
|
||||
@@ -131,6 +137,7 @@ class Hub:
|
||||
if expiry > 0 and now - room.updated > expiry and not self.members(room.key):
|
||||
self.rooms.pop(room.key, None)
|
||||
self.store.delete_room(room.key)
|
||||
self.auto_unload(now)
|
||||
except Exception as e:
|
||||
log.error(f"[RigShare] housekeeping error: {e}")
|
||||
|
||||
@@ -142,10 +149,115 @@ class Hub:
|
||||
continue
|
||||
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}")
|
||||
|
||||
# ----- queue ----------------------------------------------------------
|
||||
|
||||
def record_prompt(self, prompt_id, user, workflow):
|
||||
"""Remember who queued a prompt (ComfyUI itself doesn't)."""
|
||||
if not prompt_id:
|
||||
return
|
||||
self.queue_owners[prompt_id] = {
|
||||
"user": user["username"] if user else None,
|
||||
"name": (user.get("display_name") or user["username"]) if user else None,
|
||||
"workflow": clean_text(workflow, 120) or None, "at": int(time.time())}
|
||||
while len(self.queue_owners) > 2000:
|
||||
self.queue_owners.popitem(last=False)
|
||||
|
||||
def owner_of(self, prompt_id):
|
||||
return (self.queue_owners.get(prompt_id) or {}).get("user")
|
||||
|
||||
def with_owners(self, items):
|
||||
if items is None:
|
||||
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
|
||||
self.last_activity = time.time()
|
||||
self.models_idle = False
|
||||
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 auto_unload(self, now):
|
||||
"""Unload models once nothing has run for ``auto_unload_minutes`` (0 = off).
|
||||
|
||||
Sets the same queue flags as ComfyUI's /free, so its prompt worker does the
|
||||
unloading between runs. Happens once per idle period.
|
||||
"""
|
||||
try:
|
||||
minutes = float(self.store.config.get("auto_unload_minutes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
minutes = 0
|
||||
if minutes <= 0 or self.models_idle or not self.server:
|
||||
return
|
||||
queue = self.server.prompt_queue
|
||||
running, pending = queue.get_current_queue_volatile()
|
||||
if running or pending:
|
||||
self.last_activity = now
|
||||
return
|
||||
if now - self.last_activity < minutes * 60:
|
||||
return
|
||||
queue.set_flag("unload_models", True)
|
||||
if self.store.config.get("auto_unload_cache"):
|
||||
queue.set_flag("free_memory", True)
|
||||
self.models_idle = True
|
||||
log.info(f"[RigShare] Unloaded models after {minutes:g} min without runs.")
|
||||
|
||||
def running_prompt_ids(self):
|
||||
try:
|
||||
running, _ = self.server.prompt_queue.get_current_queue_volatile()
|
||||
return [entry[1] for entry in running]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def snapshot(self, room, author, label=None):
|
||||
if room.doc is None:
|
||||
return None
|
||||
@@ -252,8 +364,12 @@ class Hub:
|
||||
Access follows the workflow's folder (restricted shared folders), within
|
||||
the person's account permissions.
|
||||
"""
|
||||
if self.workspace and room.key.startswith("file:workflows/"):
|
||||
return self.workspace.role(room.key[len("file:workflows/"):], kind, key, perms)
|
||||
return self.access_for_key(room.key, kind, key, perms)
|
||||
|
||||
def access_for_key(self, room_key, kind, key, perms):
|
||||
"""Same as access(), from a room key: works before (or without) a live room."""
|
||||
if self.workspace and room_key.startswith("file:workflows/"):
|
||||
return self.workspace.role(room_key[len("file:workflows/"):], kind, key, perms)
|
||||
return "edit" if (perms.get("edit") or perms.get("admin")) else "view"
|
||||
|
||||
def client_access(self, room, client):
|
||||
@@ -334,6 +450,14 @@ class Hub:
|
||||
return True
|
||||
return self.workspace.role(rel, client.kind, key, client.perms) is not None
|
||||
|
||||
async def files_changed(self, *paths):
|
||||
"""Workflow files changed in these folders (userdata paths): tell whoever can
|
||||
open one of them, so their Files tab reloads."""
|
||||
folders = {posixpath.dirname(p) if p.endswith(".json") else p for p in paths if p}
|
||||
for client in list(self.clients.values()):
|
||||
if client.kind and any(self.path_access(f, client) for f in folders):
|
||||
await self.send(client, {"type": "files_changed"})
|
||||
|
||||
def who_sees(self, path):
|
||||
"""Connected clients that may open ``path``; call before moving it."""
|
||||
return [c for c in self.clients.values() if c.kind and self.path_access(path, c)]
|
||||
@@ -351,6 +475,7 @@ class Hub:
|
||||
key = (renamed or {}).get(client.key, client.key) if client.kind == "user" else client.key
|
||||
notes.append((client, self.path_access(new, client, key=key)))
|
||||
self.rekey_rooms("file:" + old, "file:" + new)
|
||||
await self.files_changed(posixpath.dirname(old), posixpath.dirname(new), new)
|
||||
for client, follows in notes:
|
||||
await self.send(client, {"type": "path_moved", "from": old, "to": new} if follows
|
||||
else {"type": "path_closed", "from": old})
|
||||
@@ -376,11 +501,18 @@ 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):
|
||||
await self.broadcast({"type": "server", "server": self.server_info()})
|
||||
|
||||
async def clear_chat(self, by):
|
||||
"""An admin wiped the chat: everyone's history empties, then a notice says who."""
|
||||
self.chat.clear()
|
||||
await self.broadcast({"type": "chat_cleared"})
|
||||
await self.system_chat(f"{by} cleared the chat history")
|
||||
|
||||
async def system_chat(self, text):
|
||||
msg = {"id": secrets.token_hex(6), "system": True, "text": text, "ts": int(time.time() * 1000)}
|
||||
msg = self.chat.append(msg)
|
||||
@@ -458,16 +590,25 @@ class Hub:
|
||||
else:
|
||||
room = self.rooms.get(key)
|
||||
created = False
|
||||
if room is None:
|
||||
doc = data.get("doc") if isinstance(data.get("doc"), dict) else {}
|
||||
room = Room(key, clean_text(data.get("name"), 120) or key, "file", doc)
|
||||
room.dirty = True
|
||||
self.rooms[key] = room
|
||||
created = True
|
||||
if self.client_access(room, client) is None:
|
||||
# Check access before anything is created: only someone who may edit the
|
||||
# workflow can open a room and seed it with their document.
|
||||
role = self.access_for_key(key, client.kind, client.key, client.perms)
|
||||
if role is None:
|
||||
client.room = None
|
||||
await self.send(client, {"type": "room_denied", "room": key, "name": room.name})
|
||||
await self.send(client, {"type": "room_denied", "room": key,
|
||||
"name": room.name if room else clean_text(data.get("name"), 120) or key})
|
||||
elif room is None and role != "edit":
|
||||
client.room = None
|
||||
await self.send(client, {"type": "room_denied", "room": key,
|
||||
"name": clean_text(data.get("name"), 120) or key,
|
||||
"reason": "Nobody has opened this workflow for editing yet."})
|
||||
else:
|
||||
if room is None:
|
||||
doc = data.get("doc") if isinstance(data.get("doc"), dict) else {}
|
||||
room = Room(key, clean_text(data.get("name"), 120) or key, "file", doc)
|
||||
room.dirty = True
|
||||
self.rooms[key] = room
|
||||
created = True
|
||||
client.room = key
|
||||
room.updated = time.time()
|
||||
await self.send(client, {"type": "room", "room": key, "name": room.name, "version": room.version,
|
||||
|
||||
@@ -84,6 +84,13 @@
|
||||
const err = document.getElementById("e");
|
||||
const btn = document.getElementById("b");
|
||||
const next = new URLSearchParams(location.search).get("next") || "/";
|
||||
// Follow `next` only when it resolves to this same origin (blocks //host, /\host, scheme tricks).
|
||||
const safeNext = () => {
|
||||
try {
|
||||
const url = new URL(next, location.origin);
|
||||
return url.origin === location.origin ? url.pathname + url.search + url.hash : "/";
|
||||
} catch { return "/"; }
|
||||
};
|
||||
if (SETUP) {
|
||||
document.title = "Set up · {{SERVER_NAME}}";
|
||||
document.getElementById("sub").textContent = "First-time setup";
|
||||
@@ -112,7 +119,7 @@
|
||||
if (res.status === 409) { location.reload(); return; } // someone finished setup meanwhile
|
||||
if (!res.ok) throw new Error(data.error || "Sign in failed");
|
||||
try { localStorage.setItem("rigshare.token", data.token); } catch {}
|
||||
location.replace(next.startsWith("/") && !next.startsWith("//") ? next : "/");
|
||||
location.replace(safeNext());
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
btn.disabled = false;
|
||||
|
||||
@@ -7,21 +7,49 @@ import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import time
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from collections import defaultdict
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .workspace import Workspace, norm
|
||||
from .workspace import InvalidPath, Workspace, norm as _norm
|
||||
|
||||
log = logging.getLogger("ComfyUI-RigShare")
|
||||
|
||||
# Runs in the ComfyUI page before anything else: any request answered "login
|
||||
# required" (session ended, cookie cleared) sends the browser to the login page.
|
||||
LOGIN_WATCH = """<script id="rigshare-login-watch">(() => {
|
||||
const fetch0 = window.fetch;
|
||||
let leaving = false;
|
||||
window.fetch = async (...args) => {
|
||||
const res = await fetch0(...args);
|
||||
if (res.status === 401 && !leaving && res.headers.get("X-RigShare-Login") === "required") {
|
||||
leaving = true;
|
||||
location.href = "/rigshare/login?next=" + encodeURIComponent(location.pathname + location.search);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
})();</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"),
|
||||
]
|
||||
@@ -66,6 +94,9 @@ BROADCAST_EVENTS = {"execution_start", "execution_cached", "executing", "execute
|
||||
|
||||
|
||||
COOKIE = "rigshare_session"
|
||||
# The login page must not be framed by another site (clickjacking) or sniffed into another type.
|
||||
SECURITY_HEADERS = {"X-Frame-Options": "DENY", "Content-Security-Policy": "frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin"}
|
||||
LOGIN_PAGE = os.path.join(os.path.dirname(__file__), "login.html")
|
||||
ICON_SVG = os.path.join(os.path.dirname(os.path.dirname(__file__)), "web", "rigshare", "assets", "icon.svg")
|
||||
# Reachable without logging in.
|
||||
@@ -84,7 +115,8 @@ def token_from(request):
|
||||
|
||||
def setup(server, store, hub):
|
||||
routes = web.RouteTableDef()
|
||||
failures = defaultdict(list)
|
||||
failures = defaultdict(list) # client address -> times of failed logins
|
||||
failures_by_name = defaultdict(list) # username -> times of failed logins
|
||||
|
||||
def current_user(request):
|
||||
return store.user_for_token(token_from(request))
|
||||
@@ -120,6 +152,13 @@ def setup(server, store, hub):
|
||||
def error(message, status=400):
|
||||
return web.json_response({"error": message}, status=status)
|
||||
|
||||
def norm(path):
|
||||
"""workspace.norm, answering 403 for a path that is refused instead of adjusted."""
|
||||
try:
|
||||
return _norm(path)
|
||||
except InvalidPath:
|
||||
raise web.HTTPForbidden(text='{"error": "Invalid path"}', content_type="application/json")
|
||||
|
||||
def set_cookie(request, response, token):
|
||||
secure = request.secure or request.headers.get("X-Forwarded-Proto") == "https"
|
||||
response.set_cookie(COOKIE, token, max_age=30 * 24 * 3600, path="/", httponly=True,
|
||||
@@ -156,19 +195,30 @@ def setup(server, store, hub):
|
||||
except OSError:
|
||||
icon = ""
|
||||
page = page.replace("{{ICON_SVG}}", icon).replace("{{ICON_B64}}", base64.b64encode(icon.encode()).decode())
|
||||
return web.Response(text=page, content_type="text/html", headers={"Cache-Control": "no-store"})
|
||||
return web.Response(text=page, content_type="text/html", headers={"Cache-Control": "no-store", **SECURITY_HEADERS})
|
||||
|
||||
@routes.post("/rigshare/api/login")
|
||||
async def login(request):
|
||||
ip = request.remote or "?"
|
||||
now = time.time()
|
||||
failures[ip] = [t for t in failures[ip] if now - t < 60]
|
||||
if len(failures[ip]) >= 5:
|
||||
return error("Too many attempts, wait a minute", 429)
|
||||
ip, now = client_ip(request) or "?", time.time()
|
||||
body = await request.json()
|
||||
user = store.authenticate(str(body.get("username", "")).strip(), str(body.get("password", "")))
|
||||
name = str(body.get("username", "")).strip()[:64].casefold()
|
||||
for table in (failures, failures_by_name): # forget old failures, so the tables stay small
|
||||
for key in [k for k, times in table.items() if not times or now - times[-1] >= 60]:
|
||||
del table[key]
|
||||
recent = [t for t in failures[ip] if now - t < 60]
|
||||
recent_name = [t for t in failures_by_name[name] if now - t < 60]
|
||||
failures[ip], failures_by_name[name] = recent, recent_name
|
||||
# Per address: 5 a minute. Per account: 10 a minute across all addresses, which slows
|
||||
# guessing from many places without locking the owner out for long. Signed-in people
|
||||
# never come through here.
|
||||
if len(recent) >= 5 or len(recent_name) >= 10:
|
||||
return web.json_response({"error": "Too many attempts, wait a minute"}, status=429,
|
||||
headers={"Retry-After": "60"})
|
||||
user = store.authenticate(name_raw := str(body.get("username", "")).strip(), str(body.get("password", "")))
|
||||
if not user:
|
||||
failures[ip].append(now)
|
||||
failures_by_name[name].append(now)
|
||||
log.warning(f"[RigShare] Failed login for '{name_raw[:32]}' from {ip}")
|
||||
return error("Invalid username or password", 401)
|
||||
token = store.create_session(user["username"])
|
||||
response = web.json_response({"token": token, "user": store.public_user(user)})
|
||||
@@ -356,6 +406,36 @@ def setup(server, store, hub):
|
||||
messages, more = hub.chat.page(before, limit)
|
||||
return web.json_response({"messages": messages, "more": more})
|
||||
|
||||
@routes.delete("/rigshare/api/chat")
|
||||
async def clear_chat(request):
|
||||
admin = require_admin(request)
|
||||
await hub.clear_chat(admin.get("display_name") or admin["username"])
|
||||
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"]])])
|
||||
@@ -394,7 +474,10 @@ def setup(server, store, hub):
|
||||
async def ws_mkdir(request):
|
||||
body = await request.json()
|
||||
err, path = ws_call(lambda: ws().make_dir(body.get("parent", ""), body.get("name"), *who(request)))
|
||||
return err or web.json_response({"path": path})
|
||||
if err:
|
||||
return err
|
||||
await hub.files_changed("workflows/" + posixpath.dirname(path))
|
||||
return web.json_response({"path": path})
|
||||
|
||||
@routes.post("/rigshare/api/workspace/rename-folder")
|
||||
async def ws_rename_dir(request):
|
||||
@@ -426,6 +509,7 @@ def setup(server, store, hub):
|
||||
if err:
|
||||
return err
|
||||
await hub.drop_rooms("file:workflows/" + path, display(request))
|
||||
await hub.files_changed("workflows/" + posixpath.dirname(path))
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
@routes.get("/rigshare/api/workspace/folder")
|
||||
@@ -450,6 +534,7 @@ def setup(server, store, hub):
|
||||
except ValueError as e:
|
||||
return error(str(e))
|
||||
log.info(f"[RigShare] {key} created folder {folder}")
|
||||
await hub.files_changed("workflows/shared")
|
||||
return web.json_response({"path": folder})
|
||||
|
||||
@routes.patch("/rigshare/api/workspace/folders")
|
||||
@@ -478,6 +563,7 @@ def setup(server, store, hub):
|
||||
except ValueError as e:
|
||||
return error(str(e))
|
||||
await hub.refresh_rooms_access()
|
||||
await hub.files_changed("workflows/shared") # the folder's access changed who sees it
|
||||
return web.json_response({"ok": True, "path": folder})
|
||||
|
||||
@routes.delete("/rigshare/api/workspace/folders")
|
||||
@@ -516,8 +602,7 @@ def setup(server, store, hub):
|
||||
if not key:
|
||||
require_admin(request)
|
||||
return web.json_response(store.list_snapshots())
|
||||
room = hub.rooms.get(key)
|
||||
if room and not room_access(request, room):
|
||||
if hub.access_for_key(key, *who(request)) is None:
|
||||
return error("No such room", 404)
|
||||
return web.json_response(store.list_snapshots(key))
|
||||
|
||||
@@ -541,8 +626,9 @@ def setup(server, store, hub):
|
||||
snap = store.get_snapshot(request.match_info["snap_id"])
|
||||
if not snap:
|
||||
return error("Not found", 404)
|
||||
room = hub.rooms.get(snap["meta"].get("room", ""))
|
||||
if room and not room_access(request, room):
|
||||
# Access follows the workflow's folder whether or not anyone has it open right now.
|
||||
room_key = snap["meta"].get("room", "")
|
||||
if room_key and hub.access_for_key(room_key, *who(request)) is None:
|
||||
return error("Not found", 404)
|
||||
return web.json_response(snap)
|
||||
|
||||
@@ -580,6 +666,50 @@ def setup(server, store, hub):
|
||||
continue
|
||||
return False
|
||||
|
||||
def client_ip(request):
|
||||
"""The caller's address. Behind a trusted proxy (one of trusted_ips), the address that
|
||||
proxy appended to X-Forwarded-For / X-Real-IP; otherwise the socket's. A proxy on the
|
||||
same machine used to make every visitor look like 127.0.0.1, so everyone was trusted."""
|
||||
remote = request.remote or ""
|
||||
if not trusted(remote):
|
||||
return remote # an untrusted peer's forwarding headers are just claims
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[-1].strip()
|
||||
real = request.headers.get("X-Real-IP")
|
||||
if real:
|
||||
return real.strip()
|
||||
if request.headers.get("Forwarded"):
|
||||
return "" # a proxy we cannot read: do not trust the visitor
|
||||
return remote
|
||||
|
||||
def cross_site(request):
|
||||
"""A browser sending its session cookie from another site. Browsers always send Origin on
|
||||
cross-site writes and websockets; API clients (no cookie, usually no Origin) are not
|
||||
affected."""
|
||||
origin = request.headers.get("Origin")
|
||||
if not origin or COOKIE not in request.cookies:
|
||||
return False
|
||||
if request.method in ("GET", "HEAD", "OPTIONS") and request.path != "/rigshare/ws":
|
||||
return False
|
||||
# Hostnames only: a proxy often passes Host without the port the browser used.
|
||||
def hostname(value):
|
||||
try:
|
||||
return (urlsplit(value if "//" in value else "//" + value).hostname or "").lower()
|
||||
except ValueError:
|
||||
return ""
|
||||
origin_host = hostname(origin)
|
||||
allowed = {hostname(request.host)}
|
||||
allowed.update(hostname(h.strip()) for h in (request.headers.get("X-Forwarded-Host") or "").split(",") if h.strip())
|
||||
if origin_host and origin_host in allowed:
|
||||
return False
|
||||
log.warning(f"[RigShare] Refused cross-site {request.method} {request.path}: Origin {origin[:80]!r} "
|
||||
f"does not match Host {request.host[:80]!r}")
|
||||
return True
|
||||
|
||||
def trusted_request(request):
|
||||
return trusted(client_ip(request))
|
||||
|
||||
def parse_userdata(request):
|
||||
"""('list'|'get'|'save'|'delete'|'move', path, dest) for ComfyUI userdata calls under workflows/."""
|
||||
raw = request.raw_path.split("?", 1)[0]
|
||||
@@ -609,6 +739,53 @@ def setup(server, store, hub):
|
||||
action = {"GET": "get", "HEAD": "get", "POST": "save", "DELETE": "delete"}.get(request.method)
|
||||
return (action, src, None) if action else None
|
||||
|
||||
def watch_login(response):
|
||||
try:
|
||||
file_path = getattr(response, "_path", None)
|
||||
if file_path:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
page = f.read()
|
||||
elif isinstance(response, web.Response) and response.body and "html" in (response.content_type or ""):
|
||||
page = response.body.decode("utf-8")
|
||||
else:
|
||||
return response
|
||||
if "<head>" not in page or "rigshare-login-watch" in page:
|
||||
return response
|
||||
page = page.replace("<head>", "<head>" + LOGIN_WATCH, 1)
|
||||
return web.Response(text=page, content_type="text/html",
|
||||
headers={"Cache-Control": "no-store, must-revalidate"})
|
||||
except Exception as e:
|
||||
log.debug(f"[RigShare] could not add the login watch: {e}")
|
||||
return response
|
||||
|
||||
def api_path(request):
|
||||
return request.path[4:] if request.path.startswith("/api/") else request.path
|
||||
|
||||
async def check_queue_ownership(request):
|
||||
"""Cancelling a queued prompt or stopping the running one: your own, unless admin."""
|
||||
if request.method != "POST" or api_path(request) not in ("/queue", "/interrupt"):
|
||||
return None
|
||||
if trusted_request(request) or perms_for(request).get("admin"):
|
||||
return None
|
||||
user = current_user(request)
|
||||
me = user["username"] if user else None
|
||||
try:
|
||||
body = await request.json() if request.body_exists else {}
|
||||
except ValueError:
|
||||
body = {}
|
||||
body = body if isinstance(body, dict) else {}
|
||||
if api_path(request) == "/queue":
|
||||
if body.get("clear"):
|
||||
return "only admins can clear the whole queue"
|
||||
ids = body.get("delete") or []
|
||||
else:
|
||||
ids = [body["prompt_id"]] if body.get("prompt_id") else hub.running_prompt_ids()
|
||||
for prompt_id in ids:
|
||||
owner = hub.owner_of(prompt_id)
|
||||
if owner and owner != me:
|
||||
return "you can only cancel your own prompts"
|
||||
return None
|
||||
|
||||
def snapshot_saved_file(request, path, body):
|
||||
if not path.endswith(".json") or Workspace.workflows_rel(path) is None:
|
||||
return
|
||||
@@ -687,29 +864,33 @@ def setup(server, store, hub):
|
||||
@web.middleware
|
||||
async def protect(request, handler):
|
||||
path = request.path
|
||||
if store.config.get("require_login") and path not in PUBLIC_PATHS and not trusted(request.remote or ""):
|
||||
if cross_site(request):
|
||||
return web.json_response({"error": "RigShare: cross-site request refused"}, status=403)
|
||||
if store.config.get("require_login") and path not in PUBLIC_PATHS and not trusted_request(request):
|
||||
if not current_user(request):
|
||||
wants_page = request.method == "GET" and (
|
||||
path in ("/", "/index.html") or "text/html" in request.headers.get("Accept", ""))
|
||||
if wants_page:
|
||||
raise web.HTTPFound(f"/rigshare/login?next={path}")
|
||||
# The header lets the ComfyUI page (see LOGIN_WATCH) go back to the login screen.
|
||||
return web.json_response({"error": "RigShare: login required. API clients: send "
|
||||
"'Authorization: Bearer <api key>'."}, status=401)
|
||||
"'Authorization: Bearer <api key>'."}, status=401,
|
||||
headers={"X-RigShare-Login": "required"})
|
||||
userdata = parse_userdata(request)
|
||||
if userdata and not trusted(request.remote or ""):
|
||||
if userdata and not trusted_request(request):
|
||||
refusal = check_userdata(request, *userdata)
|
||||
if refusal:
|
||||
return web.json_response({"error": f"RigShare: {refusal}"}, status=403)
|
||||
cfg = store.config.get("api_protection", {})
|
||||
if cfg.get("enabled"):
|
||||
path = request.path[4:] if request.path.startswith("/api/") else request.path
|
||||
if needs_manager(request.method, path) and not trusted(request.remote or ""):
|
||||
if needs_manager(request.method, path) and not trusted_request(request):
|
||||
if not perms_for(request).get("manager"):
|
||||
return web.json_response({"error": "RigShare: you need the 'manager' permission to use ComfyUI Manager"},
|
||||
status=403)
|
||||
for method, prefix, need in PROTECTED:
|
||||
if (not method or request.method == method) and (path == prefix or path.startswith(prefix)):
|
||||
if trusted(request.remote or ""):
|
||||
if trusted_request(request):
|
||||
break
|
||||
perms = perms_for(request)
|
||||
if not any(perms.get(p) for p in need.split("|")):
|
||||
@@ -719,20 +900,37 @@ 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(request) 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)
|
||||
if userdata and userdata[0] == "save":
|
||||
await request.read() # keep the body for the snapshot below (aiohttp caches it)
|
||||
response = await handler(request)
|
||||
# Remember who queued each prompt, for the Queue tab.
|
||||
if request.method == "POST" and api_path(request) == "/prompt" and getattr(response, "status", 0) == 200:
|
||||
try:
|
||||
prompt_id = json.loads(response.body).get("prompt_id")
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
prompt_id = None
|
||||
hub.record_prompt(prompt_id, current_user(request), unquote(request.headers.get("X-RigShare-Workflow", "")))
|
||||
# Every save of a workflow keeps a snapshot, named after when it was saved.
|
||||
if userdata and userdata[0] == "save" and getattr(response, "status", 0) == 200:
|
||||
snapshot_saved_file(request, userdata[1], await request.read())
|
||||
await hub.files_changed(userdata[1])
|
||||
# Workflow listings only show what this person may open.
|
||||
if userdata and userdata[0] == "list" and not trusted(request.remote or "") and hub.workspace:
|
||||
if userdata and userdata[0] == "list" and not trusted_request(request) and hub.workspace:
|
||||
response = filter_listing(request, response, userdata[1])
|
||||
# A file moved on disk: its live room follows it.
|
||||
if userdata and userdata[0] == "move" and getattr(response, "status", 0) == 200:
|
||||
await hub.path_moved(userdata[1], userdata[2])
|
||||
if userdata and userdata[0] == "delete" and getattr(response, "status", 0) in (200, 204):
|
||||
await hub.drop_rooms("file:" + userdata[1], display(request))
|
||||
await hub.files_changed(userdata[1])
|
||||
# Hide API templates: rewrite the template index ComfyUI serves.
|
||||
if (store.config.get("hide_api_templates") and request.method == "GET"
|
||||
and path.startswith("/templates/index") and path.endswith(".json")):
|
||||
@@ -747,6 +945,11 @@ def setup(server, store, hub):
|
||||
response = web.json_response(filter_templates(index), headers={"Cache-Control": "no-store"})
|
||||
except Exception as e:
|
||||
log.debug(f"[RigShare] could not filter templates: {e}")
|
||||
# The ComfyUI page: never cached (an ended session must reach the redirect
|
||||
# above), and watched, so a session that ends while it is open goes back
|
||||
# to the login screen instead of showing API errors.
|
||||
if request.method == "GET" and request.path in ("/", "/index.html") and store.config.get("require_login"):
|
||||
response = watch_login(response)
|
||||
# Sliding session: renew the cookie JWT when it is getting old.
|
||||
cookie = request.cookies.get(COOKIE)
|
||||
if cookie and isinstance(response, web.Response) and not response.prepared:
|
||||
@@ -765,6 +968,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
|
||||
|
||||
@@ -51,6 +51,23 @@ def _gpus():
|
||||
return out
|
||||
|
||||
|
||||
def queue_items(server):
|
||||
"""Running and pending prompts: [{id, number, status, nodes}], running first."""
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
except Exception:
|
||||
return None
|
||||
items = []
|
||||
for status, entries in (("running", running), ("pending", sorted(pending, key=lambda e: e[0]))):
|
||||
for entry in entries:
|
||||
try:
|
||||
prompt = entry[2] if isinstance(entry[2], dict) else {}
|
||||
items.append({"id": entry[1], "number": entry[0], "status": status, "nodes": len(prompt)})
|
||||
except (IndexError, TypeError):
|
||||
continue
|
||||
return items
|
||||
|
||||
|
||||
def _queue(server):
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
@@ -76,4 +93,5 @@ def collect_stats(server=None):
|
||||
stats["gpus"] = []
|
||||
if server is not None:
|
||||
stats["queue"] = _queue(server)
|
||||
stats["queue_items"] = queue_items(server)
|
||||
return stats
|
||||
|
||||
@@ -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.
|
||||
@@ -56,6 +58,10 @@ DEFAULT_CONFIG = {
|
||||
"snapshot_interval_sec": 300,
|
||||
"snapshot_keep": 30,
|
||||
"chat_keep": 0, # 0 keeps the whole chat; N trims it to the last N messages at startup
|
||||
# Unload models once the queue has been empty this many minutes (0 = off),
|
||||
# and with auto_unload_cache also clear ComfyUI's node cache.
|
||||
"auto_unload_minutes": 0,
|
||||
"auto_unload_cache": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -214,10 +220,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 +247,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 +454,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,66 @@ 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
|
||||
|
||||
|
||||
class InvalidPath(PermissionError):
|
||||
"""A path Windows would read as a different one than the checks do: refused, never adjusted."""
|
||||
|
||||
|
||||
def norm(path):
|
||||
"""Decode and normalise a userdata-relative path ('' for the root)."""
|
||||
"""Decode and normalise a userdata-relative path ('' for the root).
|
||||
|
||||
A path with a segment Windows would read as something else is refused (InvalidPath), not
|
||||
adjusted: a colon (drive prefix 'C:', NTFS stream), a 8.3 short name, or 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]
|
||||
for p in parts:
|
||||
if ":" in p or (_WIN and (_SHORT_NAME_RE.search(p) or p != p.rstrip(" ."))):
|
||||
raise InvalidPath("Invalid path")
|
||||
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 +112,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 +205,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 +256,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 +430,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 +443,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 +453,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");
|
||||
});
|
||||
|
||||
@@ -33,17 +33,38 @@ def enc(path):
|
||||
return quote(path, safe="")
|
||||
|
||||
|
||||
class FakeQueue:
|
||||
"""ComfyUI's PromptQueue, as far as RigShare reads it."""
|
||||
|
||||
def __init__(self):
|
||||
self.running, self.pending, self.number = [], [], 0
|
||||
self.flags = {}
|
||||
|
||||
def get_current_queue_volatile(self):
|
||||
return list(self.running), list(self.pending)
|
||||
|
||||
def set_flag(self, name, data):
|
||||
self.flags[name] = data
|
||||
|
||||
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):
|
||||
self.app = web.Application()
|
||||
self.sent = []
|
||||
self.prompt_queue = FakeQueue()
|
||||
|
||||
def send_sync(self, event, data, sid=None):
|
||||
self.sent.append((event, sid))
|
||||
|
||||
|
||||
def comfy_routes(user_root):
|
||||
"""Minimal ComfyUI /userdata routes for the single default user."""
|
||||
def comfy_routes(user_root, queue=None):
|
||||
"""Minimal ComfyUI /userdata routes for the single default user, plus the queue."""
|
||||
|
||||
def resolve(name):
|
||||
if "%" in name:
|
||||
@@ -83,6 +104,24 @@ def comfy_routes(user_root):
|
||||
async def ok(request):
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
async def prompt(request):
|
||||
if request.method != "POST" or queue is None:
|
||||
return web.json_response({"ok": True})
|
||||
body = await request.json() if request.body_exists else {}
|
||||
queue.number += 1
|
||||
prompt_id = f"p{queue.number}"
|
||||
queue.pending.append((queue.number, prompt_id, (body or {}).get("prompt") or {"1": {}}, {}, []))
|
||||
return web.json_response({"prompt_id": prompt_id, "number": queue.number, "node_errors": {}})
|
||||
|
||||
async def queue_route(request):
|
||||
body = await request.json() if request.body_exists else {}
|
||||
if queue is not None:
|
||||
gone = set((body or {}).get("delete") or [])
|
||||
queue.pending = [e for e in queue.pending if e[1] not in gone]
|
||||
if (body or {}).get("clear"):
|
||||
queue.pending = []
|
||||
return web.Response(status=200)
|
||||
|
||||
table = web.RouteTableDef()
|
||||
for prefix in ("", "/api"):
|
||||
table.get(prefix + "/userdata")(listing)
|
||||
@@ -90,7 +129,10 @@ def comfy_routes(user_root):
|
||||
table.post(prefix + "/userdata/{file}")(post)
|
||||
table.delete(prefix + "/userdata/{file}")(delete)
|
||||
table.post(prefix + "/userdata/{file}/move/{dest}")(move)
|
||||
table.route("*", prefix + "/prompt")(ok)
|
||||
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
|
||||
@@ -184,7 +226,7 @@ async def rig_server(config=None, workspace=True, setup=None):
|
||||
if workspace:
|
||||
hub.workspace = Workspace(store, os.path.join(user_root, "workflows"))
|
||||
routes.setup(ps, store, hub)
|
||||
ps.app.add_routes(comfy_routes(user_root))
|
||||
ps.app.add_routes(comfy_routes(user_root, ps.prompt_queue))
|
||||
if setup:
|
||||
setup(ps.app)
|
||||
server = TestServer(ps.app, host="127.0.0.1")
|
||||
|
||||
@@ -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")
|
||||
@@ -5,6 +5,8 @@ import json
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from helpers import PASSWORD, enc, rig_server
|
||||
|
||||
BASE_DOC = {"last_node_id": 2, "last_link_id": 0, "nodes": [
|
||||
@@ -440,3 +442,395 @@ def test_every_save_keeps_a_snapshot(run):
|
||||
await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=b'{"nodes": []}', headers=viewer)
|
||||
assert len(rig.store.list_snapshots("file:" + path)) == 2
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_admin_clears_chat_history(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
sock = await rig.ws("alice")
|
||||
for i in range(5):
|
||||
await sock.send({"type": "chat", "text": f"hi {i}"})
|
||||
await asyncio.sleep(0.2)
|
||||
await sock.drain()
|
||||
assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=alice)).status == 403, "admins only"
|
||||
assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=admin)).status == 200
|
||||
msgs = await sock.drain()
|
||||
kinds = [m["type"] for m in msgs]
|
||||
assert "chat_cleared" in kinds and kinds.index("chat_cleared") < kinds.index("chat")
|
||||
notice = next(m for m in msgs if m["type"] == "chat")["message"]
|
||||
assert notice["system"] and "cleared the chat history" in notice["text"] and notice["seq"] == 0
|
||||
history = await (await rig.http.get(rig.url("/rigshare/api/chat"), headers=alice)).json()
|
||||
assert [m["text"] for m in history["messages"]] == [notice["text"]] and not history["more"]
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_queue_shows_who_launched_and_guards_cancelling(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", queue=True)
|
||||
bob = await rig.add_user("bob", queue=True)
|
||||
h = rig.http
|
||||
|
||||
async def queue(user, name):
|
||||
r = await h.post(rig.url("/api/prompt"), json={"prompt": {"1": {}, "2": {}}},
|
||||
headers={**user, "X-RigShare-Workflow": quote(name)})
|
||||
return (await r.json())["prompt_id"]
|
||||
a = await queue(alice, "Face swap é")
|
||||
b = await queue(bob, "Upscale")
|
||||
rig.ps.prompt_queue.running.append(rig.ps.prompt_queue.pending.pop(0)) # alice's starts
|
||||
|
||||
from rigshare_core.stats import queue_items
|
||||
items = rig.hub.with_owners(queue_items(rig.ps))
|
||||
assert [(i["status"], i["user"], i["workflow"], i["nodes"]) for i in items] == [
|
||||
("running", "alice", "Face swap é", 2), ("pending", "bob", "Upscale", 2)]
|
||||
|
||||
# Your own runs only, unless admin; clearing everyone's is admin-only.
|
||||
assert (await h.post(rig.url("/api/queue"), json={"delete": [b]}, headers=alice)).status == 403
|
||||
assert (await h.post(rig.url("/api/interrupt"), json={}, headers=bob)).status == 403, "alice's is running"
|
||||
assert (await h.post(rig.url("/api/interrupt"), json={"prompt_id": a}, headers=alice)).status == 200
|
||||
assert (await h.post(rig.url("/api/queue"), json={"clear": True}, headers=bob)).status == 403
|
||||
assert (await h.post(rig.url("/api/queue"), json={"delete": [b]}, headers=bob)).status == 200
|
||||
assert not rig.ps.prompt_queue.pending
|
||||
assert (await h.post(rig.url("/api/queue"), json={"clear": True}, headers=admin)).status == 200
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_ended_session_goes_back_to_login(run, tmp_path):
|
||||
index = tmp_path / "index.html"
|
||||
index.write_text("<!doctype html><html><head><title>ComfyUI</title></head><body></body></html>")
|
||||
|
||||
async def scenario():
|
||||
async def page(request):
|
||||
from aiohttp import web
|
||||
return web.FileResponse(str(index))
|
||||
|
||||
async with rig_server(setup=lambda app: app.router.add_get("/", page)) as rig:
|
||||
admin = await rig.setup_admin()
|
||||
h = rig.http
|
||||
r = await h.get(rig.url("/"), headers=admin)
|
||||
text = await r.text()
|
||||
assert r.status == 200 and "rigshare-login-watch" in text and text.index("rigshare-login-watch") < text.index("<title>")
|
||||
assert "no-store" in r.headers.get("Cache-Control", ""), "never served from the browser cache"
|
||||
|
||||
# Session gone: API calls say so in a header the page watches for...
|
||||
await h.post(rig.url("/rigshare/api/logout"), headers=admin)
|
||||
r = await h.get(rig.url("/api/prompt"), headers={"Authorization": "Bearer nope"})
|
||||
assert r.status == 401 and r.headers.get("X-RigShare-Login") == "required"
|
||||
# ...and the page itself redirects to the login screen.
|
||||
r = await h.get(rig.url("/"), headers={"Accept": "text/html"}, allow_redirects=False)
|
||||
assert r.status == 302 and r.headers["Location"].startswith("/rigshare/login")
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_file_changes_reach_who_can_see_them(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
await rig.add_user("bob", edit=True)
|
||||
a, b = await rig.ws("alice"), await rig.ws("bob")
|
||||
h = rig.http
|
||||
|
||||
async def changed(sock):
|
||||
return any(m["type"] == "files_changed" for m in await sock.drain())
|
||||
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/mine.json')}"), data=b'{"nodes": []}', headers=alice)
|
||||
assert await changed(a) and not await changed(b), "a private save only tells its owner"
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}"), data=b'{"nodes": []}', headers=alice)
|
||||
assert await changed(a) and await changed(b)
|
||||
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Crew"}, headers=alice)
|
||||
assert await changed(b), "new shared folder"
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}/move/{enc('workflows/shared/Crew/team.json')}"), headers=rig.headers["boss"])
|
||||
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_auto_unload_after_idle(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
import time
|
||||
admin = await rig.setup_admin()
|
||||
hub, queue = rig.hub, rig.ps.prompt_queue
|
||||
|
||||
def run(pid):
|
||||
hub.run_event("execution_start", {"prompt_id": pid, "timestamp": 1})
|
||||
hub.run_event("execution_success", {"prompt_id": pid, "timestamp": 2})
|
||||
|
||||
r = await rig.http.patch(rig.url("/rigshare/api/config"), json={"auto_unload_minutes": 15}, headers=admin)
|
||||
assert (await r.json())["auto_unload_minutes"] == 15
|
||||
hub.auto_unload(time.time() + 3600)
|
||||
assert queue.flags == {}, "nothing ran since startup, nothing to unload"
|
||||
|
||||
run("a")
|
||||
now = time.time()
|
||||
hub.auto_unload(now + 14 * 60)
|
||||
assert queue.flags == {}, "not idle long enough"
|
||||
queue.pending.append((1, "b", {}, {}, []))
|
||||
hub.auto_unload(now + 3600)
|
||||
assert queue.flags == {}, "a waiting prompt keeps models loaded and restarts the clock"
|
||||
queue.pending.clear()
|
||||
hub.auto_unload(now + 3600 + 14 * 60)
|
||||
assert queue.flags == {}
|
||||
hub.auto_unload(now + 3600 + 15 * 60)
|
||||
assert queue.flags == {"unload_models": True}
|
||||
queue.flags.clear()
|
||||
hub.auto_unload(now + 4 * 3600)
|
||||
assert queue.flags == {}, "once per idle period"
|
||||
|
||||
await rig.http.patch(rig.url("/rigshare/api/config"), json={"auto_unload_cache": True}, headers=admin)
|
||||
run("c")
|
||||
hub.auto_unload(time.time() + 15 * 60)
|
||||
assert queue.flags == {"unload_models": True, "free_memory": True}
|
||||
|
||||
queue.flags.clear()
|
||||
await rig.http.patch(rig.url("/rigshare/api/config"), json={"auto_unload_minutes": 0}, headers=admin)
|
||||
run("d")
|
||||
hub.auto_unload(time.time() + 24 * 3600)
|
||||
assert queue.flags == {}, "0 turns it off"
|
||||
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())
|
||||
|
||||
|
||||
def test_access_is_checked_without_a_live_room(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
bob = await rig.add_user("bob", edit=True)
|
||||
await rig.add_user("viewer")
|
||||
h = rig.http
|
||||
r = await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=alice)
|
||||
assert r.status == 200
|
||||
rig.hub.workspace.set_acl("shared/Team", {"members": {"alice": "edit"}}, "alice")
|
||||
path = "workflows/shared/Team/plan.json"
|
||||
doc = json.dumps({"nodes": [{"id": 1}], "links": []}).encode()
|
||||
assert (await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=doc, headers=alice)).status == 200
|
||||
key = "file:" + path
|
||||
assert key not in rig.hub.rooms, "nobody has it open"
|
||||
snap_id = rig.store.list_snapshots(key)[0]["id"]
|
||||
|
||||
for who_, status in ((alice, 200), (bob, 404)):
|
||||
r = await h.get(rig.url(f"/rigshare/api/snapshots?room={enc(key)}"), headers=who_)
|
||||
assert r.status == status, who_
|
||||
r = await h.get(rig.url(f"/rigshare/api/snapshots/{snap_id}"), headers=who_)
|
||||
assert r.status == status, who_
|
||||
|
||||
# Joining a workflow you cannot open must not create (or seed) a room.
|
||||
sock = await rig.ws("bob")
|
||||
await sock.send({"type": "join", "room": key, "name": "plan", "doc": BASE_DOC})
|
||||
await sock.until("room_denied")
|
||||
assert key not in rig.hub.rooms
|
||||
|
||||
# A viewer cannot be the one who opens a room, so cannot seed it with their own document.
|
||||
open_key = "file:workflows/common.json"
|
||||
viewer_sock = await rig.ws("viewer")
|
||||
await viewer_sock.send({"type": "join", "room": open_key, "name": "common", "doc": BASE_DOC})
|
||||
assert "editing" in (await viewer_sock.until("room_denied"))["reason"]
|
||||
assert open_key not in rig.hub.rooms
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_a_local_proxy_does_not_make_visitors_trusted(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
rig.store.update_config({"api_protection": {"enabled": True, "trusted_ips": ["127.0.0.1"]}})
|
||||
anon = {"Accept": "application/json"}
|
||||
async with ClientSession() as plain:
|
||||
# Straight from the machine itself: trusted, no login asked.
|
||||
assert (await plain.get(rig.url("/api/prompt"), headers=anon)).status != 401
|
||||
# The same machine relaying an outside visitor (nginx, Caddy, a tunnel): not trusted.
|
||||
for header in ({"X-Forwarded-For": "203.0.113.9"},
|
||||
{"X-Forwarded-For": "10.0.0.1, 203.0.113.9"},
|
||||
{"X-Real-IP": "203.0.113.9"}, {"Forwarded": "for=203.0.113.9"}):
|
||||
r = await plain.get(rig.url("/api/prompt"), headers={**anon, **header})
|
||||
assert r.status == 401, header
|
||||
# A proxy relaying someone on the trusted network keeps that trust.
|
||||
r = await plain.get(rig.url("/api/prompt"), headers={**anon, "X-Forwarded-For": "127.0.0.1"})
|
||||
assert r.status != 401
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_login_attempts_are_throttled_per_address_and_per_account(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
await rig.add_user("alice", edit=True)
|
||||
h, login = rig.http, rig.url("/rigshare/api/login")
|
||||
bad = {"username": "alice", "password": "wrong-password"}
|
||||
for _ in range(5):
|
||||
assert (await h.post(login, json=bad)).status == 401
|
||||
r = await h.post(login, json=bad)
|
||||
assert r.status == 429 and r.headers["Retry-After"] == "60"
|
||||
assert (await h.post(login, json={"username": "alice", "password": PASSWORD})).status == 429, "even the right password waits"
|
||||
# Someone already signed in is not affected: no login involved.
|
||||
assert (await h.get(rig.url("/rigshare/api/status"), headers=rig.headers["alice"])).status == 200
|
||||
|
||||
# Many addresses (a trusted proxy relaying them) against one account.
|
||||
rig.store.update_config({"api_protection": {"enabled": True, "trusted_ips": ["127.0.0.1"]}})
|
||||
statuses = []
|
||||
for n in range(12):
|
||||
r = await h.post(login, json={"username": "Alice", "password": "wrong-password"},
|
||||
headers={"X-Forwarded-For": f"203.0.113.{n + 1}"})
|
||||
statuses.append(r.status)
|
||||
# The 5 failures above already count against the account: 5 more fit, then it waits.
|
||||
assert statuses[:5] == [401] * 5 and statuses[5:] == [429] * 7
|
||||
r = await h.post(login, json={"username": "someone-else", "password": "x"}, headers={"X-Forwarded-For": "198.51.100.7"})
|
||||
assert r.status == 401, "other accounts are not slowed down"
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_cross_site_requests_with_a_session_cookie_are_refused(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
h = rig.http
|
||||
assert any(c.key == "rigshare_session" for c in h.cookie_jar), "logged in by cookie"
|
||||
own = {"Origin": rig.url("")}
|
||||
evil = {"Origin": "https://evil.example"}
|
||||
r = await h.post(rig.url("/rigshare/api/snapshots"), json={}, headers=evil)
|
||||
assert r.status == 403 and "cross-site" in (await r.json())["error"]
|
||||
assert (await h.post(rig.url("/rigshare/api/snapshots"), json={}, headers=own)).status != 403
|
||||
assert (await h.post(rig.url("/rigshare/api/snapshots"), json={})).status != 403, "no Origin: API client"
|
||||
assert (await h.get(rig.url("/rigshare/api/status"), headers=evil)).status == 200, "reads are not writes"
|
||||
r = await h.get(rig.url("/rigshare/ws"), headers={**evil, "Upgrade": "websocket", "Connection": "Upgrade"})
|
||||
assert r.status == 403
|
||||
# Behind a proxy that rewrites Host, X-Forwarded-Host names the public host.
|
||||
r = await h.post(rig.url("/rigshare/api/snapshots"), json={},
|
||||
headers={"Origin": "https://comfy.example.com", "X-Forwarded-Host": "comfy.example.com"})
|
||||
assert r.status != 403
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_login_page_cannot_be_framed(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
r = await rig.http.get(rig.url("/rigshare/login"))
|
||||
assert r.headers["X-Frame-Options"] == "DENY"
|
||||
assert "frame-ancestors 'none'" in r.headers["Content-Security-Policy"]
|
||||
assert r.headers["X-Content-Type-Options"] == "nosniff"
|
||||
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,68 @@ 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", "c%3A%5Cmodels"])
|
||||
def test_norm_refuses_drive_segments(raw):
|
||||
with pytest.raises(PermissionError):
|
||||
norm(raw)
|
||||
|
||||
|
||||
def test_norm_still_resolves_dotdot_inside_the_tree():
|
||||
assert norm(r"..\..\x") == "x"
|
||||
assert norm("a/../b") == "b"
|
||||
|
||||
|
||||
@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.skipif(os.name != "nt", reason="trailing dots, spaces and short names only alias on Windows")
|
||||
@pytest.mark.parametrize("raw", ["users./bob/a.json", "users /bob", "USERS~1/bob", "users/bob./a.json"])
|
||||
def test_norm_refuses_windows_aliases(raw):
|
||||
with pytest.raises(PermissionError):
|
||||
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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// RigShare in ComfyUI's App mode.
|
||||
//
|
||||
// The App view's sidebar shows a fixed set of tabs (assets and apps), so the
|
||||
// RigShare and Files tabs disappear there. While the tab on screen is in the
|
||||
// App view, RigShare adds its own buttons to that sidebar, copied from
|
||||
// ComfyUI's so they look the same, and opens the panels in a drawer next to it.
|
||||
// If the sidebar can't be found, a small floating dock offers them instead.
|
||||
// The App view's sidebar only draws buttons for a fixed set of tabs (assets
|
||||
// and apps), but its docked side panel shows whichever sidebar tab is active.
|
||||
// So RigShare adds its own buttons to that sidebar, copied from ComfyUI's so
|
||||
// they look the same, and they open the regular RigShare and Files tabs in the
|
||||
// docked panel, exactly like ComfyUI's own tabs. If the sidebar can't be
|
||||
// found, a small floating dock offers the same buttons.
|
||||
|
||||
import { h } from "./ui.js";
|
||||
|
||||
@@ -12,19 +13,15 @@ const RAIL = 'nav[data-testid="side-toolbar"]';
|
||||
|
||||
export class AppDock {
|
||||
/**
|
||||
* views: { id: { icon, title, mount(el), unmount() } }
|
||||
* views: { id: { icon, title, tabId } }, tabId being a registered sidebar tab.
|
||||
*/
|
||||
constructor({ sync, views }) {
|
||||
constructor({ app, sync, views }) {
|
||||
this.app = app;
|
||||
this.sync = sync;
|
||||
this.views = views;
|
||||
this.open = null;
|
||||
this.badges = {};
|
||||
this.railButtons = {};
|
||||
this.dockButtons = {};
|
||||
this.body = h("div", { class: "rs-host rs-dock-drawer-body" });
|
||||
this.drawer = h("div", { class: "rs-dock-drawer rs-panel-vars", role: "dialog" },
|
||||
h("button", { class: "rs-dock-close", title: "Close", onclick: () => this.show(null) }, h("i", { class: "pi pi-times" })),
|
||||
this.body);
|
||||
this.dock = h("div", { class: "rs-dock rs-panel-vars" }, ...Object.entries(views).map(([id, v]) => {
|
||||
const badge = h("span", { class: "rs-dock-badge" });
|
||||
const btn = h("button", {
|
||||
@@ -35,24 +32,53 @@ export class AppDock {
|
||||
return btn;
|
||||
}));
|
||||
this.dock.style.display = "none";
|
||||
this.drawer.style.display = "none";
|
||||
document.body.append(this.dock, this.drawer);
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.open) this.show(null); });
|
||||
setInterval(() => this.update(), 400);
|
||||
document.body.append(this.dock);
|
||||
this.wasApp = false;
|
||||
this.graphTab = null; // our tab open in the graph view, if any
|
||||
this.restore = null; // { tabId, until }: reopen it after entering the App view
|
||||
setInterval(() => this.update(), 300);
|
||||
}
|
||||
|
||||
get activeTabId() {
|
||||
return this.app.extensionManager?.sidebarTab?.activeSidebarTabId ?? null;
|
||||
}
|
||||
|
||||
/** Open (or close) the tab in ComfyUI's docked side panel, like its own buttons do. */
|
||||
toggle(id) {
|
||||
this.show(this.open === id ? null : id);
|
||||
this.app.extensionManager?.command?.execute(`Workspace.ToggleSidebarTab.${this.views[id].tabId}`);
|
||||
setTimeout(() => this.refreshButtons(), 30);
|
||||
}
|
||||
|
||||
update() {
|
||||
const active = this.sync.inAppMode;
|
||||
this.keepOpenIntoApp(active);
|
||||
const rail = active ? document.querySelector(RAIL) : null;
|
||||
if (rail) this.addToRail(rail);
|
||||
else this.removeFromRail();
|
||||
this.dock.style.display = active && !rail ? "" : "none";
|
||||
if (!active && this.open) this.show(null);
|
||||
if (this.open) this.place(rail);
|
||||
this.refreshButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* ComfyUI closes the side panel when a tab enters the App view (not when it
|
||||
* goes back to the graph). If a RigShare tab was open, open it again.
|
||||
*/
|
||||
keepOpenIntoApp(inApp) {
|
||||
const open = this.activeTabId;
|
||||
const ours = Object.values(this.views).map((v) => v.tabId);
|
||||
if (!inApp) {
|
||||
this.graphTab = ours.includes(open) ? open : null;
|
||||
this.restore = null;
|
||||
} else if (!this.wasApp && this.graphTab) {
|
||||
this.restore = { tabId: this.graphTab, until: Date.now() + 1500 };
|
||||
}
|
||||
this.wasApp = inApp;
|
||||
if (!this.restore) return;
|
||||
if (open === this.restore.tabId || Date.now() > this.restore.until) {
|
||||
this.restore = null;
|
||||
} else if (open === null) {
|
||||
this.app.extensionManager?.command?.execute(`Workspace.ToggleSidebarTab.${this.restore.tabId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy one of ComfyUI's sidebar buttons for each view, placed after its own tabs. */
|
||||
@@ -99,41 +125,14 @@ export class AppDock {
|
||||
this.railButtons = {};
|
||||
}
|
||||
|
||||
/** Beside the sidebar when there is one, above the floating dock otherwise. */
|
||||
place(rail) {
|
||||
const d = this.drawer.style;
|
||||
if (rail) {
|
||||
const r = rail.getBoundingClientRect();
|
||||
d.left = `${Math.round(r.right + 8)}px`;
|
||||
d.top = `${Math.round(r.top + 8)}px`;
|
||||
d.bottom = "8px";
|
||||
} else {
|
||||
d.left = "64px";
|
||||
d.top = "8px";
|
||||
d.bottom = "64px";
|
||||
}
|
||||
}
|
||||
|
||||
show(id) {
|
||||
if (this.open) this.views[this.open].unmount?.();
|
||||
this.open = id;
|
||||
this.refreshButtons();
|
||||
this.drawer.style.display = id ? "" : "none";
|
||||
if (id) {
|
||||
this.place(document.querySelector(RAIL));
|
||||
this.views[id].mount(this.body);
|
||||
} else {
|
||||
this.body.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
refreshButtons() {
|
||||
const open = this.activeTabId;
|
||||
for (const [id, b] of Object.entries(this.railButtons)) {
|
||||
b.btn.classList.toggle("side-bar-button-selected", id === this.open);
|
||||
b.btn.classList.toggle("side-bar-button-selected", this.views[id].tabId === open);
|
||||
this.paintBadge(b.badge, this.badges[id]);
|
||||
}
|
||||
for (const [id, b] of Object.entries(this.dockButtons)) {
|
||||
b.btn.classList.toggle("active", id === this.open);
|
||||
b.btn.classList.toggle("active", this.views[id].tabId === open);
|
||||
this.paintBadge(b.badge, this.badges[id]);
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 118 KiB |
@@ -146,6 +146,11 @@ export class RigClient extends EventTarget {
|
||||
this.stats = msg.stats;
|
||||
this.emit("stats", msg.stats);
|
||||
break;
|
||||
case "chat_cleared":
|
||||
this.chat = [];
|
||||
this.chatMore = false;
|
||||
this.emit("chat-history", this.chat);
|
||||
break;
|
||||
case "chat":
|
||||
this.chat.push(msg.message);
|
||||
this.emit("chat", msg.message);
|
||||
|
||||
@@ -35,7 +35,14 @@ export class FilesBrowser {
|
||||
h("div", { class: "rs-fb-tools" }, this.search),
|
||||
this.list);
|
||||
// The sidebar browser lives as long as the page: keep its live dots current.
|
||||
if (mode === "browse") client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
|
||||
if (mode === "browse") {
|
||||
client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
|
||||
// Someone (maybe you, from the Save dialog) saved, moved or deleted a workflow: reload.
|
||||
client.on("files_changed", () => {
|
||||
clearTimeout(this.changedTimer);
|
||||
this.changedTimer = setTimeout(() => { if (this.el.isConnected && !this.accessFor) this.reload(); }, 250);
|
||||
});
|
||||
}
|
||||
this.setStart(start);
|
||||
}
|
||||
|
||||
@@ -303,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}`);
|
||||
@@ -318,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();
|
||||
}
|
||||
@@ -352,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,32 @@ import { h } from "./ui.js";
|
||||
import { AppDock } from "./app-dock.js";
|
||||
|
||||
const TAB_ID = "rigshare";
|
||||
const FILES_TAB_ID = "rigshare-files";
|
||||
|
||||
/** Remembers the side panel elements each custom tab was rendered into and keeps
|
||||
* the tab's content in whichever of them is on screen. */
|
||||
class SlotTracker {
|
||||
constructor(app, tabs) {
|
||||
this.app = app;
|
||||
this.tabs = tabs;
|
||||
this.slots = Object.fromEntries(Object.keys(tabs).map((id) => [id, new Set()]));
|
||||
setInterval(() => this.check(), 300);
|
||||
}
|
||||
|
||||
add(id, el) {
|
||||
this.slots[id]?.add(el);
|
||||
}
|
||||
|
||||
check() {
|
||||
const active = this.app.extensionManager?.sidebarTab?.activeSidebarTabId;
|
||||
for (const [id, set] of Object.entries(this.slots)) {
|
||||
for (const el of set) if (!el.isConnected) set.delete(el);
|
||||
if (id !== active) continue;
|
||||
const visible = [...set].find((el) => el.getClientRects().length > 0);
|
||||
if (visible && !this.tabs[id].has(visible)) this.tabs[id].fill(visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
const ICON_CLASS = "rigshare-tab-icon";
|
||||
|
||||
function setting(id, fallback) {
|
||||
@@ -25,12 +51,18 @@ function setting(id, fallback) {
|
||||
}
|
||||
}
|
||||
|
||||
function installAuthHeaders(client) {
|
||||
function installAuthHeaders(client, sync) {
|
||||
const original = api.fetchApi.bind(api);
|
||||
api.fetchApi = (route, options = {}) => {
|
||||
let headers = options.headers || {};
|
||||
if (headers instanceof Headers) headers = Object.fromEntries(headers.entries());
|
||||
return original(route, { ...options, headers: { ...headers, ...client.authHeaders() } });
|
||||
headers = { ...headers, ...client.authHeaders() };
|
||||
// Queueing: name the workflow, so the Queue tab can show what each run is.
|
||||
if (route === "/prompt" && (options.method || "GET").toUpperCase() === "POST") {
|
||||
const wf = sync.store?.activeWorkflow;
|
||||
if (wf) headers["X-RigShare-Workflow"] = encodeURIComponent(sync.tabName(wf));
|
||||
}
|
||||
return original(route, { ...options, headers });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,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
|
||||
@@ -107,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.
|
||||
@@ -271,14 +367,27 @@ app.registerExtension({
|
||||
panel.chatToasts = setting("RigShare.ChatToasts", false);
|
||||
|
||||
client.on("toast", (t) => app.extensionManager?.toast?.add({ life: 4000, ...t }));
|
||||
installAuthHeaders(client);
|
||||
installAuthHeaders(client, sync);
|
||||
installQueueGuard(client);
|
||||
installManagerGuard(client);
|
||||
installComfyAccountGuard(client);
|
||||
installFileTabsGuard(client);
|
||||
installManagerTab(client);
|
||||
installSaveDialog(app, api, client, sync);
|
||||
presence.install();
|
||||
|
||||
// ComfyUI keeps a separate side panel for the graph and the App view and
|
||||
// shows the old one again without asking the tab to render: follow the
|
||||
// visible one, or the panel comes back blank after switching views.
|
||||
let filesPanelRef = () => null; // set once the Files panel is built below
|
||||
const slots = new SlotTracker(app, {
|
||||
[TAB_ID]: { has: (el) => el.contains(panel.root), fill: (el) => panel.mount(el) },
|
||||
[FILES_TAB_ID]: {
|
||||
has: (el) => !filesPanelRef() || el.contains(filesPanelRef()),
|
||||
fill: (el) => { if (el.contains(panel.root)) panel.unmount(); el.replaceChildren(filesPanelRef()); },
|
||||
},
|
||||
});
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: TAB_ID,
|
||||
icon: `pi pi-users ${ICON_CLASS}`,
|
||||
@@ -287,7 +396,7 @@ app.registerExtension({
|
||||
label: "RigShare",
|
||||
type: "custom",
|
||||
iconBadge: null,
|
||||
render: (el) => panel.mount(el),
|
||||
render: (el) => { slots.add(TAB_ID, el); panel.mount(el); },
|
||||
destroy: () => panel.unmount(),
|
||||
});
|
||||
|
||||
@@ -312,15 +421,17 @@ app.registerExtension({
|
||||
onclick: () => app.extensionManager.command.execute("Comfy.OpenWorkflow"),
|
||||
}, h("i", { class: "pi pi-upload" }), "From computer")),
|
||||
h("div", { class: "rs-body" }, filesBrowser.el));
|
||||
filesPanelRef = () => filesPanel;
|
||||
let filesLoaded = false;
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "rigshare-files",
|
||||
id: FILES_TAB_ID,
|
||||
icon: "pi pi-folder-open",
|
||||
title: "Files",
|
||||
tooltip: "RigShare files: My files and Shared",
|
||||
label: "Files",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
slots.add(FILES_TAB_ID, el);
|
||||
el.classList.add("rs-host");
|
||||
// Same slot element as the RigShare tab when switching between them
|
||||
// (ComfyUI only calls destroy on unmount): take it over cleanly.
|
||||
@@ -331,15 +442,12 @@ app.registerExtension({
|
||||
});
|
||||
client.on("self", () => { if (filesPanel.isConnected) filesBrowser.reload(); });
|
||||
|
||||
// ComfyUI's App view hides extension sidebar tabs: offer them in a dock there.
|
||||
// ComfyUI's App view draws buttons only for its own tabs: add ours to its sidebar.
|
||||
const dock = new AppDock({
|
||||
sync,
|
||||
app, sync,
|
||||
views: {
|
||||
[TAB_ID]: { icon: "pi-users", title: "RigShare", mount: (el) => panel.mount(el), unmount: () => panel.unmount() },
|
||||
files: {
|
||||
icon: "pi-folder-open", title: "Files",
|
||||
mount: (el) => { el.replaceChildren(filesPanel); filesBrowser.reload(); },
|
||||
},
|
||||
[TAB_ID]: { icon: "pi-users", title: "RigShare", tabId: TAB_ID },
|
||||
files: { icon: "pi-folder-open", title: "Files", tabId: FILES_TAB_ID },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -10,8 +11,9 @@ const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
|
||||
|
||||
const VIEWS = [
|
||||
{ id: "workflows", icon: "pi-share-alt", label: "Live" },
|
||||
{ id: "people", icon: "pi-users", label: "People" },
|
||||
{ id: "chat", icon: "pi-comments", label: "Chat" },
|
||||
{ id: "people", icon: "pi-users", label: "People" },
|
||||
{ id: "queue", icon: "pi-list", label: "Queue" },
|
||||
{ id: "server", icon: "pi-server", label: "Server" },
|
||||
{ id: "admin", icon: "pi-shield", label: "Admin", admin: true },
|
||||
];
|
||||
@@ -37,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" });
|
||||
@@ -60,7 +74,10 @@ export class RigSharePanel {
|
||||
client.on("status", () => this.render());
|
||||
client.on("self", () => this.render());
|
||||
client.on("presence", () => this.renderSoft());
|
||||
client.on("stats", () => { if (this.view === "server") this.renderBody(); });
|
||||
client.on("stats", () => {
|
||||
if (this.view === "server" || this.view === "queue") this.renderBody();
|
||||
this.renderNav(); // queue count
|
||||
});
|
||||
client.on("chat-history", () => this.renderChatLog());
|
||||
client.on("chat", (m) => this.onChat(m));
|
||||
sync.addEventListener("state", () => { if (this.view === "workflows") this.renderBody(); });
|
||||
@@ -171,6 +188,7 @@ export class RigSharePanel {
|
||||
people: c.users.length || null,
|
||||
chat: this.unread || null,
|
||||
workflows: c.rooms?.length || null,
|
||||
queue: c.stats?.queue_items?.length || null,
|
||||
};
|
||||
this.nav.replaceChildren(...VIEWS.filter((v) => !v.admin || c.perms.admin).map((v) =>
|
||||
h("button", {
|
||||
@@ -199,6 +217,7 @@ export class RigSharePanel {
|
||||
people: () => this.peopleView(),
|
||||
chat: () => [this.chatView],
|
||||
server: () => this.serverView(),
|
||||
queue: () => this.queueView(),
|
||||
admin: () => this.adminView(),
|
||||
account: () => this.accountView(),
|
||||
};
|
||||
@@ -566,6 +585,44 @@ export class RigSharePanel {
|
||||
|
||||
// ----- server ---------------------------------------------------------
|
||||
|
||||
// ----- queue ----------------------------------------------------------
|
||||
|
||||
queueView() {
|
||||
const c = this.client;
|
||||
const items = c.stats?.queue_items;
|
||||
if (items == null) return [this.section("pi-list", "Queue", h("p", { class: "rs-muted" }, "Waiting for the server…"))];
|
||||
const running = items.filter((it) => it.status === "running");
|
||||
const pending = items.filter((it) => it.status === "pending");
|
||||
return [
|
||||
this.section("pi-play", "Running", ...(running.length ? running.map((it) => this.queueRow(it)) : [h("p", { class: "rs-muted" }, "Nothing running.")])),
|
||||
this.section("pi-list", `Pending${pending.length ? ` (${pending.length})` : ""}`,
|
||||
...(pending.length ? pending.map((it, i) => this.queueRow(it, i + 1)) : [h("p", { class: "rs-muted" }, "Nothing waiting.")])),
|
||||
];
|
||||
}
|
||||
|
||||
queueRow(it, position) {
|
||||
const c = this.client;
|
||||
const online = it.user ? c.users.find((u) => u.key === it.user) : null;
|
||||
const who = it.name || (it.user ? `@${it.user}` : "Unknown");
|
||||
const mine = it.user && it.user === c.self?.key;
|
||||
const canCancel = c.perms.queue && (mine || c.perms.admin);
|
||||
const running = it.status === "running";
|
||||
const meta = [position ? `#${position}` : null, `${it.nodes} nodes`, it.at ? `queued ${timeAgo(it.at * 1000)}` : null].filter(Boolean).join(" · ");
|
||||
return h("div", { class: `rs-row-item rs-queue-row ${running ? "running" : ""}` },
|
||||
h("span", { class: "rs-avatar", style: `background:${online?.color ?? "var(--rs-border)"}`, title: who }, initials(who)),
|
||||
h("div", { class: "rs-grow rs-min0" },
|
||||
h("div", { class: "rs-name rs-ellipsis" }, it.workflow || "Workflow",
|
||||
running ? h("i", { class: "pi pi-spin pi-spinner rs-inline-icon rs-muted", title: "Running" }) : null),
|
||||
h("div", { class: "rs-muted rs-small rs-ellipsis" }, `${who}${mine ? " (you)" : ""}`),
|
||||
h("div", { class: "rs-muted rs-small" }, meta)),
|
||||
canCancel ? h("button", {
|
||||
class: "rs-btn rs-btn-icon rs-danger", title: running ? "Stop this run" : "Remove from the queue",
|
||||
onclick: () => this.run(() => running
|
||||
? c.request("POST", "/api/interrupt", { prompt_id: it.id })
|
||||
: c.request("POST", "/api/queue", { delete: [it.id] }), running ? "Stopping…" : "Removed from the queue"),
|
||||
}, running ? h("span", { class: "rs-stop-icon" }) : h("i", { class: "pi pi-times" })) : null);
|
||||
}
|
||||
|
||||
serverView() {
|
||||
const st = this.client.stats;
|
||||
const floating = this.widget.enabled;
|
||||
@@ -587,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);
|
||||
}
|
||||
|
||||
@@ -687,6 +753,14 @@ export class RigSharePanel {
|
||||
return [
|
||||
this.section("pi-users", "Accounts", this.adminAccounts, this.createAccountForm()),
|
||||
this.section("pi-cog", "Server settings", this.adminSettings),
|
||||
this.section("pi-comments", "Chat",
|
||||
h("p", { class: "rs-muted rs-small" }, "The whole chat history is kept. Clearing it deletes every message for everyone; it cannot be undone."),
|
||||
h("div", { class: "rs-actions" }, h("button", {
|
||||
class: "rs-btn rs-danger", onclick: async () => {
|
||||
if (!(await this.confirm("Clear chat history", "Delete every chat message for everyone? This cannot be undone."))) return;
|
||||
await this.run(() => this.client.request("DELETE", "/rigshare/api/chat"), "Chat history cleared");
|
||||
},
|
||||
}, h("i", { class: "pi pi-trash" }), "Clear chat history"))),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -782,10 +856,13 @@ 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) }),
|
||||
keep: h("input", { class: "rs-input rs-num", type: "number", min: 1, value: cfg.snapshot_keep ?? 30 }),
|
||||
unloadMinutes: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: cfg.auto_unload_minutes ?? 0 }),
|
||||
unloadCache: box(cfg.auto_unload_cache),
|
||||
};
|
||||
const line = (input, label, hint) => h("label", { class: "rs-check rs-block" }, input,
|
||||
h("span", {}, label, hint ? h("div", { class: "rs-muted rs-small" }, hint) : null));
|
||||
@@ -800,10 +877,13 @@ 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,
|
||||
snapshot_keep: Math.max(1, +f.keep.value || 30),
|
||||
auto_unload_minutes: Math.max(0, +f.unloadMinutes.value || 0),
|
||||
auto_unload_cache: f.unloadCache.checked,
|
||||
}), "Settings saved");
|
||||
if (ok) { this.client.server.name = ok.server_name; this.renderHeader(); }
|
||||
},
|
||||
@@ -814,11 +894,14 @@ 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),
|
||||
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Automatic snapshots kept"), f.keep),
|
||||
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Unload models after idle (min, 0 = off)"), f.unloadMinutes),
|
||||
line(f.unloadCache, "Also clear the cache when unloading", "Frees RAM as well, but the next run computes every node again"),
|
||||
h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, h("i", { class: "pi pi-save" }), "Save settings"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
@@ -253,14 +291,8 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-dock-btn.active { color: var(--rs-accent-fg); background: var(--rs-accent); }
|
||||
.rs-dock-badge { display: none; position: absolute; top: 1px; right: 1px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
|
||||
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; }
|
||||
.rs-dock-drawer { position: fixed; top: 8px; bottom: 64px; left: 64px; width: min(380px, calc(100vw - 80px)); z-index: 1100;
|
||||
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 12px 40px rgb(0 0 0 / .45); overflow: hidden; }
|
||||
.rs-dock-drawer-body { height: 100%; }
|
||||
.rs-dock-drawer .rs-header { padding-right: 40px; }
|
||||
.rs-rail-badge { position: absolute; top: -4px; right: -6px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
|
||||
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; text-align: center; }
|
||||
.rs-dock-close { position: absolute; top: 8px; right: 8px; z-index: 2; border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 4px; }
|
||||
.rs-dock-close:hover { color: var(--rs-fg); }
|
||||
.rs-view-avatar { position: relative; overflow: visible; }
|
||||
.rs-comfy-icon { display: inline-block; width: 1em; height: 1em; flex: none; }
|
||||
.rs-view-mark { position: absolute; right: -6px; bottom: -5px; font-size: 8px; width: 13px; height: 13px; display: grid; place-items: center;
|
||||
@@ -269,6 +301,7 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-view-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 6px; font-size: 10.5px; padding: 0 6px; }
|
||||
.rs-view-chip .rs-comfy-icon { font-size: 11px; }
|
||||
.rs-view-chip.rs-view-app { color: var(--rs-accent); border-color: var(--rs-accent); }
|
||||
.rs-stop-icon { display: block; width: 10px; height: 10px; border-radius: 2px; background: currentColor; }
|
||||
.rs-cursor-layer { position: fixed; inset: 0; pointer-events: none; z-index: 900; overflow: hidden; }
|
||||
.rs-cursor { position: absolute; left: 0; top: 0; will-change: transform; transition: transform 70ms linear; }
|
||||
.rs-cursor svg { display: block; filter: drop-shadow(0 1px 1px rgb(0 0 0 / .4)); }
|
||||
|
||||
@@ -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
|
||||
|
||||