Files
l-ui/frontend/src/pages/settings/ProbeSettingsTab.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

265 lines
10 KiB
TypeScript

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 { HttpUtil } from '@/utils';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import ProbeMetaSection from '../nodes/ProbeMetaSection';
import ProbeTasksPanel from './ProbeTasksPanel';
// V2.0.1 probe batch: the dedicated probe settings page. Everything
// probe-related that used to be scattered — the public-page block
// (public-page tab), the latency task manager (nodes-page modal)
// 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).
interface ProbeSettingsWire {
publicPage: boolean;
siteName: string;
site: string;
githubProxy: string;
retentionDays: number;
fastIntervalSec: number;
favicon: string;
touchIcon: string;
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const MAX_ICON_BYTES = 20 * 1024;
function readAsDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
// icon budget (moved unchanged from the public-page tab): small
// files pass through; a large raster is scaled down until it fits;
// a large SVG cannot be scaled and is refused here.
async function fileToIconDataURL(file: File): Promise<string> {
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
throw new Error('tooLarge');
}
const bitmap = await createImageBitmap(file);
try {
for (const size of [256, 180, 128, 96, 64]) {
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) break;
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
}
} finally {
bitmap.close();
}
throw new Error('tooLarge');
}
export default function ProbeSettingsTab() {
const { t } = useTranslation();
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;
HttpUtil.get<ProbeSettingsWire>('/panel/api/probe/settings', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) setSettings(msg.obj);
});
return () => {
cancelled = true;
};
}, []);
async function saveSettings() {
if (!settings) return;
setSaving(true);
try {
const msg = await HttpUtil.put('/panel/api/probe/settings', settings, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
} finally {
setSaving(false);
}
}
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
if (!file || !settings) return;
try {
const url = await fileToIconDataURL(file);
setSettings({ ...settings, [which]: url });
} catch {
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
}
}
const set = <K extends keyof ProbeSettingsWire>(key: K, value: ProbeSettingsWire[K]) =>
setSettings((s) => (s ? { ...s, [key]: value } : s));
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 }} />
) : 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 }}
/>
{metaNodeId ? <ProbeMetaSection key={metaNodeId} nodeId={metaNodeId} /> : null}
</div>
);
}