feat: enhance backups page with live data loading and backup job management
Implemented live data fetching for servers and backups on the backups page, replacing static initial data. Added functionality for manual backup creation and job status tracking, including error handling and UI updates. Updated the network map layout to improve node prioritization and visual representation of server roles. Also, registered new backups API routes in the backend for improved data handling.
This commit is contained in:
+162
-38
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { backups as initialBackups, servers } from "@/lib/data"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import type { Backup, Server } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -18,6 +17,10 @@ import {
|
||||
FolderIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
|
||||
|
||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -102,10 +105,16 @@ const defaultStorage = {
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function BackupsPage() {
|
||||
const { backendUrl } = useDataSource()
|
||||
// ── state ──────────────────────────────────────────────────────────────
|
||||
const [tab, setTab] = useState<PageTab>("history")
|
||||
const [backupList, setBackupList] = useState<Backup[]>(initialBackups)
|
||||
const [backupList, setBackupList] = useState<Backup[]>([])
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [opBusy, setOpBusy] = useState(false)
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
||||
|
||||
// Schedule
|
||||
const [schedule, setSchedule] = useState(defaultSchedule)
|
||||
@@ -119,7 +128,7 @@ export default function BackupsPage() {
|
||||
|
||||
// Server selection (all enabled by default)
|
||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(
|
||||
new Set(servers.map((s) => s.id))
|
||||
new Set()
|
||||
)
|
||||
function toggleServer(id: string) {
|
||||
setSelectedServers((prev) => {
|
||||
@@ -147,31 +156,118 @@ export default function BackupsPage() {
|
||||
return next
|
||||
})
|
||||
}
|
||||
function handleManualBackup() {
|
||||
const now = new Date()
|
||||
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}_${String(now.getHours()).padStart(2,"0")}-${String(now.getMinutes()).padStart(2,"0")}`
|
||||
const newBackups: Backup[] = Array.from(manualServers).map((sid, i) => {
|
||||
const sv = servers.find((s) => s.id === sid)!
|
||||
return {
|
||||
id: `b${Date.now()}${i}`,
|
||||
server: sv.name,
|
||||
filename: `${sv.name}_${ts}_manual.rsc`,
|
||||
size: `${Math.floor(60 + Math.random()*80)} КБ`,
|
||||
created: "Только что",
|
||||
kind: "manual",
|
||||
notes: manualNotes || "",
|
||||
}
|
||||
})
|
||||
setBackupList((list) => [...newBackups, ...list])
|
||||
setManualOpen(false)
|
||||
setManualServers(new Set())
|
||||
setManualNotes("")
|
||||
setTab("history")
|
||||
const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
|
||||
const kb = Math.max(1, Math.round(b.sizeBytes / 1024))
|
||||
return {
|
||||
id: b.id,
|
||||
server: b.serverName,
|
||||
filename: b.filename,
|
||||
size: `${kb} КБ`,
|
||||
created: new Date(b.createdAt).toLocaleString("ru-RU"),
|
||||
kind: b.kind,
|
||||
notes: b.notes ?? "",
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setOpError(null)
|
||||
try {
|
||||
const [serversRows, backupsRows] = await Promise.all([
|
||||
listServers(backendUrl),
|
||||
listBackups(backendUrl),
|
||||
])
|
||||
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
||||
setLiveServers(mappedServers)
|
||||
setBackupList(backupsRows.map(mapApiBackupToUi))
|
||||
setSelectedServers(new Set(mappedServers.map((s) => s.id)))
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [backendUrl, mapApiBackupToUi])
|
||||
|
||||
useEffect(() => {
|
||||
void loadLive()
|
||||
}, [loadLive])
|
||||
|
||||
async function handleManualBackup() {
|
||||
if (manualServers.size === 0 || opBusy) return
|
||||
setOpError(null)
|
||||
try {
|
||||
const res = await createBackupsAsync(backendUrl, {
|
||||
serverIds: [...manualServers],
|
||||
notes: manualNotes || undefined,
|
||||
})
|
||||
setBackupJobId(res.jobId)
|
||||
setManualOpen(false)
|
||||
setManualServers(new Set())
|
||||
setManualNotes("")
|
||||
setTab("history")
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!backupJobId) return
|
||||
let cancelled = false
|
||||
const timer = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const job = await getBackupJob(backendUrl, backupJobId)
|
||||
if (cancelled) return
|
||||
if (job.status === "done") {
|
||||
clearInterval(timer)
|
||||
setBackupJobId(null)
|
||||
if (job.failures.length > 0) {
|
||||
setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`)
|
||||
}
|
||||
await loadLive()
|
||||
} else if (job.status === "failed") {
|
||||
clearInterval(timer)
|
||||
setBackupJobId(null)
|
||||
setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа")
|
||||
await loadLive()
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(timer)
|
||||
setBackupJobId(null)
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа")
|
||||
}
|
||||
})()
|
||||
}, 1200)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [backendUrl, backupJobId, loadLive])
|
||||
|
||||
// Delete backup
|
||||
function handleDelete(id: string) {
|
||||
setBackupList((list) => list.filter((b) => b.id !== id))
|
||||
async function handleDelete(id: string) {
|
||||
setOpBusy(true)
|
||||
setOpError(null)
|
||||
try {
|
||||
await deleteBackup(backendUrl, id)
|
||||
await loadLive()
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа")
|
||||
} finally {
|
||||
setOpBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(id: string, fallbackFilename: string) {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
|
||||
if (!res.ok) throw new Error("Не удалось скачать файл")
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = fallbackFilename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── derived ────────────────────────────────────────────────────────────
|
||||
@@ -205,10 +301,22 @@ export default function BackupsPage() {
|
||||
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => { setManualServers(new Set(servers.map(s=>s.id))); setManualOpen(true) }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setManualServers(new Set(liveServers.map((s) => s.id)))
|
||||
setManualOpen(true)
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />Снять со всех
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
|
||||
disabled={loading}
|
||||
>
|
||||
<PlusIcon className="size-4" />Новый бэкап
|
||||
</Button>
|
||||
</>
|
||||
@@ -252,13 +360,23 @@ export default function BackupsPage() {
|
||||
<span className="h-3 w-px bg-border" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ServerIcon className="size-3" />
|
||||
{selectedServers.size} из {servers.length} серверов
|
||||
{selectedServers.size} из {liveServers.length} серверов
|
||||
</span>
|
||||
<button onClick={() => setTab("settings")}
|
||||
className="ml-auto text-xs text-primary hover:underline">
|
||||
Изменить настройки →
|
||||
</button>
|
||||
</div>
|
||||
{opError && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
||||
{opError}
|
||||
</div>
|
||||
)}
|
||||
{backupJobId && (
|
||||
<div className="rounded-md border border-blue-500/30 bg-blue-500/10 px-3 py-2 text-sm text-blue-300">
|
||||
Бэкап выполняется в фоне...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
@@ -328,14 +446,20 @@ export default function BackupsPage() {
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="size-7" title="Скачать">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title="Скачать"
|
||||
onClick={() => void handleDownload(b.id, b.filename)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
|
||||
title="Удалить" onClick={() => handleDelete(b.id)}>
|
||||
title="Удалить" onClick={() => void handleDelete(b.id)}>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -548,7 +672,7 @@ export default function BackupsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onClick={() => setSelectedServers(new Set(servers.map(s => s.id)))}
|
||||
<button type="button" onClick={() => setSelectedServers(new Set(liveServers.map(s => s.id)))}
|
||||
className="text-xs text-primary hover:underline">Выбрать все</button>
|
||||
<span className="text-border">·</span>
|
||||
<button type="button" onClick={() => setSelectedServers(new Set())}
|
||||
@@ -557,7 +681,7 @@ export default function BackupsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||
{servers.map((s) => {
|
||||
{liveServers.map((s) => {
|
||||
const checked = selectedServers.has(s.id)
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => toggleServer(s.id)}
|
||||
@@ -586,7 +710,7 @@ export default function BackupsPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выбрано {selectedServers.size} из {servers.length} серверов
|
||||
Выбрано {selectedServers.size} из {liveServers.length} серверов
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -622,14 +746,14 @@ export default function BackupsPage() {
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-sm font-medium">Выберите серверы</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => setManualServers(new Set(servers.map(s=>s.id)))}
|
||||
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
||||
className="text-xs text-primary hover:underline">Все</button>
|
||||
<span className="text-border">·</span>
|
||||
<button type="button" onClick={() => setManualServers(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
{servers.map((s) => {
|
||||
{liveServers.map((s) => {
|
||||
const checked = manualServers.has(s.id)
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
||||
@@ -668,7 +792,7 @@ export default function BackupsPage() {
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
<Button className="flex-1"
|
||||
disabled={manualServers.size === 0}
|
||||
disabled={manualServers.size === 0 || backupJobId !== null}
|
||||
onClick={handleManualBackup}>
|
||||
Снять бэкап ({manualServers.size})
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user