Edit backup destinations and schedules in Settings (0.64.1)
CI / check (push) Successful in 13m18s
CI / build-and-push (push) Successful in 1m53s

Both had update endpoints, but Settings only offered add, delete and, for
schedules, enable/disable, so a changed NAS address or backup time meant
deleting and recreating the entry.

Each row now has an Edit button that swaps it for the prefilled form, as
for private registries.

- Destinations: name and connection fields. Stored secrets come back
  masked, so they start empty with "Stored - leave empty to keep it"; the
  server keeps the stored value for an empty one. The type is fixed.
- Schedules: destination, timing, retention, volumes, stop-first and
  enabled. The stack is fixed, since retention matches backups by stack
  id. The last status shows warning/retrying in amber instead of red.

Tests pin that an empty secret keeps the stored one, a typed one replaces
it, and a timing change moves next_run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-18 12:10:18 +02:00
1 parent 8f0a01c4f5
commit 3c187e2e80
4 files changed
+236 -33

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.64.0",
"version": "0.64.1",
"type": "module",
"scripts": {
"dev": "vite",
+135 -31
View File
@@ -131,6 +131,9 @@ function SchedulesSection() {
}
function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChange: () => void }) {
const [editing, setEditing] = useState(false);
const destinations = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list });
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list });
const toggle = useMutation({
mutationFn: () => schedulesApi.update(schedule.id, { enabled: !schedule.enabled }),
onSuccess: onChange,
@@ -148,7 +151,28 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
onError: (e) => toast.error(apiErrorMessage(e)),
});
const ok = schedule.last_status === "ok";
if (editing) {
return (
<ScheduleForm
schedule={schedule}
stacks={stacks.data ?? []}
destinations={destinations.data ?? []}
onDone={() => {
setEditing(false);
onChange();
}}
onCancel={() => setEditing(false)}
/>
);
}
const status = schedule.last_status ?? "";
const statusClass =
status === "ok"
? "text-green-600 dark:text-green-400"
: status.startsWith("warning") || status.startsWith("retrying")
? "text-amber-600 dark:text-amber-400"
: "text-red-500";
return (
<Card className="space-y-2">
@@ -166,6 +190,9 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
<Button variant="ghost" onClick={() => toggle.mutate()} loading={toggle.isPending}>
<Power className="h-4 w-4" /> {schedule.enabled ? "Disable" : "Enable"}
</Button>
<Button variant="ghost" onClick={() => setEditing(true)}>
Edit
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
@@ -177,7 +204,7 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
{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"}>
<span className={statusClass}>
last {relativeTime(schedule.last_run)} · {schedule.last_status}
</span>
)}
@@ -187,42 +214,59 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
}
function ScheduleForm({
schedule,
stacks,
destinations,
onDone,
onCancel,
}: {
/** Set to edit an existing schedule; its stack cannot change. */
schedule?: BackupSchedule;
stacks: { id: string; name: string }[];
destinations: BackupDestination[];
onDone: () => void;
onCancel: () => void;
}) {
const editing = Boolean(schedule);
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,
stack_id: schedule?.stack_id ?? stacks[0]?.id ?? "",
destination_id: schedule?.destination_id ?? destinations[0]?.id ?? 0,
frequency: schedule?.frequency ?? "daily",
hour: schedule?.hour ?? 3,
minute: schedule?.minute ?? 0,
weekday: schedule?.weekday ?? 0,
include_volumes: schedule?.include_volumes ?? true,
stop_first: schedule?.stop_first ?? true,
keep: schedule?.keep ?? 7,
enabled: schedule?.enabled ?? true,
});
const set = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
const stackOptions = stacks;
// Keep stack_id valid as the options change.
// Keep stack_id and destination_id valid as the options load or change.
useEffect(() => {
if (stackOptions.length && !stackOptions.some((s) => s.id === form.stack_id)) {
if (!editing && stackOptions.length && !stackOptions.some((s) => s.id === form.stack_id)) {
set("stack_id", stackOptions[0].id);
}
}, [stackOptions]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (destinations.length && !destinations.some((d) => d.id === form.destination_id)) {
set("destination_id", destinations[0].id);
}
}, [destinations]); // eslint-disable-line react-hooks/exhaustive-deps
const create = useMutation({
mutationFn: () => schedulesApi.create(form),
onSuccess: () => { toast.success("Schedule added"); onDone(); },
const save = useMutation({
mutationFn: () => {
if (!schedule) return schedulesApi.create(form);
// The stack is fixed once scheduled: its retention matches by stack id.
const { stack_id: _stack, ...changes } = form;
return schedulesApi.update(schedule.id, changes);
},
onSuccess: () => {
toast.success(editing ? "Schedule updated" : "Schedule added");
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
@@ -231,8 +275,16 @@ function ScheduleForm({
<div className="grid gap-3 sm:grid-cols-3">
<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)}>
<select
className={selectClass}
value={form.stack_id}
onChange={(e) => set("stack_id", e.target.value)}
disabled={editing}
>
{stackOptions.length === 0 && <option value="">—</option>}
{editing && !stackOptions.some((s) => s.id === form.stack_id) && (
<option value={form.stack_id}>{form.stack_id}</option>
)}
{stackOptions.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
@@ -293,8 +345,8 @@ function ScheduleForm({
</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 onClick={() => save.mutate()} loading={save.isPending} disabled={!form.stack_id || !form.destination_id}>
{editing ? "Save" : "Add schedule"}
</Button>
</div>
</Card>
@@ -606,6 +658,8 @@ function DestinationsSection() {
}
function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange: () => void }) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const test = useMutation({
mutationFn: () => destinationsApi.test(dest.id),
onSuccess: (r) =>
@@ -618,6 +672,21 @@ function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange:
onError: (e) => toast.error(apiErrorMessage(e)),
});
if (editing) {
return (
<DestinationForm
dest={dest}
onDone={() => {
setEditing(false);
onChange();
// Schedules show the destination's name.
qc.invalidateQueries({ queryKey: ["schedules"] });
}}
onCancel={() => setEditing(false)}
/>
);
}
const summary =
dest.type === "s3"
? `${dest.config.bucket}${dest.config.prefix ? "/" + dest.config.prefix : ""}`
@@ -638,6 +707,9 @@ function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange:
<Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
<RefreshCw className="h-4 w-4" /> Test
</Button>
<Button variant="ghost" onClick={() => setEditing(true)}>
Edit
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
@@ -646,14 +718,40 @@ function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange:
);
}
function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [name, setName] = useState("");
const [type, setType] = useState("sftp");
const [config, setConfig] = useState<Record<string, string>>({});
/** How the server shows a stored secret; never sent back as a value. */
const MASKED = "••••••";
const create = useMutation({
mutationFn: () => destinationsApi.create({ name, type, config }),
onSuccess: () => { toast.success("Destination added"); onDone(); },
function DestinationForm({
dest,
onDone,
onCancel,
}: {
/** Set to edit an existing destination; its type cannot change. */
dest?: BackupDestination;
onDone: () => void;
onCancel: () => void;
}) {
const editing = Boolean(dest);
const [name, setName] = useState(dest?.name ?? "");
const [type, setType] = useState<string>(dest?.type ?? "sftp");
// Stored secrets come back masked: start those empty, and an empty secret
// on save means "keep the stored one" to the server.
const [config, setConfig] = useState<Record<string, string>>(() =>
Object.fromEntries(
Object.entries(dest?.config ?? {}).map(([k, v]) => [k, v === MASKED ? "" : String(v ?? "")])
)
);
const stored = (key: string) => dest?.config[key] === MASKED;
const save = useMutation({
mutationFn: () =>
dest
? destinationsApi.update(dest.id, { name, config })
: destinationsApi.create({ name, type, config }),
onSuccess: () => {
toast.success(editing ? "Destination updated" : "Destination added");
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
@@ -668,7 +766,12 @@ function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: (
</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({}); }}>
<select
className={selectClass}
value={type}
onChange={(e) => { setType(e.target.value); setConfig({}); }}
disabled={editing}
>
<option value="sftp">SFTP</option>
<option value="s3">S3-compatible</option>
<option value="nfs">NFS share</option>
@@ -683,12 +786,13 @@ function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: (
value={config[f.key] ?? ""}
onChange={(e) => setField(f.key, e.target.value)}
rows={3}
placeholder={stored(f.key) ? "Stored — leave empty to keep it" : undefined}
className={selectClass + " font-mono"}
/>
) : (
<Input
type={f.secret ? "password" : "text"}
placeholder={f.placeholder}
placeholder={stored(f.key) ? "Stored — leave empty to keep it" : f.placeholder}
value={config[f.key] ?? ""}
onChange={(e) => setField(f.key, e.target.value)}
/>
@@ -697,8 +801,8 @@ function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: (
))}
<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 onClick={() => save.mutate()} loading={save.isPending} disabled={!name.trim()}>
{editing ? "Save" : "Add destination"}
</Button>
</div>
</Card>