feat(update): add rolling dev update channel for per-commit builds

Adds an opt-in Dev channel so panels running CI per-commit builds can self-update to the latest commit, mirroring the stable online-update flow.

CI publishes/overwrites a single fixed-tag pre-release (dev-latest), force-moved to the newest main commit and marked --latest=false so releases/latest stays the stable tag. Builds stamp the short commit via -ldflags; the panel compares the running commit to the dev release commit to detect an update, and update.sh honors XUI_UPDATE_TAG to install from that tag. Linux/systemd only.
This commit is contained in:
MHSanaei
2026-06-24 18:11:22 +02:00
parent 93ff60e568
commit aad2b3eb1e
25 changed files with 556 additions and 48 deletions
+6
View File
@@ -400,6 +400,12 @@ export const sections: readonly Section[] = [
path: '/panel/api/server/updatePanel',
summary: 'Self-update the panel to the latest version. The server restarts on success.',
},
{
method: 'POST',
path: '/panel/api/server/setUpdateChannel',
summary: 'Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.',
body: '{\n "dev": true\n}',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile',
+25 -4
View File
@@ -65,6 +65,8 @@ export default function IndexPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [accessLogEnable, setAccessLogEnable] = useState(false);
const [isDevBuild, setIsDevBuild] = useState(false);
const [devChannelEnable, setDevChannelEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '',
latestVersion: '',
@@ -87,8 +89,14 @@ export default function IndexPage() {
const [loadingTip, setLoadingTip] = useState(t('loading'));
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable);
HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
).then((msg) => {
if (msg?.success && msg.obj) {
setAccessLogEnable(!!msg.obj.accessLogEnable);
setIsDevBuild(!!msg.obj.isDevBuild);
setDevChannelEnable(!!msg.obj.devChannelEnable);
}
});
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
@@ -119,13 +127,21 @@ export default function IndexPage() {
}, [refresh]);
function openPanelVersion() {
if (panelUpdateInfo.updateAvailable) {
if (panelUpdateInfo.updateAvailable || isDevBuild) {
setPanelUpdateOpen(true);
} else {
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
}
}
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
setDevChannelEnable(dev);
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
@@ -224,7 +240,9 @@ export default function IndexPage() {
{isMobile && displayVersion && (
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
{panelUpdateInfo.updateAvailable
? `v${panelUpdateInfo.latestVersion}`
? panelUpdateInfo.channel === 'dev'
? panelUpdateInfo.latestVersion
: `v${panelUpdateInfo.latestVersion}`
: `v${displayVersion}`}
</Tag>
)}
@@ -446,6 +464,9 @@ export default function IndexPage() {
<PanelUpdateModal
open={panelUpdateOpen}
info={panelUpdateInfo}
isDevBuild={isDevBuild}
devChannelEnable={devChannelEnable}
onChannelChange={handleChannelChange}
onClose={() => setPanelUpdateOpen(false)}
onBusy={setBusy}
/>
+60 -6
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Modal, Tag } from 'antd';
import { Alert, Button, Modal, Switch, Tag } from 'antd';
import { CloudDownloadOutlined } from '@ant-design/icons';
import axios from 'axios';
@@ -7,8 +8,11 @@ import { HttpUtil, PromiseUtil } from '@/utils';
import './PanelUpdateModal.css';
export interface PanelUpdateInfo {
channel?: string;
currentVersion: string;
latestVersion: string;
currentCommit?: string;
latestCommit?: string;
updateAvailable: boolean;
}
@@ -20,13 +24,27 @@ interface BusyEvent {
interface PanelUpdateModalProps {
open: boolean;
info: PanelUpdateInfo;
isDevBuild?: boolean;
devChannelEnable?: boolean;
onChannelChange?: (dev: boolean) => void | Promise<void>;
onClose: () => void;
onBusy: (e: BusyEvent) => void;
}
export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelUpdateModalProps) {
export default function PanelUpdateModal({
open,
info,
isDevBuild,
devChannelEnable,
onChannelChange,
onClose,
onBusy,
}: PanelUpdateModalProps) {
const { t } = useTranslation();
const [modal, contextHolder] = Modal.useModal();
const [channelBusy, setChannelBusy] = useState(false);
const isDev = info.channel === 'dev';
async function pollUntilBack(): Promise<boolean> {
await PromiseUtil.sleep(5000);
@@ -43,6 +61,16 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
return false;
}
async function handleChannel(checked: boolean) {
if (!onChannelChange) return;
setChannelBusy(true);
try {
await onChannelChange(checked);
} finally {
setChannelBusy(false);
}
}
function updatePanel() {
modal.confirm({
title: t('pages.index.panelUpdateDialog'),
@@ -84,15 +112,41 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
/>
)}
{isDevBuild && (
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
</div>
)}
{devChannelEnable && (
<Alert
type="info"
className="mb-12"
title={t('pages.index.devChannelWarning')}
showIcon
/>
)}
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.currentPanelVersion')}</span>
<Tag color="green">v{info.currentVersion || '?'}</Tag>
<span>{isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')}</span>
{isDev ? (
<Tag color="green">{info.currentCommit || '?'}</Tag>
) : (
<Tag color="green">v{info.currentVersion || '?'}</Tag>
)}
</div>
{info.updateAvailable ? (
<div className="version-list-item">
<span>{t('pages.index.latestPanelVersion')}</span>
<Tag color="purple">{info.latestVersion || '-'}</Tag>
<span>{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}</span>
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
</div>
) : (
<div className="version-list-item">