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

@@ -0,0 +1,99 @@
"""Editing backup destinations and schedules from Settings.
The UI only ever sees stored secrets masked, so it sends them back empty; the
server must keep the stored value then, and apply every other change. A
schedule whose timing changes gets a new next run.
"""
from __future__ import annotations
import uuid
from datetime import datetime
import pytest
@pytest.fixture
def destination(as_admin):
r = as_admin.post(
"/api/backups/destinations",
json={
"name": f"nas-{uuid.uuid4().hex[:6]}",
"type": "sftp",
"config": {"host": "nas", "username": "backup", "password": "s3cret", "path": "/b"},
},
)
assert r.status_code == 201
return r.json()
def _stored_config(dest_id):
from sqlmodel import Session
from database import engine
from models.backup_destination import BackupDestination
from services import backup_destination_service as dest_service
with Session(engine) as session:
return dest_service.parse_config(session.get(BackupDestination, dest_id))
def test_edit_keeps_a_secret_sent_back_empty(as_admin, destination):
assert destination["config"]["password"] == "••••••"
r = as_admin.put(
f"/api/backups/destinations/{destination['id']}",
json={
"name": "renamed",
"config": {"host": "nas2", "username": "backup", "password": "", "path": "/new"},
},
)
assert r.status_code == 200
assert r.json()["name"] == "renamed"
stored = _stored_config(destination["id"])
assert stored == {"host": "nas2", "username": "backup", "password": "s3cret", "path": "/new"}
def test_edit_replaces_a_secret_that_was_typed(as_admin, destination):
as_admin.put(
f"/api/backups/destinations/{destination['id']}",
json={"config": {"password": "n3w"}},
)
assert _stored_config(destination["id"])["password"] == "n3w"
def test_edit_schedule_moves_its_next_run(as_admin, destination):
from sqlmodel import Session
from database import engine
from models.stack import Stack
stack_id = f"s{uuid.uuid4().hex[:8]}"
with Session(engine) as session:
session.add(Stack(id=stack_id, name=stack_id))
session.commit()
r = as_admin.post(
"/api/backups/schedules",
json={"stack_id": stack_id, "destination_id": destination["id"],
"frequency": "daily", "hour": 3, "minute": 0},
)
assert r.status_code == 201
created = r.json()
r = as_admin.put(
f"/api/backups/schedules/{created['id']}",
json={"frequency": "weekly", "weekday": 6, "hour": 4, "minute": 30, "keep": 3,
"include_volumes": False, "stop_first": False, "enabled": True,
"destination_id": destination["id"]},
)
assert r.status_code == 200
updated = r.json()
assert updated["stack_id"] == stack_id
assert (updated["frequency"], updated["weekday"], updated["hour"], updated["minute"]) == (
"weekly", 6, 4, 30,
)
assert (updated["keep"], updated["include_volumes"], updated["stop_first"]) == (3, False, False)
next_run = datetime.fromisoformat(updated["next_run"])
assert (next_run.weekday(), next_run.hour, next_run.minute) == (6, 4, 30)
assert updated["next_run"] != created["next_run"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version.""" """Single source of truth for the StackPilot release version."""
APP_VERSION = "0.64.0" APP_VERSION = "0.64.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.64.0", "version": "0.64.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+135 -31
View File
@@ -131,6 +131,9 @@ function SchedulesSection() {
} }
function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChange: () => void }) { 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({ const toggle = useMutation({
mutationFn: () => schedulesApi.update(schedule.id, { enabled: !schedule.enabled }), mutationFn: () => schedulesApi.update(schedule.id, { enabled: !schedule.enabled }),
onSuccess: onChange, onSuccess: onChange,
@@ -148,7 +151,28 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
onError: (e) => toast.error(apiErrorMessage(e)), 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 ( return (
<Card className="space-y-2"> <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}> <Button variant="ghost" onClick={() => toggle.mutate()} loading={toggle.isPending}>
<Power className="h-4 w-4" /> {schedule.enabled ? "Disable" : "Enable"} <Power className="h-4 w-4" /> {schedule.enabled ? "Disable" : "Enable"}
</Button> </Button>
<Button variant="ghost" onClick={() => setEditing(true)}>
Edit
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}> <Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" /> <Trash2 className="h-4 w-4 text-red-500" />
</Button> </Button>
@@ -177,7 +204,7 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
{schedule.include_volumes && <span>+volumes</span>} {schedule.include_volumes && <span>+volumes</span>}
{schedule.next_run && <span>next {relativeTime(schedule.next_run)}</span>} {schedule.next_run && <span>next {relativeTime(schedule.next_run)}</span>}
{schedule.last_run && ( {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} last {relativeTime(schedule.last_run)} · {schedule.last_status}
</span> </span>
)} )}
@@ -187,42 +214,59 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang
} }
function ScheduleForm({ function ScheduleForm({
schedule,
stacks, stacks,
destinations, destinations,
onDone, onDone,
onCancel, onCancel,
}: { }: {
/** Set to edit an existing schedule; its stack cannot change. */
schedule?: BackupSchedule;
stacks: { id: string; name: string }[]; stacks: { id: string; name: string }[];
destinations: BackupDestination[]; destinations: BackupDestination[];
onDone: () => void; onDone: () => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const editing = Boolean(schedule);
const [form, setForm] = useState({ const [form, setForm] = useState({
stack_id: stacks[0]?.id ?? "", stack_id: schedule?.stack_id ?? stacks[0]?.id ?? "",
destination_id: destinations[0]?.id ?? 0, destination_id: schedule?.destination_id ?? destinations[0]?.id ?? 0,
frequency: "daily", frequency: schedule?.frequency ?? "daily",
hour: 3, hour: schedule?.hour ?? 3,
minute: 0, minute: schedule?.minute ?? 0,
weekday: 0, weekday: schedule?.weekday ?? 0,
include_volumes: true, include_volumes: schedule?.include_volumes ?? true,
stop_first: true, stop_first: schedule?.stop_first ?? true,
keep: 7, keep: schedule?.keep ?? 7,
enabled: true, enabled: schedule?.enabled ?? true,
}); });
const set = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v })); const set = (k: string, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
const stackOptions = stacks; 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(() => { 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); set("stack_id", stackOptions[0].id);
} }
}, [stackOptions]); // eslint-disable-line react-hooks/exhaustive-deps }, [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({ const save = useMutation({
mutationFn: () => schedulesApi.create(form), mutationFn: () => {
onSuccess: () => { toast.success("Schedule added"); onDone(); }, 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)), onError: (e) => toast.error(apiErrorMessage(e)),
}); });
@@ -231,8 +275,16 @@ function ScheduleForm({
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Stack</span> <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>} {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) => ( {stackOptions.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
@@ -293,8 +345,8 @@ function ScheduleForm({
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button> <Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!form.stack_id || !form.destination_id}> <Button onClick={() => save.mutate()} loading={save.isPending} disabled={!form.stack_id || !form.destination_id}>
Add schedule {editing ? "Save" : "Add schedule"}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -606,6 +658,8 @@ function DestinationsSection() {
} }
function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange: () => void }) { function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange: () => void }) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const test = useMutation({ const test = useMutation({
mutationFn: () => destinationsApi.test(dest.id), mutationFn: () => destinationsApi.test(dest.id),
onSuccess: (r) => onSuccess: (r) =>
@@ -618,6 +672,21 @@ function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange:
onError: (e) => toast.error(apiErrorMessage(e)), 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 = const summary =
dest.type === "s3" dest.type === "s3"
? `${dest.config.bucket}${dest.config.prefix ? "/" + dest.config.prefix : ""}` ? `${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}> <Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
<RefreshCw className="h-4 w-4" /> Test <RefreshCw className="h-4 w-4" /> Test
</Button> </Button>
<Button variant="ghost" onClick={() => setEditing(true)}>
Edit
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}> <Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" /> <Trash2 className="h-4 w-4 text-red-500" />
</Button> </Button>
@@ -646,14 +718,40 @@ function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange:
); );
} }
function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) { /** How the server shows a stored secret; never sent back as a value. */
const [name, setName] = useState(""); const MASKED = "••••••";
const [type, setType] = useState("sftp");
const [config, setConfig] = useState<Record<string, string>>({});
const create = useMutation({ function DestinationForm({
mutationFn: () => destinationsApi.create({ name, type, config }), dest,
onSuccess: () => { toast.success("Destination added"); onDone(); }, 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)), onError: (e) => toast.error(apiErrorMessage(e)),
}); });
@@ -668,7 +766,12 @@ function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: (
</label> </label>
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Type</span> <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="sftp">SFTP</option>
<option value="s3">S3-compatible</option> <option value="s3">S3-compatible</option>
<option value="nfs">NFS share</option> <option value="nfs">NFS share</option>
@@ -683,12 +786,13 @@ function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: (
value={config[f.key] ?? ""} value={config[f.key] ?? ""}
onChange={(e) => setField(f.key, e.target.value)} onChange={(e) => setField(f.key, e.target.value)}
rows={3} rows={3}
placeholder={stored(f.key) ? "Stored — leave empty to keep it" : undefined}
className={selectClass + " font-mono"} className={selectClass + " font-mono"}
/> />
) : ( ) : (
<Input <Input
type={f.secret ? "password" : "text"} type={f.secret ? "password" : "text"}
placeholder={f.placeholder} placeholder={stored(f.key) ? "Stored — leave empty to keep it" : f.placeholder}
value={config[f.key] ?? ""} value={config[f.key] ?? ""}
onChange={(e) => setField(f.key, e.target.value)} 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"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button> <Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!name.trim()}> <Button onClick={() => save.mutate()} loading={save.isPending} disabled={!name.trim()}>
Add destination {editing ? "Save" : "Add destination"}
</Button> </Button>
</div> </div>
</Card> </Card>