mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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.
This commit is contained in:
parent
c8df1b19ff
commit
971843f669
25 changed files with 511 additions and 42 deletions
|
|
@ -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}": {
|
"/panel/api/nodes/history/{id}/{metric}/{bucket}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import { ProbeResultSchema, type ProbeResult } from '@/schemas/node';
|
||||||
|
|
||||||
export type { ProbeResult };
|
export type { ProbeResult };
|
||||||
|
|
||||||
|
export interface NodeUpdateResult {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useNodeMutations() {
|
export function useNodeMutations() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
|
||||||
|
|
@ -44,12 +51,21 @@ export function useNodeMutations() {
|
||||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
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 {
|
return {
|
||||||
create: (payload: Partial<NodeRecord>) => createMut.mutateAsync(payload),
|
create: (payload: Partial<NodeRecord>) => createMut.mutateAsync(payload),
|
||||||
update: (id: number, payload: Partial<NodeRecord>) => updateMut.mutateAsync({ id, payload }),
|
update: (id: number, payload: Partial<NodeRecord>) => updateMut.mutateAsync({ id, payload }),
|
||||||
remove: (id: number) => removeMut.mutateAsync(id),
|
remove: (id: number) => removeMut.mutateAsync(id),
|
||||||
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
|
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
|
||||||
probe: (id: number) => probeMut.mutateAsync(id),
|
probe: (id: number) => probeMut.mutateAsync(id),
|
||||||
|
updatePanels: (ids: number[]): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync(ids),
|
||||||
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
|
||||||
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
|
||||||
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
return parseMsg(raw, ProbeResultSchema, 'nodes/test');
|
||||||
|
|
|
||||||
29
frontend/src/lib/panel-version.ts
Normal file
29
frontend/src/lib/panel-version.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -777,6 +777,13 @@ export const sections: readonly Section[] = [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
{ 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',
|
method: 'GET',
|
||||||
path: '/panel/api/nodes/history/:id/:metric/:bucket',
|
path: '/panel/api/nodes/history/:id/:metric/:bucket',
|
||||||
|
|
|
||||||
|
|
@ -62,26 +62,6 @@
|
||||||
.dot-orange { background: var(--ant-color-warning); }
|
.dot-orange { background: var(--ant-color-warning); }
|
||||||
.dot-gray { background: var(--ant-color-text-quaternary); }
|
.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 {
|
.status-tag {
|
||||||
margin: 0 0 0 4px;
|
margin: 0 0 0 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { BadgeProps } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
|
CloudDownloadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
|
|
@ -30,17 +31,27 @@ import {
|
||||||
|
|
||||||
import NodeHistoryPanel from './NodeHistoryPanel';
|
import NodeHistoryPanel from './NodeHistoryPanel';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||||
import './NodeList.css';
|
import './NodeList.css';
|
||||||
|
|
||||||
interface NodeListProps {
|
interface NodeListProps {
|
||||||
nodes: NodeRecord[];
|
nodes: NodeRecord[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
latestVersion?: string;
|
||||||
|
selectedIds: number[];
|
||||||
|
onSelectionChange: (ids: number[]) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (node: NodeRecord) => void;
|
onEdit: (node: NodeRecord) => void;
|
||||||
onDelete: (node: NodeRecord) => void;
|
onDelete: (node: NodeRecord) => void;
|
||||||
onProbe: (node: NodeRecord) => void;
|
onProbe: (node: NodeRecord) => void;
|
||||||
onToggleEnable: (node: NodeRecord, next: boolean) => 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 {
|
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 {
|
function formatPct(p?: number): string {
|
||||||
if (typeof p !== 'number' || Number.isNaN(p)) return '-';
|
if (typeof p !== 'number' || Number.isNaN(p)) return '-';
|
||||||
return `${p.toFixed(1)}%`;
|
return `${p.toFixed(1)}%`;
|
||||||
|
|
@ -88,11 +113,16 @@ export default function NodeList({
|
||||||
nodes,
|
nodes,
|
||||||
loading = false,
|
loading = false,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
|
latestVersion = '',
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onProbe,
|
onProbe,
|
||||||
onToggleEnable,
|
onToggleEnable,
|
||||||
|
onUpdateNode,
|
||||||
|
onUpdateSelected,
|
||||||
}: NodeListProps) {
|
}: NodeListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const relativeTime = useRelativeTime();
|
const relativeTime = useRelativeTime();
|
||||||
|
|
@ -122,12 +152,17 @@ export default function NodeList({
|
||||||
{
|
{
|
||||||
title: t('pages.nodes.actions'),
|
title: t('pages.nodes.actions'),
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 160,
|
width: 190,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={t('pages.nodes.probe')}>
|
<Tooltip title={t('pages.nodes.probe')}>
|
||||||
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{isUpdateEligible(record) && (
|
||||||
|
<Tooltip title={t('pages.nodes.updatePanel')}>
|
||||||
|
<Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip title={t('edit')}>
|
<Tooltip title={t('edit')}>
|
||||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -193,8 +228,8 @@ export default function NodeList({
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_value, record) => (
|
render: (_value, record) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Badge status={badgeStatus(record.status)} />
|
<StatusDot status={record.status} />
|
||||||
<span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
|
<StatusLabel status={record.status} />
|
||||||
{record.lastError && (
|
{record.lastError && (
|
||||||
<Tooltip title={record.lastError}>
|
<Tooltip title={record.lastError}>
|
||||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||||
|
|
@ -227,7 +262,22 @@ export default function NodeList({
|
||||||
title: t('pages.nodes.panelVersion') || 'Panel version',
|
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||||
dataIndex: 'panelVersion',
|
dataIndex: 'panelVersion',
|
||||||
align: 'center',
|
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'),
|
title: t('pages.nodes.uptime'),
|
||||||
|
|
@ -266,7 +316,7 @@ export default function NodeList({
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||||
},
|
},
|
||||||
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
|
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size="small" hoverable>
|
<Card size="small" hoverable>
|
||||||
|
|
@ -274,6 +324,11 @@ export default function NodeList({
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||||
{t('pages.nodes.addNode')}
|
{t('pages.nodes.addNode')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
|
||||||
|
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
|
|
@ -289,7 +344,7 @@ export default function NodeList({
|
||||||
<div key={record.id} className="node-card">
|
<div key={record.id} className="node-card">
|
||||||
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
||||||
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
<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>
|
<span className="node-name">{record.name}</span>
|
||||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
<Tooltip title={t('info')}>
|
<Tooltip title={t('info')}>
|
||||||
|
|
@ -313,6 +368,11 @@ export default function NodeList({
|
||||||
label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
|
label: <><ThunderboltOutlined /> {t('pages.nodes.probe')}</>,
|
||||||
onClick: () => onProbe(record),
|
onClick: () => onProbe(record),
|
||||||
},
|
},
|
||||||
|
...(isUpdateEligible(record) ? [{
|
||||||
|
key: 'update',
|
||||||
|
label: <><CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}</>,
|
||||||
|
onClick: () => onUpdateNode(record),
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: <><EditOutlined /> {t('edit')}</>,
|
label: <><EditOutlined /> {t('edit')}</>,
|
||||||
|
|
@ -378,8 +438,8 @@ export default function NodeList({
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.nodes.status')}</span>
|
<span className="stat-label">{t('pages.nodes.status')}</span>
|
||||||
<Badge status={badgeStatus(statsNode.status)} />
|
<StatusDot status={statsNode.status} />
|
||||||
<span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
|
<StatusLabel status={statsNode.status} />
|
||||||
{statsNode.lastError && (
|
{statsNode.lastError && (
|
||||||
<Tooltip title={statsNode.lastError}>
|
<Tooltip title={statsNode.lastError}>
|
||||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||||
|
|
@ -439,6 +499,11 @@ export default function NodeList({
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content' }}
|
||||||
size="middle"
|
size="middle"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedIds,
|
||||||
|
onChange: (keys) => onSelectionChange(keys as number[]),
|
||||||
|
getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
|
||||||
|
}}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
<div className="card-empty">
|
<div className="card-empty">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd';
|
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
|
|
@ -17,6 +18,8 @@ import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import NodeList from './NodeList';
|
import NodeList from './NodeList';
|
||||||
import NodeFormModal from './NodeFormModal';
|
import NodeFormModal from './NodeFormModal';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -27,11 +30,21 @@ export default function NodesPage() {
|
||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
||||||
const { nodes, loading, fetched, totals } = useNodesQuery();
|
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 [formOpen, setFormOpen] = useState(false);
|
||||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||||
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
|
||||||
const onAdd = useCallback(() => {
|
const onAdd = useCallback(() => {
|
||||||
setFormMode('add');
|
setFormMode('add');
|
||||||
|
|
@ -81,6 +94,52 @@ export default function NodesPage() {
|
||||||
await setEnable(node.id, next);
|
await setEnable(node.id, next);
|
||||||
}, [setEnable]);
|
}, [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 pageClass = useMemo(() => {
|
||||||
const classes = ['nodes-page'];
|
const classes = ['nodes-page'];
|
||||||
if (isDark) classes.push('is-dark');
|
if (isDark) classes.push('is-dark');
|
||||||
|
|
@ -142,11 +201,16 @@ export default function NodesPage() {
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onSelectionChange={setSelectedIds}
|
||||||
onAdd={onAdd}
|
onAdd={onAdd}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onProbe={onProbe}
|
onProbe={onProbe}
|
||||||
onToggleEnable={onToggleEnable}
|
onToggleEnable={onToggleEnable}
|
||||||
|
onUpdateNode={onUpdateNode}
|
||||||
|
onUpdateSelected={onUpdateSelected}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,23 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--ant-color-error);
|
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
frontend/src/test/panel-version.test.ts
Normal file
33
frontend/src/test/panel-version.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -35,6 +35,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.POST("/test", a.test)
|
g.POST("/test", a.test)
|
||||||
g.POST("/probe/:id", a.probe)
|
g.POST("/probe/:id", a.probe)
|
||||||
|
g.POST("/updatePanel", a.updatePanel)
|
||||||
g.GET("/history/:id/:metric/:bucket", a.history)
|
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)
|
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) {
|
func (a *NodeController) history(c *gin.Context) {
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,14 @@ func (r *Remote) RestartXray(ctx context.Context) error {
|
||||||
return err
|
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 {
|
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
|
||||||
_, err := r.do(ctx, http.MethodPost,
|
_, err := r.do(ctx, http.MethodPost,
|
||||||
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
|
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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 {
|
func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "إصدار اللوحة",
|
"panelVersion": "إصدار اللوحة",
|
||||||
"actions": "العمليات",
|
"actions": "العمليات",
|
||||||
"probe": "فحص فوري",
|
"probe": "فحص فوري",
|
||||||
|
"updatePanel": "تحديث اللوحة",
|
||||||
|
"updateSelected": "تحديث المحدد ({count})",
|
||||||
|
"updateAvailable": "تحديث متاح",
|
||||||
|
"upToDate": "محدّث",
|
||||||
|
"updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
|
||||||
|
"updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
|
||||||
"testConnection": "اختبار الاتصال",
|
"testConnection": "اختبار الاتصال",
|
||||||
"connectionOk": "الاتصال شغال ({ms} ms)",
|
"connectionOk": "الاتصال شغال ({ms} ms)",
|
||||||
"connectionFailed": "فشل الاتصال",
|
"connectionFailed": "فشل الاتصال",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "اتمسح النود",
|
"deleted": "اتمسح النود",
|
||||||
"test": "اختبار الاتصال",
|
"test": "اختبار الاتصال",
|
||||||
"fillRequired": "الاسم والعنوان والبورت وتوكن API كلهم مطلوبين",
|
"fillRequired": "الاسم والعنوان والبورت وتوكن API كلهم مطلوبين",
|
||||||
"probeFailed": "فشل الفحص"
|
"probeFailed": "فشل الفحص",
|
||||||
|
"updateStarted": "بدأ تحديث اللوحة",
|
||||||
|
"updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
|
||||||
|
"updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Panel Version",
|
"panelVersion": "Panel Version",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"probe": "Probe Now",
|
"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",
|
"testConnection": "Test Connection",
|
||||||
"connectionOk": "Connection OK ({ms} ms)",
|
"connectionOk": "Connection OK ({ms} ms)",
|
||||||
"connectionFailed": "Connection failed",
|
"connectionFailed": "Connection failed",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Node deleted",
|
"deleted": "Node deleted",
|
||||||
"test": "Test connection",
|
"test": "Test connection",
|
||||||
"fillRequired": "Name, address, port and API token are required",
|
"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": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Versión del panel",
|
"panelVersion": "Versión del panel",
|
||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"probe": "Sondear ahora",
|
"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",
|
"testConnection": "Probar conexión",
|
||||||
"connectionOk": "Conexión correcta ({ms} ms)",
|
"connectionOk": "Conexión correcta ({ms} ms)",
|
||||||
"connectionFailed": "Conexión fallida",
|
"connectionFailed": "Conexión fallida",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Nodo eliminado",
|
"deleted": "Nodo eliminado",
|
||||||
"test": "Probar conexión",
|
"test": "Probar conexión",
|
||||||
"fillRequired": "El nombre, la dirección, el puerto y el token de API son obligatorios",
|
"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": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "نسخه پنل",
|
"panelVersion": "نسخه پنل",
|
||||||
"actions": "عملیات",
|
"actions": "عملیات",
|
||||||
"probe": "بررسی فوری",
|
"probe": "بررسی فوری",
|
||||||
|
"updatePanel": "بهروزرسانی پنل",
|
||||||
|
"updateSelected": "بهروزرسانی انتخابشدهها ({count})",
|
||||||
|
"updateAvailable": "بهروزرسانی موجود",
|
||||||
|
"upToDate": "بهروز",
|
||||||
|
"updateConfirmTitle": "{count} نود به آخرین نسخه بهروزرسانی شوند؟",
|
||||||
|
"updateConfirmContent": "هر نود انتخابشده آخرین نسخه را دانلود و روی آن ریاستارت میشود. فقط نودهای فعال و آنلاین بهروزرسانی میشوند.",
|
||||||
"testConnection": "تست اتصال",
|
"testConnection": "تست اتصال",
|
||||||
"connectionOk": "اتصال موفق ({ms} میلیثانیه)",
|
"connectionOk": "اتصال موفق ({ms} میلیثانیه)",
|
||||||
"connectionFailed": "اتصال ناموفق",
|
"connectionFailed": "اتصال ناموفق",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "نود حذف شد",
|
"deleted": "نود حذف شد",
|
||||||
"test": "تست اتصال",
|
"test": "تست اتصال",
|
||||||
"fillRequired": "نام، آدرس، پورت و توکن API الزامی است",
|
"fillRequired": "نام، آدرس، پورت و توکن API الزامی است",
|
||||||
"probeFailed": "بررسی ناموفق"
|
"probeFailed": "بررسی ناموفق",
|
||||||
|
"updateStarted": "بهروزرسانی پنل آغاز شد",
|
||||||
|
"updateResult": "بهروزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
|
||||||
|
"updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Versi panel",
|
"panelVersion": "Versi panel",
|
||||||
"actions": "Aksi",
|
"actions": "Aksi",
|
||||||
"probe": "Probe Sekarang",
|
"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",
|
"testConnection": "Tes Koneksi",
|
||||||
"connectionOk": "Koneksi OK ({ms} ms)",
|
"connectionOk": "Koneksi OK ({ms} ms)",
|
||||||
"connectionFailed": "Koneksi gagal",
|
"connectionFailed": "Koneksi gagal",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Node dihapus",
|
"deleted": "Node dihapus",
|
||||||
"test": "Tes koneksi",
|
"test": "Tes koneksi",
|
||||||
"fillRequired": "Nama, alamat, port, dan token API wajib diisi",
|
"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": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "パネルのバージョン",
|
"panelVersion": "パネルのバージョン",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"probe": "今すぐプローブ",
|
"probe": "今すぐプローブ",
|
||||||
|
"updatePanel": "パネルを更新",
|
||||||
|
"updateSelected": "選択を更新 ({count})",
|
||||||
|
"updateAvailable": "更新あり",
|
||||||
|
"upToDate": "最新",
|
||||||
|
"updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
|
||||||
|
"updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
|
||||||
"testConnection": "接続テスト",
|
"testConnection": "接続テスト",
|
||||||
"connectionOk": "接続OK ({ms} ms)",
|
"connectionOk": "接続OK ({ms} ms)",
|
||||||
"connectionFailed": "接続に失敗しました",
|
"connectionFailed": "接続に失敗しました",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "ノードを削除しました",
|
"deleted": "ノードを削除しました",
|
||||||
"test": "接続テスト",
|
"test": "接続テスト",
|
||||||
"fillRequired": "名前、アドレス、ポート、APIトークンは必須です",
|
"fillRequired": "名前、アドレス、ポート、APIトークンは必須です",
|
||||||
"probeFailed": "プローブに失敗しました"
|
"probeFailed": "プローブに失敗しました",
|
||||||
|
"updateStarted": "パネルの更新を開始しました",
|
||||||
|
"updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
|
||||||
|
"updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Versão do painel",
|
"panelVersion": "Versão do painel",
|
||||||
"actions": "Ações",
|
"actions": "Ações",
|
||||||
"probe": "Sondar agora",
|
"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",
|
"testConnection": "Testar conexão",
|
||||||
"connectionOk": "Conexão OK ({ms} ms)",
|
"connectionOk": "Conexão OK ({ms} ms)",
|
||||||
"connectionFailed": "Falha na conexão",
|
"connectionFailed": "Falha na conexão",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Nó excluído",
|
"deleted": "Nó excluído",
|
||||||
"test": "Testar conexão",
|
"test": "Testar conexão",
|
||||||
"fillRequired": "Nome, endereço, porta e token da API são obrigatórios",
|
"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": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Версия панели",
|
"panelVersion": "Версия панели",
|
||||||
"actions": "Действия",
|
"actions": "Действия",
|
||||||
"probe": "Проверить сейчас",
|
"probe": "Проверить сейчас",
|
||||||
|
"updatePanel": "Обновить панель",
|
||||||
|
"updateSelected": "Обновить выбранные ({count})",
|
||||||
|
"updateAvailable": "Доступно обновление",
|
||||||
|
"upToDate": "Актуально",
|
||||||
|
"updateConfirmTitle": "Обновить {count} узлов до последней версии?",
|
||||||
|
"updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.",
|
||||||
"testConnection": "Проверить соединение",
|
"testConnection": "Проверить соединение",
|
||||||
"connectionOk": "Соединение в порядке ({ms} мс)",
|
"connectionOk": "Соединение в порядке ({ms} мс)",
|
||||||
"connectionFailed": "Не удалось подключиться",
|
"connectionFailed": "Не удалось подключиться",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Узел удалён",
|
"deleted": "Узел удалён",
|
||||||
"test": "Проверить соединение",
|
"test": "Проверить соединение",
|
||||||
"fillRequired": "Имя, адрес, порт и токен API обязательны",
|
"fillRequired": "Имя, адрес, порт и токен API обязательны",
|
||||||
"probeFailed": "Проверка не удалась"
|
"probeFailed": "Проверка не удалась",
|
||||||
|
"updateStarted": "Обновление панели запущено",
|
||||||
|
"updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
|
||||||
|
"updateNoneEligible": "Выберите хотя бы один включённый узел в сети"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Panel sürümü",
|
"panelVersion": "Panel sürümü",
|
||||||
"actions": "İşlemler",
|
"actions": "İşlemler",
|
||||||
"probe": "Şimdi Test Et",
|
"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",
|
"testConnection": "Bağlantıyı Test Et",
|
||||||
"connectionOk": "Bağlantı tamam ({ms} ms)",
|
"connectionOk": "Bağlantı tamam ({ms} ms)",
|
||||||
"connectionFailed": "Bağlantı başarısız",
|
"connectionFailed": "Bağlantı başarısız",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Düğüm silindi",
|
"deleted": "Düğüm silindi",
|
||||||
"test": "Bağlantıyı test et",
|
"test": "Bağlantıyı test et",
|
||||||
"fillRequired": "Ad, adres, port ve API token gereklidir",
|
"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": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Версія панелі",
|
"panelVersion": "Версія панелі",
|
||||||
"actions": "Дії",
|
"actions": "Дії",
|
||||||
"probe": "Перевірити зараз",
|
"probe": "Перевірити зараз",
|
||||||
|
"updatePanel": "Оновити панель",
|
||||||
|
"updateSelected": "Оновити вибрані ({count})",
|
||||||
|
"updateAvailable": "Доступне оновлення",
|
||||||
|
"upToDate": "Актуально",
|
||||||
|
"updateConfirmTitle": "Оновити {count} вузлів до останньої версії?",
|
||||||
|
"updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.",
|
||||||
"testConnection": "Перевірити з'єднання",
|
"testConnection": "Перевірити з'єднання",
|
||||||
"connectionOk": "З'єднання в порядку ({ms} мс)",
|
"connectionOk": "З'єднання в порядку ({ms} мс)",
|
||||||
"connectionFailed": "Помилка з'єднання",
|
"connectionFailed": "Помилка з'єднання",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Вузол видалено",
|
"deleted": "Вузол видалено",
|
||||||
"test": "Перевірити з'єднання",
|
"test": "Перевірити з'єднання",
|
||||||
"fillRequired": "Назва, адреса, порт та токен API є обов'язковими",
|
"fillRequired": "Назва, адреса, порт та токен API є обов'язковими",
|
||||||
"probeFailed": "Помилка перевірки"
|
"probeFailed": "Помилка перевірки",
|
||||||
|
"updateStarted": "Оновлення панелі розпочато",
|
||||||
|
"updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
|
||||||
|
"updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "Phiên bản panel",
|
"panelVersion": "Phiên bản panel",
|
||||||
"actions": "Hành động",
|
"actions": "Hành động",
|
||||||
"probe": "Kiểm tra ngay",
|
"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",
|
"testConnection": "Kiểm tra kết nối",
|
||||||
"connectionOk": "Kết nối OK ({ms} ms)",
|
"connectionOk": "Kết nối OK ({ms} ms)",
|
||||||
"connectionFailed": "Kết nối thất bại",
|
"connectionFailed": "Kết nối thất bại",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "Đã xóa nút",
|
"deleted": "Đã xóa nút",
|
||||||
"test": "Kiểm tra kết nối",
|
"test": "Kiểm tra kết nối",
|
||||||
"fillRequired": "Tên, địa chỉ, cổng và token API là bắt buộc",
|
"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": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "面板版本",
|
"panelVersion": "面板版本",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"probe": "立即探测",
|
"probe": "立即探测",
|
||||||
|
"updatePanel": "更新面板",
|
||||||
|
"updateSelected": "更新所选 ({count})",
|
||||||
|
"updateAvailable": "有可用更新",
|
||||||
|
"upToDate": "已是最新",
|
||||||
|
"updateConfirmTitle": "将 {count} 个节点更新到最新版本?",
|
||||||
|
"updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。",
|
||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
"connectionOk": "连接正常 ({ms} ms)",
|
"connectionOk": "连接正常 ({ms} ms)",
|
||||||
"connectionFailed": "连接失败",
|
"connectionFailed": "连接失败",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "节点已删除",
|
"deleted": "节点已删除",
|
||||||
"test": "测试连接",
|
"test": "测试连接",
|
||||||
"fillRequired": "名称、地址、端口和 API 令牌为必填项",
|
"fillRequired": "名称、地址、端口和 API 令牌为必填项",
|
||||||
"probeFailed": "探测失败"
|
"probeFailed": "探测失败",
|
||||||
|
"updateStarted": "已开始更新面板",
|
||||||
|
"updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
|
||||||
|
"updateNoneEligible": "请至少选择一个在线且已启用的节点"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,12 @@
|
||||||
"panelVersion": "面板版本",
|
"panelVersion": "面板版本",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"probe": "立即探測",
|
"probe": "立即探測",
|
||||||
|
"updatePanel": "更新面板",
|
||||||
|
"updateSelected": "更新所選 ({count})",
|
||||||
|
"updateAvailable": "有可用更新",
|
||||||
|
"upToDate": "已是最新",
|
||||||
|
"updateConfirmTitle": "將 {count} 個節點更新到最新版本?",
|
||||||
|
"updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。",
|
||||||
"testConnection": "測試連線",
|
"testConnection": "測試連線",
|
||||||
"connectionOk": "連線正常 ({ms} ms)",
|
"connectionOk": "連線正常 ({ms} ms)",
|
||||||
"connectionFailed": "連線失敗",
|
"connectionFailed": "連線失敗",
|
||||||
|
|
@ -853,7 +859,10 @@
|
||||||
"deleted": "節點已刪除",
|
"deleted": "節點已刪除",
|
||||||
"test": "測試連線",
|
"test": "測試連線",
|
||||||
"fillRequired": "名稱、位址、埠與 API 權杖為必填",
|
"fillRequired": "名稱、位址、埠與 API 權杖為必填",
|
||||||
"probeFailed": "探測失敗"
|
"probeFailed": "探測失敗",
|
||||||
|
"updateStarted": "已開始更新面板",
|
||||||
|
"updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
|
||||||
|
"updateNoneEligible": "請至少選擇一個在線且已啟用的節點"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue