feat(probe): public contract API + theme hosting + meta editing (item 7 B3)

This commit is contained in:
Muse
2026-10-22 21:39:06 +00:00
parent 3e052c42b8
commit 540c05b0a5
28 changed files with 3279 additions and 13 deletions
@@ -21,6 +21,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import { currentPanelMasterUrl } from './agentMasterUrl';
import ProbeMetaSection from './ProbeMetaSection';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -472,6 +473,10 @@ export default function NodeFormModal({
</div>
)}
{mode === 'edit' && isAgent && node?.id ? (
<ProbeMetaSection nodeId={node.id} />
) : null}
{!isAgent && (
<FormField
label={t('pages.nodes.allowPrivateAddress')}
@@ -0,0 +1,262 @@
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;
}
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,
});
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.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>
);
}
+29
View File
@@ -125,6 +125,35 @@ export class HttpUtil {
}
}
static async put<T = unknown>(
url: string,
data?: unknown,
options: HttpOptions = {},
): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {
const resp = await httpRequest('PUT', url, data, rest);
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('PUT request failed:', error);
const err = error as {
response?: { data?: { msg?: string; message?: string } };
message?: string;
};
const data = err.response?.data;
const errorMsg = new Msg<T>(
false,
data?.msg || data?.message || err.message || 'Request failed',
);
if (!silent) {
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
static async delete<T = unknown>(url: string, options: HttpOptions = {}): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {