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:
menzelj
2026-06-07 16:49:38 +00:00
co-authored by Claude Opus 4.8
parent b553c1b861
commit 22d9864436
32 changed files with 1728 additions and 21 deletions
+25 -2
View File
@@ -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
View File
@@ -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)
+2 -1
View File
@@ -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"]
+57
View File
@@ -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] = {}
+26
View File
@@ -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
+64
View File
@@ -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()
+21
View File
@@ -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)}
+99
View File
@@ -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}
+54
View File
@@ -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())
+158
View File
@@ -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
+167
View File
@@ -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
+204
View File
@@ -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)
+15
View File
@@ -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
+14
View File
@@ -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
+74
View File
@@ -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" }
]
}
]
+38
View File
@@ -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
+9
View File
@@ -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
+13
View File
@@ -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
+3 -1
View File
@@ -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";
+13
View File
@@ -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),
}; };
+27
View File
@@ -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),
};
+18
View File
@@ -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),
};
+38
View File
@@ -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
View File
@@ -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>
);
}
+16 -1
View File
@@ -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 ?? "—"} />
+91
View File
@@ -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>
);
}
+1 -3
View File
@@ -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" />;
+36 -9
View File
@@ -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>
); );
} }
+129
View File
@@ -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>
);
}