Add backup and restore functionality
- Add `GET /api/backup` endpoint to stream `data` directory as a tar file. - Add `POST /api/restore` endpoint to handle tar file upload and restore. - Implement `BackupRestoreDialog` in the UI for user interaction. - Integrate backup/restore dialog into the sidebar via the logo. - Ensure safe restore with rollback on failure and database connection management.
This commit is contained in:
1 parent
92742b1272
commit
7381400d79
5 files changed
+264
-29
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get('/', async () => {
|
||||
// Stream the content of the data directory as a tar file
|
||||
const proc = Bun.spawn(['tar', '-cf', '-', '-C', 'data', '.'])
|
||||
|
||||
return new Response(proc.stdout, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-tar',
|
||||
'Content-Disposition': 'attachment; filename="backup.tar"',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Hono } from 'hono'
|
||||
import { existsSync, renameSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { prisma } from '../db'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.post('/', async (c) => {
|
||||
// Disconnect prisma before starting restore
|
||||
await prisma.$disconnect()
|
||||
|
||||
const backupDir = `data_backup_${Date.now()}`
|
||||
|
||||
try {
|
||||
// Move current data directory to backup location
|
||||
if (existsSync('data')) {
|
||||
renameSync('data', backupDir)
|
||||
}
|
||||
|
||||
// Create new data directory
|
||||
mkdirSync('data')
|
||||
|
||||
// Extract the uploaded tar stream to the new data directory
|
||||
// We expect the body to be the tar file content directly
|
||||
const proc = Bun.spawn(['tar', '-xf', '-', '-C', 'data'], {
|
||||
stdin: c.req.raw.body,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
throw new Error(`Tar extraction failed with code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
|
||||
// Remove the backup directory
|
||||
rmSync(backupDir, { recursive: true, force: true })
|
||||
|
||||
// Reconnect prisma
|
||||
await prisma.$connect()
|
||||
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Restore failed:', error)
|
||||
|
||||
// Attempt to restore from backup
|
||||
try {
|
||||
if (existsSync('data')) {
|
||||
rmSync('data', { recursive: true, force: true })
|
||||
}
|
||||
if (existsSync(backupDir)) {
|
||||
renameSync(backupDir, 'data')
|
||||
}
|
||||
} catch (restoreError) {
|
||||
console.error('Critical: Failed to restore backup after error:', restoreError)
|
||||
}
|
||||
|
||||
// Reconnect prisma even if restore failed
|
||||
try {
|
||||
await prisma.$connect()
|
||||
} catch (connectError) {
|
||||
console.error('Failed to reconnect prisma:', connectError)
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
500,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -4,6 +4,7 @@ import { serveStatic } from 'hono/bun'
|
||||
import { cors } from 'hono/cors'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import asset from './api/asset'
|
||||
import backup from './api/backup'
|
||||
import character from './api/character'
|
||||
import equipment from './api/equipment'
|
||||
import extract from './api/extract'
|
||||
@@ -11,6 +12,7 @@ import expression from './api/expression'
|
||||
import generation from './api/generation'
|
||||
import outfit from './api/outfit'
|
||||
import pose from './api/pose'
|
||||
import restore from './api/restore'
|
||||
import setting from './api/setting'
|
||||
import index from './ui/index.html'
|
||||
|
||||
@@ -28,12 +30,14 @@ app.use('/*', cors())
|
||||
const api = new Hono()
|
||||
const route = api
|
||||
.route('/assets', asset)
|
||||
.route('/backup', backup)
|
||||
.route('/characters', character)
|
||||
.route('/equipments', equipment)
|
||||
.route('/generations', generation)
|
||||
.route('/extract', extract)
|
||||
.route('/outfits', outfit)
|
||||
.route('/poses', pose)
|
||||
.route('/restore', restore)
|
||||
.route('/expressions', expression)
|
||||
.route('/settings', setting)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Backpack, Frame, Settings, Smile, Users } from 'lucide-react'
|
||||
import logo from 'public/logo.webp'
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { BackupRestoreDialog } from './BackupRestoreDialog'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -43,36 +45,46 @@ const items = [
|
||||
|
||||
export function AppSidebar() {
|
||||
const location = useLocation()
|
||||
const [isBackupOpen, setIsBackupOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title} className="mb-3">
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={location.pathname === item.url}
|
||||
className="flex h-auto flex-col items-center gap-1 py-3"
|
||||
>
|
||||
<Link to={item.url} className="flex flex-col items-center justify-center">
|
||||
<item.icon className="!h-6 !w-6 mb-1" />
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<div className="w-full flex items-center justify-center mb-3">
|
||||
<img src={logo} alt="Logo" className="w-14 h-14 rotate-10" />
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title} className="mb-3">
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={location.pathname === item.url}
|
||||
className="flex h-auto flex-col items-center gap-1 py-3"
|
||||
>
|
||||
<Link to={item.url} className="flex flex-col items-center justify-center">
|
||||
<item.icon className="!h-6 !w-6 mb-1" />
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<div className="w-full flex items-center justify-center mb-3">
|
||||
<button
|
||||
onClick={() => setIsBackupOpen(true)}
|
||||
className="hover:scale-110 transition-transform cursor-pointer focus:outline-none"
|
||||
title="Backup & Restore"
|
||||
>
|
||||
<img src={logo} alt="Logo" className="w-14 h-14 rotate-10" />
|
||||
</button>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<BackupRestoreDialog open={isBackupOpen} onOpenChange={setIsBackupOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Archive, Download, RefreshCw, Upload } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog'
|
||||
import { Separator } from './ui/separator'
|
||||
|
||||
interface BackupRestoreDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogProps) {
|
||||
const [isRestoring, setIsRestoring] = useState(false)
|
||||
|
||||
const handleDownloadBackup = () => {
|
||||
window.location.href = '/api/backup'
|
||||
}
|
||||
|
||||
const handleRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!confirm('Warning: This will overwrite all existing data. Are you sure you want to proceed?')) {
|
||||
event.target.value = '' // Reset input
|
||||
return
|
||||
}
|
||||
|
||||
setIsRestoring(true)
|
||||
const toastId = toast.loading('Restoring backup...')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/restore', {
|
||||
method: 'POST',
|
||||
body: file,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Restore failed')
|
||||
}
|
||||
|
||||
toast.success('Restore successful! Please refresh the page.', { id: toastId })
|
||||
onOpenChange(false)
|
||||
// Optionally reload the page to refresh data
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('Failed to restore backup.', { id: toastId })
|
||||
} finally {
|
||||
setIsRestoring(false)
|
||||
event.target.value = '' // Reset input
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup & Restore</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage your data backup and restoration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-4">
|
||||
{/* Backup Section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Archive className="h-4 w-4" />
|
||||
<span>Backup</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download a complete backup of your data, including the database and all files.
|
||||
</p>
|
||||
<Button onClick={handleDownloadBackup} variant="outline" className="w-full">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Backup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Restore Section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 font-medium text-destructive">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>Restore</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Restore data from a backup file. <span className="font-bold text-destructive">Warning: This will overwrite all current data.</span>
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar"
|
||||
onChange={handleRestore}
|
||||
disabled={isRestoring}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{isRestoring ? (
|
||||
<>Restoring...</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Select Backup File to Restore
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user