mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-31 15:37:14 +00:00
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:
@@ -68,18 +68,63 @@ function badgeStatus(status?: string): BadgeProps['status'] {
|
||||
}
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status?: string }) {
|
||||
if (status === 'online') return <span className="online-dot" />;
|
||||
interface HealthProps {
|
||||
status?: string;
|
||||
xrayState?: string;
|
||||
xrayError?: string;
|
||||
}
|
||||
|
||||
// Purple: the node's panel API is reachable (status=online) but its Xray core
|
||||
// has failed or been stopped. Distinct from a normal offline/unknown node.
|
||||
const XRAY_ERROR_COLOR = '#722ED1';
|
||||
|
||||
// True when the panel is online but Xray itself reports error/stop.
|
||||
function hasXrayProblem(status?: string, xrayState?: string): boolean {
|
||||
if (status !== 'online') return false;
|
||||
const xs = (xrayState || '').toLowerCase().trim();
|
||||
return xs === 'error' || xs === 'stop';
|
||||
}
|
||||
|
||||
// Tooltip text + icon color for the status cell. A real probe error (lastError)
|
||||
// is a warning and takes precedence; otherwise an Xray-core problem shows purple.
|
||||
function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
|
||||
const tip = record.lastError || (hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') || '';
|
||||
const iconColor = !record.lastError && hasXrayProblem(record.status, record.xrayState)
|
||||
? XRAY_ERROR_COLOR
|
||||
: 'var(--ant-color-warning)';
|
||||
return { tip, iconColor };
|
||||
}
|
||||
|
||||
function StatusDot({ status, xrayState }: HealthProps) {
|
||||
if (status === 'online') {
|
||||
return hasXrayProblem(status, xrayState)
|
||||
? <span className="xray-error-dot" />
|
||||
: <span className="online-dot" />;
|
||||
}
|
||||
return <Badge status={badgeStatus(status)} />;
|
||||
}
|
||||
|
||||
function StatusLabel({ status }: { status?: string }) {
|
||||
function StatusLabel({ status, xrayState }: HealthProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}>
|
||||
{t(`pages.nodes.statusValues.${status || 'unknown'}`)}
|
||||
</span>
|
||||
);
|
||||
if (status === 'online') {
|
||||
const xs = (xrayState || '').toLowerCase().trim();
|
||||
if (xs === 'error' || xs === 'stop') {
|
||||
const detail = xs === 'error'
|
||||
? t('pages.nodes.statusValues.xrayError')
|
||||
: t('pages.nodes.statusValues.xrayStopped');
|
||||
return (
|
||||
<span style={{ color: XRAY_ERROR_COLOR }}>
|
||||
{t('pages.nodes.statusValues.online')} ({detail})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span style={{ color: 'var(--ant-color-success)' }}>
|
||||
{t('pages.nodes.statusValues.online')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
|
||||
}
|
||||
|
||||
function formatPct(p?: number): string {
|
||||
@@ -271,17 +316,20 @@ export default function NodeList({
|
||||
title: t('pages.nodes.status'),
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
render: (_value, record) => (
|
||||
<Space size={4}>
|
||||
<StatusDot status={record.status} />
|
||||
<StatusLabel status={record.status} />
|
||||
{record.lastError && (
|
||||
<Tooltip title={record.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
render: (_value, record) => {
|
||||
const { tip, iconColor } = statusIssue(record);
|
||||
return (
|
||||
<Space size={4}>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<StatusLabel status={record.status} xrayState={record.xrayState} />
|
||||
{tip && (
|
||||
<Tooltip title={tip}>
|
||||
<ExclamationCircleOutlined style={{ color: iconColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.cpu'),
|
||||
@@ -389,7 +437,7 @@ export default function NodeList({
|
||||
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
|
||||
<div className="card-head">
|
||||
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
||||
<StatusDot status={record.status} />
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions">
|
||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
|
||||
@@ -400,7 +448,7 @@ export default function NodeList({
|
||||
<div key={record.id} className="node-card">
|
||||
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
||||
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
||||
<StatusDot status={record.status} />
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title={t('info')}>
|
||||
@@ -494,13 +542,16 @@ export default function NodeList({
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.status')}</span>
|
||||
<StatusDot status={statsNode.status} />
|
||||
<StatusLabel status={statsNode.status} />
|
||||
{statsNode.lastError && (
|
||||
<Tooltip title={statsNode.lastError}>
|
||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
|
||||
<StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
|
||||
{(() => {
|
||||
const { tip, iconColor } = statusIssue(statsNode);
|
||||
return tip ? (
|
||||
<Tooltip title={tip}>
|
||||
<ExclamationCircleOutlined style={{ color: iconColor }} />
|
||||
</Tooltip>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.cpu')}</span>
|
||||
|
||||
@@ -83,12 +83,15 @@ export default function NodesPage() {
|
||||
const msg = await probe(node.id);
|
||||
if (msg?.success && msg.obj) {
|
||||
if (msg.obj.status === 'online') {
|
||||
// Even if xray is in error/stop on the node we still reached its panel API.
|
||||
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
||||
} else {
|
||||
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
||||
}
|
||||
}
|
||||
}, [probe, t, messageApi]);
|
||||
// Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
|
||||
refetch();
|
||||
}, [probe, t, messageApi, refetch]);
|
||||
|
||||
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
|
||||
await setEnable(node.id, next);
|
||||
|
||||
Reference in New Issue
Block a user