diff --git a/backend/tests/test_backup_settings_edit.py b/backend/tests/test_backup_settings_edit.py new file mode 100644 index 0000000..aad6b04 --- /dev/null +++ b/backend/tests/test_backup_settings_edit.py @@ -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"] diff --git a/backend/version.py b/backend/version.py index c308f14..ac52be1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.64.0" +APP_VERSION = "0.64.1" diff --git a/frontend/package.json b/frontend/package.json index 72d6932..ee0aec3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.64.0", + "version": "0.64.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 94e05a9..2b5b791 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 ( + { + 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 ( @@ -166,6 +190,9 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang + @@ -177,7 +204,7 @@ function ScheduleRow({ schedule, onChange }: { schedule: BackupSchedule; onChang {schedule.include_volumes && +volumes} {schedule.next_run && next {relativeTime(schedule.next_run)}} {schedule.last_run && ( - + last {relativeTime(schedule.last_run)} · {schedule.last_status} )} @@ -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({
-
@@ -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 ( + { + 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: + @@ -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>({}); +/** 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(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>(() => + 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: (