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:
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, AutoComplete, Button, Card, Form, Input, Space, Tabs, Tag, Typography, message } from 'antd'
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import { Alert, AutoComplete, Button, Card, Col, Form, Input, Popconfirm, Row, Space, Statistic, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { ExclamationCircleOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -79,6 +80,18 @@ function daysUntil(s?: string | null): number | null {
|
||||
return Math.round((t - Date.now()) / 86_400_000)
|
||||
}
|
||||
|
||||
// relativeAgo: kompakte Vergangenheits-Anzeige für "last_renewed_at".
|
||||
// Wir übergeben das i18n-`t` als zweites Argument damit die JSX-Render-
|
||||
// Funktion in der Column-Definition diese Helper-Funktion auch dann
|
||||
// nutzen kann wenn sie ausserhalb des Components definiert ist.
|
||||
function relativeAgo(ms: number, t: (k: string, v?: Record<string, unknown>) => string): string {
|
||||
if (ms < 0) return '—'
|
||||
if (ms < 60_000) return t('ssl.relAgo.justNow')
|
||||
if (ms < 3_600_000) return t('ssl.relAgo.minutes', { n: Math.round(ms / 60_000) })
|
||||
if (ms < 86_400_000) return t('ssl.relAgo.hours', { n: Math.round(ms / 3_600_000) })
|
||||
return t('ssl.relAgo.days', { n: Math.round(ms / 86_400_000) })
|
||||
}
|
||||
|
||||
export default function SSLPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -139,8 +152,38 @@ export default function SSLPage() {
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['tls-certs'] }) },
|
||||
})
|
||||
|
||||
// Force-Renew re-uses /tls-certs/issue — der Endpoint upsertet, also
|
||||
// ist ein zweiter Issue für dieselbe Domain effektiv ein Renew. Wir
|
||||
// mappen die Row-ID auf die Domain damit die Mutation nur eine ID
|
||||
// braucht (UI-seitig).
|
||||
const renewMut = useMutation({
|
||||
mutationFn: async (domain: string) => {
|
||||
const r = await apiClient.post('/tls-certs/issue', { domain })
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('ssl.renewSuccess'))
|
||||
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
message.error(t('ssl.renewFailed') + ': ' + e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const columns: ColumnsType<TLSCert> = [
|
||||
{ title: t('ssl.domain'), dataIndex: 'domain', key: 'domain', render: (s: string) => <code>{s}</code> },
|
||||
{
|
||||
title: t('ssl.domain'), dataIndex: 'domain', key: 'domain',
|
||||
render: (s: string, row) => (
|
||||
<Space size={6}>
|
||||
<code>{s}</code>
|
||||
{row.last_error && (
|
||||
<Tooltip title={row.last_error}>
|
||||
<ExclamationCircleOutlined style={{ color: '#cf1322' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('ssl.issuer'), dataIndex: 'issuer', key: 'issuer' },
|
||||
{
|
||||
title: t('ssl.status'), dataIndex: 'status', key: 'status',
|
||||
@@ -156,13 +199,46 @@ export default function SSLPage() {
|
||||
return `${d}d`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('ssl.lastRenewed'), key: 'lastRenewed', width: 130,
|
||||
render: (_, row) => {
|
||||
if (!row.last_renewed_at) {
|
||||
return <Typography.Text type="secondary" style={{ fontSize: 12 }}>—</Typography.Text>
|
||||
}
|
||||
const ms = Date.now() - new Date(row.last_renewed_at).getTime()
|
||||
const rel = relativeAgo(ms, t)
|
||||
return (
|
||||
<Tooltip title={new Date(row.last_renewed_at).toLocaleString()}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{rel}</Typography.Text>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<ActionButtons
|
||||
onDelete={() => delMut.mutate(row.id)}
|
||||
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||
/>
|
||||
<Space size={4}>
|
||||
{row.issuer === 'letsencrypt' && (
|
||||
<Popconfirm
|
||||
title={t('ssl.renewConfirmTitle')}
|
||||
description={t('ssl.renewConfirmDesc', { domain: row.domain })}
|
||||
okText={t('common.yes')} cancelText={t('common.no')}
|
||||
onConfirm={() => renewMut.mutate(row.domain)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={renewMut.isPending && renewMut.variables === row.domain}
|
||||
>
|
||||
{t('ssl.renewBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<ActionButtons
|
||||
onDelete={() => delMut.mutate(row.id)}
|
||||
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -225,6 +301,19 @@ export default function SSLPage() {
|
||||
},
|
||||
]
|
||||
|
||||
// Aggregate counts — operator-glance health. Berechnet aus der
|
||||
// bereits geladenen Liste; keine zusätzlichen API-Calls nötig.
|
||||
const total = certs?.length ?? 0
|
||||
const expiring = (certs ?? []).filter((c) => {
|
||||
const d = daysUntil(c.not_after)
|
||||
return d != null && d >= 0 && d < 30
|
||||
}).length
|
||||
const expired = (certs ?? []).filter((c) => {
|
||||
const d = daysUntil(c.not_after)
|
||||
return d != null && d < 0
|
||||
}).length
|
||||
const inError = (certs ?? []).filter((c) => !!c.last_error || c.status === 'error').length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -233,10 +322,57 @@ export default function SSLPage() {
|
||||
subtitle={t('ssl.intro')}
|
||||
/>
|
||||
|
||||
{total > 0 && (
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><Statistic title={t('ssl.statTotal')} value={total} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('ssl.statExpiring')}
|
||||
value={expiring}
|
||||
valueStyle={expiring > 0 ? { color: '#d48806' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('ssl.statExpired')}
|
||||
value={expired}
|
||||
valueStyle={expired > 0 ? { color: '#cf1322' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('ssl.statErrors')}
|
||||
value={inError}
|
||||
valueStyle={inError > 0 ? { color: '#cf1322' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Tabs items={tabs} defaultActiveKey="letsencrypt" />
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24 }}>{t('ssl.installedTitle')}</Typography.Title>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={certs ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={certs ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
title={t('ssl.emptyTitle')}
|
||||
description={t('ssl.emptyDesc')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user