feat: add database backup import/export (#194)

This commit is contained in:
Mai Trung Tiến
2026-02-25 15:32:38 +07:00
committed by GitHub
parent 07717bad60
commit 3cf900a0a1
3 changed files with 172 additions and 2 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Badge, Toggle, Input } from "@/shared/components";
import { useState, useEffect, useRef } from "react";
import { Card, Button, Toggle, Input } from "@/shared/components";
import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG } from "@/shared/constants/config";
@@ -13,6 +13,9 @@ export default function ProfilePage() {
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
const [passStatus, setPassStatus] = useState({ type: "", message: "" });
const [passLoading, setPassLoading] = useState(false);
const [dbLoading, setDbLoading] = useState(false);
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
const importFileRef = useRef(null);
useEffect(() => {
fetch("/api/settings")
@@ -143,6 +146,82 @@ export default function ProfilePage() {
}
};
const reloadSettings = async () => {
try {
const res = await fetch("/api/settings");
if (!res.ok) return;
const data = await res.json();
setSettings(data);
} catch (err) {
console.error("Failed to reload settings:", err);
}
};
const handleExportDatabase = async () => {
setDbLoading(true);
setDbStatus({ type: "", message: "" });
try {
const res = await fetch("/api/settings/database");
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to export database");
}
const payload = await res.json();
const content = JSON.stringify(payload, null, 2);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
const stamp = new Date().toISOString().replace(/[.:]/g, "-");
anchor.href = url;
anchor.download = `9router-backup-${stamp}.json`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
setDbStatus({ type: "success", message: "Database backup downloaded" });
} catch (err) {
setDbStatus({ type: "error", message: err.message || "Failed to export database" });
} finally {
setDbLoading(false);
}
};
const handleImportDatabase = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
setDbLoading(true);
setDbStatus({ type: "", message: "" });
try {
const raw = await file.text();
const payload = JSON.parse(raw);
const res = await fetch("/api/settings/database", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "Failed to import database");
}
await reloadSettings();
setDbStatus({ type: "success", message: "Database imported successfully" });
} catch (err) {
setDbStatus({ type: "error", message: err.message || "Invalid backup file" });
} finally {
if (importFileRef.current) {
importFileRef.current.value = "";
}
setDbLoading(false);
}
};
const observabilityEnabled = settings.observabilityEnabled !== false;
return (
@@ -363,6 +442,36 @@ export default function ProfilePage() {
<p className="text-sm text-text-muted font-mono">~/.9router/db.json</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
icon="download"
onClick={handleExportDatabase}
loading={dbLoading}
>
Download Backup
</Button>
<Button
variant="outline"
icon="upload"
onClick={() => importFileRef.current?.click()}
disabled={dbLoading}
>
Import Backup
</Button>
<input
ref={importFileRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={handleImportDatabase}
/>
</div>
{dbStatus.message && (
<p className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
{dbStatus.message}
</p>
)}
</div>
</Card>

View File

@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { exportDb, importDb } from "@/lib/localDb";
export async function GET() {
try {
const payload = await exportDb();
return NextResponse.json(payload);
} catch (error) {
console.log("Error exporting database:", error);
return NextResponse.json({ error: "Failed to export database" }, { status: 500 });
}
}
export async function POST(request) {
try {
const payload = await request.json();
await importDb(payload);
return NextResponse.json({ success: true });
} catch (error) {
console.log("Error importing database:", error);
return NextResponse.json(
{ error: error?.message || "Failed to import database" },
{ status: 400 }
);
}
}

View File

@@ -779,6 +779,41 @@ export async function updateSettings(updates) {
return db.data.settings;
}
/**
* Export full database payload
*/
export async function exportDb() {
const db = await getDb();
return db.data || cloneDefaultData();
}
/**
* Import full database payload
*/
export async function importDb(payload) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("Invalid database payload");
}
const nextData = {
...cloneDefaultData(),
...payload,
settings: {
...cloneDefaultData().settings,
...(payload.settings && typeof payload.settings === "object" && !Array.isArray(payload.settings)
? payload.settings
: {}),
},
};
const { data: normalized } = ensureDbShape(nextData);
const db = await getDb();
db.data = normalized;
await db.write();
return db.data;
}
/**
* Check if cloud is enabled
*/