Procházet zdrojové kódy

feat(nodes): add Dev channel option to node panel updates

The node update confirm dialog now offers a 'Dev channel (latest commit)' choice. The dev flag threads master -> nodes/updatePanel -> UpdatePanels -> remote.UpdatePanel -> the node's updatePanel endpoint, which calls StartUpdateChannel(dev) to install the rolling dev-latest build. With no dev flag the node keeps following its own channel setting.
MHSanaei před 10 hodinami
rodič
revize
e8878b71a4

+ 3 - 2
frontend/public/openapi.json

@@ -7440,7 +7440,7 @@
         "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.",
+        "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. Set \"dev\": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.",
         "operationId": "post_panel_api_nodes_updatePanel",
         "requestBody": {
           "required": true,
@@ -7454,7 +7454,8 @@
                   1,
                   2,
                   3
-                ]
+                ],
+                "dev": false
               }
             }
           }

+ 3 - 3
frontend/src/api/queries/useNodeMutations.ts

@@ -59,8 +59,8 @@ export function useNodeMutations() {
   });
 
   const updatePanelsMut = useMutation({
-    mutationFn: (ids: number[]) =>
-      HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids }, {
+    mutationFn: ({ ids, dev }: { ids: number[]; dev: boolean }) =>
+      HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids, dev }, {
         headers: { 'Content-Type': 'application/json' },
       }),
     onSuccess: (msg) => { if (msg?.success) invalidate(); },
@@ -72,7 +72,7 @@ export function useNodeMutations() {
     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),
+    updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync({ ids, dev }),
     testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
       const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
       return parseMsg(raw, ProbeResultSchema, 'nodes/test');

+ 2 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -945,8 +945,8 @@ export const sections: readonly Section[] = [
       {
         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}',
+        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. Set "dev": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.',
+        body: '{\n  "ids": [1, 2, 3],\n  "dev": false\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}',
       },
       {

+ 39 - 8
frontend/src/pages/nodes/NodesPage.tsx

@@ -1,7 +1,7 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { Button, Card, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
+import { Alert, Button, Card, Checkbox, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
 import {
   CheckCircleOutlined,
   CloseCircleOutlined,
@@ -21,6 +21,33 @@ import { setMessageInstance } from '@/utils/messageBus';
 import { HttpUtil } from '@/utils';
 import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
 
+// Confirm-dialog body that lets the operator pick the stable or dev channel for
+// a node panel update. Reports changes via onChange so the imperative
+// modal.confirm onOk can read the latest choice through a ref.
+function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
+  const { t } = useTranslation();
+  const [dev, setDev] = useState(false);
+  return (
+    <div>
+      <p>{t('pages.nodes.updateConfirmContent')}</p>
+      <Checkbox
+        checked={dev}
+        onChange={(e) => { setDev(e.target.checked); onChange(e.target.checked); }}
+      >
+        {t('pages.nodes.updateDevChannel')}
+      </Checkbox>
+      {dev && (
+        <Alert
+          type="info"
+          showIcon
+          style={{ marginTop: 8 }}
+          message={t('pages.index.devChannelWarning')}
+        />
+      )}
+    </div>
+  );
+}
+
 export default function NodesPage() {
   const { t } = useTranslation();
   const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -136,8 +163,10 @@ export default function NodesPage() {
     await setEnable(node.id, next);
   }, [setEnable]);
 
-  const runUpdate = useCallback(async (ids: number[]) => {
-    const msg = await updatePanels(ids);
+  const devRef = useRef(false);
+
+  const runUpdate = useCallback(async (ids: number[], dev: boolean) => {
+    const msg = await updatePanels(ids, dev);
     if (!msg?.success) {
       messageApi.error(msg?.msg || t('somethingWentWrong'));
       return;
@@ -156,12 +185,13 @@ export default function NodesPage() {
   }, [updatePanels, messageApi, t]);
 
   const onUpdateNode = useCallback((node: NodeRecord) => {
+    devRef.current = false;
     modal.confirm({
       title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
-      content: t('pages.nodes.updateConfirmContent'),
+      content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
       okText: t('update'),
       cancelText: t('cancel'),
-      onOk: () => runUpdate([node.id]),
+      onOk: () => runUpdate([node.id], devRef.current),
     });
   }, [modal, t, runUpdate]);
 
@@ -173,12 +203,13 @@ export default function NodesPage() {
       messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
       return;
     }
+    devRef.current = false;
     modal.confirm({
       title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
-      content: t('pages.nodes.updateConfirmContent'),
+      content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
       okText: t('update'),
       cancelText: t('cancel'),
-      onOk: () => runUpdate(eligible),
+      onOk: () => runUpdate(eligible, devRef.current),
     });
   }, [modal, t, nodes, selectedIds, runUpdate, messageApi]);
 

+ 2 - 1
internal/web/controller/node.go

@@ -318,6 +318,7 @@ func (a *NodeController) probe(c *gin.Context) {
 func (a *NodeController) updatePanel(c *gin.Context) {
 	var req struct {
 		Ids []int `json:"ids"`
+		Dev bool  `json:"dev"`
 	}
 	if err := c.ShouldBindJSON(&req); err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
@@ -327,7 +328,7 @@ func (a *NodeController) updatePanel(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
 		return
 	}
-	results, err := a.nodeService.UpdatePanels(req.Ids)
+	results, err := a.nodeService.UpdatePanels(req.Ids, req.Dev)
 	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
 }
 

+ 15 - 2
internal/web/controller/server.go

@@ -206,9 +206,22 @@ func (a *ServerController) installXray(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
 }
 
-// updatePanel starts a panel self-update to the latest release.
+// updatePanel starts a panel self-update. With no "dev" form value it follows
+// this panel's own channel setting; an explicit "dev" (sent by the master node
+// updater) overrides it for this run.
 func (a *ServerController) updatePanel(c *gin.Context) {
-	err := a.panelService.StartUpdate()
+	devParam := c.PostForm("dev")
+	var err error
+	if devParam == "" {
+		err = a.panelService.StartUpdate()
+	} else {
+		dev, perr := strconv.ParseBool(devParam)
+		if perr != nil {
+			jsonMsg(c, "invalid data", perr)
+			return
+		}
+		err = a.panelService.StartUpdateChannel(dev)
+	}
 	jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
 }
 

+ 8 - 3
internal/web/runtime/remote.go

@@ -538,9 +538,14 @@ func (r *Remote) RestartXray(ctx context.Context) error {
 
 // 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)
+// launched; the new version surfaces on the next heartbeat. When dev is true the
+// node is moved to the rolling dev channel instead of the latest stable release.
+func (r *Remote) UpdatePanel(ctx context.Context, dev bool) error {
+	var body any
+	if dev {
+		body = url.Values{"dev": {"true"}}
+	}
+	_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", body)
 	return err
 }
 

+ 2 - 2
internal/web/service/node.go

@@ -637,7 +637,7 @@ type NodeUpdateResult struct {
 // 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) {
+func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, error) {
 	mgr := runtime.GetManager()
 	if mgr == nil {
 		return nil, fmt.Errorf("runtime manager unavailable")
@@ -662,7 +662,7 @@ func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
 				break
 			}
 			ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
-			updErr := remote.UpdatePanel(ctx)
+			updErr := remote.UpdatePanel(ctx, dev)
 			cancel()
 			if updErr != nil {
 				res.Error = updErr.Error()

+ 13 - 2
internal/web/service/panel/panel.go

@@ -122,8 +122,19 @@ func getDevUpdateInfo() (*PanelUpdateInfo, error) {
 	}, nil
 }
 
-// StartUpdate starts the official updater outside of the current web request.
+// StartUpdate starts the official updater using this panel's own channel setting.
 func (s *PanelService) StartUpdate() error {
+	return s.startUpdate(devChannelActive())
+}
+
+// StartUpdateChannel runs the updater against an explicitly chosen channel,
+// overriding the local dev-channel setting. Used by the master node updater so
+// a node can be moved to the dev channel from the central panel.
+func (s *PanelService) StartUpdateChannel(dev bool) error {
+	return s.startUpdate(dev)
+}
+
+func (s *PanelService) startUpdate(useDev bool) error {
 	if runtime.GOOS != "linux" {
 		return fmt.Errorf("panel web update is supported only on Linux installations")
 	}
@@ -140,7 +151,7 @@ func (s *PanelService) StartUpdate() error {
 
 	mainFolder, serviceFolder := resolveUpdateFolders()
 	updateTag := ""
-	if devChannelActive() {
+	if useDev {
 		updateTag = devReleaseTag
 	}
 	updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -979,6 +979,7 @@
       "upToDate": "محدّث",
       "updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
       "updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
+      "updateDevChannel": "التحديث إلى قناة التطوير (أحدث كومِت)",
       "testConnection": "اختبار الاتصال",
       "connectionOk": "الاتصال شغال ({ms} ms)",
       "connectionFailed": "فشل الاتصال",

+ 1 - 0
internal/web/translation/en-US.json

@@ -1098,6 +1098,7 @@
       "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.",
+      "updateDevChannel": "Update to Dev channel (latest commit)",
       "testConnection": "Test Connection",
       "connectionOk": "Connection OK ({ms} ms)",
       "connectionFailed": "Connection failed",

+ 1 - 0
internal/web/translation/es-ES.json

@@ -979,6 +979,7 @@
       "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.",
+      "updateDevChannel": "Actualizar al canal de desarrollo (último commit)",
       "testConnection": "Probar conexión",
       "connectionOk": "Conexión correcta ({ms} ms)",
       "connectionFailed": "Conexión fallida",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -979,6 +979,7 @@
       "upToDate": "به‌روز",
       "updateConfirmTitle": "{count} نود به آخرین نسخه به‌روزرسانی شوند؟",
       "updateConfirmContent": "هر نود انتخاب‌شده آخرین نسخه را دانلود و روی آن ری‌استارت می‌شود. فقط نودهای فعال و آنلاین به‌روزرسانی می‌شوند.",
+      "updateDevChannel": "به‌روزرسانی به کانال دِو (آخرین کامیت)",
       "testConnection": "تست اتصال",
       "connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)",
       "connectionFailed": "اتصال ناموفق",

+ 1 - 0
internal/web/translation/id-ID.json

@@ -979,6 +979,7 @@
       "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.",
+      "updateDevChannel": "Perbarui ke kanal dev (commit terbaru)",
       "testConnection": "Tes Koneksi",
       "connectionOk": "Koneksi OK ({ms} ms)",
       "connectionFailed": "Koneksi gagal",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -979,6 +979,7 @@
       "upToDate": "最新",
       "updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
       "updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
+      "updateDevChannel": "開発チャンネルに更新(最新コミット)",
       "testConnection": "接続テスト",
       "connectionOk": "接続OK ({ms} ms)",
       "connectionFailed": "接続に失敗しました",

+ 1 - 0
internal/web/translation/pt-BR.json

@@ -979,6 +979,7 @@
       "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.",
+      "updateDevChannel": "Atualizar para o canal de desenvolvimento (último commit)",
       "testConnection": "Testar conexão",
       "connectionOk": "Conexão OK ({ms} ms)",
       "connectionFailed": "Falha na conexão",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -979,6 +979,7 @@
       "upToDate": "Актуально",
       "updateConfirmTitle": "Обновить {count} узлов до последней версии?",
       "updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.",
+      "updateDevChannel": "Обновить до канала разработки (последний коммит)",
       "testConnection": "Проверить соединение",
       "connectionOk": "Соединение в порядке ({ms} мс)",
       "connectionFailed": "Не удалось подключиться",

+ 1 - 0
internal/web/translation/tr-TR.json

@@ -979,6 +979,7 @@
       "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.",
+      "updateDevChannel": "Dev kanalına güncelle (son commit)",
       "testConnection": "Bağlantıyı Test Et",
       "connectionOk": "Bağlantı tamam ({ms} ms)",
       "connectionFailed": "Bağlantı başarısız",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -979,6 +979,7 @@
       "upToDate": "Актуально",
       "updateConfirmTitle": "Оновити {count} вузлів до останньої версії?",
       "updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.",
+      "updateDevChannel": "Оновити до каналу розробки (останній коміт)",
       "testConnection": "Перевірити з'єднання",
       "connectionOk": "З'єднання в порядку ({ms} мс)",
       "connectionFailed": "Помилка з'єднання",

+ 1 - 0
internal/web/translation/vi-VN.json

@@ -979,6 +979,7 @@
       "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.",
+      "updateDevChannel": "Cập nhật lên kênh phát triển (commit mới 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",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -979,6 +979,7 @@
       "upToDate": "已是最新",
       "updateConfirmTitle": "将 {count} 个节点更新到最新版本?",
       "updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。",
+      "updateDevChannel": "更新到开发通道(最新提交)",
       "testConnection": "测试连接",
       "connectionOk": "连接正常 ({ms} ms)",
       "connectionFailed": "连接失败",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -979,6 +979,7 @@
       "upToDate": "已是最新",
       "updateConfirmTitle": "將 {count} 個節點更新到最新版本?",
       "updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。",
+      "updateDevChannel": "更新到開發通道(最新提交)",
       "testConnection": "測試連線",
       "connectionOk": "連線正常 ({ms} ms)",
       "connectionFailed": "連線失敗",