Files
l-ui/frontend/src/pages/settings/ProbeTasksPanel.tsx
T
Rogue 29de9761c8
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

432 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { HttpUtil } from '@/utils';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Probe (latency) task management — the panel form of what used to
// be the nodes-page modal (moved to the probe settings page in
// V2.0.1; the target set lives in exactly one place now). The
// operator maintains the target set here — grouped by the free-form
// region label, no preset list (user ruling Q4). The surface is
// self-contained: it loads and saves on its own through the
// dedicated endpoints, effective immediately (the master pushes the
// set on each agent's next config poll; nothing restarts).
interface ProbeTaskWire {
id: number;
name: string;
target: string;
interval: number;
autoJoin: boolean;
sort: number;
region: string;
nodeIds: number[];
}
interface TaskDraft {
id: number; // 0 = new task
name: string;
target: string;
interval: number;
autoJoin: boolean;
region: string;
nodeIds: number[];
baseNodeIds: number[] | null; // editor-open snapshot (existing tasks)
}
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);
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}`,
[nodes],
);
const load = useCallback(async () => {
const msg = await HttpUtil.get<ProbeTaskWire[]>(TASKS_URL, { silent: true });
if (msg?.success && msg.obj) {
setTasks(msg.obj);
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.loadFailed'));
}
}, [messageApi, t]);
useEffect(() => {
void load();
}, [load]);
// Group by region: region labels ascending, the ungrouped bucket
// last; inside a group the panel order (sort) rules.
const groups = useMemo(() => {
const byRegion = new Map<string, ProbeTaskWire[]>();
for (const task of tasks ?? []) {
const key = task.region ?? '';
const list = byRegion.get(key) ?? [];
list.push(task);
byRegion.set(key, list);
}
return [...byRegion.entries()]
.sort(([a], [b]) => {
if (a === b) return 0;
if (a === '') return 1;
if (b === '') return -1;
return a < b ? -1 : 1;
})
.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,
name: task.name,
target: task.target,
interval: task.interval,
autoJoin: task.autoJoin,
region: task.region ?? '',
nodeIds: [...task.nodeIds],
baseNodeIds: [...task.nodeIds],
});
async function onSave() {
if (!draft) return;
setSaving(true);
try {
const payload: Record<string, unknown> = {
id: draft.id,
name: draft.name,
target: draft.target,
interval: draft.interval,
autoJoin: draft.autoJoin,
region: draft.region,
nodeIds: draft.nodeIds,
};
if (draft.baseNodeIds) payload.baseNodeIds = draft.baseNodeIds;
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) {
messageApi.success(t('pages.nodes.probeTasks.saved'));
setDraft(null);
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
} finally {
setSaving(false);
}
}
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);
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
}
const columns: ColumnsType<ProbeTaskWire> = [
{ title: t('pages.nodes.probeTasks.name'), dataIndex: 'name', key: 'name' },
{
title: t('pages.nodes.probeTasks.target'),
dataIndex: 'target',
key: 'target',
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{
title: t('pages.nodes.probeTasks.interval'),
dataIndex: 'interval',
key: 'interval',
width: 90,
render: (v: number) => `${v}s`,
},
{
title: t('pages.nodes.probeTasks.autoJoin'),
dataIndex: 'autoJoin',
key: 'autoJoin',
width: 90,
render: (v: boolean) =>
v ? <Tag color="blue">{t('pages.nodes.probeTasks.autoJoinOn')}</Tag> : null,
},
{
title: t('pages.nodes.probeTasks.nodes'),
dataIndex: 'nodeIds',
key: 'nodeIds',
render: (ids: number[]) => <span title={ids.map(nodeName).join(', ')}>{ids.length}</span>,
},
{
title: t('pages.nodes.probeTasks.actions'),
key: 'actions',
width: 110,
render: (_, task) => (
<Space size="small">
<Button size="small" icon={<EditOutlined />} onClick={() => startEdit(task)}>
{t('pages.nodes.probeTasks.edit')}
</Button>
<Popconfirm
title={t('pages.nodes.probeTasks.deleteConfirm')}
onConfirm={() => void onDelete(task)}
okText={t('pages.nodes.probeTasks.delete')}
cancelText={t('pages.nodes.probeTasks.cancel')}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const setDraftField = <K extends keyof TaskDraft>(key: K, value: TaskDraft[K]) =>
setDraft((d) => (d ? { ...d, [key]: value } : d));
return (
<div>
{messageContextHolder}
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{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 ? (
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.empty')}</Typography.Text>
) : (
groups.map(({ region, list }) => (
<div key={region || '__ungrouped'} style={{ marginBottom: 16 }}>
<Typography.Text strong>
{region || t('pages.nodes.probeTasks.ungrouped')}
</Typography.Text>
<Table<ProbeTaskWire>
size="small"
rowKey="id"
columns={columns}
dataSource={list}
pagination={false}
style={{ marginTop: 6 }}
/>
</div>
))
)}
{draft ? (
<div
style={{
borderTop: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
paddingTop: 12,
marginTop: 4,
}}
>
<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')}
</Button>
)}
</div>
);
}