Move probe settings into sub-pages; per-node probe metadata to node edit dialog; drop probe-task batch add
Probe settings page (settings#probe) is now a tab container with three second-level sub-pages, following the same antd Tabs pattern the other settings tabs use for in-section grouping, in this order: - status: status-page settings (the former public-status-page form), tab and heading renamed from public-status-page to status-page settings (new pages.settings.probe.statusPageTitle key, all 13 locales); - latency: latency probing task manager (ProbeTasksPanel), with its batch-add module removed (see below); - notify: notifications, moved unchanged from the public-page tab (PublicPageTab, theme-store section) into ProbeNotifyTab. Per-node probe metadata (ProbeMetaSection) no longer lives on the probe settings page: it moved into the node edit dialog's public-status-page section (NodeFormModal), where it is edited in place per node — the component was already designed for that embedding (own endpoints, immediate save, outside the node form). The dialog's 'moved to probe settings' placeholder goes away with it. The global probe settings stay where they were (status tab). Batch-add removal (latency page): the batch card, its frontend state and handlers (parseTargets, onBatchAdd), the POST /panel/api/agent/probe-tasks/batch endpoint (route, form type, handler), its dedicated TestProbeTasksBatch test, and the six probeTasks.batch* locale keys are all deleted. The endpoint's only production caller was the removed module; the single-task add/edit flow, the target list and all other task endpoints are untouched. Orphaned locale keys removed across all 13 locales (settingsTitle, metaTitle, metaNodePlaceholder, movedHint, goSettings, plus the six batch keys); the 13-locale key sets stay identical, enforced by the i18n-dead-keys test. Backend APIs, setting keys, TG/webhook channel logic and theme-store content are unchanged.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,19 +51,6 @@ 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();
|
||||
@@ -71,16 +58,6 @@ export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||
const [draft, setDraft] = useState<TaskDraft | null>(null);
|
||||
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}`,
|
||||
@@ -174,40 +151,6 @@ 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) {
|
||||
@@ -280,66 +223,6 @@ 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 ? (
|
||||
|
||||
@@ -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) {
|
||||
@@ -381,179 +295,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -1031,8 +1031,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"
|
||||
@@ -1062,19 +1060,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",
|
||||
@@ -1104,12 +1095,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,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"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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",
|
||||
|
||||
@@ -913,8 +913,6 @@
|
||||
"calDown": "下行校正(GB)",
|
||||
"calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。",
|
||||
"statIface": "统计网卡",
|
||||
"movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。",
|
||||
"goSettings": "前往探针设置",
|
||||
"save": "保存状态页信息",
|
||||
"saved": "状态页信息已保存",
|
||||
"saveFailed": "状态页信息保存失败"
|
||||
@@ -944,19 +942,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": "站点名",
|
||||
@@ -986,12 +977,11 @@
|
||||
},
|
||||
"probe": {
|
||||
"title": "探针设置",
|
||||
"statusPageTitle": "状态页设置",
|
||||
"retentionDays": "历史保留(天)",
|
||||
"retentionHint": "探针历史数据(指标/延迟)的保留时长。",
|
||||
"fastInterval": "快报间隔",
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。",
|
||||
"metaTitle": "按节点探针元数据",
|
||||
"metaNodePlaceholder": "选择节点"
|
||||
"fastIntervalHint": "agent 上报运行时指标的频率;其余内容仍走 60 秒心跳。"
|
||||
},
|
||||
"probeNotify": {
|
||||
"notifyTitle": "通知",
|
||||
|
||||
@@ -913,8 +913,6 @@
|
||||
"calDown": "下行校正(GB)",
|
||||
"calHint": "校正值加到已统计流量上(可负数扣减),作用于面板统计、公开页与流量告警。",
|
||||
"statIface": "统计网卡",
|
||||
"movedHint": "探针元数据(价格、流量上限、校准等)已统一到「探针设置」页编辑。",
|
||||
"goSettings": "前往探针设置",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
@@ -944,19 +942,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",
|
||||
@@ -986,12 +977,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