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
+12
View File
@@ -152,6 +152,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -639,6 +644,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -932,6 +938,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -1427,6 +1438,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
+12
View File
@@ -152,6 +152,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -639,6 +644,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -932,6 +938,11 @@
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -1427,6 +1438,7 @@
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -20,6 +20,7 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'outbound.down', key: 'outbound.down',
label: 'eventOutboundDown', label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold', settingKey: 'outboundDownThreshold',
hint: 'eventDownThresholdHint',
extra: ({ value, onChange, ariaLabel }) => ( extra: ({ value, onChange, ariaLabel }) => (
<InputNumber <InputNumber
size="small" size="small"
@@ -44,7 +45,23 @@ const GROUPS: NotificationGroupConfig[] = [
icon: <DesktopOutlined aria-hidden />, icon: <DesktopOutlined aria-hidden />,
title: 'eventGroupNode', title: 'eventGroupNode',
events: [ 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: '' }, { 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 { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import type { NotificationGroupConfig } from './types'; import type { NotificationGroupConfig } from './types';
@@ -55,12 +55,22 @@ export function NotificationGroup({
checked={selected.includes(event.key)} checked={selected.includes(event.key)}
onToggle={() => onToggle(event.key)} onToggle={() => onToggle(event.key)}
> >
{event.extra?.({ {event.extra && (
value: <Space size={8}>
Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0, {event.extra({
onChange: (v) => updateSetting({ [event.settingKey]: v }), value:
ariaLabel: t(`pages.settings.${event.label}`), 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> </NotificationEvent>
))} ))}
</Space> </Space>
@@ -20,6 +20,7 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'outbound.down', key: 'outbound.down',
label: 'eventOutboundDown', label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold', settingKey: 'outboundDownThreshold',
hint: 'eventDownThresholdHint',
extra: ({ value, onChange, ariaLabel }) => ( extra: ({ value, onChange, ariaLabel }) => (
<InputNumber <InputNumber
size="small" size="small"
@@ -44,7 +45,23 @@ const GROUPS: NotificationGroupConfig[] = [
icon: <DesktopOutlined aria-hidden />, icon: <DesktopOutlined aria-hidden />,
title: 'eventGroupNode', title: 'eventGroupNode',
events: [ 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: '' }, { key: 'node.up', label: 'eventNodeUp', settingKey: '' },
], ],
}, },
@@ -4,6 +4,7 @@ export interface NotificationEventConfig {
key: string; key: string;
label: string; label: string;
settingKey: string; settingKey: string;
hint?: string;
extra?: (props: { extra?: (props: {
value: number; value: number;
onChange: (v: number | null) => void; onChange: (v: number | null) => void;
+2
View File
@@ -39,6 +39,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapUserAttr": "", "ldapUserAttr": "",
"ldapUserFilter": "", "ldapUserFilter": "",
"ldapVlessField": "", "ldapVlessField": "",
"nodeDownThreshold": 1,
"outboundDownThreshold": 1, "outboundDownThreshold": 1,
"pageSize": 0, "pageSize": 0,
"panelOutbound": "", "panelOutbound": "",
@@ -227,6 +228,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapUserAttr": "", "ldapUserAttr": "",
"ldapUserFilter": "", "ldapUserFilter": "",
"ldapVlessField": "", "ldapVlessField": "",
"nodeDownThreshold": 1,
"outboundDownThreshold": 1, "outboundDownThreshold": 1,
"pageSize": 0, "pageSize": 0,
"panelOutbound": "", "panelOutbound": "",
+12
View File
@@ -126,6 +126,11 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -613,6 +618,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
@@ -906,6 +912,11 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"nodeDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -1401,6 +1412,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"nodeDownThreshold",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
+2
View File
@@ -47,6 +47,7 @@ export interface AllSetting {
ldapUserAttr: string; ldapUserAttr: string;
ldapUserFilter: string; ldapUserFilter: string;
ldapVlessField: string; ldapVlessField: string;
nodeDownThreshold: number;
outboundDownThreshold: number; outboundDownThreshold: number;
pageSize: number; pageSize: number;
panelOutbound: string; panelOutbound: string;
@@ -236,6 +237,7 @@ export interface AllSettingView {
ldapUserAttr: string; ldapUserAttr: string;
ldapUserFilter: string; ldapUserFilter: string;
ldapVlessField: string; ldapVlessField: string;
nodeDownThreshold: number;
outboundDownThreshold: number; outboundDownThreshold: number;
pageSize: number; pageSize: number;
panelOutbound: string; panelOutbound: string;
+2
View File
@@ -63,6 +63,7 @@ export const AllSettingSchema = z.object({
ldapUserAttr: z.string(), ldapUserAttr: z.string(),
ldapUserFilter: z.string(), ldapUserFilter: z.string(),
ldapVlessField: z.string(), ldapVlessField: z.string(),
nodeDownThreshold: z.number().int().min(1).max(100),
outboundDownThreshold: z.number().int().min(1).max(100), outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000), pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(), panelOutbound: z.string(),
@@ -253,6 +254,7 @@ export const AllSettingViewSchema = z.object({
ldapUserAttr: z.string(), ldapUserAttr: z.string(),
ldapUserFilter: z.string(), ldapUserFilter: z.string(),
ldapVlessField: z.string(), ldapVlessField: z.string(),
nodeDownThreshold: z.number().int().min(1).max(100),
outboundDownThreshold: z.number().int().min(1).max(100), outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000), pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(), panelOutbound: z.string(),
+5
View File
@@ -174,6 +174,7 @@ export class AllSetting {
smtpCpu = 80; smtpCpu = 80;
smtpMemory = 80; smtpMemory = 80;
outboundDownThreshold = 3; outboundDownThreshold = 3;
nodeDownThreshold = 1;
hasTgBotToken = false; hasTgBotToken = false;
hasTwoFactorToken = false; hasTwoFactorToken = false;
hasLdapPassword = false; hasLdapPassword = false;
@@ -216,6 +217,10 @@ export class AllSetting {
this.outboundDownThreshold = Number.isFinite(threshold) this.outboundDownThreshold = Number.isFinite(threshold)
? Math.min(100, Math.max(1, threshold)) ? Math.min(100, Math.max(1, threshold))
: 3; : 3;
const nodeThreshold = Math.round(Number(this.nodeDownThreshold));
this.nodeDownThreshold = Number.isFinite(nodeThreshold)
? Math.min(100, Math.max(1, nodeThreshold))
: 1;
} }
equals(other: AllSetting): boolean { equals(other: AllSetting): boolean {
+1
View File
@@ -39,6 +39,7 @@ export const AllSettingSchema = z
tgBotBackup: z.boolean().optional(), tgBotBackup: z.boolean().optional(),
tgCpu: z.number().int().min(0).max(100).optional(), tgCpu: z.number().int().min(0).max(100).optional(),
outboundDownThreshold: z.number().int().min(1).max(100).optional(), outboundDownThreshold: z.number().int().min(1).max(100).optional(),
nodeDownThreshold: z.number().int().min(1).max(100).optional(),
tgLang: z.string().optional(), tgLang: z.string().optional(),
twoFactorEnable: z.boolean().optional(), twoFactorEnable: z.boolean().optional(),
twoFactorToken: z.string().optional(), twoFactorToken: z.string().optional(),
+18 -1
View File
@@ -1475,7 +1475,7 @@ func runSeeders(isUsersEmpty bool) error {
} }
if empty && isUsersEmpty { if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban"}
for _, name := range seeders { for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err return err
@@ -1640,6 +1640,12 @@ func runSeeders(isUsersEmpty bool) error {
} }
} }
if !slices.Contains(seedersHistory, "NodeDownNotified") {
if err := seedNodeDownNotified(); err != nil {
return err
}
}
if err := seedHostsFromExternalProxy(); err != nil { if err := seedHostsFromExternalProxy(); err != nil {
return err return err
} }
@@ -1689,6 +1695,17 @@ func seedNodeInboundsAdopted() error {
return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
} }
// seedNodeDownNotified treats nodes already offline at upgrade as announced,
// so the first heartbeat after it does not re-send their node.down.
func seedNodeDownNotified() error {
if err := db.Model(&model.Node{}).
Where("status = ?", "offline").
Update("down_notified", true).Error; err != nil {
return err
}
return db.Create(&model.HistoryOfSeeders{SeederName: "NodeDownNotified"}).Error
}
// backfillEmptyHostGroupIds is idempotent and not seeder-gated: builds that // backfillEmptyHostGroupIds is idempotent and not seeder-gated: builds that
// predate group ids on the inbound-import path (and restored backups) can // predate group ids on the inbound-import path (and restored backups) can
// re-introduce hosts rows with an empty group_id, and such rows render as a // re-introduce hosts rows with an empty group_id, and such rows render as a
+2
View File
@@ -878,6 +878,8 @@ type Node struct {
// save that grows the selection zeroes it so reconcile waits before sweeping. // save that grows the selection zeroes it so reconcile waits before sweeping.
InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"` InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"`
DownNotified bool `json:"-" gorm:"column:down_notified;default:false"`
InboundCount int `json:"inboundCount" gorm:"-" example:"5"` InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
ClientCount int `json:"clientCount" gorm:"-" example:"27"` ClientCount int `json:"clientCount" gorm:"-" example:"27"`
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"` OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
@@ -0,0 +1,43 @@
package database
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestNodeDownNotifiedSeederMarksOfflineNodesAnnounced(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := InitDB(config.GetDBPath()); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Where("seeder_name = ?", "NodeDownNotified").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
offline := model.Node{Name: "offline", Address: "10.0.0.1", Port: 1, Status: "offline"}
online := model.Node{Name: "online", Address: "10.0.0.2", Port: 1, Status: "online"}
for _, n := range []*model.Node{&offline, &online} {
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node %s: %v", n.Name, err)
}
}
if err := runSeeders(false); err != nil {
t.Fatalf("runSeeders: %v", err)
}
for _, tc := range []struct {
node *model.Node
want bool
}{{&offline, true}, {&online, false}} {
var got model.Node
if err := db.First(&got, tc.node.Id).Error; err != nil {
t.Fatalf("reload node %s: %v", tc.node.Name, err)
}
if got.DownNotified != tc.want {
t.Errorf("node %s DownNotified = %v, want %v", tc.node.Name, got.DownNotified, tc.want)
}
}
}
+1
View File
@@ -80,6 +80,7 @@ type AllSetting struct {
DiscordEnabledEvents string `json:"discordEnabledEvents" form:"discordEnabledEvents"` DiscordEnabledEvents string `json:"discordEnabledEvents" form:"discordEnabledEvents"`
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"` OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
NodeDownThreshold int `json:"nodeDownThreshold" form:"nodeDownThreshold" validate:"gte=1,lte=100"`
TimeLocation string `json:"timeLocation" form:"timeLocation"` TimeLocation string `json:"timeLocation" form:"timeLocation"`
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
@@ -0,0 +1,118 @@
package job
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func switchableNode(t *testing.T) *atomic.Bool {
t.Helper()
xuilogger.InitLogger(logging.ERROR)
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
t.Cleanup(func() { runtime.SetManager(nil) })
var up atomic.Bool
up.Store(true)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !up.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "server/status") {
_, _ = w.Write([]byte(`{"success":true,"obj":{"panelGuid":"flaky-guid","xray":{"state":"running"}}}`))
return
}
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
}))
t.Cleanup(srv.Close)
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
portNum, _ := strconv.Atoi(port)
node := &model.Node{
Name: "flaky", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
}
if err := database.GetDB().Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
return &up
}
func TestHeartbeatHoldsNodeDownUntilThreshold(t *testing.T) {
cases := []struct {
name string
probes []bool
restartBefore int
want []string
}{
{
name: "flapping below threshold",
probes: []bool{false, true, false, false, true, false, true},
},
{
name: "outage reaching threshold",
probes: []bool{false, false, false, false, true},
want: []string{"probe 3: node.down", "probe 5: node.up"},
},
{
name: "restart mid-streak still announces the outage",
probes: []bool{false, false, false, false, true},
restartBefore: 2,
want: []string{"probe 4: node.down", "probe 5: node.up"},
},
{
name: "restart after announced outage does not repeat it",
probes: []bool{false, false, false, false, false, false, true},
restartBefore: 5,
want: []string{"probe 3: node.down", "probe 7: node.up"},
},
{
name: "restart after recovery announces the next outage",
probes: []bool{false, false, false, true, false, false, false, true},
restartBefore: 5,
want: []string{"probe 3: node.down", "probe 4: node.up", "probe 7: node.down", "probe 8: node.up"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
up := switchableNode(t)
if err := (&service.SettingService{}).SetNodeDownThreshold(3); err != nil {
t.Fatalf("SetNodeDownThreshold: %v", err)
}
events := collectNodeEvents(t)
job := NewNodeHeartbeatJob()
var got []string
for i, probeOK := range tc.probes {
if i+1 == tc.restartBefore {
job = NewNodeHeartbeatJob()
}
up.Store(probeOK)
job.Run()
for _, e := range events()[len(got):] {
got = append(got, fmt.Sprintf("probe %d: %s", i+1, e.Type))
}
}
if strings.Join(got, ", ") != strings.Join(tc.want, ", ") {
t.Fatalf("notifications = %q, want %q", got, tc.want)
}
})
}
}
+62 -25
View File
@@ -27,8 +27,20 @@ const (
) )
type NodeHeartbeatJob struct { type NodeHeartbeatJob struct {
nodeService service.NodeService nodeService service.NodeService
running sync.Mutex settingService service.SettingService
running sync.Mutex
health map[int]nodeHealth
}
type nodeHealth struct {
failStreak int
notified bool
}
type nodeProbe struct {
node *model.Node
patch service.HeartbeatPatch
} }
func NewNodeHeartbeatJob() *NodeHeartbeatJob { func NewNodeHeartbeatJob() *NodeHeartbeatJob {
@@ -53,8 +65,8 @@ func (j *NodeHeartbeatJob) Run() {
sem := make(chan struct{}, nodeHeartbeatConcurrency) sem := make(chan struct{}, nodeHeartbeatConcurrency)
var wg sync.WaitGroup var wg sync.WaitGroup
var transitionsMu sync.Mutex var probesMu sync.Mutex
var transitions []eventbus.Event var probes []nodeProbe
for _, n := range nodes { for _, n := range nodes {
if !n.Enable { if !n.Enable {
continue continue
@@ -65,15 +77,14 @@ func (j *NodeHeartbeatJob) Run() {
common.GoRecover("node-heartbeat:"+n.Name, func() { common.GoRecover("node-heartbeat:"+n.Name, func() {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
if event := j.probeOne(n); event != nil { patch := j.probeOne(n)
transitionsMu.Lock() probesMu.Lock()
transitions = append(transitions, *event) probes = append(probes, nodeProbe{node: n, patch: patch})
transitionsMu.Unlock() probesMu.Unlock()
}
}) })
} }
wg.Wait() wg.Wait()
publishNodeTransitions(transitions) publishNodeTransitions(j.healthTransitions(probes))
if !websocket.HasClients() { if !websocket.HasClients() {
return return
@@ -86,10 +97,9 @@ func (j *NodeHeartbeatJob) Run() {
websocket.BroadcastNodes(updated) websocket.BroadcastNodes(updated)
} }
func (j *NodeHeartbeatJob) probeOne(n *model.Node) *eventbus.Event { func (j *NodeHeartbeatJob) probeOne(n *model.Node) service.HeartbeatPatch {
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout) ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
defer cancel() defer cancel()
prevStatus := n.Status
patch, err := j.nodeService.Probe(ctx, n) patch, err := j.nodeService.Probe(ctx, n)
if err != nil { if err != nil {
patch.Status = "offline" patch.Status = "offline"
@@ -109,26 +119,53 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) *eventbus.Event {
} else { } else {
j.nodeService.ClearDescendants(n.Id) j.nodeService.ClearDescendants(n.Id)
} }
return nodeTransitionEvent(n, prevStatus, patch) return patch
} }
// nodeTransitionEvent is node.down / node.up on a genuine state change only; an unknown func (j *NodeHeartbeatJob) healthTransitions(probes []nodeProbe) []eventbus.Event {
// previous status (fresh start) counts as not-online, so it never yields node.down. threshold := 1
func nodeTransitionEvent(n *model.Node, prevStatus string, patch service.HeartbeatPatch) *eventbus.Event { if v, err := j.settingService.GetNodeDownThreshold(); err == nil && v > 0 {
var eventType eventbus.EventType threshold = v
switch {
case prevStatus == "online" && patch.Status == "offline":
eventType = eventbus.EventNodeDown
case prevStatus != "online" && patch.Status == "online":
eventType = eventbus.EventNodeUp
default:
return nil
} }
prev := j.health
j.health = make(map[int]nodeHealth, len(probes))
var events []eventbus.Event
for _, p := range probes {
h, seen := prev[p.node.Id]
if !seen {
h.notified = p.node.DownNotified
}
if p.patch.Status == "online" {
if h.notified {
events = append(events, nodeHealthEvent(eventbus.EventNodeUp, p.node, p.patch))
j.saveDownNotified(p.node.Id, false)
}
h = nodeHealth{}
} else {
h.failStreak++
if h.failStreak >= threshold && !h.notified {
events = append(events, nodeHealthEvent(eventbus.EventNodeDown, p.node, p.patch))
h.notified = true
j.saveDownNotified(p.node.Id, true)
}
}
j.health[p.node.Id] = h
}
return events
}
func (j *NodeHeartbeatJob) saveDownNotified(id int, notified bool) {
if err := j.nodeService.SetDownNotified(id, notified); err != nil {
logger.Warning("node heartbeat: save down-notified flag for node", id, "failed:", err)
}
}
func nodeHealthEvent(eventType eventbus.EventType, n *model.Node, patch service.HeartbeatPatch) eventbus.Event {
source := n.Name source := n.Name
if source == "" { if source == "" {
source = "node-" + strconv.Itoa(n.Id) source = "node-" + strconv.Itoa(n.Id)
} }
return &eventbus.Event{ return eventbus.Event{
Type: eventType, Type: eventType,
Source: source, Source: source,
Data: &eventbus.NodeHealthData{ Data: &eventbus.NodeHealthData{
@@ -18,6 +18,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/eventbus"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
) )
// goingDownNodes seeds n online nodes whose address refuses connections, so the // goingDownNodes seeds n online nodes whose address refuses connections, so the
@@ -28,6 +29,9 @@ func goingDownNodes(t *testing.T, n int) {
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})) runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
t.Cleanup(func() { runtime.SetManager(nil) }) t.Cleanup(func() { runtime.SetManager(nil) })
if err := (&service.SettingService{}).SetNodeDownThreshold(1); err != nil {
t.Fatalf("SetNodeDownThreshold: %v", err)
}
srv := httptest.NewServer(nil) srv := httptest.NewServer(nil)
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":") host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
portNum, _ := strconv.Atoi(port) portNum, _ := strconv.Atoi(port)
+6
View File
@@ -1064,6 +1064,12 @@ func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
Update("config_dirty", false).Error Update("config_dirty", false).Error
} }
func (s *NodeService) SetDownNotified(id int, notified bool) error {
return database.GetDB().Model(model.Node{}).
Where("id = ?", id).
Update("down_notified", notified).Error
}
func (s *NodeService) MarkNodeInboundsAdopted(id int) error { func (s *NodeService) MarkNodeInboundsAdopted(id int) error {
if id <= 0 { if id <= 0 {
return nil return nil
+10
View File
@@ -238,6 +238,8 @@ var defaultValueMap = map[string]string{
// Consecutive failed observatory probes before an outbound.down event fires // Consecutive failed observatory probes before an outbound.down event fires
"outboundDownThreshold": "3", "outboundDownThreshold": "3",
"nodeDownThreshold": "1",
// Email (SMTP) notifications // Email (SMTP) notifications
"smtpEnable": "false", "smtpEnable": "false",
"smtpHost": "", "smtpHost": "",
@@ -1641,6 +1643,14 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error {
return s.setInt("outboundDownThreshold", value) return s.setInt("outboundDownThreshold", value)
} }
func (s *SettingService) GetNodeDownThreshold() (int, error) {
return s.getInt("nodeDownThreshold")
}
func (s *SettingService) SetNodeDownThreshold(value int) error {
return s.setInt("nodeDownThreshold", value)
}
// SecretClears marks redacted secrets the user explicitly emptied. Without a // SecretClears marks redacted secrets the user explicitly emptied. Without a
// flag, a blank submitted secret means "unchanged" (the field is always served // flag, a blank submitted secret means "unchanged" (the field is always served
// blank to the browser) and the stored value is preserved. // blank to the browser) and the stored value is preserved.
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "العقد", "eventGroupNode": "العقد",
"eventOutboundDown": "غير متصل", "eventOutboundDown": "غير متصل",
"eventOutboundUp": "متصل", "eventOutboundUp": "متصل",
"eventDownThresholdHint": "فحوصات فاشلة متتالية",
"eventXrayCrash": "تعطّل", "eventXrayCrash": "تعطّل",
"eventNodeDown": "غير متصلة", "eventNodeDown": "غير متصلة",
"eventNodeUp": "متصلة", "eventNodeUp": "متصلة",
+1
View File
@@ -1610,6 +1610,7 @@
"eventGroupNode": "Nodes", "eventGroupNode": "Nodes",
"eventOutboundDown": "Down", "eventOutboundDown": "Down",
"eventOutboundUp": "Up", "eventOutboundUp": "Up",
"eventDownThresholdHint": "failed checks in a row",
"eventXrayCrash": "Crash", "eventXrayCrash": "Crash",
"eventNodeDown": "Down", "eventNodeDown": "Down",
"eventNodeUp": "Up", "eventNodeUp": "Up",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "Nodos", "eventGroupNode": "Nodos",
"eventOutboundDown": "Caído", "eventOutboundDown": "Caído",
"eventOutboundUp": "Activo", "eventOutboundUp": "Activo",
"eventDownThresholdHint": "comprobaciones fallidas seguidas",
"eventXrayCrash": "Caída", "eventXrayCrash": "Caída",
"eventNodeDown": "Caído", "eventNodeDown": "Caído",
"eventNodeUp": "Activo", "eventNodeUp": "Activo",
+1
View File
@@ -1492,6 +1492,7 @@
"eventGroupNode": "نودها", "eventGroupNode": "نودها",
"eventOutboundDown": "قطع", "eventOutboundDown": "قطع",
"eventOutboundUp": "وصل", "eventOutboundUp": "وصل",
"eventDownThresholdHint": "بررسی ناموفق پیاپی",
"eventXrayCrash": "کرش", "eventXrayCrash": "کرش",
"eventNodeDown": "قطع", "eventNodeDown": "قطع",
"eventNodeUp": "وصل", "eventNodeUp": "وصل",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "Node", "eventGroupNode": "Node",
"eventOutboundDown": "Mati", "eventOutboundDown": "Mati",
"eventOutboundUp": "Aktif", "eventOutboundUp": "Aktif",
"eventDownThresholdHint": "pemeriksaan gagal berturut-turut",
"eventXrayCrash": "Crash", "eventXrayCrash": "Crash",
"eventNodeDown": "Mati", "eventNodeDown": "Mati",
"eventNodeUp": "Aktif", "eventNodeUp": "Aktif",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "ノード", "eventGroupNode": "ノード",
"eventOutboundDown": "ダウン", "eventOutboundDown": "ダウン",
"eventOutboundUp": "アップ", "eventOutboundUp": "アップ",
"eventDownThresholdHint": "回連続でチェック失敗",
"eventXrayCrash": "クラッシュ", "eventXrayCrash": "クラッシュ",
"eventNodeDown": "ダウン", "eventNodeDown": "ダウン",
"eventNodeUp": "アップ", "eventNodeUp": "アップ",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "Nós", "eventGroupNode": "Nós",
"eventOutboundDown": "Inativo", "eventOutboundDown": "Inativo",
"eventOutboundUp": "Ativo", "eventOutboundUp": "Ativo",
"eventDownThresholdHint": "verificações com falha seguidas",
"eventXrayCrash": "Falha", "eventXrayCrash": "Falha",
"eventNodeDown": "Inativo", "eventNodeDown": "Inativo",
"eventNodeUp": "Ativo", "eventNodeUp": "Ativo",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "Узлы", "eventGroupNode": "Узлы",
"eventOutboundDown": "Недоступен", "eventOutboundDown": "Недоступен",
"eventOutboundUp": "Работает", "eventOutboundUp": "Работает",
"eventDownThresholdHint": "неудачных проверок подряд",
"eventXrayCrash": "Сбой", "eventXrayCrash": "Сбой",
"eventNodeDown": "Недоступен", "eventNodeDown": "Недоступен",
"eventNodeUp": "В сети", "eventNodeUp": "В сети",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "Düğümler", "eventGroupNode": "Düğümler",
"eventOutboundDown": "Çevrimdışı", "eventOutboundDown": "Çevrimdışı",
"eventOutboundUp": "Çevrimiçi", "eventOutboundUp": "Çevrimiçi",
"eventDownThresholdHint": "art arda başarısız kontrol",
"eventXrayCrash": "Çökme", "eventXrayCrash": "Çökme",
"eventNodeDown": "Çevrimdışı", "eventNodeDown": "Çevrimdışı",
"eventNodeUp": "Çevrimiçi", "eventNodeUp": "Çevrimiçi",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "Вузли", "eventGroupNode": "Вузли",
"eventOutboundDown": "Недоступне", "eventOutboundDown": "Недоступне",
"eventOutboundUp": "Доступне", "eventOutboundUp": "Доступне",
"eventDownThresholdHint": "невдалих перевірок поспіль",
"eventXrayCrash": "Збій", "eventXrayCrash": "Збій",
"eventNodeDown": "Недоступний", "eventNodeDown": "Недоступний",
"eventNodeUp": "Доступний", "eventNodeUp": "Доступний",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "Node", "eventGroupNode": "Node",
"eventOutboundDown": "Ngừng hoạt động", "eventOutboundDown": "Ngừng hoạt động",
"eventOutboundUp": "Hoạt động", "eventOutboundUp": "Hoạt động",
"eventDownThresholdHint": "lần kiểm tra thất bại liên tiếp",
"eventXrayCrash": "Sự cố", "eventXrayCrash": "Sự cố",
"eventNodeDown": "Ngừng hoạt động", "eventNodeDown": "Ngừng hoạt động",
"eventNodeUp": "Hoạt động", "eventNodeUp": "Hoạt động",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "节点", "eventGroupNode": "节点",
"eventOutboundDown": "断开", "eventOutboundDown": "断开",
"eventOutboundUp": "恢复", "eventOutboundUp": "恢复",
"eventDownThresholdHint": "次连续检查失败",
"eventXrayCrash": "崩溃", "eventXrayCrash": "崩溃",
"eventNodeDown": "离线", "eventNodeDown": "离线",
"eventNodeUp": "上线", "eventNodeUp": "上线",
+1
View File
@@ -1488,6 +1488,7 @@
"eventGroupNode": "節點", "eventGroupNode": "節點",
"eventOutboundDown": "中斷", "eventOutboundDown": "中斷",
"eventOutboundUp": "恢復", "eventOutboundUp": "恢復",
"eventDownThresholdHint": "次連續檢查失敗",
"eventXrayCrash": "當機", "eventXrayCrash": "當機",
"eventNodeDown": "離線", "eventNodeDown": "離線",
"eventNodeUp": "上線", "eventNodeUp": "上線",