846 lines
49 KiB
JavaScript
846 lines
49 KiB
JavaScript
// Sidebar panel: tabs for workflows, people, chat, server and admin, plus an
|
|
// account view (profile, password, API keys) behind the avatar button.
|
|
|
|
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
|
|
import { ServerWidget } from "./server-widget.js";
|
|
|
|
const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
|
|
|
|
const VIEWS = [
|
|
{ id: "workflows", icon: "pi-share-alt", label: "Live" },
|
|
{ id: "files", icon: "pi-folder", label: "Files" },
|
|
{ id: "people", icon: "pi-users", label: "People" },
|
|
{ id: "chat", icon: "pi-comments", label: "Chat" },
|
|
{ id: "server", icon: "pi-server", label: "Server" },
|
|
{ id: "admin", icon: "pi-shield", label: "Admin", admin: true },
|
|
];
|
|
|
|
export class RigSharePanel {
|
|
constructor(app, client, sync, presence) {
|
|
this.app = app;
|
|
this.client = client;
|
|
this.sync = sync;
|
|
this.presence = presence;
|
|
this.visible = false;
|
|
this.unread = 0;
|
|
this.chatToasts = false;
|
|
this.view = storage.get("rigshare.view") || "workflows";
|
|
this.historyOpen = false;
|
|
this.newKey = null;
|
|
|
|
this.widget = new ServerWidget(client, () => this.render());
|
|
|
|
this.header = h("div", { class: "rs-header" });
|
|
this.nav = h("div", { class: "rs-nav", role: "tablist" });
|
|
this.body = h("div", { class: "rs-body" });
|
|
this.root = h("div", { class: "rs-panel" }, this.header, this.nav, this.body);
|
|
|
|
this.chatLog = h("div", { class: "rs-chat-log" });
|
|
this.chatInput = h("textarea", {
|
|
class: "rs-input rs-chat-input", placeholder: "Message… (Enter to send)", maxLength: 1000, rows: 1,
|
|
onkeydown: (e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.sendChat(); }
|
|
},
|
|
});
|
|
this.chatView = h("div", { class: "rs-chat" }, this.chatLog,
|
|
h("div", { class: "rs-chat-compose" }, this.chatInput,
|
|
h("button", { class: "rs-btn rs-btn-primary rs-btn-icon", title: "Send", onclick: () => this.sendChat() }, h("i", { class: "pi pi-send" }))));
|
|
|
|
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("chat-history", () => this.renderChatLog());
|
|
client.on("chat", (m) => this.onChat(m));
|
|
sync.addEventListener("state", () => { if (this.view === "workflows") this.renderBody(); });
|
|
presence.addEventListener("follow", () => { if (this.view === "people") this.renderBody(); });
|
|
this.render();
|
|
}
|
|
|
|
// ----- sidebar lifecycle ---------------------------------------------
|
|
|
|
mount(container) {
|
|
container.classList.add("rs-host");
|
|
container.append(this.root);
|
|
this.visible = true;
|
|
if (this.view === "chat") this.clearUnread();
|
|
this.render();
|
|
}
|
|
|
|
unmount() {
|
|
this.visible = false;
|
|
}
|
|
|
|
focusChat() {
|
|
this.setView("chat");
|
|
setTimeout(() => this.chatInput.focus(), 50);
|
|
}
|
|
|
|
setView(view) {
|
|
this.view = view;
|
|
storage.set("rigshare.view", view);
|
|
if (view === "chat") this.clearUnread();
|
|
this.render();
|
|
}
|
|
|
|
get badge() {
|
|
return this.unread ? String(this.unread > 99 ? "99+" : this.unread) : null;
|
|
}
|
|
|
|
clearUnread() {
|
|
if (!this.unread) return;
|
|
this.unread = 0;
|
|
this.onBadge?.(null);
|
|
}
|
|
|
|
toast(severity, detail, summary = "RigShare") {
|
|
this.client.emit("toast", { severity, summary, detail });
|
|
}
|
|
|
|
async confirm(title, message) {
|
|
const dialog = this.app.extensionManager?.dialog;
|
|
if (dialog?.confirm) return !!(await dialog.confirm({ title, message }));
|
|
return window.confirm(`${title}\n\n${message}`);
|
|
}
|
|
|
|
async prompt(title, message, defaultValue = "") {
|
|
const dialog = this.app.extensionManager?.dialog;
|
|
if (dialog?.prompt) return await dialog.prompt({ title, message, defaultValue });
|
|
return window.prompt(`${title}\n\n${message}`, defaultValue);
|
|
}
|
|
|
|
async run(fn, okMessage) {
|
|
try {
|
|
const result = await fn();
|
|
if (okMessage) this.toast("success", okMessage);
|
|
return result;
|
|
} catch (e) {
|
|
this.toast("error", e.message || String(e));
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// ----- frame ----------------------------------------------------------
|
|
|
|
render() {
|
|
this.renderHeader();
|
|
this.renderNav();
|
|
this.renderBody();
|
|
}
|
|
|
|
/** Presence changes: refresh counts and the current view if it shows people. */
|
|
renderSoft() {
|
|
this.renderNav();
|
|
if (["workflows", "people"].includes(this.view)) this.renderBody();
|
|
}
|
|
|
|
renderHeader() {
|
|
const c = this.client;
|
|
const s = c.status;
|
|
const label = { online: "Connected", connecting: "Connecting…", offline: "Offline", denied: "Signed out" }[s];
|
|
const me = c.self;
|
|
this.header.replaceChildren(...[
|
|
h("div", { class: "rs-title" }, h("img", { class: "rs-logo", src: ICON_URL, alt: "" }),
|
|
h("div", { class: "rs-title-text" },
|
|
h("div", { class: "rs-title-name" }, c.server?.name || "RigShare"),
|
|
h("div", { class: `rs-status rs-status-${s}` }, h("span", { class: "rs-dot" }), label))),
|
|
me ? h("button", {
|
|
class: `rs-me-btn ${this.view === "account" ? "active" : ""}`, title: `${me.name} — account & API keys`,
|
|
onclick: () => this.setView(this.view === "account" ? "workflows" : "account"),
|
|
}, h("span", { class: "rs-avatar rs-avatar-sm", style: `background:${me.color}` }, initials(me.name))) : null,
|
|
].filter(Boolean));
|
|
}
|
|
|
|
renderNav() {
|
|
const c = this.client;
|
|
const counts = {
|
|
people: c.users.length || null,
|
|
chat: this.unread || null,
|
|
workflows: c.rooms?.length || null,
|
|
};
|
|
this.nav.replaceChildren(...VIEWS.filter((v) => !v.admin || c.perms.admin).map((v) =>
|
|
h("button", {
|
|
class: `rs-tab ${this.view === v.id ? "active" : ""}`, role: "tab", title: v.label,
|
|
onclick: () => this.setView(v.id),
|
|
}, h("i", { class: `pi ${v.icon}` }), h("span", { class: "rs-tab-label" }, v.label),
|
|
counts[v.id] ? h("span", { class: `rs-count ${v.id === "chat" ? "hot" : ""}` }, counts[v.id]) : null)));
|
|
this.nav.style.display = c.self ? "" : "none";
|
|
}
|
|
|
|
renderBody() {
|
|
const c = this.client;
|
|
this.body.classList.toggle("rs-body-chat", this.view === "chat" && !!c.self);
|
|
if (!c.self) {
|
|
const failing = c.retry >= 2 && c.status !== "denied";
|
|
this.body.replaceChildren(h("div", { class: "rs-empty" },
|
|
h("i", { class: "pi pi-spin pi-spinner" }),
|
|
c.status === "denied" ? "Signed out — redirecting to login…" : c.status === "connecting" ? "Connecting…" : "Server unreachable, retrying…",
|
|
failing ? h("div", { class: "rs-small rs-hint" },
|
|
"Can't open the live connection. If ComfyUI is behind a reverse proxy, enable WebSocket support for it (Nginx Proxy Manager: ",
|
|
h("b", {}, "Websockets Support"), ").") : null));
|
|
return;
|
|
}
|
|
const views = {
|
|
workflows: () => this.workflowsView(),
|
|
files: () => this.filesView(),
|
|
people: () => this.peopleView(),
|
|
chat: () => [this.chatView],
|
|
server: () => this.serverView(),
|
|
admin: () => this.adminView(),
|
|
account: () => this.accountView(),
|
|
};
|
|
if (this.view === "admin" && !c.perms.admin) this.view = "workflows";
|
|
const scroll = this.body.scrollTop;
|
|
this.body.replaceChildren(...(views[this.view] ?? views.workflows)().filter(Boolean));
|
|
this.body.scrollTop = scroll;
|
|
if (this.view === "chat") this.scrollChat();
|
|
}
|
|
|
|
section(icon, title, ...children) {
|
|
return h("section", { class: "rs-section" },
|
|
h("div", { class: "rs-section-title" }, h("i", { class: `pi ${icon}` }), title), ...children);
|
|
}
|
|
|
|
// ----- workflows ------------------------------------------------------
|
|
|
|
workflowsView() {
|
|
return [this.currentTabSection(), this.roomsSection()];
|
|
}
|
|
|
|
currentTabSection() {
|
|
const c = this.client;
|
|
const s = this.sync;
|
|
const wf = s.store?.activeWorkflow;
|
|
if (!wf) return this.section("pi-file", "This tab", h("p", { class: "rs-muted" }, "No tab open."));
|
|
const room = s.target;
|
|
const head = h("div", { class: "rs-tab-head" },
|
|
h("div", { class: "rs-name" }, s.tabName(wf)),
|
|
wf.isTemporary ? h("span", { class: "rs-chip" }, "unsaved") : null);
|
|
const row = h("div", { class: "rs-actions" });
|
|
const body = [head];
|
|
if (!room) {
|
|
body.push(h("p", { class: "rs-muted" }, wf.isTemporary
|
|
? "Private until you edit it or share it."
|
|
: "Private — not synced with others."));
|
|
row.append(h("button", { class: "rs-btn rs-btn-primary", onclick: () => s.shareCurrent() }, h("i", { class: "pi pi-share-alt" }), "Share this tab"));
|
|
} else {
|
|
const here = c.users.filter((u) => u.room === room.key);
|
|
const last = s.lastEditor && Date.now() - s.lastEditor.at < 60000 ? ` · ${s.lastEditor.name} editing` : "";
|
|
body.push(h("div", { class: "rs-live-row" },
|
|
s.live
|
|
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
|
|
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
|
|
h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))));
|
|
body.push(this.accessLine());
|
|
if (this.accessOpen && s.room?.manage) body.push(this.accessEditor());
|
|
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
|
|
if (s.canEdit) {
|
|
row.append(h("button", {
|
|
class: "rs-btn", title: "Save a named snapshot", onclick: async () => {
|
|
const label = await this.prompt("Snapshot", "Name this snapshot", `Snapshot ${new Date().toLocaleString()}`);
|
|
if (label === null || label === undefined) return;
|
|
await this.run(() => c.request("POST", "/rigshare/api/snapshots", { room: room.key, label: label || "Manual" }), "Snapshot saved");
|
|
if (this.historyOpen) this.loadHistory();
|
|
},
|
|
}, h("i", { class: "pi pi-camera" }), "Snapshot"));
|
|
}
|
|
row.append(
|
|
h("button", { class: `rs-btn ${this.historyOpen ? "active" : ""}`, onclick: () => { this.historyOpen = !this.historyOpen; this.renderBody(); if (this.historyOpen) this.loadHistory(); } },
|
|
h("i", { class: "pi pi-history" }), "History"),
|
|
h("button", { class: "rs-btn rs-btn-ghost", title: c.perms.admin ? "Stop sharing this tab (removed from the list if nobody else is in it)" : "Leave: stop syncing this tab and keep a private copy", onclick: () => s.unshareCurrent() },
|
|
h("i", { class: "pi pi-eye-slash" }), "Stop sharing"));
|
|
}
|
|
this.historyEl ??= h("div", { class: "rs-history" });
|
|
return this.section("pi-file", "This tab", ...body, row, room && this.historyOpen ? this.historyEl : null);
|
|
}
|
|
|
|
accessLine() {
|
|
const s = this.sync;
|
|
const info = s.room;
|
|
if (!info) return null;
|
|
const owner = info.owner ? this.client.users.find((u) => u.key === info.owner)?.name ?? `@${info.owner}` : null;
|
|
return h("div", { class: "rs-access" },
|
|
h("i", { class: `pi ${info.restricted ? "pi-lock" : "pi-globe"}` }),
|
|
h("span", { class: "rs-grow" }, info.restricted ? "Only people with access" : "Everyone can open it",
|
|
owner ? h("span", { class: "rs-muted" }, ` · owner ${owner}`) : null),
|
|
info.manage ? h("button", {
|
|
class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`,
|
|
onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); },
|
|
}, "Access") : null);
|
|
}
|
|
|
|
accessEditor() {
|
|
const key = this.sync.targetKey;
|
|
return this.accessEditorFor({
|
|
load: () => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`),
|
|
save: (restricted, members) => this.sync.setAccess(restricted, members),
|
|
everyoneHint: "Anyone with an account can open it, with their usual permissions",
|
|
close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); },
|
|
draftKey: "accessDraft",
|
|
});
|
|
}
|
|
|
|
/** Access editor shared by live workflows and shared folders. */
|
|
accessEditorFor({ load, save, everyoneHint, close, draftKey }) {
|
|
const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…"));
|
|
Promise.all([this.run(load), this.run(() => this.client.request("GET", "/rigshare/api/people"))]).then(([acl, people]) => {
|
|
if (!acl || !people) return;
|
|
const draft = this[draftKey] ??= { restricted: acl.restricted, members: { ...acl.members } };
|
|
const render = () => {
|
|
const fixed = people.filter((p) => p.username === acl.owner || p.admin);
|
|
const choosable = people.filter((p) => p.username !== acl.owner && !p.admin);
|
|
const fixedRows = fixed.map((p) => h("div", { class: "rs-row-item" },
|
|
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`)),
|
|
h("span", { class: "rs-chip" }, p.username === acl.owner ? "Owner · always" : "Admin · always")));
|
|
const rows = choosable.map((p) => {
|
|
const select = h("select", {
|
|
class: "rs-input rs-select", disabled: !draft.restricted,
|
|
onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; },
|
|
}, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) =>
|
|
h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label)));
|
|
return h("div", { class: "rs-row-item" },
|
|
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`),
|
|
p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")),
|
|
select);
|
|
});
|
|
const name = `rs-access-${draftKey}`;
|
|
const radio = (value, label, hint) => h("label", { class: "rs-check rs-block" },
|
|
h("input", { type: "radio", name, checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }),
|
|
h("span", {}, label, h("div", { class: "rs-muted rs-small" }, hint)));
|
|
box.replaceChildren(...[
|
|
radio(false, "Everyone", everyoneHint),
|
|
radio(true, "Only people I choose", "The owner and admins always have access"),
|
|
draft.restricted ? h("div", { class: "rs-access-list" }, ...fixedRows, ...rows,
|
|
rows.length ? null : h("p", { class: "rs-muted rs-small" },
|
|
"Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open it.")) : null,
|
|
h("div", { class: "rs-actions" },
|
|
h("button", {
|
|
class: "rs-btn rs-btn-primary", onclick: async () => {
|
|
const ok = await this.run(() => save(draft.restricted, draft.restricted ? draft.members : {}), "Access updated");
|
|
if (ok !== undefined) { this[draftKey] = null; close(); }
|
|
},
|
|
}, "Save"),
|
|
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this[draftKey] = null; close(); } }, "Cancel")),
|
|
].filter(Boolean));
|
|
};
|
|
render();
|
|
});
|
|
return box;
|
|
}
|
|
|
|
async loadHistory() {
|
|
const room = this.sync.target;
|
|
if (!room) return;
|
|
this.historyEl.replaceChildren(h("p", { class: "rs-muted" }, "Loading…"));
|
|
const snaps = await this.run(() => this.client.request("GET", `/rigshare/api/snapshots?room=${encodeURIComponent(room.key)}`));
|
|
if (!snaps) return;
|
|
if (!snaps.length) {
|
|
this.historyEl.replaceChildren(h("p", { class: "rs-muted" }, "No snapshots yet. They are taken automatically every few minutes while people edit."));
|
|
return;
|
|
}
|
|
this.historyEl.replaceChildren(...snaps.map((snap) => h("div", { class: "rs-row-item" },
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-ellipsis" }, snap.label || "Auto", h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
|
|
h("div", { class: "rs-muted rs-small" }, `${timeAgo(snap.time * 1000)} · ${snap.author}`)),
|
|
h("button", { class: "rs-btn rs-btn-icon", title: "Open a private copy", onclick: () => this.openSnapshot(snap) }, h("i", { class: "pi pi-external-link" })),
|
|
this.sync.canEdit ? h("button", {
|
|
class: "rs-btn rs-btn-icon", title: "Restore for everyone", onclick: async () => {
|
|
if (!(await this.confirm("Restore snapshot", `Restore "${snap.label || "Auto"}" for everyone in this workflow?`))) return;
|
|
await this.run(() => this.client.request("POST", `/rigshare/api/snapshots/${snap.id}/restore`), "Snapshot restored");
|
|
this.loadHistory();
|
|
},
|
|
}, h("i", { class: "pi pi-replay" })) : null)));
|
|
}
|
|
|
|
async openSnapshot(snap) {
|
|
const data = await this.run(() => this.client.request("GET", `/rigshare/api/snapshots/${snap.id}`));
|
|
if (!data) return;
|
|
const doc = structuredClone(data.doc);
|
|
if (doc.extra) delete doc.extra.rigshare; // a private copy, not the live room
|
|
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snap.label || snap.id}).json`);
|
|
}
|
|
|
|
roomsSection() {
|
|
const c = this.client;
|
|
const rooms = c.rooms || [];
|
|
const byId = new Map(c.users.map((u) => [u.id, u]));
|
|
const rows = rooms.map((r) => {
|
|
const here = r.key === this.sync.targetKey;
|
|
const members = r.members.map((id) => byId.get(id)).filter(Boolean);
|
|
return h("div", { class: `rs-row-item rs-room ${here ? "here" : ""}`, title: r.key.replace(/^file:workflows\//, "") },
|
|
h("i", { class: `pi ${r.kind === "file" ? "pi-file" : "pi-file-edit"} rs-row-icon` }),
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-name rs-ellipsis" }, r.name, r.restricted ? h("i", { class: "pi pi-lock rs-inline-icon rs-muted", title: "Restricted" }) : null),
|
|
members.length
|
|
? h("div", { class: "rs-avatars" }, ...members.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))
|
|
: h("div", { class: "rs-muted rs-small" }, `${timeAgo(r.updated * 1000)} · ${r.nodes} nodes`)),
|
|
!here && !members.length && c.perms.admin ? h("button", {
|
|
class: "rs-btn rs-btn-icon rs-danger", title: "Stop sharing (remove from this list)",
|
|
onclick: async () => {
|
|
if (!(await this.confirm("Stop sharing", `Remove "${r.name}" from the shared workflows? A snapshot is kept in its history.`))) return;
|
|
await this.run(() => c.request("DELETE", `/rigshare/api/room?key=${encodeURIComponent(r.key)}`), `"${r.name}" is no longer shared`);
|
|
},
|
|
}, h("i", { class: "pi pi-trash" })) : null,
|
|
here ? h("span", { class: "rs-chip rs-chip-edit" }, "open") : h("button", {
|
|
class: "rs-btn rs-btn-icon", title: "Open",
|
|
onclick: () => this.run(() => this.sync.openRoom(r)),
|
|
}, h("i", { class: "pi pi-arrow-right" })));
|
|
});
|
|
return this.section("pi-share-alt", "Shared workflows",
|
|
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nothing shared yet. Open a saved workflow or edit a tab.")]));
|
|
}
|
|
|
|
// ----- files ----------------------------------------------------------
|
|
|
|
filesView() {
|
|
this.filesEl ??= h("div", {});
|
|
this.loadFiles();
|
|
return [this.filesEl];
|
|
}
|
|
|
|
async loadFiles() {
|
|
const tree = await this.run(() => this.client.request("GET", "/rigshare/api/workspace"));
|
|
if (!tree) return;
|
|
this.tree = tree;
|
|
const destinations = [
|
|
tree.private ? { folder: tree.private.path, label: "My files" } : null,
|
|
...tree.shared.filter((f) => f.role === "edit").map((f) => ({ folder: f.path, label: f.name })),
|
|
tree.common.role === "edit" ? { folder: "", label: "Common" } : null,
|
|
].filter(Boolean);
|
|
|
|
const fileRow = (file, folder, canMoveOut) => h("div", { class: "rs-row-item rs-file" },
|
|
h("i", { class: "pi pi-file rs-row-icon" }),
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-name rs-ellipsis", title: file.path.slice("workflows/".length) }, file.name.replace(/\.json$/, "")),
|
|
h("div", { class: "rs-muted rs-small" }, timeAgo(file.modified * 1000))),
|
|
canMoveOut && destinations.length > 1 ? h("select", {
|
|
class: "rs-input rs-select", title: "Move to another folder",
|
|
onchange: async (e) => {
|
|
const dest = e.target.value;
|
|
e.target.value = "__";
|
|
if (dest === "__") return;
|
|
const base = file.path.split("/").pop();
|
|
const target = dest ? `workflows/${dest}/${base}` : `workflows/${base}`;
|
|
await this.run(() => this.sync.movePath(file.path, target), "Moved");
|
|
this.loadFiles();
|
|
},
|
|
}, h("option", { value: "__" }, "Move to…"), ...destinations.filter((d) => d.folder !== folder).map((d) =>
|
|
h("option", { value: d.folder }, d.label))) : null,
|
|
h("button", { class: "rs-btn rs-btn-icon", title: "Open", onclick: () => this.run(() => this.sync.openPath(file.path)) },
|
|
h("i", { class: "pi pi-arrow-right" })));
|
|
|
|
const empty = (text) => h("p", { class: "rs-muted rs-small" }, text);
|
|
const sections = [];
|
|
if (tree.private) {
|
|
sections.push(this.section("pi-user", "My files",
|
|
h("p", { class: "rs-muted rs-small" }, "Only you (and admins) can see these."),
|
|
...(tree.private.files.length ? tree.private.files.map((f) => fileRow(f, tree.private.path, true)) : [empty("Nothing here yet. Save a workflow and choose My files.")])));
|
|
}
|
|
|
|
const folderBlocks = tree.shared.map((f) => {
|
|
const open = this.openFolders?.has(f.path);
|
|
const owner = f.owner ? this.client.users.find((u) => u.key === f.owner)?.name ?? `@${f.owner}` : null;
|
|
const head = h("div", { class: "rs-row-item rs-folder" },
|
|
h("button", { class: "rs-btn rs-btn-icon rs-btn-ghost", title: open ? "Collapse" : "Expand", onclick: () => {
|
|
this.openFolders ??= new Set();
|
|
open ? this.openFolders.delete(f.path) : this.openFolders.add(f.path);
|
|
this.loadFiles();
|
|
} }, h("i", { class: `pi ${open ? "pi-chevron-down" : "pi-chevron-right"}` })),
|
|
h("i", { class: `pi ${f.restricted ? "pi-lock" : "pi-folder"} rs-row-icon` }),
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-name rs-ellipsis" }, f.name),
|
|
h("div", { class: "rs-muted rs-small" }, `${f.files.length} workflow${f.files.length === 1 ? "" : "s"}${owner ? ` · owner ${owner}` : ""}${f.role === "view" ? " · view only" : ""}`)),
|
|
f.manage ? h("button", { class: `rs-btn rs-btn-sm ${this.folderAccess === f.path ? "active" : ""}`, onclick: () => {
|
|
this.folderAccess = this.folderAccess === f.path ? null : f.path;
|
|
this.folderDraft = null;
|
|
this.loadFiles();
|
|
} }, "Access") : null,
|
|
f.manage ? h("button", { class: "rs-btn rs-btn-icon", title: "Rename folder", onclick: async () => {
|
|
const name = await this.prompt("Rename folder", `New name for "${f.name}"`, f.name);
|
|
if (!name || name === f.name) return;
|
|
await this.run(() => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: f.path, name }), "Folder renamed");
|
|
this.loadFiles();
|
|
} }, h("i", { class: "pi pi-pencil" })) : null,
|
|
f.manage ? h("button", { class: "rs-btn rs-btn-icon rs-danger", title: "Delete folder (must be empty)", onclick: async () => {
|
|
if (!(await this.confirm("Delete folder", `Delete the empty folder "${f.name}"?`))) return;
|
|
await this.run(() => this.client.request("DELETE", `/rigshare/api/workspace/folders?path=${encodeURIComponent(f.path)}`), "Folder deleted");
|
|
this.loadFiles();
|
|
} }, h("i", { class: "pi pi-trash" })) : null);
|
|
const parts = [head];
|
|
if (this.folderAccess === f.path) {
|
|
parts.push(this.accessEditorFor({
|
|
load: async () => {
|
|
const t = await this.client.request("GET", "/rigshare/api/workspace");
|
|
const folder = t.shared.find((x) => x.path === f.path);
|
|
const members = await this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(f.path)}`);
|
|
return { owner: folder?.owner, restricted: !!folder?.restricted, members: members.members || {} };
|
|
},
|
|
save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: f.path, restricted, members }),
|
|
everyoneHint: "Anyone with an account can open its workflows, with their usual permissions",
|
|
close: () => { this.folderAccess = null; this.loadFiles(); },
|
|
draftKey: "folderDraft",
|
|
}));
|
|
}
|
|
if (open) {
|
|
parts.push(h("div", { class: "rs-folder-files" },
|
|
...(f.files.length ? f.files.map((file) => fileRow(file, f.path, f.manage)) : [empty("Empty folder.")])));
|
|
}
|
|
return h("div", { class: "rs-folder-block" }, ...parts);
|
|
});
|
|
const newFolderBtn = tree.can_create ? h("button", { class: "rs-btn", onclick: async () => {
|
|
const name = await this.prompt("New shared folder", "Folder name");
|
|
if (!name) return;
|
|
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/folders", { name }), "Folder created");
|
|
this.loadFiles();
|
|
} }, h("i", { class: "pi pi-folder-plus" }), "New shared folder") : null;
|
|
sections.push(this.section("pi-folder", "Shared folders",
|
|
...(folderBlocks.length ? folderBlocks : [empty("No shared folders yet.")]),
|
|
newFolderBtn ? h("div", { class: "rs-actions" }, newFolderBtn) : null));
|
|
|
|
sections.push(this.section("pi-globe", "Common",
|
|
h("p", { class: "rs-muted rs-small" }, "Top-level workflows, visible to everyone."),
|
|
...(tree.common.files.length ? tree.common.files.map((f) => fileRow(f, "", this.client.perms.admin)) : [empty("No workflows at the top level.")])));
|
|
this.filesEl.replaceChildren(...sections);
|
|
}
|
|
|
|
// ----- people ---------------------------------------------------------
|
|
|
|
peopleView() {
|
|
const c = this.client;
|
|
const isAdmin = c.perms.admin;
|
|
const users = [...c.users].sort((a, b) => (a.id === c.self.id ? -1 : b.id === c.self.id ? 1 : a.joined - b.joined));
|
|
const rows = users.map((u) => {
|
|
const self = u.id === c.self.id;
|
|
const following = this.presence.following === u.id;
|
|
const toggles = [];
|
|
if (isAdmin && !self && !u.perms.admin) {
|
|
for (const perm of ["edit", "queue", "manager"]) {
|
|
toggles.push(h("button", {
|
|
class: `rs-toggle ${u.perms[perm] ? "on" : ""}`,
|
|
title: `${u.perms[perm] ? "Revoke" : "Grant"} ${perm} permission${u.kind === "guest" ? "" : ` for @${u.key}`}`,
|
|
onclick: () => this.setPerm(u, perm, !u.perms[perm]),
|
|
}, { edit: "Edit", queue: "Queue", manager: "Manager" }[perm]));
|
|
}
|
|
}
|
|
const where = c.rooms.find((r) => r.key === u.room)?.name;
|
|
return h("div", { class: "rs-row-item rs-person" },
|
|
h("span", { class: "rs-avatar", style: `background:${u.color}` }, initials(u.name)),
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
|
|
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
|
|
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
|
|
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"))),
|
|
self ? null : h("button", {
|
|
class: `rs-btn rs-btn-icon ${following ? "rs-btn-primary" : ""}`,
|
|
title: following ? "Stop following" : `Follow ${u.name} (jump to their tab and view)`,
|
|
onclick: () => this.presence.follow(following ? null : u.id),
|
|
}, h("i", { class: following ? "pi pi-eye-slash" : "pi pi-eye" })));
|
|
});
|
|
return [this.section("pi-users", `Online (${users.length})`, ...rows)];
|
|
}
|
|
|
|
async setPerm(user, perm, value) {
|
|
const perms = { [perm]: value };
|
|
if (user.kind === "user") await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(user.key)}`, { perms }));
|
|
else await this.run(() => this.client.request("POST", `/rigshare/api/guests/${encodeURIComponent(user.guest_id)}`, perms));
|
|
}
|
|
|
|
// ----- chat -----------------------------------------------------------
|
|
|
|
sendChat() {
|
|
const text = this.chatInput.value.trim();
|
|
if (!text) return;
|
|
if (this.client.send({ type: "chat", text })) this.chatInput.value = "";
|
|
}
|
|
|
|
chatLine(m) {
|
|
if (m.system) return h("div", { class: "rs-msg rs-msg-system" }, h("span", { class: "rs-time" }, clock(m.ts)), m.text);
|
|
const mine = m.from === this.client.self?.id;
|
|
return h("div", { class: `rs-msg ${mine ? "mine" : ""}` },
|
|
h("div", { class: "rs-msg-head" },
|
|
h("span", { class: "rs-msg-name", style: `color:${m.color}` }, m.name),
|
|
h("span", { class: "rs-time" }, clock(m.ts))),
|
|
h("div", { class: "rs-msg-text" }, m.text));
|
|
}
|
|
|
|
renderChatLog() {
|
|
this.chatLog.replaceChildren(...this.client.chat.slice(-200).map((m) => this.chatLine(m)));
|
|
this.scrollChat();
|
|
}
|
|
|
|
scrollChat() {
|
|
requestAnimationFrame(() => { this.chatLog.scrollTop = this.chatLog.scrollHeight; });
|
|
}
|
|
|
|
onChat(m) {
|
|
const atBottom = this.chatLog.scrollHeight - this.chatLog.scrollTop - this.chatLog.clientHeight < 40;
|
|
this.chatLog.append(this.chatLine(m));
|
|
while (this.chatLog.childElementCount > 300) this.chatLog.firstChild.remove();
|
|
if (atBottom || m.from === this.client.self?.id) this.scrollChat();
|
|
const seen = this.visible && this.view === "chat" && !document.hidden;
|
|
if (!seen && !m.system && m.from !== this.client.self?.id) {
|
|
this.unread++;
|
|
this.onBadge?.(this.badge);
|
|
this.onNotify?.(m);
|
|
if (this.chatToasts && !this.visible) this.toast("info", m.text, m.name);
|
|
this.renderNav();
|
|
}
|
|
}
|
|
|
|
// ----- server ---------------------------------------------------------
|
|
|
|
serverView() {
|
|
const st = this.client.stats;
|
|
const floating = this.widget.enabled;
|
|
const pop = h("button", {
|
|
class: `rs-btn ${floating ? "active" : ""}`, title: "Show a small floating monitor over the canvas",
|
|
onclick: () => this.widget.toggle(),
|
|
}, h("i", { class: "pi pi-window-maximize" }), floating ? "Floating: on" : "Pop out");
|
|
if (!st) return [this.section("pi-server", "Server", h("p", { class: "rs-muted" }, "Waiting for data…"), pop)];
|
|
const items = [];
|
|
if (st.cpu != null) items.push(meter("CPU", st.cpu, `${Math.round(st.cpu)}%`));
|
|
if (st.ram_total) items.push(meter("RAM", (st.ram_used / st.ram_total) * 100, `${st.ram_used} / ${st.ram_total} GB`));
|
|
const gpus = (st.gpus || []).map((g) => {
|
|
const extra = [g.temp != null ? `${g.temp}°C` : null, g.power != null ? `${g.power} / ${g.power_limit} W` : null].filter(Boolean).join(" · ");
|
|
return h("div", { class: "rs-gpu" },
|
|
h("div", { class: "rs-gpu-name" }, h("span", {}, `GPU ${g.index} · ${g.name}`), h("span", { class: "rs-muted" }, extra)),
|
|
meter("Load", g.util, `${g.util}%`),
|
|
meter("VRAM", (g.vram_used / g.vram_total) * 100, `${g.vram_used.toFixed(1)} / ${g.vram_total.toFixed(0)} GB`));
|
|
});
|
|
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)),
|
|
gpus.length ? this.section("pi-bolt", "GPUs", ...gpus) : null].filter(Boolean);
|
|
}
|
|
|
|
// ----- account --------------------------------------------------------
|
|
|
|
accountView() {
|
|
const me = this.client.self;
|
|
const profile = this.section("pi-user", "Profile",
|
|
h("div", { class: "rs-me" },
|
|
h("span", { class: "rs-avatar rs-avatar-lg", style: `background:${me.color}` }, initials(me.name)),
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-name" }, me.name),
|
|
h("div", { class: "rs-muted rs-small" }, me.key ? `@${me.key}` : "guest"),
|
|
h("div", { class: "rs-chips" }, ...roleChips(me.perms)))),
|
|
me.kind === "user" ? this.profileForm(me) : null,
|
|
h("div", { class: "rs-actions" },
|
|
h("button", { class: "rs-btn", onclick: () => this.client.logout() }, h("i", { class: "pi pi-sign-out" }), "Log out")));
|
|
const out = [profile];
|
|
if (me.kind === "user") {
|
|
out.push(this.passwordSection());
|
|
this.keysEl ??= h("div", {});
|
|
out.push(this.section("pi-key", "API keys",
|
|
h("p", { class: "rs-muted rs-small" }, "Use a key to call the ComfyUI API from scripts or other apps with your permissions: ",
|
|
h("code", {}, "Authorization: Bearer <key>")),
|
|
this.newKey ? h("div", { class: "rs-newkey" },
|
|
h("div", { class: "rs-small" }, "Copy it now — it will not be shown again."),
|
|
h("div", { class: "rs-row" }, h("code", { class: "rs-grow rs-keytext" }, this.newKey),
|
|
h("button", { class: "rs-btn rs-btn-icon", title: "Copy", onclick: async () => { await navigator.clipboard?.writeText(this.newKey); this.toast("success", "Copied"); } }, h("i", { class: "pi pi-copy" })))) : null,
|
|
this.keysEl, this.keyForm()));
|
|
this.loadKeys();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
profileForm(me) {
|
|
const display = h("input", { class: "rs-input", value: me.name, maxLength: 32 });
|
|
return h("form", {
|
|
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
|
|
onsubmit: async (e) => {
|
|
e.preventDefault();
|
|
await this.run(() => this.client.request("POST", "/rigshare/api/me", { display_name: display.value.trim() }), "Display name saved");
|
|
},
|
|
}, h("label", { class: "rs-label" }, "Display name"),
|
|
h("div", { class: "rs-row" }, display, h("button", { class: "rs-btn", type: "submit" }, "Save")));
|
|
}
|
|
|
|
passwordSection() {
|
|
const cur = h("input", { class: "rs-input", type: "password", placeholder: "Current password", autocomplete: "current-password" });
|
|
const next = h("input", { class: "rs-input", type: "password", placeholder: "New password (min 8)", autocomplete: "new-password" });
|
|
return this.section("pi-lock", "Password", h("form", {
|
|
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
|
|
onsubmit: async (e) => {
|
|
e.preventDefault();
|
|
const res = await this.run(() => this.client.request("POST", "/rigshare/api/me",
|
|
{ current_password: cur.value, new_password: next.value }), "Password changed; other sessions were signed out");
|
|
if (res?.token) this.client.setToken(res.token);
|
|
if (res) { cur.value = ""; next.value = ""; }
|
|
},
|
|
}, cur, next, h("button", { class: "rs-btn", type: "submit" }, "Change password")));
|
|
}
|
|
|
|
async loadKeys() {
|
|
const keys = await this.run(() => this.client.request("GET", "/rigshare/api/me/keys"));
|
|
if (!keys) return;
|
|
this.keysEl.replaceChildren(...keys.map((k) => h("div", { class: "rs-row-item" },
|
|
h("i", { class: "pi pi-key rs-row-icon" }),
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-name rs-ellipsis" }, k.name),
|
|
h("div", { class: "rs-muted rs-small" }, `${k.hint} · ${k.last_used ? `used ${timeAgo(k.last_used * 1000)}` : "never used"}`)),
|
|
h("button", {
|
|
class: "rs-btn rs-btn-icon rs-danger", title: "Revoke", onclick: async () => {
|
|
if (!(await this.confirm("Revoke API key", `Revoke "${k.name}"? Apps using it stop working.`))) return;
|
|
await this.run(() => this.client.request("DELETE", `/rigshare/api/me/keys/${k.id}`), "Key revoked");
|
|
this.loadKeys();
|
|
},
|
|
}, h("i", { class: "pi pi-trash" })))));
|
|
}
|
|
|
|
keyForm() {
|
|
const name = h("input", { class: "rs-input", placeholder: "Key name, e.g. Open WebUI", maxLength: 40 });
|
|
return h("form", {
|
|
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
|
|
onsubmit: async (e) => {
|
|
e.preventDefault();
|
|
const res = await this.run(() => this.client.request("POST", "/rigshare/api/me/keys", { name: name.value.trim() || "API key" }));
|
|
if (res) { this.newKey = res.key; this.renderBody(); }
|
|
},
|
|
}, h("div", { class: "rs-row" }, name, h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, h("i", { class: "pi pi-plus" }), "Create")));
|
|
}
|
|
|
|
// ----- admin ----------------------------------------------------------
|
|
|
|
adminView() {
|
|
this.adminAccounts ??= h("div", {});
|
|
this.adminSettings ??= h("div", {});
|
|
this.loadAdmin();
|
|
return [
|
|
this.section("pi-users", "Accounts", this.adminAccounts, this.createAccountForm()),
|
|
this.section("pi-cog", "Server settings", this.adminSettings),
|
|
];
|
|
}
|
|
|
|
async loadAdmin() {
|
|
const [users, config] = await Promise.all([
|
|
this.run(() => this.client.request("GET", "/rigshare/api/users")),
|
|
this.run(() => this.client.request("GET", "/rigshare/api/config")),
|
|
]);
|
|
if (users) this.adminAccounts.replaceChildren(...users.map((u) => this.accountRow(u)));
|
|
if (config) this.adminSettings.replaceChildren(this.configForm(config));
|
|
}
|
|
|
|
accountRow(u) {
|
|
const me = u.username === this.client.self?.key;
|
|
const toggle = (perm, label) => u.perms.admin && perm !== "admin" ? h("button", {
|
|
class: "rs-toggle on locked", disabled: true, title: `Admins always have ${label}. Remove Admin to control it.`,
|
|
}, label) : h("button", {
|
|
class: `rs-toggle ${u.perms[perm] ? "on" : ""}`, title: `Toggle ${label}`,
|
|
onclick: async () => {
|
|
await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { perms: { [perm]: !u.perms[perm] } }));
|
|
this.loadAdmin();
|
|
},
|
|
}, label);
|
|
return h("div", { class: "rs-row-item" },
|
|
h("div", { class: "rs-grow rs-min0" },
|
|
h("div", { class: "rs-name rs-ellipsis" }, u.display_name, h("span", { class: "rs-muted" }, ` @${u.username}${me ? " (you)" : ""}`)),
|
|
h("div", { class: "rs-chips" }, toggle("edit", "Edit"), toggle("queue", "Queue"), toggle("manager", "Manager"), toggle("admin", "Admin"))),
|
|
h("button", {
|
|
class: "rs-btn rs-btn-icon", title: "Change display name", onclick: async () => {
|
|
const name = await this.prompt("Display name", `Name shown to others for @${u.username}${me ? " (you)" : ""}`, u.display_name);
|
|
if (name == null || name.trim() === u.display_name) return;
|
|
const ok = await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { display_name: name.trim() }), "Display name saved");
|
|
if (ok) this.loadAdmin();
|
|
},
|
|
}, h("i", { class: "pi pi-id-card" })),
|
|
h("button", {
|
|
class: "rs-btn rs-btn-icon", title: "Change login username", onclick: async () => {
|
|
const name = await this.prompt("Change username", `New login username for @${u.username}${me ? " (you)" : ""}. Letters, digits, _ . -`, u.username);
|
|
if (!name || name.trim() === u.username) return;
|
|
const ok = await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { username: name.trim() }), `Renamed to @${name.trim()}`);
|
|
if (ok) this.loadAdmin();
|
|
},
|
|
}, h("i", { class: "pi pi-pencil" })),
|
|
h("button", {
|
|
class: "rs-btn rs-btn-icon", title: "Set password", onclick: async () => {
|
|
const pw = await this.prompt("Set password", `New password for @${u.username} (min 8 characters)`);
|
|
if (!pw) return;
|
|
await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { password: pw }), "Password changed; their sessions were signed out");
|
|
},
|
|
}, h("i", { class: "pi pi-key" })),
|
|
me ? null : h("button", {
|
|
class: "rs-btn rs-btn-icon rs-danger", title: "Delete account", onclick: async () => {
|
|
if (!(await this.confirm("Delete account", `Delete @${u.username}? This cannot be undone.`))) return;
|
|
await this.run(() => this.client.request("DELETE", `/rigshare/api/users/${encodeURIComponent(u.username)}`), "Account deleted");
|
|
this.loadAdmin();
|
|
},
|
|
}, h("i", { class: "pi pi-trash" })));
|
|
}
|
|
|
|
createAccountForm() {
|
|
const username = h("input", { class: "rs-input", placeholder: "Username", autocomplete: "off" });
|
|
const display = h("input", { class: "rs-input", placeholder: "Display name (optional)", autocomplete: "off" });
|
|
const password = h("input", { class: "rs-input", placeholder: "Password (min 8)", type: "password", autocomplete: "new-password" });
|
|
const check = (label, checked) => { const box = h("input", { type: "checkbox", checked }); return [box, h("label", { class: "rs-check" }, box, label)]; };
|
|
const [edit, editL] = check("Edit", true);
|
|
const [queue, queueL] = check("Queue", true);
|
|
const [manager, managerL] = check("Manager", false);
|
|
const [admin, adminL] = check("Admin", false);
|
|
return h("details", { class: "rs-details" }, h("summary", {}, h("i", { class: "pi pi-user-plus" }), "New account"),
|
|
h("form", {
|
|
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
|
|
onsubmit: async (e) => {
|
|
e.preventDefault();
|
|
const ok = await this.run(() => this.client.request("POST", "/rigshare/api/users", {
|
|
username: username.value.trim(), password: password.value, display_name: display.value.trim() || null,
|
|
perms: { edit: edit.checked, queue: queue.checked, manager: manager.checked, admin: admin.checked },
|
|
}), "Account created");
|
|
if (ok) { username.value = display.value = password.value = ""; this.loadAdmin(); }
|
|
},
|
|
}, username, display, password,
|
|
h("div", { class: "rs-row rs-wrap" }, editL, queueL, managerL, adminL),
|
|
h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, "Create account")));
|
|
}
|
|
|
|
configForm(cfg) {
|
|
const text = (value, placeholder) => h("input", { class: "rs-input", value: value ?? "", placeholder });
|
|
const box = (checked) => h("input", { type: "checkbox", checked: !!checked });
|
|
const f = {
|
|
name: text(cfg.server_name, "Server name"),
|
|
login: box(cfg.require_login),
|
|
guests: box(cfg.allow_guests),
|
|
broadcast: box(cfg.broadcast_execution),
|
|
protect: box(cfg.api_protection?.enabled),
|
|
hideAccount: box(cfg.hide_comfy_account),
|
|
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 }),
|
|
};
|
|
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));
|
|
return h("form", {
|
|
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
|
|
onsubmit: async (e) => {
|
|
e.preventDefault();
|
|
const ok = await this.run(() => this.client.request("PATCH", "/rigshare/api/config", {
|
|
server_name: f.name.value.trim() || "RigShare",
|
|
require_login: f.login.checked,
|
|
allow_guests: f.guests.checked,
|
|
broadcast_execution: f.broadcast.checked,
|
|
hide_comfy_account: f.hideAccount.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),
|
|
}), "Settings saved");
|
|
if (ok) { this.client.server.name = ok.server_name; this.renderHeader(); }
|
|
},
|
|
},
|
|
h("label", { class: "rs-label" }, "Server name"), f.name,
|
|
line(f.login, "Require login", "Everyone signs in before ComfyUI loads. API clients use API keys."),
|
|
line(f.guests, "Allow guests (only without required login)", "Visitors can watch and chat without an account"),
|
|
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.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("button", { class: "rs-btn rs-btn-primary", type: "submit" }, h("i", { class: "pi pi-save" }), "Save settings"));
|
|
}
|
|
}
|