Phase 3: env masking, image updates, port conflicts, resources, templates (0.3.0)
Backend:
- update_service: registry manifest digest check (Docker Hub/ghcr/lscr/private
v2 token auth) vs local RepoDigests; in-memory cache + background loop
- port_service: parse compose ports, check /proc/net/tcp[6] + docker bindings
- template_service + bundled templates (jellyfin/vaultwarden/uptime-kuma/
paperless-ngx/gitea) with {{VAR}} placeholders; custom templates in DB
- compose_edit set_resources (deploy.resources.limits/reservations)
- routers: images, ports, templates, editor/set-resources
- Template model; background update task wired into lifespan
Frontend:
- EnvEditor (table + raw, sensitive masking, quick-insert)
- Images page + UpdateBadge + dashboard 'updates available' banner
- PortConflictDialog pre-deploy check on Deploy
- ResourcePanel (CPU/RAM sliders) as editor Limits tab
- Templates page with per-variable instantiate form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b553c1b861
commit
22d9864436
@@ -3,8 +3,8 @@
|
|||||||
A self-hosted Docker Compose manager for power users and homelab enthusiasts —
|
A self-hosted Docker Compose manager for power users and homelab enthusiasts —
|
||||||
as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||||
|
|
||||||
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) complete. Image-update
|
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
|
||||||
> checks, templates, multi-host agents and backups land in later phases.
|
> Life) complete. Multi-host agents, backups and notifications land in Phase 4.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## What works today (Phase 1)
|
||||||
|
|
||||||
@@ -41,6 +41,20 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
> GPU/device detection needs host visibility. The bundled compose bind-mounts
|
> GPU/device detection needs host visibility. The bundled compose bind-mounts
|
||||||
> `/dev:/dev:ro`; NVIDIA additionally requires the NVIDIA container runtime on the host.
|
> `/dev:/dev:ro`; NVIDIA additionally requires the NVIDIA container runtime on the host.
|
||||||
|
|
||||||
|
### Phase 3 — Quality of Life
|
||||||
|
|
||||||
|
- **Env editor**: table mode with sensitive-value masking (`PASS`/`SECRET`/`KEY`/…
|
||||||
|
auto-detected) + raw mode, plus PUID/PGID/TZ quick-insert.
|
||||||
|
- **Image update checker**: background task compares the local manifest digest with
|
||||||
|
the registry (Docker Hub / ghcr / lscr / private v2 with token auth); update
|
||||||
|
badges on the Images page + an "updates available" banner on the dashboard.
|
||||||
|
- **Port conflict detector**: pre-deploy check against host-bound ports
|
||||||
|
(`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog.
|
||||||
|
- **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`.
|
||||||
|
- **Template library**: bundled templates (Jellyfin, Vaultwarden, Uptime-Kuma,
|
||||||
|
Paperless-NGX, Gitea) with `{{VARIABLE}}` forms; save any stack as a custom template.
|
||||||
|
- **Healthcheck status** surfaced per container in the stack overview.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -120,6 +134,15 @@ GET /api/host/paths?path=&show_hidden= (sandboxed browser)
|
|||||||
POST /api/editor/services | add-volume | set-gpu | add-device | remove-device | set-privileged
|
POST /api/editor/services | add-volume | set-gpu | add-device | remove-device | set-privileged
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Phase 3 endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/images | /updates POST /api/images/check
|
||||||
|
POST /api/ports/conflicts POST /api/editor/set-resources
|
||||||
|
GET /api/templates | /{id} POST /api/templates/{id}/instantiate
|
||||||
|
POST /api/templates DELETE /api/templates/custom/{slug}
|
||||||
|
```
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- The Docker socket is only ever touched by the backend process; it is never
|
- The Docker socket is only ever touched by the backend process; it is never
|
||||||
|
|||||||
+20
-2
@@ -1,6 +1,7 @@
|
|||||||
"""StackPilot backend — FastAPI application entry point."""
|
"""StackPilot backend — FastAPI application entry point."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -12,7 +13,19 @@ from sqlmodel import Session
|
|||||||
from config import settings
|
from config import settings
|
||||||
from database import engine, init_db
|
from database import engine, init_db
|
||||||
from docker_client import DockerError
|
from docker_client import DockerError
|
||||||
from routers import audit, auth, editor, stacks, system, volumes, ws
|
from routers import (
|
||||||
|
audit,
|
||||||
|
auth,
|
||||||
|
editor,
|
||||||
|
images,
|
||||||
|
ports,
|
||||||
|
stacks,
|
||||||
|
system,
|
||||||
|
templates,
|
||||||
|
volumes,
|
||||||
|
ws,
|
||||||
|
)
|
||||||
|
from services import update_service
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("stackpilot")
|
logger = logging.getLogger("stackpilot")
|
||||||
@@ -27,11 +40,13 @@ async def lifespan(app: FastAPI):
|
|||||||
stacks.sync_discovered_stacks(session)
|
stacks.sync_discovered_stacks(session)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Stack discovery failed: %s", exc)
|
logger.warning("Stack discovery failed: %s", exc)
|
||||||
|
update_task = asyncio.create_task(update_service.background_loop())
|
||||||
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
||||||
yield
|
yield
|
||||||
|
update_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.2.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.3.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -55,6 +70,9 @@ app.include_router(stacks.router)
|
|||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(volumes.router)
|
app.include_router(volumes.router)
|
||||||
app.include_router(editor.router)
|
app.include_router(editor.router)
|
||||||
|
app.include_router(images.router)
|
||||||
|
app.include_router(ports.router)
|
||||||
|
app.include_router(templates.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""SQLModel table models. Importing this package registers all tables."""
|
"""SQLModel table models. Importing this package registers all tables."""
|
||||||
from models.audit import AuditLog
|
from models.audit import AuditLog
|
||||||
from models.stack import Stack
|
from models.stack import Stack
|
||||||
|
from models.template import Template
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
|
||||||
__all__ = ["User", "Stack", "AuditLog"]
|
__all__ = ["User", "Stack", "AuditLog", "Template"]
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Template(SQLModel, table=True):
|
||||||
|
"""User-saved custom template (bundled ones live on disk)."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
slug: str = Field(index=True, unique=True)
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
tags: str = "" # comma separated
|
||||||
|
yaml: str = ""
|
||||||
|
created_at: datetime = Field(default_factory=_now)
|
||||||
|
|
||||||
|
|
||||||
|
# --- API schemas ---
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateVariable(SQLModel):
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
default: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSummary(SQLModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
tags: list[str] = []
|
||||||
|
gpu: Optional[str] = None
|
||||||
|
source: str = "bundled" # "bundled" | "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateDetail(TemplateSummary):
|
||||||
|
yaml: str
|
||||||
|
variables: list[TemplateVariable] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSaveRequest(SQLModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
tags: list[str] = []
|
||||||
|
yaml: str
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateInstantiateRequest(SQLModel):
|
||||||
|
name: str # new stack name
|
||||||
|
values: dict[str, str] = {}
|
||||||
@@ -36,6 +36,15 @@ class PrivilegedBody(BaseModel):
|
|||||||
value: bool
|
value: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ResourcesBody(BaseModel):
|
||||||
|
yaml: str
|
||||||
|
service: str
|
||||||
|
cpus: float | None = None
|
||||||
|
memory: str | None = None
|
||||||
|
cpus_reserve: float | None = None
|
||||||
|
memory_reserve: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _run(fn, *args) -> dict:
|
def _run(fn, *args) -> dict:
|
||||||
try:
|
try:
|
||||||
return {"yaml": fn(*args)}
|
return {"yaml": fn(*args)}
|
||||||
@@ -74,3 +83,20 @@ def remove_device(body: DeviceBody, _user: User = Depends(get_current_user)) ->
|
|||||||
@router.post("/set-privileged")
|
@router.post("/set-privileged")
|
||||||
def set_privileged(body: PrivilegedBody, _user: User = Depends(get_current_user)) -> dict:
|
def set_privileged(body: PrivilegedBody, _user: User = Depends(get_current_user)) -> dict:
|
||||||
return _run(edit.set_privileged, body.yaml, body.service, body.value)
|
return _run(edit.set_privileged, body.yaml, body.service, body.value)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-resources")
|
||||||
|
def set_resources(body: ResourcesBody, _user: User = Depends(get_current_user)) -> dict:
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"yaml": edit.set_resources(
|
||||||
|
body.yaml,
|
||||||
|
body.service,
|
||||||
|
cpus=body.cpus,
|
||||||
|
memory=body.memory,
|
||||||
|
cpus_reserve=body.cpus_reserve,
|
||||||
|
memory_reserve=body.memory_reserve,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
except edit.EditError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Image listing + update-check endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from auth import get_current_user, require_admin
|
||||||
|
from docker_client import DockerError, get_client, safe_call
|
||||||
|
from models.user import User
|
||||||
|
from services import update_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/images", tags=["images"])
|
||||||
|
|
||||||
|
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
images = safe_call(client.images.list)
|
||||||
|
containers = safe_call(client.containers.list, all=True)
|
||||||
|
except DockerError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Map image ref -> stacks using it.
|
||||||
|
usage: dict[str, set[str]] = {}
|
||||||
|
for c in containers:
|
||||||
|
ref = c.attrs.get("Config", {}).get("Image")
|
||||||
|
stack = c.labels.get(COMPOSE_PROJECT_LABEL)
|
||||||
|
if ref:
|
||||||
|
usage.setdefault(ref, set())
|
||||||
|
if stack:
|
||||||
|
usage[ref].add(stack)
|
||||||
|
|
||||||
|
cache = update_service.get_cache()
|
||||||
|
result = []
|
||||||
|
for img in images:
|
||||||
|
tags = img.tags or []
|
||||||
|
if not tags:
|
||||||
|
continue
|
||||||
|
for tag in tags:
|
||||||
|
upd = cache.get(tag)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": img.short_id,
|
||||||
|
"tag": tag,
|
||||||
|
"size": img.attrs.get("Size", 0),
|
||||||
|
"created": img.attrs.get("Created"),
|
||||||
|
"stacks": sorted(usage.get(tag, set())),
|
||||||
|
"update": upd,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result.sort(key=lambda r: r["tag"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/updates")
|
||||||
|
def updates(_user: User = Depends(get_current_user)) -> dict:
|
||||||
|
return update_service.get_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check")
|
||||||
|
async def check(_user: User = Depends(require_admin)) -> dict:
|
||||||
|
return await update_service.check_all()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Port conflict detection endpoint."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
from models.user import User
|
||||||
|
from services import port_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/ports", tags=["ports"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictBody(BaseModel):
|
||||||
|
yaml: str
|
||||||
|
ignore_stack: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conflicts")
|
||||||
|
def conflicts(body: ConflictBody, _user: User = Depends(get_current_user)) -> dict:
|
||||||
|
return {"conflicts": port_service.detect_conflicts(body.yaml, body.ignore_stack)}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Template library endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from auth import get_current_user, require_admin
|
||||||
|
from database import get_session
|
||||||
|
from models.stack import Stack
|
||||||
|
from models.template import TemplateInstantiateRequest, TemplateSaveRequest
|
||||||
|
from models.user import User
|
||||||
|
from services import audit_service, compose_service, template_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/templates", tags=["templates"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ip(request: Request) -> str:
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_templates(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> list[dict]:
|
||||||
|
return template_service.list_templates(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}")
|
||||||
|
def get_template(
|
||||||
|
template_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
tpl = template_service.get_template(session, template_id)
|
||||||
|
if not tpl:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
return tpl
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def save_template(
|
||||||
|
body: TemplateSaveRequest,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
tpl = template_service.save_custom(
|
||||||
|
session, body.name, body.yaml, body.description or "", body.tags
|
||||||
|
)
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="template.save", target=tpl.slug, ip=_ip(request)
|
||||||
|
)
|
||||||
|
return {"id": f"custom:{tpl.slug}", "name": tpl.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/custom/{slug}")
|
||||||
|
def delete_template(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
if not template_service.delete_custom(session, slug):
|
||||||
|
raise HTTPException(status_code=404, detail="Custom template not found")
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="template.delete", target=slug, ip=_ip(request)
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{template_id}/instantiate", status_code=201)
|
||||||
|
def instantiate(
|
||||||
|
template_id: str,
|
||||||
|
body: TemplateInstantiateRequest,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
tpl = template_service.get_template(session, template_id)
|
||||||
|
if not tpl:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
stack_id = compose_service.slugify(body.name)
|
||||||
|
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)):
|
||||||
|
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
|
||||||
|
|
||||||
|
rendered = template_service.render(tpl["yaml"], body.values)
|
||||||
|
compose_service.write_compose(stack_id, rendered)
|
||||||
|
stack = Stack(id=stack_id, name=body.name, description=tpl.get("description"))
|
||||||
|
session.add(stack)
|
||||||
|
session.commit()
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="template.instantiate",
|
||||||
|
target=stack_id, detail=template_id, ip=_ip(request),
|
||||||
|
)
|
||||||
|
return {"id": stack_id, "name": body.name}
|
||||||
@@ -144,6 +144,60 @@ def set_privileged(yaml_str: str, service: str, value: bool) -> str:
|
|||||||
return _dump(data)
|
return _dump(data)
|
||||||
|
|
||||||
|
|
||||||
|
def set_resources(
|
||||||
|
yaml_str: str,
|
||||||
|
service: str,
|
||||||
|
*,
|
||||||
|
cpus: float | None = None,
|
||||||
|
memory: str | None = None,
|
||||||
|
cpus_reserve: float | None = None,
|
||||||
|
memory_reserve: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Set deploy.resources limits/reservations for a service.
|
||||||
|
|
||||||
|
Pass None / empty to clear an individual value.
|
||||||
|
"""
|
||||||
|
data = _load(yaml_str)
|
||||||
|
svc = _get_service(data, service)
|
||||||
|
deploy = svc.setdefault("deploy", {})
|
||||||
|
resources = deploy.setdefault("resources", {})
|
||||||
|
|
||||||
|
limits = resources.get("limits", {})
|
||||||
|
if cpus:
|
||||||
|
limits["cpus"] = str(cpus)
|
||||||
|
else:
|
||||||
|
limits.pop("cpus", None)
|
||||||
|
if memory:
|
||||||
|
limits["memory"] = memory
|
||||||
|
else:
|
||||||
|
limits.pop("memory", None)
|
||||||
|
if limits:
|
||||||
|
resources["limits"] = limits
|
||||||
|
else:
|
||||||
|
resources.pop("limits", None)
|
||||||
|
|
||||||
|
reservations = resources.get("reservations", {})
|
||||||
|
if cpus_reserve:
|
||||||
|
reservations["cpus"] = str(cpus_reserve)
|
||||||
|
else:
|
||||||
|
reservations.pop("cpus", None)
|
||||||
|
if memory_reserve:
|
||||||
|
reservations["memory"] = memory_reserve
|
||||||
|
else:
|
||||||
|
reservations.pop("memory", None)
|
||||||
|
# Don't drop reservations entirely — it may hold GPU devices.
|
||||||
|
if reservations:
|
||||||
|
resources["reservations"] = reservations
|
||||||
|
elif "reservations" in resources and not resources["reservations"]:
|
||||||
|
resources.pop("reservations", None)
|
||||||
|
|
||||||
|
if not resources:
|
||||||
|
deploy.pop("resources", None)
|
||||||
|
if not deploy:
|
||||||
|
svc.pop("deploy", None)
|
||||||
|
return _dump(data)
|
||||||
|
|
||||||
|
|
||||||
def list_services(yaml_str: str) -> list[str]:
|
def list_services(yaml_str: str) -> list[str]:
|
||||||
data = _load(yaml_str)
|
data = _load(yaml_str)
|
||||||
return list(data["services"].keys())
|
return list(data["services"].keys())
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Port conflict detection before deploying a stack."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from docker_client import DockerError, get_client, safe_call
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parse compose `ports:` entries
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def parse_compose_ports(yaml_str: str) -> list[dict]:
|
||||||
|
"""Return [{host_port:int, protocol:'tcp'|'udp', service:str}]."""
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(yaml_str) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return []
|
||||||
|
services = data.get("services") or {}
|
||||||
|
out: list[dict] = []
|
||||||
|
for svc_name, svc in services.items():
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
continue
|
||||||
|
for entry in svc.get("ports", []) or []:
|
||||||
|
parsed = _parse_port_entry(entry)
|
||||||
|
if parsed:
|
||||||
|
parsed["service"] = svc_name
|
||||||
|
out.append(parsed)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_port_entry(entry) -> Optional[dict]:
|
||||||
|
# Long form: {target, published, protocol}
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
published = entry.get("published")
|
||||||
|
if published is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
host_port = int(str(published).split("-")[0])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return {"host_port": host_port, "protocol": entry.get("protocol", "tcp")}
|
||||||
|
|
||||||
|
# Short form string: "[ip:]host:container[/proto]" or "container"
|
||||||
|
s = str(entry)
|
||||||
|
proto = "tcp"
|
||||||
|
if "/" in s:
|
||||||
|
s, proto = s.rsplit("/", 1)
|
||||||
|
parts = s.split(":")
|
||||||
|
# No host mapping (only container port) -> random host port, no conflict.
|
||||||
|
if len(parts) == 1:
|
||||||
|
return None
|
||||||
|
# host:container or ip:host:container
|
||||||
|
host = parts[-2]
|
||||||
|
try:
|
||||||
|
host_port = int(host.split("-")[0])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return {"host_port": host_port, "protocol": proto}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Host bound ports
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _read_proc_net(name: str) -> set[int]:
|
||||||
|
ports: set[int] = set()
|
||||||
|
path = os.path.join(settings.HOST_PROC_PATH, "net", name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
path = os.path.join("/proc/net", name)
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
lines = fh.readlines()[1:]
|
||||||
|
except OSError:
|
||||||
|
return ports
|
||||||
|
for line in lines:
|
||||||
|
cols = line.split()
|
||||||
|
if len(cols) < 4:
|
||||||
|
continue
|
||||||
|
local = cols[1] # hexip:hexport
|
||||||
|
state = cols[3]
|
||||||
|
# TCP listen state is 0A; for UDP accept all.
|
||||||
|
if name.startswith("tcp") and state != "0A":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
port = int(local.split(":")[1], 16)
|
||||||
|
ports.add(port)
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
continue
|
||||||
|
return ports
|
||||||
|
|
||||||
|
|
||||||
|
def host_listening_ports() -> dict[str, set[int]]:
|
||||||
|
return {
|
||||||
|
"tcp": _read_proc_net("tcp") | _read_proc_net("tcp6"),
|
||||||
|
"udp": _read_proc_net("udp") | _read_proc_net("udp6"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def docker_bound_ports() -> dict[tuple[int, str], str]:
|
||||||
|
"""Return {(host_port, proto): container_name}."""
|
||||||
|
out: dict[tuple[int, str], str] = {}
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
for c in safe_call(client.containers.list):
|
||||||
|
bindings = (c.attrs.get("NetworkSettings") or {}).get("Ports") or {}
|
||||||
|
for container_port, hosts in bindings.items():
|
||||||
|
if not hosts:
|
||||||
|
continue
|
||||||
|
proto = container_port.split("/")[-1] if "/" in container_port else "tcp"
|
||||||
|
for h in hosts:
|
||||||
|
hp = h.get("HostPort")
|
||||||
|
if hp:
|
||||||
|
out[(int(hp), proto)] = c.name
|
||||||
|
except (DockerError, ValueError):
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Detect conflicts
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def detect_conflicts(yaml_str: str, ignore_stack: Optional[str] = None) -> list[dict]:
|
||||||
|
wanted = parse_compose_ports(yaml_str)
|
||||||
|
host_ports = host_listening_ports()
|
||||||
|
docker_ports = docker_bound_ports()
|
||||||
|
|
||||||
|
conflicts: list[dict] = []
|
||||||
|
for w in wanted:
|
||||||
|
port = w["host_port"]
|
||||||
|
proto = w.get("protocol", "tcp")
|
||||||
|
used_by = None
|
||||||
|
owner = docker_ports.get((port, proto))
|
||||||
|
if owner:
|
||||||
|
# A container from the same stack (re-deploy) is not a conflict.
|
||||||
|
if ignore_stack and owner.startswith(f"{ignore_stack}-"):
|
||||||
|
continue
|
||||||
|
used_by = f"container {owner}"
|
||||||
|
elif port in host_ports.get(proto, set()):
|
||||||
|
used_by = "host process"
|
||||||
|
if used_by:
|
||||||
|
conflicts.append(
|
||||||
|
{
|
||||||
|
"port": port,
|
||||||
|
"protocol": proto,
|
||||||
|
"service": w.get("service"),
|
||||||
|
"used_by": used_by,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return conflicts
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Template library — bundled (on-disk) + custom (DB)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from models.template import Template
|
||||||
|
|
||||||
|
_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
|
||||||
|
_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _manifest() -> list[dict]:
|
||||||
|
path = os.path.join(_TEMPLATES_DIR, "manifest.json")
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _read_template_file(filename: str) -> str:
|
||||||
|
path = os.path.join(_TEMPLATES_DIR, filename)
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_variables(yaml_str: str) -> list[str]:
|
||||||
|
seen: list[str] = []
|
||||||
|
for m in _VAR_RE.finditer(yaml_str):
|
||||||
|
if m.group(1) not in seen:
|
||||||
|
seen.append(m.group(1))
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def render(yaml_str: str, values: dict[str, str]) -> str:
|
||||||
|
def repl(m: re.Match) -> str:
|
||||||
|
key = m.group(1)
|
||||||
|
return str(values.get(key, m.group(0)))
|
||||||
|
|
||||||
|
return _VAR_RE.sub(repl, yaml_str)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Listing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def list_templates(session: Session) -> list[dict]:
|
||||||
|
out: list[dict] = []
|
||||||
|
for entry in _manifest():
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": entry["id"],
|
||||||
|
"name": entry["name"],
|
||||||
|
"description": entry.get("description"),
|
||||||
|
"tags": entry.get("tags", []),
|
||||||
|
"gpu": entry.get("gpu"),
|
||||||
|
"source": "bundled",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for tpl in session.exec(select(Template)).all():
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": f"custom:{tpl.slug}",
|
||||||
|
"name": tpl.name,
|
||||||
|
"description": tpl.description,
|
||||||
|
"tags": [t for t in tpl.tags.split(",") if t],
|
||||||
|
"gpu": None,
|
||||||
|
"source": "custom",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_template(session: Session, template_id: str) -> Optional[dict]:
|
||||||
|
if template_id.startswith("custom:"):
|
||||||
|
slug = template_id.split(":", 1)[1]
|
||||||
|
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
|
||||||
|
if not tpl:
|
||||||
|
return None
|
||||||
|
variables = [
|
||||||
|
{"name": v, "description": "", "default": ""}
|
||||||
|
for v in extract_variables(tpl.yaml)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"id": template_id,
|
||||||
|
"name": tpl.name,
|
||||||
|
"description": tpl.description,
|
||||||
|
"tags": [t for t in tpl.tags.split(",") if t],
|
||||||
|
"gpu": None,
|
||||||
|
"source": "custom",
|
||||||
|
"yaml": tpl.yaml,
|
||||||
|
"variables": variables,
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in _manifest():
|
||||||
|
if entry["id"] == template_id:
|
||||||
|
yaml_str = _read_template_file(entry["file"])
|
||||||
|
declared = {v["name"]: v for v in entry.get("variables", [])}
|
||||||
|
# Merge declared metadata with any vars actually present.
|
||||||
|
variables = []
|
||||||
|
for name in extract_variables(yaml_str):
|
||||||
|
meta = declared.get(name, {})
|
||||||
|
variables.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"default": meta.get("default", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": entry["id"],
|
||||||
|
"name": entry["name"],
|
||||||
|
"description": entry.get("description"),
|
||||||
|
"tags": entry.get("tags", []),
|
||||||
|
"gpu": entry.get("gpu"),
|
||||||
|
"source": "bundled",
|
||||||
|
"yaml": yaml_str,
|
||||||
|
"variables": variables,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_custom(
|
||||||
|
session: Session, name: str, yaml_str: str, description: str = "", tags: list[str] | None = None
|
||||||
|
) -> Template:
|
||||||
|
slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-") or "template"
|
||||||
|
existing = session.exec(select(Template).where(Template.slug == slug)).first()
|
||||||
|
if existing:
|
||||||
|
existing.name = name
|
||||||
|
existing.description = description
|
||||||
|
existing.tags = ",".join(tags or [])
|
||||||
|
existing.yaml = yaml_str
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(existing)
|
||||||
|
return existing
|
||||||
|
tpl = Template(
|
||||||
|
slug=slug,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
tags=",".join(tags or []),
|
||||||
|
yaml=yaml_str,
|
||||||
|
)
|
||||||
|
session.add(tpl)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(tpl)
|
||||||
|
return tpl
|
||||||
|
|
||||||
|
|
||||||
|
def delete_custom(session: Session, slug: str) -> bool:
|
||||||
|
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
|
||||||
|
if not tpl:
|
||||||
|
return False
|
||||||
|
session.delete(tpl)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Image update checker.
|
||||||
|
|
||||||
|
Compares the locally-pulled manifest digest (from RepoDigests) against the
|
||||||
|
current manifest digest in the registry. Supports Docker Hub, ghcr.io, lscr.io
|
||||||
|
and other token-auth v2 registries.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from docker_client import DockerError, get_client, safe_call
|
||||||
|
|
||||||
|
logger = logging.getLogger("stackpilot.update")
|
||||||
|
|
||||||
|
_MANIFEST_ACCEPT = ", ".join(
|
||||||
|
[
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
"application/vnd.oci.image.manifest.v1+json",
|
||||||
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateStatus:
|
||||||
|
image: str
|
||||||
|
update_available: bool
|
||||||
|
current_digest: Optional[str]
|
||||||
|
remote_digest: Optional[str]
|
||||||
|
checked_at: float
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
# image ref -> UpdateStatus
|
||||||
|
_CACHE: dict[str, UpdateStatus] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Image reference parsing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ref(image: str) -> tuple[str, str, str]:
|
||||||
|
"""Return (registry_host, repository, tag/digest)."""
|
||||||
|
ref = image
|
||||||
|
tag = "latest"
|
||||||
|
# split tag (but not the registry port colon)
|
||||||
|
if "@" in ref:
|
||||||
|
ref, tag = ref.split("@", 1)
|
||||||
|
else:
|
||||||
|
# find last colon after last slash
|
||||||
|
slash = ref.rfind("/")
|
||||||
|
colon = ref.rfind(":")
|
||||||
|
if colon > slash:
|
||||||
|
tag = ref[colon + 1 :]
|
||||||
|
ref = ref[:colon]
|
||||||
|
|
||||||
|
parts = ref.split("/", 1)
|
||||||
|
if len(parts) == 2 and ("." in parts[0] or ":" in parts[0] or parts[0] == "localhost"):
|
||||||
|
registry = parts[0]
|
||||||
|
repo = parts[1]
|
||||||
|
else:
|
||||||
|
registry = "registry-1.docker.io"
|
||||||
|
repo = ref
|
||||||
|
if "/" not in repo:
|
||||||
|
repo = "library/" + repo
|
||||||
|
return registry, repo, tag
|
||||||
|
|
||||||
|
|
||||||
|
def _local_digest(image: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
img = safe_call(client.images.get, image)
|
||||||
|
except DockerError:
|
||||||
|
return None
|
||||||
|
repo_digests = img.attrs.get("RepoDigests") or []
|
||||||
|
for rd in repo_digests:
|
||||||
|
if "@" in rd:
|
||||||
|
return rd.split("@", 1)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Registry manifest digest
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_token(client: httpx.AsyncClient, www_auth: str) -> Optional[str]:
|
||||||
|
# Parse: Bearer realm="...",service="...",scope="..."
|
||||||
|
params = {}
|
||||||
|
if not www_auth.lower().startswith("bearer"):
|
||||||
|
return None
|
||||||
|
for part in www_auth[len("Bearer ") :].split(","):
|
||||||
|
if "=" in part:
|
||||||
|
k, v = part.split("=", 1)
|
||||||
|
params[k.strip()] = v.strip().strip('"')
|
||||||
|
realm = params.pop("realm", None)
|
||||||
|
if not realm:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resp = await client.get(realm, params=params, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("token") or data.get("access_token")
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def remote_digest(image: str) -> Optional[str]:
|
||||||
|
registry, repo, tag = parse_ref(image)
|
||||||
|
if tag.startswith("sha256:"):
|
||||||
|
return tag
|
||||||
|
scheme = "https"
|
||||||
|
url = f"{scheme}://{registry}/v2/{repo}/manifests/{tag}"
|
||||||
|
headers = {"Accept": _MANIFEST_ACCEPT}
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.head(url, headers=headers, timeout=10)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = await _get_token(client, resp.headers.get("WWW-Authenticate", ""))
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
resp = await client.head(url, headers=headers, timeout=10)
|
||||||
|
if resp.status_code == 405 or "Docker-Content-Digest" not in resp.headers:
|
||||||
|
# Some registries don't support HEAD; fall back to GET.
|
||||||
|
resp = await client.get(url, headers=headers, timeout=10)
|
||||||
|
digest = resp.headers.get("Docker-Content-Digest")
|
||||||
|
return digest
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.debug("remote_digest failed for %s: %s", image, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Public API
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
async def check_image(image: str) -> UpdateStatus:
|
||||||
|
local = _local_digest(image)
|
||||||
|
remote = await remote_digest(image)
|
||||||
|
error = None
|
||||||
|
if remote is None:
|
||||||
|
error = "could not reach registry"
|
||||||
|
update_available = bool(local and remote and local != remote)
|
||||||
|
status = UpdateStatus(
|
||||||
|
image=image,
|
||||||
|
update_available=update_available,
|
||||||
|
current_digest=local,
|
||||||
|
remote_digest=remote,
|
||||||
|
checked_at=time.time(),
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
_CACHE[image] = status
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _all_running_images() -> set[str]:
|
||||||
|
images: set[str] = set()
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
for c in safe_call(client.containers.list, all=True):
|
||||||
|
cfg_image = c.attrs.get("Config", {}).get("Image")
|
||||||
|
if cfg_image:
|
||||||
|
images.add(cfg_image)
|
||||||
|
except DockerError:
|
||||||
|
pass
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
async def check_all() -> dict[str, dict]:
|
||||||
|
images = _all_running_images()
|
||||||
|
for image in images:
|
||||||
|
await check_image(image)
|
||||||
|
return {k: v.to_dict() for k, v in _CACHE.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache() -> dict[str, dict]:
|
||||||
|
return {k: v.to_dict() for k, v in _CACHE.items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def background_loop():
|
||||||
|
interval = max(settings.UPDATE_CHECK_INTERVAL_MINUTES, 5) * 60
|
||||||
|
# initial delay so startup isn't blocked
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await check_all()
|
||||||
|
logger.info("Image update check complete (%d images)", len(_CACHE))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Image update check failed: %s", exc)
|
||||||
|
await asyncio.sleep(interval)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
container_name: gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- USER_UID={{PUID}}
|
||||||
|
- USER_GID={{PGID}}
|
||||||
|
ports:
|
||||||
|
- "{{HTTP_PORT}}:3000"
|
||||||
|
- "{{SSH_PORT}}:22"
|
||||||
|
volumes:
|
||||||
|
- {{DATA_PATH}}:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
jellyfin:
|
||||||
|
image: jellyfin/jellyfin:latest
|
||||||
|
container_name: jellyfin
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PUID={{PUID}}
|
||||||
|
- PGID={{PGID}}
|
||||||
|
- TZ={{TZ}}
|
||||||
|
ports:
|
||||||
|
- "{{HTTP_PORT}}:8096"
|
||||||
|
volumes:
|
||||||
|
- {{CONFIG_PATH}}:/config
|
||||||
|
- {{MEDIA_PATH}}:/media
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "jellyfin",
|
||||||
|
"name": "Jellyfin",
|
||||||
|
"description": "Free media streaming server with optional hardware transcoding.",
|
||||||
|
"tags": ["media", "streaming"],
|
||||||
|
"gpu": "NVIDIA / Intel optional",
|
||||||
|
"file": "jellyfin.yaml",
|
||||||
|
"variables": [
|
||||||
|
{ "name": "PUID", "description": "User ID", "default": "1000" },
|
||||||
|
{ "name": "PGID", "description": "Group ID", "default": "1000" },
|
||||||
|
{ "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" },
|
||||||
|
{ "name": "CONFIG_PATH", "description": "Host path for config", "default": "/srv/jellyfin/config" },
|
||||||
|
{ "name": "MEDIA_PATH", "description": "Host path to media library", "default": "/srv/media" },
|
||||||
|
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "8096" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vaultwarden",
|
||||||
|
"name": "Vaultwarden",
|
||||||
|
"description": "Lightweight Bitwarden-compatible password manager.",
|
||||||
|
"tags": ["password-manager", "security"],
|
||||||
|
"gpu": null,
|
||||||
|
"file": "vaultwarden.yaml",
|
||||||
|
"variables": [
|
||||||
|
{ "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" },
|
||||||
|
{ "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/vaultwarden" },
|
||||||
|
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "8200" },
|
||||||
|
{ "name": "ADMIN_TOKEN", "description": "Admin panel token", "default": "change-me" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uptime-kuma",
|
||||||
|
"name": "Uptime Kuma",
|
||||||
|
"description": "Self-hosted uptime / status monitoring tool.",
|
||||||
|
"tags": ["monitoring"],
|
||||||
|
"gpu": null,
|
||||||
|
"file": "uptime-kuma.yaml",
|
||||||
|
"variables": [
|
||||||
|
{ "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/uptime-kuma" },
|
||||||
|
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "3001" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "paperless-ngx",
|
||||||
|
"name": "Paperless-NGX",
|
||||||
|
"description": "Document management system that indexes scanned documents.",
|
||||||
|
"tags": ["documents"],
|
||||||
|
"gpu": null,
|
||||||
|
"file": "paperless-ngx.yaml",
|
||||||
|
"variables": [
|
||||||
|
{ "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" },
|
||||||
|
{ "name": "DATA_PATH", "description": "Host path base", "default": "/srv/paperless" },
|
||||||
|
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "8000" },
|
||||||
|
{ "name": "ADMIN_USER", "description": "Admin username", "default": "admin" },
|
||||||
|
{ "name": "ADMIN_PASSWORD", "description": "Admin password", "default": "change-me" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gitea",
|
||||||
|
"name": "Gitea",
|
||||||
|
"description": "Lightweight self-hosted Git service.",
|
||||||
|
"tags": ["git", "dev"],
|
||||||
|
"gpu": null,
|
||||||
|
"file": "gitea.yaml",
|
||||||
|
"variables": [
|
||||||
|
{ "name": "PUID", "description": "User ID", "default": "1000" },
|
||||||
|
{ "name": "PGID", "description": "Group ID", "default": "1000" },
|
||||||
|
{ "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/gitea" },
|
||||||
|
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "3000" },
|
||||||
|
{ "name": "SSH_PORT", "description": "SSH port", "default": "2222" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
broker:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: paperless-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- {{DATA_PATH}}/redis:/data
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: paperless-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=paperless
|
||||||
|
- POSTGRES_USER=paperless
|
||||||
|
- POSTGRES_PASSWORD=paperless
|
||||||
|
volumes:
|
||||||
|
- {{DATA_PATH}}/db:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||||
|
container_name: paperless
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- broker
|
||||||
|
environment:
|
||||||
|
- PAPERLESS_REDIS=redis://broker:6379
|
||||||
|
- PAPERLESS_DBHOST=db
|
||||||
|
- PAPERLESS_TIME_ZONE={{TZ}}
|
||||||
|
- PAPERLESS_ADMIN_USER={{ADMIN_USER}}
|
||||||
|
- PAPERLESS_ADMIN_PASSWORD={{ADMIN_PASSWORD}}
|
||||||
|
ports:
|
||||||
|
- "{{HTTP_PORT}}:8000"
|
||||||
|
volumes:
|
||||||
|
- {{DATA_PATH}}/data:/usr/src/paperless/data
|
||||||
|
- {{DATA_PATH}}/media:/usr/src/paperless/media
|
||||||
|
- {{DATA_PATH}}/consume:/usr/src/paperless/consume
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
container_name: uptime-kuma
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "{{HTTP_PORT}}:3001"
|
||||||
|
volumes:
|
||||||
|
- {{DATA_PATH}}:/app/data
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
vaultwarden:
|
||||||
|
image: vaultwarden/server:latest
|
||||||
|
container_name: vaultwarden
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ={{TZ}}
|
||||||
|
- ADMIN_TOKEN={{ADMIN_TOKEN}}
|
||||||
|
- WEBSOCKET_ENABLED=true
|
||||||
|
ports:
|
||||||
|
- "{{HTTP_PORT}}:80"
|
||||||
|
volumes:
|
||||||
|
- {{DATA_PATH}}:/data
|
||||||
@@ -6,7 +6,9 @@ import { Dashboard } from "@/pages/Dashboard";
|
|||||||
import { Stacks } from "@/pages/Stacks";
|
import { Stacks } from "@/pages/Stacks";
|
||||||
import { StackDetail } from "@/pages/StackDetail";
|
import { StackDetail } from "@/pages/StackDetail";
|
||||||
import { StackEditor } from "@/pages/StackEditor";
|
import { StackEditor } from "@/pages/StackEditor";
|
||||||
import { Networks, Images, Templates, Settings } from "@/pages/Placeholder";
|
import { Images } from "@/pages/Images";
|
||||||
|
import { Templates } from "@/pages/Templates";
|
||||||
|
import { Networks, Settings } from "@/pages/Placeholder";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,17 @@ export const editorApi = {
|
|||||||
value,
|
value,
|
||||||
})
|
})
|
||||||
.then((r) => r.data.yaml),
|
.then((r) => r.data.yaml),
|
||||||
|
setResources: (
|
||||||
|
yaml: string,
|
||||||
|
service: string,
|
||||||
|
res: {
|
||||||
|
cpus?: number | null;
|
||||||
|
memory?: string | null;
|
||||||
|
cpus_reserve?: number | null;
|
||||||
|
memory_reserve?: string | null;
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
api
|
||||||
|
.post<{ yaml: string }>("/api/editor/set-resources", { yaml, service, ...res })
|
||||||
|
.then((r) => r.data.yaml),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import api from "./client";
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
image: string;
|
||||||
|
update_available: boolean;
|
||||||
|
current_digest: string | null;
|
||||||
|
remote_digest: string | null;
|
||||||
|
checked_at: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageRow {
|
||||||
|
id: string;
|
||||||
|
tag: string;
|
||||||
|
size: number;
|
||||||
|
created: string;
|
||||||
|
stacks: string[];
|
||||||
|
update: UpdateStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imagesApi = {
|
||||||
|
list: () => api.get<ImageRow[]>("/api/images").then((r) => r.data),
|
||||||
|
updates: () =>
|
||||||
|
api.get<Record<string, UpdateStatus>>("/api/images/updates").then((r) => r.data),
|
||||||
|
check: () =>
|
||||||
|
api.post<Record<string, UpdateStatus>>("/api/images/check").then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import api from "./client";
|
||||||
|
|
||||||
|
export interface PortConflict {
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
service: string | null;
|
||||||
|
used_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portsApi = {
|
||||||
|
conflicts: (yaml: string, ignore_stack?: string) =>
|
||||||
|
api
|
||||||
|
.post<{ conflicts: PortConflict[] }>("/api/ports/conflicts", {
|
||||||
|
yaml,
|
||||||
|
ignore_stack,
|
||||||
|
})
|
||||||
|
.then((r) => r.data.conflicts),
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import api from "./client";
|
||||||
|
|
||||||
|
export interface TemplateVariable {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
default: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
gpu?: string | null;
|
||||||
|
source: "bundled" | "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateDetail extends TemplateSummary {
|
||||||
|
yaml: string;
|
||||||
|
variables: TemplateVariable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templatesApi = {
|
||||||
|
list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data),
|
||||||
|
get: (id: string) =>
|
||||||
|
api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data),
|
||||||
|
instantiate: (id: string, name: string, values: Record<string, string>) =>
|
||||||
|
api
|
||||||
|
.post<{ id: string; name: string }>(`/api/templates/${id}/instantiate`, {
|
||||||
|
name,
|
||||||
|
values,
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
save: (body: { name: string; description?: string; tags: string[]; yaml: string }) =>
|
||||||
|
api.post("/api/templates", body).then((r) => r.data),
|
||||||
|
remove: (slug: string) =>
|
||||||
|
api.delete(`/api/templates/custom/${slug}`).then((r) => r.data),
|
||||||
|
};
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, Trash2, Eye, EyeOff, Table, FileText } from "lucide-react";
|
||||||
|
import { Button, Input } from "@/components/ui";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_RE = /(PASS|SECRET|TOKEN|KEY|APIKEY|PWD|CREDENTIAL)/i;
|
||||||
|
|
||||||
|
function parseEnv(text: string): Row[] {
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l.trim() && !l.trim().startsWith("#") && l.includes("="))
|
||||||
|
.map((l) => {
|
||||||
|
const idx = l.indexOf("=");
|
||||||
|
return { key: l.slice(0, idx).trim(), value: l.slice(idx + 1) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(rows: Row[]): string {
|
||||||
|
return rows
|
||||||
|
.filter((r) => r.key.trim())
|
||||||
|
.map((r) => `${r.key.trim()}=${r.value}`)
|
||||||
|
.join("\n")
|
||||||
|
.concat(rows.length ? "\n" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK = [
|
||||||
|
{ key: "PUID", value: "1000" },
|
||||||
|
{ key: "PGID", value: "1000" },
|
||||||
|
{ key: "TZ", value: "Europe/Berlin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EnvEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<"table" | "raw">("table");
|
||||||
|
const [reveal, setReveal] = useState<Record<number, boolean>>({});
|
||||||
|
const rows = useMemo(() => parseEnv(value), [value]);
|
||||||
|
|
||||||
|
const update = (next: Row[]) => onChange(serialize(next));
|
||||||
|
|
||||||
|
const setRow = (i: number, patch: Partial<Row>) =>
|
||||||
|
update(rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||||
|
const addRow = (row: Row = { key: "", value: "" }) => update([...rows, row]);
|
||||||
|
const delRow = (i: number) => update(rows.filter((_, idx) => idx !== i));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode("table")}
|
||||||
|
className={
|
||||||
|
mode === "table"
|
||||||
|
? "flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
|
||||||
|
: "flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table className="h-3.5 w-3.5" /> Table
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode("raw")}
|
||||||
|
className={
|
||||||
|
mode === "raw"
|
||||||
|
? "flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
|
||||||
|
: "flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5" /> Raw
|
||||||
|
</button>
|
||||||
|
{mode === "table" && (
|
||||||
|
<div className="ml-auto flex gap-1">
|
||||||
|
{QUICK.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q.key}
|
||||||
|
onClick={() => addRow(q)}
|
||||||
|
className="rounded border border-slate-300 px-2 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
+ {q.key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "raw" ? (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 resize-none rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs dark:border-slate-600 dark:bg-slate-800"
|
||||||
|
placeholder="KEY=value"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-xs text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-1">Key</th>
|
||||||
|
<th className="pb-1">Value</th>
|
||||||
|
<th className="pb-1 w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const sensitive = SENSITIVE_RE.test(r.key);
|
||||||
|
const masked = sensitive && !reveal[i];
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="pr-2 py-1">
|
||||||
|
<Input value={r.key} onChange={(e) => setRow(i, { key: e.target.value })} />
|
||||||
|
</td>
|
||||||
|
<td className="pr-2 py-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type={masked ? "password" : "text"}
|
||||||
|
value={r.value}
|
||||||
|
onChange={(e) => setRow(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
{sensitive && (
|
||||||
|
<button
|
||||||
|
onClick={() => setReveal((s) => ({ ...s, [i]: !s[i] }))}
|
||||||
|
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
title={masked ? "Reveal" : "Hide"}
|
||||||
|
>
|
||||||
|
{masked ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => delRow(i)}
|
||||||
|
className="rounded p-1.5 text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Button variant="outline" className="mt-2" onClick={() => addRow()}>
|
||||||
|
<Plus className="h-4 w-4" /> Add variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { RefreshCw, HardDrive, Cpu, Plug } from "lucide-react";
|
import { RefreshCw, HardDrive, Cpu, Plug, Gauge } from "lucide-react";
|
||||||
import { VolumeWizard } from "@/components/volumes/VolumeWizard";
|
import { VolumeWizard } from "@/components/volumes/VolumeWizard";
|
||||||
import { GPUSelector } from "@/components/gpu/GPUSelector";
|
import { GPUSelector } from "@/components/gpu/GPUSelector";
|
||||||
import { DevicePanel } from "@/components/gpu/DevicePanel";
|
import { DevicePanel } from "@/components/gpu/DevicePanel";
|
||||||
|
import { ResourcePanel } from "@/components/stacks/ResourcePanel";
|
||||||
import { editorApi } from "@/api/editor";
|
import { editorApi } from "@/api/editor";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type Tab = "volumes" | "gpu" | "devices";
|
type Tab = "volumes" | "gpu" | "devices" | "resources";
|
||||||
|
|
||||||
export function EditorHelperPanel({
|
export function EditorHelperPanel({
|
||||||
yaml,
|
yaml,
|
||||||
@@ -83,6 +84,7 @@ export function EditorHelperPanel({
|
|||||||
["volumes", "Volumes", HardDrive],
|
["volumes", "Volumes", HardDrive],
|
||||||
["gpu", "GPU", Cpu],
|
["gpu", "GPU", Cpu],
|
||||||
["devices", "Devices", Plug],
|
["devices", "Devices", Plug],
|
||||||
|
["resources", "Limits", Gauge],
|
||||||
] as [Tab, string, typeof Cpu][]).map(([id, label, Icon]) => (
|
] as [Tab, string, typeof Cpu][]).map(([id, label, Icon]) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
@@ -130,6 +132,14 @@ export function EditorHelperPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{tab === "resources" && (
|
||||||
|
<ResourcePanel
|
||||||
|
onApply={(res) => {
|
||||||
|
if (!guard()) return;
|
||||||
|
run(() => editorApi.setResources(yaml, service, res));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import type { PortConflict } from "@/api/ports";
|
||||||
|
|
||||||
|
export function PortConflictDialog({
|
||||||
|
conflicts,
|
||||||
|
onContinue,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
conflicts: PortConflict[];
|
||||||
|
onContinue: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="w-full max-w-lg rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
<h2 className="text-lg font-semibold">Port conflicts detected</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="mb-4 space-y-2">
|
||||||
|
{conflicts.map((c, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="rounded-lg border border-slate-200 px-3 py-2 text-sm dark:border-slate-700"
|
||||||
|
>
|
||||||
|
Port <span className="font-mono font-semibold">{c.port}</span>/
|
||||||
|
{c.protocol}
|
||||||
|
{c.service && <span className="text-slate-500"> ({c.service})</span>} →
|
||||||
|
already in use by <span className="font-medium">{c.used_by}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Edit Compose
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={onContinue}>
|
||||||
|
Continue anyway
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Check as CheckIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
export function ResourcePanel({
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
onApply: (res: {
|
||||||
|
cpus?: number | null;
|
||||||
|
memory?: string | null;
|
||||||
|
cpus_reserve?: number | null;
|
||||||
|
memory_reserve?: string | null;
|
||||||
|
}) => void;
|
||||||
|
}) {
|
||||||
|
const [cpuOn, setCpuOn] = useState(false);
|
||||||
|
const [cpus, setCpus] = useState(1);
|
||||||
|
const [memOn, setMemOn] = useState(false);
|
||||||
|
const [mem, setMem] = useState(512); // MB
|
||||||
|
|
||||||
|
const apply = () =>
|
||||||
|
onApply({
|
||||||
|
cpus: cpuOn ? cpus : null,
|
||||||
|
memory: memOn ? `${mem}m` : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input type="checkbox" checked={cpuOn} onChange={(e) => setCpuOn(e.target.checked)} />
|
||||||
|
CPU limit
|
||||||
|
</label>
|
||||||
|
{cpuOn && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.25}
|
||||||
|
max={16}
|
||||||
|
step={0.25}
|
||||||
|
value={cpus}
|
||||||
|
onChange={(e) => setCpus(Number(e.target.value))}
|
||||||
|
className="w-full accent-sky-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">{cpus} CPU(s)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input type="checkbox" checked={memOn} onChange={(e) => setMemOn(e.target.checked)} />
|
||||||
|
Memory limit
|
||||||
|
</label>
|
||||||
|
{memOn && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={64}
|
||||||
|
max={16384}
|
||||||
|
step={64}
|
||||||
|
value={mem}
|
||||||
|
onChange={(e) => setMem(Number(e.target.value))}
|
||||||
|
className="w-full accent-sky-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{mem >= 1024 ? `${(mem / 1024).toFixed(1)} GB` : `${mem} MB`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Generates a <code>deploy.resources.limits</code> block. Disable a checkbox
|
||||||
|
and apply to remove that limit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button onClick={apply}>
|
||||||
|
<CheckIcon className="h-4 w-4" /> Apply to YAML
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Cpu, MemoryStick, HardDrive, Container, Clock } from "lucide-react";
|
import { Cpu, MemoryStick, HardDrive, Container, Clock, ArrowUpCircle } from "lucide-react";
|
||||||
import { Card, Spinner } from "@/components/ui";
|
import { Card, Spinner } from "@/components/ui";
|
||||||
import { StackCard } from "@/components/stacks/StackCard";
|
import { StackCard } from "@/components/stacks/StackCard";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { systemApi } from "@/api/system";
|
import { systemApi } from "@/api/system";
|
||||||
|
import { imagesApi } from "@/api/images";
|
||||||
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
|
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
@@ -15,9 +17,22 @@ export function Dashboard() {
|
|||||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
||||||
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
||||||
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
||||||
|
const updates = useQuery({ queryKey: ["image-updates"], queryFn: imagesApi.updates, refetchInterval: 60000 });
|
||||||
|
|
||||||
|
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{updateCount > 0 && (
|
||||||
|
<Link
|
||||||
|
to="/images"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="h-5 w-5" />
|
||||||
|
{updateCount} image update{updateCount > 1 ? "s" : ""} available — view on the Images page.
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resource bar */}
|
{/* Resource bar */}
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
|
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
|
||||||
|
import { Button, Card, Spinner } from "@/components/ui";
|
||||||
|
import { imagesApi, type ImageRow } from "@/api/images";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { formatBytes, relativeTime } from "@/lib/utils";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function UpdateBadge({ row }: { row: ImageRow }) {
|
||||||
|
const u = row.update;
|
||||||
|
if (!u) return <span className="inline-flex items-center gap-1 text-xs text-slate-400"><HelpCircle className="h-3.5 w-3.5" /> not checked</span>;
|
||||||
|
if (u.error) return <span className="text-xs text-amber-500">⚠ {u.error}</span>;
|
||||||
|
if (u.update_available)
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
<ArrowUpCircle className="h-3.5 w-3.5" /> update available
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" /> up to date
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Images() {
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ["images"], queryFn: imagesApi.list });
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
const t = toast.loading("Checking for updates…");
|
||||||
|
try {
|
||||||
|
await imagesApi.check();
|
||||||
|
await qc.invalidateQueries({ queryKey: ["images"] });
|
||||||
|
toast.success("Update check complete", { id: t });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(apiErrorMessage(e), { id: t });
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-500">{data?.length ?? 0} image tags</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button onClick={check} loading={checking}>
|
||||||
|
<RefreshCw className="h-4 w-4" /> Check updates
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">Image</th>
|
||||||
|
<th className="p-3">Used by</th>
|
||||||
|
<th className="p-3">Size</th>
|
||||||
|
<th className="p-3">Created</th>
|
||||||
|
<th className="p-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data?.map((row) => (
|
||||||
|
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
||||||
|
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
||||||
|
<td className="p-3 text-xs text-slate-500">
|
||||||
|
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
||||||
|
<td className="p-3 text-xs text-slate-500">
|
||||||
|
{row.created ? relativeTime(row.created) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-3"><UpdateBadge row={row} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,5 @@ export function Placeholder({ title, phase }: { title: string; phase: string })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Networks = () => <Placeholder title="Networks" phase="Phase 2" />;
|
export const Networks = () => <Placeholder title="Networks" phase="Phase 4" />;
|
||||||
export const Images = () => <Placeholder title="Images" phase="Phase 3" />;
|
|
||||||
export const Templates = () => <Placeholder title="Templates" phase="Phase 3" />;
|
|
||||||
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
|
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import Editor from "@monaco-editor/react";
|
|||||||
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
|
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
|
||||||
import { Button, Card, Input } from "@/components/ui";
|
import { Button, Card, Input } from "@/components/ui";
|
||||||
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
||||||
|
import { EnvEditor } from "@/components/env/EnvEditor";
|
||||||
|
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
|
import { portsApi, type PortConflict } from "@/api/ports";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -33,6 +36,8 @@ export function StackEditor() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [convertOpen, setConvertOpen] = useState(false);
|
const [convertOpen, setConvertOpen] = useState(false);
|
||||||
const [runCmd, setRunCmd] = useState("");
|
const [runCmd, setRunCmd] = useState("");
|
||||||
|
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
|
||||||
const existing = useQuery({
|
const existing = useQuery({
|
||||||
queryKey: ["stack", id],
|
queryKey: ["stack", id],
|
||||||
@@ -79,6 +84,22 @@ export function StackEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDeploy = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
try {
|
||||||
|
const found = await portsApi.conflicts(yaml, id);
|
||||||
|
if (found.length > 0) {
|
||||||
|
setConflicts(found);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* if the check fails, fall through and let compose surface errors */
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
save(true);
|
||||||
|
};
|
||||||
|
|
||||||
const convert = async () => {
|
const convert = async () => {
|
||||||
try {
|
try {
|
||||||
const { yaml: converted } = await stacksApi.convert(runCmd);
|
const { yaml: converted } = await stacksApi.convert(runCmd);
|
||||||
@@ -145,14 +166,9 @@ export function StackEditor() {
|
|||||||
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
|
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Editor
|
<div className="h-full p-3">
|
||||||
height="100%"
|
<EnvEditor value={env} onChange={setEnv} />
|
||||||
language="ini"
|
</div>
|
||||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
|
||||||
value={env}
|
|
||||||
onChange={(v) => setEnv(v ?? "")}
|
|
||||||
options={{ minimap: { enabled: false }, fontSize: 13 }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,10 +184,21 @@ export function StackEditor() {
|
|||||||
<Button variant="outline" onClick={() => save(false)} loading={saving}>
|
<Button variant="outline" onClick={() => save(false)} loading={saving}>
|
||||||
<Save className="h-4 w-4" /> Save Draft
|
<Save className="h-4 w-4" /> Save Draft
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => save(true)} loading={saving}>
|
<Button onClick={onDeploy} loading={saving || checking}>
|
||||||
<Rocket className="h-4 w-4" /> Deploy
|
<Rocket className="h-4 w-4" /> Deploy
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{conflicts && (
|
||||||
|
<PortConflictDialog
|
||||||
|
conflicts={conflicts}
|
||||||
|
onContinue={() => {
|
||||||
|
setConflicts(null);
|
||||||
|
save(true);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConflicts(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { LayoutTemplate, Cpu, Package } from "lucide-react";
|
||||||
|
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||||
|
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function Templates() {
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
|
const [selected, setSelected] = useState<TemplateDetail | null>(null);
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
|
||||||
|
|
||||||
|
const open = async (t: TemplateSummary) => {
|
||||||
|
try {
|
||||||
|
setSelected(await templatesApi.get(t.id));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(apiErrorMessage(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{data?.map((t) => (
|
||||||
|
<Card key={t.id} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{t.source === "custom" ? (
|
||||||
|
<Package className="h-5 w-5 text-accent dark:text-accent-dark" />
|
||||||
|
) : (
|
||||||
|
<LayoutTemplate className="h-5 w-5 text-accent dark:text-accent-dark" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold">{t.name}</span>
|
||||||
|
{t.source === "custom" && <Badge>custom</Badge>}
|
||||||
|
</div>
|
||||||
|
{t.description && <p className="text-sm text-slate-500">{t.description}</p>}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{t.tags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{t.gpu && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||||
|
<Cpu className="h-3 w-3" /> {t.gpu}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="outline" className="mt-2" onClick={() => open(t)}>
|
||||||
|
Use template
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UseTemplateDialog({
|
||||||
|
template,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
template: TemplateDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState(template.name);
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(
|
||||||
|
Object.fromEntries(template.variables.map((v) => [v.name, v.default]))
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error("Stack name required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await templatesApi.instantiate(template.id, name, values);
|
||||||
|
toast.success(`Stack '${res.name}' created`);
|
||||||
|
navigate(`/stacks/${res.id}/edit`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(apiErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Use “{template.name}”</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">Stack name</span>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{template.variables.map((v) => (
|
||||||
|
<label key={v.name} className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">
|
||||||
|
{v.name}
|
||||||
|
{v.description && <span className="ml-1 font-normal text-slate-400">— {v.description}</span>}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={values[v.name] ?? ""}
|
||||||
|
onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={create} loading={busy}>Create stack</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user