Edit backup destinations and schedules in Settings (0.64.1)
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:
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
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.64.0"
|
||||
APP_VERSION = "0.64.1"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.64.0",
|
||||
"version": "0.64.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+135
-31
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user