- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status) - Images: preview stage, purpose-based model choice and reference images (Nano Banana Pro edit refines the chosen preview) - Settings: restore a stored or uploaded backup (staged, applied on restart) - Provider form: test connection; saving or reloading re-checks the status at once - opencode Zen/Go: send x-opencode-session with a stable id per conversation - Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes - Fix: backups made in the same second no longer overwrite each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
9.6 KiB
TypeScript
236 lines
9.6 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { api, get, patch, post } from "../../api";
|
|
import { confirmDialog, toast } from "../../dialogs";
|
|
import { useFetch } from "../../hooks";
|
|
import { fmtDate, useT } from "../../i18n";
|
|
import { formatSize } from "../FileFigure";
|
|
import { Icon } from "../Icon";
|
|
|
|
const MASK = "••••••••";
|
|
|
|
interface Overview {
|
|
files: { name: string; size: number; modified: number }[];
|
|
dir: string;
|
|
last?: { at: number; file: string; size: number; error?: string; s3?: string } | null;
|
|
enabled: boolean;
|
|
hour: number;
|
|
keep: number;
|
|
s3: boolean;
|
|
}
|
|
|
|
interface S3 {
|
|
endpoint: string;
|
|
region: string;
|
|
bucket: string;
|
|
prefix: string;
|
|
access_key: string;
|
|
secret_key: string;
|
|
keep: number;
|
|
}
|
|
|
|
/** Admin: automatic daily backups (local + S3) and manual download. */
|
|
export function Backups() {
|
|
const t = useT();
|
|
const { data, reload } = useFetch<Overview>("/backups");
|
|
const [form, setForm] = useState<{ enabled: boolean; hour: number; keep: number; s3: S3 }>();
|
|
const [secret, setSecret] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
useEffect(() => {
|
|
get<{ config: { backup?: { enabled?: boolean; hour?: number; keep?: number; s3?: Partial<S3> } } }>("/config")
|
|
.then((r) => {
|
|
const b = r.config.backup ?? {};
|
|
const s3 = b.s3 ?? {};
|
|
setForm({
|
|
enabled: b.enabled ?? true,
|
|
hour: b.hour ?? 3,
|
|
keep: b.keep ?? 30,
|
|
s3: { endpoint: s3.endpoint ?? "", region: s3.region ?? "", bucket: s3.bucket ?? "", prefix: s3.prefix ?? "", access_key: s3.access_key ?? "", secret_key: s3.secret_key ?? "", keep: s3.keep ?? 0 },
|
|
});
|
|
})
|
|
.catch((e) => toast(e.message));
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
if (!form) return;
|
|
const patches = [
|
|
{ path: ["backup", "enabled"], value: form.enabled },
|
|
{ path: ["backup", "hour"], value: Math.min(23, Math.max(0, Number(form.hour) || 0)) },
|
|
{ path: ["backup", "keep"], value: Math.max(1, Number(form.keep) || 30) },
|
|
{ path: ["backup", "s3", "endpoint"], value: form.s3.endpoint.trim() },
|
|
{ path: ["backup", "s3", "region"], value: form.s3.region.trim() },
|
|
{ path: ["backup", "s3", "bucket"], value: form.s3.bucket.trim() },
|
|
{ path: ["backup", "s3", "prefix"], value: form.s3.prefix.trim() },
|
|
{ path: ["backup", "s3", "access_key"], value: form.s3.access_key.trim() },
|
|
{ path: ["backup", "s3", "keep"], value: Math.max(0, Number(form.s3.keep) || 0) },
|
|
];
|
|
// Empty field = keep the stored secret.
|
|
if (secret) patches.push({ path: ["backup", "s3", "secret_key"], value: secret });
|
|
try {
|
|
await patch("/config", { patches });
|
|
setSecret("");
|
|
toast(t("settings.saved"), "success");
|
|
reload();
|
|
} catch (e: any) {
|
|
toast(e.message);
|
|
}
|
|
};
|
|
|
|
const runNow = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const r = await post<{ file: string; s3?: string }>("/backups");
|
|
if (r.s3 && r.s3 !== "ok") toast(`${t("backup.s3Failed")}: ${r.s3}`);
|
|
else toast(t("backup.created", { file: r.file }), "success");
|
|
reload();
|
|
} catch (e: any) {
|
|
toast(e.message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
// Restore: the server stages the backup, restarts and applies it before opening the database.
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const [restoring, setRestoring] = useState(false);
|
|
const restore = async (source: { name: string } | { file: File }) => {
|
|
const label = "name" in source ? source.name : source.file.name;
|
|
const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true });
|
|
if (!ok) return;
|
|
setRestoring(true);
|
|
try {
|
|
if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`);
|
|
else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } });
|
|
toast(t("backup.restarting"), "info");
|
|
// Wait until the server is gone and back, then reload with the restored data.
|
|
await new Promise((r) => setTimeout(r, 2500));
|
|
for (let i = 0; i < 120; i++) {
|
|
if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload();
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
toast(t("backup.notBack"));
|
|
} catch (e: any) {
|
|
toast(e.message);
|
|
} finally {
|
|
setRestoring(false);
|
|
}
|
|
};
|
|
|
|
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
|
|
form && (
|
|
<label className="field">
|
|
<span>{label}</span>
|
|
<input
|
|
type={type}
|
|
value={String(form.s3[k] ?? "")}
|
|
placeholder={placeholder}
|
|
onChange={(e) => setForm({ ...form, s3: { ...form.s3, [k]: type === "number" ? Number(e.target.value) : e.target.value } })}
|
|
/>
|
|
</label>
|
|
);
|
|
|
|
return (
|
|
<div className="panel">
|
|
<h3>
|
|
<Icon name="archive" /> {t("audit.backup")}
|
|
<span className="spacer" />
|
|
<a className="button small" href="/api/backup">
|
|
<Icon name="download" /> {t("audit.download")}
|
|
</a>
|
|
<button className="small" onClick={() => fileInput.current?.click()} disabled={restoring}>
|
|
<Icon name="upload" /> {restoring ? t("backup.restoring") : t("backup.restoreFile")}
|
|
</button>
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
accept=".zip,application/zip"
|
|
hidden
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = "";
|
|
if (file) void restore({ file });
|
|
}}
|
|
/>
|
|
<button className="small primary" onClick={runNow} disabled={busy}>
|
|
{busy ? t("backup.running") : t("backup.now")}
|
|
</button>
|
|
</h3>
|
|
<div className="hint" style={{ marginBottom: 10 }}>
|
|
{t("audit.backupHint")}
|
|
</div>
|
|
{form && (
|
|
<>
|
|
<div className="row" style={{ gap: 12, flexWrap: "wrap", alignItems: "center" }}>
|
|
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
|
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
|
|
{t("backup.daily")}
|
|
</label>
|
|
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
|
{t("backup.at")}
|
|
<input type="number" min={0} max={23} style={{ width: 64 }} value={form.hour} onChange={(e) => setForm({ ...form, hour: Number(e.target.value) })} />
|
|
{t("backup.oclock")}
|
|
</label>
|
|
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
|
{t("backup.keep")}
|
|
<input type="number" min={1} style={{ width: 70 }} value={form.keep} onChange={(e) => setForm({ ...form, keep: Number(e.target.value) })} />
|
|
</label>
|
|
</div>
|
|
{data && <div className="hint mono" style={{ marginTop: 6 }}>{data.dir}</div>}
|
|
<details className="backup-s3" open={!!form.s3.endpoint}>
|
|
<summary>{t("backup.s3")}</summary>
|
|
<div className="hint" style={{ margin: "6px 0 10px" }}>
|
|
{t("backup.s3Hint")}
|
|
</div>
|
|
<div className="field-grid">
|
|
{s3("endpoint", t("backup.endpoint"), "https://s3.eu-central-1.amazonaws.com")}
|
|
{s3("region", t("backup.region"), "eu-central-1")}
|
|
{s3("bucket", t("backup.bucket"), "my-backups")}
|
|
{s3("prefix", t("backup.prefix"), "codecraft/")}
|
|
{s3("access_key", t("backup.accessKey"), "AKIA… or ${S3_ACCESS_KEY}")}
|
|
<label className="field">
|
|
<span>{t("backup.secretKey")}</span>
|
|
<input
|
|
type="password"
|
|
value={secret}
|
|
autoComplete="new-password"
|
|
placeholder={form.s3.secret_key === MASK ? t("backup.secretStored") : form.s3.secret_key || "${S3_SECRET_KEY}"}
|
|
onChange={(e) => setSecret(e.target.value)}
|
|
/>
|
|
</label>
|
|
{s3("keep", t("backup.s3Keep"), "0", "number")}
|
|
</div>
|
|
</details>
|
|
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end" }}>
|
|
<button className="primary" onClick={save}>
|
|
{t("common.save")}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{data?.last && (
|
|
<div className={`notice ${data.last.error || (data.last.s3 && data.last.s3 !== "ok") ? "err" : "ok"}`} style={{ marginTop: 10 }}>
|
|
{t("backup.last")}: {fmtDate(data.last.at)} · {data.last.error ?? `${data.last.file} (${formatSize(data.last.size)})`}
|
|
{data.last.s3 && ` · S3: ${data.last.s3 === "ok" ? t("backup.s3Ok") : data.last.s3}`}
|
|
</div>
|
|
)}
|
|
{data && data.files.length > 0 && (
|
|
<div className="backup-list">
|
|
{data.files.map((f) => (
|
|
<div key={f.name} className="backup-file">
|
|
<Icon name="file" />
|
|
<span className="grow">{f.name}</span>
|
|
<span className="hint">{formatSize(f.size)}</span>
|
|
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
|
|
<Icon name="undo" />
|
|
</button>
|
|
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
|
|
<Icon name="download" />
|
|
</a>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|