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:
menzelj
2026-06-07 22:02:52 +00:00
co-authored by Claude Opus 4.8
parent 7bd449101d
commit 84ef3df59e
10 changed files with 697 additions and 5 deletions
+19 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
]
+80
View File
@@ -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
+2
View File
@@ -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"]
+156
View File
@@ -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
+156
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.6.0",
"version": "0.7.0",
"type": "module",
"scripts": {
"dev": "vite",
+49
View File
@@ -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),
};
+226
View File
@@ -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 */
/* -------------------------------------------------------------------------- */