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
- 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).
432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|