Phase 25: templates as stack folders (0.31.0)

Templates are now stack-shaped folders (compose.yaml + .env.example +
template.json) instead of DB rows + manifest.json + {{VAR}} rendering.
Pull copies the folder into a new stack; custom templates persist under
DATA_DIR/templates. Adds POST /api/templates/from-stack and a one-time
startup migration for pre-0.31 DB templates (drops the template table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-12 07:29:54 +00:00
co-authored by Claude Fable 5
parent 34cb215266
commit 1609b8bcc3
29 changed files with 564 additions and 329 deletions
+7 -3
View File
@@ -65,8 +65,12 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
- **Port conflict detector**: pre-deploy check against host-bound ports - **Port conflict detector**: pre-deploy check against host-bound ports
(`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog. (`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog.
- **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`. - **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`.
- **Template library**: bundled templates (Jellyfin, Vaultwarden, Uptime-Kuma, - **Template library**: each template is a ready-to-run **stack folder** in git
Paperless-NGX, Gitea) with `{{VARIABLE}}` forms; save any stack as a custom template. (`backend/templates/<slug>/` — `compose.yaml` + `.env.example` + `template.json`).
Bundled set: Jellyfin, Vaultwarden, Uptime-Kuma, Paperless-NGX, Gitea. "Pull"
copies the whole folder into a new stack (`.env.example` → `.env`) which you then
edit and deploy. Save any stack back as a custom template (stored under
`${DATA_DIR}/templates/`). Add your own by dropping a folder into the templates dir.
- **Healthcheck status** surfaced per container in the stack overview. - **Healthcheck status** surfaced per container in the stack overview.
### Phase 4 — Operations ### Phase 4 — Operations
@@ -434,7 +438,7 @@ POST /api/editor/services | add-volume | set-gpu | add-device | remove-device
GET /api/images | /updates POST /api/images/check GET /api/images | /updates POST /api/images/check
POST /api/ports/conflicts POST /api/editor/set-resources POST /api/ports/conflicts POST /api/editor/set-resources
GET /api/templates | /{id} POST /api/templates/{id}/instantiate GET /api/templates | /{id} POST /api/templates/{id}/instantiate
POST /api/templates DELETE /api/templates/custom/{slug} POST /api/templates | /from-stack DELETE /api/templates/custom/{slug}
``` ```
### Phase 4 endpoints ### Phase 4 endpoints
+18
View File
@@ -208,6 +208,24 @@ RemoteStackDetail** (not the new-stack editor, which has no dir yet).
--- ---
## Phase 25 — Templates as stack folders ☑ DONE — shipped 0.31.0
Templates reworked from DB rows + `manifest.json` + `{{VAR}}` mustache rendering
to **stack-shaped folders**: `backend/templates/<slug>/` with `compose.yaml`,
optional `.env.example` and a `template.json` (name/description/tags/gpu).
"Pull" copies the whole folder into a new stack (`.env.example` → `.env`);
custom templates live under `${DATA_DIR}/templates/` and are written by
"Save as template" (stack detail) or the manual save endpoint.
- Backend: `template_service` rewritten (folder scan, traversal-guarded resolve,
`copy_into_stack`, `save_from_stack`); `Template` DB table dropped with a
one-time startup migration (`{{VAR}}` → `${VAR}`, vars → `.env.example`).
- API: `POST /api/templates/from-stack` new; instantiate no longer takes `values`.
- Frontend: Templates page shows compose/env preview + file list, delete for
custom templates; StackDetail gains "Save as template".
---
## After 23 ## After 23
Remaining un-built ideas from the gap analysis (not chosen this round): Remaining un-built ideas from the gap analysis (not chosen this round):
Health-monitoring & alerting (Docker-events → notify; note `/ws/events` already Health-monitoring & alerting (Docker-events → notify; note `/ws/events` already
+1 -1
View File
@@ -66,7 +66,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.30.0" AGENT_VERSION = "0.31.0"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+8 -2
View File
@@ -35,7 +35,7 @@ from routers import (
volumes, volumes,
ws, ws,
) )
from services import schedule_service, update_service from services import schedule_service, template_service, update_service
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("stackpilot") logger = logging.getLogger("stackpilot")
@@ -50,6 +50,12 @@ 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)
try:
moved = template_service.migrate_legacy_db_templates()
if moved:
logger.info("Migrated %d custom template(s) from the database to folders", moved)
except Exception as exc: # noqa: BLE001
logger.warning("Legacy template migration failed: %s", exc)
update_task = asyncio.create_task(update_service.background_loop()) update_task = asyncio.create_task(update_service.background_loop())
schedule_task = asyncio.create_task(schedule_service.scheduler_loop()) schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
logger.info("StackPilot backend ready on port %s", settings.PORT) logger.info("StackPilot backend ready on port %s", settings.PORT)
@@ -58,7 +64,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel() schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.30.0", lifespan=lifespan) app = FastAPI(title="StackPilot", version="0.31.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+1 -2
View File
@@ -6,10 +6,9 @@ from models.backup_destination import BackupDestination
from models.backup_schedule import BackupSchedule from models.backup_schedule import BackupSchedule
from models.setting import Setting, Webhook from models.setting import Setting, Webhook
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__ = [ __all__ = [
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent", "User", "Stack", "AuditLog", "Setting", "Webhook", "Agent",
"BackupDestination", "BackupSchedule", "AutoUpdate", "BackupDestination", "BackupSchedule", "AutoUpdate",
] ]
+15 -30
View File
@@ -1,34 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlmodel import Field, SQLModel from sqlmodel import SQLModel
# Templates are stored as stack-shaped folders on disk (see
def _now() -> datetime: # services/template_service.py), not in the database. These are API schemas only.
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): class TemplateSummary(SQLModel):
@@ -41,18 +18,26 @@ class TemplateSummary(SQLModel):
class TemplateDetail(TemplateSummary): class TemplateDetail(TemplateSummary):
yaml: str compose: str = ""
variables: list[TemplateVariable] = [] env: str = ""
files: list[str] = [] # relative paths the template ships
class TemplateSaveRequest(SQLModel): class TemplateSaveRequest(SQLModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
tags: list[str] = [] tags: list[str] = []
yaml: str gpu: Optional[str] = None
compose: str
env: str = ""
class TemplateFromStackRequest(SQLModel):
stack_id: str
name: str
description: Optional[str] = None
class TemplateInstantiateRequest(SQLModel): class TemplateInstantiateRequest(SQLModel):
name: str # new stack name name: str # new stack name
values: dict[str, str] = {}
agent_id: int | None = None # None = local host; otherwise deploy to a remote agent agent_id: int | None = None # None = local host; otherwise deploy to a remote agent
+47 -16
View File
@@ -1,4 +1,8 @@
"""Template library endpoints.""" """Template library endpoints.
Templates are stack-shaped folders on disk. Listing reads them; "instantiate"
(pull) copies the whole folder into a new stack, which is then editable.
"""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -10,7 +14,11 @@ from auth import get_current_user, require_admin
from database import get_session from database import get_session
from models.agent import Agent from models.agent import Agent
from models.stack import Stack from models.stack import Stack
from models.template import TemplateInstantiateRequest, TemplateSaveRequest from models.template import (
TemplateFromStackRequest,
TemplateInstantiateRequest,
TemplateSaveRequest,
)
from models.user import User from models.user import User
from services import agent_service, audit_service, compose_service, template_service from services import agent_service, audit_service, compose_service, template_service
from services.agent_service import AgentError from services.agent_service import AgentError
@@ -24,19 +32,17 @@ def _ip(request: Request) -> str:
@router.get("") @router.get("")
def list_templates( def list_templates(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user), _user: User = Depends(get_current_user),
) -> list[dict]: ) -> list[dict]:
return template_service.list_templates(session) return template_service.list_templates()
@router.get("/{template_id}") @router.get("/{template_id}")
def get_template( def get_template(
template_id: str, template_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user), _user: User = Depends(get_current_user),
) -> dict: ) -> dict:
tpl = template_service.get_template(session, template_id) tpl = template_service.get_template(template_id)
if not tpl: if not tpl:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail="Template not found")
return tpl return tpl
@@ -49,13 +55,32 @@ def save_template(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(require_admin), user: User = Depends(require_admin),
) -> dict: ) -> dict:
tpl = template_service.save_custom( slug = template_service.save_custom(
session, body.name, body.yaml, body.description or "", body.tags body.name, body.compose, body.env, body.description or "", body.tags, body.gpu
) )
audit_service.record( audit_service.record(
session, user=user.username, action="template.save", target=tpl.slug, ip=_ip(request) session, user=user.username, action="template.save", target=slug, ip=_ip(request)
) )
return {"id": f"custom:{tpl.slug}", "name": tpl.name} return {"id": f"custom:{slug}", "name": body.name}
@router.post("/from-stack")
def save_from_stack(
body: TemplateFromStackRequest,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
if not session.get(Stack, body.stack_id) and not os.path.isdir(
compose_service.stack_dir(body.stack_id)
):
raise HTTPException(status_code=404, detail=f"Stack '{body.stack_id}' not found")
slug = template_service.save_from_stack(body.stack_id, body.name, body.description or "")
audit_service.record(
session, user=user.username, action="template.save",
target=slug, detail=body.stack_id, ip=_ip(request),
)
return {"id": f"custom:{slug}", "name": body.name}
@router.delete("/custom/{slug}") @router.delete("/custom/{slug}")
@@ -65,7 +90,7 @@ def delete_template(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(require_admin), user: User = Depends(require_admin),
) -> dict: ) -> dict:
if not template_service.delete_custom(session, slug): if not template_service.delete_custom(slug):
raise HTTPException(status_code=404, detail="Custom template not found") raise HTTPException(status_code=404, detail="Custom template not found")
audit_service.record( audit_service.record(
session, user=user.username, action="template.delete", target=slug, ip=_ip(request) session, user=user.username, action="template.delete", target=slug, ip=_ip(request)
@@ -81,12 +106,11 @@ async def instantiate(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(require_admin), user: User = Depends(require_admin),
) -> dict: ) -> dict:
tpl = template_service.get_template(session, template_id) tpl = template_service.get_template(template_id)
if not tpl: if not tpl:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail="Template not found")
rendered = template_service.render(tpl["yaml"], body.values) # Remote host: agents don't share our filesystem, so ship compose + env.
if body.agent_id is not None: if body.agent_id is not None:
agent = session.get(Agent, body.agent_id) agent = session.get(Agent, body.agent_id)
if not agent: if not agent:
@@ -94,7 +118,7 @@ async def instantiate(
try: try:
result = await agent_service.call( result = await agent_service.call(
session, agent, "POST", "/agent/stacks", session, agent, "POST", "/agent/stacks",
json={"name": body.name, "yaml": rendered, "env": None}, json={"name": body.name, "yaml": tpl["compose"], "env": tpl["env"] or None},
) )
except AgentError as exc: except AgentError as exc:
raise HTTPException( raise HTTPException(
@@ -107,11 +131,18 @@ async def instantiate(
) )
return {"id": result.get("id"), "name": body.name, "agent_id": agent.id} return {"id": result.get("id"), "name": body.name, "agent_id": agent.id}
# Local host: copy the whole template folder into a new stack.
stack_id = compose_service.slugify(body.name) stack_id = compose_service.slugify(body.name)
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)): 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") raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
compose_service.write_compose(stack_id, rendered) try:
template_service.copy_into_stack(template_id, stack_id)
except FileExistsError:
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Template not found")
stack = Stack(id=stack_id, name=body.name, description=tpl.get("description")) stack = Stack(id=stack_id, name=body.name, description=tpl.get("description"))
session.add(stack) session.add(stack)
session.commit() session.commit()
+222 -127
View File
@@ -1,32 +1,44 @@
"""Template library — bundled (on-disk) + custom (DB).""" """Template library — stack-shaped folders.
A template is just a directory laid out like a real stack (``compose.yaml`` plus
optional ``.env.example`` and any extra files), accompanied by a small
``template.json`` describing it. "Pulling" a template copies the whole folder
into a new stack, which is then editable like any other stack.
Two roots are scanned:
* **bundled** — ``backend/templates/`` ships in the image / git repo (read-only).
* **custom** — ``${DATA_DIR}/templates/`` is writable and persists on the data
volume; this is where "save stack as template" writes to.
"""
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import re import re
from functools import lru_cache import shutil
from typing import Optional from typing import Optional
from sqlmodel import Session, select from config import settings
from services import compose_service
from models.template import Template BUNDLED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates") _META_NAME = "template.json"
_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}") _ENV_EXAMPLE = ".env.example"
_CUSTOM_PREFIX = "custom:"
@lru_cache(maxsize=1) def custom_dir() -> str:
def _manifest() -> list[dict]: return os.path.join(settings.DATA_DIR, "templates")
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) # Low-level helpers
# --------------------------------------------------------------------------- #
def _read_file(path: str) -> str:
try: try:
with open(path, "r", encoding="utf-8") as fh: with open(path, "r", encoding="utf-8") as fh:
return fh.read() return fh.read()
@@ -34,134 +46,217 @@ def _read_template_file(filename: str) -> str:
return "" return ""
def extract_variables(yaml_str: str) -> list[str]: def _load_meta(folder: str, slug: str) -> dict:
seen: list[str] = [] """Metadata from template.json, with sensible fallbacks."""
for m in _VAR_RE.finditer(yaml_str): meta: dict = {}
if m.group(1) not in seen: raw = _read_file(os.path.join(folder, _META_NAME))
seen.append(m.group(1)) if raw:
return seen try:
meta = json.loads(raw)
except json.JSONDecodeError:
def render(yaml_str: str, values: dict[str, str]) -> str: meta = {}
def repl(m: re.Match) -> str: return {
key = m.group(1) "name": meta.get("name") or slug.replace("-", " ").title(),
return str(values.get(key, m.group(0))) "description": meta.get("description"),
"tags": meta.get("tags") or [],
return _VAR_RE.sub(repl, yaml_str) "gpu": meta.get("gpu"),
# --------------------------------------------------------------------------- #
# 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():
def _is_template(folder: str) -> bool:
return os.path.isdir(folder) and compose_service.find_compose_file(folder) is not None
def _list_files(folder: str) -> list[str]:
"""Relative paths shipped by the template (excludes the metadata file)."""
out: list[str] = []
for root, _dirs, files in os.walk(folder):
for fn in sorted(files):
rel = os.path.relpath(os.path.join(root, fn), folder)
if rel == _META_NAME:
continue
out.append(rel)
return sorted(out)
def _resolve_dir(template_id: str) -> Optional[str]:
"""Map a template id to its on-disk folder (guards against traversal)."""
if template_id.startswith(_CUSTOM_PREFIX):
slug = template_id[len(_CUSTOM_PREFIX):]
base = custom_dir()
else:
slug = template_id
base = BUNDLED_DIR
slug = os.path.basename(slug.strip())
if not slug:
return None
path = os.path.join(base, slug)
return path if _is_template(path) else None
def _scan(base: str, source: str) -> list[dict]:
out: list[dict] = []
if not os.path.isdir(base):
return out
for slug in sorted(os.listdir(base)):
folder = os.path.join(base, slug)
if not _is_template(folder):
continue
meta = _load_meta(folder, slug)
out.append( out.append(
{ {
"id": f"custom:{tpl.slug}", "id": f"{_CUSTOM_PREFIX}{slug}" if source == "custom" else slug,
"name": tpl.name, "name": meta["name"],
"description": tpl.description, "description": meta["description"],
"tags": [t for t in tpl.tags.split(",") if t], "tags": meta["tags"],
"gpu": None, "gpu": meta["gpu"],
"source": "custom", "source": source,
} }
) )
return out return out
def get_template(session: Session, template_id: str) -> Optional[dict]: # --------------------------------------------------------------------------- #
if template_id.startswith("custom:"): # Listing / detail
slug = template_id.split(":", 1)[1] # --------------------------------------------------------------------------- #
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
if not tpl:
def list_templates() -> list[dict]:
return _scan(BUNDLED_DIR, "bundled") + _scan(custom_dir(), "custom")
def get_template(template_id: str) -> Optional[dict]:
folder = _resolve_dir(template_id)
if not folder:
return None return None
variables = [ slug = os.path.basename(folder)
{"name": v, "description": "", "default": ""} meta = _load_meta(folder, slug)
for v in extract_variables(tpl.yaml) compose_file = compose_service.find_compose_file(folder)
]
return { return {
"id": template_id, "id": template_id,
"name": tpl.name, "name": meta["name"],
"description": tpl.description, "description": meta["description"],
"tags": [t for t in tpl.tags.split(",") if t], "tags": meta["tags"],
"gpu": None, "gpu": meta["gpu"],
"source": "custom", "source": "custom" if template_id.startswith(_CUSTOM_PREFIX) else "bundled",
"yaml": tpl.yaml, "compose": _read_file(compose_file) if compose_file else "",
"variables": variables, "env": _read_file(os.path.join(folder, _ENV_EXAMPLE)),
"files": _list_files(folder),
} }
for entry in _manifest():
if entry["id"] == template_id: # --------------------------------------------------------------------------- #
yaml_str = _read_template_file(entry["file"]) # Pull (instantiate) — copy the whole folder into a new stack
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): def copy_into_stack(template_id: str, stack_id: str, override: Optional[str] = None) -> None:
meta = declared.get(name, {}) """Copy a template folder into a fresh stack directory.
variables.append(
{ The ``template.json`` is left behind and any ``.env.example`` is promoted to
"name": name, a real ``.env`` so the pulled stack is immediately runnable + editable.
"description": meta.get("description", ""), """
"default": meta.get("default", ""), src = _resolve_dir(template_id)
} if not src:
) raise FileNotFoundError(f"Template '{template_id}' not found")
return { dst = compose_service.stack_dir(stack_id, override)
"id": entry["id"], if os.path.exists(dst):
"name": entry["name"], raise FileExistsError(f"Stack '{stack_id}' already exists")
"description": entry.get("description"), shutil.copytree(src, dst, ignore=shutil.ignore_patterns(_META_NAME))
"tags": entry.get("tags", []), example = os.path.join(dst, _ENV_EXAMPLE)
"gpu": entry.get("gpu"), env = os.path.join(dst, ".env")
"source": "bundled", if os.path.isfile(example) and not os.path.isfile(env):
"yaml": yaml_str, os.replace(example, env)
"variables": variables,
}
return None # --------------------------------------------------------------------------- #
# Save / delete custom templates (folder-based, persisted on the data volume)
# --------------------------------------------------------------------------- #
def save_custom( def save_custom(
session: Session, name: str, yaml_str: str, description: str = "", tags: list[str] | None = None name: str,
) -> Template: compose: str,
slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-") or "template" env: str = "",
existing = session.exec(select(Template).where(Template.slug == slug)).first() description: str = "",
if existing: tags: list[str] | None = None,
existing.name = name gpu: str | None = None,
existing.description = description ) -> str:
existing.tags = ",".join(tags or []) """Write a custom template folder; returns its slug. Overwrites if it exists."""
existing.yaml = yaml_str slug = compose_service.slugify(name)
session.add(existing) folder = os.path.join(custom_dir(), slug)
session.commit() os.makedirs(folder, exist_ok=True)
session.refresh(existing) meta = {
return existing "name": name,
tpl = Template( "description": description or None,
slug=slug, "tags": tags or [],
name=name, "gpu": gpu,
description=description, }
tags=",".join(tags or []), with open(os.path.join(folder, _META_NAME), "w", encoding="utf-8") as fh:
yaml=yaml_str, json.dump(meta, fh, indent=2)
) fh.write("\n")
session.add(tpl) with open(os.path.join(folder, compose_service.DEFAULT_COMPOSE_NAME), "w", encoding="utf-8") as fh:
session.commit() fh.write(compose or "services:\n")
session.refresh(tpl) example = os.path.join(folder, _ENV_EXAMPLE)
return tpl if env.strip():
with open(example, "w", encoding="utf-8") as fh:
fh.write(env)
elif os.path.isfile(example):
os.remove(example)
return slug
def delete_custom(session: Session, slug: str) -> bool: def save_from_stack(stack_id: str, name: str, description: str = "") -> str:
tpl = session.exec(select(Template).where(Template.slug == slug)).first() """Snapshot an existing stack's compose + env into a custom template."""
if not tpl: compose = compose_service.read_compose(stack_id)
env = compose_service.read_env(stack_id)
return save_custom(name, compose, env, description=description)
def delete_custom(slug: str) -> bool:
folder = os.path.join(custom_dir(), os.path.basename(slug.strip()))
if not _is_template(folder):
return False return False
session.delete(tpl) shutil.rmtree(folder)
session.commit()
return True return True
# --------------------------------------------------------------------------- #
# Legacy migration (pre-0.31 custom templates lived in the database)
# --------------------------------------------------------------------------- #
_LEGACY_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
def migrate_legacy_db_templates() -> int:
"""One-time: move custom templates out of the dropped ``template`` table.
Old templates used ``{{VAR}}`` placeholders; compose interpolates ``${VAR}``
from ``.env``, so placeholders are rewritten and the variable names land in
the template's ``.env.example``. Returns the number of templates moved.
"""
from sqlalchemy import inspect, text
from database import engine
if not inspect(engine).has_table("template"):
return 0
moved = 0
with engine.begin() as conn:
rows = conn.execute(
text("SELECT name, description, tags, yaml FROM template")
).all()
for name, description, tags, yaml_str in rows:
compose = _LEGACY_VAR_RE.sub(r"${\1}", yaml_str or "")
variables = dict.fromkeys(_LEGACY_VAR_RE.findall(yaml_str or ""))
env = "".join(f"{v}=\n" for v in variables)
save_custom(
name or "template",
compose,
env,
description=description or "",
tags=[t for t in (tags or "").split(",") if t],
)
moved += 1
conn.execute(text("DROP TABLE template"))
return moved
+5
View File
@@ -0,0 +1,5 @@
PUID=1000
PGID=1000
DATA_PATH=/srv/gitea
HTTP_PORT=3000
SSH_PORT=2222
@@ -4,12 +4,12 @@ services:
container_name: gitea container_name: gitea
restart: unless-stopped restart: unless-stopped
environment: environment:
- USER_UID={{PUID}} - USER_UID=${PUID:-1000}
- USER_GID={{PGID}} - USER_GID=${PGID:-1000}
ports: ports:
- "{{HTTP_PORT}}:3000" - "${HTTP_PORT:-3000}:3000"
- "{{SSH_PORT}}:22" - "${SSH_PORT:-2222}:22"
volumes: volumes:
- {{DATA_PATH}}:/data - ${DATA_PATH:-/srv/gitea}:/data
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Gitea",
"description": "Lightweight self-hosted Git service.",
"tags": [
"git",
"dev"
],
"gpu": null
}
-14
View File
@@ -1,14 +0,0 @@
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
+6
View File
@@ -0,0 +1,6 @@
PUID=1000
PGID=1000
TZ=Europe/Berlin
CONFIG_PATH=/srv/jellyfin/config
MEDIA_PATH=/srv/media
HTTP_PORT=8096
+14
View File
@@ -0,0 +1,14 @@
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Europe/Berlin}
ports:
- "${HTTP_PORT:-8096}:8096"
volumes:
- ${CONFIG_PATH:-/srv/jellyfin/config}:/config
- ${MEDIA_PATH:-/srv/media}:/media
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Jellyfin",
"description": "Free media streaming server with optional hardware transcoding.",
"tags": [
"media",
"streaming"
],
"gpu": "NVIDIA / Intel optional"
}
-74
View File
@@ -1,74 +0,0 @@
[
{
"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,5 @@
TZ=Europe/Berlin
DATA_PATH=/srv/paperless
HTTP_PORT=8000
ADMIN_USER=admin
ADMIN_PASSWORD=change-me
@@ -4,7 +4,7 @@ services:
container_name: paperless-redis container_name: paperless-redis
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- {{DATA_PATH}}/redis:/data - ${DATA_PATH:-/srv/paperless}/redis:/data
db: db:
image: postgres:16-alpine image: postgres:16-alpine
@@ -15,7 +15,7 @@ services:
- POSTGRES_USER=paperless - POSTGRES_USER=paperless
- POSTGRES_PASSWORD=paperless - POSTGRES_PASSWORD=paperless
volumes: volumes:
- {{DATA_PATH}}/db:/var/lib/postgresql/data - ${DATA_PATH:-/srv/paperless}/db:/var/lib/postgresql/data
webserver: webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest image: ghcr.io/paperless-ngx/paperless-ngx:latest
@@ -27,12 +27,12 @@ services:
environment: environment:
- PAPERLESS_REDIS=redis://broker:6379 - PAPERLESS_REDIS=redis://broker:6379
- PAPERLESS_DBHOST=db - PAPERLESS_DBHOST=db
- PAPERLESS_TIME_ZONE={{TZ}} - PAPERLESS_TIME_ZONE=${TZ:-Europe/Berlin}
- PAPERLESS_ADMIN_USER={{ADMIN_USER}} - PAPERLESS_ADMIN_USER=${ADMIN_USER:-admin}
- PAPERLESS_ADMIN_PASSWORD={{ADMIN_PASSWORD}} - PAPERLESS_ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
ports: ports:
- "{{HTTP_PORT}}:8000" - "${HTTP_PORT:-8000}:8000"
volumes: volumes:
- {{DATA_PATH}}/data:/usr/src/paperless/data - ${DATA_PATH:-/srv/paperless}/data:/usr/src/paperless/data
- {{DATA_PATH}}/media:/usr/src/paperless/media - ${DATA_PATH:-/srv/paperless}/media:/usr/src/paperless/media
- {{DATA_PATH}}/consume:/usr/src/paperless/consume - ${DATA_PATH:-/srv/paperless}/consume:/usr/src/paperless/consume
@@ -0,0 +1,8 @@
{
"name": "Paperless-NGX",
"description": "Document management system that indexes scanned documents.",
"tags": [
"documents"
],
"gpu": null
}
@@ -0,0 +1,2 @@
DATA_PATH=/srv/uptime-kuma
HTTP_PORT=3001
@@ -4,6 +4,6 @@ services:
container_name: uptime-kuma container_name: uptime-kuma
restart: unless-stopped restart: unless-stopped
ports: ports:
- "{{HTTP_PORT}}:3001" - "${HTTP_PORT:-3001}:3001"
volumes: volumes:
- {{DATA_PATH}}:/app/data - ${DATA_PATH:-/srv/uptime-kuma}:/app/data
@@ -0,0 +1,8 @@
{
"name": "Uptime Kuma",
"description": "Self-hosted uptime / status monitoring tool.",
"tags": [
"monitoring"
],
"gpu": null
}
@@ -0,0 +1,4 @@
TZ=Europe/Berlin
DATA_PATH=/srv/vaultwarden
HTTP_PORT=8200
ADMIN_TOKEN=change-me
@@ -4,10 +4,10 @@ services:
container_name: vaultwarden container_name: vaultwarden
restart: unless-stopped restart: unless-stopped
environment: environment:
- TZ={{TZ}} - TZ=${TZ:-Europe/Berlin}
- ADMIN_TOKEN={{ADMIN_TOKEN}} - ADMIN_TOKEN=${ADMIN_TOKEN:-change-me}
- WEBSOCKET_ENABLED=true - WEBSOCKET_ENABLED=true
ports: ports:
- "{{HTTP_PORT}}:80" - "${HTTP_PORT:-8200}:80"
volumes: volumes:
- {{DATA_PATH}}:/data - ${DATA_PATH:-/srv/vaultwarden}:/data
@@ -0,0 +1,9 @@
{
"name": "Vaultwarden",
"description": "Lightweight Bitwarden-compatible password manager.",
"tags": [
"password-manager",
"security"
],
"gpu": null
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.30.0", "version": "0.31.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+15 -17
View File
@@ -1,11 +1,5 @@
import api from "./client"; import api from "./client";
export interface TemplateVariable {
name: string;
description: string;
default: string;
}
export interface TemplateSummary { export interface TemplateSummary {
id: string; id: string;
name: string; name: string;
@@ -16,28 +10,32 @@ export interface TemplateSummary {
} }
export interface TemplateDetail extends TemplateSummary { export interface TemplateDetail extends TemplateSummary {
yaml: string; compose: string;
variables: TemplateVariable[]; env: string;
files: string[];
} }
export const templatesApi = { export const templatesApi = {
list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data), list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data),
get: (id: string) => get: (id: string) =>
api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data), api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data),
instantiate: ( instantiate: (id: string, name: string, agentId?: number | null) =>
id: string,
name: string,
values: Record<string, string>,
agentId?: number | null
) =>
api api
.post<{ id: string; name: string; agent_id: number | null }>( .post<{ id: string; name: string; agent_id: number | null }>(
`/api/templates/${id}/instantiate`, `/api/templates/${id}/instantiate`,
{ name, values, agent_id: agentId ?? null } { name, agent_id: agentId ?? null }
) )
.then((r) => r.data), .then((r) => r.data),
save: (body: { name: string; description?: string; tags: string[]; yaml: string }) => save: (body: {
api.post("/api/templates", body).then((r) => r.data), name: string;
description?: string;
tags: string[];
gpu?: string | null;
compose: string;
env?: string;
}) => api.post("/api/templates", body).then((r) => r.data),
saveFromStack: (body: { stack_id: string; name: string; description?: string }) =>
api.post<{ id: string; name: string }>("/api/templates/from-stack", body).then((r) => r.data),
remove: (slug: string) => remove: (slug: string) =>
api.delete(`/api/templates/custom/${slug}`).then((r) => r.data), api.delete(`/api/templates/custom/${slug}`).then((r) => r.data),
}; };
+50 -1
View File
@@ -10,9 +10,10 @@ import {
Pencil, Pencil,
Power, Power,
Trash2, Trash2,
LayoutTemplate,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui"; import { Badge, Button, Card, Input, Spinner, StatusDot } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer"; import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard"; import { ContainerCard } from "@/components/stacks/ContainerCard";
@@ -20,6 +21,7 @@ import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel"; import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore"; import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
import { templatesApi } from "@/api/templates";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions"; import { useStackActions } from "@/hooks/useStackActions";
@@ -78,6 +80,7 @@ export function StackDetail() {
<Power className="h-4 w-4" /> Down <Power className="h-4 w-4" /> Down
</Button> </Button>
<BackupButton stackId={id} /> <BackupButton stackId={id} />
<SaveAsTemplateButton stackId={id} defaultName={data.name} />
<Link to={`/stacks/${id}/edit`}> <Link to={`/stacks/${id}/edit`}>
<Button> <Button>
<Pencil className="h-4 w-4" /> Edit <Pencil className="h-4 w-4" /> Edit
@@ -230,3 +233,49 @@ function DeleteStackButton({ stackId }: { stackId: string }) {
</> </>
); );
} }
function SaveAsTemplateButton({ stackId, defaultName }: { stackId: string; defaultName: string }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState(defaultName);
const [busy, setBusy] = useState(false);
const save = async () => {
if (!name.trim()) {
toast.error("Template name required");
return;
}
setBusy(true);
try {
await templatesApi.saveFromStack({ stack_id: stackId, name: name.trim() });
toast.success(`Saved template “${name.trim()}`);
setOpen(false);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setBusy(false);
}
};
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<LayoutTemplate className="h-4 w-4" /> Save as template
</Button>
{open && (
<ConfirmDialog
title="Save as template"
message="Snapshots this stack's compose and .env into a reusable custom template."
confirmLabel="Save template"
busy={busy}
onConfirm={save}
onCancel={() => setOpen(false)}
>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Template name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
</ConfirmDialog>
)}
</>
);
}
+78 -19
View File
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LayoutTemplate, Cpu, Package } from "lucide-react"; import { LayoutTemplate, Cpu, Package, Trash2, FileCode } from "lucide-react";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates"; import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
@@ -14,7 +15,9 @@ const selectClass =
export function Templates() { export function Templates() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const queryClient = useQueryClient();
const [selected, setSelected] = useState<TemplateDetail | null>(null); const [selected, setSelected] = useState<TemplateDetail | null>(null);
const [toDelete, setToDelete] = useState<TemplateSummary | null>(null);
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list }); const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
const open = async (t: TemplateSummary) => { const open = async (t: TemplateSummary) => {
@@ -25,10 +28,28 @@ export function Templates() {
} }
}; };
const remove = async () => {
if (!toDelete) return;
const slug = toDelete.id.replace(/^custom:/, "");
try {
await templatesApi.remove(slug);
toast.success(`Template '${toDelete.name}' deleted`);
queryClient.invalidateQueries({ queryKey: ["templates"] });
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setToDelete(null);
}
};
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-slate-500">
Templates are stored as ready-to-run stack folders. Pull one to copy it into a new stack,
then edit it like any other.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{data?.map((t) => ( {data?.map((t) => (
<Card key={t.id} className="flex flex-col gap-2"> <Card key={t.id} className="flex flex-col gap-2">
@@ -55,9 +76,16 @@ export function Templates() {
)} )}
</div> </div>
{isAdmin && ( {isAdmin && (
<Button variant="outline" className="mt-2" onClick={() => open(t)}> <div className="mt-2 flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => open(t)}>
Use template Use template
</Button> </Button>
{t.source === "custom" && (
<Button variant="outline" onClick={() => setToDelete(t)} title="Delete template">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
)} )}
</Card> </Card>
))} ))}
@@ -66,6 +94,17 @@ export function Templates() {
{selected && ( {selected && (
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} /> <UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
)} )}
{toDelete && (
<ConfirmDialog
title={`Delete template “${toDelete.name}”?`}
message="This removes the custom template folder. Existing stacks are not affected."
confirmLabel="Delete"
danger
onConfirm={remove}
onCancel={() => setToDelete(null)}
/>
)}
</div> </div>
); );
} }
@@ -79,9 +118,6 @@ function UseTemplateDialog({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(template.name); const [name, setName] = useState(template.name);
const [values, setValues] = useState<Record<string, string>>(
Object.fromEntries(template.variables.map((v) => [v.name, v.default]))
);
const [host, setHost] = useState("local"); const [host, setHost] = useState("local");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -96,7 +132,7 @@ function UseTemplateDialog({
setBusy(true); setBusy(true);
try { try {
const agentId = host === "local" ? null : Number(host); const agentId = host === "local" ? null : Number(host);
const res = await templatesApi.instantiate(template.id, name, values, agentId); const res = await templatesApi.instantiate(template.id, name, agentId);
toast.success(`Stack '${res.name}' created`); toast.success(`Stack '${res.name}' created`);
if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`); if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`);
else navigate(`/stacks/${res.id}/edit`); else navigate(`/stacks/${res.id}/edit`);
@@ -109,7 +145,7 @@ function UseTemplateDialog({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <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"> <div className="max-h-[85vh] w-full max-w-2xl 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 sp-heading text-lg">Use {template.name}</h2> <h2 className="mb-3 sp-heading text-lg">Use {template.name}</h2>
<div className="space-y-3"> <div className="space-y-3">
<label className="block space-y-1"> <label className="block space-y-1">
@@ -129,22 +165,45 @@ function UseTemplateDialog({
</select> </select>
</label> </label>
)} )}
{template.variables.map((v) => (
<label key={v.name} className="block space-y-1"> {template.files.length > 0 && (
<span className="text-xs font-medium text-slate-500"> <div className="space-y-1">
{v.name} <span className="text-xs font-medium text-slate-500">Files</span>
{v.description && <span className="ml-1 font-normal text-slate-400"> {v.description}</span>} <div className="flex flex-wrap gap-1">
{template.files.map((f) => (
<span
key={f}
className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300"
>
<FileCode className="h-3 w-3" /> {f}
</span> </span>
<Input
value={values[v.name] ?? ""}
onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))}
/>
</label>
))} ))}
</div> </div>
</div>
)}
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">compose.yaml</span>
<pre className="max-h-64 overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">
{template.compose}
</pre>
</div>
{template.env.trim() && (
<div className="space-y-1">
<span className="text-xs font-medium text-slate-500">.env (defaults)</span>
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">
{template.env}
</pre>
</div>
)}
</div>
<p className="mt-3 text-xs text-slate-400">
The whole folder is copied into a new stack you can edit and deploy afterwards.
</p>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button> <Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={create} loading={busy}>Create stack</Button> <Button onClick={create} loading={busy}>Pull into stack</Button>
</div> </div>
</div> </div>
</div> </div>