feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)
Backend: - Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date) - NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle) - System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler) - Domain-Response-Headers + Rate-Limit (Migration 0024) - Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out - apt-Service für Update-Banner (apt-get update + Versionsprüfung) - Backup-Retry mit exponential backoff (retry_apt 3×) - publish.sh fail-fast + cleanup-old.sh (max 10 Versionen) Frontend: - Audit-Log-Page (/audit) mit Filter + Pagination - ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+) - Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix) - EmptyState-Komponente überall ausgerollt - SSL: Aggregate-Karte (total/expiring/expired/errors) - Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now - NTP: Sync-Status-Karte (chronyc tracking live) - Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats - Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN) - Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler) - Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention - Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint - System-Regeln im Firewall als eigener Tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
204
management-ui/src/pages/Audit/index.tsx
Normal file
204
management-ui/src/pages/Audit/index.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Col, DatePicker, Form, Input, Row, Space, Tag, Typography } from 'antd'
|
||||
import { FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number
|
||||
actor: string
|
||||
action: string
|
||||
subject?: string | null
|
||||
detail?: unknown
|
||||
node_id?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
actor?: string
|
||||
action?: string
|
||||
subject?: string
|
||||
since?: Dayjs | null
|
||||
until?: Dayjs | null
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
actor?: string
|
||||
action?: string
|
||||
subject?: string
|
||||
range?: [Dayjs, Dayjs] | null
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
async function searchAudit(p: SearchParams, offset: number): Promise<AuditEntry[]> {
|
||||
const params: Record<string, string> = { limit: String(PAGE_SIZE), offset: String(offset) }
|
||||
if (p.actor) params.actor = p.actor
|
||||
if (p.action) params.action = p.action
|
||||
if (p.subject) params.subject = p.subject
|
||||
if (p.since) params.since = p.since.toISOString()
|
||||
if (p.until) params.until = p.until.toISOString()
|
||||
const r = await apiClient.get('/audit/search', { params })
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { entries?: AuditEntry[] }).entries ?? []
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
const [filters, setFilters] = useState<SearchParams>({})
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const { data: entries, isLoading, refetch } = useQuery({
|
||||
queryKey: ['audit', 'search', filters, offset],
|
||||
queryFn: () => searchAudit(filters, offset),
|
||||
})
|
||||
|
||||
const onSubmit = (v: FormValues) => {
|
||||
setOffset(0)
|
||||
setFilters({
|
||||
actor: v.actor?.trim() || undefined,
|
||||
action: v.action?.trim() || undefined,
|
||||
subject: v.subject?.trim() || undefined,
|
||||
since: v.range?.[0] ?? null,
|
||||
until: v.range?.[1] ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
setOffset(0)
|
||||
setFilters({})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AuditEntry> = [
|
||||
{
|
||||
title: t('audit.col.time'), key: 'created_at', dataIndex: 'created_at', width: 170,
|
||||
render: (s: string) => (
|
||||
<Typography.Text style={{ fontSize: 12 }}>
|
||||
{new Date(s).toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('audit.col.actor'), key: 'actor', dataIndex: 'actor', width: 200,
|
||||
render: (s: string) => <code style={{ fontSize: 12 }}>{s}</code>,
|
||||
},
|
||||
{
|
||||
title: t('audit.col.action'), key: 'action', dataIndex: 'action', width: 220,
|
||||
render: (s: string) => <Tag color="blue" style={{ fontFamily: 'monospace' }}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('audit.col.subject'), key: 'subject', dataIndex: 'subject',
|
||||
render: (s?: string | null) => s ? <code style={{ fontSize: 12 }}>{s}</code> : <Typography.Text type="secondary">—</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: t('audit.col.detail'), key: 'detail',
|
||||
render: (_, row) => {
|
||||
if (!row.detail) return <Typography.Text type="secondary">—</Typography.Text>
|
||||
const txt = typeof row.detail === 'string' ? row.detail : JSON.stringify(row.detail)
|
||||
if (txt.length <= 80) {
|
||||
return <Typography.Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{txt}</Typography.Text>
|
||||
}
|
||||
return (
|
||||
<details>
|
||||
<summary style={{ fontSize: 11, color: '#64748B', cursor: 'pointer' }}>
|
||||
{t('audit.detailShow')}
|
||||
</summary>
|
||||
<pre style={{ fontSize: 11, margin: '4px 0 0 0', maxWidth: 480, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{txt}</pre>
|
||||
</details>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const hasMore = (entries?.length ?? 0) === PAGE_SIZE
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<FileSearchOutlined />}
|
||||
title={t('audit.title')}
|
||||
subtitle={t('audit.intro')}
|
||||
/>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
initialValues={{ actor: '', action: '', subject: '', range: null }}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.actor')} name="actor">
|
||||
<Input placeholder="z.B. admin@…" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.action')} name="action">
|
||||
<Input placeholder="z.B. domain.update" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.subject')} name="subject">
|
||||
<Input placeholder="z.B. example.com" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.range')} name="range">
|
||||
<DatePicker.RangePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">{t('audit.filter.search')}</Button>
|
||||
<Button onClick={onReset}>{t('audit.filter.reset')}</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={entries ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<FileSearchOutlined />}
|
||||
title={t('audit.empty.title')}
|
||||
description={t('audit.empty.desc')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
{t('audit.page.prev')}
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('audit.page.showing', { from: offset + 1, to: offset + (entries?.length ?? 0) })}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
disabled={!hasMore}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
{t('audit.page.next')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user