Add stack Update, ComfyUI model manager, and slim ComfyUI stack.

Server UI gains Update on stack cards, ComfyUI Models tab with workflow
scan and downloads, and centralized comfyui_config. Model catalog and
download scripts move from stacks/comfyui to server-ui so ComfyUI stays a
minimal Docker wrapper for easier image updates.
This commit is contained in:
tomasz-syn-grzegorza
2026-07-05 18:45:17 +00:00
parent 359afb3a59
commit 73e4fc005e
33 changed files with 3604 additions and 307 deletions
+50 -1
View File
@@ -21,6 +21,7 @@ Sprawdzenie: `systemctl status server-ui` lub `docker compose --profile server-u
| Server UI | **8091** | `http://HOST:8091` |
| CLI (zakładka) | — | ten sam URL, zakładka **CLI** lub `#cli` |
| Pliki (zakładka) | — | ten sam URL, zakładka **Pliki** lub `#files` |
| ComfyUI Modele (zakładka) | — | ten sam URL, zakładka **ComfyUI Modele** lub `#comfyui-models` |
| GPU Fan (zakładka) | — | ten sam URL, zakładka **GPU Fan** |
| gpu-fan agent API | **18090** | tylko localhost (proxy z Server UI) |
@@ -70,10 +71,51 @@ Tutorial: [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutoria
Porty edytowalne w UI (zapis do `stacks/<name>/.env`). Szczegóły: [`../../coding-agent/SERVER-UI-PORT-CONFIG.md`](../../coding-agent/SERVER-UI-PORT-CONFIG.md)
## Update stacków
Przycisk **Update** na karcie stacka (Server UI → Stacki):
1. `docker compose pull` — pobiera najnowszy obraz (timeout do ~10 min).
2. Gdy stack **działa**`up -d --force-recreate` (krótka przerwa w usłudze).
3. Gdy stack **zatrzymany** — tylko pull; uruchom **Start**, aby użyć nowej wersji.
Dane użytkownika są na dysku hosta (`/data/apps/*`) i **nie znikają** przy rebuildzie kontenera:
| Stack | Katalogi danych |
|-------|-----------------|
| LocalAI | `/data/apps/localai/{models,backends,configuration,images,data}` |
| ComfyUI | `/data/apps/comfyui/{models,workflows,custom_nodes,input,output,cache,storage}` |
| vLLM | `/data/apps/vllm/huggingface` (cache Hugging Face) |
| NPMPlus | `/data/apps/npmplus` |
CLI (pull bez startu): `./scripts/pull.sh` w katalogu danego stacka.
### Wdrożenie zmian Server UI (prod)
```bash
rsync -a --delete --exclude '.venv' --exclude '.env' --exclude '__pycache__' \
~/cursor/ubuntu-bare-metal/stacks/server-ui/ /opt/server-ui/
sudo systemctl restart server-ui
```
## ComfyUI integracja
Logika katalogu modeli, pobierania i skanu workflow jest w **Server UI**, nie w stacku ComfyUI. Stack [`../comfyui/`](../comfyui/) to tylko Docker (compose + start/pull).
| Plik | Rola |
|------|------|
| `comfyui-models.catalog.yaml` | Kuratorowana lista modeli (sekcja „Katalog repo”) |
| `comfyui_models.py` | API + joby pobierania |
| `workflow_scanner.py` | Skan workflow JSON |
| `scripts/comfyui/*.sh` | CLI pobierania (używane też przez backend) |
Po update obrazu ComfyUI: checklist w [`../comfyui/README.md`](../comfyui/README.md#checklist-po-update-obrazu).
## Bezpieczeństwo
- Odczyt statusu stacków/logów/GPU — bez klucza (LAN).
- **Start / Stop / Restart** stacków, **GPU Fan**, **Pliki** (CRUD) i **CLI** — nagłówek `X-API-Key` (CLI: klucz w query WebSocket `?api_key=`).
- **Start / Stop / Restart / Update** stacków, **GPU Fan**, **Pliki** (CRUD), **ComfyUI Modele** (pobieranie, anulowanie, usuwanie) i **CLI** — nagłówek `X-API-Key` (CLI: klucz w query WebSocket `?api_key=`).
- Przy `SERVER_UI_HOST=0.0.0.0` klucz jest **wymagany** przy starcie usługi.
## Tryb dev (z repo)
@@ -88,6 +130,9 @@ cp ../control-plane/.env.example ../control-plane/.env
```
stacks/server-ui/
├── app.py
├── comfyui_config.py # stałe ścieżek ComfyUI (container, mounty)
├── comfyui_models.py
├── comfyui-models.catalog.yaml
├── Dockerfile
├── docker-compose.yml
├── compose_runner.py
@@ -97,6 +142,10 @@ stacks/server-ui/
├── static/vendor/xterm/ # xterm.js dla zakładki CLI
├── cli_pty.py
└── scripts/
├── comfyui/ # pobieranie modeli (CLI + backend Server UI)
│ ├── download-model.sh
│ ├── download-file.sh
│ └── list-models.sh
├── install-control-plane.sh # gpu-fan + server-ui (menu)
├── install.sh # native only
├── install-docker.sh # docker only
+213 -10
View File
@@ -20,6 +20,7 @@ from compose_runner import (
ComposeError,
PortConfigError,
StackConfig,
find_compose_file,
find_running_gpu_stacks,
get_container_state,
get_logs,
@@ -28,8 +29,26 @@ from compose_runner import (
stack_restart,
stack_start,
stack_stop,
stack_update,
)
from cli_pty import (
CliSessionLimitError,
get_session_manager,
init_session_manager,
run_pty_session,
sessions_available,
)
from comfyui_models import (
ComfyUIModelError,
DeleteModelError,
JobConflictError,
JobNotFoundError,
ModelNotFoundError,
disk_usage,
get_download_manager,
init_download_manager,
list_installed,
)
from cli_pty import CliSessionLimitError, run_pty_session, sessions_available
from file_explorer import FileExplorer, FileExplorerError
from gpu_info import GpuInfoError, query_gpu
from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request, resolve_agent_url
@@ -37,10 +56,19 @@ from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request,
STACK_DIR = Path(__file__).resolve().parent
# Unified control-plane env (see stacks/control-plane/env_loader.py)
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
if _cp not in sys.path and Path(_cp).exists():
sys.path.insert(0, _cp)
from env_loader import api_key_source, load_control_plane_env # noqa: E402
_local_env_loader = STACK_DIR / "env_loader.py"
if _local_env_loader.is_file():
if str(STACK_DIR) not in sys.path:
sys.path.insert(0, str(STACK_DIR))
else:
for _cp in (
"/opt/control-plane",
"/repo/stacks/control-plane",
str(STACK_DIR.parent / "control-plane"),
):
if _cp not in sys.path and Path(_cp).joinpath("env_loader.py").is_file():
sys.path.insert(0, _cp)
from env_loader import api_key_source, load_control_plane_env, resolve_repo_root # noqa: E402
STATIC_DIR = STACK_DIR / "static"
CONFIG_PATH = STACK_DIR / "stacks.yaml"
@@ -60,9 +88,7 @@ GPU_FAN_AGENT_URL = resolve_agent_url(
ENV.get("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090"),
API_KEY,
)
REPO_ROOT = Path(
ENV.get("REPO_ROOT", str(STACK_DIR.parent.parent))
).resolve()
REPO_ROOT = resolve_repo_root(STACK_DIR, ENV)
STACKS_BASE = REPO_ROOT / "stacks"
STACKS: list[StackConfig] = load_stacks(CONFIG_PATH, STACKS_BASE)
@@ -76,6 +102,13 @@ CLI_ENABLED = ENV.get("CLI_ENABLED", "1").strip().lower() in ("1", "true", "yes"
CLI_SHELL = ENV.get("CLI_SHELL", "/bin/bash")
CLI_DEFAULT_CWD = ENV.get("CLI_DEFAULT_CWD", os.path.expanduser("~"))
CLI_MAX_SESSIONS = int(ENV.get("CLI_MAX_SESSIONS", "5"))
CLI_SESSION_TTL = int(ENV.get("CLI_SESSION_TTL", "300"))
DATA_ROOT = Path(ENV.get("DATA_ROOT", "/data"))
COMFYUI_JOB_TTL = int(ENV.get("COMFYUI_JOB_TTL", "3600"))
init_session_manager(session_ttl=CLI_SESSION_TTL, max_sessions=CLI_MAX_SESSIONS)
init_download_manager(REPO_ROOT, DATA_ROOT, job_ttl=COMFYUI_JOB_TTL)
app = FastAPI(title="Server UI", version="1.0.0")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@@ -104,6 +137,15 @@ class AuthVerifyBody(BaseModel):
api_key: str = ""
class ComfyUIDownloadBody(BaseModel):
model_id: str = Field(min_length=1)
class ComfyUIFileDownloadBody(BaseModel):
url: str = Field(min_length=1)
relative_path: str = Field(min_length=1)
def get_stack(stack_id: str) -> StackConfig:
stack = STACK_BY_ID.get(stack_id)
if not stack:
@@ -135,6 +177,22 @@ def require_file_auth(request: Request) -> None:
require_mutation_auth(request)
def require_comfyui_auth(request: Request) -> None:
require_file_auth(request)
def _comfyui_exc_to_http(exc: ComfyUIModelError) -> HTTPException:
if isinstance(exc, ModelNotFoundError):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, JobNotFoundError):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, JobConflictError):
return HTTPException(status_code=409, detail=str(exc))
if isinstance(exc, DeleteModelError):
return HTTPException(status_code=403, detail=str(exc))
return HTTPException(status_code=500, detail=str(exc))
def _cli_ws_auth_ok(websocket: WebSocket) -> bool:
if not _is_lan_bind() or not API_KEY:
return True
@@ -253,6 +311,16 @@ def api_restart(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict
return {"ok": True, "action": "restart", "stack_id": stack_id, "output": output}
@app.post("/api/stacks/{stack_id}/update")
def api_update(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]:
stack = get_stack(stack_id)
try:
output = stack_update(stack)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "action": "update", "stack_id": stack_id, "output": output}
@app.patch("/api/stacks/{stack_id}/port")
def api_set_port(
stack_id: str,
@@ -313,6 +381,109 @@ async def api_gpu_fan_proxy(
return Response(content=payload, status_code=status, media_type=media_type)
@app.get("/api/comfyui/models")
def api_comfyui_models(_: None = Depends(require_comfyui_auth)) -> dict[str, Any]:
mgr = get_download_manager()
try:
payload = mgr.list_models_response()
payload["disk"] = disk_usage(mgr.data_root)
except ComfyUIModelError as exc:
raise _comfyui_exc_to_http(exc) from exc
return payload
@app.get("/api/comfyui/models/installed")
def api_comfyui_models_installed(_: None = Depends(require_comfyui_auth)) -> dict[str, Any]:
mgr = get_download_manager()
return {"installed": list_installed(mgr.data_root), "data_root": str(mgr.data_root)}
@app.post("/api/comfyui/models/download")
def api_comfyui_models_download(
body: ComfyUIDownloadBody,
_: None = Depends(require_mutation_auth),
) -> dict[str, Any]:
mgr = get_download_manager()
model_id = body.model_id.strip()
try:
if model_id.startswith("wf:"):
job = mgr.start_workflow_download(model_id)
else:
job = mgr.start_download(model_id)
except ComfyUIModelError as exc:
raise _comfyui_exc_to_http(exc) from exc
return {"ok": True, "job": job.to_dict()}
@app.post("/api/comfyui/models/download-file")
def api_comfyui_models_download_file(
body: ComfyUIFileDownloadBody,
_: None = Depends(require_mutation_auth),
) -> dict[str, Any]:
mgr = get_download_manager()
try:
job = mgr.start_file_download(
body.url.strip(),
body.relative_path.strip(),
)
except ComfyUIModelError as exc:
raise _comfyui_exc_to_http(exc) from exc
return {"ok": True, "job": job.to_dict()}
@app.get("/api/comfyui/models/jobs/{job_id}")
def api_comfyui_models_job(
job_id: str,
_: None = Depends(require_comfyui_auth),
) -> dict[str, Any]:
mgr = get_download_manager()
try:
job = mgr.get_job(job_id)
except ComfyUIModelError as exc:
raise _comfyui_exc_to_http(exc) from exc
return {"job": job.to_dict()}
@app.delete("/api/comfyui/models/jobs/{job_id}")
def api_comfyui_models_job_cancel(
job_id: str,
cleanup: bool = Query(default=True),
_: None = Depends(require_mutation_auth),
) -> dict[str, Any]:
mgr = get_download_manager()
try:
job = mgr.cancel_job(job_id, cleanup=cleanup)
except ComfyUIModelError as exc:
raise _comfyui_exc_to_http(exc) from exc
return {"ok": True, "job": job.to_dict()}
@app.delete("/api/comfyui/models/file")
def api_comfyui_models_delete_file(
relative_path: str = Query(min_length=1),
_: None = Depends(require_mutation_auth),
) -> dict[str, Any]:
mgr = get_download_manager()
try:
result = mgr.delete_model_file(relative_path.strip())
except ComfyUIModelError as exc:
raise _comfyui_exc_to_http(exc) from exc
return {"ok": True, **result}
@app.delete("/api/comfyui/models/{model_id}")
def api_comfyui_models_delete(
model_id: str,
_: None = Depends(require_mutation_auth),
) -> dict[str, Any]:
mgr = get_download_manager()
try:
result = mgr.delete_model(model_id.strip())
except ComfyUIModelError as exc:
raise _comfyui_exc_to_http(exc) from exc
return {"ok": True, **result}
@app.get("/api/files")
def api_files_list(
path: str = Query(default="/"),
@@ -400,7 +571,13 @@ async def api_cli_ws(websocket: WebSocket) -> None:
await websocket.close(code=1008, reason="CLI disabled")
return
if not sessions_available(CLI_MAX_SESSIONS):
session_id = websocket.query_params.get("session_id", "").strip() or None
if session_id:
if get_session_manager().get(session_id) is None:
await websocket.close(code=1008, reason="Session expired")
return
elif not sessions_available(CLI_MAX_SESSIONS):
await websocket.close(
code=1008,
reason=f"Limit sesji CLI ({CLI_MAX_SESSIONS}) — spróbuj później",
@@ -416,11 +593,27 @@ async def api_cli_ws(websocket: WebSocket) -> None:
cwd=CLI_DEFAULT_CWD,
env=os.environ.copy(),
max_sessions=CLI_MAX_SESSIONS,
session_id=session_id,
session_ttl=CLI_SESSION_TTL,
)
except CliSessionLimitError:
await websocket.close(code=1008, reason="Session limit")
def _validate_stacks(stacks: list[StackConfig]) -> None:
for stack in stacks:
compose_dir = stack.path
if find_compose_file(compose_dir) is None:
log.error(
"Stack %s: brak compose w %s (REPO_ROOT=%s). "
"Ustaw REPO_ROOT w /opt/control-plane/.env i zrestartuj server-ui.",
stack.id,
compose_dir,
REPO_ROOT,
)
sys.exit(1)
def main() -> None:
if HOST.strip() not in ("127.0.0.1", "localhost", "::1") and not API_KEY:
log.error(
@@ -433,7 +626,11 @@ def main() -> None:
log.error("No stacks loaded from %s", CONFIG_PATH)
sys.exit(1)
_validate_stacks(STACKS)
log.info("Repo root: %s", REPO_ROOT)
if STACKS:
log.info("First stack compose_dir: %s", STACKS[0].compose_dir)
log.info("Stacks: %s", ", ".join(s.id for s in STACKS))
log.info("GPU fan agent: %s", GPU_FAN_AGENT_URL)
if API_KEY:
@@ -442,7 +639,13 @@ def main() -> None:
else:
log.warning("API_KEY not set (proxy may fail agent auth on LAN)")
if CLI_ENABLED:
log.info("CLI: enabled (shell=%s, cwd=%s, max_sessions=%d)", CLI_SHELL, CLI_DEFAULT_CWD, CLI_MAX_SESSIONS)
log.info(
"CLI: enabled (shell=%s, cwd=%s, max_sessions=%d, session_ttl=%ds)",
CLI_SHELL,
CLI_DEFAULT_CWD,
CLI_MAX_SESSIONS,
CLI_SESSION_TTL,
)
else:
log.info("CLI: disabled (CLI_ENABLED=0)")
log.info("Web UI at http://%s:%d", HOST if HOST != "0.0.0.0" else "0.0.0.0", PORT)
+286 -97
View File
@@ -12,6 +12,8 @@ import shlex
import signal
import struct
import termios
import uuid
from dataclasses import dataclass, field
from typing import Any
from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -19,15 +21,17 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
log = logging.getLogger(__name__)
TIOCSWINSZ = termios.TIOCSWINSZ
_active_sessions = 0
_session_lock = asyncio.Lock()
OUTPUT_BUFFER_MAX = 256 * 1024
class CliSessionLimitError(Exception):
pass
class CliSessionNotFoundError(Exception):
pass
def _set_winsize(fd: int, rows: int, cols: int) -> None:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, TIOCSWINSZ, winsize)
@@ -38,25 +42,6 @@ def _parse_shell(shell: str) -> list[str]:
return parts if parts else ["/bin/bash"]
async def _read_pty_loop(
master_fd: int,
websocket: WebSocket,
running: asyncio.Event,
) -> None:
loop = asyncio.get_running_loop()
while running.is_set():
try:
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
except OSError:
break
if not data:
break
try:
await websocket.send_bytes(data)
except Exception:
break
async def _kill_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
@@ -74,34 +59,71 @@ async def _kill_process(process: asyncio.subprocess.Process) -> None:
await process.wait()
def sessions_available(max_sessions: int) -> bool:
return _active_sessions < max_sessions
async def run_pty_session(
websocket: WebSocket,
*,
shell: str,
cwd: str,
env: dict[str, str],
max_sessions: int,
) -> None:
global _active_sessions
async with _session_lock:
if _active_sessions >= max_sessions:
raise CliSessionLimitError(
f"Limit sesji CLI ({max_sessions}) — spróbuj później"
)
_active_sessions += 1
master_fd: int | None = None
process: asyncio.subprocess.Process | None = None
@dataclass
class CliSession:
id: str
master_fd: int
process: asyncio.subprocess.Process
cols: int = 80
rows: int = 24
ws: WebSocket | None = None
output_buffer: bytearray = field(default_factory=bytearray)
running: asyncio.Event = field(default_factory=asyncio.Event)
read_task: asyncio.Task[None] | None = None
running = asyncio.Event()
running.set()
detach_timer: asyncio.Task[None] | None = None
closed: bool = False
def append_output(self, data: bytes) -> None:
self.output_buffer.extend(data)
overflow = len(self.output_buffer) - OUTPUT_BUFFER_MAX
if overflow > 0:
del self.output_buffer[:overflow]
async def send_to_ws(self, data: bytes) -> bool:
if not self.ws:
return False
try:
await self.ws.send_bytes(data)
return True
except Exception:
return False
async def send_json(self, obj: dict[str, Any]) -> bool:
if not self.ws:
return False
try:
await self.ws.send_text(json.dumps(obj))
return True
except Exception:
return False
class CliSessionManager:
def __init__(self, *, session_ttl: int = 300, max_sessions: int = 5) -> None:
self.session_ttl = session_ttl
self.max_sessions = max_sessions
self._sessions: dict[str, CliSession] = {}
self._lock = asyncio.Lock()
def sessions_available(self) -> bool:
return len(self._sessions) < self.max_sessions
def get(self, session_id: str) -> CliSession | None:
return self._sessions.get(session_id)
async def create_session(
self,
*,
shell: str,
cwd: str,
env: dict[str, str],
) -> CliSession:
async with self._lock:
if len(self._sessions) >= self.max_sessions:
raise CliSessionLimitError(
f"Limit sesji CLI ({self.max_sessions}) — spróbuj później"
)
try:
master_fd, slave_fd = pty.openpty()
shell_cmd = _parse_shell(shell)
proc_env = {**env, "TERM": "xterm-256color"}
@@ -116,59 +138,226 @@ async def run_pty_session(
preexec_fn=os.setsid,
)
os.close(slave_fd)
slave_fd = -1
read_task = asyncio.create_task(_read_pty_loop(master_fd, websocket, running))
session = CliSession(
id=str(uuid.uuid4()),
master_fd=master_fd,
process=process,
)
session.running.set()
while True:
async with self._lock:
self._sessions[session.id] = session
session.read_task = asyncio.create_task(self._read_pty_loop(session))
log.info("CLI session created: %s", session.id)
return session
async def _read_pty_loop(self, session: CliSession) -> None:
loop = asyncio.get_running_loop()
while session.running.is_set() and not session.closed:
try:
data = await loop.run_in_executor(None, os.read, session.master_fd, 4096)
except OSError:
break
if not data:
break
session.append_output(data)
await session.send_to_ws(data)
if session.process.returncode is not None:
break
if not session.closed:
log.info("CLI session ended (shell exit): %s", session.id)
await self.close_session(session.id)
async def attach_session(self, session_id: str, websocket: WebSocket) -> CliSession:
session = self.get(session_id)
if session is None or session.closed:
raise CliSessionNotFoundError(session_id)
await self._cancel_detach_timer(session)
if session.ws is not None and session.ws is not websocket:
try:
await session.ws.close()
except Exception:
pass
session.ws = websocket
if session.output_buffer:
await session.send_to_ws(bytes(session.output_buffer))
await session.send_json({"type": "replay", "complete": True})
_set_winsize(session.master_fd, session.rows, session.cols)
log.info("CLI session reattached: %s", session.id)
return session
async def detach_session(self, session_id: str) -> None:
session = self.get(session_id)
if session is None or session.closed:
return
session.ws = None
await self._schedule_detach_timer(session)
log.info("CLI session detached (TTL %ds): %s", self.session_ttl, session.id)
async def close_session(self, session_id: str) -> None:
async with self._lock:
session = self._sessions.pop(session_id, None)
if session is None or session.closed:
return
session.closed = True
session.running.clear()
await self._cancel_detach_timer(session)
if session.read_task is not None:
session.read_task.cancel()
try:
await session.read_task
except asyncio.CancelledError:
pass
await _kill_process(session.process)
try:
os.close(session.master_fd)
except OSError:
pass
if session.ws is not None:
try:
await session.ws.close()
except Exception:
pass
log.info("CLI session closed: %s", session_id)
async def _cancel_detach_timer(self, session: CliSession) -> None:
if session.detach_timer is not None:
session.detach_timer.cancel()
try:
await session.detach_timer
except asyncio.CancelledError:
pass
session.detach_timer = None
async def _schedule_detach_timer(self, session: CliSession) -> None:
await self._cancel_detach_timer(session)
async def _expire() -> None:
try:
await asyncio.sleep(self.session_ttl)
except asyncio.CancelledError:
return
log.info("CLI session expired after detach TTL: %s", session.id)
await self.close_session(session.id)
session.detach_timer = asyncio.create_task(_expire())
async def handle_resize(self, session: CliSession, rows: int, cols: int) -> None:
session.rows = rows
session.cols = cols
_set_winsize(session.master_fd, rows, cols)
async def write_input(self, session: CliSession, payload: bytes) -> None:
try:
os.write(session.master_fd, payload)
except OSError:
await self.close_session(session.id)
_manager: CliSessionManager | None = None
def init_session_manager(*, session_ttl: int = 300, max_sessions: int = 5) -> CliSessionManager:
global _manager
_manager = CliSessionManager(session_ttl=session_ttl, max_sessions=max_sessions)
return _manager
def get_session_manager() -> CliSessionManager:
if _manager is None:
raise RuntimeError("CLI session manager not initialized")
return _manager
def sessions_available(max_sessions: int) -> bool:
if _manager is not None:
return _manager.sessions_available()
return True
async def _handle_ws_input(session: CliSession, message: dict[str, Any], mgr: CliSessionManager) -> bool:
"""Process one WS message. Returns False to end the connection loop."""
msg_type = message.get("type")
if msg_type == "websocket.disconnect":
return False
if message.get("bytes"):
await mgr.write_input(session, message["bytes"])
return True
if message.get("text"):
text = message["text"]
if text.startswith("{"):
try:
obj: dict[str, Any] = json.loads(text)
cmd = obj.get("type")
if cmd == "resize":
rows = int(obj.get("rows", 24))
cols = int(obj.get("cols", 80))
await mgr.handle_resize(session, rows, cols)
return True
if cmd == "close":
await mgr.close_session(session.id)
return False
except (json.JSONDecodeError, TypeError, ValueError):
pass
await mgr.write_input(session, text.encode("utf-8"))
if session.closed or session.process.returncode is not None:
return False
return True
async def run_pty_session(
websocket: WebSocket,
*,
shell: str,
cwd: str,
env: dict[str, str],
max_sessions: int,
session_id: str | None = None,
session_ttl: int = 300,
) -> None:
if _manager is None:
init_session_manager(session_ttl=session_ttl, max_sessions=max_sessions)
mgr = get_session_manager()
session: CliSession
if session_id:
try:
session = await mgr.attach_session(session_id, websocket)
except CliSessionNotFoundError:
await websocket.close(code=1008, reason="Session expired")
return
else:
session = await mgr.create_session(shell=shell, cwd=cwd, env=env)
session.ws = websocket
await websocket.send_text(json.dumps({"type": "session", "id": session.id}))
_set_winsize(session.master_fd, session.rows, session.cols)
try:
while not session.closed:
message = await websocket.receive()
msg_type = message.get("type")
if msg_type == "websocket.disconnect":
if not await _handle_ws_input(session, message, mgr):
break
payload: bytes | None = None
if message.get("bytes"):
payload = message["bytes"]
elif message.get("text"):
text = message["text"]
if text.startswith("{"):
try:
obj: dict[str, Any] = json.loads(text)
if obj.get("type") == "resize":
rows = int(obj.get("rows", 24))
cols = int(obj.get("cols", 80))
if master_fd is not None:
_set_winsize(master_fd, rows, cols)
continue
except (json.JSONDecodeError, TypeError, ValueError):
pass
payload = text.encode("utf-8")
if payload and master_fd is not None:
try:
os.write(master_fd, payload)
except OSError:
break
if process.returncode is not None:
break
except WebSocketDisconnect:
pass
finally:
running.clear()
if read_task is not None:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
if process is not None:
await _kill_process(process)
if master_fd is not None:
try:
os.close(master_fd)
except OSError:
pass
async with _session_lock:
_active_sessions = max(0, _active_sessions - 1)
if not session.closed:
if session.ws is websocket:
session.ws = None
await mgr.detach_session(session.id)
@@ -0,0 +1,4 @@
# ComfyUI model catalog — managed by Server UI (not the ComfyUI Docker stack).
# Use: stacks/server-ui/scripts/comfyui/list-models.sh
# stacks/server-ui/scripts/comfyui/download-model.sh <id>
+53
View File
@@ -0,0 +1,53 @@
"""Shared ComfyUI paths and constants for Server UI integration."""
from __future__ import annotations
from pathlib import Path
COMFYUI_CONTAINER = "comfyui"
CONTAINER_MODELS_ROOT = "/root/ComfyUI/models"
CONTAINER_WORKFLOWS_ROOT = "/root/ComfyUI/user/default/workflows"
HOST_DATA_SUBDIR = "apps/comfyui"
STACK_ID = "comfyui"
CATALOG_FILENAME = "comfyui-models.catalog.yaml"
def server_ui_root(base: Path | None = None) -> Path:
return base or Path(__file__).resolve().parent
def catalog_path(base: Path | None = None) -> Path:
return server_ui_root(base) / CATALOG_FILENAME
def scripts_dir(base: Path | None = None) -> Path:
return server_ui_root(base) / "scripts" / "comfyui"
def stack_env_path(repo_root: Path) -> Path:
return repo_root / "stacks" / "comfyui" / ".env"
def host_models_root(data_root: Path) -> Path:
return data_root / HOST_DATA_SUBDIR / "models"
def host_workflows_dir(data_root: Path) -> Path:
return data_root / HOST_DATA_SUBDIR / "workflows"
def host_custom_nodes_dir(data_root: Path) -> Path:
return data_root / HOST_DATA_SUBDIR / "custom_nodes"
def container_model_path(relative: str) -> str:
return f"{CONTAINER_MODELS_ROOT}/{relative.lstrip('/')}"
def container_workflow_path(relative: str) -> str:
return f"{CONTAINER_WORKFLOWS_ROOT}/{relative.lstrip('/')}"
def host_to_container_model_path(host_path: Path, data_root: Path) -> str:
rel = host_path.resolve().relative_to(host_models_root(data_root).resolve())
return container_model_path(rel.as_posix())
@@ -0,0 +1,65 @@
"""Index ComfyUI-Manager model-list.json for filename → download URL lookup."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from comfyui_config import host_custom_nodes_dir
log = logging.getLogger(__name__)
MANAGER_REL_PATHS = (
"custom_nodes/ComfyUI-Manager/model-list.json",
"custom_nodes/ComfyUI-Manager/node_db/new/model-list.json",
)
def _manager_list_paths(data_root: Path) -> list[Path]:
base = host_custom_nodes_dir(data_root).parent
paths: list[Path] = []
for rel in MANAGER_REL_PATHS:
path = base / rel
if path.is_file():
paths.append(path)
return paths
def _score_entry(entry: dict[str, Any]) -> tuple[int, int]:
"""Prefer entries with URL and shorter save_path."""
has_url = 0 if entry.get("url") else 1
save_path = str(entry.get("save_path") or "")
return (has_url, len(save_path))
def load_manager_index(data_root: Path) -> dict[str, dict[str, Any]]:
"""Build filename → best matching ComfyUI-Manager entry."""
index: dict[str, dict[str, Any]] = {}
for list_path in _manager_list_paths(data_root):
try:
with list_path.open(encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
log.warning("Failed to read ComfyUI-Manager list %s: %s", list_path, exc)
continue
models = data.get("models") if isinstance(data, dict) else None
if not isinstance(models, list):
continue
for entry in models:
if not isinstance(entry, dict):
continue
filename = entry.get("filename")
if not isinstance(filename, str) or not filename:
continue
existing = index.get(filename)
if existing is None or _score_entry(entry) < _score_entry(existing):
index[filename] = entry
return index
def lookup_manager_entry(
data_root: Path, filename: str
) -> dict[str, Any] | None:
return load_manager_index(data_root).get(filename)
+858
View File
@@ -0,0 +1,858 @@
"""ComfyUI model catalog and download jobs for Server UI."""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import yaml
from comfyui_config import (
COMFYUI_CONTAINER,
catalog_path as comfyui_catalog_path,
host_models_root,
host_to_container_model_path,
host_workflows_dir,
scripts_dir as comfyui_scripts_dir,
stack_env_path as comfyui_stack_env_path,
)
from comfyui_manager_catalog import load_manager_index
from workflow_scanner import find_model_file, scan_workflows
log = logging.getLogger(__name__)
WORKFLOW_MODEL_PREFIX = "wf:"
CATEGORY_DIRS: dict[str, str] = {
"checkpoint": "checkpoints",
"lora": "loras",
"vae": "vae",
"controlnet": "controlnet",
"upscale": "upscale_models",
"latent_upscale": "latent_upscale_models",
"text_encoder": "text_encoders",
"diffusion_models": "diffusion_models",
"clip": "clip",
"unet": "unet",
"unknown": "checkpoints",
}
LOG_BUFFER_MAX = 256 * 1024
JOB_TTL_SECONDS = 3600
PROGRESS_RE = re.compile(r"(\d{1,3})%")
_job_manager: DownloadJobManager | None = None
class ComfyUIModelError(Exception):
pass
class ModelNotFoundError(ComfyUIModelError):
pass
class JobNotFoundError(ComfyUIModelError):
pass
class JobConflictError(ComfyUIModelError):
pass
class DeleteModelError(ComfyUIModelError):
pass
def models_root(data_root: Path) -> Path:
return host_models_root(data_root)
def workflows_dir(data_root: Path) -> Path:
return host_workflows_dir(data_root)
def workflow_model_id(filename: str) -> str:
return f"{WORKFLOW_MODEL_PREFIX}{filename}"
def is_workflow_model_id(model_id: str) -> bool:
return model_id.startswith(WORKFLOW_MODEL_PREFIX)
def resolve_relative_model_path(relative_path: str, data_root: Path) -> Path:
rel = relative_path.strip().lstrip("/")
if not rel or ".." in Path(rel).parts:
raise DeleteModelError(f"Invalid relative model path: {relative_path}")
dest = models_root(data_root) / rel
if not _is_under_models_root(dest, data_root):
raise DeleteModelError(f"Path outside models root: {dest}")
return dest
def _validate_download_url(url: str) -> None:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ComfyUIModelError("URL must start with http:// or https://")
def _catalog_by_filename(catalog_path: Path) -> dict[str, dict[str, Any]]:
by_filename: dict[str, dict[str, Any]] = {}
for entry in load_catalog(catalog_path):
filename = entry.get("filename")
model_id = entry.get("id")
if filename and model_id:
by_filename[str(filename)] = entry
return by_filename
def build_workflow_required(
catalog_path: Path,
data_root: Path,
active_by_model: dict[str, str],
jobs: dict[str, DownloadJob] | None = None,
) -> tuple[list[dict[str, Any]], int, str]:
"""Build workflow-required model list, excluding catalog entries (dedup)."""
jobs = jobs or {}
wf_dir = workflows_dir(data_root)
refs, workflows_scanned = scan_workflows(wf_dir)
catalog_filenames = {
str(e.get("filename", "")) for e in load_catalog(catalog_path) if e.get("filename")
}
manager_index = load_manager_index(data_root)
root = models_root(data_root)
items: list[dict[str, Any]] = []
for ref in refs:
if ref.filename in catalog_filenames:
continue
model_id = workflow_model_id(ref.filename)
found = find_model_file(root, ref.filename)
source = "none"
download_url: str | None = None
save_path: str | None = None
catalog_id: str | None = None
size_gb: Any = None
note = ""
catalog_entry = _catalog_by_filename(catalog_path).get(ref.filename)
if catalog_entry:
source = "catalog"
catalog_id = str(catalog_entry.get("id"))
download_url = catalog_entry.get("download_url")
size_gb = catalog_entry.get("size_gb")
note = str(catalog_entry.get("note") or "")
dest = resolve_dest_path(catalog_entry, data_root)
else:
mgr = manager_index.get(ref.filename)
if mgr and mgr.get("url"):
source = "manager"
download_url = str(mgr["url"])
save_path = str(mgr.get("save_path") or category_dir(ref.category))
size_str = mgr.get("size")
if isinstance(size_str, str) and size_str.upper().endswith("GB"):
try:
size_gb = float(size_str.upper().replace("GB", "").strip())
except ValueError:
size_gb = None
note = str(mgr.get("description") or mgr.get("name") or "")
dest = root / save_path / ref.filename
else:
dest = root / category_dir(ref.category) / ref.filename
if found is not None:
dest = found
try:
relative_path = dest.relative_to(root).as_posix()
except ValueError:
relative_path = None
active_job: DownloadJob | None = None
job_id = active_by_model.get(model_id)
if job_id and job_id in jobs:
active_job = jobs[job_id]
state = _model_file_state(dest, size_gb, active_job)
items.append(
{
"id": model_id,
"name": ref.filename,
"filename": ref.filename,
"category": ref.category,
"workflows": ref.workflows,
"source": source,
"downloadable": source in ("catalog", "manager"),
"download_url": download_url,
"save_path": save_path,
"catalog_id": catalog_id,
"relative_path": relative_path,
"note": note,
"size_gb": size_gb,
**state,
}
)
return items, workflows_scanned, str(wf_dir)
def _expected_bytes(size_gb: Any) -> int | None:
if size_gb is None:
return None
try:
return int(float(size_gb) * 1_000_000_000)
except (TypeError, ValueError):
return None
def _is_under_models_root(path: Path, data_root: Path) -> bool:
root = models_root(data_root).resolve()
try:
path.resolve().relative_to(root)
return True
except ValueError:
return False
def _file_size(path: Path) -> int:
if not path.is_file():
return 0
return path.stat().st_size
def category_dir(category: str) -> str:
return CATEGORY_DIRS.get(category, category)
def resolve_dest_path(entry: dict[str, Any], data_root: Path) -> Path:
local_path = entry.get("local_path")
if local_path:
return Path(local_path)
category = entry.get("category", "")
filename = entry.get("filename", "")
subdir = category_dir(str(category))
return data_root / "apps" / "comfyui" / "models" / subdir / filename
def load_catalog(catalog_path: Path) -> list[dict[str, Any]]:
if not catalog_path.is_file():
raise ComfyUIModelError(f"Catalog not found: {catalog_path}")
with catalog_path.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
models = data.get("models") or []
if not isinstance(models, list):
raise ComfyUIModelError("Invalid catalog: models must be a list")
return models
def _model_file_state(
dest: Path,
size_gb: Any,
active_job: DownloadJob | None,
) -> dict[str, Any]:
size_bytes = _file_size(dest)
expected = _expected_bytes(size_gb)
download_info: dict[str, Any] | None = None
if active_job and active_job.status == "running":
download_info = {
"job_id": active_job.id,
"status": active_job.status,
"progress": active_job.progress,
}
installed = False
partial = False
if size_bytes > 0:
if active_job and active_job.status == "running":
partial = True
elif expected is None or size_bytes >= int(expected * 0.95):
installed = True
else:
partial = True
return {
"path": str(dest),
"size_bytes": size_bytes,
"expected_bytes": expected,
"installed": installed,
"partial": partial,
"download": download_info,
}
def _unlink_model_file(dest: Path, data_root: Path) -> bool:
if not dest.is_file():
return False
if not _is_under_models_root(dest, data_root):
raise DeleteModelError(f"Refusing to delete path outside models root: {dest}")
try:
dest.unlink()
if not dest.is_file():
return True
except OSError as exc:
log.warning("Host unlink failed for %s: %s", dest, exc)
container_path = host_to_container_model_path(dest, data_root)
try:
result = subprocess.run(
["docker", "exec", COMFYUI_CONTAINER, "rm", "-f", container_path],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
log.warning("docker exec rm failed: %s", result.stderr.strip())
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("docker exec rm error for %s: %s", container_path, exc)
return not dest.is_file()
def list_models(catalog_path: Path, data_root: Path) -> list[dict[str, Any]]:
"""Basic catalog list without active download jobs (legacy helper)."""
return _build_model_list(catalog_path, data_root, active_by_model={})
def _build_model_list(
catalog_path: Path,
data_root: Path,
active_by_model: dict[str, str],
jobs: dict[str, DownloadJob] | None = None,
) -> list[dict[str, Any]]:
jobs = jobs or {}
items: list[dict[str, Any]] = []
for entry in load_catalog(catalog_path):
model_id = entry.get("id")
if not model_id:
continue
dest = resolve_dest_path(entry, data_root)
active_job: DownloadJob | None = None
job_id = active_by_model.get(str(model_id))
if job_id and job_id in jobs:
active_job = jobs[job_id]
state = _model_file_state(dest, entry.get("size_gb"), active_job)
items.append(
{
"id": model_id,
"name": entry.get("name", model_id),
"category": entry.get("category", ""),
"source": entry.get("source", ""),
"size_gb": entry.get("size_gb"),
"note": entry.get("note", ""),
"filename": entry.get("filename", dest.name),
**state,
}
)
return items
def list_installed(data_root: Path) -> list[dict[str, Any]]:
models_root = data_root / "apps" / "comfyui" / "models"
if not models_root.is_dir():
return []
installed: list[dict[str, Any]] = []
for path in sorted(models_root.rglob("*")):
if not path.is_file():
continue
if path.name.startswith("."):
continue
rel = path.relative_to(models_root)
installed.append(
{
"path": str(path),
"relative_path": str(rel),
"category_dir": rel.parts[0] if rel.parts else "",
"filename": path.name,
"size_bytes": path.stat().st_size,
}
)
return installed
def disk_usage(data_root: Path) -> dict[str, Any]:
usage = shutil.disk_usage(data_root)
return {
"path": str(data_root),
"total_bytes": usage.total,
"used_bytes": usage.used,
"free_bytes": usage.free,
}
def _parse_progress(line: str, current: int) -> int:
match = PROGRESS_RE.search(line)
if match:
return max(current, min(100, int(match.group(1))))
lower = line.lower()
if "already exists" in lower:
return 100
if line.strip() == "Done.":
return 100
return current
@dataclass
class DownloadJob:
id: str
model_id: str
status: str = "running"
progress: int = 0
log: str = ""
error: str = ""
started_at: float = field(default_factory=time.time)
finished_at: float | None = None
file_dest: Path | None = None
_log_buffer: list[str] = field(default_factory=list)
_process: subprocess.Popen[str] | None = field(default=None, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def append_log(self, chunk: str) -> None:
with self._lock:
self._log_buffer.append(chunk)
combined = "".join(self._log_buffer)
if len(combined) > LOG_BUFFER_MAX:
combined = combined[-LOG_BUFFER_MAX:]
self._log_buffer = [combined]
self.log = combined
for line in chunk.splitlines():
self.progress = _parse_progress(line, self.progress)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"model_id": self.model_id,
"status": self.status,
"progress": self.progress,
"log": self.log,
"error": self.error,
"started_at": self.started_at,
"finished_at": self.finished_at,
}
class DownloadJobManager:
def __init__(
self,
catalog_path: Path,
data_root: Path,
download_script: Path,
download_file_script: Path,
stack_env_path: Path | None = None,
job_ttl: int = JOB_TTL_SECONDS,
) -> None:
self.catalog_path = catalog_path
self.data_root = data_root
self.download_script = download_script
self.download_file_script = download_file_script
self.stack_env_path = stack_env_path
self.job_ttl = job_ttl
self._jobs: dict[str, DownloadJob] = {}
self._active_by_model: dict[str, str] = {}
self._lock = threading.Lock()
def _catalog_by_id(self) -> dict[str, dict[str, Any]]:
return {str(m["id"]): m for m in load_catalog(self.catalog_path) if m.get("id")}
def _cleanup_old_jobs(self) -> None:
cutoff = time.time() - self.job_ttl
with self._lock:
expired = [
jid
for jid, job in self._jobs.items()
if job.finished_at is not None and job.finished_at < cutoff
]
for jid in expired:
job = self._jobs.pop(jid, None)
if job and job.model_id in self._active_by_model:
if self._active_by_model.get(job.model_id) == jid:
del self._active_by_model[job.model_id]
def get_job(self, job_id: str) -> DownloadJob:
self._cleanup_old_jobs()
job = self._jobs.get(job_id)
if not job:
raise JobNotFoundError(f"Unknown job: {job_id}")
return job
def list_models_with_state(self) -> list[dict[str, Any]]:
self._cleanup_old_jobs()
with self._lock:
active = dict(self._active_by_model)
jobs = dict(self._jobs)
return _build_model_list(self.catalog_path, self.data_root, active, jobs)
def list_models_response(self) -> dict[str, Any]:
self._cleanup_old_jobs()
with self._lock:
active = dict(self._active_by_model)
jobs = dict(self._jobs)
models = _build_model_list(self.catalog_path, self.data_root, active, jobs)
workflow_required, workflows_scanned, workflows_dir_path = build_workflow_required(
self.catalog_path, self.data_root, active, jobs
)
return {
"models": models,
"workflow_required": workflow_required,
"workflows_scanned": workflows_scanned,
"workflows_dir": workflows_dir_path,
}
def _dest_for_model(self, model_id: str) -> Path:
catalog = self._catalog_by_id()
if model_id not in catalog:
raise ModelNotFoundError(f"Unknown model id: {model_id}")
dest = resolve_dest_path(catalog[model_id], self.data_root)
if not _is_under_models_root(dest, self.data_root):
raise DeleteModelError(f"Invalid model path: {dest}")
return dest
def _remove_model_file(self, model_id: str) -> bool:
dest = self._dest_for_model(model_id)
return _unlink_model_file(dest, self.data_root)
def delete_model(self, model_id: str) -> dict[str, Any]:
self._cleanup_old_jobs()
with self._lock:
job_id = self._active_by_model.get(model_id)
if job_id:
self.cancel_job(job_id, cleanup=True)
if is_workflow_model_id(model_id):
raise ModelNotFoundError(
f"Workflow model {model_id} must be deleted via relative_path"
)
removed = self._remove_model_file(model_id)
dest = self._dest_for_model(model_id)
return {
"model_id": model_id,
"removed": removed,
"path": str(dest),
}
def delete_model_file(self, relative_path: str) -> dict[str, Any]:
self._cleanup_old_jobs()
dest = resolve_relative_model_path(relative_path, self.data_root)
model_id = workflow_model_id(dest.name)
with self._lock:
job_id = self._active_by_model.get(model_id)
if job_id:
self.cancel_job(job_id, cleanup=True)
removed = _unlink_model_file(dest, self.data_root)
return {
"relative_path": relative_path,
"removed": removed,
"path": str(dest),
}
def start_download(self, model_id: str) -> DownloadJob:
self._cleanup_old_jobs()
catalog = self._catalog_by_id()
if model_id not in catalog:
raise ModelNotFoundError(f"Unknown model id: {model_id}")
entry = catalog[model_id]
dest = resolve_dest_path(entry, self.data_root)
if dest.is_file() and _model_file_state(dest, entry.get("size_gb"), None)["installed"]:
job = DownloadJob(
id=str(uuid.uuid4()),
model_id=model_id,
status="completed",
progress=100,
log=f"Already exists: {dest}\n",
finished_at=time.time(),
)
with self._lock:
self._jobs[job.id] = job
return job
with self._lock:
if model_id in self._active_by_model:
active_id = self._active_by_model[model_id]
active = self._jobs.get(active_id)
if active and active.status == "running":
raise JobConflictError(
f"Download already running for {model_id} (job {active_id})"
)
if not self.download_script.is_file():
raise ComfyUIModelError(f"Download script not found: {self.download_script}")
job = DownloadJob(id=str(uuid.uuid4()), model_id=model_id)
with self._lock:
self._jobs[job.id] = job
self._active_by_model[model_id] = job.id
thread = threading.Thread(
target=self._run_download,
args=(job,),
name=f"comfyui-download-{job.id[:8]}",
daemon=True,
)
thread.start()
return job
def start_file_download(
self,
url: str,
relative_path: str,
*,
model_id: str | None = None,
size_gb: Any = None,
) -> DownloadJob:
self._cleanup_old_jobs()
_validate_download_url(url)
dest = resolve_relative_model_path(relative_path, self.data_root)
wf_id = model_id or workflow_model_id(dest.name)
if dest.is_file() and _model_file_state(dest, size_gb, None)["installed"]:
job = DownloadJob(
id=str(uuid.uuid4()),
model_id=wf_id,
status="completed",
progress=100,
log=f"Already exists: {dest}\n",
finished_at=time.time(),
file_dest=dest,
)
with self._lock:
self._jobs[job.id] = job
return job
with self._lock:
if wf_id in self._active_by_model:
active_id = self._active_by_model[wf_id]
active = self._jobs.get(active_id)
if active and active.status == "running":
raise JobConflictError(
f"Download already running for {wf_id} (job {active_id})"
)
if not self.download_file_script.is_file():
raise ComfyUIModelError(
f"Download file script not found: {self.download_file_script}"
)
job = DownloadJob(
id=str(uuid.uuid4()),
model_id=wf_id,
file_dest=dest,
)
with self._lock:
self._jobs[job.id] = job
self._active_by_model[wf_id] = job.id
thread = threading.Thread(
target=self._run_file_download,
args=(job, url, dest),
name=f"comfyui-file-download-{job.id[:8]}",
daemon=True,
)
thread.start()
return job
def start_workflow_download(self, model_id: str) -> DownloadJob:
"""Download a workflow-required model using catalog or manager metadata."""
if not is_workflow_model_id(model_id):
raise ModelNotFoundError(f"Not a workflow model id: {model_id}")
with self._lock:
active = dict(self._active_by_model)
jobs = dict(self._jobs)
items, _, _ = build_workflow_required(
self.catalog_path, self.data_root, active, jobs
)
entry = next((m for m in items if m["id"] == model_id), None)
if not entry:
raise ModelNotFoundError(f"Unknown workflow model: {model_id}")
if not entry.get("downloadable"):
raise ComfyUIModelError(
f"No download URL for {entry.get('filename')} — add to catalog or ComfyUI-Manager"
)
if entry.get("catalog_id"):
return self.start_download(str(entry["catalog_id"]))
relative_path = entry.get("relative_path")
download_url = entry.get("download_url")
if not relative_path or not download_url:
raise ComfyUIModelError(f"Missing download metadata for {model_id}")
return self.start_file_download(
str(download_url),
str(relative_path),
model_id=model_id,
size_gb=entry.get("size_gb"),
)
def cancel_job(self, job_id: str, *, cleanup: bool = True) -> DownloadJob:
job = self.get_job(job_id)
if job.status == "running":
proc = job._process
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
job.status = "cancelled"
job.finished_at = time.time()
job.append_log("\nCancelled by user.\n")
with self._lock:
if self._active_by_model.get(job.model_id) == job.id:
del self._active_by_model[job.model_id]
if cleanup:
try:
if job.file_dest:
_unlink_model_file(job.file_dest, self.data_root)
job.append_log("Partial file removed.\n")
else:
self._remove_model_file(job.model_id)
job.append_log("Partial file removed.\n")
except ComfyUIModelError as exc:
job.append_log(f"Cleanup failed: {exc}\n")
return job
def _run_file_download(self, job: DownloadJob, url: str, dest: Path) -> None:
env = os.environ.copy()
env["DATA_ROOT"] = str(self.data_root)
if self.stack_env_path and self.stack_env_path.is_file():
for line in self.stack_env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in env:
env[key] = value
cmd = [
str(self.download_file_script),
"--url",
url,
"--dest",
str(dest),
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
cwd=str(self.download_file_script.parent),
)
job._process = proc
assert proc.stdout is not None
for line in proc.stdout:
job.append_log(line)
rc = proc.wait()
if job.status == "cancelled":
return
if rc == 0:
job.status = "completed"
job.progress = 100
else:
job.status = "failed"
job.error = f"Download script exited with code {rc}"
except Exception as exc:
job.status = "failed"
job.error = str(exc)
job.append_log(f"\nERROR: {exc}\n")
log.exception("ComfyUI file download failed for %s", job.model_id)
finally:
job.finished_at = time.time()
job._process = None
with self._lock:
if self._active_by_model.get(job.model_id) == job.id:
del self._active_by_model[job.model_id]
def _run_download(self, job: DownloadJob) -> None:
env = os.environ.copy()
env["DATA_ROOT"] = str(self.data_root)
if self.stack_env_path and self.stack_env_path.is_file():
for line in self.stack_env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in env:
env[key] = value
cmd = [str(self.download_script), job.model_id]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
cwd=str(self.download_script.parent),
)
job._process = proc
assert proc.stdout is not None
for line in proc.stdout:
job.append_log(line)
rc = proc.wait()
if job.status == "cancelled":
return
if rc == 0:
job.status = "completed"
job.progress = 100
else:
job.status = "failed"
job.error = f"Download script exited with code {rc}"
except Exception as exc:
job.status = "failed"
job.error = str(exc)
job.append_log(f"\nERROR: {exc}\n")
log.exception("ComfyUI download failed for %s", job.model_id)
finally:
job.finished_at = time.time()
job._process = None
with self._lock:
if self._active_by_model.get(job.model_id) == job.id:
del self._active_by_model[job.model_id]
def init_download_manager(
repo_root: Path,
data_root: Path,
job_ttl: int = JOB_TTL_SECONDS,
) -> DownloadJobManager:
global _job_manager
ui_root = Path(__file__).resolve().parent
catalog_path = comfyui_catalog_path(ui_root)
scripts = comfyui_scripts_dir(ui_root)
download_script = scripts / "download-model.sh"
download_file_script = scripts / "download-file.sh"
stack_env = comfyui_stack_env_path(repo_root)
_job_manager = DownloadJobManager(
catalog_path=catalog_path,
data_root=Path(data_root),
download_script=download_script,
download_file_script=download_file_script,
stack_env_path=stack_env if stack_env.is_file() else None,
job_ttl=job_ttl,
)
return _job_manager
def get_download_manager() -> DownloadJobManager:
if _job_manager is None:
raise ComfyUIModelError("Download manager not initialized")
return _job_manager
+48 -3
View File
@@ -230,6 +230,14 @@ def _parse_stacks_yaml_simple(text: str) -> dict[str, Any]:
return {"stacks": stacks}
def find_compose_file(stack_dir: Path) -> Path | None:
for name in ("compose.yaml", "docker-compose.yml", "docker-compose.yaml"):
path = stack_dir / name
if path.is_file():
return path
return None
def _run_compose(
stack: StackConfig,
*args: str,
@@ -240,10 +248,17 @@ def _run_compose(
"compose",
"--project-directory",
stack.compose_dir,
"--profile",
stack.profile,
*args,
]
compose_file = find_compose_file(stack.path)
if compose_file is not None:
cmd.extend(["-f", str(compose_file)])
cmd.extend(
[
"--profile",
stack.profile,
*args,
]
)
return subprocess.run(
cmd,
capture_output=True,
@@ -389,6 +404,36 @@ def stack_recreate(stack: StackConfig) -> str:
return (proc.stdout + proc.stderr).strip()
def stack_update(stack: StackConfig) -> str:
state = get_container_state(stack)
was_running = state["running"]
pull = _run_compose(stack, "pull", timeout=600)
if pull.returncode != 0:
raise ComposeError(
pull.stderr.strip() or pull.stdout.strip() or "pull failed",
pull.returncode,
pull.stderr,
)
output = (pull.stdout + pull.stderr).strip()
if not was_running:
suffix = "\n[server-ui] Obraz pobrany. Stack zatrzymany — użyj Start."
return (output + suffix).strip() if output else suffix.strip()
proc = _run_compose(stack, "up", "-d", "--force-recreate", timeout=300)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or proc.stdout.strip() or "recreate failed",
proc.returncode,
proc.stderr,
)
recreate_out = (proc.stdout + proc.stderr).strip()
if recreate_out:
output = f"{output}\n{recreate_out}".strip() if output else recreate_out
return output
def set_stack_port(
stack: StackConfig,
port: int,
+165
View File
@@ -0,0 +1,165 @@
"""Load unified control-plane environment from file + os.environ."""
from __future__ import annotations
import os
from pathlib import Path
_ENV_OVERRIDE_KEYS = (
"API_KEY",
"SERVER_UI_HOST",
"SERVER_UI_PORT",
"REPO_ROOT",
"DOCKER_GID",
"GPU_FAN_AGENT_URL",
"GPU_FAN_API_HOST",
"GPU_FAN_API_PORT",
"GPU_FAN_HOST",
"GPU_FAN_PORT",
"CURVE_PATH",
"POLL_INTERVAL",
"GPU_INDEX",
"DRY_RUN",
"FILE_EXPLORER_ROOT",
"FILE_EXPLORER_MAX_BYTES",
"CLI_ENABLED",
"CLI_SHELL",
"CLI_DEFAULT_CWD",
"CLI_MAX_SESSIONS",
)
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.is_file():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
# e.g. /opt/control-plane/.env is root-only; systemd still injects via os.environ
return values
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
values[key.strip()] = val.strip()
return values
def _merge_into(target: dict[str, str], source: dict[str, str]) -> None:
for key, val in source.items():
if val:
target[key] = val
def _is_production_stack_dir(stack_dir: Path) -> bool:
try:
return stack_dir.resolve().parts[1:2] == ("opt",)
except IndexError:
return False
def control_plane_env_paths(stack_dir: Path) -> list[Path]:
"""Candidate .env files in load order (earlier = lower priority among files)."""
paths: list[Path] = []
custom = os.environ.get("CONTROL_PLANE_ENV", "").strip()
if custom:
paths.append(Path(custom))
if _is_production_stack_dir(stack_dir):
# Production (/opt/server-ui, /opt/gpu-fan): single canonical file only.
prod = Path("/opt/control-plane/.env")
if prod not in paths:
paths.append(prod)
docker_repo = Path("/repo/stacks/control-plane/.env")
if docker_repo.is_file() and docker_repo not in paths:
paths.append(docker_repo)
return paths
# Dev (repo stacks/*): stacks/control-plane/.env only — no legacy per-service .env.
repo_control_plane = stack_dir.parent / "control-plane" / ".env"
if repo_control_plane.is_file() and repo_control_plane not in paths:
paths.append(repo_control_plane)
docker_repo = Path("/repo/stacks/control-plane/.env")
if docker_repo.is_file() and docker_repo not in paths:
paths.append(docker_repo)
return paths
def _has_stack_compose(repo_root: Path) -> bool:
stacks = repo_root / "stacks"
if not stacks.is_dir():
return False
for name in ("compose.yaml", "docker-compose.yml", "docker-compose.yaml"):
if (stacks / "localai" / name).is_file():
return True
return False
def _production_repo_root_candidates() -> list[Path]:
home = Path.home()
return [
home / "cursor" / "ubuntu-bare-metal",
Path("/data/apps/ubuntu-bare-metal"),
Path("/repo"),
home / "ubuntu-bare-metal",
]
def resolve_repo_root(stack_dir: Path, env: dict[str, str]) -> Path:
"""Resolve ubuntu-bare-metal repo root for compose stack paths."""
for key in ("REPO_ROOT",):
raw = (env.get(key) or os.environ.get(key) or "").strip()
if raw:
candidate = Path(raw).expanduser().resolve()
if _has_stack_compose(candidate):
return candidate
if _is_production_stack_dir(stack_dir):
for candidate in _production_repo_root_candidates():
resolved = candidate.expanduser().resolve()
if _has_stack_compose(resolved):
return resolved
return (stack_dir.parent.parent).resolve()
def api_key_source(stack_dir: Path, values: dict[str, str]) -> str:
"""Human-readable hint for logs (no secret values)."""
if os.environ.get("API_KEY"):
return "systemd/os.environ"
for path in control_plane_env_paths(stack_dir):
parsed = _parse_env_file(path)
if parsed.get("API_KEY"):
return str(path)
if values.get("API_KEY"):
return "merged env"
return "not configured"
def load_control_plane_env(stack_dir: Path) -> dict[str, str]:
"""Merge env files then apply os.environ (systemd EnvironmentFile wins)."""
values: dict[str, str] = {}
for path in control_plane_env_paths(stack_dir):
_merge_into(values, _parse_env_file(path))
for key in _ENV_OVERRIDE_KEYS:
if key in os.environ:
values[key] = os.environ[key]
values.setdefault("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090")
return values
def ensure_control_plane_import_path() -> None:
"""Add /opt/control-plane to sys.path for production imports."""
import sys
opt = "/opt/control-plane"
if opt not in sys.path:
sys.path.insert(0, opt)
repo = Path(__file__).resolve().parent
repo_str = str(repo)
if repo_str not in sys.path:
sys.path.insert(0, repo_str)
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Shared helpers for comfyui-models.catalog.yaml (simple parser, no PyYAML required).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVER_UI_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
CATALOG_FILE="${CATALOG_FILE:-${SERVER_UI_DIR}/comfyui-models.catalog.yaml}"
catalog_list_ids() {
grep '^ - id:' "${CATALOG_FILE}" | awk '{print $3}'
}
catalog_get_field() {
local model_id="$1"
local field="$2"
awk -v id="${model_id}" -v key="${field}:" '
$0 ~ "^ - id: " id "$" { found=1; next }
found && $0 ~ "^ - id:" { exit }
found && index($0, key) == 5 { sub(/^ [^:]+: /, ""); print; exit }
' "${CATALOG_FILE}"
}
catalog_category_dir() {
local category="$1"
case "${category}" in
checkpoint) echo "checkpoints" ;;
lora) echo "loras" ;;
vae) echo "vae" ;;
controlnet) echo "controlnet" ;;
upscale) echo "upscale_models" ;;
latent_upscale) echo "latent_upscale_models" ;;
text_encoder) echo "text_encoders" ;;
diffusion_models) echo "diffusion_models" ;;
clip) echo "clip" ;;
unet) echo "unet" ;;
*) echo "${category}" ;;
esac
}
catalog_dest_path() {
local model_id="$1"
local data_root="${2:-${DATA_ROOT:-/data}}"
local local_path category filename subdir
local_path="$(catalog_get_field "${model_id}" local_path)"
if [[ -n "${local_path}" ]]; then
echo "${local_path}"
return
fi
category="$(catalog_get_field "${model_id}" category)"
filename="$(catalog_get_field "${model_id}" filename)"
subdir="$(catalog_category_dir "${category}")"
echo "${data_root}/apps/comfyui/models/${subdir}/${filename}"
}
catalog_ensure_dirs() {
local data_root="${1:-/data}"
mkdir -p \
"${data_root}/apps/comfyui/storage" \
"${data_root}/apps/comfyui/models/checkpoints" \
"${data_root}/apps/comfyui/models/loras" \
"${data_root}/apps/comfyui/models/vae" \
"${data_root}/apps/comfyui/models/controlnet" \
"${data_root}/apps/comfyui/models/upscale_models" \
"${data_root}/apps/comfyui/models/latent_upscale_models" \
"${data_root}/apps/comfyui/models/text_encoders" \
"${data_root}/apps/comfyui/models/diffusion_models" \
"${data_root}/apps/comfyui/models/clip" \
"${data_root}/apps/comfyui/models/unet" \
"${data_root}/apps/comfyui/cache/hf-hub" \
"${data_root}/apps/comfyui/cache/torch-hub" \
"${data_root}/apps/comfyui/input" \
"${data_root}/apps/comfyui/output" \
"${data_root}/apps/comfyui/custom_nodes" \
"${data_root}/apps/comfyui/workflows"
}
catalog_model_downloaded() {
local model_id="$1"
local dest
dest="$(catalog_dest_path "${model_id}")"
[[ -f "${dest}" ]]
}
load_comfyui_stack_env() {
local repo_root
repo_root="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
local env_file="${repo_root}/stacks/comfyui/.env"
if [[ -f "${env_file}" ]]; then
set -a
# shellcheck disable=SC1090
source "${env_file}"
set +a
fi
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/catalog-lib.sh"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/download-http-lib.sh"
URL=""
DEST=""
while [[ $# -gt 0 ]]; do
case "$1" in
--url)
URL="${2:-}"
shift 2
;;
--dest)
DEST="${2:-}"
shift 2
;;
*)
echo "Usage: $0 --url URL --dest /absolute/path/to/file"
exit 1
;;
esac
done
if [[ -z "${URL}" || -z "${DEST}" ]]; then
echo "Usage: $0 --url URL --dest /absolute/path/to/file"
exit 1
fi
load_comfyui_stack_env
DATA_ROOT="${DATA_ROOT:-/data}"
MODELS_ROOT="${DATA_ROOT}/apps/comfyui/models"
if [[ "${DEST}" != "${MODELS_ROOT}"/* ]]; then
echo "ERROR: Destination must be under ${MODELS_ROOT}"
exit 1
fi
if [[ -f "${DEST}" ]]; then
echo "Already exists: ${DEST}"
echo ""
echo "Done."
exit 0
fi
mkdir -p "$(dirname "${DEST}")"
download_http_file "${URL}" "${DEST}"
if [[ ! -f "${DEST}" ]]; then
echo "ERROR: Download finished but file missing: ${DEST}"
exit 1
fi
echo ""
echo "Done."
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Shared HTTP download helpers for ComfyUI model scripts.
# shellcheck disable=SC2034
download_http_file() {
local url="$1"
local dest="$2"
local data_root="${DATA_ROOT:-/data}"
local dest_dir
dest_dir="$(dirname "${dest}")"
echo "Downloading ${url} ..."
echo "Target: ${dest}"
if [[ -w "${dest_dir}" ]]; then
if [[ -n "${HF_TOKEN:-}" ]]; then
if command -v wget >/dev/null 2>&1; then
wget -c --progress=dot:giga \
--header="Authorization: Bearer ${HF_TOKEN}" \
-O "${dest}" "${url}"
else
curl -fL -C - \
-H "Authorization: Bearer ${HF_TOKEN}" \
-o "${dest}" "${url}"
fi
else
if command -v wget >/dev/null 2>&1; then
wget -c --progress=dot:giga -O "${dest}" "${url}"
else
curl -fL -C - -o "${dest}" "${url}"
fi
fi
return
fi
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'comfyui'; then
local container_dest="/root/ComfyUI/models${dest#"${data_root}/apps/comfyui/models"}"
echo "Host directory not writable — using comfyui container: ${container_dest}"
if [[ -n "${HF_TOKEN:-}" ]]; then
docker exec comfyui wget -c --progress=dot:giga \
--header="Authorization: Bearer ${HF_TOKEN}" \
-O "${container_dest}" "${url}"
else
docker exec comfyui wget -c --progress=dot:giga \
-O "${container_dest}" "${url}"
fi
return
fi
echo "ERROR: Cannot write to ${dest_dir}"
echo "Fix: sudo chown -R \$USER:\$USER ${data_root}/apps/comfyui/models"
echo "Or start ComfyUI: cd stacks/comfyui && ./scripts/start.sh"
exit 1
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/catalog-lib.sh"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <catalog-model-id>"
echo ""
echo "Available models:"
catalog_list_ids | sed 's/^/ /'
exit 1
fi
MODEL_ID="$1"
if ! catalog_list_ids | grep -qx "${MODEL_ID}"; then
echo "ERROR: Unknown model id: ${MODEL_ID}"
echo "Run: ${SCRIPT_DIR}/list-models.sh"
exit 1
fi
load_comfyui_stack_env
DATA_ROOT="${DATA_ROOT:-/data}"
catalog_ensure_dirs "${DATA_ROOT}"
SOURCE="$(catalog_get_field "${MODEL_ID}" source)"
NAME="$(catalog_get_field "${MODEL_ID}" name)"
DEST="$(catalog_dest_path "${MODEL_ID}" "${DATA_ROOT}")"
echo "=== Download: ${NAME} (${MODEL_ID}) ==="
echo "Source: ${SOURCE}"
echo "Target: ${DEST}"
echo ""
if [[ -f "${DEST}" ]]; then
echo "Already exists: ${DEST}"
echo ""
echo "Done."
exit 0
fi
mkdir -p "$(dirname "${DEST}")"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/download-http-lib.sh"
case "${SOURCE}" in
huggingface)
HF_REPO="$(catalog_get_field "${MODEL_ID}" hf_repo)"
FILENAME="$(catalog_get_field "${MODEL_ID}" filename)"
DOWNLOAD_URL="$(catalog_get_field "${MODEL_ID}" download_url)"
HF_REVISION="$(catalog_get_field "${MODEL_ID}" hf_revision)"
REVISION="${HF_REVISION:-main}"
if [[ -n "${DOWNLOAD_URL}" ]]; then
URL="${DOWNLOAD_URL}"
elif [[ -n "${HF_REPO}" && -n "${FILENAME}" ]]; then
URL="https://huggingface.co/${HF_REPO}/resolve/${REVISION}/${FILENAME}"
else
echo "ERROR: Catalog entry missing download_url or hf_repo/filename"
exit 1
fi
download_http_file "${URL}" "${DEST}"
;;
url)
DOWNLOAD_URL="$(catalog_get_field "${MODEL_ID}" download_url)"
if [[ -z "${DOWNLOAD_URL}" ]]; then
echo "ERROR: Catalog entry missing download_url"
exit 1
fi
download_http_file "${DOWNLOAD_URL}" "${DEST}"
;;
civitai)
CIVITAI_MODEL_ID="$(catalog_get_field "${MODEL_ID}" civitai_model_id)"
if [[ -z "${CIVITAI_MODEL_ID}" ]]; then
echo "ERROR: Catalog entry missing civitai_model_id"
exit 1
fi
if [[ -z "${CIVITAI_TOKEN:-}" ]]; then
echo "ERROR: CIVITAI_TOKEN not set in stacks/comfyui/.env"
exit 1
fi
curl -fL \
-H "Authorization: Bearer ${CIVITAI_TOKEN}" \
-o "${DEST}" \
"https://civitai.com/api/download/models/${CIVITAI_MODEL_ID}"
;;
*)
echo "ERROR: Unknown source: ${SOURCE}"
exit 1
;;
esac
if [[ ! -f "${DEST}" ]]; then
echo "ERROR: Download finished but file missing: ${DEST}"
exit 1
fi
echo ""
echo "Done."
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/catalog-lib.sh"
load_comfyui_stack_env
DATA_ROOT="${DATA_ROOT:-/data}"
catalog_ensure_dirs "${DATA_ROOT}"
echo "=== ComfyUI model catalog ==="
echo "Catalog: ${CATALOG_FILE}"
echo "Data: ${DATA_ROOT}"
echo ""
printf "%-24s %-12s %-8s %s\n" "ID" "CATEGORY" "ON DISK" "NAME"
printf "%-24s %-12s %-8s %s\n" "----" "--------" "-------" "----"
while IFS= read -r model_id; do
name="$(catalog_get_field "${model_id}" name)"
category="$(catalog_get_field "${model_id}" category)"
if catalog_model_downloaded "${model_id}"; then
on_disk="yes"
else
on_disk="no"
fi
printf "%-24s %-12s %-8s %s\n" "${model_id}" "${category}" "${on_disk}" "${name}"
done < <(catalog_list_ids)
echo ""
echo "Download on demand: ${SCRIPT_DIR}/download-model.sh <id>"
echo "Target root: ${DATA_ROOT}/apps/comfyui/models/"
+2
View File
@@ -49,6 +49,8 @@ rsync -a --delete \
--exclude '__pycache__' \
"${STACK_DIR}/" "${INSTALL_DIR}/"
install -m 644 "$(dirname "${STACK_DIR}")/control-plane/env_loader.py" "${INSTALL_DIR}/env_loader.py"
python3 -m venv "${INSTALL_DIR}/.venv"
"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q
"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q
@@ -2,17 +2,34 @@
# Print API key + browser instructions (shared by install scripts and show-api-key.sh).
set -euo pipefail
print_api_key_instructions() {
local env_file="${1:-/opt/control-plane/.env}"
local ui_port="${2:-8091}"
_read_api_key() {
local env_file="$1"
local api_key=""
local lan_ip=""
if [[ -r "${env_file}" ]]; then
api_key="$(grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
elif [[ -f "${env_file}" ]]; then
api_key="$(sudo grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
fi
if [[ -z "${api_key}" ]]; then
local pid
pid="$(pgrep -f '/opt/server-ui/app.py' 2>/dev/null | head -1 || true)"
if [[ -n "${pid}" && -r "/proc/${pid}/environ" ]]; then
api_key="$(tr '\0' '\n' < "/proc/${pid}/environ" 2>/dev/null | grep '^API_KEY=' | cut -d= -f2- || true)"
fi
fi
printf '%s' "${api_key}"
}
print_api_key_instructions() {
local env_file="${1:-/opt/control-plane/.env}"
local ui_port="${2:-8091}"
local dev_env="${3:-}"
local api_key=""
local dev_key=""
local lan_ip=""
local verify_ok=0
api_key="$(_read_api_key "${env_file}")"
lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${lan_ip}" ]] || lan_ip="<IP-serwera>"
@@ -30,6 +47,33 @@ print_api_key_instructions() {
return 1
fi
if [[ -n "${dev_env}" && -f "${dev_env}" ]]; then
dev_key="$(_read_api_key "${dev_env}")"
if [[ -n "${dev_key}" && "${dev_key}" != "${api_key}" ]]; then
echo "OSTRZEŻENIE: klucz dev różni się od produkcji!"
echo " prod: ${env_file}"
echo " dev: ${dev_env}"
echo " Napraw: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh"
echo ""
fi
fi
if curl -sf -X POST "http://127.0.0.1:${ui_port}/api/auth/verify" \
-H "Content-Type: application/json" \
-d "{\"api_key\":\"${api_key}\"}" >/dev/null 2>&1; then
verify_ok=1
fi
echo "Plik klucza: ${env_file}"
if [[ "${verify_ok}" -eq 1 ]]; then
echo "Weryfikacja live (server-ui :${ui_port}): OK"
else
echo "Weryfikacja live (server-ui :${ui_port}): FAIL"
echo " Sprawdź: systemctl status server-ui"
echo " Lub: bash stacks/server-ui/scripts/verify-api-key.sh"
fi
echo ""
echo "1. Twój klucz API (skopiuj):"
echo ""
echo " ${api_key}"
@@ -45,13 +89,20 @@ print_api_key_instructions() {
echo " d) Kliknij „Sprawdź klucz” (powinno być: Klucz poprawny)"
echo " e) Dopiero potem Start/Stop, CLI, Pliki, GPU Fan"
echo ""
echo "4. Stary klucz w przeglądarce?"
echo " a) W panelu kliknij „Wyczyść klucz”"
echo " b) Lub F12 → Application → Local Storage → usuń server-ui-api-key"
echo " c) Ctrl+F5, potem kroki 23"
echo ""
echo "Plik klucza (na przyszłość):"
echo " sudo grep ^API_KEY= ${env_file}"
echo ""
echo "══════════════════════════════════════════════"
echo ""
[[ "${verify_ok}" -eq 1 ]]
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
print_api_key_instructions "${1:-/opt/control-plane/.env}" "${2:-8091}"
print_api_key_instructions "${1:-/opt/control-plane/.env}" "${2:-8091}" "${3:-}"
fi
+19 -7
View File
@@ -9,16 +9,28 @@ PROD_ENV="/opt/control-plane/.env"
DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env"
UI_PORT=8091
if [[ -f "${DEV_ENV}" ]]; then
# Production is canonical when installed; dev only as fallback.
if [[ -f "${PROD_ENV}" ]]; then
ENV_FILE="${PROD_ENV}"
UI_PORT="$(grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)"
if [[ -z "${UI_PORT}" ]]; then
UI_PORT="$(sudo grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)"
fi
elif [[ -r "${DEV_ENV}" ]]; then
ENV_FILE="${DEV_ENV}"
UI_PORT="$(grep '^SERVER_UI_PORT=' "${DEV_ENV}" 2>/dev/null | cut -d= -f2- || echo 8091)"
fi
# Prefer dev copy when readable (same key after sync); else production.
ENV_FILE="${DEV_ENV}"
if [[ ! -r "${ENV_FILE}" ]]; then
else
ENV_FILE="${PROD_ENV}"
fi
[[ -n "${UI_PORT}" ]] || UI_PORT=8091
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "${ENV_FILE}" "${UI_PORT}"
if ! print_api_key_instructions "${ENV_FILE}" "${UI_PORT}" "${DEV_ENV}"; then
if [[ -r "${DEV_ENV}" && "${ENV_FILE}" != "${DEV_ENV}" ]]; then
echo "Fallback: odczyt z dev (prod nieczytelny bez sudo): ${DEV_ENV}"
print_api_key_instructions "${DEV_ENV}" "${UI_PORT}" ""
else
exit 1
fi
fi
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Verify API_KEY from /opt/control-plane/.env against live server-ui.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
PROD_ENV="/opt/control-plane/.env"
UI_PORT=8091
if [[ -f "${PROD_ENV}" ]]; then
UI_PORT="$(grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)"
if [[ -z "${UI_PORT}" ]]; then
UI_PORT="$(sudo grep '^SERVER_UI_PORT=' "${PROD_ENV}" 2>/dev/null | cut -d= -f2- || true)"
fi
fi
[[ -n "${UI_PORT}" ]] || UI_PORT=8091
api_key="$(_read_api_key "${PROD_ENV}")"
if [[ -z "${api_key}" && -r "${SCRIPT_DIR}/../../control-plane/.env" ]]; then
api_key="$(_read_api_key "${SCRIPT_DIR}/../../control-plane/.env")"
fi
if [[ -z "${api_key}" ]]; then
echo "FAIL: brak API_KEY w ${PROD_ENV}"
exit 1
fi
if curl -sf -X POST "http://127.0.0.1:${UI_PORT}/api/auth/verify" \
-H "Content-Type: application/json" \
-d "{\"api_key\":\"${api_key}\"}" >/dev/null; then
echo "OK: klucz z ${PROD_ENV} działa na http://127.0.0.1:${UI_PORT}"
exit 0
fi
echo "FAIL: klucz z ${PROD_ENV} odrzucony przez server-ui (:${UI_PORT})"
echo " Uruchom: bash ${SCRIPT_DIR}/show-api-key.sh"
echo " Sync env: sudo bash ${SCRIPT_DIR}/setup-control-plane-env.sh"
echo " Restart: sudo systemctl restart server-ui"
exit 1
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
"""Scan ComfyUI workflow JSON files for required model filenames."""
from __future__ import annotations
import json
import logging
import subprocess
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from comfyui_config import COMFYUI_CONTAINER, container_workflow_path
log = logging.getLogger(__name__)
# class_type -> list of (input_field, category)
LOADER_NODES: dict[str, list[tuple[str, str]]] = {
"CheckpointLoaderSimple": [("ckpt_name", "checkpoint")],
"CheckpointLoader": [("ckpt_name", "checkpoint")],
"unCLIPCheckpointLoader": [("ckpt_name", "checkpoint")],
"ImageOnlyCheckpointLoader": [("ckpt_name", "checkpoint")],
"VAELoader": [("vae_name", "vae")],
"LoraLoader": [("lora_name", "lora")],
"LoraLoaderModelOnly": [("lora_name", "lora")],
"UNETLoader": [("unet_name", "unet")],
"CLIPLoader": [("clip_name", "clip")],
"DualCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip")],
"ControlNetLoader": [("control_net_name", "controlnet")],
"UpscaleModelLoader": [("model_name", "upscale")],
"StyleModelLoader": [("style_model_name", "style_models")],
"LatentUpscaleModelLoader": [("model_name", "latent_upscale")],
"LTXVAudioVAELoader": [("ckpt_name", "vae")],
"LTXAVTextEncoderLoader": [("gemma_model", "text_encoder"), ("ltx_model", "checkpoint")],
"DiffusersLoader": [("model_path", "diffusion_models")],
}
# Nodes where every .safetensors in widgets_values should be collected
MULTI_WIDGET_LOADERS = frozenset({"LTXAVTextEncoderLoader"})
@dataclass
class ModelRef:
filename: str
category: str
workflows: list[str] = field(default_factory=list)
def _is_model_filename(value: Any) -> bool:
if not isinstance(value, str) or not value.strip():
return False
lower = value.lower()
return lower.endswith(
(".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".onnx", ".sft")
)
def _infer_category_from_node(class_type: str, filename: str) -> str:
ct = class_type.lower()
fn = filename.lower()
if "lora" in ct or "lora" in fn:
return "lora"
if "upscale" in ct or "upscaler" in fn or "upscale" in fn:
return "latent_upscale"
if "vae" in ct:
return "vae"
if "clip" in ct or "text_encoder" in ct or "gemma" in fn:
return "text_encoder"
if "unet" in ct:
return "unet"
if "controlnet" in ct:
return "controlnet"
return "checkpoint"
def _read_workflow_json(path: Path, workflows_dir: Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except PermissionError:
try:
rel = path.relative_to(workflows_dir.resolve())
except ValueError:
log.warning("Workflow outside workflows dir, cannot use docker fallback: %s", path)
return None
container_path = container_workflow_path(rel.as_posix())
try:
result = subprocess.run(
["docker", "exec", COMFYUI_CONTAINER, "cat", container_path],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("Docker fallback failed for workflow %s: %s", path, exc)
return None
if result.returncode != 0:
log.warning(
"Docker fallback failed for workflow %s: %s",
path,
result.stderr.strip(),
)
return None
log.debug("Read workflow via docker exec: %s", path.name)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
log.warning("Invalid JSON from docker fallback for %s: %s", path, exc)
return None
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError) as exc:
log.warning("Failed to read workflow %s: %s", path, exc)
return None
def _iter_workflow_nodes(data: dict[str, Any]) -> Iterator[dict[str, Any]]:
nodes = data.get("nodes")
if isinstance(nodes, list):
for node in nodes:
if isinstance(node, dict):
yield node
definitions = data.get("definitions")
if isinstance(definitions, dict):
subgraphs = definitions.get("subgraphs")
if isinstance(subgraphs, list):
for subgraph in subgraphs:
if not isinstance(subgraph, dict):
continue
inner = subgraph.get("nodes")
if isinstance(inner, list):
for node in inner:
if isinstance(node, dict):
yield node
def _node_class_type(node: dict[str, Any]) -> str | None:
ct = node.get("class_type") or node.get("type")
if isinstance(ct, str) and not ct.isupper() and ct not in ("INT", "FLOAT", "STRING", "COMBO", "BOOLEAN", "*"):
return ct
return None
def _extract_from_widgets(
class_type: str,
widgets: list[Any],
) -> list[tuple[str, str]]:
refs: list[tuple[str, str]] = []
if class_type in MULTI_WIDGET_LOADERS:
field_specs = LOADER_NODES.get(class_type, [])
for idx, value in enumerate(widgets):
if not _is_model_filename(value):
continue
if idx < len(field_specs):
category = field_specs[idx][1]
else:
category = _infer_category_from_node(class_type, str(value))
refs.append((str(value), category))
return refs
if class_type in LOADER_NODES and widgets:
first = widgets[0]
if _is_model_filename(first):
refs.append((str(first), LOADER_NODES[class_type][0][1]))
return refs
def _extract_generic_widgets(class_type: str, widgets: list[Any]) -> list[tuple[str, str]]:
refs: list[tuple[str, str]] = []
for value in widgets:
if _is_model_filename(value):
refs.append((str(value), _infer_category_from_node(class_type, str(value))))
return refs
def _extract_from_node(node: dict[str, Any]) -> list[tuple[str, str]]:
refs: list[tuple[str, str]] = []
class_type = _node_class_type(node)
if not class_type:
return refs
widgets = node.get("widgets_values")
if isinstance(widgets, list):
if class_type in LOADER_NODES:
refs.extend(_extract_from_widgets(class_type, widgets))
else:
refs.extend(_extract_generic_widgets(class_type, widgets))
inputs = node.get("inputs")
if isinstance(inputs, dict) and class_type in LOADER_NODES:
for field_name, category in LOADER_NODES[class_type]:
value = inputs.get(field_name)
if _is_model_filename(value):
refs.append((str(value), category))
return refs
def _extract_from_api_format(data: dict[str, Any]) -> list[tuple[str, str]]:
refs: list[tuple[str, str]] = []
for _node_id, node in data.items():
if not isinstance(node, dict):
continue
class_type = node.get("class_type")
if not isinstance(class_type, str):
continue
if class_type not in LOADER_NODES:
continue
inputs = node.get("inputs")
if not isinstance(inputs, dict):
continue
for field_name, category in LOADER_NODES[class_type]:
value = inputs.get(field_name)
if _is_model_filename(value):
refs.append((str(value), category))
return refs
def _extract_from_ui_format(data: dict[str, Any]) -> list[tuple[str, str]]:
refs: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
for node in _iter_workflow_nodes(data):
for ref in _extract_from_node(node):
if ref not in seen:
seen.add(ref)
refs.append(ref)
return refs
def parse_workflow_data(data: dict[str, Any]) -> list[tuple[str, str]]:
if "nodes" in data:
return _extract_from_ui_format(data)
return _extract_from_api_format(data)
def parse_workflow_file(path: Path, workflows_dir: Path | None = None) -> list[tuple[str, str]]:
"""Return list of (filename, category) referenced in a workflow file."""
wf_dir = workflows_dir or path.parent
data = _read_workflow_json(path, wf_dir)
if not data:
return []
return parse_workflow_data(data)
def find_model_file(models_root: Path, filename: str) -> Path | None:
"""Find a model file by basename anywhere under models_root."""
if not models_root.is_dir():
return None
matches: list[Path] = []
for path in models_root.rglob(filename):
if path.is_file() and path.name == filename:
matches.append(path)
if not matches:
return None
return min(matches, key=lambda p: len(p.parts))
def scan_workflows(workflows_dir: Path) -> tuple[list[ModelRef], int]:
"""Scan all JSON workflows and aggregate model references."""
if not workflows_dir.is_dir():
return [], 0
aggregated: dict[tuple[str, str], ModelRef] = {}
file_count = 0
resolved_dir = workflows_dir.resolve()
for path in sorted(resolved_dir.rglob("*.json")):
if not path.is_file():
continue
file_count += 1
wf_name = path.stem
for filename, category in parse_workflow_file(path, resolved_dir):
key = (filename, category)
if key not in aggregated:
aggregated[key] = ModelRef(filename=filename, category=category)
if wf_name not in aggregated[key].workflows:
aggregated[key].workflows.append(wf_name)
return sorted(aggregated.values(), key=lambda r: (r.category, r.filename)), file_count