feat(nodes): add distinct purple indicator when panel is online but Xray core failed (#5040)

* feat(nodes): add distinct purple indicator when panel is online but Xray core failed

Currently nodes only show binary online/offline based on panel API reachability.

This adds a third state:
- Green: panel reachable + Xray healthy
- Purple pulsing dot + "Online (Xray Error)": panel API works (management actions still available) but the node Xray process is in error or stopped. Tooltip shows the remote xrayError.
- Red: unreachable (unchanged)

Backend now captures xray.state + xray.errorMsg from /panel/api/server/status heartbeats and probes.
New fields on Node + NodeSummary, forwarded for transitive nodes.
Frontend Zod + NodeList rendering + dedicated .xray-error-dot CSS (color #722ED1) + i18n key.

Color chosen purple per feedback after initial implementation.

Refs: worktree xray-failed-in-nodes

* fix: remove invalid JSON comment causing CI failures

* chore: regenerate OpenAPI schemas and types for xray error indicators

* chore: regenerate examples and schemas for xray error indicators

* chore: regenerate missing openapi.json examples

* fix

---------

Co-authored-by: Rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-06-08 20:24:00 +02:00
committed by GitHub
parent 0daedd3db9
commit 1c74b995c3
25 changed files with 239 additions and 49 deletions
+18 -1
View File
@@ -35,6 +35,11 @@ type HeartbeatPatch struct {
MemPct float64
UptimeSecs uint64
LastError string
// XrayState and XrayError come from the remote /panel/api/server/status when the
// panel API is reachable. They allow distinguishing panel connectivity from
// Xray core health on the node.
XrayState string
XrayError string
}
type NodeService struct{}
@@ -474,6 +479,8 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
"mem_pct": p.MemPct,
"uptime_secs": p.UptimeSecs,
"last_error": p.LastError,
"xray_state": p.XrayState,
"xray_error": p.XrayError,
}
// Only learn the GUID; never clear a known one if an old-build node (or a
// failed probe) reports none, so the stable identity survives blips.
@@ -607,7 +614,9 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
Total uint64 `json:"total"`
} `json:"mem"`
Xray struct {
Version string `json:"version"`
Version string `json:"version"`
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
} `json:"xray"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
@@ -628,6 +637,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
}
patch.XrayVersion = o.Xray.Version
patch.XrayState = o.Xray.State
patch.XrayError = o.Xray.ErrorMsg
patch.PanelVersion = o.PanelVersion
patch.Guid = o.PanelGuid
patch.UptimeSecs = o.Uptime
@@ -643,6 +654,10 @@ type ProbeResultUI struct {
MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
Error string `json:"error"`
// XrayState/XrayError are populated on successful probes even when the node's
// Xray core is not healthy. The UI uses them for a distinct "panel ok, xray failed" indicator.
XrayState string `json:"xrayState"`
XrayError string `json:"xrayError"`
}
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
@@ -654,6 +669,8 @@ func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
MemPct: p.MemPct,
UptimeSecs: p.UptimeSecs,
Error: FriendlyProbeError(p.LastError),
XrayState: p.XrayState,
XrayError: p.XrayError,
}
if ok {
r.Status = "online"
+4
View File
@@ -40,6 +40,8 @@ func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
LatencyMs: n.LatencyMs,
PanelVersion: n.PanelVersion,
XrayVersion: n.XrayVersion,
XrayState: n.XrayState,
XrayError: n.XrayError,
})
}
return out, nil
@@ -140,6 +142,8 @@ func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
LatencyMs: sum.LatencyMs,
PanelVersion: sum.PanelVersion,
XrayVersion: sum.XrayVersion,
XrayState: sum.XrayState,
XrayError: sum.XrayError,
Transitive: true,
})
}
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "متصل",
"offline": "غير متصل",
"unknown": "غير معروف"
"unknown": "غير معروف",
"xrayError": "خطأ Xray",
"xrayStopped": "متوقف"
},
"toasts": {
"list": "فشل تحميل النودز",
+3 -1
View File
@@ -897,7 +897,9 @@
"statusValues": {
"online": "Online",
"offline": "Offline",
"unknown": "Unknown"
"unknown": "Unknown",
"xrayError": "Xray Error",
"xrayStopped": "Stopped"
},
"toasts": {
"list": "Failed to load nodes",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "En línea",
"offline": "Sin conexión",
"unknown": "Desconocido"
"unknown": "Desconocido",
"xrayError": "Error de Xray",
"xrayStopped": "Detenido"
},
"toasts": {
"list": "Error al cargar los nodos",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "آنلاین",
"offline": "آفلاین",
"unknown": "نامشخص"
"unknown": "نامشخص",
"xrayError": "خطای Xray",
"xrayStopped": "متوقف"
},
"toasts": {
"list": "بارگذاری نودها ناموفق",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "Online",
"offline": "Offline",
"unknown": "Tidak diketahui"
"unknown": "Tidak diketahui",
"xrayError": "Kesalahan Xray",
"xrayStopped": "Berhenti"
},
"toasts": {
"list": "Gagal memuat node",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "オンライン",
"offline": "オフライン",
"unknown": "不明"
"unknown": "不明",
"xrayError": "Xray エラー",
"xrayStopped": "停止"
},
"toasts": {
"list": "ノードの読み込みに失敗しました",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "Online",
"offline": "Offline",
"unknown": "Desconhecido"
"unknown": "Desconhecido",
"xrayError": "Erro do Xray",
"xrayStopped": "Parado"
},
"toasts": {
"list": "Falha ao carregar os nós",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "В сети",
"offline": "Не в сети",
"unknown": "Неизвестно"
"unknown": "Неизвестно",
"xrayError": "Ошибка Xray",
"xrayStopped": "Остановлен"
},
"toasts": {
"list": "Не удалось загрузить узлы",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "Çevrimiçi",
"offline": "Çevrimdışı",
"unknown": "Bilinmiyor"
"unknown": "Bilinmiyor",
"xrayError": "Xray Hatası",
"xrayStopped": "Durduruldu"
},
"toasts": {
"list": "Düğümler yüklenemedi",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "У мережі",
"offline": "Не в мережі",
"unknown": "Невідомо"
"unknown": "Невідомо",
"xrayError": "Помилка Xray",
"xrayStopped": "Зупинено"
},
"toasts": {
"list": "Не вдалося завантажити вузли",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "Trực tuyến",
"offline": "Ngoại tuyến",
"unknown": "Không xác định"
"unknown": "Không xác định",
"xrayError": "Lỗi Xray",
"xrayStopped": "Đã dừng"
},
"toasts": {
"list": "Không tải được danh sách nút",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "在线",
"offline": "离线",
"unknown": "未知"
"unknown": "未知",
"xrayError": "Xray 错误",
"xrayStopped": "已停止"
},
"toasts": {
"list": "加载节点失败",
+3 -1
View File
@@ -896,7 +896,9 @@
"statusValues": {
"online": "上線",
"offline": "離線",
"unknown": "未知"
"unknown": "未知",
"xrayError": "Xray 錯誤",
"xrayStopped": "已停止"
},
"toasts": {
"list": "載入節點失敗",