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:
+213
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user