Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
739f35f804 | ||
|
|
d19d7f39f3 | ||
|
|
f6f93cf973 | ||
|
|
495115f869 | ||
|
|
2212604636 | ||
|
|
20db43210e | ||
|
|
54dc346697 |
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Alert, Button, Col, Form, Input, Modal, Row, Switch, message } from 'antd';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
@@ -8,6 +7,7 @@ import { HttpUtil, type Msg } from '@/utils';
|
||||
import { NodeFormSchema, type NodeFormValues } from '@/schemas/node';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||
import ProbeMetaSection from './ProbeMetaSection';
|
||||
|
||||
import './NodeFormModal.css';
|
||||
|
||||
@@ -44,7 +44,6 @@ export default function NodeFormModal({
|
||||
onOpenChange,
|
||||
}: NodeFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
@@ -283,15 +282,15 @@ export default function NodeFormModal({
|
||||
)}
|
||||
|
||||
{mode === 'edit' && node?.id ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.nodes.probe.title')}
|
||||
</div>
|
||||
<p style={{ marginBottom: 8 }}>{t('pages.nodes.probe.movedHint')}</p>
|
||||
<Button onClick={() => navigate('/settings#probe')}>
|
||||
{t('pages.nodes.probe.goSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
// Per-node probe metadata, edited in place: the section
|
||||
// loads and saves on its own (dedicated endpoints,
|
||||
// effective immediately on save — no node save, no
|
||||
// restart), so it sits outside the node form. It used
|
||||
// to live on the probe settings page behind a node
|
||||
// picker; it now lives here, where the node is already
|
||||
// the subject. The component renders its own
|
||||
// public-status-page title and hint.
|
||||
<ProbeMetaSection nodeId={node.id} />
|
||||
) : null}
|
||||
</Form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
// Notify settings block, moved unchanged from the public-page tab
|
||||
// (PublicPageTab, theme-store section) into the probe settings page
|
||||
// as its own sub-page. It stays self-contained: it loads and saves
|
||||
// through its own endpoints and every change takes effect immediately
|
||||
// (no panel save, no restart). Telegram reuses the panel bot settings
|
||||
// (token + chat ID configured on the TG bot tab); this block only
|
||||
// reads whether that channel is configured, it does not own it.
|
||||
|
||||
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
|
||||
// The webhook URL and headers are secrets: the GET answers only
|
||||
// whether they are set, and the PUT omits them unless the admin
|
||||
// edited the field (an explicit clear sends "").
|
||||
interface NotifySettingsWire {
|
||||
notifyGrace: number;
|
||||
notifyTraffic: number;
|
||||
notifyExpiry: number;
|
||||
notifyLogin: boolean;
|
||||
notifyTelegramText: string;
|
||||
notifyWebhookBody: string;
|
||||
telegramConfigured: boolean;
|
||||
webhookConfigured: boolean;
|
||||
notifyWebhookUrlSet: boolean;
|
||||
notifyWebhookHeadersSet: boolean;
|
||||
}
|
||||
|
||||
interface NotifyPayload {
|
||||
settings: NotifySettingsWire;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
export default function ProbeNotifyTab() {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
const [webhookHeaders, setWebhookHeaders] = useState('');
|
||||
const [urlTouched, setUrlTouched] = useState(false);
|
||||
const [headersTouched, setHeadersTouched] = useState(false);
|
||||
const [notifySaving, setNotifySaving] = useState(false);
|
||||
const [notifyTesting, setNotifyTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setNotify(msg.obj.settings);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function saveNotify() {
|
||||
if (!notify) return;
|
||||
setNotifySaving(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
notifyGrace: notify.notifyGrace,
|
||||
notifyTraffic: notify.notifyTraffic,
|
||||
notifyExpiry: notify.notifyExpiry,
|
||||
notifyLogin: notify.notifyLogin,
|
||||
notifyTelegramText: notify.notifyTelegramText,
|
||||
notifyWebhookBody: notify.notifyWebhookBody,
|
||||
};
|
||||
// Secrets ride along only when the field was edited; an
|
||||
// edited-but-empty field clears the stored value server-side.
|
||||
if (urlTouched) body.notifyWebhookUrl = webhookUrl;
|
||||
if (headersTouched) body.notifyWebhookHeaders = webhookHeaders;
|
||||
const msg = await HttpUtil.put<NotifyPayload>('/panel/api/probe/notify', body, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setNotify(msg.obj.settings);
|
||||
setWebhookUrl('');
|
||||
setWebhookHeaders('');
|
||||
setUrlTouched(false);
|
||||
setHeadersTouched(false);
|
||||
messageApi.success(t('pages.settings.probeThemes.saved'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||
}
|
||||
} finally {
|
||||
setNotifySaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testNotify() {
|
||||
setNotifyTesting(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ sent: string[] }>(
|
||||
'/panel/api/probe/notify/test',
|
||||
undefined,
|
||||
{
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
if (msg?.success) {
|
||||
messageApi.success(
|
||||
t('pages.settings.probeNotify.testSent', { channels: (msg.obj?.sent ?? []).join('、') }),
|
||||
);
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||
}
|
||||
} finally {
|
||||
setNotifyTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messageContextHolder}
|
||||
<Typography.Title level={5}>{t('pages.settings.probeNotify.notifyTitle')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.settings.probeNotify.notifyHint')}
|
||||
</Typography.Paragraph>
|
||||
{notify ? (
|
||||
<>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.grace')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={30}
|
||||
value={notify.notifyGrace}
|
||||
onChange={onNumber((v) => setNotify({ ...notify, notifyGrace: v }))}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.graceHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.trafficPct')}</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={notify.notifyTraffic}
|
||||
onChange={onNumber((v) => setNotify({ ...notify, notifyTraffic: v }))}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.trafficHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.expiryDays')}</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={365}
|
||||
value={notify.notifyExpiry}
|
||||
onChange={onNumber((v) => setNotify({ ...notify, notifyExpiry: v }))}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.expiryHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.loginAlert')}</div>
|
||||
<Switch
|
||||
checked={notify.notifyLogin}
|
||||
onChange={(v) => setNotify({ ...notify, notifyLogin: v })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeNotify.telegramChannel')}
|
||||
</div>
|
||||
{notify.telegramConfigured ? (
|
||||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||||
)}
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.telegramHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeNotify.webhookChannel')}
|
||||
</div>
|
||||
{notify.webhookConfigured ? (
|
||||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.telegramText')}</div>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={notify.notifyTelegramText}
|
||||
placeholder={'{{title}}\n{{message}}'}
|
||||
onChange={(e) => setNotify({ ...notify, notifyTelegramText: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookUrl')}</div>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
placeholder={
|
||||
notify.notifyWebhookUrlSet
|
||||
? t('pages.settings.probeNotify.secretSet')
|
||||
: 'https://'
|
||||
}
|
||||
onChange={(e) => {
|
||||
setWebhookUrl(e.target.value);
|
||||
setUrlTouched(true);
|
||||
}}
|
||||
/>
|
||||
{notify.notifyWebhookUrlSet && !urlTouched ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setWebhookUrl('');
|
||||
setUrlTouched(true);
|
||||
}}
|
||||
>
|
||||
{t('pages.settings.probeNotify.clearSecret')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeNotify.webhookHeaders')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={webhookHeaders}
|
||||
placeholder={
|
||||
notify.notifyWebhookHeadersSet
|
||||
? t('pages.settings.probeNotify.secretSet')
|
||||
: 'Authorization: Bearer …'
|
||||
}
|
||||
onChange={(e) => {
|
||||
setWebhookHeaders(e.target.value);
|
||||
setHeadersTouched(true);
|
||||
}}
|
||||
/>
|
||||
{notify.notifyWebhookHeadersSet && !headersTouched ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setWebhookHeaders('');
|
||||
setHeadersTouched(true);
|
||||
}}
|
||||
>
|
||||
{t('pages.settings.probeNotify.clearSecret')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookBody')}</div>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={notify.notifyWebhookBody}
|
||||
onChange={(e) => setNotify({ ...notify, notifyWebhookBody: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.templateHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button type="primary" loading={notifySaving} onClick={saveNotify}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
<Button loading={notifyTesting} onClick={testNotify}>
|
||||
{t('pages.settings.probeNotify.testSend')}
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Col, Input, InputNumber, Row, Select, Space, Switch, Typography, message } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Space,
|
||||
Switch,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { BellOutlined, GlobalOutlined, RadarChartOutlined } from '@ant-design/icons';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import ProbeMetaSection from '../nodes/ProbeMetaSection';
|
||||
import ProbeTasksPanel from './ProbeTasksPanel';
|
||||
import ProbeNotifyTab from './ProbeNotifyTab';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
|
||||
// V2.0.1 probe batch: the dedicated probe settings page. Everything
|
||||
// probe-related that used to be scattered — the public-page block
|
||||
@@ -12,7 +26,15 @@ import ProbeTasksPanel from './ProbeTasksPanel';
|
||||
// and the per-node probe metadata (node edit dialog) — is gathered
|
||||
// here and lives nowhere else. Like the surfaces it replaces, each
|
||||
// block loads and saves through its own endpoints and takes effect
|
||||
// immediately (no panel save, no restart).
|
||||
// immediately (no panel save, no restart). The page is split into
|
||||
// second-level sub-pages, following the same Tabs pattern the other
|
||||
// settings tabs use for in-section grouping: status-page settings
|
||||
// (the former public-status-page form), latency probing (the task
|
||||
// manager) and notifications (moved from the public-page tab, where
|
||||
// it sat under the theme store). The per-node probe metadata block
|
||||
// no longer lives here at all: it moved into the node edit dialog's
|
||||
// public-status-page section (see ProbeMetaSection), where it is
|
||||
// edited in place per node.
|
||||
interface ProbeSettingsWire {
|
||||
publicPage: boolean;
|
||||
siteName: string;
|
||||
@@ -66,14 +88,13 @@ async function fileToIconDataURL(file: File): Promise<string> {
|
||||
|
||||
export default function ProbeSettingsTab() {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [settings, setSettings] = useState<ProbeSettingsWire | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [metaNodeId, setMetaNodeId] = useState<number | undefined>(undefined);
|
||||
const faviconRef = useRef<HTMLInputElement>(null);
|
||||
const touchRef = useRef<HTMLInputElement>(null);
|
||||
const { nodes } = useNodesQuery();
|
||||
const agentNodes = nodes.filter((n) => n.nodeType === 'agent');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -116,149 +137,190 @@ export default function ProbeSettingsTab() {
|
||||
return (
|
||||
<div>
|
||||
{messageContextHolder}
|
||||
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
|
||||
{settings ? (
|
||||
<>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
|
||||
<Switch
|
||||
checked={settings.publicPage}
|
||||
onChange={(v) => set('publicPage', v)}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{t('pages.settings.probeThemes.publicPageHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
|
||||
<Input
|
||||
value={settings.siteName}
|
||||
onChange={(e) => set('siteName', e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
|
||||
<Input
|
||||
value={settings.site}
|
||||
placeholder="https://"
|
||||
onChange={(e) => set('site', e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
|
||||
<Input
|
||||
value={settings.githubProxy}
|
||||
placeholder="https://"
|
||||
onChange={(e) => set('githubProxy', e.target.value)}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.githubProxyHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.retentionDays')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.retentionDays}
|
||||
onChange={(v) => set('retentionDays', v ?? 7)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probe.retentionHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.fastInterval')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={60}
|
||||
value={settings.fastIntervalSec}
|
||||
onChange={(v) => set('fastIntervalSec', v ?? 3)}
|
||||
addonAfter="s"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probe.fastIntervalHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
|
||||
<Space>
|
||||
{settings.favicon ? (
|
||||
<img src={settings.favicon} alt="" style={{ width: 24, height: 24 }} />
|
||||
<Tabs
|
||||
defaultActiveKey="status"
|
||||
items={[
|
||||
{
|
||||
key: 'status',
|
||||
label: catTabLabel(
|
||||
<GlobalOutlined />,
|
||||
t('pages.settings.probe.statusPageTitle'),
|
||||
isMobile,
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<Typography.Title level={5}>
|
||||
{t('pages.settings.probe.statusPageTitle')}
|
||||
</Typography.Title>
|
||||
{settings ? (
|
||||
<>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeThemes.publicPage')}
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.publicPage}
|
||||
onChange={(v) => set('publicPage', v)}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{t('pages.settings.probeThemes.publicPageHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeThemes.siteName')}
|
||||
</div>
|
||||
<Input
|
||||
value={settings.siteName}
|
||||
onChange={(e) => set('siteName', e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeThemes.site')}
|
||||
</div>
|
||||
<Input
|
||||
value={settings.site}
|
||||
placeholder="https://"
|
||||
onChange={(e) => set('site', e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeThemes.githubProxy')}
|
||||
</div>
|
||||
<Input
|
||||
value={settings.githubProxy}
|
||||
placeholder="https://"
|
||||
onChange={(e) => set('githubProxy', e.target.value)}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.githubProxyHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probe.retentionDays')}
|
||||
</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.retentionDays}
|
||||
onChange={(v) => set('retentionDays', v ?? 7)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probe.retentionHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probe.fastInterval')}
|
||||
</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={60}
|
||||
value={settings.fastIntervalSec}
|
||||
onChange={(v) => set('fastIntervalSec', v ?? 3)}
|
||||
addonAfter="s"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probe.fastIntervalHint')}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeThemes.favicon')}
|
||||
</div>
|
||||
<Space>
|
||||
{settings.favicon ? (
|
||||
<img src={settings.favicon} alt="" style={{ width: 24, height: 24 }} />
|
||||
) : null}
|
||||
<Button size="small" onClick={() => faviconRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{settings.favicon ? (
|
||||
<Button size="small" onClick={() => set('favicon', '')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={faviconRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeThemes.touchIcon')}
|
||||
</div>
|
||||
<Space>
|
||||
{settings.touchIcon ? (
|
||||
<img
|
||||
src={settings.touchIcon}
|
||||
alt=""
|
||||
style={{ width: 24, height: 24 }}
|
||||
/>
|
||||
) : null}
|
||||
<Button size="small" onClick={() => touchRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{settings.touchIcon ? (
|
||||
<Button size="small" onClick={() => set('touchIcon', '')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={touchRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.iconHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button type="primary" loading={saving} onClick={saveSettings}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<Button size="small" onClick={() => faviconRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{settings.favicon ? (
|
||||
<Button size="small" onClick={() => set('favicon', '')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={faviconRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
|
||||
<Space>
|
||||
{settings.touchIcon ? (
|
||||
<img src={settings.touchIcon} alt="" style={{ width: 24, height: 24 }} />
|
||||
) : null}
|
||||
<Button size="small" onClick={() => touchRef.current?.click()}>
|
||||
{t('pages.settings.probeThemes.chooseImage')}
|
||||
</Button>
|
||||
{settings.touchIcon ? (
|
||||
<Button size="small" onClick={() => set('touchIcon', '')}>
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
<input
|
||||
ref={touchRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeThemes.iconHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button type="primary" loading={saving} onClick={saveSettings}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
{t('pages.nodes.probeTasks.title')}
|
||||
</Typography.Title>
|
||||
<ProbeTasksPanel nodes={nodes} />
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
{t('pages.settings.probe.metaTitle')}
|
||||
</Typography.Title>
|
||||
<Select
|
||||
placeholder={t('pages.settings.probe.metaNodePlaceholder')}
|
||||
value={metaNodeId}
|
||||
onChange={setMetaNodeId}
|
||||
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||
style={{ minWidth: 260, marginBottom: 16 }}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'latency',
|
||||
label: catTabLabel(<RadarChartOutlined />, t('pages.nodes.probeTasks.title'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<Typography.Title level={5}>{t('pages.nodes.probeTasks.title')}</Typography.Title>
|
||||
<ProbeTasksPanel nodes={nodes} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'notify',
|
||||
label: catTabLabel(
|
||||
<BellOutlined />,
|
||||
t('pages.settings.probeNotify.notifyTitle'),
|
||||
isMobile,
|
||||
),
|
||||
children: <ProbeNotifyTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{metaNodeId ? <ProbeMetaSection key={metaNodeId} nodeId={metaNodeId} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
@@ -51,36 +51,26 @@ interface TaskDraft {
|
||||
const TASKS_URL = '/panel/api/agent/probe-tasks';
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
function parseTargets(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of text.split(/[\n,,]+/)) {
|
||||
const v = raw.trim();
|
||||
if (v && !seen.has(v)) {
|
||||
seen.add(v);
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [tasks, setTasks] = useState<ProbeTaskWire[] | null>(null);
|
||||
const [draft, setDraft] = useState<TaskDraft | null>(null);
|
||||
// The add row is always visible (no expand button): a blank draft
|
||||
// is the resting state, and editing a row loads that task into the
|
||||
// same row (cancel / a successful save resets it to blank).
|
||||
const blankDraft = (): TaskDraft => ({
|
||||
id: 0,
|
||||
name: '',
|
||||
target: '',
|
||||
interval: 60,
|
||||
autoJoin: false,
|
||||
region: '',
|
||||
nodeIds: [],
|
||||
baseNodeIds: null,
|
||||
});
|
||||
const [draft, setDraft] = useState<TaskDraft>(blankDraft);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Batch-add state (V2.0.1 ⑤): many targets pasted at once sharing
|
||||
// every other field.
|
||||
const [batchText, setBatchText] = useState('');
|
||||
const [batchName, setBatchName] = useState('');
|
||||
const [batchInterval, setBatchInterval] = useState(60);
|
||||
const [batchAutoJoin, setBatchAutoJoin] = useState(false);
|
||||
const [batchRegion, setBatchRegion] = useState('');
|
||||
const [batchNodeIds, setBatchNodeIds] = useState<number[]>([]);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
|
||||
const agentNodes = useMemo(() => nodes.filter((n) => n.nodeType === 'agent'), [nodes]);
|
||||
const nodeName = useCallback(
|
||||
(id: number) => nodes.find((n) => n.id === id)?.name ?? `#${id}`,
|
||||
@@ -120,18 +110,6 @@ export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
.map(([region, list]) => ({ region, list }));
|
||||
}, [tasks]);
|
||||
|
||||
const startCreate = () =>
|
||||
setDraft({
|
||||
id: 0,
|
||||
name: '',
|
||||
target: '',
|
||||
interval: 60,
|
||||
autoJoin: false,
|
||||
region: '',
|
||||
nodeIds: [],
|
||||
baseNodeIds: null,
|
||||
});
|
||||
|
||||
const startEdit = (task: ProbeTaskWire) =>
|
||||
setDraft({
|
||||
id: task.id,
|
||||
@@ -145,7 +123,6 @@ export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
});
|
||||
|
||||
async function onSave() {
|
||||
if (!draft) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -164,7 +141,7 @@ export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
});
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.nodes.probeTasks.saved'));
|
||||
setDraft(null);
|
||||
setDraft(blankDraft());
|
||||
await load();
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
|
||||
@@ -174,45 +151,11 @@ export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onBatchAdd() {
|
||||
const targets = parseTargets(batchText);
|
||||
if (targets.length === 0) {
|
||||
messageApi.error(t('pages.nodes.probeTasks.batchEmpty'));
|
||||
return;
|
||||
}
|
||||
setBatchSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ ids: number[] }>(
|
||||
`${TASKS_URL}/batch`,
|
||||
{
|
||||
targets,
|
||||
name: batchName,
|
||||
interval: batchInterval,
|
||||
autoJoin: batchAutoJoin,
|
||||
region: batchRegion,
|
||||
nodeIds: batchNodeIds,
|
||||
},
|
||||
{ silent: true, ...JSON_HEADERS },
|
||||
);
|
||||
if (msg?.success) {
|
||||
messageApi.success(
|
||||
t('pages.nodes.probeTasks.batchDone', { count: msg.obj?.ids.length ?? targets.length }),
|
||||
);
|
||||
setBatchText('');
|
||||
await load();
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
|
||||
}
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(task: ProbeTaskWire) {
|
||||
const msg = await HttpUtil.delete(`${TASKS_URL}/${task.id}`, { silent: true });
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.nodes.probeTasks.deleted'));
|
||||
if (draft?.id === task.id) setDraft(null);
|
||||
if (draft.id === task.id) setDraft(blankDraft());
|
||||
await load();
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
|
||||
@@ -280,69 +223,9 @@ export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
{t('pages.nodes.probeTasks.intro')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>{t('pages.nodes.probeTasks.batchTitle')}</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={batchText}
|
||||
placeholder={t('pages.nodes.probeTasks.batchPlaceholder')}
|
||||
onChange={(e) => setBatchText(e.target.value)}
|
||||
style={{ marginTop: 8, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.name')}
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.region')}
|
||||
value={batchRegion}
|
||||
onChange={(e) => setBatchRegion(e.target.value)}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={5}
|
||||
max={3600}
|
||||
value={batchInterval}
|
||||
onChange={(v) => setBatchInterval(v ?? 60)}
|
||||
addonAfter="s"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span>
|
||||
{t('pages.nodes.probeTasks.autoJoin')}{' '}
|
||||
<Switch checked={batchAutoJoin} onChange={setBatchAutoJoin} />
|
||||
</span>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
|
||||
value={batchNodeIds}
|
||||
onChange={setBatchNodeIds}
|
||||
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||
style={{ minWidth: 260 }}
|
||||
/>
|
||||
<Button type="primary" loading={batchSaving} onClick={() => void onBatchAdd()}>
|
||||
{t('pages.nodes.probeTasks.batchSubmit')}
|
||||
</Button>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.nodes.probeTasks.batchHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks === null ? (
|
||||
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.loading')}</Typography.Text>
|
||||
) : tasks.length === 0 && !draft ? (
|
||||
) : tasks.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.empty')}</Typography.Text>
|
||||
) : (
|
||||
groups.map(({ region, list }) => (
|
||||
@@ -362,70 +245,79 @@ export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
))
|
||||
)}
|
||||
|
||||
{draft ? (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
paddingTop: 12,
|
||||
marginTop: 4,
|
||||
}}
|
||||
{/* Always-visible single-line add row (no expand button): the
|
||||
name field the redesign did not mention is kept, first, so
|
||||
the specified order of the other elements is unchanged.
|
||||
The region field is gone from the row; the value itself
|
||||
still rides along in the payload, so editing an existing
|
||||
task keeps its region (new tasks get ''). Widths are flex
|
||||
ratios: target 6 : interval 1, matching the specified
|
||||
1.5x target (230 -> ~345) against 0.5x interval (120 -> 60)
|
||||
relationship (345:60 ~ 5.75:1, rounded to 6:1). */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
paddingTop: 12,
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.name')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraftField('name', e.target.value)}
|
||||
style={{ flex: '2 1 0', minWidth: 0 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.targetPlaceholder')}
|
||||
value={draft.target}
|
||||
onChange={(e) => setDraftField('target', e.target.value)}
|
||||
style={{ flex: '6 1 0', minWidth: 0, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={5}
|
||||
max={3600}
|
||||
value={draft.interval}
|
||||
onChange={(v) => setDraftField('interval', v ?? 60)}
|
||||
addonAfter="s"
|
||||
style={{ flex: '1 1 0', minWidth: 0 }}
|
||||
/>
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{t('pages.nodes.probeTasks.autoJoin')}{' '}
|
||||
<Switch checked={draft.autoJoin} onChange={(v) => setDraftField('autoJoin', v)} />
|
||||
</span>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
|
||||
value={draft.nodeIds}
|
||||
onChange={(v) => setDraftField('nodeIds', v)}
|
||||
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||
style={{ flex: '2 1 0', minWidth: 120 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={agentNodes.length === 0}
|
||||
onClick={() =>
|
||||
setDraftField(
|
||||
'nodeIds',
|
||||
agentNodes.map((n) => n.id),
|
||||
)
|
||||
}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.name')}
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraftField('name', e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.region')}
|
||||
value={draft.region}
|
||||
onChange={(e) => setDraftField('region', e.target.value)}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('pages.nodes.probeTasks.targetPlaceholder')}
|
||||
value={draft.target}
|
||||
onChange={(e) => setDraftField('target', e.target.value)}
|
||||
style={{ width: 230, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={5}
|
||||
max={3600}
|
||||
value={draft.interval}
|
||||
onChange={(v) => setDraftField('interval', v ?? 60)}
|
||||
addonAfter="s"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<span>
|
||||
{t('pages.nodes.probeTasks.autoJoin')}{' '}
|
||||
<Switch checked={draft.autoJoin} onChange={(v) => setDraftField('autoJoin', v)} />
|
||||
</span>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
|
||||
value={draft.nodeIds}
|
||||
onChange={(v) => setDraftField('nodeIds', v)}
|
||||
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||
style={{ minWidth: 260 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={() => void onSave()}>
|
||||
{t('pages.nodes.probeTasks.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setDraft(null)}>{t('pages.nodes.probeTasks.cancel')}</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Button icon={<PlusOutlined />} onClick={startCreate} style={{ marginTop: 8 }}>
|
||||
{t('pages.nodes.probeTasks.add')}
|
||||
{t('pages.nodes.probeTasks.selectAllNodes')}
|
||||
</Button>
|
||||
)}
|
||||
<Space style={{ flexShrink: 0 }}>
|
||||
<Button type="primary" loading={saving} onClick={() => void onSave()}>
|
||||
{t('pages.nodes.probeTasks.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setDraft(blankDraft())}>
|
||||
{t('pages.nodes.probeTasks.cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
|
||||
// Item 7 B5: the public status page's theme store + notify block.
|
||||
// (The public-page settings themselves moved to the probe settings
|
||||
// page in V2.0.1.) Like the probe-meta section this surface is
|
||||
// Item 7 B5: the public status page's theme store. (The public-page
|
||||
// settings themselves moved to the probe settings page in V2.0.1,
|
||||
// and the notify block moved there too as its own sub-page — see
|
||||
// ProbeNotifyTab.) Like the probe-meta section this surface is
|
||||
// self-contained — it loads and saves through its own endpoints and
|
||||
// every change takes effect immediately (no panel save, no
|
||||
// restart). Theme packages are static files served by the panel;
|
||||
@@ -53,27 +53,6 @@ interface ThemesPayload {
|
||||
themes: ThemeWire[];
|
||||
}
|
||||
|
||||
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
|
||||
// The webhook URL and headers are secrets: the GET answers only
|
||||
// whether they are set, and the PUT omits them unless the admin
|
||||
// edited the field (an explicit clear sends "").
|
||||
interface NotifySettingsWire {
|
||||
notifyGrace: number;
|
||||
notifyTraffic: number;
|
||||
notifyExpiry: number;
|
||||
notifyLogin: boolean;
|
||||
notifyTelegramText: string;
|
||||
notifyWebhookBody: string;
|
||||
telegramConfigured: boolean;
|
||||
webhookConfigured: boolean;
|
||||
notifyWebhookUrlSet: boolean;
|
||||
notifyWebhookHeadersSet: boolean;
|
||||
}
|
||||
|
||||
interface NotifyPayload {
|
||||
settings: NotifySettingsWire;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||
const UPLOAD_CHUNK = 1 << 20;
|
||||
|
||||
@@ -90,13 +69,6 @@ export default function PublicPageTab() {
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [uploadPct, setUploadPct] = useState<number | null>(null);
|
||||
const [busyShort, setBusyShort] = useState('');
|
||||
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
const [webhookHeaders, setWebhookHeaders] = useState('');
|
||||
const [urlTouched, setUrlTouched] = useState(false);
|
||||
const [headersTouched, setHeadersTouched] = useState(false);
|
||||
const [notifySaving, setNotifySaving] = useState(false);
|
||||
const [notifyTesting, setNotifyTesting] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const applyPayload = useCallback((payload: ThemesPayload) => {
|
||||
@@ -113,69 +85,11 @@ export default function PublicPageTab() {
|
||||
HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true }).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) applyPayload(msg.obj);
|
||||
});
|
||||
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setNotify(msg.obj.settings);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyPayload]);
|
||||
|
||||
async function saveNotify() {
|
||||
if (!notify) return;
|
||||
setNotifySaving(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
notifyGrace: notify.notifyGrace,
|
||||
notifyTraffic: notify.notifyTraffic,
|
||||
notifyExpiry: notify.notifyExpiry,
|
||||
notifyLogin: notify.notifyLogin,
|
||||
notifyTelegramText: notify.notifyTelegramText,
|
||||
notifyWebhookBody: notify.notifyWebhookBody,
|
||||
};
|
||||
// Secrets ride along only when the field was edited; an
|
||||
// edited-but-empty field clears the stored value server-side.
|
||||
if (urlTouched) body.notifyWebhookUrl = webhookUrl;
|
||||
if (headersTouched) body.notifyWebhookHeaders = webhookHeaders;
|
||||
const msg = await HttpUtil.put<NotifyPayload>('/panel/api/probe/notify', body, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
if (msg?.success && msg.obj) {
|
||||
setNotify(msg.obj.settings);
|
||||
setWebhookUrl('');
|
||||
setWebhookHeaders('');
|
||||
setUrlTouched(false);
|
||||
setHeadersTouched(false);
|
||||
messageApi.success(t('pages.settings.probeThemes.saved'));
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||
}
|
||||
} finally {
|
||||
setNotifySaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testNotify() {
|
||||
setNotifyTesting(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ sent: string[] }>('/panel/api/probe/notify/test', undefined, {
|
||||
silent: true,
|
||||
});
|
||||
if (msg?.success) {
|
||||
messageApi.success(
|
||||
t('pages.settings.probeNotify.testSent', { channels: (msg.obj?.sent ?? []).join('、') }),
|
||||
);
|
||||
} else {
|
||||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||
}
|
||||
} finally {
|
||||
setNotifyTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTheme(file: File | undefined) {
|
||||
if (!file) return;
|
||||
if (file.size === 0) {
|
||||
@@ -301,9 +215,11 @@ export default function PublicPageTab() {
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{themes.map((theme) => (
|
||||
<Col key={theme.short} xs={24} sm={12} lg={8}>
|
||||
<Col key={theme.short} xs={24} sm={12} lg={8} style={{ display: 'flex' }}>
|
||||
<Card
|
||||
hoverable
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1 } }}
|
||||
cover={
|
||||
theme.preview ? (
|
||||
<img
|
||||
@@ -381,179 +297,6 @@ export default function PublicPageTab() {
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
{t('pages.settings.probeNotify.notifyTitle')}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.settings.probeNotify.notifyHint')}
|
||||
</Typography.Paragraph>
|
||||
{notify ? (
|
||||
<>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.grace')}</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={30}
|
||||
value={notify.notifyGrace}
|
||||
onChange={onNumber((v) => setNotify({ ...notify, notifyGrace: v }))}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.graceHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.trafficPct')}</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={notify.notifyTraffic}
|
||||
onChange={onNumber((v) => setNotify({ ...notify, notifyTraffic: v }))}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.trafficHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.expiryDays')}</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={365}
|
||||
value={notify.notifyExpiry}
|
||||
onChange={onNumber((v) => setNotify({ ...notify, notifyExpiry: v }))}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.expiryHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.loginAlert')}</div>
|
||||
<Switch
|
||||
checked={notify.notifyLogin}
|
||||
onChange={(v) => setNotify({ ...notify, notifyLogin: v })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeNotify.telegramChannel')}
|
||||
</div>
|
||||
{notify.telegramConfigured ? (
|
||||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||||
)}
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.telegramHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeNotify.webhookChannel')}
|
||||
</div>
|
||||
{notify.webhookConfigured ? (
|
||||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.telegramText')}</div>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={notify.notifyTelegramText}
|
||||
placeholder={'{{title}}\n{{message}}'}
|
||||
onChange={(e) => setNotify({ ...notify, notifyTelegramText: e.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookUrl')}</div>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
placeholder={
|
||||
notify.notifyWebhookUrlSet ? t('pages.settings.probeNotify.secretSet') : 'https://'
|
||||
}
|
||||
onChange={(e) => {
|
||||
setWebhookUrl(e.target.value);
|
||||
setUrlTouched(true);
|
||||
}}
|
||||
/>
|
||||
{notify.notifyWebhookUrlSet && !urlTouched ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setWebhookUrl('');
|
||||
setUrlTouched(true);
|
||||
}}
|
||||
>
|
||||
{t('pages.settings.probeNotify.clearSecret')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{t('pages.settings.probeNotify.webhookHeaders')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={webhookHeaders}
|
||||
placeholder={
|
||||
notify.notifyWebhookHeadersSet
|
||||
? t('pages.settings.probeNotify.secretSet')
|
||||
: 'Authorization: Bearer …'
|
||||
}
|
||||
onChange={(e) => {
|
||||
setWebhookHeaders(e.target.value);
|
||||
setHeadersTouched(true);
|
||||
}}
|
||||
/>
|
||||
{notify.notifyWebhookHeadersSet && !headersTouched ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setWebhookHeaders('');
|
||||
setHeadersTouched(true);
|
||||
}}
|
||||
>
|
||||
{t('pages.settings.probeNotify.clearSecret')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Col>
|
||||
<Col xs={24}>
|
||||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookBody')}</div>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={notify.notifyWebhookBody}
|
||||
onChange={(e) => setNotify({ ...notify, notifyWebhookBody: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.probeNotify.templateHint')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button type="primary" loading={notifySaving} onClick={saveNotify}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
<Button loading={notifyTesting} onClick={testNotify}>
|
||||
{t('pages.settings.probeNotify.testSend')}
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ esac
|
||||
# 改名对齐:旧 tag v3.7.0-agent.1 从未携带改名后的 l-ui-linux 资产,沿用它
|
||||
# 会指向不存在的发行;此处改指本批发版 V2.7.0。仓库名定为 rogue/l-ui,
|
||||
# Gitea 改名在发版前完成,占位警告随之销账。
|
||||
LUI_URL="${LUI_URL:-https://git.881218.xyz/rogue/l-ui/releases/download/V2.7.1/l-ui-linux-${BIN_ARCH}}"
|
||||
LUI_URL="${LUI_URL:-https://git.881218.xyz/rogue/l-ui/releases/download/V2.7.2/l-ui-linux-${BIN_ARCH}}"
|
||||
XRAY_VER="26.6.27"
|
||||
|
||||
info "创建目录..."
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -152,71 +151,6 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// V2.0.1 ⑤: batch task creation — shared fields, auto names,
|
||||
// duplicate collapse, all-or-nothing validation.
|
||||
func TestProbeTasksBatch(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
cleanPingTasks(t)
|
||||
const batchPath = "/panel/api/agent/probe-tasks/batch"
|
||||
|
||||
rec := rig.do(http.MethodPost, batchPath, []byte(
|
||||
`{"targets":["1.1.1.1:443","8.8.8.8:53"," 1.1.1.1:443 "],"autoJoin":true}`), true)
|
||||
env := decodeEnvelope(t, rec.Body.Bytes())
|
||||
if !env.Success {
|
||||
t.Fatalf("batch create refused: %s", env.Msg)
|
||||
}
|
||||
var obj struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Obj, &obj); err != nil || len(obj.IDs) != 2 {
|
||||
t.Fatalf("batch ids = %s (err %v), want 2 ids (duplicate collapsed)", env.Obj, err)
|
||||
}
|
||||
tasks, err := probe.PingTasks()
|
||||
if err != nil || len(tasks) != 2 {
|
||||
t.Fatalf("tasks after batch = %d (err %v)", len(tasks), err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.Name != task.Target {
|
||||
t.Errorf("auto name = %q, want the target %q", task.Name, task.Target)
|
||||
}
|
||||
if task.Interval != 60 {
|
||||
t.Errorf("default interval = %d, want 60", task.Interval)
|
||||
}
|
||||
if !task.AutoJoin {
|
||||
t.Errorf("autoJoin not carried into task %d", task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
cleanPingTasks(t)
|
||||
rec = rig.do(http.MethodPost, batchPath, []byte(
|
||||
`{"targets":["1.1.1.1:443","8.8.8.8:53"],"name":"线路","interval":30}`), true)
|
||||
env = decodeEnvelope(t, rec.Body.Bytes())
|
||||
if !env.Success {
|
||||
t.Fatalf("named batch refused: %s", env.Msg)
|
||||
}
|
||||
tasks, _ = probe.PingTasks()
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("named batch tasks = %d", len(tasks))
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if !strings.HasPrefix(task.Name, "线路 · ") || task.Interval != 30 {
|
||||
t.Errorf("named batch task = %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
// One bad line refuses the whole paste; nothing is created.
|
||||
cleanPingTasks(t)
|
||||
rec = rig.do(http.MethodPost, batchPath, []byte(
|
||||
`{"targets":["1.1.1.1:443","not-a-target"]}`), true)
|
||||
env = decodeEnvelope(t, rec.Body.Bytes())
|
||||
if env.Success {
|
||||
t.Fatal("batch with a bad target succeeded")
|
||||
}
|
||||
if tasks, _ := probe.PingTasks(); len(tasks) != 0 {
|
||||
t.Fatalf("refused batch leaked %d tasks", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// V2.0.1 probe batch: the meta editor's calibration + interface pin
|
||||
// fields round-trip and validate.
|
||||
func TestProbeMetaCalibrationFields(t *testing.T) {
|
||||
|
||||
@@ -668,3 +668,87 @@ func TestProbePublicCountryFlag(t *testing.T) {
|
||||
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||
}
|
||||
}
|
||||
|
||||
// Title injection on the served theme shell: the first paint must
|
||||
// already carry the configured site name, not the theme package's
|
||||
// static title (a CFSM-era package still says CF-Server-Monitor until
|
||||
// the theme JS rewrites it after fetching /api/me). The injected value
|
||||
// is the same site_name setting /api/me reports, so it can never
|
||||
// disagree with the JS rewrite; with nothing configured the shell
|
||||
// passes through untouched.
|
||||
func TestPublicThemeTitleInject(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist")
|
||||
if err := os.MkdirAll(filepath.Join(themeDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"name":"Test Theme","short":"testtheme","description":"contract test fixture","version":"0.0.1","author":"tests","url":""}`
|
||||
if err := os.WriteFile(filepath.Join(rig.dbDir, "themes", "testtheme", "theme.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexHTML := "<!doctype html><html><head><title>CF-Server-Monitor</title></head><body>shell</body></html>"
|
||||
if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(themeDir, "assets", "app-abc123.js"), []byte("console.log(1)"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rig.setSetting(t, "theme", "testtheme")
|
||||
// The root shell is anonymous content only with the public page
|
||||
// switched on (V2.0.1 gate); without it the test would 404.
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
|
||||
// Unconfigured: the theme's own title stands, byte-identical.
|
||||
rec := rig.do(http.MethodGet, "/", nil, false)
|
||||
if rec.Code != 200 || rec.Body.String() != indexHTML {
|
||||
t.Fatalf("unconfigured root: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Configured: the served shell carries the configured title, and
|
||||
// /api/me reports the same value from the same setting.
|
||||
rig.setSetting(t, "site_name", "Amo Monitor")
|
||||
rec = rig.do(http.MethodGet, "/", nil, false)
|
||||
want := "<!doctype html><html><head><title>Amo Monitor</title></head><body>shell</body></html>"
|
||||
if rec.Code != 200 || rec.Body.String() != want {
|
||||
t.Fatalf("injected root: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-cache") {
|
||||
t.Fatalf("injected shell cache: %q", cc)
|
||||
}
|
||||
etag1 := rec.Header().Get("ETag")
|
||||
if etag1 == "" {
|
||||
t.Fatal("injected shell missing etag")
|
||||
}
|
||||
meRec := rig.do(http.MethodGet, "/api/me", nil, false)
|
||||
var me map[string]any
|
||||
if err := json.Unmarshal(meRec.Body.Bytes(), &me); err != nil || me["site_name"] != "Amo Monitor" {
|
||||
t.Fatalf("me site_name: %v err=%v", me["site_name"], err)
|
||||
}
|
||||
|
||||
// Non-HTML assets pass through byte-identical, still immutable.
|
||||
rec = rig.do(http.MethodGet, "/assets/app-abc123.js", nil, false)
|
||||
if rec.Code != 200 || rec.Body.String() != "console.log(1)" || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
|
||||
t.Fatalf("asset with title set: %d %q cc=%q", rec.Code, rec.Body.String(), rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
|
||||
// A title change changes the shell ETag, so no-cache
|
||||
// revalidation cannot serve the old title; the new ETag 304s.
|
||||
// The title is HTML-escaped and a "$" in it survives verbatim.
|
||||
rig.setSetting(t, "site_name", "A & B <x> $1")
|
||||
rec = rig.do(http.MethodGet, "/", nil, false)
|
||||
want = "<!doctype html><html><head><title>A & B <x> $1</title></head><body>shell</body></html>"
|
||||
if rec.Code != 200 || rec.Body.String() != want {
|
||||
t.Fatalf("escaped root: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
etag2 := rec.Header().Get("ETag")
|
||||
if etag2 == "" || etag2 == etag1 {
|
||||
t.Fatalf("etag must change with title: %q -> %q", etag1, etag2)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("If-None-Match", etag2)
|
||||
rec304 := httptest.NewRecorder()
|
||||
rig.engine.ServeHTTP(rec304, req)
|
||||
if rec304.Code != 304 {
|
||||
t.Fatalf("etag revalidation: %d", rec304.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ func NewProbeTasksController(g *gin.RouterGroup) *ProbeTasksController {
|
||||
pc := &ProbeTasksController{}
|
||||
g.GET("/probe-tasks", pc.listTasks)
|
||||
g.PUT("/probe-tasks", pc.saveTask)
|
||||
g.POST("/probe-tasks/batch", pc.saveTaskBatch)
|
||||
g.DELETE("/probe-tasks/:id", pc.deleteTask)
|
||||
g.POST("/probe-tasks/reorder", pc.reorderTasks)
|
||||
g.PUT("/probe-tasks/by-node/:nodeId", pc.setNodeTasks)
|
||||
@@ -270,114 +269,6 @@ func (pc *ProbeTasksController) saveTask(c *gin.Context) {
|
||||
jsonObj(c, gin.H{"id": id}, nil)
|
||||
}
|
||||
|
||||
// probeTaskBatchForm is the batch-create shape (V2.0.1 ⑤): many
|
||||
// targets at once — already split client-side from the pasted
|
||||
// lines/commas — sharing every other field. Name is a shared label:
|
||||
// with several targets each task is auto-named after its target
|
||||
// unless a name is given, in which case it prefixes the target
|
||||
// ("<name> · <target>"); with exactly one target the name is used
|
||||
// verbatim (and may be empty, falling back to the target).
|
||||
type probeTaskBatchForm struct {
|
||||
Targets []string `json:"targets"`
|
||||
Name string `json:"name"`
|
||||
Interval int64 `json:"interval"`
|
||||
AutoJoin bool `json:"autoJoin"`
|
||||
Region string `json:"region"`
|
||||
NodeIDs []int64 `json:"nodeIds"`
|
||||
}
|
||||
|
||||
// saveTaskBatch creates one task per target, all-or-nothing on
|
||||
// validation: every form is validated before the first insert, so a
|
||||
// bad line refuses the whole paste with the hub-style message
|
||||
// instead of leaving a half-entered list behind.
|
||||
func (pc *ProbeTasksController) saveTaskBatch(c *gin.Context) {
|
||||
var form probeTaskBatchForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
jsonMsg(c, "Invalid request", err)
|
||||
return
|
||||
}
|
||||
if form.NodeIDs == nil {
|
||||
form.NodeIDs = []int64{}
|
||||
}
|
||||
// The batch form's interval is a shared convenience field: an
|
||||
// omitted one means the standard 60 s (the single-task form
|
||||
// keeps requiring an explicit value).
|
||||
if form.Interval == 0 {
|
||||
form.Interval = 60
|
||||
}
|
||||
// Split remnants and duplicates in the paste collapse here;
|
||||
// order is preserved (it becomes the tasks' sort order).
|
||||
seen := map[string]bool{}
|
||||
targets := make([]string, 0, len(form.Targets))
|
||||
for _, raw := range form.Targets {
|
||||
tgt := strings.TrimSpace(raw)
|
||||
if tgt == "" || seen[tgt] {
|
||||
continue
|
||||
}
|
||||
seen[tgt] = true
|
||||
targets = append(targets, tgt)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
jsonMsg(c, "请填写目标", errRequest)
|
||||
return
|
||||
}
|
||||
sharedName := strings.TrimSpace(form.Name)
|
||||
forms := make([]probeTaskForm, 0, len(targets))
|
||||
for _, tgt := range targets {
|
||||
name := sharedName
|
||||
switch {
|
||||
case len(targets) == 1:
|
||||
if name == "" {
|
||||
name = tgt
|
||||
}
|
||||
case name == "":
|
||||
name = tgt
|
||||
default:
|
||||
name = name + " · " + tgt
|
||||
}
|
||||
f := probeTaskForm{
|
||||
Name: name,
|
||||
Target: tgt,
|
||||
Interval: form.Interval,
|
||||
AutoJoin: form.AutoJoin,
|
||||
Region: form.Region,
|
||||
NodeIDs: form.NodeIDs,
|
||||
}
|
||||
if msg := validateProbeTaskForm(&f); msg != "" {
|
||||
jsonMsg(c, msg, errRequest)
|
||||
return
|
||||
}
|
||||
forms = append(forms, f)
|
||||
}
|
||||
bound, err := agentsByNode()
|
||||
if err != nil {
|
||||
jsonMsg(c, "save probe tasks failed", err)
|
||||
return
|
||||
}
|
||||
agentIDs, refusal := resolveAgentIDs(form.NodeIDs, bound)
|
||||
if refusal != "" {
|
||||
jsonMsg(c, refusal, errRequest)
|
||||
return
|
||||
}
|
||||
ids := make([]int64, 0, len(forms))
|
||||
for _, f := range forms {
|
||||
id, err := probe.SavePingTask(probe.PingTask{
|
||||
Name: f.Name,
|
||||
Target: f.Target,
|
||||
Interval: f.Interval,
|
||||
AutoJoin: f.AutoJoin,
|
||||
Region: f.Region,
|
||||
AgentIDs: agentIDs,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
probeTaskError(c, err)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
jsonObj(c, gin.H{"ids": ids}, nil)
|
||||
}
|
||||
|
||||
func (pc *ProbeTasksController) deleteTask(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,10 +3,12 @@ package service
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"html"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
@@ -208,11 +210,44 @@ func stampProbeIcons(html []byte) []byte {
|
||||
return []byte(text)
|
||||
}
|
||||
|
||||
// probeTitlePattern matches the single <title> element of an HTML
|
||||
// shell, case-insensitively and across line breaks.
|
||||
var probeTitlePattern = regexp.MustCompile(`(?is)<title[^>]*>.*?</title>`)
|
||||
|
||||
// injectProbeTitle replaces the shell's static <title> with the
|
||||
// operator-configured site name, so the first paint already shows the
|
||||
// configured title instead of whatever the theme package shipped with
|
||||
// (a CFSM-era package still says CF-Server-Monitor until the theme JS
|
||||
// rewrites it after fetching /api/me). The value comes from the same
|
||||
// probe setting /api/me's site_name comes from, so the injected title
|
||||
// and the JS rewrite can never disagree. When no site name is
|
||||
// configured the bytes pass through untouched — the theme's own title
|
||||
// stands and no empty title is ever injected. The title is
|
||||
// HTML-escaped and spliced in place (never a regex replacement, so a
|
||||
// "$" in the title cannot be mistaken for an expansion). Only the
|
||||
// first <title> is replaced; a shell without one passes through.
|
||||
func injectProbeTitle(htmlBytes []byte) []byte {
|
||||
title, ok := probeSetting("site_name")
|
||||
if !ok || strings.TrimSpace(title) == "" {
|
||||
return htmlBytes
|
||||
}
|
||||
loc := probeTitlePattern.FindIndex(htmlBytes)
|
||||
if loc == nil {
|
||||
return htmlBytes
|
||||
}
|
||||
out := make([]byte, 0, len(htmlBytes)+len(title))
|
||||
out = append(out, htmlBytes[:loc[0]]...)
|
||||
out = append(out, []byte("<title>"+html.EscapeString(title)+"</title>")...)
|
||||
out = append(out, htmlBytes[loc[1]:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// serveProbeThemeFile serves one resolved theme file, stamping the
|
||||
// shell's icon URLs on the way out.
|
||||
// shell's icon URLs and injecting the configured title on the way out.
|
||||
func serveProbeThemeFile(c *gin.Context, path string, data []byte) {
|
||||
if path == "index.html" {
|
||||
data = stampProbeIcons(data)
|
||||
data = injectProbeTitle(data)
|
||||
}
|
||||
serveThemeAsset(c, path, data)
|
||||
}
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -1028,8 +1028,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -1039,17 +1037,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -1059,19 +1056,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -1101,12 +1091,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "Downlink correction (GB)",
|
||||
"calHint": "Corrections are added to the counted traffic (negative values subtract) and apply to panel statistics, the public page and traffic alerts.",
|
||||
"statIface": "Counted interface",
|
||||
"movedHint": "Probe metadata (pricing, traffic limits, calibration…) is edited on the probe settings page.",
|
||||
"goSettings": "Open probe settings",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "Batch add",
|
||||
"batchPlaceholder": "One target per line, or comma-separated — host:port, e.g. 1.1.1.1:443",
|
||||
"batchSubmit": "Add all",
|
||||
"batchHint": "The shared fields apply to every target; leave the name empty to name each task after its target.",
|
||||
"batchEmpty": "Enter at least one target",
|
||||
"batchDone": "Added {{count}} probe tasks"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "Probe settings",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "History retention (days)",
|
||||
"retentionHint": "How long probe history (metrics and latency) is kept.",
|
||||
"fastInterval": "Fast report interval",
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat.",
|
||||
"metaTitle": "Per-node probe metadata",
|
||||
"metaNodePlaceholder": "Select a node"
|
||||
"fastIntervalHint": "How often agents report runtime metrics; everything else still rides the 60 s heartbeat."
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "下行校正(GB)",
|
||||
"calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。",
|
||||
"statIface": "统计网卡",
|
||||
"movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。",
|
||||
"goSettings": "前往探针设置",
|
||||
"save": "保存状态页信息",
|
||||
"saved": "状态页信息已保存",
|
||||
"saveFailed": "状态页信息保存失败"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "由 agent 节点执行的 TCP 延迟探测。修改在各 agent 下一次拉取配置时生效(约 30 秒),无需重启。",
|
||||
"loading": "加载中…",
|
||||
"empty": "还没有探测任务,新增一个开始测延迟。",
|
||||
"add": "新增探测",
|
||||
"name": "名称",
|
||||
"target": "目标",
|
||||
"targetPlaceholder": "主机:端口,如 1.1.1.1:443",
|
||||
"interval": "间隔(秒)",
|
||||
"region": "地区",
|
||||
"ungrouped": "未分组",
|
||||
"autoJoin": "自动加入",
|
||||
"autoJoinOn": "自动",
|
||||
"nodes": "指派节点",
|
||||
"nodesPlaceholder": "选择节点",
|
||||
"selectAllNodes": "选择全部节点",
|
||||
"actions": "操作",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "探测任务已保存",
|
||||
"deleted": "探测任务已删除",
|
||||
"loadFailed": "探测任务加载失败",
|
||||
"saveFailed": "操作失败",
|
||||
"batchTitle": "批量添加",
|
||||
"batchPlaceholder": "每行一个目标,或用逗号分隔——主机:端口,如 1.1.1.1:443",
|
||||
"batchSubmit": "全部添加",
|
||||
"batchHint": "共用字段对每个目标生效;名称留空时按目标自动命名。",
|
||||
"batchEmpty": "至少填写一个目标",
|
||||
"batchDone": "已添加 {{count}} 个探测任务"
|
||||
"saveFailed": "操作失败"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "主题商店",
|
||||
"settingsTitle": "公开状态页",
|
||||
"publicPage": "公开状态页",
|
||||
"publicPageHint": "关闭时,状态页及其接口只对已登录的面板开放。",
|
||||
"siteName": "站点名",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "探针设置",
|
||||
"statusPageTitle": "状态页设置",
|
||||
"retentionDays": "历史保留(天)",
|
||||
"retentionHint": "探针历史数据(指标/延迟)的保留时长。",
|
||||
"fastInterval": "快报间隔",
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。",
|
||||
"metaTitle": "按节点探针元数据",
|
||||
"metaNodePlaceholder": "选择节点"
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "通知",
|
||||
|
||||
@@ -910,8 +910,6 @@
|
||||
"calDown": "下行校正(GB)",
|
||||
"calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。",
|
||||
"statIface": "统计网卡",
|
||||
"movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。",
|
||||
"goSettings": "前往探针设置",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -921,17 +919,16 @@
|
||||
"intro": "TCP latency probes run by agent nodes. Changes apply on each agent's next config poll (about 30 s); nothing restarts.",
|
||||
"loading": "Loading…",
|
||||
"empty": "No probe tasks yet. Add one to start measuring latency.",
|
||||
"add": "Add probe",
|
||||
"name": "Name",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "host:port, e.g. 1.1.1.1:443",
|
||||
"interval": "Interval (s)",
|
||||
"region": "Region",
|
||||
"ungrouped": "Ungrouped",
|
||||
"autoJoin": "Auto-join",
|
||||
"autoJoinOn": "Auto",
|
||||
"nodes": "Nodes",
|
||||
"nodesPlaceholder": "Assign nodes",
|
||||
"selectAllNodes": "Select all nodes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -941,19 +938,12 @@
|
||||
"saved": "Probe task saved",
|
||||
"deleted": "Probe task deleted",
|
||||
"loadFailed": "Failed to load probe tasks",
|
||||
"saveFailed": "Operation failed",
|
||||
"batchTitle": "批量添加",
|
||||
"batchPlaceholder": "每行一个目标,或用逗号分隔——主机:端口,如 1.1.1.1:443",
|
||||
"batchSubmit": "全部添加",
|
||||
"batchHint": "共用字段对每个目标生效;名称留空时按目标自动命名。",
|
||||
"batchEmpty": "至少填写一个目标",
|
||||
"batchDone": "已添加 {{count}} 个探测任务"
|
||||
"saveFailed": "Operation failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"probeThemes": {
|
||||
"title": "Theme store",
|
||||
"settingsTitle": "Public status page",
|
||||
"publicPage": "Public page",
|
||||
"publicPageHint": "When off, the status page and its API answer only to the logged-in panel.",
|
||||
"siteName": "Site name",
|
||||
@@ -983,12 +973,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "探针设置",
|
||||
"statusPageTitle": "Status page settings",
|
||||
"retentionDays": "历史保留(天)",
|
||||
"retentionHint": "探针历史数据(指标/延迟)的保留时长。",
|
||||
"fastInterval": "快报间隔",
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。",
|
||||
"metaTitle": "按节点探针元数据",
|
||||
"metaNodePlaceholder": "选择节点"
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "Notifications",
|
||||
|
||||
Reference in New Issue
Block a user