b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
203 lines
6.9 KiB
TypeScript
203 lines
6.9 KiB
TypeScript
/**
|
|
* @file BackupSettings.tsx
|
|
* @description Settings → Data → Backup: the daily database backup of
|
|
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
|
|
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
|
|
* while it runs, then toasts the outcome) and the newest backup in the folder.
|
|
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
|
|
* field the environment set is read-only. Restoring is manual and the card says
|
|
* how.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AlertTriangle, DatabaseBackup } from "lucide-react";
|
|
import { api } from "../../lib/api";
|
|
import type { BackupState } from "../../lib/api";
|
|
import { formatBytes, formatDateTime } from "../../lib/format";
|
|
import { toast } from "../../lib/toast";
|
|
import { Select } from "../Select";
|
|
import { SettingsSection } from "./SettingsRow";
|
|
|
|
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
|
|
export function BackupSettings() {
|
|
const { t } = useTranslation("settings");
|
|
const [state, setState] = useState<BackupState | null>(null);
|
|
const [dir, setDir] = useState("");
|
|
const [hour, setHour] = useState(2);
|
|
const [keep, setKeep] = useState("7");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const apply = (s: BackupState) => {
|
|
setState(s);
|
|
setDir(s.dir);
|
|
setHour(s.hour);
|
|
setKeep(String(s.keep));
|
|
};
|
|
|
|
useEffect(() => {
|
|
api.settings.backup
|
|
.get()
|
|
.then(apply)
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
// A backup takes tens of seconds on a large database: poll until it ends.
|
|
const running = !!state?.running;
|
|
useEffect(() => {
|
|
if (!running) return;
|
|
const id = window.setInterval(() => {
|
|
api.settings.backup
|
|
.get()
|
|
.then((s) => {
|
|
setState(s); // the form keeps its drafts
|
|
if (s.running || !s.last) return;
|
|
if (s.last.ok) toast.success(t("backup.done", { file: s.last.file }));
|
|
else toast.error(t("backup.failed", { error: s.last.error }));
|
|
})
|
|
.catch(() => undefined);
|
|
}, 2000);
|
|
return () => window.clearInterval(id);
|
|
}, [running, t]);
|
|
|
|
const keepN = Number(keep);
|
|
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
|
|
const patch: { dir?: string; hour?: number; keep?: number } = {};
|
|
if (state) {
|
|
if (dir.trim() !== state.dir) patch.dir = dir.trim();
|
|
if (hour !== state.hour) patch.hour = hour;
|
|
if (keepN !== state.keep) patch.keep = keepN;
|
|
}
|
|
const changed = Object.keys(patch).length > 0;
|
|
|
|
async function save() {
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
apply(await api.settings.backup.save(patch));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function runNow() {
|
|
try {
|
|
setState(await api.settings.backup.run());
|
|
} catch (err) {
|
|
toast.error(err);
|
|
}
|
|
}
|
|
|
|
const owned = state?.env_owned;
|
|
const newest = state?.files[0];
|
|
const total = state?.files.reduce((n, f) => n + f.size, 0) ?? 0;
|
|
|
|
return (
|
|
<SettingsSection
|
|
id="backup"
|
|
title={
|
|
<span className="flex items-center gap-2">
|
|
<DatabaseBackup className="w-4 h-4 text-fg-muted" />
|
|
{t("backup.title")}
|
|
</span>
|
|
}
|
|
description={t("backup.description")}
|
|
>
|
|
<div className="py-3 space-y-3">
|
|
<label className="block text-xs text-fg-muted">
|
|
{t("backup.folder")}
|
|
<input
|
|
className="input mt-1 w-full font-mono text-xs"
|
|
placeholder="/mnt/hdd/agenthub-backups"
|
|
value={dir}
|
|
disabled={!state || owned?.dir}
|
|
onChange={(e) => {
|
|
setDir(e.target.value);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
</label>
|
|
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div className="w-28">
|
|
<Select
|
|
value={String(hour)}
|
|
onChange={(v) => setHour(Number(v))}
|
|
label={t("backup.hour")}
|
|
disabled={!state || owned?.hour}
|
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
|
/>
|
|
</div>
|
|
<label className="text-xs text-fg-muted">
|
|
{t("backup.keep")}
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
step={1}
|
|
className="input mt-1 block w-24 text-sm"
|
|
value={keep}
|
|
disabled={!state || owned?.keep}
|
|
onChange={(e) => setKeep(e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={saving || !changed || !keepValid}
|
|
onClick={() => void save()}
|
|
>
|
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={!state?.dir || running || changed}
|
|
onClick={() => void runNow()}
|
|
>
|
|
<DatabaseBackup className={`w-4 h-4 ${running ? "animate-pulse" : ""}`} />{" "}
|
|
{running ? t("backup.running") : t("backup.runNow")}
|
|
</button>
|
|
</div>
|
|
{error && <p className="text-xs text-status-danger">{error}</p>}
|
|
<p className="text-xs text-fg-secondary">
|
|
{newest
|
|
? t("backup.newest", {
|
|
time: formatDateTime(newest.mtime),
|
|
size: formatBytes(newest.size),
|
|
n: state?.files.length,
|
|
total: formatBytes(total),
|
|
})
|
|
: state?.dir
|
|
? t("backup.none")
|
|
: t("backup.off")}
|
|
</p>
|
|
{state?.last && !state.last.ok && (
|
|
<p className="text-xs text-status-danger">
|
|
{t("backup.lastFailed", {
|
|
time: formatDateTime(state.last.at),
|
|
error: state.last.error,
|
|
})}
|
|
</p>
|
|
)}
|
|
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
|
|
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
|
|
{(owned?.dir || owned?.hour || owned?.keep) && (
|
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
|
{t("privacy.envOwned")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
}
|