Răsfoiți Sursa

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.
MHSanaei 11 ore în urmă
părinte
comite
971843f669

+ 63 - 0
frontend/public/openapi.json

@@ -4244,6 +4244,69 @@
         }
       }
     },
+    "/panel/api/nodes/updatePanel": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "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.",
+        "operationId": "post_panel_api_nodes_updatePanel",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "ids": [
+                  1,
+                  2,
+                  3
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "id": 1,
+                      "name": "de-1",
+                      "ok": true
+                    },
+                    {
+                      "id": 2,
+                      "name": "fr-1",
+                      "ok": false,
+                      "error": "node is offline"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/history/{id}/{metric}/{bucket}": {
       "get": {
         "tags": [

+ 16 - 0
frontend/src/api/queries/useNodeMutations.ts

@@ -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 - 0
frontend/src/lib/panel-version.ts

@@ -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 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -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',

+ 0 - 20
frontend/src/pages/clients/ClientsPage.css

@@ -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
frontend/src/pages/nodes/NodeList.tsx

@@ -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
frontend/src/pages/nodes/NodesPage.tsx

@@ -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 - 0
frontend/src/styles/utils.css

@@ -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 - 0
frontend/src/test/panel-version.test.ts

@@ -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);
+  });
+});

+ 17 - 0
web/controller/node.go

@@ -35,6 +35,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/test", a.test)
 	g.POST("/probe/:id", a.probe)
+	g.POST("/updatePanel", a.updatePanel)
 	g.GET("/history/:id/:metric/:bucket", a.history)
 }
 
@@ -165,6 +166,22 @@ func (a *NodeController) probe(c *gin.Context) {
 	jsonObj(c, patch.ToUI(probeErr == nil), nil)
 }
 
+func (a *NodeController) updatePanel(c *gin.Context) {
+	var req struct {
+		Ids []int `json:"ids"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if len(req.Ids) == 0 {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
+		return
+	}
+	results, err := a.nodeService.UpdatePanels(req.Ids)
+	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
+}
+
 func (a *NodeController) history(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {

+ 8 - 0
web/runtime/remote.go

@@ -320,6 +320,14 @@ func (r *Remote) RestartXray(ctx context.Context) error {
 	return err
 }
 
+// UpdatePanel asks the node to run its own official self-updater (update.sh)
+// and restart onto the latest release. The node returns as soon as the job is
+// launched; the new version surfaces on the next heartbeat.
+func (r *Remote) UpdatePanel(ctx context.Context) error {
+	_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", nil)
+	return err
+}
+
 func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
 	_, err := r.do(ctx, http.MethodPost,
 		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)

+ 50 - 0
web/service/node.go

@@ -246,6 +246,56 @@ func (s *NodeService) SetEnable(id int, enable bool) error {
 	return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
 }
 
+// NodeUpdateResult reports the outcome of triggering a panel self-update on one
+// node so the UI can show per-node success/failure for a bulk request.
+type NodeUpdateResult struct {
+	Id    int    `json:"id"`
+	Name  string `json:"name"`
+	OK    bool   `json:"ok"`
+	Error string `json:"error,omitempty"`
+}
+
+// UpdatePanels triggers the official self-updater on each given node. Only
+// enabled, online nodes are eligible — an offline node can't be reached, so it
+// is reported as skipped rather than silently dropped.
+func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
+	mgr := runtime.GetManager()
+	if mgr == nil {
+		return nil, fmt.Errorf("runtime manager unavailable")
+	}
+	results := make([]NodeUpdateResult, 0, len(ids))
+	for _, id := range ids {
+		n, err := s.GetById(id)
+		if err != nil || n == nil {
+			results = append(results, NodeUpdateResult{Id: id, OK: false, Error: "node not found"})
+			continue
+		}
+		res := NodeUpdateResult{Id: id, Name: n.Name}
+		switch {
+		case !n.Enable:
+			res.Error = "node is disabled"
+		case n.Status != "online":
+			res.Error = "node is offline"
+		default:
+			remote, remoteErr := mgr.RemoteFor(n)
+			if remoteErr != nil {
+				res.Error = remoteErr.Error()
+				break
+			}
+			ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+			updErr := remote.UpdatePanel(ctx)
+			cancel()
+			if updErr != nil {
+				res.Error = updErr.Error()
+			} else {
+				res.OK = true
+			}
+		}
+		results = append(results, res)
+	}
+	return results, nil
+}
+
 func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 	db := database.GetDB()
 	updates := map[string]any{

+ 10 - 1
web/translation/ar-EG.json

@@ -832,6 +832,12 @@
       "panelVersion": "إصدار اللوحة",
       "actions": "العمليات",
       "probe": "فحص فوري",
+      "updatePanel": "تحديث اللوحة",
+      "updateSelected": "تحديث المحدد ({count})",
+      "updateAvailable": "تحديث متاح",
+      "upToDate": "محدّث",
+      "updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
+      "updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
       "testConnection": "اختبار الاتصال",
       "connectionOk": "الاتصال شغال ({ms} ms)",
       "connectionFailed": "فشل الاتصال",
@@ -853,7 +859,10 @@
         "deleted": "اتمسح النود",
         "test": "اختبار الاتصال",
         "fillRequired": "الاسم والعنوان والبورت وتوكن API كلهم مطلوبين",
-        "probeFailed": "فشل الفحص"
+        "probeFailed": "فشل الفحص",
+        "updateStarted": "بدأ تحديث اللوحة",
+        "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
+        "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
       }
     },
     "settings": {

+ 10 - 1
web/translation/en-US.json

@@ -832,6 +832,12 @@
       "panelVersion": "Panel Version",
       "actions": "Actions",
       "probe": "Probe Now",
+      "updatePanel": "Update Panel",
+      "updateSelected": "Update Selected ({count})",
+      "updateAvailable": "Update available",
+      "upToDate": "Up to date",
+      "updateConfirmTitle": "Update {count} node(s) to the latest version?",
+      "updateConfirmContent": "Each selected node downloads the latest release and restarts onto it. Only enabled, online nodes are updated.",
       "testConnection": "Test Connection",
       "connectionOk": "Connection OK ({ms} ms)",
       "connectionFailed": "Connection failed",
@@ -853,7 +859,10 @@
         "deleted": "Node deleted",
         "test": "Test connection",
         "fillRequired": "Name, address, port and API token are required",
-        "probeFailed": "Probe failed"
+        "probeFailed": "Probe failed",
+        "updateStarted": "Panel update started",
+        "updateResult": "Update triggered on {ok} node(s), {failed} failed",
+        "updateNoneEligible": "Select at least one online, enabled node"
       }
     },
     "settings": {

+ 10 - 1
web/translation/es-ES.json

@@ -832,6 +832,12 @@
       "panelVersion": "Versión del panel",
       "actions": "Acciones",
       "probe": "Sondear ahora",
+      "updatePanel": "Actualizar panel",
+      "updateSelected": "Actualizar seleccionados ({count})",
+      "updateAvailable": "Actualización disponible",
+      "upToDate": "Actualizado",
+      "updateConfirmTitle": "¿Actualizar {count} nodo(s) a la última versión?",
+      "updateConfirmContent": "Cada nodo seleccionado descarga la última versión y se reinicia con ella. Solo se actualizan los nodos habilitados y en línea.",
       "testConnection": "Probar conexión",
       "connectionOk": "Conexión correcta ({ms} ms)",
       "connectionFailed": "Conexión fallida",
@@ -853,7 +859,10 @@
         "deleted": "Nodo eliminado",
         "test": "Probar conexión",
         "fillRequired": "El nombre, la dirección, el puerto y el token de API son obligatorios",
-        "probeFailed": "Sondeo fallido"
+        "probeFailed": "Sondeo fallido",
+        "updateStarted": "Actualización del panel iniciada",
+        "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
+        "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado"
       }
     },
     "settings": {

+ 10 - 1
web/translation/fa-IR.json

@@ -832,6 +832,12 @@
       "panelVersion": "نسخه پنل",
       "actions": "عملیات",
       "probe": "بررسی فوری",
+      "updatePanel": "به‌روزرسانی پنل",
+      "updateSelected": "به‌روزرسانی انتخاب‌شده‌ها ({count})",
+      "updateAvailable": "به‌روزرسانی موجود",
+      "upToDate": "به‌روز",
+      "updateConfirmTitle": "{count} نود به آخرین نسخه به‌روزرسانی شوند؟",
+      "updateConfirmContent": "هر نود انتخاب‌شده آخرین نسخه را دانلود و روی آن ری‌استارت می‌شود. فقط نودهای فعال و آنلاین به‌روزرسانی می‌شوند.",
       "testConnection": "تست اتصال",
       "connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)",
       "connectionFailed": "اتصال ناموفق",
@@ -853,7 +859,10 @@
         "deleted": "نود حذف شد",
         "test": "تست اتصال",
         "fillRequired": "نام، آدرس، پورت و توکن API الزامی است",
-        "probeFailed": "بررسی ناموفق"
+        "probeFailed": "بررسی ناموفق",
+        "updateStarted": "به‌روزرسانی پنل آغاز شد",
+        "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
+        "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید"
       }
     },
     "settings": {

+ 10 - 1
web/translation/id-ID.json

@@ -832,6 +832,12 @@
       "panelVersion": "Versi panel",
       "actions": "Aksi",
       "probe": "Probe Sekarang",
+      "updatePanel": "Perbarui Panel",
+      "updateSelected": "Perbarui Terpilih ({count})",
+      "updateAvailable": "Pembaruan tersedia",
+      "upToDate": "Terbaru",
+      "updateConfirmTitle": "Perbarui {count} node ke versi terbaru?",
+      "updateConfirmContent": "Setiap node terpilih mengunduh rilis terbaru dan memulai ulang. Hanya node aktif dan online yang diperbarui.",
       "testConnection": "Tes Koneksi",
       "connectionOk": "Koneksi OK ({ms} ms)",
       "connectionFailed": "Koneksi gagal",
@@ -853,7 +859,10 @@
         "deleted": "Node dihapus",
         "test": "Tes koneksi",
         "fillRequired": "Nama, alamat, port, dan token API wajib diisi",
-        "probeFailed": "Probe gagal"
+        "probeFailed": "Probe gagal",
+        "updateStarted": "Pembaruan panel dimulai",
+        "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
+        "updateNoneEligible": "Pilih minimal satu node online dan aktif"
       }
     },
     "settings": {

+ 10 - 1
web/translation/ja-JP.json

@@ -832,6 +832,12 @@
       "panelVersion": "パネルのバージョン",
       "actions": "操作",
       "probe": "今すぐプローブ",
+      "updatePanel": "パネルを更新",
+      "updateSelected": "選択を更新 ({count})",
+      "updateAvailable": "更新あり",
+      "upToDate": "最新",
+      "updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
+      "updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
       "testConnection": "接続テスト",
       "connectionOk": "接続OK ({ms} ms)",
       "connectionFailed": "接続に失敗しました",
@@ -853,7 +859,10 @@
         "deleted": "ノードを削除しました",
         "test": "接続テスト",
         "fillRequired": "名前、アドレス、ポート、APIトークンは必須です",
-        "probeFailed": "プローブに失敗しました"
+        "probeFailed": "プローブに失敗しました",
+        "updateStarted": "パネルの更新を開始しました",
+        "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
+        "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください"
       }
     },
     "settings": {

+ 10 - 1
web/translation/pt-BR.json

@@ -832,6 +832,12 @@
       "panelVersion": "Versão do painel",
       "actions": "Ações",
       "probe": "Sondar agora",
+      "updatePanel": "Atualizar painel",
+      "updateSelected": "Atualizar selecionados ({count})",
+      "updateAvailable": "Atualização disponível",
+      "upToDate": "Atualizado",
+      "updateConfirmTitle": "Atualizar {count} nó(s) para a versão mais recente?",
+      "updateConfirmContent": "Cada nó selecionado baixa a versão mais recente e reinicia nela. Apenas nós ativos e online são atualizados.",
       "testConnection": "Testar conexão",
       "connectionOk": "Conexão OK ({ms} ms)",
       "connectionFailed": "Falha na conexão",
@@ -853,7 +859,10 @@
         "deleted": "Nó excluído",
         "test": "Testar conexão",
         "fillRequired": "Nome, endereço, porta e token da API são obrigatórios",
-        "probeFailed": "Falha na sondagem"
+        "probeFailed": "Falha na sondagem",
+        "updateStarted": "Atualização do painel iniciada",
+        "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
+        "updateNoneEligible": "Selecione pelo menos um nó online e ativo"
       }
     },
     "settings": {

+ 10 - 1
web/translation/ru-RU.json

@@ -832,6 +832,12 @@
       "panelVersion": "Версия панели",
       "actions": "Действия",
       "probe": "Проверить сейчас",
+      "updatePanel": "Обновить панель",
+      "updateSelected": "Обновить выбранные ({count})",
+      "updateAvailable": "Доступно обновление",
+      "upToDate": "Актуально",
+      "updateConfirmTitle": "Обновить {count} узлов до последней версии?",
+      "updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.",
       "testConnection": "Проверить соединение",
       "connectionOk": "Соединение в порядке ({ms} мс)",
       "connectionFailed": "Не удалось подключиться",
@@ -853,7 +859,10 @@
         "deleted": "Узел удалён",
         "test": "Проверить соединение",
         "fillRequired": "Имя, адрес, порт и токен API обязательны",
-        "probeFailed": "Проверка не удалась"
+        "probeFailed": "Проверка не удалась",
+        "updateStarted": "Обновление панели запущено",
+        "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
+        "updateNoneEligible": "Выберите хотя бы один включённый узел в сети"
       }
     },
     "settings": {

+ 10 - 1
web/translation/tr-TR.json

@@ -832,6 +832,12 @@
       "panelVersion": "Panel sürümü",
       "actions": "İşlemler",
       "probe": "Şimdi Test Et",
+      "updatePanel": "Paneli Güncelle",
+      "updateSelected": "Seçilenleri Güncelle ({count})",
+      "updateAvailable": "Güncelleme mevcut",
+      "upToDate": "Güncel",
+      "updateConfirmTitle": "{count} düğüm en son sürüme güncellensin mi?",
+      "updateConfirmContent": "Seçilen her düğüm en son sürümü indirir ve yeniden başlatılır. Yalnızca etkin ve çevrimiçi düğümler güncellenir.",
       "testConnection": "Bağlantıyı Test Et",
       "connectionOk": "Bağlantı tamam ({ms} ms)",
       "connectionFailed": "Bağlantı başarısız",
@@ -853,7 +859,10 @@
         "deleted": "Düğüm silindi",
         "test": "Bağlantıyı test et",
         "fillRequired": "Ad, adres, port ve API token gereklidir",
-        "probeFailed": "Test başarısız"
+        "probeFailed": "Test başarısız",
+        "updateStarted": "Panel güncellemesi başlatıldı",
+        "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
+        "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin"
       }
     },
     "settings": {

+ 10 - 1
web/translation/uk-UA.json

@@ -832,6 +832,12 @@
       "panelVersion": "Версія панелі",
       "actions": "Дії",
       "probe": "Перевірити зараз",
+      "updatePanel": "Оновити панель",
+      "updateSelected": "Оновити вибрані ({count})",
+      "updateAvailable": "Доступне оновлення",
+      "upToDate": "Актуально",
+      "updateConfirmTitle": "Оновити {count} вузлів до останньої версії?",
+      "updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.",
       "testConnection": "Перевірити з'єднання",
       "connectionOk": "З'єднання в порядку ({ms} мс)",
       "connectionFailed": "Помилка з'єднання",
@@ -853,7 +859,10 @@
         "deleted": "Вузол видалено",
         "test": "Перевірити з'єднання",
         "fillRequired": "Назва, адреса, порт та токен API є обов'язковими",
-        "probeFailed": "Помилка перевірки"
+        "probeFailed": "Помилка перевірки",
+        "updateStarted": "Оновлення панелі розпочато",
+        "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
+        "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі"
       }
     },
     "settings": {

+ 10 - 1
web/translation/vi-VN.json

@@ -832,6 +832,12 @@
       "panelVersion": "Phiên bản panel",
       "actions": "Hành động",
       "probe": "Kiểm tra ngay",
+      "updatePanel": "Cập nhật bảng điều khiển",
+      "updateSelected": "Cập nhật đã chọn ({count})",
+      "updateAvailable": "Có bản cập nhật",
+      "upToDate": "Mới nhất",
+      "updateConfirmTitle": "Cập nhật {count} node lên phiên bản mới nhất?",
+      "updateConfirmContent": "Mỗi node đã chọn sẽ tải bản phát hành mới nhất và khởi động lại. Chỉ các node đang bật và trực tuyến được cập nhật.",
       "testConnection": "Kiểm tra kết nối",
       "connectionOk": "Kết nối OK ({ms} ms)",
       "connectionFailed": "Kết nối thất bại",
@@ -853,7 +859,10 @@
         "deleted": "Đã xóa nút",
         "test": "Kiểm tra kết nối",
         "fillRequired": "Tên, địa chỉ, cổng và token API là bắt buộc",
-        "probeFailed": "Kiểm tra thất bại"
+        "probeFailed": "Kiểm tra thất bại",
+        "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
+        "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
+        "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật"
       }
     },
     "settings": {

+ 10 - 1
web/translation/zh-CN.json

@@ -832,6 +832,12 @@
       "panelVersion": "面板版本",
       "actions": "操作",
       "probe": "立即探测",
+      "updatePanel": "更新面板",
+      "updateSelected": "更新所选 ({count})",
+      "updateAvailable": "有可用更新",
+      "upToDate": "已是最新",
+      "updateConfirmTitle": "将 {count} 个节点更新到最新版本?",
+      "updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。",
       "testConnection": "测试连接",
       "connectionOk": "连接正常 ({ms} ms)",
       "connectionFailed": "连接失败",
@@ -853,7 +859,10 @@
         "deleted": "节点已删除",
         "test": "测试连接",
         "fillRequired": "名称、地址、端口和 API 令牌为必填项",
-        "probeFailed": "探测失败"
+        "probeFailed": "探测失败",
+        "updateStarted": "已开始更新面板",
+        "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
+        "updateNoneEligible": "请至少选择一个在线且已启用的节点"
       }
     },
     "settings": {

+ 10 - 1
web/translation/zh-TW.json

@@ -832,6 +832,12 @@
       "panelVersion": "面板版本",
       "actions": "操作",
       "probe": "立即探測",
+      "updatePanel": "更新面板",
+      "updateSelected": "更新所選 ({count})",
+      "updateAvailable": "有可用更新",
+      "upToDate": "已是最新",
+      "updateConfirmTitle": "將 {count} 個節點更新到最新版本?",
+      "updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。",
       "testConnection": "測試連線",
       "connectionOk": "連線正常 ({ms} ms)",
       "connectionFailed": "連線失敗",
@@ -853,7 +859,10 @@
         "deleted": "節點已刪除",
         "test": "測試連線",
         "fillRequired": "名稱、位址、埠與 API 權杖為必填",
-        "probeFailed": "探測失敗"
+        "probeFailed": "探測失敗",
+        "updateStarted": "已開始更新面板",
+        "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
+        "updateNoneEligible": "請至少選擇一個在線且已啟用的節點"
       }
     },
     "settings": {