feat(backup): Off-Site-Upload nach S3 + SFTP
Schutz gegen Box-Total-Loss — lokale Backups in /var/backups/edgeguard
helfen nicht, wenn die Disk stirbt oder die Box brennt. Nach jedem
erfolgreichen lokalen Backup wird die tar.gz an alle aktiven
Off-Site-Ziele hochgeladen.
Migration 0022: backup_remotes (kind=s3|sftp, target_url, settings
JSONB, active, last_upload_at, last_error) + backups.remote_uploads
JSONB (per-Target-Result).
internal/services/backup/remote/:
- UploadAll() — pro aktivem Target ein Upload, Failures non-fatal
- S3 via minio-go/v7 — funktioniert mit AWS, MinIO, Backblaze B2,
Cloudflare R2, Hetzner Object Storage (alle S3-API-kompatibel)
- SFTP via golang.org/x/crypto/ssh + pkg/sftp. Password + Private-
Key (OpenSSH, base64-encoded) als Auth. Optional host_key_
fingerprint-Pinning (SHA256:...); leer = TOFU (unsicher vs MitM,
OK für initial setup).
- Test() lädt eine 1KB-Probe + löscht sie wieder — Operator-UI hat
einen „Verbindung testen"-Button.
backup.Service.RemoteUploader-Interface: nach erfolgreichem
recordSuccess() läuft UploadAll, Results landen in backups.remote_
uploads JSONB. last_upload_at/last_error in backup_remotes pro Target
gepflegt. API + Scheduler injizieren beide den Adapter.
internal/handlers/backup_remotes.go: CRUD + POST /:id/test. Sensitive
Felder (secret_key, password, private_key) werden in GET-Responses
durch ***SET*** maskiert; UpdateChannel merged das zurück damit der
Operator bei Edit ohne Re-Eingabe speichern kann.
UI: Backups-Page jetzt mit Tabs "Sicherungen" + "Off-Site-Ziele".
Tab 2 hat CRUD-Tabelle mit kind-konditionalem Form (S3-Felder oder
SFTP-Felder), Test-Button pro Row, last_upload-Status mit FAIL-Tag
bei Errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.74'
|
||||
const VERSION = '1.0.75'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
|
||||
@@ -711,6 +711,26 @@
|
||||
"delivered": "Gesendet"
|
||||
}
|
||||
},
|
||||
"remotes": {
|
||||
"scopeTitle": "Off-Site-Backup-Ziele",
|
||||
"scopeDesc": "Nach jedem erfolgreichen lokalen Backup wird das tar.gz in alle aktiven Ziele hochgeladen. S3-Endpoints (AWS, MinIO, Backblaze B2, Cloudflare R2, Hetzner Object Storage) und SFTP/SSH. Schutz gegen Box-Total-Loss.",
|
||||
"add": "Ziel hinzufügen",
|
||||
"addTitle": "Off-Site-Ziel anlegen",
|
||||
"editTitle": "Off-Site-Ziel bearbeiten",
|
||||
"empty": "Keine Off-Site-Ziele. Lokale Backups schützen NICHT gegen Disk-Verlust oder Box-Total-Defekt.",
|
||||
"test": "Test",
|
||||
"testOk": "Test erfolgreich — Verbindung + Upload + Cleanup OK.",
|
||||
"testFailed": "Test fehlgeschlagen",
|
||||
"confirmDelete": "Ziel {{name}} wirklich löschen?",
|
||||
"targetExtra": "Frei-Text-Label für die Übersicht. Setze hier den Bucket/Path-Hinweis (s3://my-bucket oder sftp://backup@host).",
|
||||
"col": {
|
||||
"name": "Name",
|
||||
"kind": "Typ",
|
||||
"target": "Ziel",
|
||||
"lastUpload": "Letzter Upload",
|
||||
"active": "Aktiv"
|
||||
}
|
||||
},
|
||||
"diag": {
|
||||
"title": "Diagnose",
|
||||
"intro": "Operator-Tools direkt aus dem UI: ping, traceroute, DNS, HTTP-Probe, TCP-Connect. Alle Calls laufen authentifiziert auf dieser Box (nicht im Browser).",
|
||||
@@ -730,6 +750,7 @@
|
||||
"intro": "Sicherungen der PostgreSQL-Datenbank + /var/lib/edgeguard (Setup, License, JWT, ACME-Account). Täglicher Auto-Job + manueller Trigger.",
|
||||
"scopeTitle": "Was wird gesichert?",
|
||||
"scopeDesc": "DB-Dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Konfig-Dateien (haproxy.cfg, nft, …) sind aus der DB regenerierbar und werden NICHT mitgesichert.",
|
||||
"tabs": { "history": "Sicherungen", "remotes": "Off-Site-Ziele" },
|
||||
"runNow": "Backup jetzt erstellen",
|
||||
"created": "Backup erstellt: {{file}}",
|
||||
"failed": "Backup fehlgeschlagen",
|
||||
|
||||
@@ -711,6 +711,26 @@
|
||||
"delivered": "Delivered"
|
||||
}
|
||||
},
|
||||
"remotes": {
|
||||
"scopeTitle": "Off-site backup targets",
|
||||
"scopeDesc": "After every successful local backup, the tar.gz is uploaded to all active targets. S3 endpoints (AWS, MinIO, Backblaze B2, Cloudflare R2, Hetzner Object Storage) and SFTP/SSH. Protects against box loss.",
|
||||
"add": "Add target",
|
||||
"addTitle": "Add off-site target",
|
||||
"editTitle": "Edit off-site target",
|
||||
"empty": "No off-site targets. Local backups do NOT protect against disk loss or full box failure.",
|
||||
"test": "Test",
|
||||
"testOk": "Test OK — connect, upload + cleanup all worked.",
|
||||
"testFailed": "Test failed",
|
||||
"confirmDelete": "Really delete target {{name}}?",
|
||||
"targetExtra": "Free-text label for the overview. Use bucket/path hint (s3://my-bucket or sftp://backup@host).",
|
||||
"col": {
|
||||
"name": "Name",
|
||||
"kind": "Kind",
|
||||
"target": "Target",
|
||||
"lastUpload": "Last upload",
|
||||
"active": "Active"
|
||||
}
|
||||
},
|
||||
"diag": {
|
||||
"title": "Diagnostics",
|
||||
"intro": "Operator tools straight from the UI: ping, traceroute, DNS, HTTP probe, TCP connect. All calls run authenticated on this box (not in the browser).",
|
||||
@@ -730,6 +750,7 @@
|
||||
"intro": "Snapshots of the PostgreSQL database + /var/lib/edgeguard (setup, license, JWT, ACME account). Daily auto job + manual trigger.",
|
||||
"scopeTitle": "What is backed up?",
|
||||
"scopeDesc": "DB dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Generated configs (haproxy.cfg, nft, …) are reproducible from the DB and are NOT included.",
|
||||
"tabs": { "history": "Backups", "remotes": "Off-site targets" },
|
||||
"runNow": "Run backup now",
|
||||
"created": "Backup created: {{file}}",
|
||||
"failed": "Backup failed",
|
||||
|
||||
332
management-ui/src/pages/Backups/History.tsx
Normal file
332
management-ui/src/pages/Backups/History.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Popconfirm, Space, 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'
|
||||
|
||||
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 [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<number | null>(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<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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<Backup> = [
|
||||
{
|
||||
title: t('backups.col.time'), dataIndex: 'started_at', width: 170,
|
||||
render: (v: string) => <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{dayjs(v).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Text>,
|
||||
},
|
||||
{
|
||||
title: t('backups.col.file'), dataIndex: 'file',
|
||||
render: (v: string, row) => (
|
||||
<div>
|
||||
<Text style={{ fontFamily: 'monospace' }}>{v}</Text>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
sha256: {row.sha256.slice(0, 16)}…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('backups.col.kind'), dataIndex: 'kind', width: 110,
|
||||
render: (k: Backup['kind']) =>
|
||||
<Tag color={k === 'scheduled' ? 'cyan' : 'blue'}>{k}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('backups.col.status'), dataIndex: 'status', width: 110,
|
||||
render: (s: Backup['status'], row) =>
|
||||
s === 'success'
|
||||
? <Tag color="green">OK</Tag>
|
||||
: (
|
||||
<Tooltip title={row.error}>
|
||||
<Tag color="red">{t('backups.failedTag')}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('backups.col.size'), dataIndex: 'size_bytes', width: 110,
|
||||
render: (n: number, row) => (
|
||||
<div>
|
||||
<Text>{fmtSize(n)}</Text>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
DB {fmtSize(row.db_dump_bytes)} · Files {fmtSize(row.files_bytes)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('backups.col.duration'), key: 'duration', width: 90,
|
||||
render: (_, row) => <Text type="secondary">{fmtDuration(row.started_at, row.finished_at)}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 280,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('backups.downloadTooltip')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => download(row)}
|
||||
disabled={row.status !== 'success'}
|
||||
>{t('backups.download')}</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={t('backups.confirmRestoreTitle')}
|
||||
description={t('backups.confirmRestoreDesc', { file: row.file })}
|
||||
okText={t('backups.restoreOk')}
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => startRestore(row)}
|
||||
disabled={row.status !== 'success'}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={row.status !== 'success'}
|
||||
>{t('backups.restore')}</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title={t('backups.confirmDelete', { file: row.file })}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} loading={busyDelete === row.id}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msgCtx}
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Tooltip title={t('backups.refreshTooltip')}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => list.refetch()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={trigger.isPending}
|
||||
onClick={() => trigger.mutate()}
|
||||
>
|
||||
{t('backups.runNow')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Card className="mb-16">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('backups.scopeTitle')}
|
||||
description={t('backups.scopeDesc')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={list.isFetching}
|
||||
dataSource={list.data ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
||||
locale={{ emptyText: t('backups.empty') }}
|
||||
/>
|
||||
|
||||
{restoring && (
|
||||
<div className="update-modal-overlay">
|
||||
<div className="update-modal">
|
||||
<div className="update-modal__orbit">
|
||||
<div className="update-modal__ring" />
|
||||
<div className="update-modal__ring update-modal__ring--2" />
|
||||
<div className="update-modal__dot" />
|
||||
<div className="update-modal__dot update-modal__dot--2" />
|
||||
<div className="update-modal__center">
|
||||
<RocketOutlined className="update-modal__icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="update-modal__title">{t('backups.restoreRunning')}</div>
|
||||
<div className="update-modal__version">{restoring.file}</div>
|
||||
<div className="update-modal__steps">
|
||||
<div className={`update-modal__step ${restoreElapsed < 5 ? 'update-modal__step--active' : 'update-modal__step--done'}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.extract')}</span>
|
||||
</div>
|
||||
<div className={`update-modal__step ${restoreElapsed >= 5 && restoreElapsed < 15 ? 'update-modal__step--active' : restoreElapsed >= 15 ? 'update-modal__step--done' : ''}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.psql')}</span>
|
||||
</div>
|
||||
<div className={`update-modal__step ${restoreElapsed >= 15 && restoreElapsed < 25 ? 'update-modal__step--active' : restoreElapsed >= 25 ? 'update-modal__step--done' : ''}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.render')}</span>
|
||||
</div>
|
||||
<div className={`update-modal__step ${restoreElapsed >= 25 ? 'update-modal__step--active' : ''}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.restart')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="update-modal__timer">{restoreElapsed}s</div>
|
||||
<div className="update-modal__hint">{t('backups.restoreHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
314
management-ui/src/pages/Backups/RemoteTargets.tsx
Normal file
314
management-ui/src/pages/Backups/RemoteTargets.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
ExperimentOutlined, PlusOutlined,
|
||||
} 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'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface RemoteTarget {
|
||||
id: number
|
||||
name: string
|
||||
kind: 's3' | 'sftp'
|
||||
target_url: string
|
||||
settings: Record<string, unknown>
|
||||
active: boolean
|
||||
last_upload_at?: string
|
||||
last_error?: string
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
kind: 's3' | 'sftp'
|
||||
target_url: string
|
||||
active: boolean
|
||||
// S3
|
||||
endpoint?: string
|
||||
region?: string
|
||||
bucket?: string
|
||||
access_key?: string
|
||||
secret_key?: string
|
||||
path_prefix?: string
|
||||
use_ssl?: boolean
|
||||
// SFTP
|
||||
host?: string
|
||||
port?: number
|
||||
username?: string
|
||||
password?: string
|
||||
private_key?: string
|
||||
remote_dir?: string
|
||||
host_key_fingerprint?: string
|
||||
}
|
||||
|
||||
function buildPayload(v: FormValues): RemoteTarget {
|
||||
const settings: Record<string, unknown> = {}
|
||||
if (v.kind === 's3') {
|
||||
if (v.endpoint) settings.endpoint = v.endpoint
|
||||
if (v.region) settings.region = v.region
|
||||
if (v.bucket) settings.bucket = v.bucket
|
||||
if (v.access_key) settings.access_key = v.access_key
|
||||
if (v.secret_key && v.secret_key !== '***SET***') settings.secret_key = v.secret_key
|
||||
else if (v.secret_key === '***SET***') settings.secret_key = '***SET***'
|
||||
if (v.path_prefix) settings.path_prefix = v.path_prefix
|
||||
settings.use_ssl = !!v.use_ssl
|
||||
} else {
|
||||
if (v.host) settings.host = v.host
|
||||
if (v.port) settings.port = v.port
|
||||
if (v.username) settings.username = v.username
|
||||
if (v.password && v.password !== '***SET***') settings.password = v.password
|
||||
else if (v.password === '***SET***') settings.password = '***SET***'
|
||||
if (v.private_key && v.private_key !== '***SET***') settings.private_key = v.private_key
|
||||
else if (v.private_key === '***SET***') settings.private_key = '***SET***'
|
||||
if (v.remote_dir) settings.remote_dir = v.remote_dir
|
||||
if (v.host_key_fingerprint) settings.host_key_fingerprint = v.host_key_fingerprint
|
||||
}
|
||||
return {
|
||||
id: 0,
|
||||
name: v.name,
|
||||
kind: v.kind,
|
||||
target_url: v.target_url,
|
||||
settings,
|
||||
active: v.active,
|
||||
}
|
||||
}
|
||||
|
||||
export default function RemoteTargetsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['backup-remotes'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/backup-remotes')
|
||||
return isEnvelope(r.data) ? (r.data.data as { remotes: RemoteTarget[] }).remotes : []
|
||||
},
|
||||
})
|
||||
|
||||
const [edit, setEdit] = useState<RemoteTarget | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
const kind = Form.useWatch('kind', form)
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (v: FormValues) => { await apiClient.post('/backup-remotes', buildPayload(v)) },
|
||||
onSuccess: () => {
|
||||
msg.success(t('common.save')); setCreating(false); form.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['backup-remotes'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(e.message),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, v }: { id: number; v: FormValues }) => {
|
||||
await apiClient.put(`/backup-remotes/${id}`, buildPayload(v))
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('common.save')); setEdit(null); form.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['backup-remotes'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => { await apiClient.delete(`/backup-remotes/${id}`) },
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['backup-remotes'] }) },
|
||||
onError: (e: Error) => msg.error(e.message),
|
||||
})
|
||||
const test = useMutation({
|
||||
mutationFn: async (id: number) => { await apiClient.post(`/backup-remotes/${id}/test`) },
|
||||
onSuccess: () => msg.success(t('remotes.testOk')),
|
||||
onError: (e: Error) => msg.error(t('remotes.testFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const columns: ColumnsType<RemoteTarget> = [
|
||||
{ title: t('remotes.col.name'), dataIndex: 'name' },
|
||||
{
|
||||
title: t('remotes.col.kind'), dataIndex: 'kind', width: 80,
|
||||
render: (v: string) =>
|
||||
<Tag color={v === 's3' ? 'blue' : 'purple'}>{v.toUpperCase()}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('remotes.col.target'), dataIndex: 'target_url',
|
||||
render: (v: string) =>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('remotes.col.lastUpload'), dataIndex: 'last_upload_at', width: 200,
|
||||
render: (v?: string, row?) => {
|
||||
if (!v) return <Text type="secondary">—</Text>
|
||||
const failed = !!row?.last_error
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Text type={failed ? 'danger' : undefined} style={{ fontSize: 12 }}>
|
||||
{dayjs(v).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Text>
|
||||
{failed && <Tooltip title={row?.last_error}><Tag color="red">FAIL</Tag></Tooltip>}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('remotes.col.active'), dataIndex: 'active', width: 80,
|
||||
render: (v: boolean) => v ? <Tag color="green">an</Tag> : <Tag>aus</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 240,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<ExperimentOutlined />}
|
||||
loading={test.isPending}
|
||||
onClick={() => test.mutate(r.id)}>
|
||||
{t('remotes.test')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => {
|
||||
setEdit(r); form.resetFields()
|
||||
const settings = (r.settings ?? {}) as Record<string, unknown>
|
||||
form.setFieldsValue({
|
||||
name: r.name, kind: r.kind, target_url: r.target_url, active: r.active,
|
||||
endpoint: settings.endpoint as string,
|
||||
region: settings.region as string,
|
||||
bucket: settings.bucket as string,
|
||||
access_key: settings.access_key as string,
|
||||
secret_key: settings.secret_key as string,
|
||||
path_prefix: settings.path_prefix as string,
|
||||
use_ssl: settings.use_ssl as boolean,
|
||||
host: settings.host as string,
|
||||
port: settings.port as number,
|
||||
username: settings.username as string,
|
||||
password: settings.password as string,
|
||||
private_key: settings.private_key as string,
|
||||
remote_dir: settings.remote_dir as string,
|
||||
host_key_fingerprint: settings.host_key_fingerprint as string,
|
||||
})
|
||||
}}>{t('common.edit')}</Button>
|
||||
<Popconfirm title={t('remotes.confirmDelete', { name: r.name })}
|
||||
onConfirm={() => del.mutate(r.id)}>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msgCtx}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-16"
|
||||
message={t('remotes.scopeTitle')}
|
||||
description={t('remotes.scopeDesc')}
|
||||
/>
|
||||
<Card size="small" extra={
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 's3', active: true, use_ssl: true, port: 22 })
|
||||
}}>
|
||||
{t('remotes.add')}
|
||||
</Button>
|
||||
}>
|
||||
<Table size="small" rowKey="id" loading={list.isFetching}
|
||||
dataSource={list.data ?? []} columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('remotes.empty') }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={edit ? t('remotes.editTitle') : t('remotes.addTitle')}
|
||||
open={edit !== null || creating}
|
||||
onCancel={() => { setEdit(null); setCreating(false); form.resetFields() }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={create.isPending || update.isPending}
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical"
|
||||
onFinish={(v) => edit ? update.mutate({ id: edit.id, v }) : create.mutate(v)}>
|
||||
<Form.Item label={t('remotes.col.name')} name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="MinIO offsite / Hetzner Storage Box" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('remotes.col.kind')} name="kind" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 's3', label: 'S3 (AWS / MinIO / R2 / B2 / Hetzner Object)' },
|
||||
{ value: 'sftp', label: 'SFTP (SSH)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('remotes.col.target')} name="target_url" rules={[{ required: true }]}
|
||||
extra={t('remotes.targetExtra')}>
|
||||
<Input placeholder={kind === 's3' ? 's3://my-bucket' : 'sftp://backup@host.example.com'} />
|
||||
</Form.Item>
|
||||
|
||||
{kind === 's3' && (
|
||||
<>
|
||||
<Form.Item label="Endpoint" name="endpoint" rules={[{ required: true }]}
|
||||
extra="s3.amazonaws.com / minio.example.com:9000 / fsn1.your-objectstorage.com">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Region" name="region">
|
||||
<Input placeholder="eu-central-1 / auto (R2)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Bucket" name="bucket" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Access Key" name="access_key" rules={[{ required: true }]}>
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Secret Key" name="secret_key" rules={[{ required: !edit }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Path Prefix" name="path_prefix"
|
||||
extra="z.B. edgeguard/utm-1 — wird vor jedem Filename gesetzt">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="HTTPS (use_ssl)" name="use_ssl" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === 'sftp' && (
|
||||
<>
|
||||
<Form.Item label="Host" name="host" rules={[{ required: true }]}>
|
||||
<Input placeholder="backup.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Port" name="port" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Username" name="username" rules={[{ required: true }]}>
|
||||
<Input autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Password" name="password"
|
||||
extra="Entweder Password ODER Private-Key.">
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Private Key (OpenSSH, base64)" name="private_key">
|
||||
<Input.TextArea rows={3} autoComplete="off"
|
||||
placeholder="base64-encoded OpenSSH private key" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Remote Dir" name="remote_dir" rules={[{ required: true }]}
|
||||
extra="z.B. /backups/edgeguard">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Host-Key-Fingerprint" name="host_key_fingerprint"
|
||||
extra="optional, SHA256:... — wenn leer wird TOFU verwendet (unsicher gegen MitM)">
|
||||
<Input placeholder="SHA256:abc123..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item label={t('remotes.col.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +1,27 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Popconfirm, Space, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
CloudDownloadOutlined,
|
||||
CloudUploadOutlined,
|
||||
DatabaseOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
RocketOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Tabs } from 'antd'
|
||||
import { DatabaseOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
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'
|
||||
}
|
||||
import HistoryTab from './History'
|
||||
import RemoteTargetsTab from './RemoteTargets'
|
||||
|
||||
export default function BackupsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
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<number | null>(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<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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<Backup> = [
|
||||
{
|
||||
title: t('backups.col.time'), dataIndex: 'started_at', width: 170,
|
||||
render: (v: string) => <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{dayjs(v).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Text>,
|
||||
},
|
||||
{
|
||||
title: t('backups.col.file'), dataIndex: 'file',
|
||||
render: (v: string, row) => (
|
||||
<div>
|
||||
<Text style={{ fontFamily: 'monospace' }}>{v}</Text>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
sha256: {row.sha256.slice(0, 16)}…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('backups.col.kind'), dataIndex: 'kind', width: 110,
|
||||
render: (k: Backup['kind']) =>
|
||||
<Tag color={k === 'scheduled' ? 'cyan' : 'blue'}>{k}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('backups.col.status'), dataIndex: 'status', width: 110,
|
||||
render: (s: Backup['status'], row) =>
|
||||
s === 'success'
|
||||
? <Tag color="green">OK</Tag>
|
||||
: (
|
||||
<Tooltip title={row.error}>
|
||||
<Tag color="red">{t('backups.failedTag')}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('backups.col.size'), dataIndex: 'size_bytes', width: 110,
|
||||
render: (n: number, row) => (
|
||||
<div>
|
||||
<Text>{fmtSize(n)}</Text>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
DB {fmtSize(row.db_dump_bytes)} · Files {fmtSize(row.files_bytes)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('backups.col.duration'), key: 'duration', width: 90,
|
||||
render: (_, row) => <Text type="secondary">{fmtDuration(row.started_at, row.finished_at)}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 280,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('backups.downloadTooltip')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => download(row)}
|
||||
disabled={row.status !== 'success'}
|
||||
>{t('backups.download')}</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={t('backups.confirmRestoreTitle')}
|
||||
description={t('backups.confirmRestoreDesc', { file: row.file })}
|
||||
okText={t('backups.restoreOk')}
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => startRestore(row)}
|
||||
disabled={row.status !== 'success'}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={row.status !== 'success'}
|
||||
>{t('backups.restore')}</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title={t('backups.confirmDelete', { file: row.file })}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} loading={busyDelete === row.id}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
const tabs = [
|
||||
{ key: 'history', label: t('backups.tabs.history'), children: <HistoryTab /> },
|
||||
{ key: 'remotes', label: t('backups.tabs.remotes'), children: <RemoteTargetsTab /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msgCtx}
|
||||
<PageHeader
|
||||
icon={<DatabaseOutlined />}
|
||||
title={t('backups.title')}
|
||||
subtitle={t('backups.intro')}
|
||||
extra={
|
||||
<Space>
|
||||
<Tooltip title={t('backups.refreshTooltip')}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => list.refetch()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={trigger.isPending}
|
||||
onClick={() => trigger.mutate()}
|
||||
>
|
||||
{t('backups.runNow')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="mb-16">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('backups.scopeTitle')}
|
||||
description={t('backups.scopeDesc')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={list.isFetching}
|
||||
dataSource={list.data ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
||||
locale={{ emptyText: t('backups.empty') }}
|
||||
/>
|
||||
|
||||
{restoring && (
|
||||
<div className="update-modal-overlay">
|
||||
<div className="update-modal">
|
||||
<div className="update-modal__orbit">
|
||||
<div className="update-modal__ring" />
|
||||
<div className="update-modal__ring update-modal__ring--2" />
|
||||
<div className="update-modal__dot" />
|
||||
<div className="update-modal__dot update-modal__dot--2" />
|
||||
<div className="update-modal__center">
|
||||
<RocketOutlined className="update-modal__icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="update-modal__title">{t('backups.restoreRunning')}</div>
|
||||
<div className="update-modal__version">{restoring.file}</div>
|
||||
<div className="update-modal__steps">
|
||||
<div className={`update-modal__step ${restoreElapsed < 5 ? 'update-modal__step--active' : 'update-modal__step--done'}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.extract')}</span>
|
||||
</div>
|
||||
<div className={`update-modal__step ${restoreElapsed >= 5 && restoreElapsed < 15 ? 'update-modal__step--active' : restoreElapsed >= 15 ? 'update-modal__step--done' : ''}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.psql')}</span>
|
||||
</div>
|
||||
<div className={`update-modal__step ${restoreElapsed >= 15 && restoreElapsed < 25 ? 'update-modal__step--active' : restoreElapsed >= 25 ? 'update-modal__step--done' : ''}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.render')}</span>
|
||||
</div>
|
||||
<div className={`update-modal__step ${restoreElapsed >= 25 ? 'update-modal__step--active' : ''}`}>
|
||||
<span className="update-modal__step-dot" />
|
||||
<span>{t('backups.step.restart')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="update-modal__timer">{restoreElapsed}s</div>
|
||||
<div className="update-modal__hint">{t('backups.restoreHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Tabs items={tabs} defaultActiveKey="history" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user