mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 01:17:15 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user