CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
- 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).
303 lines
9.9 KiB
TypeScript
303 lines
9.9 KiB
TypeScript
import { useEffect, useState, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, Col, Input, InputNumber, Row, Select, Switch, message } from 'antd';
|
|
import { HttpUtil } from '@/utils';
|
|
|
|
// Item 7 B3: probe/public-page metadata for an agent node, edited in
|
|
// place from the node edit dialog. The section is deliberately outside
|
|
// the node form: it loads and saves on its own (dedicated endpoints,
|
|
// effective immediately on save — no node save, no restart).
|
|
interface ProbeMetaWire {
|
|
price: number;
|
|
currency: string;
|
|
billingCycle: string;
|
|
expiresAt: string | null;
|
|
trafficLimit: number; // bytes
|
|
trafficMode: string;
|
|
trafficResetDay: number;
|
|
public: boolean;
|
|
sort: number;
|
|
group: string;
|
|
publicRemark: string;
|
|
notify: boolean;
|
|
countryPin: string;
|
|
ipv4Pin: string;
|
|
ipv6Pin: string;
|
|
// V2.0.1: ledger corrections in GB (either sign) and the counted
|
|
// interface pin ('' = automatic selection on the agent).
|
|
trafficCalUpGb: number;
|
|
trafficCalDownGb: number;
|
|
statIface: string;
|
|
}
|
|
|
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
|
|
|
const NAMED_CYCLES = [
|
|
'once',
|
|
'monthly',
|
|
'quarterly',
|
|
'semiannual',
|
|
'yearly',
|
|
'biennial',
|
|
'triennial',
|
|
] as const;
|
|
|
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div style={{ marginBottom: 12 }}>
|
|
<div style={{ marginBottom: 4 }}>{label}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
|
const { t } = useTranslation();
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
// Loaded meta, keyed by node id so a stale result for a previously
|
|
// edited node is never shown (same discipline as the install block
|
|
// in NodeFormModal); visibility derives during render.
|
|
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
|
|
const [trafficGb, setTrafficGb] = useState(0);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
HttpUtil.get<ProbeMetaWire>(`/panel/api/agent/probe-meta/${nodeId}`, {
|
|
silent: true,
|
|
}).then((msg) => {
|
|
if (!cancelled && msg?.success && msg.obj) {
|
|
setLoaded({ nodeId, meta: msg.obj });
|
|
setTrafficGb(msg.obj.trafficLimit / 1e9);
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [nodeId]);
|
|
|
|
const meta = loaded && loaded.nodeId === nodeId ? loaded.meta : null;
|
|
if (!meta) return null;
|
|
|
|
const set = <K extends keyof ProbeMetaWire>(key: K, value: ProbeMetaWire[K]) =>
|
|
setLoaded((l) => (l ? { nodeId: l.nodeId, meta: { ...l.meta, [key]: value } } : l));
|
|
|
|
const cycleLabel = (cycle: string) => {
|
|
switch (cycle) {
|
|
case 'once':
|
|
return t('pages.nodes.probe.cycleOnce');
|
|
case 'monthly':
|
|
return t('pages.nodes.probe.cycleMonthly');
|
|
case 'quarterly':
|
|
return t('pages.nodes.probe.cycleQuarterly');
|
|
case 'semiannual':
|
|
return t('pages.nodes.probe.cycleSemiannual');
|
|
case 'yearly':
|
|
return t('pages.nodes.probe.cycleYearly');
|
|
case 'biennial':
|
|
return t('pages.nodes.probe.cycleBiennial');
|
|
case 'triennial':
|
|
return t('pages.nodes.probe.cycleTriennial');
|
|
default:
|
|
return cycle;
|
|
}
|
|
};
|
|
const cycleOptions: { value: string; label: string }[] = NAMED_CYCLES.map((cycle) => ({
|
|
value: cycle,
|
|
label: cycleLabel(cycle),
|
|
}));
|
|
if (!NAMED_CYCLES.includes(meta.billingCycle as (typeof NAMED_CYCLES)[number])) {
|
|
cycleOptions.push({ value: meta.billingCycle, label: meta.billingCycle });
|
|
}
|
|
|
|
async function onSave() {
|
|
if (!meta) return;
|
|
setSaving(true);
|
|
try {
|
|
const payload: ProbeMetaWire = {
|
|
...meta,
|
|
trafficLimit: Math.round(trafficGb * 1e9),
|
|
};
|
|
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
|
|
silent: true,
|
|
...JSON_HEADERS,
|
|
});
|
|
if (msg?.success) {
|
|
setLoaded({ nodeId, meta: payload });
|
|
messageApi.success(t('pages.nodes.probe.saved'));
|
|
} else {
|
|
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ marginBottom: 16 }}>
|
|
{messageContextHolder}
|
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t('pages.nodes.probe.title')}</div>
|
|
<p style={{ marginBottom: 12 }}>{t('pages.nodes.probe.hint')}</p>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.public')}>
|
|
<Switch checked={meta.public} onChange={(v) => set('public', v)} />
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.notify')}>
|
|
<Switch checked={meta.notify} onChange={(v) => set('notify', v)} />
|
|
</Field>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.group')}>
|
|
<Input value={meta.group} onChange={(e) => set('group', e.target.value)} />
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.sort')}>
|
|
<InputNumber
|
|
value={meta.sort}
|
|
onChange={(v) => set('sort', v ?? 0)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
</Row>
|
|
<Field label={t('pages.nodes.probe.publicRemark')}>
|
|
<Input value={meta.publicRemark} onChange={(e) => set('publicRemark', e.target.value)} />
|
|
</Field>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.price')}>
|
|
<InputNumber
|
|
value={meta.price}
|
|
min={0}
|
|
onChange={(v) => set('price', v ?? 0)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.currency')}>
|
|
<Input value={meta.currency} onChange={(e) => set('currency', e.target.value)} />
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.billingCycle')}>
|
|
<Select
|
|
value={meta.billingCycle}
|
|
options={cycleOptions}
|
|
onChange={(v) => set('billingCycle', v)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.expiresAt')}>
|
|
<Input
|
|
value={meta.expiresAt ?? ''}
|
|
placeholder="YYYY-MM-DD"
|
|
onChange={(e) => set('expiresAt', e.target.value || null)}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.trafficResetDay')}>
|
|
<InputNumber
|
|
value={meta.trafficResetDay}
|
|
min={1}
|
|
max={31}
|
|
onChange={(v) => set('trafficResetDay', v ?? 1)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.trafficLimit')}>
|
|
<InputNumber
|
|
value={trafficGb}
|
|
min={0}
|
|
onChange={(v) => setTrafficGb(v ?? 0)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={12}>
|
|
<Field label={t('pages.nodes.probe.trafficMode')}>
|
|
<Select
|
|
value={meta.trafficMode}
|
|
options={[
|
|
{ value: 'sum', label: t('pages.nodes.probe.modeSum') },
|
|
{ value: 'up', label: t('pages.nodes.probe.modeUp') },
|
|
{ value: 'down', label: t('pages.nodes.probe.modeDown') },
|
|
{ value: 'max', label: t('pages.nodes.probe.modeMax') },
|
|
]}
|
|
onChange={(v) => set('trafficMode', v)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.calUp')}>
|
|
<InputNumber
|
|
value={meta.trafficCalUpGb}
|
|
step={0.1}
|
|
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.calDown')}>
|
|
<InputNumber
|
|
value={meta.trafficCalDownGb}
|
|
step={0.1}
|
|
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.statIface')}>
|
|
<Input
|
|
value={meta.statIface}
|
|
placeholder="eth0"
|
|
onChange={(e) => set('statIface', e.target.value)}
|
|
/>
|
|
</Field>
|
|
</Col>
|
|
</Row>
|
|
<p style={{ marginTop: -4, marginBottom: 12 }}>{t('pages.nodes.probe.calHint')}</p>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.countryPin')}>
|
|
<Input value={meta.countryPin} onChange={(e) => set('countryPin', e.target.value)} />
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.ipv4Pin')}>
|
|
<Input value={meta.ipv4Pin} onChange={(e) => set('ipv4Pin', e.target.value)} />
|
|
</Field>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Field label={t('pages.nodes.probe.ipv6Pin')}>
|
|
<Input value={meta.ipv6Pin} onChange={(e) => set('ipv6Pin', e.target.value)} />
|
|
</Field>
|
|
</Col>
|
|
</Row>
|
|
<Button type="primary" loading={saving} onClick={onSave}>
|
|
{t('pages.nodes.probe.save')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|