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.
This commit is contained in:
MHSanaei 2026-06-25 00:29:03 +02:00
parent 11c5b53fac
commit e8878b71a4
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
22 changed files with 100 additions and 25 deletions

View file

@ -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
}
}
}

View file

@ -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');

View file

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

View file

@ -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]);

View file

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

View file

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

View file

@ -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
}

View file

@ -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()

View file

@ -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))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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