diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 22dad2a2a..0cf8edb68 100644 --- a/frontend/public/openapi.json +++ b/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 } } } diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index aad3b12ca..45229145b 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -59,8 +59,8 @@ export function useNodeMutations() { }); const updatePanelsMut = useMutation({ - mutationFn: (ids: number[]) => - HttpUtil.post('/panel/api/nodes/updatePanel', { ids }, { + mutationFn: ({ ids, dev }: { ids: number[]; dev: boolean }) => + HttpUtil.post('/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> => updatePanelsMut.mutateAsync(ids), + updatePanels: (ids: number[], dev: boolean): Promise> => updatePanelsMut.mutateAsync({ ids, dev }), testConnection: async (payload: Partial): Promise> => { const raw = await HttpUtil.post('/panel/api/nodes/test', payload); return parseMsg(raw, ProbeResultSchema, 'nodes/test'); diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 794982433..dd1a88341 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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}', }, { diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index f9caf4bd7..53c05d0d1 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/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 ( +
+

{t('pages.nodes.updateConfirmContent')}

+ { setDev(e.target.checked); onChange(e.target.checked); }} + > + {t('pages.nodes.updateDevChannel')} + + {dev && ( + + )} +
+ ); +} + 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: { 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: { devRef.current = v; }} />, okText: t('update'), cancelText: t('cancel'), - onOk: () => runUpdate(eligible), + onOk: () => runUpdate(eligible, devRef.current), }); }, [modal, t, nodes, selectedIds, runUpdate, messageApi]); diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go index ccfda96fb..a19ea6299 100644 --- a/internal/web/controller/node.go +++ b/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) } diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 27ced715c..05800c8f2 100644 --- a/internal/web/controller/server.go +++ b/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) } diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 74303f090..4a255840f 100644 --- a/internal/web/runtime/remote.go +++ b/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 } diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 6dc53d8b3..b3cbfef90 100644 --- a/internal/web/service/node.go +++ b/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() diff --git a/internal/web/service/panel/panel.go b/internal/web/service/panel/panel.go index 42fbdc571..23bb38657 100644 --- a/internal/web/service/panel/panel.go +++ b/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)) diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 2ae222b6e..427c03326 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -979,6 +979,7 @@ "upToDate": "محدّث", "updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟", "updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.", + "updateDevChannel": "التحديث إلى قناة التطوير (أحدث كومِت)", "testConnection": "اختبار الاتصال", "connectionOk": "الاتصال شغال ({ms} ms)", "connectionFailed": "فشل الاتصال", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index f0d429626..8e75954a5 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 3bf142e55..7bcf38e19 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index e85e6a028..8de92bde4 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -979,6 +979,7 @@ "upToDate": "به‌روز", "updateConfirmTitle": "{count} نود به آخرین نسخه به‌روزرسانی شوند؟", "updateConfirmContent": "هر نود انتخاب‌شده آخرین نسخه را دانلود و روی آن ری‌استارت می‌شود. فقط نودهای فعال و آنلاین به‌روزرسانی می‌شوند.", + "updateDevChannel": "به‌روزرسانی به کانال دِو (آخرین کامیت)", "testConnection": "تست اتصال", "connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)", "connectionFailed": "اتصال ناموفق", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index ad9292dbe..a30f5576a 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 17a9c17b7..b525adeba 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -979,6 +979,7 @@ "upToDate": "最新", "updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?", "updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。", + "updateDevChannel": "開発チャンネルに更新(最新コミット)", "testConnection": "接続テスト", "connectionOk": "接続OK ({ms} ms)", "connectionFailed": "接続に失敗しました", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index a08549a7d..edfee7c52 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index e37e3c2d0..5135381af 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -979,6 +979,7 @@ "upToDate": "Актуально", "updateConfirmTitle": "Обновить {count} узлов до последней версии?", "updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.", + "updateDevChannel": "Обновить до канала разработки (последний коммит)", "testConnection": "Проверить соединение", "connectionOk": "Соединение в порядке ({ms} мс)", "connectionFailed": "Не удалось подключиться", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index d664adb8c..84054f775 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 42d8388ea..a84354bfe 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -979,6 +979,7 @@ "upToDate": "Актуально", "updateConfirmTitle": "Оновити {count} вузлів до останньої версії?", "updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.", + "updateDevChannel": "Оновити до каналу розробки (останній коміт)", "testConnection": "Перевірити з'єднання", "connectionOk": "З'єднання в порядку ({ms} мс)", "connectionFailed": "Помилка з'єднання", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 9afa3edaa..512f2e478 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 61461c46d..6b589cdc6 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -979,6 +979,7 @@ "upToDate": "已是最新", "updateConfirmTitle": "将 {count} 个节点更新到最新版本?", "updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。", + "updateDevChannel": "更新到开发通道(最新提交)", "testConnection": "测试连接", "connectionOk": "连接正常 ({ms} ms)", "connectionFailed": "连接失败", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 338d846f6..82daada7f 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -979,6 +979,7 @@ "upToDate": "已是最新", "updateConfirmTitle": "將 {count} 個節點更新到最新版本?", "updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。", + "updateDevChannel": "更新到開發通道(最新提交)", "testConnection": "測試連線", "connectionOk": "連線正常 ({ms} ms)", "connectionFailed": "連線失敗",