feat(backup): pg_dump + state-tarball + daily auto + UI
Production-Box braucht Backups — bisher keine. Jetzt komplette
Pipeline:
Backend (internal/services/backup/):
- Output: /var/backups/edgeguard/eg-YYYYMMDD-HHMMSS.tar.gz
- Inhalt: dump.sql (pg_dump --clean --if-exists --no-owner --no-acl),
files/setup.json, files/license_key, files/license.cache,
files/.jwt_fingerprint, files/node.conf, files/acme-account/* +
manifest.json (Version, kind, hostname, sizes)
- sha256 während-write via TeeWriter, Size + sha in backups-DB-Row
- Failure-Path: row mit status=failed + error, kein orphan-tarball
- Prune(keepN=14) löscht erfolgreiche Backups älter als die letzten N
Migration 0018: backups(id, file, size, sha256, db/files bytes, kind,
status, error, host, started/finished).
Scheduler (cmd/edgeguard-scheduler):
- 24h-Tick → backup.Run(KindScheduled) + Prune. Beim Boot wird ein
initialer Backup NICHT sofort gezogen (kein nervöses Spam),
sondern erst beim nächsten 24h-Slot.
REST (internal/handlers/backup.go):
GET /api/v1/backups — list (newest first)
POST /api/v1/backups — trigger manual (sync, audit'ed)
GET /api/v1/backups/:id — single
GET /api/v1/backups/:id/download — sendfile tar.gz
DELETE /api/v1/backups/:id — entferne file + row
UI (management-ui/src/pages/Backups):
- Liste mit Time, File+sha (first 16), Kind-Tag, Status, Size (mit
DB + Files Aufschlüsselung), Dauer
- „Backup jetzt erstellen" Button, Refresh, Download, Delete
- Auto-Refresh 30s
- Sidebar-Eintrag „Backups" unter System
postinst:
- /var/backups/edgeguard 0750 edgeguard:edgeguard (enthält sensitive
pg_dump + license_key → NICHT world-readable)
- sudoers-Whitelist `sudo -u postgres /usr/bin/pg_dump --clean
--if-exists --no-owner --no-acl edgeguard` — exakte Form
Verifiziert auf der Box: backups-Tabelle existiert, scheduler logged
„backup enabled tick=24h dir=/var/backups/edgeguard keep_n=14",
pg_dump-via-sudoers liefert 2808 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const LogsPage = lazy(() => import('./pages/Logs'))
|
||||
const BackupsPage = lazy(() => import('./pages/Backups'))
|
||||
const LicensePage = lazy(() => import('./pages/License'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
|
||||
@@ -111,6 +112,7 @@ export default function App() {
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -18,6 +18,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/logs': 'nav.logs',
|
||||
'/backups': 'nav.backups',
|
||||
'/license': 'nav.license',
|
||||
'/settings': 'nav.settings',
|
||||
}
|
||||
|
||||
@@ -71,13 +71,14 @@ const NAV: NavSection[] = [
|
||||
items: [
|
||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
|
||||
{ path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> },
|
||||
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
||||
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.63'
|
||||
const VERSION = '1.0.64'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall-Log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"backups": "Backups",
|
||||
"license": "Lizenz",
|
||||
"settings": "Einstellungen",
|
||||
"section": {
|
||||
@@ -621,6 +622,30 @@
|
||||
"cta": "Jetzt aktivieren →",
|
||||
"openPage": "Lizenz-Seite öffnen →"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"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.",
|
||||
"runNow": "Backup jetzt erstellen",
|
||||
"created": "Backup erstellt: {{file}}",
|
||||
"failed": "Backup fehlgeschlagen",
|
||||
"deleted": "Backup gelöscht",
|
||||
"download": "Download",
|
||||
"downloadTooltip": "tar.gz herunterladen",
|
||||
"refreshTooltip": "Liste neu laden",
|
||||
"confirmDelete": "Backup {{file}} wirklich löschen?",
|
||||
"empty": "Noch keine Backups. Klicke „Backup jetzt erstellen\" oder warte den nächsten Auto-Tick ab.",
|
||||
"failedTag": "FEHLER",
|
||||
"col": {
|
||||
"time": "Zeit",
|
||||
"file": "Datei",
|
||||
"kind": "Typ",
|
||||
"status": "Status",
|
||||
"size": "Größe",
|
||||
"duration": "Dauer"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "System-Logs",
|
||||
"intro": "Aggregierter Blick auf alle Service-Journals + audit_log. Multi-Source-Auswahl, Level-Filter, Freitext-Suche, Zeit-Range, Auto-Refresh (5s).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"backups": "Backups",
|
||||
"license": "License",
|
||||
"settings": "Settings",
|
||||
"section": {
|
||||
@@ -621,6 +622,30 @@
|
||||
"cta": "Activate now →",
|
||||
"openPage": "Open license page →"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"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.",
|
||||
"runNow": "Run backup now",
|
||||
"created": "Backup created: {{file}}",
|
||||
"failed": "Backup failed",
|
||||
"deleted": "Backup deleted",
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download tar.gz",
|
||||
"refreshTooltip": "Reload list",
|
||||
"confirmDelete": "Really delete backup {{file}}?",
|
||||
"empty": "No backups yet. Click “Run backup now” or wait for the next scheduled tick.",
|
||||
"failedTag": "FAILED",
|
||||
"col": {
|
||||
"time": "Time",
|
||||
"file": "File",
|
||||
"kind": "Kind",
|
||||
"status": "Status",
|
||||
"size": "Size",
|
||||
"duration": "Duration"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "System logs",
|
||||
"intro": "Aggregated view across all service journals + audit_log. Multi-source selection, level filter, free-text search, time range, auto-refresh (5s).",
|
||||
|
||||
227
management-ui/src/pages/Backups/index.tsx
Normal file
227
management-ui/src/pages/Backups/index.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { 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,
|
||||
} 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 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'
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
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: 200,
|
||||
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.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}
|
||||
<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') }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user