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
+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)