feat: CrowdSec journald-Acquisition + Alarm-Quittieren/Löschen — v1.3.5
- fix(crowdsec/packaging): postinst setzt die HAProxy-Acquisition deterministisch auf die journald-Unit (haproxy.service) statt der cscli-setup-Datei-Default (/var/log/haproxy.log existiert nicht → CrowdSec las nichts → HTTP/CVE-Szenarien liefen leer). Self-healing auf jedem configure; admin-Custom bleibt unangetastet. - feat(alerts): Alarme (alert_events) bulk quittieren + löschen. Migration 0045 (acknowledged_at + Teil-Index). Dashboard-Karte zählt nur noch OFFENE (open=true) → Quittieren lässt die "Aktuelle Alerts"-Meldung verschwinden, History bleibt. Events-Tab: Row-Selection, Quittieren/Löschen (Auswahl) + "Alle quittieren", Status-Spalte (offen/quittiert). Audit-geloggt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1505,6 +1505,12 @@
|
||||
"emptyEventsDesc": "Triggers (Cert-Expiry, Backup-Fail, Cluster-Drift, License-Invalid, etc.) haben noch keinen Event gefeuert. Wenn sie feuern, landen sie hier und werden an die konfigurierten Channels zugestellt.",
|
||||
"noChannels": "kein Channel aktiv",
|
||||
"confirmDelete": "Channel {{name}} wirklich löschen?",
|
||||
"acknowledge": "Quittieren",
|
||||
"acknowledgeAll": "Alle quittieren",
|
||||
"acknowledged": "Quittiert",
|
||||
"acked": "quittiert",
|
||||
"open": "offen",
|
||||
"confirmDeleteEvents": "{{n}} Event(s) endgültig löschen?",
|
||||
"kindWebhook": "Webhook (Slack/Discord/Teams/HTTP-Endpoint)",
|
||||
"kindEmail": "E-Mail (SMTP)",
|
||||
"smtp": {
|
||||
@@ -1525,7 +1531,8 @@
|
||||
"time": "Zeit",
|
||||
"severity": "Severity",
|
||||
"subject": "Betreff",
|
||||
"delivered": "Gesendet"
|
||||
"delivered": "Gesendet",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"remotes": {
|
||||
|
||||
@@ -1505,6 +1505,12 @@
|
||||
"emptyEventsDesc": "Triggers (cert expiry, backup failure, cluster drift, license invalid, etc.) haven't fired any events yet. When they do, they land here and get delivered to the configured channels.",
|
||||
"noChannels": "no active channel",
|
||||
"confirmDelete": "Really delete channel {{name}}?",
|
||||
"acknowledge": "Acknowledge",
|
||||
"acknowledgeAll": "Acknowledge all",
|
||||
"acknowledged": "Acknowledged",
|
||||
"acked": "acknowledged",
|
||||
"open": "open",
|
||||
"confirmDeleteEvents": "Permanently delete {{n}} event(s)?",
|
||||
"kindWebhook": "Webhook (Slack/Discord/Teams/HTTP endpoint)",
|
||||
"kindEmail": "Email (SMTP)",
|
||||
"smtp": {
|
||||
@@ -1525,7 +1531,8 @@
|
||||
"time": "Time",
|
||||
"severity": "Severity",
|
||||
"subject": "Subject",
|
||||
"delivered": "Delivered"
|
||||
"delivered": "Delivered",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"remotes": {
|
||||
|
||||
@@ -42,6 +42,7 @@ interface AlertEvent {
|
||||
message: string
|
||||
sent_to: SendResult[]
|
||||
fired_at: string
|
||||
acknowledged_at?: string | null
|
||||
}
|
||||
|
||||
interface ChannelFormValues {
|
||||
@@ -99,6 +100,29 @@ export default function AlertsPage() {
|
||||
const [form] = Form.useForm<ChannelFormValues>()
|
||||
const [filterSev, setFilterSev] = useState<string | undefined>()
|
||||
const [filterKind, setFilterKind] = useState<string | undefined>()
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
|
||||
// Nach Quittieren/Löschen sowohl die Event-Liste als auch die Dashboard-
|
||||
// Karte (queryKey ['alerts','events','recent']) invalidieren — Prefix-Match.
|
||||
const refreshEvents = () => {
|
||||
setSelectedIds([])
|
||||
void qc.invalidateQueries({ queryKey: ['alerts', 'events'] })
|
||||
}
|
||||
const ackMut = useMutation({
|
||||
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/acknowledge', { ids }),
|
||||
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const ackAllMut = useMutation({
|
||||
mutationFn: () => apiClient.post('/alerts/events/acknowledge-all'),
|
||||
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const delEventsMut = useMutation({
|
||||
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }),
|
||||
onSuccess: () => { message.success(t('common.delete')); refreshEvents() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const kindOptions = useMemo(() => {
|
||||
const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort()
|
||||
@@ -276,6 +300,14 @@ export default function AlertsPage() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('alerts.col.status'), key: 'ack', width: 110,
|
||||
render: (_, r: AlertEvent) => r.acknowledged_at
|
||||
? <Tooltip title={dayjs(r.acknowledged_at).format('YYYY-MM-DD HH:mm')}>
|
||||
<Tag color="green">{t('alerts.acked')}</Tag>
|
||||
</Tooltip>
|
||||
: <Tag color="gold">{t('alerts.open')}</Tag>,
|
||||
},
|
||||
]
|
||||
|
||||
const kind = Form.useWatch('kind', form)
|
||||
@@ -351,32 +383,61 @@ export default function AlertsPage() {
|
||||
label: t('alerts.tabs.events'),
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Space className="mb-8">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('alerts.col.severity')}
|
||||
style={{ width: 150 }}
|
||||
value={filterSev}
|
||||
onChange={setFilterSev}
|
||||
options={[
|
||||
{ value: 'info', label: <Tag color="blue">INFO</Tag> },
|
||||
{ value: 'warning', label: <Tag color="orange">WARNING</Tag> },
|
||||
{ value: 'error', label: <Tag color="red">ERROR</Tag> },
|
||||
{ value: 'critical', label: <Tag color="magenta">CRITICAL</Tag> },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('alerts.col.kind')}
|
||||
style={{ width: 220 }}
|
||||
value={filterKind}
|
||||
onChange={setFilterKind}
|
||||
options={kindOptions}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }} className="mb-8">
|
||||
<Space>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('alerts.col.severity')}
|
||||
style={{ width: 150 }}
|
||||
value={filterSev}
|
||||
onChange={setFilterSev}
|
||||
options={[
|
||||
{ value: 'info', label: <Tag color="blue">INFO</Tag> },
|
||||
{ value: 'warning', label: <Tag color="orange">WARNING</Tag> },
|
||||
{ value: 'error', label: <Tag color="red">ERROR</Tag> },
|
||||
{ value: 'critical', label: <Tag color="magenta">CRITICAL</Tag> },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('alerts.col.kind')}
|
||||
style={{ width: 220 }}
|
||||
value={filterKind}
|
||||
onChange={setFilterKind}
|
||||
options={kindOptions}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button size="small" disabled={isViewer || selectedIds.length === 0}
|
||||
loading={ackMut.isPending} onClick={() => ackMut.mutate(selectedIds)}>
|
||||
{t('alerts.acknowledge')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm title={t('alerts.confirmDeleteEvents', { n: selectedIds.length })}
|
||||
onConfirm={() => delEventsMut.mutate(selectedIds)}
|
||||
disabled={isViewer || selectedIds.length === 0}>
|
||||
<Button size="small" danger disabled={isViewer || selectedIds.length === 0}
|
||||
loading={delEventsMut.isPending}>
|
||||
{t('common.delete')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button size="small" type="primary" ghost disabled={isViewer}
|
||||
loading={ackAllMut.isPending} onClick={() => ackAllMut.mutate()}>
|
||||
{t('alerts.acknowledgeAll')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
<Table size="small" rowKey="id" loading={events.isFetching}
|
||||
dataSource={filteredEvents} columns={evColumns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => setSelectedIds(keys as number[]),
|
||||
}}
|
||||
rowClassName={(r) => (r.acknowledged_at ? 'eg-row-muted' : '')}
|
||||
pagination={{ pageSize: 25 }}
|
||||
locale={{ emptyText: (
|
||||
<EmptyState
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function DashboardPage() {
|
||||
})
|
||||
const recentAlerts = useQuery({
|
||||
queryKey: ['alerts', 'events', 'recent'],
|
||||
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10', 'events'),
|
||||
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10&open=true', 'events'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const services = useQuery({
|
||||
|
||||
@@ -1160,6 +1160,9 @@ h1, h2, h3, h4, h5, h6 {
|
||||
.threat-tile.threat-tile--warn { opacity: 1; border-left: 3px solid #F59E0B; }
|
||||
.threat-tile.threat-tile--crit { opacity: 1; border-left: 3px solid #DC2626; }
|
||||
.threat-tile.threat-tile--idle { opacity: 0.55; border-left: 3px solid transparent; }
|
||||
|
||||
/* Quittierte Alert-Events (Alerts → Events-Tab) werden abgedimmt. */
|
||||
tr.eg-row-muted > td { opacity: 0.5; }
|
||||
.threat-tile-value--hit { color: #EF4444; }
|
||||
.threat-tile-value--warn { color: #F59E0B; }
|
||||
.threat-tile-value--crit { color: #DC2626; }
|
||||
|
||||
Reference in New Issue
Block a user