feat(notifications): add a failed-check threshold for node.down alerts (#6768)

One lost heartbeat on a lossy link flipped a node offline and sent node.down,
and the next probe sent node.up, so a flaky node flooded Telegram, email and
Discord with alert pairs.

A new nodeDownThreshold setting (default 1, today's behaviour) works like
outboundDownThreshold: node.down fires once the failed-check streak reaches it,
once per outage, and node.up only after a node.down was sent. The announcement
is persisted in nodes.down_notified, so a restart inside the threshold window
neither drops the outage's node.down nor re-announces nodes already reported
offline; nodes offline at upgrade are seeded as announced.
This commit is contained in:
Grigoriy
2026-10-24 05:26:13 +03:00
committed by GitHub
parent c3037fc716
commit 2f62dadd25
34 changed files with 379 additions and 35 deletions
@@ -20,6 +20,7 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'outbound.down',
label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold',
hint: 'eventDownThresholdHint',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
@@ -44,7 +45,23 @@ const GROUPS: NotificationGroupConfig[] = [
icon: <DesktopOutlined aria-hidden />,
title: 'eventGroupNode',
events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
{
key: 'node.down',
label: 'eventNodeDown',
settingKey: 'nodeDownThreshold',
hint: 'eventDownThresholdHint',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
min={1}
max={100}
value={value}
onChange={onChange}
aria-label={ariaLabel}
style={{ width: 80 }}
/>
),
},
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
],
},
@@ -1,4 +1,4 @@
import { Space } from 'antd';
import { Space, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting';
import type { NotificationGroupConfig } from './types';
@@ -55,12 +55,22 @@ export function NotificationGroup({
checked={selected.includes(event.key)}
onToggle={() => onToggle(event.key)}
>
{event.extra?.({
value:
Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
onChange: (v) => updateSetting({ [event.settingKey]: v }),
ariaLabel: t(`pages.settings.${event.label}`),
})}
{event.extra && (
<Space size={8}>
{event.extra({
value:
Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) ||
0,
onChange: (v) => updateSetting({ [event.settingKey]: v }),
ariaLabel: t(`pages.settings.${event.label}`),
})}
{event.hint && (
<Typography.Text type="secondary">
{t(`pages.settings.${event.hint}`)}
</Typography.Text>
)}
</Space>
)}
</NotificationEvent>
))}
</Space>
@@ -20,6 +20,7 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'outbound.down',
label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold',
hint: 'eventDownThresholdHint',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
@@ -44,7 +45,23 @@ const GROUPS: NotificationGroupConfig[] = [
icon: <DesktopOutlined aria-hidden />,
title: 'eventGroupNode',
events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
{
key: 'node.down',
label: 'eventNodeDown',
settingKey: 'nodeDownThreshold',
hint: 'eventDownThresholdHint',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber
size="small"
min={1}
max={100}
value={value}
onChange={onChange}
aria-label={ariaLabel}
style={{ width: 80 }}
/>
),
},
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
],
},
@@ -4,6 +4,7 @@ export interface NotificationEventConfig {
key: string;
label: string;
settingKey: string;
hint?: string;
extra?: (props: {
value: number;
onChange: (v: number | null) => void;