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).
265 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|