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."""
|
"""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",
|
"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
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user