Zwei unabhängige Bugs hinter "Live-Log zeigt Einträge, aber nichts Neues":
1) ulogd2 stirbt beim nächtlichen Logrotate. Der postinst nahm an, ulogd
laufe als root — die Debian-Unit startet aber `ulogd --daemon --uid ulog`.
Beim Start öffnet ulogd die JSONL noch als root und schreibt danach über
den offenen fd weiter, egal wem sie gehört. Nachts schickt das Distro-
Profil /etc/logrotate.d/ulogd2 ein SIGHUP; das Reopen läuft dann als
`ulog` und scheitert an root:edgeguard 0640 ("can't open JSON log file:
Permission denied"). ulogd wertet das als fatal und beendet sich mit
Exit-Code 0 — Restart=on-failure hätte also nicht gegriffen, und ohne
Restart= blieb der Dienst tot (auf utm-1 5 Tage unbemerkt). Die API
servierte derweil weiter ihren In-Memory-Ring von vor der Rotation,
deshalb sah die UI Einträge, aber nie neue.
Fix: Owner ulog (Schreiber) : edgeguard (Leser), logrotate `create`
passend, plus Drop-in Restart=always als Selbstheilung.
2) Seitengröße liess sich nicht umstellen. Die Tabellen übergaben ein
literales `pagination={{ pageSize: N }}`. antd merged via
extendsObject(innerPagination, paginationObj) — der Prop überschreibt
bei jedem Render den State, den der Size-Changer gerade gesetzt hat.
Bei einem Live-Log rendert das im Sekundentakt, der Klick auf 20/100
war also sofort wieder weg. Fix: defaultPageSize (unkontrolliert).
Betraf ausser dem Live-Log auch Logs, Backups-History, Routes,
Alerts und CrowdSec.
Ausserdem: `t` aus den WS-Effect-Deps genommen. i18n wechselt dessen
Identität bei Store-Updates, was den Effect neu laufen liess — inklusive
setEntries([]), d.h. der Live-Puffer leerte sich ohne erkennbaren Grund.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
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<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">{t('backups.okTag')}</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>
|
|
{isViewer
|
|
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" icon={<UndoOutlined />} disabled>{t('backups.restore')}</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>
|
|
}
|
|
{isViewer
|
|
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" danger icon={<DeleteOutlined />} disabled>{t('common.delete')}</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>
|
|
),
|
|
},
|
|
]
|
|
|
|
// 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 (
|
|
<div>
|
|
{msgCtx}
|
|
|
|
{all.length > 0 && (
|
|
<Row gutter={12} className="mb-16">
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title={t('backups.statLastSuccess')}
|
|
value={
|
|
lastSuccAge == null
|
|
? '—'
|
|
: lastSuccAge < 24
|
|
? t('backups.statHoursAgo', { n: lastSuccAge })
|
|
: t('backups.statDaysAgo', { n: Math.round(lastSuccAge / 24) })
|
|
}
|
|
valueStyle={
|
|
lastSuccAge == null ? { color: '#cf1322' }
|
|
: lastSuccAge > 48 ? { color: '#d48806' }
|
|
: { color: '#0F172A' }
|
|
}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic title={t('backups.statTotal')} value={succ.length} />
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic title={t('backups.statSize')} value={fmtSize(totalSize)} />
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title={t('backups.statFails24h')}
|
|
value={failsLast24h}
|
|
valueStyle={failsLast24h > 0 ? { color: '#cf1322' } : undefined}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
)}
|
|
|
|
<Space style={{ marginBottom: 12 }}>
|
|
<Tooltip title={t('backups.refreshTooltip')}>
|
|
<Button icon={<ReloadOutlined />} onClick={() => list.refetch()}>
|
|
{t('common.refresh')}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button
|
|
type="primary"
|
|
icon={<CloudUploadOutlined />}
|
|
loading={trigger.isPending}
|
|
disabled={isViewer}
|
|
onClick={() => trigger.mutate()}
|
|
>
|
|
{t('backups.runNow')}
|
|
</Button>
|
|
</Tooltip>
|
|
</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={{ defaultPageSize: 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>
|
|
)
|
|
}
|