feat(nodes): bulk panel self-update with live online indicator

Adds the ability to update node panels to the latest release from the Nodes
page: select online, enabled nodes (checkboxes) and trigger their official
self-updater, or use the per-row Update action. A node whose reported panel
version trails the latest GitHub release is flagged with an 'update available'
tag (compared via lib/panel-version, mirroring the Go isNewerVersion).

Backend: Remote.UpdatePanel calls the node's existing
POST /panel/api/server/updatePanel; NodeService.UpdatePanels fans out over the
selected ids, skipping disabled/offline nodes with a per-node reason; exposed
as POST /panel/api/nodes/updatePanel (documented in endpoints.ts + openapi.json).

The bulk request sends a JSON body, so it sets Content-Type: application/json
explicitly — axios defaults POST to form-urlencoded, which made ShouldBindJSON
fail with 'invalid character i'.

Also reuses the clients-page online cue on the Nodes page: a pulsing green dot
plus green label for an online node. The .online-dot style moved to the shared
styles/utils.css so both pages load it.

Translations for all new node keys added across every language file.
This commit is contained in:
MHSanaei
2026-06-01 07:03:06 +02:00
parent c8df1b19ff
commit 971843f669
25 changed files with 511 additions and 42 deletions
@@ -8,6 +8,13 @@ import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
export type { ProbeResult };
export interface NodeUpdateResult {
id: number;
name?: string;
ok: boolean;
error?: string;
}
export function useNodeMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
@@ -44,12 +51,21 @@ export function useNodeMutations() {
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const updatePanelsMut = useMutation({
mutationFn: (ids: number[]) =>
HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids }, {
headers: { 'Content-Type': 'application/json' },
}),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
return {
create: (payload: Partial<NodeRecord>) => createMut.mutateAsync(payload),
update: (id: number, payload: Partial<NodeRecord>) => updateMut.mutateAsync({ id, payload }),
remove: (id: number) => removeMut.mutateAsync(id),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
probe: (id: number) => probeMut.mutateAsync(id),
updatePanels: (ids: number[]): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync(ids),
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
+29
View File
@@ -0,0 +1,29 @@
// Mirror of web/service/panel.go isNewerVersion: parse a vMAJOR.MINOR.PATCH tag
// and report whether `latest` is ahead of `current`. When either side isn't a
// clean three-part numeric tag, fall back to a normalized string inequality —
// the same heuristic the Go side uses so the node "update available" badge
// agrees with what the server would decide.
function parseVersionParts(version: string): [number, number, number] | null {
const parts = version.trim().replace(/^v/, '').split('.');
if (parts.length !== 3) return null;
const out: number[] = [];
for (const part of parts) {
if (!/^\d+$/.test(part)) return null;
out.push(Number(part));
}
return [out[0], out[1], out[2]];
}
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
if (!latest || !current) return false;
const a = parseVersionParts(latest);
const b = parseVersionParts(current);
if (!a || !b) {
return latest.trim().replace(/^v/, '') !== current.trim().replace(/^v/, '');
}
for (let i = 0; i < 3; i++) {
if (a[i] > b[i]) return true;
if (a[i] < b[i]) return false;
}
return false;
}
+7
View File
@@ -777,6 +777,13 @@ export const sections: readonly Section[] = [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
],
},
{
method: 'POST',
path: '/panel/api/nodes/updatePanel',
summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.',
body: '{\n "ids": [1, 2, 3]\n}',
response: '{\n "success": true,\n "obj": [\n { "id": 1, "name": "de-1", "ok": true },\n { "id": 2, "name": "fr-1", "ok": false, "error": "node is offline" }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/nodes/history/:id/:metric/:bucket',
@@ -62,26 +62,6 @@
.dot-orange { background: var(--ant-color-warning); }
.dot-gray { background: var(--ant-color-text-quaternary); }
.online-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-inline-end: 5px;
vertical-align: middle;
background: var(--ant-color-success);
animation: online-blink 1.1s ease-in-out infinite;
}
@keyframes online-blink {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.55); }
50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }
}
@media (prefers-reduced-motion: reduce) {
.online-dot { animation: none; }
}
.status-tag {
margin: 0 0 0 4px;
font-size: 11px;
+73 -8
View File
@@ -16,6 +16,7 @@ import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ClusterOutlined,
CloudDownloadOutlined,
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
@@ -30,17 +31,27 @@ import {
import NodeHistoryPanel from './NodeHistoryPanel';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isPanelUpdateAvailable } from '@/lib/panel-version';
import './NodeList.css';
interface NodeListProps {
nodes: NodeRecord[];
loading?: boolean;
isMobile?: boolean;
latestVersion?: string;
selectedIds: number[];
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onEdit: (node: NodeRecord) => void;
onDelete: (node: NodeRecord) => void;
onProbe: (node: NodeRecord) => void;
onToggleEnable: (node: NodeRecord, next: boolean) => void;
onUpdateNode: (node: NodeRecord) => void;
onUpdateSelected: () => void;
}
function isUpdateEligible(n: NodeRecord): boolean {
return !!n.enable && n.status === 'online';
}
interface NodeRow extends NodeRecord {
@@ -56,6 +67,20 @@ function badgeStatus(status?: string): BadgeProps['status'] {
}
}
function StatusDot({ status }: { status?: string }) {
if (status === 'online') return <span className="online-dot" />;
return <Badge status={badgeStatus(status)} />;
}
function StatusLabel({ status }: { status?: string }) {
const { t } = useTranslation();
return (
<span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}>
{t(`pages.nodes.statusValues.${status || 'unknown'}`)}
</span>
);
}
function formatPct(p?: number): string {
if (typeof p !== 'number' || Number.isNaN(p)) return '-';
return `${p.toFixed(1)}%`;
@@ -88,11 +113,16 @@ export default function NodeList({
nodes,
loading = false,
isMobile = false,
latestVersion = '',
selectedIds,
onSelectionChange,
onAdd,
onEdit,
onDelete,
onProbe,
onToggleEnable,
onUpdateNode,
onUpdateSelected,
}: NodeListProps) {
const { t } = useTranslation();
const relativeTime = useRelativeTime();
@@ -122,12 +152,17 @@ export default function NodeList({
{
title: t('pages.nodes.actions'),
align: 'center',
width: 160,
width: 190,
render: (_value, record) => (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
@@ -193,8 +228,8 @@ export default function NodeList({
align: 'center',
render: (_value, record) => (
<Space size={4}>
<Badge status={badgeStatus(record.status)} />
<span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
<StatusDot status={record.status} />
<StatusLabel status={record.status} />
{record.lastError && (
<Tooltip title={record.lastError}>
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
@@ -227,7 +262,22 @@ export default function NodeList({
title: t('pages.nodes.panelVersion') || 'Panel version',
dataIndex: 'panelVersion',
align: 'center',
render: (_value, record) => record.panelVersion || '-',
render: (_value, record) => {
const canUpdate = isUpdateEligible(record)
&& isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
return (
<Space size={4}>
<span>{record.panelVersion || '-'}</span>
{canUpdate && (
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
{t('pages.nodes.updateAvailable')}
</Tag>
</Tooltip>
)}
</Space>
);
},
},
{
title: t('pages.nodes.uptime'),
@@ -266,7 +316,7 @@ export default function NodeList({
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
return (
<Card size="small" hoverable>
@@ -274,6 +324,11 @@ export default function NodeList({
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{t('pages.nodes.addNode')}
</Button>
{selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
</Button>
)}
</div>
{isMobile ? (
@@ -289,7 +344,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' : ''}`} />
<Badge status={badgeStatus(record.status)} />
<StatusDot status={record.status} />
<span className="node-name">{record.name}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('info')}>
@@ -313,6 +368,11 @@ export default function NodeList({
label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
onClick: () => onProbe(record),
},
...(isUpdateEligible(record) ? [{
key: 'update',
label: <><CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}</>,
onClick: () => onUpdateNode(record),
}] : []),
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
@@ -378,8 +438,8 @@ export default function NodeList({
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.status')}</span>
<Badge status={badgeStatus(statsNode.status)} />
<span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
<StatusDot status={statsNode.status} />
<StatusLabel status={statsNode.status} />
{statsNode.lastError && (
<Tooltip title={statsNode.lastError}>
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
@@ -439,6 +499,11 @@ export default function NodeList({
scroll={{ x: 'max-content' }}
size="middle"
rowKey="id"
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => onSelectionChange(keys as number[]),
getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
}}
locale={{
emptyText: (
<div className="card-empty">
+65 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd';
import {
CheckCircleOutlined,
@@ -17,6 +18,8 @@ import AppSidebar from '@/layouts/AppSidebar';
import NodeList from './NodeList';
import NodeFormModal from './NodeFormModal';
import { setMessageInstance } from '@/utils/messageBus';
import { HttpUtil } from '@/utils';
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
export default function NodesPage() {
const { t } = useTranslation();
@@ -27,11 +30,21 @@ export default function NodesPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes, loading, fetched, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, probe } = useNodeMutations();
const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
const { data: latestVersion = '' } = useQuery({
queryKey: ['server', 'panelUpdateInfo'],
queryFn: async () => {
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
return msg?.obj?.latestVersion || '';
},
staleTime: 5 * 60 * 1000,
});
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const onAdd = useCallback(() => {
setFormMode('add');
@@ -81,6 +94,52 @@ export default function NodesPage() {
await setEnable(node.id, next);
}, [setEnable]);
const runUpdate = useCallback(async (ids: number[]) => {
const msg = await updatePanels(ids);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
return;
}
const results = msg.obj ?? [];
const ok = results.filter((r) => r.ok).length;
const failed = results.length - ok;
if (failed === 0) {
messageApi.success(t('pages.nodes.toasts.updateStarted'));
} else {
const firstError = results.find((r) => !r.ok)?.error ?? '';
const base = t('pages.nodes.toasts.updateResult', { ok, failed });
messageApi.warning(firstError ? `${base}${firstError}` : base);
}
setSelectedIds([]);
}, [updatePanels, messageApi, t]);
const onUpdateNode = useCallback((node: NodeRecord) => {
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
content: t('pages.nodes.updateConfirmContent'),
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate([node.id]),
});
}, [modal, t, runUpdate]);
const onUpdateSelected = useCallback(() => {
const eligible = nodes
.filter((n) => selectedIds.includes(n.id) && n.enable && n.status === 'online')
.map((n) => n.id);
if (eligible.length === 0) {
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
return;
}
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
content: t('pages.nodes.updateConfirmContent'),
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate(eligible),
});
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
const pageClass = useMemo(() => {
const classes = ['nodes-page'];
if (isDark) classes.push('is-dark');
@@ -142,11 +201,16 @@ export default function NodesPage() {
nodes={nodes}
loading={loading}
isMobile={isMobile}
latestVersion={latestVersion}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
onProbe={onProbe}
onToggleEnable={onToggleEnable}
onUpdateNode={onUpdateNode}
onUpdateSelected={onUpdateSelected}
/>
</Col>
</Row>
+20
View File
@@ -21,3 +21,23 @@
cursor: pointer;
color: var(--ant-color-error);
}
.online-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-inline-end: 5px;
vertical-align: middle;
background: var(--ant-color-success);
animation: online-blink 1.1s ease-in-out infinite;
}
@keyframes online-blink {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.55); }
50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }
}
@media (prefers-reduced-motion: reduce) {
.online-dot { animation: none; }
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { isPanelUpdateAvailable } from '@/lib/panel-version';
// Parity with web/service/panel.go isNewerVersion.
describe('isPanelUpdateAvailable', () => {
it('flags a strictly newer latest', () => {
expect(isPanelUpdateAvailable('2.6.5', '2.6.4')).toBe(true);
expect(isPanelUpdateAvailable('v2.7.0', 'v2.6.9')).toBe(true);
expect(isPanelUpdateAvailable('3.0.0', '2.9.9')).toBe(true);
});
it('returns false when equal or the node is ahead', () => {
expect(isPanelUpdateAvailable('2.6.4', '2.6.4')).toBe(false);
expect(isPanelUpdateAvailable('v2.6.4', '2.6.4')).toBe(false);
expect(isPanelUpdateAvailable('2.6.4', '2.6.5')).toBe(false);
});
it('ignores a leading v on either side', () => {
expect(isPanelUpdateAvailable('v2.6.5', '2.6.4')).toBe(true);
expect(isPanelUpdateAvailable('2.6.5', 'v2.6.4')).toBe(true);
});
it('never flags when a version is unknown', () => {
expect(isPanelUpdateAvailable('', '2.6.4')).toBe(false);
expect(isPanelUpdateAvailable('2.6.5', '')).toBe(false);
});
it('falls back to string inequality for non-semver tags', () => {
expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true);
expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
});
});