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 { 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 { 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((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(null); const [saving, setSaving] = useState(false); const [metaNodeId, setMetaNodeId] = useState(undefined); const faviconRef = useRef(null); const touchRef = useRef(null); const { nodes } = useNodesQuery(); const agentNodes = nodes.filter((n) => n.nodeType === 'agent'); useEffect(() => { let cancelled = false; HttpUtil.get('/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 = (key: K, value: ProbeSettingsWire[K]) => setSettings((s) => (s ? { ...s, [key]: value } : s)); return (
{messageContextHolder} {t('pages.settings.probeThemes.settingsTitle')} {settings ? ( <>
{t('pages.settings.probeThemes.publicPage')}
set('publicPage', v)} /> {t('pages.settings.probeThemes.publicPageHint')}
{t('pages.settings.probeThemes.siteName')}
set('siteName', e.target.value)} />
{t('pages.settings.probeThemes.site')}
set('site', e.target.value)} />
{t('pages.settings.probeThemes.githubProxy')}
set('githubProxy', e.target.value)} /> {t('pages.settings.probeThemes.githubProxyHint')}
{t('pages.settings.probe.retentionDays')}
set('retentionDays', v ?? 7)} style={{ width: '100%' }} /> {t('pages.settings.probe.retentionHint')}
{t('pages.settings.probe.fastInterval')}
set('fastIntervalSec', v ?? 3)} addonAfter="s" style={{ width: '100%' }} /> {t('pages.settings.probe.fastIntervalHint')}
{t('pages.settings.probeThemes.favicon')}
{settings.favicon ? ( ) : null} {settings.favicon ? ( ) : null} pickIcon('favicon', e.target.files?.[0])} />
{t('pages.settings.probeThemes.touchIcon')}
{settings.touchIcon ? ( ) : null} {settings.touchIcon ? ( ) : null} pickIcon('touchIcon', e.target.files?.[0])} />
{t('pages.settings.probeThemes.iconHint')}
) : null} {t('pages.nodes.probeTasks.title')} {t('pages.settings.probe.metaTitle')}