mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat: add database backup import/export (#194)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
26
src/app/api/settings/database/route.js
Normal file
26
src/app/api/settings/database/route.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user