/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import React, { useEffect, useState, useMemo } from 'react'; import { useActions, useSelector, useIsLoading } from '../../services/state/store'; import { useTranslation, availableLanguages } from '../../services/i18n/i18n.jsx'; import { Tabs, TabPane, TimePicker, Button, Checkbox, Input, Modal, AutoComplete, Select, Banner, Radio, RadioGroup, Typography, Progress, } from '@douyinfe/semi-ui-19'; import { InputNumber } from '@douyinfe/semi-ui-19'; import { xhrPost, xhrGet } from '../../services/xhr'; import { Toast } from '@douyinfe/semi-ui-19'; import { SegmentPart } from '../../components/segment/SegmentPart'; import { downloadBackup as downloadBackupZip, precheckRestore as clientPrecheckRestore, restore as clientRestore, } from '../../services/backupRestoreClient'; import { fetchDebugStatus, enableDebugLogging as apiEnableDebugLogging, disableDebugLogging as apiDisableDebugLogging, downloadDebugBundle, clearDebugLogs as apiClearDebugLogs, } from '../../services/debugLoggingClient'; import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder, IconAlertTriangle } from '@douyinfe/semi-icons'; import { debounce } from '../../utils'; import Headline from '../../components/headline/Headline.jsx'; import './GeneralSettings.less'; const { Text } = Typography; function formatFromTimestamp(ts) { const date = new Date(ts); return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`; } function formatFromTBackend(time) { if (time == null || time.length === 0) { return null; } const date = new Date(); const split = time.split(':'); date.setHours(split[0]); date.setMinutes(split[1]); return date.getTime(); } /** * Human-readable byte formatter used by the Debug tab's usage label. * @param {number} bytes * @returns {string} */ function formatBytes(bytes) { if (!Number.isFinite(bytes)) return String(bytes); if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; } /** * Compute the integer percentage that `used` represents of `total`, clamped to [0, 100]. * @param {number} used * @param {number} total * @returns {number} */ function percentOf(used, total) { if (!total || total <= 0) return 0; const pct = Math.round((used / total) * 100); if (pct < 0) return 0; if (pct > 100) return 100; return pct; } const GeneralSettings = function GeneralSettings() { const actions = useActions(); const t = useTranslation(); const [loading, setLoading] = React.useState(true); const settings = useSelector((state) => state.generalSettings.settings); const currentUser = useSelector((state) => state.user.currentUser); const language = useSelector((state) => state.userSettings.settings.language); const [interval, setInterval] = React.useState(''); const [proxyUrl, setProxyUrl] = React.useState(''); const [port, setPort] = React.useState(''); const [workingHourFrom, setWorkingHourFrom] = React.useState(null); const [workingHourTo, setWorkingHourTo] = React.useState(null); const [demoMode, setDemoMode] = React.useState(null); const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null); const [sqlitePath, setSqlitePath] = React.useState(null); const [baseUrl, setBaseUrl] = React.useState(''); const fileInputRef = React.useRef(null); const [restoreModalVisible, setRestoreModalVisible] = React.useState(false); const [precheckInfo, setPrecheckInfo] = React.useState(null); const [restoreBusy, setRestoreBusy] = React.useState(false); const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null); // Debug-logging tab state. status is fetched on mount + polled every 3s while the // feature is active so the progress bar reflects the live byte budget. // debugStatusSeq monotonically increases with every applied status update so we can // discard stale polling responses that arrive after a manual enable/disable. const [debugStatus, setDebugStatus] = React.useState(null); const [debugBusy, setDebugBusy] = React.useState(false); const [debugConfirmVisible, setDebugConfirmVisible] = React.useState(false); const [debugClearConfirmVisible, setDebugClearConfirmVisible] = React.useState(false); const debugStatusSeqRef = React.useRef(0); const applyDebugStatus = React.useCallback((fresh) => { debugStatusSeqRef.current += 1; setDebugStatus(fresh); }, []); // User settings state const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const providerDetails = useSelector((state) => state.userSettings.settings.provider_details); const blacklistFilterOnProviderDetails = useSelector( (state) => state.userSettings.settings.blacklist_filter_on_provider_details, ); const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference); const allProviders = useSelector((state) => state.provider); const [address, setAddress] = useState(homeAddress?.address || ''); const [coords, setCoords] = useState(homeAddress?.coords || null); const [listingDeleteHard, setListingDeleteHard] = useState(false); const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false); const saving = useIsLoading(actions.userSettings.setHomeAddress); const savingLanguage = useIsLoading(actions.userSettings.setLanguage); const [dataSource, setDataSource] = useState([]); React.useEffect(() => { async function init() { await actions.generalSettings.getGeneralSettings(); setLoading(false); } init(); }, []); React.useEffect(() => { async function init() { setInterval(settings?.interval); setProxyUrl(settings?.proxyUrl ?? ''); setPort(settings?.port); setWorkingHourFrom(settings?.workingHours?.from); setWorkingHourTo(settings?.workingHours?.to); setAnalyticsEnabled(settings?.analyticsEnabled || false); setDemoMode(settings?.demoMode || false); setSqlitePath(settings?.sqlitepath); setBaseUrl(settings?.baseUrl ?? ''); } init(); }, [settings]); useEffect(() => { setAddress(homeAddress?.address || ''); setCoords(homeAddress?.coords || null); }, [homeAddress]); useEffect(() => { setListingDeleteHard(listingDeletionPreference?.hardDelete ?? false); setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false); }, [listingDeletionPreference]); // Initial debug-status load. Subsequent updates flow through applyDebugStatus() // (called by polling + after every enable/disable action), so this effect only // needs to fire once on mount. useEffect(() => { let cancelled = false; fetchDebugStatus() .then((s) => { if (!cancelled) applyDebugStatus(s); }) .catch((e) => { // Non-fatal: tab is still usable, polling will retry. console.error('Failed to load debug status', e); }); return () => { cancelled = true; }; }, [applyDebugStatus]); // Live polling while the feature is active so the progress bar reflects new entries // as they are written. We intentionally do NOT poll while inactive — the size stays // constant and there's no Banner to update. Stale poll responses (where a manual // enable/disable bumped the sequence in the meantime) are discarded so the UI does // not flicker back to the previous state for ~3s. useEffect(() => { if (!debugStatus?.enabled) return undefined; const id = window.setInterval(async () => { const seqAtStart = debugStatusSeqRef.current; try { const fresh = await fetchDebugStatus(); if (debugStatusSeqRef.current === seqAtStart) { applyDebugStatus(fresh); } } catch { // ignore transient errors; next tick will retry } }, 3000); return () => window.clearInterval(id); }, [debugStatus?.enabled, applyDebugStatus]); const nullOrEmpty = (val) => val == null || val.length === 0; const handleStore = async () => { if (nullOrEmpty(interval)) { Toast.error(t('settings.toastIntervalEmpty')); return; } if (nullOrEmpty(port)) { Toast.error(t('settings.toastPortEmpty')); return; } if ( (!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) || (nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo)) ) { Toast.error(t('settings.toastWorkingHoursIncomplete')); return; } if (nullOrEmpty(sqlitePath)) { Toast.error(t('settings.toastSqlitePathEmpty')); return; } try { await xhrPost('/api/admin/generalSettings', { interval, proxyUrl: proxyUrl?.trim() ?? '', port, workingHours: { from: workingHourFrom, to: workingHourTo, }, demoMode, analyticsEnabled, sqlitepath: sqlitePath, baseUrl, }); } catch (exception) { console.error(exception); if (exception?.json?.message != null) { Toast.error(exception.json.message); } else { Toast.error(t('settings.toastSaveError')); } return; } Toast.success(t('settings.toastSavedReloading')); setTimeout(() => { location.reload(); }, 3000); }; const handleDownloadBackup = React.useCallback(async () => { try { await downloadBackupZip(); } catch (e) { console.error(e); Toast.error(t('settings.backupDownloadError')); } }, []); const precheckRestore = React.useCallback(async (file) => { try { const data = await clientPrecheckRestore(file); setPrecheckInfo(data); setRestoreModalVisible(true); } catch (e) { console.error(e); Toast.error(t('settings.backupAnalyzeError')); } }, []); const performRestore = React.useCallback( async (force) => { try { setRestoreBusy(true); await clientRestore(selectedRestoreFile, force); Toast.success(t('settings.backupRestoreCompleted')); } catch (e) { console.error(e); Toast.error(e?.message || t('settings.backupRestoreError')); } finally { setRestoreBusy(false); } }, [selectedRestoreFile], ); const handleSelectRestoreFile = React.useCallback( async (ev) => { const file = ev?.target?.files?.[0]; if (!file) return; setSelectedRestoreFile(file); await precheckRestore(file); ev.target.value = ''; }, [precheckRestore], ); const handleOpenFilePicker = React.useCallback(() => { if (fileInputRef.current) { fileInputRef.current.click(); } }, []); // ── Debug-logging actions ──────────────────────────────────────────────────── // performEnableDebug() centralizes the actual enable call so both branches of the // confirm dialog ("delete" vs. "keep") plus the no-confirm fast-path can share it. const performEnableDebug = React.useCallback( async ({ clearPrevious }) => { setDebugBusy(true); try { const fresh = await apiEnableDebugLogging({ clearPrevious }); applyDebugStatus(fresh); // Keep the global generalSettings store in sync so the app-wide red banner // (which reads settings.debug_logging_enabled) updates immediately. await actions.generalSettings.getGeneralSettings(); Toast.success(t('settings.debugToastEnabled')); } catch (e) { console.error(e); Toast.error(t('settings.debugToastEnableError')); } finally { setDebugBusy(false); setDebugConfirmVisible(false); } }, [actions.generalSettings, applyDebugStatus, t], ); const handleToggleDebugLogging = React.useCallback(async () => { // Guard against the initial-load race: if status hasn't arrived yet, ignore the // click. The button is also disabled when debugStatus == null, this is belt & // braces for the case where the click somehow reached the handler anyway. if (debugStatus == null) return; if (debugStatus.enabled) { setDebugBusy(true); try { const fresh = await apiDisableDebugLogging(); applyDebugStatus(fresh); await actions.generalSettings.getGeneralSettings(); Toast.success(t('settings.debugToastDisabled')); } catch (e) { console.error(e); Toast.error(t('settings.debugToastDisableError')); } finally { setDebugBusy(false); } return; } // Enabling: if logs from a previous session are still around, ask first. if (debugStatus.hasLogs) { setDebugConfirmVisible(true); return; } await performEnableDebug({ clearPrevious: false }); }, [debugStatus, performEnableDebug, actions.generalSettings, applyDebugStatus, t]); const handleDownloadDebugBundle = React.useCallback(async () => { try { await downloadDebugBundle(); } catch (e) { console.error(e); if (e?.code === 'NO_LOGS') { Toast.error(t('settings.debugToastNoLogs')); } else { Toast.error(t('settings.debugToastDownloadError')); } } }, [t]); // Deleting stored logs is a separate action from disabling the feature: the user can // free up the rolling buffer mid-recording without turning off collection. The // confirmation dialog makes the destructive nature explicit. const performClearDebugLogs = React.useCallback(async () => { setDebugBusy(true); try { const fresh = await apiClearDebugLogs(); applyDebugStatus(fresh); Toast.success(t('settings.debugToastCleared')); } catch (e) { console.error(e); Toast.error(t('settings.debugToastClearError')); } finally { setDebugBusy(false); setDebugClearConfirmVisible(false); } }, [applyDebugStatus, t]); const handleSaveUserSettings = async () => { try { const responseJson = await actions.userSettings.setHomeAddress(address); setCoords(responseJson.coords); await actions.userSettings.setListingDeletionPreference({ skipPrompt: listingDeleteSkipPrompt, hardDelete: listingDeleteHard, }); await actions.userSettings.getUserSettings(); Toast.success(t('settings.userSettingsSaved')); } catch (error) { Toast.error(error.json?.error || t('settings.userSettingsSaveError')); } }; const debouncedSearch = useMemo( () => debounce((value) => { xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`) .then((response) => { if (response.status === 200) { setDataSource(response.json); } }) .catch(() => {}); }, 300), [], ); const searchAddress = (value) => { if (!value) { setDataSource([]); return; } debouncedSearch(value); }; return (