mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
Add dashboard backup upload and download
This commit is contained in:
parent
8fe800ee1a
commit
476875acb9
5 changed files with 524 additions and 38 deletions
|
|
@ -338,6 +338,7 @@ fi
|
|||
# shell isn't a second interpreter — defends against $HERMES_HOME values
|
||||
# containing shell metacharacters. PR #30136 review item O2.
|
||||
as_hermes mkdir -p \
|
||||
"$HERMES_HOME/backups" \
|
||||
"$HERMES_HOME/cron" \
|
||||
"$HERMES_HOME/sessions" \
|
||||
"$HERMES_HOME/logs" \
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import threading
|
|||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
|
||||
from hermes_cli._subprocess_compat import windows_detach_flags, windows_hide_flags
|
||||
import urllib.request
|
||||
|
|
@ -9631,17 +9632,63 @@ class BackupRequest(BaseModel):
|
|||
output: Optional[str] = None
|
||||
|
||||
|
||||
def _dashboard_backup_dir() -> Path:
|
||||
return get_hermes_home() / "backups"
|
||||
|
||||
|
||||
def _new_dashboard_backup_path() -> Path:
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
return _dashboard_backup_dir() / f"hermes-backup-{stamp}-{secrets.token_hex(4)}.zip"
|
||||
|
||||
|
||||
@app.post("/api/ops/backup")
|
||||
async def run_backup(body: BackupRequest):
|
||||
args = ["backup"]
|
||||
archive: Optional[Path] = None
|
||||
if body.output:
|
||||
args.append(body.output.strip())
|
||||
else:
|
||||
archive = _new_dashboard_backup_path()
|
||||
try:
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Could not create backup directory: {exc}",
|
||||
)
|
||||
args.append(str(archive))
|
||||
try:
|
||||
proc = _spawn_hermes_action(args, "backup")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn backup")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run backup: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "backup"}
|
||||
response = {"ok": True, "pid": proc.pid, "name": "backup"}
|
||||
if archive is not None:
|
||||
response["archive"] = str(archive)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/ops/backup/download")
|
||||
async def download_dashboard_backup(archive: str):
|
||||
try:
|
||||
backup_dir = _dashboard_backup_dir().expanduser().resolve(strict=False)
|
||||
target = Path(archive).expanduser().resolve(strict=True)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
except (OSError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid backup path")
|
||||
|
||||
if not _path_is_under(backup_dir, target):
|
||||
raise HTTPException(status_code=403, detail="Backup is outside the dashboard backup directory")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
|
||||
return FileResponse(
|
||||
path=str(target),
|
||||
media_type="application/zip",
|
||||
filename=target.name,
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
|
|
@ -9674,6 +9721,94 @@ async def run_import(body: ImportRequest):
|
|||
return {"ok": True, "pid": proc.pid, "name": "import"}
|
||||
|
||||
|
||||
def _safe_backup_upload_name(filename: str | None) -> str:
|
||||
name = Path(filename or "backup.zip").name.strip()
|
||||
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-")
|
||||
if not name:
|
||||
name = "backup.zip"
|
||||
if not name.lower().endswith(".zip"):
|
||||
name = f"{name}.zip"
|
||||
return name
|
||||
|
||||
|
||||
@app.post("/api/ops/import-upload")
|
||||
async def run_import_upload(
|
||||
file: UploadFile = File(...),
|
||||
force: bool = Form(False),
|
||||
):
|
||||
staging_dir = _dashboard_backup_dir()
|
||||
try:
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Could not create import staging directory: {exc}",
|
||||
)
|
||||
|
||||
safe_name = _safe_backup_upload_name(file.filename)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
target = staging_dir / f"dashboard-import-{stamp}-{secrets.token_hex(4)}-{safe_name}"
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{target.name}.",
|
||||
suffix=".upload",
|
||||
dir=str(staging_dir),
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
renamed = False
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Archive is too large")
|
||||
out.write(chunk)
|
||||
os.replace(tmp_path, target)
|
||||
renamed = True
|
||||
except HTTPException:
|
||||
raise
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Import staging directory is not writable",
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Could not write uploaded archive: {exc}",
|
||||
)
|
||||
finally:
|
||||
if not renamed:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
await file.close()
|
||||
|
||||
if not zipfile.is_zipfile(target):
|
||||
target.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Uploaded archive is not a valid zip file",
|
||||
)
|
||||
|
||||
args = ["import", str(target)]
|
||||
if force:
|
||||
args.append("--force")
|
||||
try:
|
||||
proc = _spawn_hermes_action(args, "import")
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn import")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}")
|
||||
return {
|
||||
"ok": True,
|
||||
"pid": proc.pid,
|
||||
"name": "import",
|
||||
"archive": str(target),
|
||||
"uploaded_bytes": total,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/ops/hooks")
|
||||
async def list_hooks():
|
||||
"""List configured shell hooks from config.yaml with consent + health.
|
||||
|
|
|
|||
|
|
@ -1609,6 +1609,145 @@ class TestWebServerEndpoints:
|
|||
assert resp.status_code == 200
|
||||
assert captured["args"] == ["import", str(archive)]
|
||||
|
||||
def test_ops_backup_defaults_to_dashboard_downloadable_archive(self, monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
captured["args"] = subcommand
|
||||
captured["name"] = name
|
||||
from types import SimpleNamespace as NS
|
||||
return NS(pid=12345)
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post("/api/ops/backup", json={})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
archive = Path(data["archive"])
|
||||
|
||||
assert data["name"] == "backup"
|
||||
assert captured["name"] == "backup"
|
||||
assert captured["args"] == ["backup", str(archive)]
|
||||
assert archive.parent == get_hermes_home() / "backups"
|
||||
assert archive.name.startswith("hermes-backup-")
|
||||
assert archive.suffix == ".zip"
|
||||
|
||||
def test_ops_backup_uses_hosted_hermes_home(self, tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
hosted_home = tmp_path / "opt-data"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hosted_home))
|
||||
captured = {}
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
captured["args"] = subcommand
|
||||
captured["name"] = name
|
||||
from types import SimpleNamespace as NS
|
||||
return NS(pid=12345)
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post("/api/ops/backup", json={})
|
||||
assert resp.status_code == 200
|
||||
archive = Path(resp.json()["archive"])
|
||||
|
||||
assert archive.parent == hosted_home / "backups"
|
||||
assert captured["args"] == ["backup", str(archive)]
|
||||
assert archive.parent.is_dir()
|
||||
|
||||
def test_ops_backup_download_streams_dashboard_backup(self, tmp_path):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
backup_dir = ws._dashboard_backup_dir()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = backup_dir / "hermes-backup-test.zip"
|
||||
archive.write_bytes(b"zip bytes")
|
||||
|
||||
resp = self.client.get(
|
||||
"/api/ops/backup/download",
|
||||
params={"archive": str(archive)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"zip bytes"
|
||||
assert "attachment" in resp.headers["content-disposition"]
|
||||
|
||||
outside = tmp_path / "outside.zip"
|
||||
outside.write_bytes(b"nope")
|
||||
denied = self.client.get(
|
||||
"/api/ops/backup/download",
|
||||
params={"archive": str(outside)},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
|
||||
def test_ops_import_upload_stages_archive_and_passes_force(self, tmp_path, monkeypatch):
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
archive = tmp_path / "backup.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("config.yaml", "model: {}\n")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
captured["args"] = subcommand
|
||||
captured["name"] = name
|
||||
from types import SimpleNamespace as NS
|
||||
return NS(pid=12345)
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import-upload",
|
||||
data={"force": "true"},
|
||||
files={
|
||||
"file": (
|
||||
"my backup.zip",
|
||||
archive.read_bytes(),
|
||||
"application/zip",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "import"
|
||||
assert data["uploaded_bytes"] == archive.stat().st_size
|
||||
staged = Path(captured["args"][1])
|
||||
assert captured["name"] == "import"
|
||||
assert captured["args"] == ["import", str(staged), "--force"]
|
||||
assert staged.is_file()
|
||||
assert staged.name.startswith("dashboard-import-")
|
||||
assert staged.name.endswith("-my-backup.zip")
|
||||
assert zipfile.is_zipfile(staged)
|
||||
assert data["archive"] == str(staged)
|
||||
|
||||
def test_ops_import_upload_rejects_invalid_zip(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
def fail_spawn(*_args):
|
||||
raise AssertionError("invalid uploads must not spawn import")
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import-upload",
|
||||
data={"force": "true"},
|
||||
files={"file": ("backup.zip", b"not a zip", "application/zip")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "valid zip" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_reveal_env_var(self, tmp_path):
|
||||
"""POST /api/env/reveal should return the real unredacted value."""
|
||||
|
|
|
|||
|
|
@ -1082,12 +1082,25 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ output }),
|
||||
}),
|
||||
downloadBackup: (archive: string) =>
|
||||
authedFetch(
|
||||
`/api/ops/backup/download?archive=${encodeURIComponent(archive)}`,
|
||||
),
|
||||
runImport: (archive: string, force = false) =>
|
||||
fetchJSON<ActionResponse>("/api/ops/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ archive, force }),
|
||||
}),
|
||||
runImportUpload: (file: File, force = false) => {
|
||||
const form = new FormData();
|
||||
form.append("force", String(force));
|
||||
form.append("file", file, file.name);
|
||||
return fetchJSON<ActionResponse>("/api/ops/import-upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
|
||||
createHook: (body: HookCreate) =>
|
||||
fetchJSON<{ ok: boolean; event: string; command: string; approved: boolean }>(
|
||||
|
|
@ -1198,11 +1211,13 @@ export interface AuthMeResponse {
|
|||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
archive?: string;
|
||||
name: string;
|
||||
ok: boolean;
|
||||
pid: number | null;
|
||||
error?: string;
|
||||
message?: string;
|
||||
uploaded_bytes?: number;
|
||||
update_command?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
Stethoscope,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
|
|
@ -72,6 +73,20 @@ function formatDuration(seconds: number): string {
|
|||
return `${m}m`;
|
||||
}
|
||||
|
||||
type BackupImportTarget =
|
||||
| { kind: "upload"; file: File }
|
||||
| { kind: "path"; path: string };
|
||||
|
||||
function backupImportLabel(target: BackupImportTarget | null): string {
|
||||
if (!target) return "the archive";
|
||||
return target.kind === "upload" ? target.file.name : target.path;
|
||||
}
|
||||
|
||||
function backupFileName(path: string | null): string {
|
||||
if (!path) return "No backup created yet";
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live action-log viewer for the spawn-based admin actions (doctor, audit,
|
||||
* backup, import, skills update, checkpoints prune, gateway start/stop).
|
||||
|
|
@ -80,17 +95,21 @@ function formatDuration(seconds: number): string {
|
|||
function ActionLogViewer({
|
||||
action,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: {
|
||||
action: string;
|
||||
onClose: () => void;
|
||||
onComplete?: (action: string, exitCode: number | null) => void;
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [running, setRunning] = useState(true);
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const completeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
completeRef.current = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const st = await api.getActionStatus(action, 400);
|
||||
|
|
@ -98,6 +117,10 @@ function ActionLogViewer({
|
|||
setLines(st.lines);
|
||||
setRunning(st.running);
|
||||
setExitCode(st.exit_code);
|
||||
if (!st.running && !completeRef.current) {
|
||||
completeRef.current = true;
|
||||
onComplete?.(action, st.exit_code);
|
||||
}
|
||||
if (st.running) timer.current = setTimeout(poll, 1200);
|
||||
} catch {
|
||||
if (!cancelled) setRunning(false);
|
||||
|
|
@ -108,7 +131,7 @@ function ActionLogViewer({
|
|||
cancelled = true;
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, [action]);
|
||||
}, [action, onComplete]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -169,12 +192,23 @@ export default function SystemPage() {
|
|||
const [credLabel, setCredLabel] = useState("");
|
||||
const [addingCred, setAddingCred] = useState(false);
|
||||
|
||||
const [pendingBackupArchive, setPendingBackupArchive] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [downloadableBackupArchive, setDownloadableBackupArchive] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [downloadingBackup, setDownloadingBackup] = useState(false);
|
||||
const importUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importPath, setImportPath] = useState("");
|
||||
// Restore-from-backup is destructive (overwrites the live config) and the
|
||||
// spawned `hermes import` runs non-interactively (stdin is /dev/null), so
|
||||
// its CLI "Continue? [y/N]" prompt would auto-abort. The dashboard owns the
|
||||
// consent: confirm here, then call the endpoint with force=true.
|
||||
const [importConfirmOpen, setImportConfirmOpen] = useState(false);
|
||||
const [importingBackup, setImportingBackup] = useState(false);
|
||||
const [importConfirmTarget, setImportConfirmTarget] =
|
||||
useState<BackupImportTarget | null>(null);
|
||||
|
||||
// Create-hook modal.
|
||||
const [hookModalOpen, setHookModalOpen] = useState(false);
|
||||
|
|
@ -335,6 +369,77 @@ export default function SystemPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const runDashboardBackup = async () => {
|
||||
try {
|
||||
const res = await api.runBackup();
|
||||
setActiveAction(res.name);
|
||||
setPendingBackupArchive(res.archive ?? null);
|
||||
setDownloadableBackupArchive(null);
|
||||
showToast("Backup started", "success");
|
||||
} catch (e) {
|
||||
showToast(`Backup failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionComplete = useCallback(
|
||||
(action: string, exitCode: number | null) => {
|
||||
if (action === "backup" && pendingBackupArchive) {
|
||||
if (exitCode === 0) {
|
||||
setDownloadableBackupArchive(pendingBackupArchive);
|
||||
showToast("Backup ready to download", "success");
|
||||
} else {
|
||||
setPendingBackupArchive(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[pendingBackupArchive, showToast],
|
||||
);
|
||||
|
||||
const downloadBackup = async () => {
|
||||
const archive = downloadableBackupArchive;
|
||||
if (!archive) return;
|
||||
setDownloadingBackup(true);
|
||||
try {
|
||||
const res = await api.downloadBackup(archive);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = backupFileName(archive);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
showToast(`Download failed: ${e}`, "error");
|
||||
} finally {
|
||||
setDownloadingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearImportFile = () => {
|
||||
setImportFile(null);
|
||||
if (importUploadInputRef.current) importUploadInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const runBackupImport = async (target: BackupImportTarget) => {
|
||||
setImportingBackup(true);
|
||||
try {
|
||||
const res =
|
||||
target.kind === "upload"
|
||||
? await api.runImportUpload(target.file, true)
|
||||
: await api.runImport(target.path, true);
|
||||
setActiveAction(res.name);
|
||||
showToast("Import started", "success");
|
||||
if (target.kind === "upload") clearImportFile();
|
||||
} catch (e) {
|
||||
showToast(`Import failed: ${e}`, "error");
|
||||
} finally {
|
||||
setImportingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Debug share ────────────────────────────────────────────────────
|
||||
// Unlike the fire-and-forget ops above, `debug share` produces shareable
|
||||
// paste URLs that are the whole point — so we surface them as real,
|
||||
|
|
@ -519,6 +624,15 @@ export default function SystemPage() {
|
|||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Toast toast={toast} />
|
||||
<input
|
||||
ref={importUploadInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
setImportFile(event.currentTarget.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={canUpdateHermes && updateConfirmOpen}
|
||||
|
|
@ -668,6 +782,7 @@ export default function SystemPage() {
|
|||
{activeAction && (
|
||||
<ActionLogViewer
|
||||
action={activeAction}
|
||||
onComplete={handleActionComplete}
|
||||
onClose={() => setActiveAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1046,9 +1161,6 @@ export default function SystemPage() {
|
|||
<Button size="sm" ghost prefix={<ShieldCheck className="h-3.5 w-3.5" />} onClick={() => runOp(api.runSecurityAudit, "Security audit")}>
|
||||
Security audit
|
||||
</Button>
|
||||
<Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(() => api.runBackup(), "Backup")}>
|
||||
Create backup
|
||||
</Button>
|
||||
<Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}>
|
||||
Update skills
|
||||
</Button>
|
||||
|
|
@ -1064,6 +1176,122 @@ export default function SystemPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="grid min-w-0 flex-1 gap-2">
|
||||
<Label>Full backup</Label>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
prefix={<Database className="h-3.5 w-3.5" />}
|
||||
onClick={() => void runDashboardBackup()}
|
||||
>
|
||||
Create backup
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
disabled={!downloadableBackupArchive || downloadingBackup}
|
||||
prefix={
|
||||
downloadingBackup ? (
|
||||
<Spinner className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
onClick={() => void downloadBackup()}
|
||||
>
|
||||
Download backup
|
||||
</Button>
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-muted-foreground"
|
||||
title={pendingBackupArchive ?? "No backup created yet"}
|
||||
>
|
||||
{backupFileName(pendingBackupArchive)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-end">
|
||||
<div className="grid min-w-0 flex-1 gap-2">
|
||||
<Label>Restore from backup upload</Label>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
ghost
|
||||
disabled={importingBackup}
|
||||
prefix={<Upload className="h-3.5 w-3.5" />}
|
||||
onClick={() => importUploadInputRef.current?.click()}
|
||||
>
|
||||
Choose restore zip
|
||||
</Button>
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-muted-foreground"
|
||||
title={importFile?.name ?? "No backup archive selected"}
|
||||
>
|
||||
{importFile?.name ?? "No backup archive selected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
disabled={!importFile || importingBackup}
|
||||
prefix={importingBackup ? <Spinner /> : undefined}
|
||||
onClick={() => {
|
||||
if (!importFile) return;
|
||||
setImportConfirmTarget({ kind: "upload", file: importFile });
|
||||
}}
|
||||
>
|
||||
Restore upload
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-end">
|
||||
<div className="grid min-w-0 flex-1 gap-2">
|
||||
<Label htmlFor="import-path">Restore from backups path</Label>
|
||||
<Input
|
||||
id="import-path"
|
||||
value={importPath}
|
||||
onChange={(e) => setImportPath(e.target.value)}
|
||||
placeholder="$HERMES_HOME/backups/hermes-backup.zip"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
disabled={!importPath.trim() || importingBackup}
|
||||
prefix={importingBackup ? <Spinner /> : undefined}
|
||||
onClick={() => {
|
||||
const path = importPath.trim();
|
||||
if (!path) return;
|
||||
setImportConfirmTarget({ kind: "path", path });
|
||||
}}
|
||||
>
|
||||
Restore path
|
||||
</Button>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={!!importConfirmTarget}
|
||||
title="Restore full Hermes backup?"
|
||||
description={`This will overwrite your current Hermes configuration, skills, sessions, and data with the contents of ${backupImportLabel(importConfirmTarget)}. This cannot be undone.`}
|
||||
destructive
|
||||
confirmLabel="Restore"
|
||||
cancelLabel="Cancel"
|
||||
onCancel={() => setImportConfirmTarget(null)}
|
||||
onConfirm={() => {
|
||||
const target = importConfirmTarget;
|
||||
setImportConfirmTarget(null);
|
||||
if (target) void runBackupImport(target);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Debug share — uploads a redacted report + logs, returns shareable
|
||||
links. Separated from the buttons above because its output is
|
||||
persistent, copyable URLs, not a fire-and-forget log tail. */}
|
||||
|
|
@ -1186,38 +1414,6 @@ export default function SystemPage() {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-end">
|
||||
<div className="grid gap-2 flex-1">
|
||||
<Label htmlFor="import-path">Restore from backup archive</Label>
|
||||
<Input id="import-path" value={importPath} onChange={(e) => setImportPath(e.target.value)} placeholder="/path/to/hermes-backup.zip" />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
disabled={!importPath.trim()}
|
||||
onClick={() => {
|
||||
if (!importPath.trim()) return;
|
||||
setImportConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={importConfirmOpen}
|
||||
title="Restore from backup?"
|
||||
description={`This will overwrite your current Hermes configuration, skills, sessions, and data with the contents of ${importPath.trim() || "the archive"}. This cannot be undone.`}
|
||||
destructive
|
||||
confirmLabel="Restore"
|
||||
cancelLabel="Cancel"
|
||||
onCancel={() => setImportConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
setImportConfirmOpen(false);
|
||||
runOp(() => api.runImport(importPath.trim(), true), "Import");
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Checkpoints ───────────────────────────────────────────── */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue