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
@@ -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