Phase 6: remote backup destinations — SFTP & S3 (0.6.0)

- BackupDestination model + backup_destination_service (SFTP via paramiko,
  S3-compatible via boto3): upload/list/download/delete/test.
- routers/destinations.py: destinations CRUD (secrets masked, merge-on-update),
  test, list/delete remote backups. backups.py: POST /{id}/backup/push and
  POST /restore-from (download from a destination + restore, volumes included).
- Frontend: Settings → Backup destinations (SFTP/S3 forms + test); Backup dialog
  can push to a destination; Restore dialog can pick a destination + backup.
- deps: paramiko 3.5.0, boto3 1.35.99.

Verified end-to-end against live MinIO + atmoz/sftp: create/test destinations,
push (incl. volumes), list, restore-from to a fresh stack (volume data intact),
delete remote backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 21:50:03 +00:00
co-authored by Claude Opus 4.8
parent 59037f4287
commit 7bd449101d
12 changed files with 999 additions and 42 deletions
+21 -4
View File
@@ -4,8 +4,8 @@ A self-hosted Docker Compose manager for power users and homelab enthusiasts —
as intuitive as Dockge, as capable as Portainer for Compose workflows.
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) complete. Remote backup
> destinations (SFTP/S3) are planned for a later phase.
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup
> destinations) complete.
## What works today (Phase 1)
@@ -81,8 +81,15 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
with full lifecycle (start/stop/restart/pull/update/down), live logs, and
compose/.env editing — all proxied to the agent.
> **Not yet:** remote backup destinations (SFTP/S3) — backups currently download
> to / upload from the browser.
### Phase 6 — Backup destinations
- **Off-box backups**: define **SFTP** or **S3-compatible** (MinIO, Backblaze B2,
AWS S3, …) destinations under **Settings → Backup destinations** (with a Test
button; secrets are masked in API responses).
- **Push & restore**: the stack Backup dialog can push straight to a destination
instead of downloading; the Restore dialog can browse a destination's backups
and restore (volumes included) directly from it. Remote backups can also be
deleted from the UI.
## Deploying an agent on another host
@@ -210,6 +217,16 @@ GET /agent/ping | /system | /stacks | /stacks/{id} | /stacks/{id}/logs
POST /agent/stacks | /stacks/{id}/{action} PUT/DELETE /agent/stacks/{id}
```
### Phase 6 endpoints
```
GET /api/backups/destinations POST /api/backups/destinations
PUT /api/backups/destinations/{id} DELETE /api/backups/destinations/{id}
POST /api/backups/destinations/{id}/test GET /api/backups/destinations/{id}/backups
DELETE /api/backups/destinations/{id}/backups/{name}
POST /api/stacks/{id}/backup/push POST /api/stacks/restore-from
```
## Security notes
- The Docker socket is only ever touched by the backend process; it is never
+3 -1
View File
@@ -18,6 +18,7 @@ from routers import (
audit,
auth,
backups,
destinations,
editor,
images,
ports,
@@ -49,7 +50,7 @@ async def lifespan(app: FastAPI):
update_task.cancel()
app = FastAPI(title="StackPilot", version="0.5.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.6.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -79,6 +80,7 @@ app.include_router(templates.router)
app.include_router(audit.router)
app.include_router(settings_router.router)
app.include_router(backups.router)
app.include_router(destinations.router)
app.include_router(agents.router)
app.include_router(ws.router)
+5 -1
View File
@@ -1,9 +1,13 @@
"""SQLModel table models. Importing this package registers all tables."""
from models.agent import Agent
from models.audit import AuditLog
from models.backup_destination import BackupDestination
from models.setting import Setting, Webhook
from models.stack import Stack
from models.template import Template
from models.user import User
__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent"]
__all__ = [
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
"BackupDestination",
]
+48
View File
@@ -0,0 +1,48 @@
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)
DESTINATION_TYPES = ["sftp", "s3"]
# config keys that hold secrets — masked in API responses.
SECRET_KEYS = {"password", "private_key", "secret_key"}
class BackupDestination(SQLModel, table=True):
"""A remote target for stack backups (SFTP or S3-compatible)."""
id: Optional[int] = Field(default=None, primary_key=True)
name: str
type: str # one of DESTINATION_TYPES
config: str = "{}" # JSON-encoded, type-specific (incl. secrets)
created_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class DestinationCreate(SQLModel):
name: str
type: str
config: dict
class DestinationUpdate(SQLModel):
name: Optional[str] = None
config: Optional[dict] = None
class DestinationRead(SQLModel):
id: int
name: str
type: str
config: dict # secrets masked
created_at: datetime
+2
View File
@@ -11,3 +11,5 @@ python-multipart==0.0.20
watchdog==6.0.0
httpx==0.28.1
PyYAML==6.0.2
paramiko==3.5.0
boto3==1.35.99
+118 -4
View File
@@ -1,18 +1,26 @@
"""Stack backup (incl. volumes) and restore."""
from __future__ import annotations
import asyncio
import os
import tempfile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlmodel import Session
from auth import require_admin
from database import get_session
from models.backup_destination import BackupDestination
from models.stack import Stack
from models.user import User
from services import audit_service, backup_service, compose_service
from services import (
audit_service,
backup_destination_service as dest_service,
backup_service,
compose_service,
)
router = APIRouter(prefix="/api/stacks", tags=["backups"])
@@ -21,6 +29,12 @@ def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _backup_filename(stack_id: str, include_volumes: bool) -> str:
date = compose_service.now().strftime("%Y%m%d-%H%M%S")
suffix = "full" if include_volumes else "config"
return f"backup-{stack_id}-{suffix}-{date}.tar.gz"
@router.get("/{stack_id}/backup")
async def backup_stack(
stack_id: str,
@@ -43,12 +57,10 @@ async def backup_stack(
session, user=user.username, action="stack.backup", target=stack_id,
detail=f"volumes={include_volumes}", ip=_ip(request),
)
date = compose_service.now().strftime("%Y%m%d-%H%M%S")
suffix = "full" if include_volumes else "config"
return FileResponse(
path,
media_type="application/gzip",
filename=f"backup-{stack_id}-{suffix}-{date}.tar.gz",
filename=_backup_filename(stack_id, include_volumes),
)
@@ -94,3 +106,105 @@ async def restore_stack(
finally:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
# --------------------------------------------------------------------------- #
# Push to / restore from a remote destination
# --------------------------------------------------------------------------- #
class PushBody(BaseModel):
destination_id: int
include_volumes: bool = True
stop_first: bool = True
class RestoreFromBody(BaseModel):
destination_id: int
name: str
target_id: str | None = None
overwrite: bool = False
restore_volumes: bool = True
def _get_dest(session: Session, dest_id: int) -> BackupDestination:
d = session.get(BackupDestination, dest_id)
if not d:
raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found")
return d
@router.post("/{stack_id}/backup/push")
async def push_backup(
stack_id: str,
body: PushBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
stack = session.get(Stack, stack_id)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
dest = _get_dest(session, body.destination_id)
try:
path = await backup_service.create_backup(
stack_id, stack.name,
include_volumes=body.include_volumes, stop_first=body.stop_first,
)
except backup_service.BackupError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
filename = _backup_filename(stack_id, body.include_volumes)
try:
remote = await asyncio.to_thread(dest_service.upload, dest, path, filename)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
finally:
if os.path.exists(path):
os.unlink(path)
audit_service.record(
session, user=user.username, action="stack.backup.push",
target=stack_id, detail=f"{dest.name}:{filename}", ip=_ip(request),
)
return {"ok": True, "destination": dest.name, "name": filename, "remote": remote}
@router.post("/restore-from")
async def restore_from_destination(
body: RestoreFromBody,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
dest = _get_dest(session, body.destination_id)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
tmp.close()
try:
try:
await asyncio.to_thread(dest_service.download, dest, body.name, tmp.name)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
target = compose_service.slugify(body.target_id) if body.target_id else None
try:
result = backup_service.restore_backup(
tmp.name, target_id=target,
overwrite=body.overwrite, restore_volumes=body.restore_volumes,
)
except backup_service.BackupError as exc:
code = 409 if "already exists" in str(exc) else 400
raise HTTPException(status_code=code, detail=str(exc)) from exc
stack_id = result["stack_id"]
if not session.get(Stack, stack_id):
session.add(Stack(id=stack_id, name=result.get("name", stack_id)))
session.commit()
audit_service.record(
session, user=user.username, action="stack.restore",
target=stack_id, detail=f"from {dest.name}:{body.name}", ip=_ip(request),
)
return result
finally:
if os.path.exists(tmp.name):
os.unlink(tmp.name)
+171
View File
@@ -0,0 +1,171 @@
"""Backup destination management (SFTP / S3-compatible)."""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from auth import get_current_user, require_admin
from database import get_session
from models.backup_destination import (
DESTINATION_TYPES,
SECRET_KEYS,
BackupDestination,
DestinationCreate,
DestinationRead,
DestinationUpdate,
)
from models.user import User
from services import audit_service, backup_destination_service as dest_service
router = APIRouter(prefix="/api/backups/destinations", tags=["backups"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _mask(config: dict) -> dict:
return {k: ("••••••" if k in SECRET_KEYS and v else v) for k, v in config.items()}
def _to_read(d: BackupDestination) -> DestinationRead:
return DestinationRead(
id=d.id,
name=d.name,
type=d.type,
config=_mask(dest_service.parse_config(d)),
created_at=d.created_at,
)
def _get_or_404(session: Session, dest_id: int) -> BackupDestination:
d = session.get(BackupDestination, dest_id)
if not d:
raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found")
return d
@router.get("", response_model=list[DestinationRead])
def list_destinations(
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> list[DestinationRead]:
rows = session.exec(select(BackupDestination).order_by(BackupDestination.id)).all()
return [_to_read(d) for d in rows]
@router.post("", response_model=DestinationRead, status_code=201)
def create_destination(
body: DestinationCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> DestinationRead:
if body.type not in DESTINATION_TYPES:
raise HTTPException(status_code=400, detail=f"Unknown type '{body.type}'")
d = BackupDestination(name=body.name, type=body.type, config=json.dumps(body.config))
session.add(d)
session.commit()
session.refresh(d)
audit_service.record(
session, user=user.username, action="destination.create", target=body.name,
detail=body.type, ip=_ip(request),
)
return _to_read(d)
@router.put("/{dest_id}", response_model=DestinationRead)
def update_destination(
dest_id: int,
body: DestinationUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> DestinationRead:
d = _get_or_404(session, dest_id)
if body.name is not None:
d.name = body.name
if body.config is not None:
# Merge so masked/blank secrets don't wipe stored ones.
existing = dest_service.parse_config(d)
for k, v in body.config.items():
if k in SECRET_KEYS and (v == "" or v == "••••••"):
continue # keep existing secret
existing[k] = v
d.config = json.dumps(existing)
session.add(d)
session.commit()
session.refresh(d)
audit_service.record(
session, user=user.username, action="destination.update", target=d.name,
ip=_ip(request),
)
return _to_read(d)
@router.delete("/{dest_id}")
def delete_destination(
dest_id: int,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
d = _get_or_404(session, dest_id)
name = d.name
session.delete(d)
session.commit()
audit_service.record(
session, user=user.username, action="destination.delete", target=name,
ip=_ip(request),
)
return {"ok": True}
@router.post("/{dest_id}/test")
async def test_destination(
dest_id: int,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> dict:
d = _get_or_404(session, dest_id)
try:
await asyncio.to_thread(dest_service.test, d)
return {"ok": True}
except dest_service.DestinationError as exc:
return {"ok": False, "error": str(exc)}
@router.get("/{dest_id}/backups")
async def list_destination_backups(
dest_id: int,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> list[dict]:
d = _get_or_404(session, dest_id)
try:
return await asyncio.to_thread(dest_service.list_backups, d)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@router.delete("/{dest_id}/backups/{name}")
async def delete_destination_backup(
dest_id: int,
name: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
d = _get_or_404(session, dest_id)
try:
await asyncio.to_thread(dest_service.delete, d, name)
except dest_service.DestinationError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
audit_service.record(
session, user=user.username, action="destination.backup.delete",
target=f"{d.name}/{name}", ip=_ip(request),
)
return {"ok": True}
@@ -0,0 +1,258 @@
"""Push/pull stack backups to remote destinations (SFTP or S3-compatible).
All operations are synchronous (paramiko / boto3); async callers should wrap
them with ``asyncio.to_thread``. Destination config is a plain dict parsed from
the ``BackupDestination.config`` JSON column.
"""
from __future__ import annotations
import io
import json
import logging
import os
import posixpath
import stat
from typing import Any
from models.backup_destination import BackupDestination
logger = logging.getLogger("stackpilot.backup_dest")
class DestinationError(Exception):
pass
def parse_config(dest: BackupDestination) -> dict:
try:
return json.loads(dest.config or "{}")
except json.JSONDecodeError:
return {}
# --------------------------------------------------------------------------- #
# SFTP (paramiko)
# --------------------------------------------------------------------------- #
def _sftp_connect(cfg: dict):
import paramiko
host = cfg.get("host")
if not host:
raise DestinationError("SFTP host is required")
port = int(cfg.get("port") or 22)
username = cfg.get("username")
transport = paramiko.Transport((host, port))
try:
pkey = None
if cfg.get("private_key"):
pkey = _load_key(cfg["private_key"])
transport.connect(username=username, password=cfg.get("password") or None, pkey=pkey)
except Exception as exc: # noqa: BLE001
transport.close()
raise DestinationError(f"SFTP connection failed: {exc}") from exc
return paramiko.SFTPClient.from_transport(transport), transport
def _load_key(key_str: str):
import paramiko
for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
try:
return cls.from_private_key(io.StringIO(key_str))
except Exception: # noqa: BLE001
continue
raise DestinationError("Could not parse SFTP private key")
def _sftp_makedirs(sftp, path: str) -> None:
if not path or path in (".", "/"):
return
parts = path.strip("/").split("/")
cur = "/" if path.startswith("/") else ""
for p in parts:
cur = posixpath.join(cur, p) if cur else p
try:
sftp.stat(cur)
except IOError:
sftp.mkdir(cur)
def _sftp_upload(cfg: dict, local_path: str, filename: str) -> str:
sftp, transport = _sftp_connect(cfg)
try:
base = cfg.get("path") or "."
if base not in (".", ""):
_sftp_makedirs(sftp, base)
remote = posixpath.join(base, filename) if base not in (".", "") else filename
sftp.put(local_path, remote)
return remote
finally:
sftp.close()
transport.close()
def _sftp_list(cfg: dict) -> list[dict]:
sftp, transport = _sftp_connect(cfg)
try:
base = cfg.get("path") or "."
out = []
try:
entries = sftp.listdir_attr(base)
except IOError:
return []
for e in entries:
if stat.S_ISDIR(e.st_mode):
continue
if not e.filename.endswith(".tar.gz"):
continue
out.append({"name": e.filename, "size": e.st_size, "modified": e.st_mtime})
return sorted(out, key=lambda x: x["modified"] or 0, reverse=True)
finally:
sftp.close()
transport.close()
def _sftp_download(cfg: dict, name: str, local_path: str) -> None:
sftp, transport = _sftp_connect(cfg)
try:
base = cfg.get("path") or "."
remote = posixpath.join(base, name) if base not in (".", "") else name
sftp.get(remote, local_path)
finally:
sftp.close()
transport.close()
def _sftp_delete(cfg: dict, name: str) -> None:
sftp, transport = _sftp_connect(cfg)
try:
base = cfg.get("path") or "."
remote = posixpath.join(base, name) if base not in (".", "") else name
sftp.remove(remote)
finally:
sftp.close()
transport.close()
# --------------------------------------------------------------------------- #
# S3-compatible (boto3)
# --------------------------------------------------------------------------- #
def _s3_client(cfg: dict):
import boto3
bucket = cfg.get("bucket")
if not bucket:
raise DestinationError("S3 bucket is required")
return boto3.client(
"s3",
endpoint_url=cfg.get("endpoint_url") or None,
region_name=cfg.get("region") or None,
aws_access_key_id=cfg.get("access_key") or None,
aws_secret_access_key=cfg.get("secret_key") or None,
)
def _s3_key(cfg: dict, filename: str) -> str:
prefix = (cfg.get("prefix") or "").strip("/")
return f"{prefix}/{filename}" if prefix else filename
def _s3_upload(cfg: dict, local_path: str, filename: str) -> str:
client = _s3_client(cfg)
key = _s3_key(cfg, filename)
try:
client.upload_file(local_path, cfg["bucket"], key)
except Exception as exc: # noqa: BLE001
raise DestinationError(f"S3 upload failed: {exc}") from exc
return key
def _s3_list(cfg: dict) -> list[dict]:
client = _s3_client(cfg)
prefix = (cfg.get("prefix") or "").strip("/")
kwargs: dict[str, Any] = {"Bucket": cfg["bucket"]}
if prefix:
kwargs["Prefix"] = prefix + "/"
try:
resp = client.list_objects_v2(**kwargs)
except Exception as exc: # noqa: BLE001
raise DestinationError(f"S3 list failed: {exc}") from exc
out = []
for obj in resp.get("Contents", []):
name = obj["Key"].split("/")[-1]
if not name.endswith(".tar.gz"):
continue
out.append(
{
"name": name,
"size": obj.get("Size", 0),
"modified": obj["LastModified"].timestamp() if obj.get("LastModified") else None,
}
)
return sorted(out, key=lambda x: x["modified"] or 0, reverse=True)
def _s3_download(cfg: dict, name: str, local_path: str) -> None:
client = _s3_client(cfg)
try:
client.download_file(cfg["bucket"], _s3_key(cfg, name), local_path)
except Exception as exc: # noqa: BLE001
raise DestinationError(f"S3 download failed: {exc}") from exc
def _s3_delete(cfg: dict, name: str) -> None:
client = _s3_client(cfg)
client.delete_object(Bucket=cfg["bucket"], Key=_s3_key(cfg, name))
# --------------------------------------------------------------------------- #
# Dispatch
# --------------------------------------------------------------------------- #
def upload(dest: BackupDestination, local_path: str, filename: str) -> str:
cfg = parse_config(dest)
if dest.type == "sftp":
return _sftp_upload(cfg, local_path, filename)
if dest.type == "s3":
return _s3_upload(cfg, local_path, filename)
raise DestinationError(f"Unknown destination type '{dest.type}'")
def list_backups(dest: BackupDestination) -> list[dict]:
cfg = parse_config(dest)
if dest.type == "sftp":
return _sftp_list(cfg)
if dest.type == "s3":
return _s3_list(cfg)
raise DestinationError(f"Unknown destination type '{dest.type}'")
def download(dest: BackupDestination, name: str, local_path: str) -> None:
cfg = parse_config(dest)
if dest.type == "sftp":
_sftp_download(cfg, name, local_path)
elif dest.type == "s3":
_s3_download(cfg, name, local_path)
else:
raise DestinationError(f"Unknown destination type '{dest.type}'")
def delete(dest: BackupDestination, name: str) -> None:
cfg = parse_config(dest)
if dest.type == "sftp":
_sftp_delete(cfg, name)
elif dest.type == "s3":
_s3_delete(cfg, name)
else:
raise DestinationError(f"Unknown destination type '{dest.type}'")
def test(dest: BackupDestination) -> bool:
"""Connectivity check — lists the target (cheap, validates auth + path)."""
list_backups(dest)
return True
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.5.0",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+60
View File
@@ -1,5 +1,19 @@
import api from "./client";
export interface BackupDestination {
id: number;
name: string;
type: "sftp" | "s3";
config: Record<string, string>;
created_at: string;
}
export interface RemoteBackup {
name: string;
size: number;
modified: number | null;
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -42,4 +56,50 @@ export const backupsApi = {
}>("/api/stacks/restore", form);
return res.data;
},
push: (
stackId: string,
body: { destination_id: number; include_volumes: boolean; stop_first: boolean }
) =>
api
.post<{ ok: boolean; destination: string; name: string }>(
`/api/stacks/${stackId}/backup/push`,
body
)
.then((r) => r.data),
restoreFrom: (body: {
destination_id: number;
name: string;
target_id?: string;
overwrite: boolean;
restore_volumes: boolean;
}) =>
api
.post<{ stack_id: string; name: string; volumes_restored: number }>(
"/api/stacks/restore-from",
body
)
.then((r) => r.data),
};
export const destinationsApi = {
list: () =>
api.get<BackupDestination[]>("/api/backups/destinations").then((r) => r.data),
create: (body: { name: string; type: string; config: Record<string, string> }) =>
api.post<BackupDestination>("/api/backups/destinations", body).then((r) => r.data),
update: (id: number, body: { name?: string; config?: Record<string, string> }) =>
api.put<BackupDestination>(`/api/backups/destinations/${id}`, body).then((r) => r.data),
remove: (id: number) =>
api.delete(`/api/backups/destinations/${id}`).then((r) => r.data),
test: (id: number) =>
api
.post<{ ok: boolean; error?: string }>(`/api/backups/destinations/${id}/test`)
.then((r) => r.data),
backups: (id: number) =>
api.get<RemoteBackup[]>(`/api/backups/destinations/${id}/backups`).then((r) => r.data),
deleteBackup: (id: number, name: string) =>
api
.delete(`/api/backups/destinations/${id}/backups/${encodeURIComponent(name)}`)
.then((r) => r.data),
};
+151 -31
View File
@@ -1,10 +1,14 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui";
import { backupsApi } from "@/api/backups";
import { backupsApi, destinationsApi } from "@/api/backups";
import { apiErrorMessage } from "@/api/client";
import { formatBytes } from "@/lib/utils";
const selectClass =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
function Checkbox({
checked,
@@ -50,14 +54,30 @@ export function BackupButton({ stackId }: { stackId: string }) {
const [open, setOpen] = useState(false);
const [includeVolumes, setIncludeVolumes] = useState(true);
const [stopFirst, setStopFirst] = useState(true);
const [target, setTarget] = useState("download"); // "download" | destination id
const [busy, setBusy] = useState(false);
const destinations = useQuery({
queryKey: ["destinations"],
queryFn: destinationsApi.list,
enabled: open,
});
const run = async () => {
setBusy(true);
const tid = toast.loading("Creating backup…");
try {
await backupsApi.download(stackId, { includeVolumes, stopFirst });
toast.success("Backup downloaded", { id: tid });
if (target === "download") {
await backupsApi.download(stackId, { includeVolumes, stopFirst });
toast.success("Backup downloaded", { id: tid });
} else {
const res = await backupsApi.push(stackId, {
destination_id: Number(target),
include_volumes: includeVolumes,
stop_first: stopFirst,
});
toast.success(`Backup pushed to ${res.destination}`, { id: tid });
}
setOpen(false);
} catch (e) {
toast.error(apiErrorMessage(e), { id: tid });
@@ -75,6 +95,17 @@ export function BackupButton({ stackId }: { stackId: string }) {
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Back up {stackId}</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Destination</span>
<select className={selectClass} value={target} onChange={(e) => setTarget(e.target.value)}>
<option value="download">Download to browser</option>
{destinations.data?.map((d) => (
<option key={d.id} value={String(d.id)}>
{d.name} ({d.type})
</option>
))}
</select>
</label>
<Checkbox
checked={includeVolumes}
onChange={setIncludeVolumes}
@@ -93,7 +124,7 @@ export function BackupButton({ stackId }: { stackId: string }) {
Cancel
</Button>
<Button onClick={run} loading={busy}>
Download backup
{target === "download" ? "Download backup" : "Push backup"}
</Button>
</div>
</Modal>
@@ -105,33 +136,62 @@ export function BackupButton({ stackId }: { stackId: string }) {
export function RestoreButton() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"upload" | "destination">("upload");
const [file, setFile] = useState<File | null>(null);
const [destId, setDestId] = useState<string>("");
const [remoteName, setRemoteName] = useState<string>("");
const [targetId, setTargetId] = useState("");
const [overwrite, setOverwrite] = useState(false);
const [restoreVolumes, setRestoreVolumes] = useState(true);
const [busy, setBusy] = useState(false);
const destinations = useQuery({
queryKey: ["destinations"],
queryFn: destinationsApi.list,
enabled: open,
});
const remoteBackups = useQuery({
queryKey: ["dest-backups", destId],
queryFn: () => destinationsApi.backups(Number(destId)),
enabled: open && mode === "destination" && !!destId,
});
const run = async () => {
if (!file) {
toast.error("Select a backup file");
return;
}
setBusy(true);
const tid = toast.loading("Restoring…");
try {
const res = await backupsApi.restore(file, {
targetId: targetId.trim() || undefined,
overwrite,
restoreVolumes,
});
toast.success(
`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`,
{ id: tid }
);
let res;
if (mode === "upload") {
if (!file) {
toast.error("Select a backup file", { id: tid });
setBusy(false);
return;
}
res = await backupsApi.restore(file, {
targetId: targetId.trim() || undefined,
overwrite,
restoreVolumes,
});
} else {
if (!destId || !remoteName) {
toast.error("Pick a destination and a backup", { id: tid });
setBusy(false);
return;
}
res = await backupsApi.restoreFrom({
destination_id: Number(destId),
name: remoteName,
target_id: targetId.trim() || undefined,
overwrite,
restore_volumes: restoreVolumes,
});
}
toast.success(`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, { id: tid });
qc.invalidateQueries({ queryKey: ["stacks"] });
setOpen(false);
setFile(null);
setTargetId("");
setRemoteName("");
} catch (e) {
toast.error(apiErrorMessage(e), { id: tid });
} finally {
@@ -147,13 +207,73 @@ export function RestoreButton() {
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
<div className="mb-3 flex gap-1 rounded-lg bg-slate-100 p-1 text-sm dark:bg-slate-800">
{(["upload", "destination"] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={
mode === m
? "flex-1 rounded-md bg-card px-3 py-1.5 font-medium shadow-sm dark:bg-card-dark"
: "flex-1 rounded-md px-3 py-1.5 text-slate-500"
}
>
{m === "upload" ? "Upload file" : "From destination"}
</button>
))}
</div>
<div className="space-y-3">
<input
type="file"
accept=".tar.gz,.tgz,application/gzip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
/>
{mode === "upload" ? (
<input
type="file"
accept=".tar.gz,.tgz,application/gzip"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
/>
) : (
<>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Destination</span>
<select
className={selectClass}
value={destId}
onChange={(e) => {
setDestId(e.target.value);
setRemoteName("");
}}
>
<option value="">Select</option>
{destinations.data?.map((d) => (
<option key={d.id} value={String(d.id)}>
{d.name} ({d.type})
</option>
))}
</select>
</label>
{destId && (
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">
Backup {remoteBackups.isFetching && "(loading…)"}
</span>
<select
className={selectClass}
value={remoteName}
onChange={(e) => setRemoteName(e.target.value)}
>
<option value="">Select</option>
{remoteBackups.data?.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {formatBytes(b.size)}
</option>
))}
</select>
</label>
)}
</>
)}
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">
Restore as (optional leave blank to use the original name)
@@ -162,14 +282,10 @@ export function RestoreButton() {
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder="new-stack-name"
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
className={selectClass}
/>
</label>
<Checkbox
checked={restoreVolumes}
onChange={setRestoreVolumes}
label="Restore volume data"
/>
<Checkbox checked={restoreVolumes} onChange={setRestoreVolumes} label="Restore volume data" />
<Checkbox
checked={overwrite}
onChange={setOverwrite}
@@ -181,7 +297,11 @@ export function RestoreButton() {
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={run} loading={busy} disabled={!file}>
<Button
onClick={run}
loading={busy}
disabled={mode === "upload" ? !file : !destId || !remoteName}
>
Restore
</Button>
</div>
+161
View File
@@ -11,6 +11,7 @@ import {
Power,
Server,
RefreshCw,
HardDrive,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
@@ -20,6 +21,7 @@ import {
type Webhook,
type WebhookInput,
} from "@/api/settings";
import { destinationsApi, type BackupDestination } from "@/api/backups";
import { agentsApi } from "@/api/agents";
import { HostDot } from "@/components/hosts/HostDot";
import { apiErrorMessage } from "@/api/client";
@@ -45,12 +47,171 @@ export function Settings() {
<div className="mx-auto max-w-3xl space-y-6">
<GeneralSection />
<HostsSection />
<DestinationsSection />
<NotificationsSection />
<UsersSection />
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Backup destinations */
/* -------------------------------------------------------------------------- */
const selectClass =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
const FIELDS: Record<string, { key: string; label: string; secret?: boolean; area?: boolean; placeholder?: string }[]> = {
sftp: [
{ key: "host", label: "Host" },
{ key: "port", label: "Port", placeholder: "22" },
{ key: "username", label: "Username" },
{ key: "password", label: "Password", secret: true },
{ key: "private_key", label: "Private key (optional, instead of password)", secret: true, area: true },
{ key: "path", label: "Remote directory", placeholder: "/backups/stackpilot" },
],
s3: [
{ key: "endpoint_url", label: "Endpoint URL (blank = AWS)", placeholder: "https://minio.example:9000" },
{ key: "region", label: "Region", placeholder: "us-east-1" },
{ key: "bucket", label: "Bucket" },
{ key: "access_key", label: "Access key", secret: true },
{ key: "secret_key", label: "Secret key", secret: true },
{ key: "prefix", label: "Key prefix (optional)", placeholder: "stackpilot/" },
],
};
function DestinationsSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list });
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["destinations"] });
return (
<section>
<SectionTitle icon={<HardDrive className="h-4 w-4" />}>Backup destinations</SectionTitle>
<div className="space-y-3">
{isLoading ? (
<Spinner />
) : (
data?.map((d) => <DestinationRow key={d.id} dest={d} onChange={invalidate} />)
)}
{data?.length === 0 && !adding && (
<Card>
<p className="text-sm text-slate-500">
No destinations. Add an SFTP server or S3-compatible bucket to push stack
backups off-box and restore from them.
</p>
</Card>
)}
{adding ? (
<DestinationForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" /> Add destination
</Button>
)}
</div>
</section>
);
}
function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange: () => void }) {
const test = useMutation({
mutationFn: () => destinationsApi.test(dest.id),
onSuccess: (r) =>
r.ok ? toast.success("Reachable") : toast.error(r.error || "Connection failed"),
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: () => destinationsApi.remove(dest.id),
onSuccess: () => { toast.success("Destination removed"); onChange(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const summary =
dest.type === "s3"
? `${dest.config.bucket}${dest.config.prefix ? "/" + dest.config.prefix : ""}`
: `${dest.config.username}@${dest.config.host}:${dest.config.path || "."}`;
return (
<Card className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{dest.name}</span>
<Badge>{dest.type}</Badge>
</div>
<p className="break-all font-mono text-xs text-slate-500">{summary}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
<RefreshCw className="h-4 w-4" /> Test
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</Card>
);
}
function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [name, setName] = useState("");
const [type, setType] = useState("sftp");
const [config, setConfig] = useState<Record<string, string>>({});
const create = useMutation({
mutationFn: () => destinationsApi.create({ name, type, config }),
onSuccess: () => { toast.success("Destination added"); onDone(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const setField = (k: string, v: string) => setConfig((c) => ({ ...c, [k]: v }));
return (
<Card className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="offsite-nas" />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Type</span>
<select className={selectClass} value={type} onChange={(e) => { setType(e.target.value); setConfig({}); }}>
<option value="sftp">SFTP</option>
<option value="s3">S3-compatible</option>
</select>
</label>
</div>
{FIELDS[type].map((f) => (
<label key={f.key} className="block space-y-1">
<span className="text-xs font-medium text-slate-500">{f.label}</span>
{f.area ? (
<textarea
value={config[f.key] ?? ""}
onChange={(e) => setField(f.key, e.target.value)}
rows={3}
className={selectClass + " font-mono"}
/>
) : (
<Input
type={f.secret ? "password" : "text"}
placeholder={f.placeholder}
value={config[f.key] ?? ""}
onChange={(e) => setField(f.key, e.target.value)}
/>
)}
</label>
))}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!name.trim()}>
Add destination
</Button>
</div>
</Card>
);
}
/* -------------------------------------------------------------------------- */
/* Remote hosts (agents) */
/* -------------------------------------------------------------------------- */