i18n(panel): migrate hardcoded panel strings to en-US and translate all locales

Surface ~400 hardcoded English labels, tooltips, placeholders, dt/divider
text, modal okText/cancelText, and Spin loading from the panel pages
(clients/groups/inbounds/nodes/settings/xray/sub/index) into
web/translation/en-US.json under existing pages.<page>.* namespaces, with
JSX swapped to t(...). Brand and protocol identifiers (TLS, MTU, SNI,
NordVPN, Cloudflare WARP, etc.) stay literal.

Sync all 12 non-English locales (ar-EG, es-ES, fa-IR, id-ID, ja-JP,
pt-BR, ru-RU, tr-TR, uk-UA, vi-VN, zh-CN, zh-TW) to match en-US's
structure and translate the 521 new key paths per locale. Every locale
file now has 1539 lines, mirroring en-US ordering.

Also remove a dead duplicate "info": "Info" key under pages.inbounds
that collided with the new pages.inbounds.info.* object.

Backend: bulk attach/detach errors in web/service/client.go now route
through logger.Warningf (so they appear under /panel/api/server/logs/)
instead of only living on the response payload.
This commit is contained in:
MHSanaei
2026-05-28 18:03:07 +02:00
parent 0829f1ecd4
commit 72b97efa8a
34 changed files with 6391 additions and 1125 deletions
+12 -12
View File
@@ -135,19 +135,19 @@ export default function BalancerFormModal({
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item
label="Tag"
label={t('pages.xray.balancer.tag')}
required
validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
help={issues.tag || (duplicateTag ? 'Tag already used by another balancer' : '')}
help={issues.tag || (duplicateTag ? t('pages.xray.balancer.tagDuplicate') : '')}
hasFeedback
>
<Input
value={state.tag}
onChange={(e) => update('tag', e.target.value)}
placeholder="unique balancer tag"
placeholder={t('pages.xray.balancer.tagPlaceholder')}
/>
</Form.Item>
<Form.Item label="Strategy">
<Form.Item label={t('pages.xray.balancer.balancerStrategy')}>
<Select
value={state.strategy}
onChange={(v) => update('strategy', v)}
@@ -155,7 +155,7 @@ export default function BalancerFormModal({
/>
</Form.Item>
<Form.Item
label="Selector"
label={t('pages.xray.balancer.selector')}
required
validateStatus={issues.selector ? 'error' : ''}
help={issues.selector || ''}
@@ -169,7 +169,7 @@ export default function BalancerFormModal({
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</Form.Item>
<Form.Item label="Fallback">
<Form.Item label={t('pages.xray.balancer.fallback')}>
<Select
value={state.fallbackTag}
onChange={(v) => update('fallbackTag', v ?? '')}
@@ -180,23 +180,23 @@ export default function BalancerFormModal({
{state.strategy === 'leastLoad' && (
<>
<Form.Item label="Expected">
<Form.Item label={t('pages.xray.balancer.expected')}>
<InputNumber
value={settings?.expected}
onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)}
min={0}
placeholder="optimal node count"
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Max RTT">
<Form.Item label={t('pages.xray.balancer.maxRtt')}>
<Input
value={settings?.maxRTT ?? ''}
onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)}
placeholder="e.g. 1s"
/>
</Form.Item>
<Form.Item label="Tolerance">
<Form.Item label={t('pages.xray.balancer.tolerance')}>
<InputNumber
value={settings?.tolerance}
onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
@@ -207,7 +207,7 @@ export default function BalancerFormModal({
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Baselines">
<Form.Item label={t('pages.xray.balancer.baselines')}>
<Button
size="small"
type="primary"
@@ -227,7 +227,7 @@ export default function BalancerFormModal({
</Space.Compact>
))}
</Form.Item>
<Form.Item label="Costs">
<Form.Item label={t('pages.xray.balancer.costs')}>
<Button
size="small"
type="primary"
+27 -25
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
@@ -58,6 +59,7 @@ export default function NordModal({
onRemoveOutbound,
onRemoveRoutingRules,
}: NordModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null);
@@ -185,7 +187,7 @@ export default function NordModal({
})
.sort((a: NordServer, b: NordServer) => a.load - b.load);
setServers(next);
if (next.length === 0) messageApi.warning('No servers found for the selected country');
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
} finally {
setLoading(false);
}
@@ -197,7 +199,7 @@ export default function NordModal({
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
if (!publicKey) {
messageApi.error('Selected server does not advertise a NordLynx public key.');
messageApi.error(t('pages.xray.nord.noPublicKey'));
return null;
}
return {
@@ -216,7 +218,7 @@ export default function NordModal({
const ob = buildNordOutbound();
if (!ob) return;
onAddOutbound(ob);
messageApi.success('NordVPN outbound added');
messageApi.success(t('pages.xray.nord.outboundAdded'));
onClose();
}
@@ -231,7 +233,7 @@ export default function NordModal({
oldTag,
newTag: ob.tag as string,
});
messageApi.success('NordVPN outbound updated');
messageApi.success(t('pages.xray.nord.outboundUpdated'));
onClose();
}
@@ -245,7 +247,7 @@ export default function NordModal({
items={[
{
key: 'token',
label: 'Access token',
label: t('pages.xray.nord.accessToken'),
children: (
<Form
colon={false}
@@ -253,14 +255,14 @@ export default function NordModal({
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form.Item label="Access token">
<Form.Item label={t('pages.xray.nord.accessToken')}>
<Input
value={token}
placeholder="Access token"
placeholder={t('pages.xray.nord.accessToken')}
onChange={(e) => setToken(e.target.value)}
/>
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
Login
{t('login')}
</Button>
</Form.Item>
</Form>
@@ -268,7 +270,7 @@ export default function NordModal({
},
{
key: 'key',
label: 'Private key',
label: t('pages.xray.nord.privateKey'),
children: (
<Form
colon={false}
@@ -276,14 +278,14 @@ export default function NordModal({
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<Form.Item label="Private key">
<Form.Item label={t('pages.xray.nord.privateKey')}>
<Input
value={manualKey}
placeholder="Private key"
placeholder={t('pages.xray.nord.privateKey')}
onChange={(e) => setManualKey(e.target.value)}
/>
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
Save
{t('save')}
</Button>
</Form.Item>
</Form>
@@ -297,25 +299,25 @@ export default function NordModal({
<tbody>
{nordData.token && (
<tr className="row-odd">
<td>Access token</td>
<td>{t('pages.xray.nord.accessToken')}</td>
<td>{nordData.token}</td>
</tr>
)}
<tr>
<td>Private key</td>
<td>{t('pages.xray.nord.privateKey')}</td>
<td>{nordData.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
Logout
{t('logout')}
</Button>
<Divider className="zero-margin">Settings</Divider>
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
<Form.Item label="Country">
<Form.Item label={t('pages.xray.outbound.country')}>
<Select
value={countryId ?? undefined}
showSearch={{ optionFilterProp: 'label' }}
@@ -328,18 +330,18 @@ export default function NordModal({
</Form.Item>
{cities.length > 0 && (
<Form.Item label="City">
<Form.Item label={t('pages.xray.outbound.city')}>
<Select
value={cityId}
showSearch={{ optionFilterProp: 'label' }}
onChange={setCityId}
options={[{ value: null, label: 'All cities' }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
/>
</Form.Item>
)}
{filteredServers.length > 0 && (
<Form.Item label="Server">
<Form.Item label={t('pages.xray.outbound.server')}>
<Select
value={serverId}
showSearch={{ optionFilterProp: 'label' }}
@@ -363,17 +365,17 @@ export default function NordModal({
)}
</Form>
<Divider className="my-10">Outbound status</Divider>
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
{nordOutboundIndex >= 0 ? (
<>
<Tag color="green">Enabled</Tag>
<Tag color="green">{t('enabled')}</Tag>
<Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
Reset
{t('reset')}
</Button>
</>
) : (
<>
<Tag color="orange">Disabled</Tag>
<Tag color="orange">{t('disabled')}</Tag>
<Button
type="primary"
className="ml-8"
@@ -381,7 +383,7 @@ export default function NordModal({
loading={loading}
onClick={addOutbound}
>
Add outbound
{t('pages.xray.warp.addOutbound')}
</Button>
</>
)}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -258,7 +258,7 @@ export default function OutboundsTab({
),
},
{
title: 'Tag',
title: t('pages.xray.outbound.tag'),
key: 'identity',
align: 'left',
render: (_v, record) => (
@@ -316,7 +316,7 @@ export default function OutboundsTab({
},
},
{
title: 'Latency',
title: t('pages.nodes.latency'),
key: 'testResult',
align: 'left',
width: 140,
@@ -398,14 +398,14 @@ export default function OutboundsTab({
</Col>
<Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap>
<Tooltip title="TCP: fast dial-only probe. HTTP: full request through xray.">
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
<Radio.Button value="tcp">TCP</Radio.Button>
<Radio.Button value="http">HTTP</Radio.Button>
</Radio.Group>
</Tooltip>
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
{!isMobile && 'Test all'}
{!isMobile && t('pages.xray.outbound.testAll')}
</Button>
<Popconfirm
placement="topRight"
+3 -3
View File
@@ -293,7 +293,7 @@ export default function RoutingTab({
<div className="action-cell">
<HolderOutlined
className="drag-handle"
title="Drag to reorder"
title={t('pages.xray.routing.dragToReorder')}
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
@@ -326,7 +326,7 @@ export default function RoutingTab({
),
},
{
title: 'Source',
title: t('pages.xray.rules.source'),
align: 'left',
width: 180,
key: 'source',
@@ -354,7 +354,7 @@ export default function RoutingTab({
),
},
{
title: 'Destination',
title: t('pages.xray.rules.dest'),
align: 'left',
key: 'destination',
render: (_v, record) => (
+22 -22
View File
@@ -148,8 +148,8 @@ export default function RuleFormModal({
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item
label={
<Tooltip title="Comma-separated list">
Source IPs <QuestionCircleOutlined />
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined />
</Tooltip>
}
>
@@ -158,8 +158,8 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title="Comma-separated list">
Source port <QuestionCircleOutlined />
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined />
</Tooltip>
}
>
@@ -168,15 +168,15 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title="Comma-separated list">
VLESS route <QuestionCircleOutlined />
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<Form.Item label="Network">
<Form.Item label={t('pages.inbounds.network')}>
<Select
value={form.network}
onChange={(v) => update('network', v)}
@@ -184,7 +184,7 @@ export default function RuleFormModal({
/>
</Form.Item>
<Form.Item label="Protocol">
<Form.Item label={t('pages.inbounds.protocol')}>
<Select
mode="multiple"
value={form.protocol}
@@ -193,7 +193,7 @@ export default function RuleFormModal({
/>
</Form.Item>
<Form.Item label="Attributes">
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
<Button size="small" icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
@@ -202,7 +202,7 @@ export default function RuleFormModal({
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={attr[0]}
placeholder="Name"
placeholder={t('pages.nodes.name')}
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
update('attrs', next);
@@ -210,7 +210,7 @@ export default function RuleFormModal({
/>
<Input
value={attr[1]}
placeholder="Value"
placeholder={t('pages.xray.ruleForm.value')}
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
update('attrs', next);
@@ -226,7 +226,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title="Comma-separated list">
<Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined />
</Tooltip>
}
@@ -236,8 +236,8 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title="Comma-separated list">
Domain <QuestionCircleOutlined />
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined />
</Tooltip>
}
>
@@ -246,8 +246,8 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title="Comma-separated list">
User <QuestionCircleOutlined />
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined />
</Tooltip>
}
>
@@ -256,15 +256,15 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title="Comma-separated list">
Port <QuestionCircleOutlined />
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined />
</Tooltip>
}
>
<Input value={form.port} onChange={(e) => update('port', e.target.value)} placeholder="53,443,1000-2000" />
</Form.Item>
<Form.Item label="Inbound tags">
<Form.Item label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
value={form.inboundTag}
@@ -273,7 +273,7 @@ export default function RuleFormModal({
/>
</Form.Item>
<Form.Item label="Outbound tag">
<Form.Item label={t('pages.xray.ruleForm.outboundTag')}>
<Select
value={form.outboundTag}
onChange={(v) => update('outboundTag', v)}
@@ -283,8 +283,8 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title="Routes traffic through one of the configured load balancers">
Balancer tag <QuestionCircleOutlined />
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined />
</Tooltip>
}
>
+30 -28
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
@@ -72,6 +73,7 @@ export default function WarpModal({
onResetOutbound,
onRemoveOutbound,
}: WarpModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState<WarpData | null>(null);
@@ -167,7 +169,7 @@ export default function WarpModal({
setWarpConfig(null);
setWarpPlus('');
} else {
setLicenseError(msg?.msg || 'Failed to set WARP license.');
setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
}
} finally {
setLoading(false);
@@ -192,7 +194,7 @@ export default function WarpModal({
function addOutbound() {
if (!stagedOutbound) {
messageApi.warning('Fetch the WARP config first.');
messageApi.warning(t('pages.xray.warp.fetchFirst'));
return;
}
onAddOutbound(stagedOutbound);
@@ -213,49 +215,49 @@ export default function WarpModal({
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
{!hasWarp ? (
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
Create WARP account
{t('pages.xray.warp.createAccount')}
</Button>
) : (
<>
<table className="warp-data-table">
<tbody>
<tr className="row-odd">
<td>Access token</td>
<td>{t('pages.xray.warp.accessToken')}</td>
<td>{warpData?.access_token}</td>
</tr>
<tr>
<td>Device ID</td>
<td>{t('pages.xray.warp.deviceId')}</td>
<td>{warpData?.device_id}</td>
</tr>
<tr className="row-odd">
<td>License key</td>
<td>{t('pages.xray.warp.licenseKey')}</td>
<td>{warpData?.license_key}</td>
</tr>
<tr>
<td>Private key</td>
<td>{t('pages.xray.warp.privateKey')}</td>
<td>{warpData?.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} type="primary" danger className="mt-8" icon={<DeleteOutlined />} onClick={delConfig}>
Delete account
{t('pages.xray.warp.deleteAccount')}
</Button>
<Divider className="zero-margin">Settings</Divider>
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Collapse
className="my-10"
items={[
{
key: '1',
label: 'WARP / WARP+ license key',
label: t('pages.xray.warp.licenseKeyLabel'),
children: (
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label="Key">
<Form.Item label={t('pages.xray.warp.key')}>
<Input
value={warpPlus}
placeholder="26-char WARP+ key"
placeholder={t('pages.xray.warp.keyPlaceholder')}
onChange={(e) => {
setWarpPlus(e.target.value);
setLicenseError('');
@@ -268,7 +270,7 @@ export default function WarpModal({
loading={loading}
onClick={updateLicense}
>
Update
{t('update')}
</Button>
{licenseError && (
<Alert title={licenseError} type="error" showIcon className="license-error" />
@@ -281,9 +283,9 @@ export default function WarpModal({
]}
/>
<Divider className="zero-margin">Account info</Divider>
<Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
<Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
Refresh
{t('refresh')}
</Button>
{hasConfig && (
@@ -291,38 +293,38 @@ export default function WarpModal({
<table className="warp-data-table">
<tbody>
<tr className="row-odd">
<td>Device name</td>
<td>{t('pages.xray.warp.deviceName')}</td>
<td>{warpConfig?.name}</td>
</tr>
<tr>
<td>Device model</td>
<td>{t('pages.xray.warp.deviceModel')}</td>
<td>{warpConfig?.model}</td>
</tr>
<tr className="row-odd">
<td>Device enabled</td>
<td>{t('pages.xray.warp.deviceEnabled')}</td>
<td>{String(warpConfig?.enabled)}</td>
</tr>
{warpConfig?.account && (
<>
<tr>
<td>Account type</td>
<td>{t('pages.xray.warp.accountType')}</td>
<td>{warpConfig.account.account_type}</td>
</tr>
<tr className="row-odd">
<td>Role</td>
<td>{t('pages.xray.warp.role')}</td>
<td>{warpConfig.account.role}</td>
</tr>
<tr>
<td>WARP+ data</td>
<td>{t('pages.xray.warp.warpPlusData')}</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
</tr>
<tr className="row-odd">
<td>Quota</td>
<td>{t('pages.xray.warp.quota')}</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
</tr>
{warpConfig.account.usage != null && (
<tr>
<td>Usage</td>
<td>{t('pages.xray.warp.usage')}</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
</tr>
)}
@@ -331,19 +333,19 @@ export default function WarpModal({
</tbody>
</table>
<Divider className="my-10">Outbound status</Divider>
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
{warpOutboundIndex >= 0 ? (
<>
<Tag color="green">Enabled</Tag>
<Tag color="green">{t('enabled')}</Tag>
<Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
Reset
{t('reset')}
</Button>
</>
) : (
<>
<Tag color="orange">Disabled</Tag>
<Tag color="orange">{t('disabled')}</Tag>
<Button type="primary" loading={loading} className="ml-8" icon={<PlusOutlined />} onClick={addOutbound}>
Add outbound
{t('pages.xray.warp.addOutbound')}
</Button>
</>
)}
+6 -6
View File
@@ -223,10 +223,10 @@ export default function XrayPage() {
function confirmRestart() {
modal.confirm({
title: 'Restart xray?',
content: 'Reloads the xray service with the saved configuration.',
okText: 'Restart',
cancelText: 'Cancel',
title: t('pages.xray.restartConfirmTitle'),
content: t('pages.xray.restartConfirmContent'),
okText: t('pages.xray.restart'),
cancelText: t('cancel'),
onOk: () => restartXray(),
});
}
@@ -255,7 +255,7 @@ export default function XrayPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={spinning || !fetched} delay={200} description="Loading…" size="large">
<Spin spinning={spinning || !fetched} delay={200} description={t('loading')} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
@@ -281,7 +281,7 @@ export default function XrayPage() {
{restartResult && (
<Popover
placement="rightTop"
title="Xray restart output"
title={t('pages.xray.restartOutputTitle')}
content={<pre className="restart-result">{restartResult}</pre>}
>
<QuestionCircleOutlined className="restart-icon" />