From 971843f669cd77e9cde0058ccdc9d676f6f9bc29 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 1 Jun 2026 07:03:06 +0200 Subject: [PATCH] feat(nodes): bulk panel self-update with live online indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/public/openapi.json | 63 +++++++++++++++ frontend/src/api/queries/useNodeMutations.ts | 16 ++++ frontend/src/lib/panel-version.ts | 29 +++++++ frontend/src/pages/api-docs/endpoints.ts | 7 ++ frontend/src/pages/clients/ClientsPage.css | 20 ----- frontend/src/pages/nodes/NodeList.tsx | 81 ++++++++++++++++++-- frontend/src/pages/nodes/NodesPage.tsx | 66 +++++++++++++++- frontend/src/styles/utils.css | 20 +++++ frontend/src/test/panel-version.test.ts | 33 ++++++++ web/controller/node.go | 17 ++++ web/runtime/remote.go | 8 ++ web/service/node.go | 50 ++++++++++++ web/translation/ar-EG.json | 11 ++- web/translation/en-US.json | 11 ++- web/translation/es-ES.json | 11 ++- web/translation/fa-IR.json | 11 ++- web/translation/id-ID.json | 11 ++- web/translation/ja-JP.json | 11 ++- web/translation/pt-BR.json | 11 ++- web/translation/ru-RU.json | 11 ++- web/translation/tr-TR.json | 11 ++- web/translation/uk-UA.json | 11 ++- web/translation/vi-VN.json | 11 ++- web/translation/zh-CN.json | 11 ++- web/translation/zh-TW.json | 11 ++- 25 files changed, 511 insertions(+), 42 deletions(-) create mode 100644 frontend/src/lib/panel-version.ts create mode 100644 frontend/src/test/panel-version.test.ts diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 745b9e5c..c5fbd08e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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}": { "get": { "tags": [ diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index 5863cb14..47f2a87d 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -8,6 +8,13 @@ import { ProbeResultSchema, type ProbeResult } from '@/schemas/node'; export type { ProbeResult }; +export interface NodeUpdateResult { + id: number; + name?: string; + ok: boolean; + error?: string; +} + export function useNodeMutations() { const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() }); @@ -44,12 +51,21 @@ export function useNodeMutations() { onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); + const updatePanelsMut = useMutation({ + mutationFn: (ids: number[]) => + HttpUtil.post('/panel/api/nodes/updatePanel', { ids }, { + headers: { 'Content-Type': 'application/json' }, + }), + onSuccess: (msg) => { if (msg?.success) invalidate(); }, + }); + return { create: (payload: Partial) => createMut.mutateAsync(payload), update: (id: number, payload: Partial) => updateMut.mutateAsync({ id, payload }), 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), 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/lib/panel-version.ts b/frontend/src/lib/panel-version.ts new file mode 100644 index 00000000..a3247cce --- /dev/null +++ b/frontend/src/lib/panel-version.ts @@ -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; +} diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 602f955c..ce40ef6d 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -777,6 +777,13 @@ export const sections: readonly Section[] = [ { 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', path: '/panel/api/nodes/history/:id/:metric/:bucket', diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index 7081c2cc..085ce490 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/frontend/src/pages/clients/ClientsPage.css @@ -62,26 +62,6 @@ .dot-orange { background: var(--ant-color-warning); } .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 { margin: 0 0 0 4px; font-size: 11px; diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index dd23402d..400536e7 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -16,6 +16,7 @@ import type { BadgeProps } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { ClusterOutlined, + CloudDownloadOutlined, DeleteOutlined, EditOutlined, ExclamationCircleOutlined, @@ -30,17 +31,27 @@ import { import NodeHistoryPanel from './NodeHistoryPanel'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import { isPanelUpdateAvailable } from '@/lib/panel-version'; import './NodeList.css'; interface NodeListProps { nodes: NodeRecord[]; loading?: boolean; isMobile?: boolean; + latestVersion?: string; + selectedIds: number[]; + onSelectionChange: (ids: number[]) => void; onAdd: () => void; onEdit: (node: NodeRecord) => void; onDelete: (node: NodeRecord) => void; onProbe: (node: NodeRecord) => 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 { @@ -56,6 +67,20 @@ function badgeStatus(status?: string): BadgeProps['status'] { } } +function StatusDot({ status }: { status?: string }) { + if (status === 'online') return ; + return ; +} + +function StatusLabel({ status }: { status?: string }) { + const { t } = useTranslation(); + return ( + + {t(`pages.nodes.statusValues.${status || 'unknown'}`)} + + ); +} + function formatPct(p?: number): string { if (typeof p !== 'number' || Number.isNaN(p)) return '-'; return `${p.toFixed(1)}%`; @@ -88,11 +113,16 @@ export default function NodeList({ nodes, loading = false, isMobile = false, + latestVersion = '', + selectedIds, + onSelectionChange, onAdd, onEdit, onDelete, onProbe, onToggleEnable, + onUpdateNode, + onUpdateSelected, }: NodeListProps) { const { t } = useTranslation(); const relativeTime = useRelativeTime(); @@ -122,12 +152,17 @@ export default function NodeList({ { title: t('pages.nodes.actions'), align: 'center', - width: 160, + width: 190, render: (_value, record) => ( + {selectedIds.length > 0 && ( + + )} {isMobile ? ( @@ -289,7 +344,7 @@ export default function NodeList({
toggleExpanded(record.id)}> - + {record.name}
e.stopPropagation()}> @@ -313,6 +368,11 @@ export default function NodeList({ label: <> {t('pages.nodes.probe')}, onClick: () => onProbe(record), }, + ...(isUpdateEligible(record) ? [{ + key: 'update', + label: <> {t('pages.nodes.updatePanel')}, + onClick: () => onUpdateNode(record), + }] : []), { key: 'edit', label: <> {t('edit')}, @@ -378,8 +438,8 @@ export default function NodeList({
{t('pages.nodes.status')} - - {t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)} + + {statsNode.lastError && ( @@ -439,6 +499,11 @@ export default function NodeList({ scroll={{ x: 'max-content' }} size="middle" rowKey="id" + rowSelection={{ + selectedRowKeys: selectedIds, + onChange: (keys) => onSelectionChange(keys as number[]), + getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }), + }} locale={{ emptyText: (
diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index 6808ee9d..219834cd 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd'; import { CheckCircleOutlined, @@ -17,6 +18,8 @@ import AppSidebar from '@/layouts/AppSidebar'; import NodeList from './NodeList'; import NodeFormModal from './NodeFormModal'; import { setMessageInstance } from '@/utils/messageBus'; +import { HttpUtil } from '@/utils'; +import type { PanelUpdateInfo } from '../index/PanelUpdateModal'; export default function NodesPage() { const { t } = useTranslation(); @@ -27,11 +30,21 @@ export default function NodesPage() { useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); 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('/panel/api/server/getPanelUpdateInfo'); + return msg?.obj?.latestVersion || ''; + }, + staleTime: 5 * 60 * 1000, + }); const [formOpen, setFormOpen] = useState(false); const [formMode, setFormMode] = useState<'add' | 'edit'>('add'); const [formNode, setFormNode] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); const onAdd = useCallback(() => { setFormMode('add'); @@ -81,6 +94,52 @@ export default function NodesPage() { await setEnable(node.id, next); }, [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 classes = ['nodes-page']; if (isDark) classes.push('is-dark'); @@ -142,11 +201,16 @@ export default function NodesPage() { nodes={nodes} loading={loading} isMobile={isMobile} + latestVersion={latestVersion} + selectedIds={selectedIds} + onSelectionChange={setSelectedIds} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} onProbe={onProbe} onToggleEnable={onToggleEnable} + onUpdateNode={onUpdateNode} + onUpdateSelected={onUpdateSelected} /> diff --git a/frontend/src/styles/utils.css b/frontend/src/styles/utils.css index 047e053f..879a2687 100644 --- a/frontend/src/styles/utils.css +++ b/frontend/src/styles/utils.css @@ -21,3 +21,23 @@ cursor: pointer; 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; } +} diff --git a/frontend/src/test/panel-version.test.ts b/frontend/src/test/panel-version.test.ts new file mode 100644 index 00000000..cd29db06 --- /dev/null +++ b/frontend/src/test/panel-version.test.ts @@ -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); + }); +}); diff --git a/web/controller/node.go b/web/controller/node.go index 1e161b77..b7066615 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -35,6 +35,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) { g.POST("/test", a.test) g.POST("/probe/:id", a.probe) + g.POST("/updatePanel", a.updatePanel) 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) } +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) { id, err := strconv.Atoi(c.Param("id")) if err != nil { diff --git a/web/runtime/remote.go b/web/runtime/remote.go index 56caaa0e..6c80b311 100644 --- a/web/runtime/remote.go +++ b/web/runtime/remote.go @@ -320,6 +320,14 @@ func (r *Remote) RestartXray(ctx context.Context) error { 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 { _, err := r.do(ctx, http.MethodPost, "panel/api/clients/resetTraffic/"+url.PathEscape(email), nil) diff --git a/web/service/node.go b/web/service/node.go index 29cf5f10..b6d2613f 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -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 } +// 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 { db := database.GetDB() updates := map[string]any{ diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index f6096c00..f5fd7436 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -832,6 +832,12 @@ "panelVersion": "إصدار اللوحة", "actions": "العمليات", "probe": "فحص فوري", + "updatePanel": "تحديث اللوحة", + "updateSelected": "تحديث المحدد ({count})", + "updateAvailable": "تحديث متاح", + "upToDate": "محدّث", + "updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟", + "updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.", "testConnection": "اختبار الاتصال", "connectionOk": "الاتصال شغال ({ms} ms)", "connectionFailed": "فشل الاتصال", @@ -853,7 +859,10 @@ "deleted": "اتمسح النود", "test": "اختبار الاتصال", "fillRequired": "الاسم والعنوان والبورت وتوكن API كلهم مطلوبين", - "probeFailed": "فشل الفحص" + "probeFailed": "فشل الفحص", + "updateStarted": "بدأ تحديث اللوحة", + "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}", + "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة" } }, "settings": { diff --git a/web/translation/en-US.json b/web/translation/en-US.json index e8758192..5c0b83dc 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -832,6 +832,12 @@ "panelVersion": "Panel Version", "actions": "Actions", "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", "connectionOk": "Connection OK ({ms} ms)", "connectionFailed": "Connection failed", @@ -853,7 +859,10 @@ "deleted": "Node deleted", "test": "Test connection", "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": { diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 9f8c0c0c..0d748024 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -832,6 +832,12 @@ "panelVersion": "Versión del panel", "actions": "Acciones", "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", "connectionOk": "Conexión correcta ({ms} ms)", "connectionFailed": "Conexión fallida", @@ -853,7 +859,10 @@ "deleted": "Nodo eliminado", "test": "Probar conexión", "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": { diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index 4f6c7283..7578c204 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -832,6 +832,12 @@ "panelVersion": "نسخه پنل", "actions": "عملیات", "probe": "بررسی فوری", + "updatePanel": "به‌روزرسانی پنل", + "updateSelected": "به‌روزرسانی انتخاب‌شده‌ها ({count})", + "updateAvailable": "به‌روزرسانی موجود", + "upToDate": "به‌روز", + "updateConfirmTitle": "{count} نود به آخرین نسخه به‌روزرسانی شوند؟", + "updateConfirmContent": "هر نود انتخاب‌شده آخرین نسخه را دانلود و روی آن ری‌استارت می‌شود. فقط نودهای فعال و آنلاین به‌روزرسانی می‌شوند.", "testConnection": "تست اتصال", "connectionOk": "اتصال موفق ({ms} میلی‌ثانیه)", "connectionFailed": "اتصال ناموفق", @@ -853,7 +859,10 @@ "deleted": "نود حذف شد", "test": "تست اتصال", "fillRequired": "نام، آدرس، پورت و توکن API الزامی است", - "probeFailed": "بررسی ناموفق" + "probeFailed": "بررسی ناموفق", + "updateStarted": "به‌روزرسانی پنل آغاز شد", + "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق", + "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید" } }, "settings": { diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index 1e04cede..0d93db1e 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -832,6 +832,12 @@ "panelVersion": "Versi panel", "actions": "Aksi", "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", "connectionOk": "Koneksi OK ({ms} ms)", "connectionFailed": "Koneksi gagal", @@ -853,7 +859,10 @@ "deleted": "Node dihapus", "test": "Tes koneksi", "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": { diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 8fee6d12..21459419 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -832,6 +832,12 @@ "panelVersion": "パネルのバージョン", "actions": "操作", "probe": "今すぐプローブ", + "updatePanel": "パネルを更新", + "updateSelected": "選択を更新 ({count})", + "updateAvailable": "更新あり", + "upToDate": "最新", + "updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?", + "updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。", "testConnection": "接続テスト", "connectionOk": "接続OK ({ms} ms)", "connectionFailed": "接続に失敗しました", @@ -853,7 +859,10 @@ "deleted": "ノードを削除しました", "test": "接続テスト", "fillRequired": "名前、アドレス、ポート、APIトークンは必須です", - "probeFailed": "プローブに失敗しました" + "probeFailed": "プローブに失敗しました", + "updateStarted": "パネルの更新を開始しました", + "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗", + "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください" } }, "settings": { diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 3cf77b08..9f7306a9 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -832,6 +832,12 @@ "panelVersion": "Versão do painel", "actions": "Ações", "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", "connectionOk": "Conexão OK ({ms} ms)", "connectionFailed": "Falha na conexão", @@ -853,7 +859,10 @@ "deleted": "Nó excluído", "test": "Testar conexão", "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": { diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index 68102712..26ab7ccb 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -832,6 +832,12 @@ "panelVersion": "Версия панели", "actions": "Действия", "probe": "Проверить сейчас", + "updatePanel": "Обновить панель", + "updateSelected": "Обновить выбранные ({count})", + "updateAvailable": "Доступно обновление", + "upToDate": "Актуально", + "updateConfirmTitle": "Обновить {count} узлов до последней версии?", + "updateConfirmContent": "Каждый выбранный узел загрузит последний релиз и перезапустится. Обновляются только включённые узлы в сети.", "testConnection": "Проверить соединение", "connectionOk": "Соединение в порядке ({ms} мс)", "connectionFailed": "Не удалось подключиться", @@ -853,7 +859,10 @@ "deleted": "Узел удалён", "test": "Проверить соединение", "fillRequired": "Имя, адрес, порт и токен API обязательны", - "probeFailed": "Проверка не удалась" + "probeFailed": "Проверка не удалась", + "updateStarted": "Обновление панели запущено", + "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось", + "updateNoneEligible": "Выберите хотя бы один включённый узел в сети" } }, "settings": { diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index cc097f60..cddc8fbb 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -832,6 +832,12 @@ "panelVersion": "Panel sürümü", "actions": "İşlemler", "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", "connectionOk": "Bağlantı tamam ({ms} ms)", "connectionFailed": "Bağlantı başarısız", @@ -853,7 +859,10 @@ "deleted": "Düğüm silindi", "test": "Bağlantıyı test et", "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": { diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index f1f7c6a5..c2289b3a 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -832,6 +832,12 @@ "panelVersion": "Версія панелі", "actions": "Дії", "probe": "Перевірити зараз", + "updatePanel": "Оновити панель", + "updateSelected": "Оновити вибрані ({count})", + "updateAvailable": "Доступне оновлення", + "upToDate": "Актуально", + "updateConfirmTitle": "Оновити {count} вузлів до останньої версії?", + "updateConfirmContent": "Кожен вибраний вузол завантажить останній реліз і перезапуститься. Оновлюються лише увімкнені вузли в мережі.", "testConnection": "Перевірити з'єднання", "connectionOk": "З'єднання в порядку ({ms} мс)", "connectionFailed": "Помилка з'єднання", @@ -853,7 +859,10 @@ "deleted": "Вузол видалено", "test": "Перевірити з'єднання", "fillRequired": "Назва, адреса, порт та токен API є обов'язковими", - "probeFailed": "Помилка перевірки" + "probeFailed": "Помилка перевірки", + "updateStarted": "Оновлення панелі розпочато", + "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося", + "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі" } }, "settings": { diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index 6d82801c..81dd1101 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -832,6 +832,12 @@ "panelVersion": "Phiên bản panel", "actions": "Hành động", "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", "connectionOk": "Kết nối OK ({ms} ms)", "connectionFailed": "Kết nối thất bại", @@ -853,7 +859,10 @@ "deleted": "Đã xóa nút", "test": "Kiểm tra kết nối", "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": { diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 427b0ec9..d11251d6 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -832,6 +832,12 @@ "panelVersion": "面板版本", "actions": "操作", "probe": "立即探测", + "updatePanel": "更新面板", + "updateSelected": "更新所选 ({count})", + "updateAvailable": "有可用更新", + "upToDate": "已是最新", + "updateConfirmTitle": "将 {count} 个节点更新到最新版本?", + "updateConfirmContent": "每个所选节点会下载最新版本并重启。仅更新已启用且在线的节点。", "testConnection": "测试连接", "connectionOk": "连接正常 ({ms} ms)", "connectionFailed": "连接失败", @@ -853,7 +859,10 @@ "deleted": "节点已删除", "test": "测试连接", "fillRequired": "名称、地址、端口和 API 令牌为必填项", - "probeFailed": "探测失败" + "probeFailed": "探测失败", + "updateStarted": "已开始更新面板", + "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败", + "updateNoneEligible": "请至少选择一个在线且已启用的节点" } }, "settings": { diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 62350109..c2a3b7be 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -832,6 +832,12 @@ "panelVersion": "面板版本", "actions": "操作", "probe": "立即探測", + "updatePanel": "更新面板", + "updateSelected": "更新所選 ({count})", + "updateAvailable": "有可用更新", + "upToDate": "已是最新", + "updateConfirmTitle": "將 {count} 個節點更新到最新版本?", + "updateConfirmContent": "每個所選節點會下載最新版本並重新啟動。僅更新已啟用且在線的節點。", "testConnection": "測試連線", "connectionOk": "連線正常 ({ms} ms)", "connectionFailed": "連線失敗", @@ -853,7 +859,10 @@ "deleted": "節點已刪除", "test": "測試連線", "fillRequired": "名稱、位址、埠與 API 權杖為必填", - "probeFailed": "探測失敗" + "probeFailed": "探測失敗", + "updateStarted": "已開始更新面板", + "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗", + "updateNoneEligible": "請至少選擇一個在線且已啟用的節點" } }, "settings": {