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
+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 */
/* -------------------------------------------------------------------------- */