import { useEffect, useRef, useState } from 'react' import { Alert, Button, Card, Col, Popconfirm, Row, Space, Statistic, Table, Tag, Tooltip, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { CloudDownloadOutlined, CloudUploadOutlined, DeleteOutlined, ReloadOutlined, RocketOutlined, UndoOutlined, } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import dayjs from 'dayjs' import apiClient, { isEnvelope } from '../../api/client' import { useAuthStore } from '../../stores/auth' const { Text } = Typography interface Backup { id: number file: string size_bytes: number sha256: string db_dump_bytes: number files_bytes: number kind: 'manual' | 'scheduled' status: 'success' | 'failed' error?: string host?: string started_at: string finished_at: string } function fmtSize(n: number): string { if (n < 1024) return n + ' B' if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB' if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB' return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB' } function fmtDuration(start: string, end: string): string { const ms = new Date(end).getTime() - new Date(start).getTime() if (ms < 1000) return ms + ' ms' if (ms < 60_000) return (ms / 1000).toFixed(1) + ' s' return (ms / 60_000).toFixed(1) + ' min' } export default function HistoryTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const [msg, msgCtx] = message.useMessage() const list = useQuery({ queryKey: ['backups'], queryFn: async () => { const r = await apiClient.get('/backups') return isEnvelope(r.data) ? (r.data.data as { backups: Backup[] }).backups : [] }, refetchInterval: 30_000, }) const trigger = useMutation({ mutationFn: async () => { const r = await apiClient.post('/backups') return isEnvelope(r.data) ? r.data.data : r.data }, onSuccess: (res: { file?: string }) => { msg.success(t('backups.created', { file: res?.file ?? '?' })) qc.invalidateQueries({ queryKey: ['backups'] }) }, onError: (e: Error) => msg.error(t('backups.failed') + ': ' + e.message), }) const [busyDelete, setBusyDelete] = useState(null) const del = useMutation({ mutationFn: async (id: number) => { setBusyDelete(id) try { await apiClient.delete(`/backups/${id}`) } finally { setBusyDelete(null) } }, onSuccess: () => { msg.success(t('backups.deleted')) qc.invalidateQueries({ queryKey: ['backups'] }) }, onError: (e: Error) => msg.error(e.message), }) // Restore-Modal-State: nach Klick aufs Restore zeigen wir ein // Vollbild-Overlay mit Step-Indicator + Health-Poll (analog Update). const [restoring, setRestoring] = useState<{ file: string } | null>(null) const [restoreElapsed, setRestoreElapsed] = useState(0) const tickRef = useRef | null>(null) const pollRef = useRef | null>(null) useEffect(() => () => { if (tickRef.current) clearInterval(tickRef.current) if (pollRef.current) clearInterval(pollRef.current) }, []) const restore = useMutation({ mutationFn: async (id: number) => { const r = await apiClient.post(`/backups/${id}/restore`) return isEnvelope(r.data) ? r.data.data : r.data }, onError: (e: Error) => { setRestoring(null) if (tickRef.current) clearInterval(tickRef.current) msg.error(t('backups.restoreFailed') + ': ' + e.message) }, }) const startRestore = (b: Backup) => { setRestoring({ file: b.file }) setRestoreElapsed(0) tickRef.current = setInterval(() => setRestoreElapsed((e) => e + 1), 1000) restore.mutate(b.id, { onSuccess: () => { // Poll /system/health bis API neu hochkommt → reload. let sawDown = false pollRef.current = setInterval(async () => { try { const res = await apiClient.get('/system/health') const v = isEnvelope(res.data) ? (res.data.data as { version: string }).version : '' if (sawDown && v) { if (pollRef.current) clearInterval(pollRef.current) if (tickRef.current) clearInterval(tickRef.current) setRestoring(null) msg.success(t('backups.restoreDone')) setTimeout(() => window.location.reload(), 1500) } } catch { sawDown = true } }, 3000) // Safety-Timeout 3 min — Restore kann bei großer DB länger // dauern als Upgrade. Danach reload trotzdem. setTimeout(() => { if (pollRef.current) clearInterval(pollRef.current) if (tickRef.current) clearInterval(tickRef.current) setRestoring(null) window.location.reload() }, 180_000) }, }) } const download = (b: Backup) => { // gin.FileAttachment liefert via Browser direkt; einfach // Cookie-authentifiziert in eine versteckte Form öffnen. const url = `/api/v1/backups/${b.id}/download` const a = document.createElement('a') a.href = url a.download = b.file document.body.appendChild(a) a.click() document.body.removeChild(a) } const columns: ColumnsType = [ { title: t('backups.col.time'), dataIndex: 'started_at', width: 170, render: (v: string) => {dayjs(v).format('YYYY-MM-DD HH:mm:ss')} , }, { title: t('backups.col.file'), dataIndex: 'file', render: (v: string, row) => (
{v}
sha256: {row.sha256.slice(0, 16)}…
), }, { title: t('backups.col.kind'), dataIndex: 'kind', width: 110, render: (k: Backup['kind']) => {k}, }, { title: t('backups.col.status'), dataIndex: 'status', width: 110, render: (s: Backup['status'], row) => s === 'success' ? {t('backups.okTag')} : ( {t('backups.failedTag')} ), }, { title: t('backups.col.size'), dataIndex: 'size_bytes', width: 110, render: (n: number, row) => (
{fmtSize(n)}
DB {fmtSize(row.db_dump_bytes)} · Files {fmtSize(row.files_bytes)}
), }, { title: t('backups.col.duration'), key: 'duration', width: 90, render: (_, row) => {fmtDuration(row.started_at, row.finished_at)}, }, { title: t('common.actions'), key: 'a', width: 280, render: (_, row) => ( {isViewer ? : startRestore(row)} disabled={row.status !== 'success'} > } {isViewer ? : del.mutate(row.id)} okButtonProps={{ danger: true }} > } ), }, ] // Aggregat-Zähler. Quelle: die bereits geladene Liste — die Backup- // History wird in der UI sowieso komplett geladen (Pagination ist // clientseitig). Keine zusätzlichen Endpoint-Calls. const all = list.data ?? [] const succ = all.filter((b) => b.status === 'success') const lastSucc = succ[0] // bereits DESC sortiert vom Backend const totalSize = succ.reduce((acc, b) => acc + (b.size_bytes || 0), 0) const last24hMs = Date.now() - 86_400_000 const failsLast24h = all.filter((b) => b.status === 'failed' && new Date(b.started_at).getTime() >= last24hMs, ).length const lastSuccAge = lastSucc ? Math.round((Date.now() - new Date(lastSucc.finished_at).getTime()) / 3_600_000) // hours : null return (
{msgCtx} {all.length > 0 && ( 48 ? { color: '#d48806' } : { color: '#0F172A' } } /> 0 ? { color: '#cf1322' } : undefined} /> )} {restoring && (
{t('backups.restoreRunning')}
{restoring.file}
{t('backups.step.extract')}
= 5 && restoreElapsed < 15 ? 'update-modal__step--active' : restoreElapsed >= 15 ? 'update-modal__step--done' : ''}`}> {t('backups.step.psql')}
= 15 && restoreElapsed < 25 ? 'update-modal__step--active' : restoreElapsed >= 25 ? 'update-modal__step--done' : ''}`}> {t('backups.step.render')}
= 25 ? 'update-modal__step--active' : ''}`}> {t('backups.step.restart')}
{restoreElapsed}s
{t('backups.restoreHint')}
)}
) }