Phase 7: scheduled (recurring) backups (0.7.0)
- BackupSchedule model + schedule_service: next-run calc (hourly/daily/weekly, UTC), background scheduler loop (lifespan), run-one with retention pruning (keep newest N per stack on the destination), backup_failed notify event. - routers/schedules.py: schedules CRUD + run-now; registered in main.py. - Frontend: api/schedules.ts + Settings → Scheduled backups (list with next/last run + status, enable/disable, run-now, delete; add form with stack/destination/ frequency/time/weekday/retention/volumes). Rough-verified only (per request): py_compile, frontend tsc build, app import (95 routes), next-run math sanity. Full live run to be tested after deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7bd449101d
commit
84ef3df59e
@@ -5,7 +5,7 @@ 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) + Phase 6 (Backup
|
||||
> destinations) complete.
|
||||
> destinations) + Phase 7 (Scheduled backups) complete.
|
||||
|
||||
## What works today (Phase 1)
|
||||
|
||||
@@ -91,6 +91,16 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||
and restore (volumes included) directly from it. Remote backups can also be
|
||||
deleted from the UI.
|
||||
|
||||
### Phase 7 — Scheduled backups
|
||||
|
||||
- **Recurring backups**: schedule a stack to back up to a destination **hourly,
|
||||
daily, or weekly** (UTC) under **Settings → Scheduled backups**. A background
|
||||
scheduler runs due jobs every minute and records last/next run + status.
|
||||
- **Retention**: keep the newest *N* backups per stack on the destination; older
|
||||
ones are pruned automatically.
|
||||
- **Run now** for an on-demand run, plus a `backup_failed` notification event
|
||||
wired into the webhook system.
|
||||
|
||||
## Deploying an agent on another host
|
||||
|
||||
```bash
|
||||
@@ -227,6 +237,14 @@ DELETE /api/backups/destinations/{id}/backups/{name}
|
||||
POST /api/stacks/{id}/backup/push POST /api/stacks/restore-from
|
||||
```
|
||||
|
||||
### Phase 7 endpoints
|
||||
|
||||
```
|
||||
GET /api/backups/schedules POST /api/backups/schedules
|
||||
PUT /api/backups/schedules/{id} DELETE /api/backups/schedules/{id}
|
||||
POST /api/backups/schedules/{id}/run
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- The Docker socket is only ever touched by the backend process; it is never
|
||||
|
||||
+6
-2
@@ -22,6 +22,7 @@ from routers import (
|
||||
editor,
|
||||
images,
|
||||
ports,
|
||||
schedules,
|
||||
settings as settings_router,
|
||||
stacks,
|
||||
system,
|
||||
@@ -29,7 +30,7 @@ from routers import (
|
||||
volumes,
|
||||
ws,
|
||||
)
|
||||
from services import update_service
|
||||
from services import schedule_service, update_service
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("stackpilot")
|
||||
@@ -45,12 +46,14 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Stack discovery failed: %s", exc)
|
||||
update_task = asyncio.create_task(update_service.background_loop())
|
||||
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
|
||||
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
||||
yield
|
||||
update_task.cancel()
|
||||
schedule_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="StackPilot", version="0.6.0", lifespan=lifespan)
|
||||
app = FastAPI(title="StackPilot", version="0.7.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -81,6 +84,7 @@ app.include_router(audit.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(backups.router)
|
||||
app.include_router(destinations.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(ws.router)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from models.agent import Agent
|
||||
from models.audit import AuditLog
|
||||
from models.backup_destination import BackupDestination
|
||||
from models.backup_schedule import BackupSchedule
|
||||
from models.setting import Setting, Webhook
|
||||
from models.stack import Stack
|
||||
from models.template import Template
|
||||
@@ -9,5 +10,5 @@ from models.user import User
|
||||
|
||||
__all__ = [
|
||||
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
|
||||
"BackupDestination",
|
||||
"BackupDestination", "BackupSchedule",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
|
||||
|
||||
FREQUENCIES = ["hourly", "daily", "weekly"]
|
||||
|
||||
|
||||
class BackupSchedule(SQLModel, table=True):
|
||||
"""An automatic, recurring backup of a local stack to a destination."""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
stack_id: str
|
||||
destination_id: int
|
||||
frequency: str = "daily" # one of FREQUENCIES
|
||||
hour: int = 3 # UTC, used for daily/weekly
|
||||
minute: int = 0
|
||||
weekday: int = 0 # 0=Mon .. 6=Sun, used for weekly
|
||||
include_volumes: bool = True
|
||||
stop_first: bool = True
|
||||
keep: int = 7 # retention: keep newest N for this stack on the dest (0 = all)
|
||||
enabled: bool = True
|
||||
last_run: Optional[datetime] = None
|
||||
last_status: Optional[str] = None # "ok" | "error: ..."
|
||||
next_run: Optional[datetime] = None
|
||||
created_at: datetime = Field(default_factory=_now)
|
||||
|
||||
|
||||
# --- API schemas ---
|
||||
|
||||
|
||||
class ScheduleCreate(SQLModel):
|
||||
stack_id: str
|
||||
destination_id: int
|
||||
frequency: str = "daily"
|
||||
hour: int = 3
|
||||
minute: int = 0
|
||||
weekday: int = 0
|
||||
include_volumes: bool = True
|
||||
stop_first: bool = True
|
||||
keep: int = 7
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ScheduleUpdate(SQLModel):
|
||||
destination_id: Optional[int] = None
|
||||
frequency: Optional[str] = None
|
||||
hour: Optional[int] = None
|
||||
minute: Optional[int] = None
|
||||
weekday: Optional[int] = None
|
||||
include_volumes: Optional[bool] = None
|
||||
stop_first: Optional[bool] = None
|
||||
keep: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class ScheduleRead(SQLModel):
|
||||
id: int
|
||||
stack_id: str
|
||||
destination_id: int
|
||||
destination_name: Optional[str]
|
||||
frequency: str
|
||||
hour: int
|
||||
minute: int
|
||||
weekday: int
|
||||
include_volumes: bool
|
||||
stop_first: bool
|
||||
keep: int
|
||||
enabled: bool
|
||||
last_run: Optional[datetime]
|
||||
last_status: Optional[str]
|
||||
next_run: Optional[datetime]
|
||||
created_at: datetime
|
||||
@@ -16,6 +16,7 @@ EVENT_STACK_START = "stack_start"
|
||||
EVENT_STACK_STOP = "stack_stop"
|
||||
EVENT_STACK_ERROR = "stack_error"
|
||||
EVENT_PULL_FAILED = "pull_failed"
|
||||
EVENT_BACKUP_FAILED = "backup_failed"
|
||||
|
||||
ALL_EVENTS = [
|
||||
EVENT_UPDATE_AVAILABLE,
|
||||
@@ -23,6 +24,7 @@ ALL_EVENTS = [
|
||||
EVENT_STACK_STOP,
|
||||
EVENT_STACK_ERROR,
|
||||
EVENT_PULL_FAILED,
|
||||
EVENT_BACKUP_FAILED,
|
||||
]
|
||||
|
||||
WEBHOOK_TYPES = ["ntfy", "discord", "slack", "gotify", "generic"]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Scheduled backup management."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from auth import require_admin
|
||||
from database import get_session
|
||||
from models.backup_destination import BackupDestination
|
||||
from models.backup_schedule import (
|
||||
FREQUENCIES,
|
||||
BackupSchedule,
|
||||
ScheduleCreate,
|
||||
ScheduleRead,
|
||||
ScheduleUpdate,
|
||||
)
|
||||
from models.stack import Stack
|
||||
from models.user import User
|
||||
from services import audit_service, schedule_service
|
||||
|
||||
router = APIRouter(prefix="/api/backups/schedules", tags=["backups"])
|
||||
|
||||
|
||||
def _ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _to_read(session: Session, s: BackupSchedule) -> ScheduleRead:
|
||||
dest = session.get(BackupDestination, s.destination_id)
|
||||
return ScheduleRead(
|
||||
id=s.id,
|
||||
stack_id=s.stack_id,
|
||||
destination_id=s.destination_id,
|
||||
destination_name=dest.name if dest else None,
|
||||
frequency=s.frequency,
|
||||
hour=s.hour,
|
||||
minute=s.minute,
|
||||
weekday=s.weekday,
|
||||
include_volumes=s.include_volumes,
|
||||
stop_first=s.stop_first,
|
||||
keep=s.keep,
|
||||
enabled=s.enabled,
|
||||
last_run=s.last_run,
|
||||
last_status=s.last_status,
|
||||
next_run=s.next_run,
|
||||
created_at=s.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_or_404(session: Session, schedule_id: int) -> BackupSchedule:
|
||||
s = session.get(BackupSchedule, schedule_id)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail=f"Schedule {schedule_id} not found")
|
||||
return s
|
||||
|
||||
|
||||
def _validate(session: Session, stack_id: str, destination_id: int, frequency: str) -> None:
|
||||
if frequency not in FREQUENCIES:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown frequency '{frequency}'")
|
||||
if not session.get(Stack, stack_id):
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
||||
if not session.get(BackupDestination, destination_id):
|
||||
raise HTTPException(status_code=404, detail=f"Destination {destination_id} not found")
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScheduleRead])
|
||||
def list_schedules(
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(require_admin),
|
||||
) -> list[ScheduleRead]:
|
||||
rows = session.exec(select(BackupSchedule).order_by(BackupSchedule.id)).all()
|
||||
return [_to_read(session, s) for s in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=ScheduleRead, status_code=201)
|
||||
def create_schedule(
|
||||
body: ScheduleCreate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> ScheduleRead:
|
||||
_validate(session, body.stack_id, body.destination_id, body.frequency)
|
||||
s = BackupSchedule(**body.model_dump())
|
||||
s.next_run = schedule_service.compute_next_run(
|
||||
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now()
|
||||
)
|
||||
session.add(s)
|
||||
session.commit()
|
||||
session.refresh(s)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="schedule.create", target=s.stack_id,
|
||||
detail=f"{s.frequency} → dest {s.destination_id}", ip=_ip(request),
|
||||
)
|
||||
return _to_read(session, s)
|
||||
|
||||
|
||||
@router.put("/{schedule_id}", response_model=ScheduleRead)
|
||||
def update_schedule(
|
||||
schedule_id: int,
|
||||
body: ScheduleUpdate,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> ScheduleRead:
|
||||
s = _get_or_404(session, schedule_id)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(s, k, v)
|
||||
_validate(session, s.stack_id, s.destination_id, s.frequency)
|
||||
# Recompute next run when timing fields change.
|
||||
if {"frequency", "hour", "minute", "weekday"} & set(data) or s.next_run is None:
|
||||
s.next_run = schedule_service.compute_next_run(
|
||||
s.frequency, s.hour, s.minute, s.weekday, schedule_service._now()
|
||||
)
|
||||
session.add(s)
|
||||
session.commit()
|
||||
session.refresh(s)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="schedule.update", target=s.stack_id,
|
||||
ip=_ip(request),
|
||||
)
|
||||
return _to_read(session, s)
|
||||
|
||||
|
||||
@router.delete("/{schedule_id}")
|
||||
def delete_schedule(
|
||||
schedule_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
s = _get_or_404(session, schedule_id)
|
||||
stack_id = s.stack_id
|
||||
session.delete(s)
|
||||
session.commit()
|
||||
audit_service.record(
|
||||
session, user=user.username, action="schedule.delete", target=stack_id,
|
||||
ip=_ip(request),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{schedule_id}/run")
|
||||
async def run_now(
|
||||
schedule_id: int,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
s = _get_or_404(session, schedule_id)
|
||||
result = await schedule_service.run_schedule(session, s)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="schedule.run", target=s.stack_id,
|
||||
detail="ok" if result.get("ok") else result.get("error", "error"), ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Scheduled (recurring) stack backups to a destination.
|
||||
|
||||
A background loop wakes every minute, runs any schedules whose ``next_run`` has
|
||||
passed, uploads the backup to the destination, prunes old backups per the
|
||||
retention setting, and records status + the next run time.
|
||||
|
||||
Times are stored as naive UTC to match SQLite's datetime handling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from database import engine
|
||||
from models.backup_destination import BackupDestination
|
||||
from models.backup_schedule import BackupSchedule
|
||||
from models.setting import EVENT_BACKUP_FAILED
|
||||
from models.stack import Stack
|
||||
from services import (
|
||||
backup_destination_service as dest_service,
|
||||
backup_service,
|
||||
compose_service,
|
||||
notify_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("stackpilot.schedule")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def compute_next_run(
|
||||
frequency: str, hour: int, minute: int, weekday: int, after: datetime
|
||||
) -> datetime:
|
||||
"""Next run strictly after ``after`` (naive UTC)."""
|
||||
if frequency == "hourly":
|
||||
nxt = after.replace(minute=minute % 60, second=0, microsecond=0)
|
||||
if nxt <= after:
|
||||
nxt += timedelta(hours=1)
|
||||
return nxt
|
||||
|
||||
base = after.replace(hour=hour % 24, minute=minute % 60, second=0, microsecond=0)
|
||||
if frequency == "weekly":
|
||||
days_ahead = (weekday - base.weekday()) % 7
|
||||
cand = base + timedelta(days=days_ahead)
|
||||
if cand <= after:
|
||||
cand += timedelta(days=7)
|
||||
return cand
|
||||
|
||||
# daily (default)
|
||||
if base <= after:
|
||||
base += timedelta(days=1)
|
||||
return base
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def _prune(dest: BackupDestination, stack_id: str, keep: int) -> int:
|
||||
if keep <= 0:
|
||||
return 0
|
||||
items = dest_service.list_backups(dest)
|
||||
mine = [i for i in items if i["name"].startswith(f"backup-{stack_id}-")]
|
||||
mine.sort(key=lambda x: x.get("modified") or 0, reverse=True)
|
||||
removed = 0
|
||||
for old in mine[keep:]:
|
||||
try:
|
||||
dest_service.delete(dest, old["name"])
|
||||
removed += 1
|
||||
except dest_service.DestinationError as exc:
|
||||
logger.warning("retention delete failed for %s: %s", old["name"], exc)
|
||||
return removed
|
||||
|
||||
|
||||
async def run_schedule(session: Session, schedule: BackupSchedule) -> dict:
|
||||
"""Execute one schedule now. Updates status + next_run. Returns a summary."""
|
||||
now = _now()
|
||||
stack = session.get(Stack, schedule.stack_id)
|
||||
dest = session.get(BackupDestination, schedule.destination_id)
|
||||
result: dict = {"ok": False}
|
||||
path = None
|
||||
try:
|
||||
if not stack:
|
||||
raise RuntimeError(f"stack '{schedule.stack_id}' not found")
|
||||
if not dest:
|
||||
raise RuntimeError(f"destination {schedule.destination_id} not found")
|
||||
path = await backup_service.create_backup(
|
||||
schedule.stack_id, stack.name,
|
||||
include_volumes=schedule.include_volumes, stop_first=schedule.stop_first,
|
||||
)
|
||||
filename = _backup_filename(schedule.stack_id, schedule.include_volumes)
|
||||
await asyncio.to_thread(dest_service.upload, dest, path, filename)
|
||||
pruned = await asyncio.to_thread(_prune, dest, schedule.stack_id, schedule.keep)
|
||||
schedule.last_status = "ok"
|
||||
result = {"ok": True, "name": filename, "destination": dest.name, "pruned": pruned}
|
||||
logger.info("Scheduled backup %s → %s ok (pruned %d)", schedule.stack_id, dest.name, pruned)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
schedule.last_status = f"error: {exc}"[:300]
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
logger.warning("Scheduled backup %s failed: %s", schedule.stack_id, exc)
|
||||
try:
|
||||
await notify_service.notify(
|
||||
EVENT_BACKUP_FAILED,
|
||||
f"Scheduled backup of '{schedule.stack_id}' failed",
|
||||
str(exc),
|
||||
session,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
if path and os.path.exists(path):
|
||||
os.unlink(path)
|
||||
schedule.last_run = now
|
||||
schedule.next_run = compute_next_run(
|
||||
schedule.frequency, schedule.hour, schedule.minute, schedule.weekday, now
|
||||
)
|
||||
session.add(schedule)
|
||||
session.commit()
|
||||
session.refresh(schedule)
|
||||
return result
|
||||
|
||||
|
||||
async def run_due() -> None:
|
||||
now = _now()
|
||||
with Session(engine) as session:
|
||||
schedules = session.exec(
|
||||
select(BackupSchedule).where(BackupSchedule.enabled == True) # noqa: E712
|
||||
).all()
|
||||
due = []
|
||||
for s in schedules:
|
||||
if s.next_run is None:
|
||||
s.next_run = compute_next_run(s.frequency, s.hour, s.minute, s.weekday, now)
|
||||
session.add(s)
|
||||
elif s.next_run <= now:
|
||||
due.append(s)
|
||||
session.commit()
|
||||
for s in due:
|
||||
await run_schedule(session, s)
|
||||
|
||||
|
||||
async def scheduler_loop() -> None:
|
||||
await asyncio.sleep(20) # let startup settle
|
||||
while True:
|
||||
try:
|
||||
await run_due()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("scheduler tick failed: %s", exc)
|
||||
await asyncio.sleep(60)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface BackupSchedule {
|
||||
id: number;
|
||||
stack_id: string;
|
||||
destination_id: number;
|
||||
destination_name: string | null;
|
||||
frequency: "hourly" | "daily" | "weekly";
|
||||
hour: number;
|
||||
minute: number;
|
||||
weekday: number;
|
||||
include_volumes: boolean;
|
||||
stop_first: boolean;
|
||||
keep: number;
|
||||
enabled: boolean;
|
||||
last_run: string | null;
|
||||
last_status: string | null;
|
||||
next_run: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ScheduleInput {
|
||||
stack_id: string;
|
||||
destination_id: number;
|
||||
frequency: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
weekday: number;
|
||||
include_volumes: boolean;
|
||||
stop_first: boolean;
|
||||
keep: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const schedulesApi = {
|
||||
list: () => api.get<BackupSchedule[]>("/api/backups/schedules").then((r) => r.data),
|
||||
create: (body: ScheduleInput) =>
|
||||
api.post<BackupSchedule>("/api/backups/schedules", body).then((r) => r.data),
|
||||
update: (id: number, body: Partial<ScheduleInput>) =>
|
||||
api.put<BackupSchedule>(`/api/backups/schedules/${id}`, body).then((r) => r.data),
|
||||
remove: (id: number) =>
|
||||
api.delete(`/api/backups/schedules/${id}`).then((r) => r.data),
|
||||
run: (id: number) =>
|
||||
api
|
||||
.post<{ ok: boolean; name?: string; destination?: string; error?: string }>(
|
||||
`/api/backups/schedules/${id}/run`
|
||||
)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
Server,
|
||||
RefreshCw,
|
||||
HardDrive,
|
||||
CalendarClock,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||
@@ -22,9 +24,12 @@ import {
|
||||
type WebhookInput,
|
||||
} from "@/api/settings";
|
||||
import { destinationsApi, type BackupDestination } from "@/api/backups";
|
||||
import { schedulesApi, type BackupSchedule } from "@/api/schedules";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { HostDot } from "@/components/hosts/HostDot";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import type { Agent, User } from "@/types";
|
||||
|
||||
@@ -48,12 +53,233 @@ export function Settings() {
|
||||
<GeneralSection />
|
||||
<HostsSection />
|
||||
<DestinationsSection />
|
||||
<SchedulesSection />
|
||||
<NotificationsSection />
|
||||
<UsersSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Scheduled backups */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
function describeSchedule(s: BackupSchedule): string {
|
||||
const t = `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")} UTC`;
|
||||
if (s.frequency === "hourly") return `Hourly at :${String(s.minute).padStart(2, "0")}`;
|
||||
if (s.frequency === "weekly") return `Weekly · ${WEEKDAYS[s.weekday] ?? "?"} ${t}`;
|
||||
return `Daily · ${t}`;
|
||||
}
|
||||
|
||||
function SchedulesSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ["schedules"], queryFn: schedulesApi.list });
|
||||
const destinations = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list });
|
||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["schedules"] });
|
||||
const noDest = (destinations.data?.length ?? 0) === 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={<CalendarClock className="h-4 w-4" />}>Scheduled backups</SectionTitle>
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
data?.map((s) => <ScheduleRow key={s.id} schedule={s} onChange={invalidate} />)
|
||||
)}
|
||||
{data?.length === 0 && !adding && (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
No scheduled backups. Add one to automatically push a stack to a destination
|
||||
on a recurring schedule.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{adding ? (
|
||||
<ScheduleForm
|
||||
stacks={stacks.data ?? []}
|
||||
destinations={destinations.data ?? []}
|
||||
onDone={() => { setAdding(false); invalidate(); }}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setAdding(true)} disabled={noDest}>
|
||||
<Plus className="h-4 w-4" /> Add schedule
|
||||
</Button>
|
||||
)}
|
||||
{noDest && (
|
||||
<p className="text-xs text-slate-500">Add a backup destination first.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChange: () => void }) {
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => schedulesApi.update(schedule.id, { enabled: !schedule.enabled }),
|
||||
onSuccess: onChange,
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const run = useMutation({
|
||||
mutationFn: () => schedulesApi.run(schedule.id),
|
||||
onSuccess: (r) =>
|
||||
r.ok ? toast.success(`Backed up to ${r.destination}`) : toast.error(r.error || "Backup failed"),
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => schedulesApi.remove(schedule.id),
|
||||
onSuccess: () => { toast.success("Schedule removed"); onChange(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
const ok = schedule.last_status === "ok";
|
||||
|
||||
return (
|
||||
<Card className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium">{schedule.stack_id}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<Badge>{schedule.destination_name ?? `dest ${schedule.destination_id}`}</Badge>
|
||||
{!schedule.enabled && <span className="text-xs text-slate-400">disabled</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => run.mutate()} loading={run.isPending}>
|
||||
<Play className="h-4 w-4" /> Run now
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => toggle.mutate()} loading={toggle.isPending}>
|
||||
<Power className="h-4 w-4" /> {schedule.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
<span>{describeSchedule(schedule)}</span>
|
||||
<span>keep {schedule.keep || "∞"}</span>
|
||||
{schedule.include_volumes && <span>+volumes</span>}
|
||||
{schedule.next_run && <span>next {relativeTime(schedule.next_run)}</span>}
|
||||
{schedule.last_run && (
|
||||
<span className={ok ? "text-green-600 dark:text-green-400" : "text-red-500"}>
|
||||
last {relativeTime(schedule.last_run)} · {schedule.last_status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleForm({
|
||||
stacks,
|
||||
destinations,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
stacks: { id: string; name: string }[];
|
||||
destinations: BackupDestination[];
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
stack_id: stacks[0]?.id ?? "",
|
||||
destination_id: destinations[0]?.id ?? 0,
|
||||
frequency: "daily",
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
weekday: 0,
|
||||
include_volumes: true,
|
||||
stop_first: true,
|
||||
keep: 7,
|
||||
enabled: true,
|
||||
});
|
||||
const set = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => schedulesApi.create(form),
|
||||
onSuccess: () => { toast.success("Schedule added"); onDone(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
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">Stack</span>
|
||||
<select className={selectClass} value={form.stack_id} onChange={(e) => set("stack_id", e.target.value)}>
|
||||
{stacks.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Destination</span>
|
||||
<select className={selectClass} value={form.destination_id} onChange={(e) => set("destination_id", Number(e.target.value))}>
|
||||
{destinations.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name} ({d.type})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Frequency</span>
|
||||
<select className={selectClass} value={form.frequency} onChange={(e) => set("frequency", e.target.value)}>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.frequency === "weekly" && (
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Weekday</span>
|
||||
<select className={selectClass} value={form.weekday} onChange={(e) => set("weekday", Number(e.target.value))}>
|
||||
{WEEKDAYS.map((d, i) => (
|
||||
<option key={d} value={i}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.frequency !== "hourly" && (
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Hour (UTC)</span>
|
||||
<Input type="number" min={0} max={23} value={form.hour} onChange={(e) => set("hour", Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Minute</span>
|
||||
<Input type="number" min={0} max={59} value={form.minute} onChange={(e) => set("minute", Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Keep (retention)</span>
|
||||
<Input type="number" min={0} value={form.keep} onChange={(e) => set("keep", Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="flex items-end gap-2 pb-2 text-sm">
|
||||
<input type="checkbox" checked={form.include_volumes} onChange={(e) => set("include_volumes", e.target.checked)} className="h-4 w-4" />
|
||||
Include volumes
|
||||
</label>
|
||||
<label className="flex items-end gap-2 pb-2 text-sm">
|
||||
<input type="checkbox" checked={form.stop_first} onChange={(e) => set("stop_first", e.target.checked)} className="h-4 w-4" />
|
||||
Stop during backup
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!form.stack_id || !form.destination_id}>
|
||||
Add schedule
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Backup destinations */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user