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:
Debian
2026-07-31 10:58:27 +02:00
parent 0ac91a7c59
commit dca40761a4
10 changed files with 279 additions and 42 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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

View File

@@ -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({

View File

@@ -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; }