mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
adding ability to record logs for debug purposes
This commit is contained in:
@@ -30,6 +30,7 @@ import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||
import ListingDetail from './views/listings/ListingDetail.jsx';
|
||||
import NewsModal from './components/news/NewsModal.jsx';
|
||||
import { I18nProvider, availableLanguages } from './services/i18n/i18n.jsx';
|
||||
import DebugLoggingBanner from './components/debug/DebugLoggingBanner.jsx';
|
||||
|
||||
const semiLocaleModules = import.meta.glob('/node_modules/@douyinfe/semi-ui-19/lib/es/locale/source/*.js', {
|
||||
eager: true,
|
||||
@@ -96,6 +97,7 @@ export default function FredyApp() {
|
||||
<Layout className="app__main">
|
||||
<Content className="app__content">
|
||||
{versionUpdate?.newVersion && <VersionBanner />}
|
||||
<DebugLoggingBanner />
|
||||
{settings.demoMode && (
|
||||
<>
|
||||
<Banner
|
||||
|
||||
58
ui/src/components/debug/DebugLoggingBanner.jsx
Normal file
58
ui/src/components/debug/DebugLoggingBanner.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Banner } from '@douyinfe/semi-ui-19';
|
||||
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
import { fetchDebugActive } from '../../services/debugLoggingClient.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 15000;
|
||||
|
||||
/**
|
||||
* Persistent, non-dismissable red banner shown on every page while the admin opt-in
|
||||
* "Debug Logging" feature is active. Polls the lightweight `/api/debug/active` probe
|
||||
* so every authenticated user (not just admins) sees the warning, without exposing
|
||||
* the rest of the settings payload.
|
||||
*
|
||||
* Polling interval is intentionally generous (15s) because the value only changes
|
||||
* when an admin toggles the feature, which happens at human speeds. The Debug tab
|
||||
* itself uses its own 3s polling for the live progress bar inside Settings.
|
||||
*
|
||||
* @returns {JSX.Element|null}
|
||||
*/
|
||||
export default function DebugLoggingBanner() {
|
||||
const t = useTranslation();
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetchDebugActive();
|
||||
if (!cancelled) setActive(Boolean(res?.enabled));
|
||||
} catch {
|
||||
// Best-effort probe: an unauthenticated 401 (e.g. session expired) simply
|
||||
// hides the banner until the next successful poll.
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!active) return null;
|
||||
return (
|
||||
<>
|
||||
<Banner fullMode={true} type="danger" bordered closeIcon={null} description={t('app.debugLoggingBanner')} />
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DebugLoggingBanner.displayName = 'DebugLoggingBanner';
|
||||
@@ -295,6 +295,7 @@
|
||||
"settings.tabExecution": "Ausführung",
|
||||
"settings.tabUserSettings": "Benutzereinstellungen",
|
||||
"settings.tabBackup": "Backup & Wiederherstellung",
|
||||
"settings.tabDebug": "Debug",
|
||||
"settings.save": "Speichern",
|
||||
"settings.port": "Port",
|
||||
"settings.portHelp": "Der Port, auf dem Fredy läuft.",
|
||||
@@ -365,6 +366,36 @@
|
||||
"settings.toastSqlitePathEmpty": "Der SQLite-Datenbankpfad darf nicht leer sein.",
|
||||
"settings.toastSavedReloading": "Einstellungen erfolgreich gespeichert. Der Browser wird in 3 Sekunden neu geladen.",
|
||||
"settings.toastSaveError": "Fehler beim Speichern der Einstellungen.",
|
||||
"settings.debugSectionName": "Debug-Logging",
|
||||
"settings.debugInfoTitle": "Was wird aufgezeichnet?",
|
||||
"settings.debugInfoDescription": "Wenn aktiviert, schreibt Fredy jede Log-Zeile (debug, info, warn und error) in seine Datenbank. Maximal 5 MB werden gespeichert; sobald die Grenze erreicht ist, werden die ältesten Einträge automatisch gelöscht. Die Konsolen-Ausgabe bleibt unverändert.",
|
||||
"settings.debugEnableButton": "Debug-Logging aktivieren",
|
||||
"settings.debugDisableButton": "Debug-Logging deaktivieren",
|
||||
"settings.debugDownloadButton": "Debug Informationen herunterladen",
|
||||
"settings.debugUsedLabel": "Belegt:",
|
||||
"settings.debugUsedValue": "{{used}} von {{max}} ({{percent}}%)",
|
||||
"settings.debugStatusActive": "Debug-Logging ist aktiv!",
|
||||
"settings.debugStatusInactive": "Debug-Logging ist inaktiv.",
|
||||
"settings.debugConfirmReenableTitle": "Vorherige Logs löschen?",
|
||||
"settings.debugConfirmReenableMessage": "Es sind noch Debug-Logs aus einer vorherigen Sitzung gespeichert. Möchtest du sie löschen, bevor das Debug-Logging erneut aktiviert wird?",
|
||||
"settings.debugConfirmKeep": "Behalten & fortfahren",
|
||||
"settings.debugConfirmDelete": "Löschen & aktivieren",
|
||||
"settings.debugToastEnabled": "Debug-Logging aktiviert.",
|
||||
"settings.debugToastDisabled": "Debug-Logging deaktiviert.",
|
||||
"settings.debugToastEnableError": "Debug-Logging konnte nicht aktiviert werden.",
|
||||
"settings.debugToastDisableError": "Debug-Logging konnte nicht deaktiviert werden.",
|
||||
"settings.debugToastDownloadError": "Debug-Paket konnte nicht heruntergeladen werden.",
|
||||
"settings.debugToastNoLogs": "Es sind noch keine Debug-Logs vorhanden. Aktiviere das Debug-Logging und reproduziere das Problem.",
|
||||
"settings.debugClearButton": "Gespeicherte Debug-Logs löschen",
|
||||
"settings.debugClearConfirmTitle": "Alle gespeicherten Debug-Logs löschen?",
|
||||
"settings.debugClearConfirmMessage": "Damit werden alle gespeicherten Debug-Log-Einträge dauerhaft aus der Datenbank entfernt. Die Aufzeichnung selbst bleibt {{recordingState}}. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"settings.debugClearConfirmRecordingOn": "Aktiv",
|
||||
"settings.debugClearConfirmRecordingOff": "Inaktiv",
|
||||
"settings.debugClearConfirmDelete": "Ja, Logs löschen",
|
||||
"settings.debugClearConfirmCancel": "Abbrechen",
|
||||
"settings.debugToastCleared": "Gespeicherte Debug-Logs wurden gelöscht.",
|
||||
"settings.debugToastClearError": "Gespeicherte Debug-Logs konnten nicht gelöscht werden.",
|
||||
"app.debugLoggingBanner": "Debug-Logging ist aktiv! Alles was Fredy loggt, wird in der Datenbank gespeichert. Deaktiviere es unter Einstellungen → Debug, sobald du fertig bist.",
|
||||
|
||||
"watchlist.sectionName": "Benachrichtigung für Watchlist",
|
||||
"watchlist.sectionHelp": "Du kannst bei Änderungen an Inseraten auf deiner Watchlist benachrichtigt werden.",
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"settings.tabExecution": "Execution",
|
||||
"settings.tabUserSettings": "User Settings",
|
||||
"settings.tabBackup": "Backup & Restore",
|
||||
"settings.tabDebug": "Debug",
|
||||
"settings.save": "Save",
|
||||
"settings.port": "Port",
|
||||
"settings.portHelp": "The port on which Fredy is running.",
|
||||
@@ -365,6 +366,36 @@
|
||||
"settings.toastSqlitePathEmpty": "SQLite db path cannot be empty.",
|
||||
"settings.toastSavedReloading": "Settings stored successfully. We will reload your browser in 3 seconds.",
|
||||
"settings.toastSaveError": "Error while trying to store settings.",
|
||||
"settings.debugSectionName": "Debug Logging",
|
||||
"settings.debugInfoTitle": "What gets recorded?",
|
||||
"settings.debugInfoDescription": "When enabled, Fredy records every log line (debug, info, warn and error) into its database. A maximum of 5 MB is kept; once the cap is reached, the oldest entries are dropped automatically. Console output is unaffected.",
|
||||
"settings.debugEnableButton": "Enable debug logging",
|
||||
"settings.debugDisableButton": "Disable debug logging",
|
||||
"settings.debugDownloadButton": "Download debug information",
|
||||
"settings.debugUsedLabel": "Used:",
|
||||
"settings.debugUsedValue": "{{used}} of {{max}} ({{percent}}%)",
|
||||
"settings.debugStatusActive": "Debug logging is currently active.",
|
||||
"settings.debugStatusInactive": "Debug logging is currently inactive.",
|
||||
"settings.debugConfirmReenableTitle": "Delete previous logs?",
|
||||
"settings.debugConfirmReenableMessage": "Debug logs from a previous session are still stored. Do you want to delete them before enabling debug logging again?",
|
||||
"settings.debugConfirmKeep": "Keep & continue",
|
||||
"settings.debugConfirmDelete": "Delete & enable",
|
||||
"settings.debugToastEnabled": "Debug logging enabled.",
|
||||
"settings.debugToastDisabled": "Debug logging disabled.",
|
||||
"settings.debugToastEnableError": "Could not enable debug logging.",
|
||||
"settings.debugToastDisableError": "Could not disable debug logging.",
|
||||
"settings.debugToastDownloadError": "Could not download the debug bundle.",
|
||||
"settings.debugToastNoLogs": "No debug logs available yet. Enable debug logging first and reproduce the issue.",
|
||||
"settings.debugClearButton": "Delete stored debug logs",
|
||||
"settings.debugClearConfirmTitle": "Delete all stored debug logs?",
|
||||
"settings.debugClearConfirmMessage": "This permanently removes every stored debug log entry from the database. Recording itself will stay {{recordingState}}. This action cannot be undone.",
|
||||
"settings.debugClearConfirmRecordingOn": "ON",
|
||||
"settings.debugClearConfirmRecordingOff": "OFF",
|
||||
"settings.debugClearConfirmDelete": "Yes, delete logs",
|
||||
"settings.debugClearConfirmCancel": "Cancel",
|
||||
"settings.debugToastCleared": "Stored debug logs were deleted.",
|
||||
"settings.debugToastClearError": "Could not delete the stored debug logs.",
|
||||
"app.debugLoggingBanner": "Debug logging is active! Everything Fredy logs is being stored in its database. Disable it in Settings → Debug once you're done.",
|
||||
|
||||
"watchlist.sectionName": "Notification for Watch List",
|
||||
"watchlist.sectionHelp": "You can get notified for changes on listings from your watch list.",
|
||||
|
||||
124
ui/src/services/debugLoggingClient.js
Normal file
124
ui/src/services/debugLoggingClient.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tiny client wrapping the /api/admin/debug endpoints.
|
||||
*
|
||||
* The server returns the same status payload from every mutation endpoint so the UI
|
||||
* does not need to re-fetch after enable/disable, it can apply the response payload
|
||||
* directly.
|
||||
*/
|
||||
|
||||
function extractFileNameFromDisposition(disposition) {
|
||||
const dispo = disposition || '';
|
||||
// RFC 6266 says the UTF-8 encoded `filename*=` form takes precedence over the
|
||||
// legacy `filename=` form when both are present. Match each form independently
|
||||
// and prefer the UTF-8 one so we cannot accidentally pick the wrong encoding.
|
||||
const utf8Match = dispo.match(/filename\*=UTF-8''([^;]+)/);
|
||||
if (utf8Match) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch {
|
||||
// malformed percent-encoding; fall through to the legacy form
|
||||
}
|
||||
}
|
||||
const legacyMatch = dispo.match(/filename="?([^";]+)"?/);
|
||||
if (legacyMatch) return legacyMatch[1];
|
||||
return 'FredyDebug.zip';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current feature status. Requires admin auth.
|
||||
* @returns {Promise<{enabled:boolean, size:number, max:number, hasLogs:boolean, everEnabled:boolean}>}
|
||||
*/
|
||||
export async function fetchDebugStatus() {
|
||||
const resp = await fetch('/api/admin/debug/status', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Failed to load debug logging status');
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight "is debug logging active right now?" probe usable by any authenticated
|
||||
* user. Used by the app-wide red banner so non-admin users also see the warning. The
|
||||
* payload is intentionally a single boolean, no other settings are exposed.
|
||||
*
|
||||
* @returns {Promise<{enabled:boolean}>}
|
||||
*/
|
||||
export async function fetchDebugActive() {
|
||||
const resp = await fetch('/api/debug/active', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Failed to load debug active flag');
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the feature. When clearPrevious is true, existing log rows are dropped
|
||||
* before the new collection starts.
|
||||
* @param {{clearPrevious?:boolean}} [options]
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function enableDebugLogging({ clearPrevious = false } = {}) {
|
||||
const resp = await fetch('/api/admin/debug/enable', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clearPrevious }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to enable debug logging');
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the feature. Existing logs remain on disk so they can still be downloaded.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function disableDebugLogging() {
|
||||
const resp = await fetch('/api/admin/debug/disable', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to disable debug logging');
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every stored debug log row. Does NOT change the enabled flag: if recording
|
||||
* was on, it stays on and the table simply starts filling again. Returns the new
|
||||
* status payload.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function clearDebugLogs() {
|
||||
const resp = await fetch('/api/admin/debug/logs', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to clear debug logs');
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the debug bundle download. Throws when there is nothing to export (server
|
||||
* returns 409 in that case) or any other non-2xx response.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function downloadDebugBundle() {
|
||||
const resp = await fetch('/api/admin/debug/download', { credentials: 'include' });
|
||||
if (resp.status === 409) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data?.error || 'No debug logs available yet');
|
||||
err.code = 'NO_LOGS';
|
||||
throw err;
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to download debug bundle');
|
||||
const blob = await resp.blob();
|
||||
const fileName = extractFileNameFromDisposition(resp.headers.get('Content-Disposition'));
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
Progress,
|
||||
} from '@douyinfe/semi-ui-19';
|
||||
import { InputNumber } from '@douyinfe/semi-ui-19';
|
||||
import { xhrPost, xhrGet } from '../../services/xhr';
|
||||
@@ -32,7 +33,14 @@ import {
|
||||
precheckRestore as clientPrecheckRestore,
|
||||
restore as clientRestore,
|
||||
} from '../../services/backupRestoreClient';
|
||||
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
|
||||
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';
|
||||
@@ -55,6 +63,32 @@ function formatFromTBackend(time) {
|
||||
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();
|
||||
@@ -79,6 +113,20 @@ const GeneralSettings = function GeneralSettings() {
|
||||
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);
|
||||
@@ -127,6 +175,45 @@ const GeneralSettings = function GeneralSettings() {
|
||||
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 () => {
|
||||
@@ -231,6 +318,89 @@ const GeneralSettings = function GeneralSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── 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);
|
||||
@@ -572,6 +742,98 @@ const GeneralSettings = function GeneralSettings() {
|
||||
</SegmentPart>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
{currentUser?.isAdmin && (
|
||||
<TabPane
|
||||
tab={
|
||||
<span>
|
||||
<IconAlertTriangle
|
||||
size="small"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
color: debugStatus?.enabled ? 'var(--semi-color-danger)' : undefined,
|
||||
}}
|
||||
/>
|
||||
{t('settings.tabDebug')}
|
||||
</span>
|
||||
}
|
||||
itemKey="debug"
|
||||
>
|
||||
<div className="generalSettings__tab-content">
|
||||
<SegmentPart name={t('settings.debugSectionName')}>
|
||||
<Banner
|
||||
type="info"
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 12 }}
|
||||
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('settings.debugInfoTitle')}</div>}
|
||||
description={t('settings.debugInfoDescription')}
|
||||
/>
|
||||
|
||||
{debugStatus?.enabled ? (
|
||||
<Banner
|
||||
type="danger"
|
||||
fullMode={false}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 12 }}
|
||||
description={
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{t('settings.debugStatusActive')}</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ marginRight: 8 }}>
|
||||
{t('settings.debugUsedLabel')}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('settings.debugUsedValue', {
|
||||
used: formatBytes(debugStatus.size),
|
||||
max: formatBytes(debugStatus.max),
|
||||
percent: percentOf(debugStatus.size, debugStatus.max),
|
||||
})}
|
||||
</Text>
|
||||
<Progress
|
||||
percent={percentOf(debugStatus.size, debugStatus.max)}
|
||||
stroke="var(--semi-color-danger)"
|
||||
aria-label="debug log storage"
|
||||
style={{ marginTop: 6 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary">{t('settings.debugStatusInactive')}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
theme="solid"
|
||||
type={debugStatus?.enabled ? 'danger' : 'primary'}
|
||||
loading={debugBusy}
|
||||
disabled={debugStatus == null}
|
||||
onClick={handleToggleDebugLogging}
|
||||
>
|
||||
{debugStatus?.enabled ? t('settings.debugDisableButton') : t('settings.debugEnableButton')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
icon={<IconSave />}
|
||||
disabled={debugStatus == null || !debugStatus?.everEnabled || !debugStatus?.hasLogs}
|
||||
onClick={handleDownloadDebugBundle}
|
||||
>
|
||||
{t('settings.debugDownloadButton')}
|
||||
</Button>
|
||||
{debugStatus?.hasLogs && (
|
||||
<Button theme="solid" type="warning" onClick={() => setDebugClearConfirmVisible(true)}>
|
||||
{t('settings.debugClearButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SegmentPart>
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
@@ -621,6 +883,65 @@ const GeneralSettings = function GeneralSettings() {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{debugConfirmVisible && (
|
||||
<Modal
|
||||
title={t('settings.debugConfirmReenableTitle')}
|
||||
visible={debugConfirmVisible}
|
||||
onCancel={() => {
|
||||
// Defensive reset in case a network blip left debugBusy stuck while the
|
||||
// user dismissed the dialog via the X / backdrop.
|
||||
setDebugBusy(false);
|
||||
setDebugConfirmVisible(false);
|
||||
}}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => performEnableDebug({ clearPrevious: false })} loading={debugBusy}>
|
||||
{t('settings.debugConfirmKeep')}
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={() => performEnableDebug({ clearPrevious: true })}
|
||||
loading={debugBusy}
|
||||
>
|
||||
{t('settings.debugConfirmDelete')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>{t('settings.debugConfirmReenableMessage')}</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{debugClearConfirmVisible && (
|
||||
<Modal
|
||||
title={t('settings.debugClearConfirmTitle')}
|
||||
visible={debugClearConfirmVisible}
|
||||
onCancel={() => {
|
||||
setDebugBusy(false);
|
||||
setDebugClearConfirmVisible(false);
|
||||
}}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setDebugClearConfirmVisible(false)} disabled={debugBusy}>
|
||||
{t('settings.debugClearConfirmCancel')}
|
||||
</Button>
|
||||
<Button type="warning" theme="solid" onClick={performClearDebugLogs} loading={debugBusy}>
|
||||
{t('settings.debugClearConfirmDelete')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{t('settings.debugClearConfirmMessage', {
|
||||
recordingState: debugStatus?.enabled
|
||||
? t('settings.debugClearConfirmRecordingOn')
|
||||
: t('settings.debugClearConfirmRecordingOff'),
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user