mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
80a29356e0
Guard scheduler system actions against pending work, serialize diagnostic log transitions, and keep speed-limit saves truthful after backend failures.\n\nNo linked issue was found for this audit.
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { invokeCommand as invoke } from '../ipc';
|
|
import { save } from '@tauri-apps/plugin-dialog';
|
|
import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger';
|
|
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
|
|
import { WindowDragRegion } from './WindowDragRegion';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useSettingsStore } from '../store/useSettingsStore';
|
|
import {
|
|
MAX_LOG_LINES,
|
|
appendBoundedLogEntries,
|
|
liveLogEntry,
|
|
mergeLogSnapshotAndLiveEntries,
|
|
persistedLogEntry,
|
|
pushBoundedLogEntry,
|
|
type LogEntry
|
|
} from '../utils/logEntries';
|
|
|
|
export default function LogsView() {
|
|
const { addToast } = useToast();
|
|
const logsEnabled = useSettingsStore(state => state.logsEnabled);
|
|
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
|
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const liveBatchRef = useRef<LogEntry[]>([]);
|
|
const liveFrameRef = useRef<number | null>(null);
|
|
const clearGenerationRef = useRef(0);
|
|
const clearInFlightRef = useRef<Promise<void> | null>(null);
|
|
const toggleInFlightRef = useRef<Promise<void> | null>(null);
|
|
const [isClearing, setIsClearing] = useState(false);
|
|
const [isToggling, setIsToggling] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!pageVisible) {
|
|
void setLogStreamActive(false).catch(console.error);
|
|
return;
|
|
}
|
|
|
|
if (!logsEnabled) {
|
|
void setLogStreamActive(false).catch(console.error);
|
|
}
|
|
|
|
let active = true;
|
|
let initialized = false;
|
|
const initGeneration = clearGenerationRef.current;
|
|
let pendingLiveEntries: LogEntry[] = [];
|
|
let unlistenPromise: Promise<() => void> | undefined;
|
|
|
|
const scheduleLiveEntry = (entry: LogEntry) => {
|
|
if (!initialized) {
|
|
pushBoundedLogEntry(pendingLiveEntries, entry);
|
|
return;
|
|
}
|
|
|
|
pushBoundedLogEntry(liveBatchRef.current, entry);
|
|
if (liveFrameRef.current !== null) return;
|
|
liveFrameRef.current = window.requestAnimationFrame(() => {
|
|
liveFrameRef.current = null;
|
|
if (!active || liveBatchRef.current.length === 0) return;
|
|
const batch = liveBatchRef.current;
|
|
liveBatchRef.current = [];
|
|
setLogs(current => appendBoundedLogEntries(current, batch));
|
|
});
|
|
};
|
|
|
|
const init = async () => {
|
|
try {
|
|
await initLogger();
|
|
if (!active) return;
|
|
|
|
if (logsEnabled) {
|
|
unlistenPromise = attachLogger((log) => {
|
|
if (!active) return;
|
|
scheduleLiveEntry(liveLogEntry(log.level, log.message));
|
|
});
|
|
await unlistenPromise;
|
|
if (!active) return;
|
|
await setLogStreamActive(true);
|
|
if (!active) {
|
|
await setLogStreamActive(false).catch(console.error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
|
|
if (!active) return;
|
|
const snapshot = lines.map(persistedLogEntry);
|
|
initialized = true;
|
|
if (initGeneration !== clearGenerationRef.current) {
|
|
pendingLiveEntries = [];
|
|
return;
|
|
}
|
|
const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries);
|
|
pendingLiveEntries = [];
|
|
setLogs(caughtUpLogs);
|
|
} catch (e) {
|
|
console.error('Failed to init logs:', e);
|
|
}
|
|
};
|
|
void init();
|
|
|
|
return () => {
|
|
active = false;
|
|
liveBatchRef.current = [];
|
|
if (liveFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(liveFrameRef.current);
|
|
liveFrameRef.current = null;
|
|
}
|
|
if (logsEnabled) {
|
|
void setLogStreamActive(false).catch(console.error);
|
|
}
|
|
if (unlistenPromise) {
|
|
void unlistenPromise.then(unlisten => unlisten()).catch(console.error);
|
|
}
|
|
};
|
|
}, [logsEnabled, pageVisible]);
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [logs]);
|
|
|
|
useEffect(() => {
|
|
const handleCloseMenu = () => setContextMenu(null);
|
|
const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') setContextMenu(null); };
|
|
window.addEventListener('click', handleCloseMenu);
|
|
window.addEventListener('keydown', handleEscape);
|
|
return () => {
|
|
window.removeEventListener('click', handleCloseMenu);
|
|
window.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, []);
|
|
|
|
const handleContextMenu = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
const selection = window.getSelection()?.toString();
|
|
if (selection && selection.trim().length > 0) {
|
|
setContextMenu({ x: e.clientX, y: e.clientY, text: selection });
|
|
} else {
|
|
setContextMenu(null);
|
|
}
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (contextMenu?.text) {
|
|
try {
|
|
await navigator.clipboard.writeText(contextMenu.text);
|
|
addToast({ message: 'Copied to clipboard', variant: 'success' });
|
|
} catch (err) {
|
|
console.error('Clipboard write error:', err);
|
|
addToast({ message: 'Failed to copy to clipboard', variant: 'error' });
|
|
}
|
|
}
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const handleExport = async () => {
|
|
try {
|
|
const path = await save({
|
|
defaultPath: 'Firelink-Support-Logs.log',
|
|
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
|
});
|
|
if (!path) return;
|
|
await invoke('export_logs', { destination: path });
|
|
addToast({ message: 'Support logs exported', variant: 'success' });
|
|
} catch (e) {
|
|
console.error('Export failed:', e);
|
|
addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
|
|
}
|
|
};
|
|
|
|
const handleClear = async () => {
|
|
if (clearInFlightRef.current) return;
|
|
|
|
const clearOperation = invoke('clear_logs');
|
|
clearInFlightRef.current = clearOperation;
|
|
setIsClearing(true);
|
|
try {
|
|
await clearOperation;
|
|
clearGenerationRef.current += 1;
|
|
liveBatchRef.current = [];
|
|
if (liveFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(liveFrameRef.current);
|
|
liveFrameRef.current = null;
|
|
}
|
|
setLogs([]);
|
|
addToast({ message: 'Logs cleared', variant: 'info' });
|
|
} catch (error) {
|
|
addToast({
|
|
message: `Could not clear logs: ${String(error)}`,
|
|
variant: 'error',
|
|
isActionable: true
|
|
});
|
|
} finally {
|
|
if (clearInFlightRef.current === clearOperation) {
|
|
clearInFlightRef.current = null;
|
|
}
|
|
setIsClearing(false);
|
|
}
|
|
};
|
|
|
|
const handleToggleLogging = async () => {
|
|
if (toggleInFlightRef.current) return;
|
|
|
|
const nextEnabled = !logsEnabled;
|
|
const toggleOperation = (async () => {
|
|
await setLogPaused(!nextEnabled);
|
|
setLogsEnabled(nextEnabled);
|
|
addToast({
|
|
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
|
|
variant: 'success'
|
|
});
|
|
})();
|
|
toggleInFlightRef.current = toggleOperation;
|
|
setIsToggling(true);
|
|
try {
|
|
await toggleOperation;
|
|
} catch (error) {
|
|
addToast({
|
|
message: `Could not update diagnostic logging: ${String(error)}`,
|
|
variant: 'error',
|
|
isActionable: true
|
|
});
|
|
} finally {
|
|
if (toggleInFlightRef.current === toggleOperation) {
|
|
toggleInFlightRef.current = null;
|
|
}
|
|
setIsToggling(false);
|
|
}
|
|
};
|
|
|
|
const severityClass = (level: string) => {
|
|
switch (level) {
|
|
case 'Error': return 'log-error';
|
|
case 'Warn': return 'log-warn';
|
|
case 'Info': return 'log-info';
|
|
default: return 'log-debug';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="logs-view flex-1 flex flex-col h-full overflow-hidden">
|
|
<WindowDragRegion />
|
|
|
|
{/* Toolbar */}
|
|
<div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
|
|
<div className="flex items-center gap-2 text-text-secondary">
|
|
<Terminal size={16} strokeWidth={1.8} />
|
|
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
|
|
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
|
logsEnabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
|
}`}>
|
|
{logsEnabled ? 'Collecting' : 'Off'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<Filter size={13} className="text-text-muted" />
|
|
<select
|
|
value={levelFilter}
|
|
onChange={e => setLevelFilter(e.target.value as LogEntry['level'] | 'All')}
|
|
className="bg-bg-input border border-border-modal rounded px-1.5 py-0.5 text-[11px] text-text-primary focus:outline-none focus:border-accent"
|
|
>
|
|
<option value="All">All Levels</option>
|
|
<option value="Error">Error</option>
|
|
<option value="Warn">Warn</option>
|
|
<option value="Info">Info</option>
|
|
<option value="Debug">Debug</option>
|
|
<option value="Trace">Trace</option>
|
|
</select>
|
|
</div>
|
|
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
|
|
<button
|
|
onClick={handleToggleLogging}
|
|
disabled={isToggling}
|
|
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
|
|
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
|
|
>
|
|
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
disabled={isClearing}
|
|
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
|
|
title="Clear displayed logs"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
<button
|
|
onClick={handleExport}
|
|
className="app-button px-3 text-[11px] gap-1.5"
|
|
title="Export logs"
|
|
>
|
|
<FileDown size={13} />
|
|
Export Logs
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Privacy Hint */}
|
|
<div className="bg-black/10 border-y border-border-modal px-4 py-2 shrink-0 flex items-center gap-2 text-text-muted text-[10px] select-none">
|
|
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
|
|
<span className="opacity-90 leading-tight">
|
|
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
|
|
Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
|
|
</span>
|
|
</div>
|
|
|
|
{/* Console */}
|
|
<div
|
|
ref={scrollRef}
|
|
onContextMenu={handleContextMenu}
|
|
className="logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5] select-text"
|
|
style={{ userSelect: 'text', WebkitUserSelect: 'text' }}
|
|
>
|
|
{logs.length === 0 && (
|
|
<div className="text-text-muted italic select-none">
|
|
{logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'}
|
|
</div>
|
|
)}
|
|
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
|
|
<div key={i} className={`log-line ${severityClass(entry.level)}`}>
|
|
<span className="log-level-tag">[{entry.level}]</span>
|
|
<span className="log-message">{entry.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Context Menu */}
|
|
{contextMenu && (
|
|
<div
|
|
role="menu"
|
|
className="app-modal fixed z-50 min-w-[150px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary"
|
|
style={{ left: Math.min(contextMenu.x, window.innerWidth - 150), top: Math.min(contextMenu.y, window.innerHeight - 50) }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
className="w-full text-left px-3 py-2 flex items-center hover:bg-item-hover transition-colors"
|
|
onClick={handleCopy}
|
|
>
|
|
<Copy size={13} className="mr-2 text-text-secondary" />
|
|
Copy
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|