0.32.0: StackPilot self-update (check on page load + one-click update)
- backend/version.py is now the single version source (main.py, agent). - GET /api/system/update: reads the version tags of the backend's own image repo (anonymous v2 token flow, https→http fallback for insecure registries), compares the highest semver tag against APP_VERSION; reports update_supported from the container's compose labels. 10 min cache. - POST /api/system/update (admin, audited): spawns a detached helper container from the current backend image that runs docker compose pull && up -d on StackPilot's own compose project (project name, working dir and config files resolved from its own container labels) — the helper outlives the backend being recreated. Non-compose installs get a 400. - /api/health now returns the version so the UI can detect the switchover. - TopNav version badge: queries the update status on page load; when a newer release exists an amber pill shows the version — one click (admin) confirms, triggers the update and overlays a wait screen that polls /api/health and reloads once the new version answers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
11effdc2ca
commit
a0dda120f5
@@ -75,6 +75,14 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
|
|
||||||
### Phase 4 — Operations
|
### Phase 4 — Operations
|
||||||
|
|
||||||
|
- **Self-update (0.32.0)**: the top-bar version badge checks the registry for a
|
||||||
|
newer StackPilot release on page load (`GET /api/system/update`, anonymous v2
|
||||||
|
token flow, 10 min cache) and shows an amber update pill. One click
|
||||||
|
(`POST /api/system/update`, admin) spawns a detached helper container that runs
|
||||||
|
`docker compose pull && up -d` on StackPilot's own compose project (resolved
|
||||||
|
from its container labels) — the helper survives the backend being recreated;
|
||||||
|
the UI polls `/api/health` and reloads when the new version answers. Installs
|
||||||
|
not managed by compose get a clear "update manually" error instead.
|
||||||
- **Backup & restore**: per-stack `.tar.gz` backups including named-volume contents
|
- **Backup & restore**: per-stack `.tar.gz` backups including named-volume contents
|
||||||
(snapshotted via a throwaway helper container); restore via upload with optional
|
(snapshotted via a throwaway helper container); restore via upload with optional
|
||||||
rename, volume restore, and overwrite/conflict detection.
|
rename, volume restore, and overwrite/conflict detection.
|
||||||
@@ -422,6 +430,7 @@ POST /api/stacks/{id}/{start|stop|restart|pull|update|down|clone}
|
|||||||
GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
|
GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
|
||||||
POST /api/stacks/convert (docker run → compose)
|
POST /api/stacks/convert (docker run → compose)
|
||||||
GET /api/system/info | gpus | devices GET /api/audit
|
GET /api/system/info | gpus | devices GET /api/audit
|
||||||
|
GET /api/system/update POST /api/system/update (self-update)
|
||||||
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
|
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from version import APP_VERSION
|
||||||
from docker_client import DockerError, get_client, safe_call
|
from docker_client import DockerError, get_client, safe_call
|
||||||
from services import (
|
from services import (
|
||||||
backup_service,
|
backup_service,
|
||||||
@@ -66,7 +67,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.31.1"
|
AGENT_VERSION = APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+3
-2
@@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from version import APP_VERSION
|
||||||
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 (
|
from routers import (
|
||||||
@@ -66,7 +67,7 @@ async def lifespan(app: FastAPI):
|
|||||||
uptime_task.cancel()
|
uptime_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.31.1", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version=APP_VERSION, lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -109,4 +110,4 @@ app.include_router(ws.router)
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok", "version": APP_VERSION}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
from auth import get_current_user
|
from auth import get_current_user, require_admin
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from database import get_session
|
||||||
from docker_client import DockerError, get_client, safe_call
|
from docker_client import DockerError, get_client, safe_call
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import device_service, gpu_service
|
from services import audit_service, device_service, gpu_service, self_update_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||||
|
|
||||||
@@ -103,3 +105,31 @@ def gpus(_user: User = Depends(get_current_user)) -> list[dict]:
|
|||||||
def devices(_user: User = Depends(get_current_user)) -> dict:
|
def devices(_user: User = Depends(get_current_user)) -> dict:
|
||||||
"""List host USB / serial / DRI devices for passthrough."""
|
"""List host USB / serial / DRI devices for passthrough."""
|
||||||
return device_service.detect_devices()
|
return device_service.detect_devices()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/update")
|
||||||
|
async def self_update_status(
|
||||||
|
refresh: bool = False,
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Is a newer StackPilot release available? (registry check, cached)"""
|
||||||
|
return await self_update_service.get_status(refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/update")
|
||||||
|
def self_update_apply(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""Update this StackPilot in place via a detached compose helper."""
|
||||||
|
try:
|
||||||
|
result = self_update_service.apply_update()
|
||||||
|
except self_update_service.SelfUpdateError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="system.update",
|
||||||
|
target=result.get("helper", ""), detail=result.get("command"),
|
||||||
|
ip=request.client.host if request.client else "",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Self-update: is a newer StackPilot release in the registry, and apply it.
|
||||||
|
|
||||||
|
The check reads the version tags of the backend's *own* image repository
|
||||||
|
(anonymous v2 token flow, https with http fallback for insecure registries)
|
||||||
|
and compares the highest semver tag against the running APP_VERSION.
|
||||||
|
|
||||||
|
Applying the update spawns a detached **helper container** (from the current
|
||||||
|
backend image — it ships the docker CLI + compose plugin) that runs
|
||||||
|
``docker compose pull && up -d`` against the compose project this backend
|
||||||
|
belongs to, resolved from its own container labels. The helper outlives the
|
||||||
|
backend container being recreated, which is what makes self-update possible.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from docker_client import DockerError, get_client, safe_call
|
||||||
|
from services import update_service
|
||||||
|
from version import APP_VERSION
|
||||||
|
|
||||||
|
logger = logging.getLogger("stackpilot.selfupdate")
|
||||||
|
|
||||||
|
STATUS_TTL = 600.0 # seconds between registry checks
|
||||||
|
_VERSION_RE = re.compile(r"^\d+(\.\d+)*$")
|
||||||
|
|
||||||
|
_LABEL_PROJECT = "com.docker.compose.project"
|
||||||
|
_LABEL_WORKING_DIR = "com.docker.compose.project.working_dir"
|
||||||
|
_LABEL_CONFIG_FILES = "com.docker.compose.project.config_files"
|
||||||
|
|
||||||
|
_status_cache: dict = {"data": None, "ts": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
class SelfUpdateError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Own container / image discovery
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _own_container():
|
||||||
|
"""The container this backend runs in (None outside a container)."""
|
||||||
|
client = get_client()
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
try:
|
||||||
|
return safe_call(client.containers.get, hostname)
|
||||||
|
except DockerError:
|
||||||
|
pass
|
||||||
|
# Fallback (custom hostname set): match by image name.
|
||||||
|
try:
|
||||||
|
for c in safe_call(client.containers.list):
|
||||||
|
if "stackpilot-backend" in (c.attrs.get("Config", {}).get("Image") or ""):
|
||||||
|
return c
|
||||||
|
except DockerError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_info(container) -> Optional[dict]:
|
||||||
|
labels = container.attrs.get("Config", {}).get("Labels") or {}
|
||||||
|
project = labels.get(_LABEL_PROJECT)
|
||||||
|
working_dir = labels.get(_LABEL_WORKING_DIR)
|
||||||
|
config_files = [f for f in (labels.get(_LABEL_CONFIG_FILES) or "").split(",") if f]
|
||||||
|
if not project or not working_dir or not config_files:
|
||||||
|
return None
|
||||||
|
return {"project": project, "working_dir": working_dir, "config_files": config_files}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Registry version lookup
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _version_key(v: str) -> tuple[int, ...]:
|
||||||
|
return tuple(int(p) for p in v.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_tags(registry: str, repo: str) -> list[str]:
|
||||||
|
"""Tag list via the v2 API; anonymous token flow; http fallback for
|
||||||
|
insecure registries (plain-IP registries usually aren't behind TLS)."""
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for scheme in ("https", "http"):
|
||||||
|
url = f"{scheme}://{registry}/v2/{repo}/tags/list"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
resp = await client.get(url, timeout=10)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = await update_service._get_token(
|
||||||
|
client, resp.headers.get("WWW-Authenticate", "")
|
||||||
|
)
|
||||||
|
if not token:
|
||||||
|
raise SelfUpdateError("Registry requires authentication")
|
||||||
|
resp = await client.get(
|
||||||
|
url, headers={"Authorization": f"Bearer {token}"}, timeout=10
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("tags") or []
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
continue
|
||||||
|
raise SelfUpdateError(f"Cannot reach registry {registry}: {last_exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_status(refresh: bool = False) -> dict:
|
||||||
|
now = time.time()
|
||||||
|
if not refresh and _status_cache["data"] and now - _status_cache["ts"] < STATUS_TTL:
|
||||||
|
return _status_cache["data"]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"current_version": APP_VERSION,
|
||||||
|
"latest_version": None,
|
||||||
|
"update_available": False,
|
||||||
|
"update_supported": False,
|
||||||
|
"image": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
container = _own_container()
|
||||||
|
if container is None:
|
||||||
|
data["error"] = "Not running in a container"
|
||||||
|
_status_cache.update(data=data, ts=now)
|
||||||
|
return data
|
||||||
|
image = container.attrs.get("Config", {}).get("Image") or ""
|
||||||
|
data["image"] = image
|
||||||
|
data["update_supported"] = _compose_info(container) is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry, repo, _tag = update_service.parse_ref(image)
|
||||||
|
tags = await _fetch_tags(registry, repo)
|
||||||
|
versions = sorted(
|
||||||
|
(t for t in tags if _VERSION_RE.match(t)), key=_version_key
|
||||||
|
)
|
||||||
|
if versions:
|
||||||
|
latest = versions[-1]
|
||||||
|
data["latest_version"] = latest
|
||||||
|
data["update_available"] = _version_key(latest) > _version_key(APP_VERSION)
|
||||||
|
else:
|
||||||
|
data["error"] = "No version tags found in the registry"
|
||||||
|
except SelfUpdateError as exc:
|
||||||
|
data["error"] = str(exc)
|
||||||
|
|
||||||
|
_status_cache.update(data=data, ts=now)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Apply: helper container runs compose pull + up on our own project
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def apply_update() -> dict:
|
||||||
|
container = _own_container()
|
||||||
|
if container is None:
|
||||||
|
raise SelfUpdateError("Not running in a container — update manually")
|
||||||
|
info = _compose_info(container)
|
||||||
|
if info is None:
|
||||||
|
raise SelfUpdateError(
|
||||||
|
"This StackPilot is not compose-managed — update it the way it was deployed"
|
||||||
|
)
|
||||||
|
image = container.attrs.get("Config", {}).get("Image") or ""
|
||||||
|
|
||||||
|
compose = f"docker compose --project-name {info['project']} --project-directory {info['working_dir']}"
|
||||||
|
for f in info["config_files"]:
|
||||||
|
compose += f" -f {f}"
|
||||||
|
command = f"{compose} pull --quiet && {compose} up -d --remove-orphans"
|
||||||
|
|
||||||
|
# Bind the project dir (and any config file living outside it) read-only
|
||||||
|
# at its host path so relative paths and .env resolve exactly as on host.
|
||||||
|
volumes = {
|
||||||
|
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"},
|
||||||
|
info["working_dir"]: {"bind": info["working_dir"], "mode": "ro"},
|
||||||
|
}
|
||||||
|
for f in info["config_files"]:
|
||||||
|
parent = f.rsplit("/", 1)[0] or "/"
|
||||||
|
if parent != info["working_dir"] and not parent.startswith(info["working_dir"] + "/"):
|
||||||
|
volumes.setdefault(parent, {"bind": parent, "mode": "ro"})
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
helper = safe_call(
|
||||||
|
client.containers.run,
|
||||||
|
image,
|
||||||
|
["sh", "-c", command],
|
||||||
|
detach=True,
|
||||||
|
auto_remove=True,
|
||||||
|
name=f"stackpilot-self-update-{int(time.time())}",
|
||||||
|
labels={"stackpilot.helper": "self-update"},
|
||||||
|
volumes=volumes,
|
||||||
|
working_dir=info["working_dir"],
|
||||||
|
environment={"DOCKER_CONFIG": "/tmp/.docker"}, # don't expect host creds
|
||||||
|
)
|
||||||
|
logger.info("Self-update helper %s started: %s", helper.short_id, command)
|
||||||
|
return {"status": "updating", "helper": helper.short_id, "command": command}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
|
APP_VERSION = "0.32.0"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.31.1",
|
"version": "0.32.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
import type { AuditEntry, DeviceList, GPUInfo, SystemInfo } from "@/types";
|
import type { AuditEntry, DeviceList, GPUInfo, SystemInfo } from "@/types";
|
||||||
|
|
||||||
|
export interface SelfUpdateStatus {
|
||||||
|
current_version: string;
|
||||||
|
latest_version: string | null;
|
||||||
|
update_available: boolean;
|
||||||
|
update_supported: boolean;
|
||||||
|
image: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const systemApi = {
|
export const systemApi = {
|
||||||
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
|
info: () => api.get<SystemInfo>("/api/system/info").then((r) => r.data),
|
||||||
audit: (limit = 10) =>
|
audit: (limit = 10) =>
|
||||||
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
|
api.get<AuditEntry[]>(`/api/audit?limit=${limit}`).then((r) => r.data),
|
||||||
gpus: () => api.get<GPUInfo[]>("/api/system/gpus").then((r) => r.data),
|
gpus: () => api.get<GPUInfo[]>("/api/system/gpus").then((r) => r.data),
|
||||||
devices: () => api.get<DeviceList>("/api/system/devices").then((r) => r.data),
|
devices: () => api.get<DeviceList>("/api/system/devices").then((r) => r.data),
|
||||||
|
selfUpdate: (refresh = false) =>
|
||||||
|
api
|
||||||
|
.get<SelfUpdateStatus>(`/api/system/update${refresh ? "?refresh=true" : ""}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
applySelfUpdate: () =>
|
||||||
|
api.post<{ status: string; helper: string }>("/api/system/update").then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
|
import { VersionBadge } from "./VersionBadge";
|
||||||
|
|
||||||
export const NAV_ITEMS = [
|
export const NAV_ITEMS = [
|
||||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||||
@@ -145,9 +146,7 @@ export function TopNav() {
|
|||||||
|
|
||||||
{/* Right cluster */}
|
{/* Right cluster */}
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
|
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
|
||||||
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
|
<VersionBadge />
|
||||||
v{__APP_VERSION__}
|
|
||||||
</span>
|
|
||||||
{agentCount > 0 && (
|
{agentCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/settings")}
|
onClick={() => navigate("/settings")}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowUpCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
|
import { systemApi } from "@/api/system";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const TIMEOUT_MS = 4 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Version pill in the top bar. Checks the registry for a newer StackPilot
|
||||||
|
* release on page load and offers a one-click in-place update (admins). */
|
||||||
|
export function VersionBadge() {
|
||||||
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["self-update"],
|
||||||
|
queryFn: () => systemApi.selfUpdate(),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startUpdate = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await systemApi.applySelfUpdate();
|
||||||
|
setConfirming(false);
|
||||||
|
setUpdating(true);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(apiErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAvailable = data?.update_available ?? false;
|
||||||
|
const canClick = isAdmin && updateAvailable && (data?.update_supported ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
|
||||||
|
v{__APP_VERSION__}
|
||||||
|
</span>
|
||||||
|
{updateAvailable && (
|
||||||
|
<button
|
||||||
|
onClick={() => canClick && setConfirming(true)}
|
||||||
|
disabled={!canClick}
|
||||||
|
className="hidden items-center gap-1.5 rounded-pill border border-sp-amber/40 bg-sp-amber/10 px-2.5 py-1 text-xs font-semibold text-sp-amber hover:bg-sp-amber/20 disabled:cursor-default sm:flex"
|
||||||
|
title={
|
||||||
|
canClick
|
||||||
|
? `Update StackPilot to ${data?.latest_version}`
|
||||||
|
: data?.update_supported
|
||||||
|
? "A newer StackPilot is available (ask an admin to update)"
|
||||||
|
: "A newer StackPilot is available — this install isn't compose-managed, update it manually"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||||
|
{data?.latest_version ?? "update"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Update StackPilot to ${data?.latest_version}?`}
|
||||||
|
message="Pulls the new images and recreates the StackPilot containers in place. The UI will be briefly unavailable and reloads automatically."
|
||||||
|
confirmLabel="Update now"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={startUpdate}
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updating && <UpdatingOverlay fromVersion={data?.current_version ?? __APP_VERSION__} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-screen wait state while the helper recreates the containers:
|
||||||
|
* polls /api/health until a different version answers, then reloads. */
|
||||||
|
function UpdatingOverlay({ fromVersion }: { fromVersion: string }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const started = useRef(Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (Date.now() - started.current > TIMEOUT_MS) {
|
||||||
|
clearInterval(timer);
|
||||||
|
setFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Raw fetch: no auth/interceptors, and the backend may be mid-restart.
|
||||||
|
const res = await fetch("/api/health", { cache: "no-store" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const body = (await res.json()) as { version?: string };
|
||||||
|
if (body.version && body.version !== fromVersion) {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* backend restarting — keep polling */
|
||||||
|
}
|
||||||
|
}, POLL_MS);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [fromVersion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-card p-6 text-center shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||||
|
{failed ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-semibold">Still on v{fromVersion}</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
The update didn't finish within a few minutes. Check the host with{" "}
|
||||||
|
<code className="font-mono text-xs">docker ps</code> / the compose logs, then reload.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 rounded-pill border border-sp-border px-4 py-1.5 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-sp-border border-t-transparent" />
|
||||||
|
<p className="mt-4 text-sm font-semibold">Updating StackPilot…</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Pulling images and recreating containers. This page reloads automatically.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user