fix(core): harden download lifecycle and scheduling

This commit is contained in:
NimBold
2026-06-22 11:19:18 +03:30
parent d535bdac8f
commit 06b14df307
38 changed files with 1257 additions and 428 deletions
+108 -44
View File
@@ -9,7 +9,7 @@ import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database,
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
@@ -72,8 +72,10 @@ export const AddDownloadsModal = () => {
const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
const [resolvedLocation, setResolvedLocation] = useState('');
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form
@@ -104,7 +106,10 @@ export const AddDownloadsModal = () => {
setParsedItems([]);
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setPendingDestinationOverrides({});
setConnections(perServerConnections);
setSpeedLimitEnabled(false);
setSpeedLimit('1024');
setUseAuth(false);
setUsername('');
setPassword('');
@@ -119,6 +124,7 @@ export const AddDownloadsModal = () => {
setCookies(pendingAddCookies);
setMirrors('');
setIsQueueMenuOpen(false);
setIsSubmitting(false);
} else {
setUrls('');
}
@@ -211,8 +217,8 @@ export const AddDownloadsModal = () => {
const mediaData = await fetchMediaMetadataDeduped({
url,
cookieBrowser: browserArg,
username: login?.username || null,
password: keychainPassword
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword
});
if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => {
@@ -235,7 +241,7 @@ export const AddDownloadsModal = () => {
});
updatedItems[i] = {
url,
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
size: mappedFormats[0].detail,
sizeBytes: mappedFormats[0].bytes,
status: 'Ready',
@@ -260,12 +266,14 @@ export const AddDownloadsModal = () => {
const meta = await invoke('fetch_metadata', {
url,
userAgent: settingsStore.customUserAgent || null,
username: login?.username || null,
password: keychainPassword
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword
});
updatedItems[i] = {
url: meta.url || url,
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
file: canonicalizeDownloadFileName(
lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename
),
size: meta.size,
sizeBytes: meta.size_bytes,
status: 'Ready'
@@ -301,7 +309,15 @@ export const AddDownloadsModal = () => {
active = false;
clearTimeout(timer);
};
}, [urls, pendingAddFilename, isSaveLocationManual, metadataRefreshNonce]);
}, [
urls,
pendingAddFilename,
isSaveLocationManual,
metadataRefreshNonce,
useAuth,
username,
password
]);
if (!isAddModalOpen) return null;
@@ -327,25 +343,41 @@ export const AddDownloadsModal = () => {
};
const handleAction = async (action: AddDownloadAction) => {
if (isSubmitting || parsedItems.length === 0 || parsedItems.some(item => item.status !== 'Ready')) {
return;
}
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true });
return;
}
setIsSubmitting(true);
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
const settings = useSettingsStore.getState();
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: finalLocation.startsWith('~') ? undefined : finalLocation
});
if (selected && typeof selected === 'string') {
finalLocation = selected;
useSharedDestination = true;
setIsSaveLocationManual(true);
} else {
return; // Cancelled
for (const [index, item] of parsedItems.entries()) {
try {
const suggestedLocation = isSaveLocationManual
? finalLocation
: await categoryLocationForFile(item.file);
const selected = await open({
directory: true,
multiple: false,
title: `Choose a folder for ${item.file}`,
defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
});
if (selected && typeof selected === 'string') {
destinationOverrides[index] = selected;
} else {
setIsSubmitting(false);
return;
}
} catch (e) {
console.error("Failed to select folder:", e);
setIsSubmitting(false);
return;
}
} catch (e) {
console.error("Failed to select folder:", e);
}
}
@@ -355,7 +387,7 @@ export const AddDownloadsModal = () => {
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -363,7 +395,7 @@ export const AddDownloadsModal = () => {
}
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
: destinationOverrides[i] || await categoryLocationForFile(finalFile);
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) {
@@ -400,14 +432,26 @@ export const AddDownloadsModal = () => {
setConflicts(newConflicts);
setPendingAction(action);
setPendingUseSharedDestination(useSharedDestination);
setPendingDestinationOverrides(destinationOverrides);
setShowingDuplicates(true);
setIsSubmitting(false);
return;
}
await executeAddDownloads(action, finalLocation, useSharedDestination);
try {
await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides);
} finally {
setIsSubmitting(false);
}
};
const executeAddDownloads = async (action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
const executeAddDownloads = async (
action: AddDownloadAction,
finalLocation: string,
useSharedDestination: boolean,
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) {
@@ -420,7 +464,7 @@ export const AddDownloadsModal = () => {
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -428,7 +472,7 @@ export const AddDownloadsModal = () => {
}
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -471,7 +515,7 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = null;
continue;
}
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -479,7 +523,7 @@ export const AddDownloadsModal = () => {
}
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
const store = useDownloadStore.getState();
@@ -510,14 +554,14 @@ export const AddDownloadsModal = () => {
}
}
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
let addedCount = 0;
const failures: string[] = [];
for (const item of resolvedItems) {
for (const [itemIndex, item] of itemsToAdd.entries()) {
if (!item) continue;
try {
const id = crypto.randomUUID();
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
let formatSelector = undefined;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
@@ -546,7 +590,9 @@ export const AddDownloadsModal = () => {
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination ? finalLocation : undefined,
destination: useSharedDestination
? finalLocation
: destinationOverrides[itemIndex],
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
@@ -598,16 +644,20 @@ export const AddDownloadsModal = () => {
selectedItem.size = format.detail || 'Unknown';
selectedItem.sizeBytes = format.bytes || 0;
const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file;
selectedItem.file = `${baseName}.${format.ext}`;
selectedItem.file = canonicalizeDownloadFileName(`${baseName}.${format.ext}`);
setParsedItems(newItems);
};
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = parsedItems.some(item =>
item.formats?.[item.selectedFormat ?? -1]?.isApproximate
);
const requiredStr = requiredBytes > 0
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
? `${hasApproximateSize ? '~' : ''}${requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`)
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
: 'Unknown';
const canSubmit = parsedItems.length > 0 && parsedItems.every(item => item.status === 'Ready');
return (
<>
@@ -616,14 +666,22 @@ export const AddDownloadsModal = () => {
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
void executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions)
setIsSubmitting(true);
void executeAddDownloads(
pendingAction,
resolvedLocation,
pendingUseSharedDestination,
resolutions,
pendingDestinationOverrides
)
.catch(error => {
addToast({
message: `Could not resolve duplicate downloads: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
})
.finally(() => setIsSubmitting(false));
}}
onCancel={() => setShowingDuplicates(false)}
/>
@@ -652,7 +710,9 @@ export const AddDownloadsModal = () => {
onChange={(e) => setUrls(e.target.value)}
/>
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">{parsedItems.length} valid link(s) detected</span>
<span className="text-[11px] text-text-muted font-medium">
{parsedItems.filter(item => item.status === 'Ready').length} ready, {parsedItems.filter(item => item.status === 'Error').length} failed
</span>
<button
type="button"
onClick={() => setMetadataRefreshNonce(value => value + 1)}
@@ -836,7 +896,7 @@ export const AddDownloadsModal = () => {
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
<div className="flex items-center gap-2">
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50" disabled={parsedItems.some(i => i.isMedia)} aria-label="Connections per file" />
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label="Connections per file" />
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
</div>
</div>
@@ -924,7 +984,11 @@ export const AddDownloadsModal = () => {
{/* Footer */}
<div className="add-download-footer p-4 flex items-center shrink-0">
<div className="text-[11px] text-text-muted font-medium flex-1">
{parsedItems.length === 0 ? "Paste one or more links." : `Ready to add ${parsedItems.length} download(s).`}
{parsedItems.length === 0
? 'Paste one or more links.'
: canSubmit
? `Ready to add ${parsedItems.length} download(s).`
: 'Wait for metadata or remove links that failed validation.'}
</div>
<div className="flex gap-2.5">
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
@@ -933,7 +997,7 @@ export const AddDownloadsModal = () => {
<div ref={actionMenuRef} className="relative flex gap-2.5">
<button
onClick={() => handleAction({ type: 'start-now' })}
disabled={parsedItems.length === 0}
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-primary px-5 text-xs"
>
<Play size={12} fill="currentColor" /> Start Downloads
@@ -942,7 +1006,7 @@ export const AddDownloadsModal = () => {
<button
type="button"
onClick={() => setIsQueueMenuOpen(open => !open)}
disabled={parsedItems.length === 0}
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-secondary px-4 text-xs"
aria-label="Add to queue"
aria-haspopup="menu"
+24 -11
View File
@@ -31,9 +31,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onClick,
}) => {
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
const pendingOrder = useDownloadStore(state => state.pendingOrder);
const queueItems = useDownloadStore(state => {
const item = state.downloads.find(candidate => candidate.id === downloadId);
if (!item) return [];
const queueId = item.queueId;
return state.downloads
.filter(candidate =>
candidate.queueId === queueId &&
candidate.status !== 'completed'
)
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(candidate => candidate.id);
});
const moveInQueue = useDownloadStore(state => state.moveInQueue);
const queueIndex = pendingOrder.indexOf(downloadId);
const queueIndex = queueItems.indexOf(downloadId);
const progressBarRef = useRef<HTMLDivElement>(null);
const statusTextRef = useRef<HTMLSpanElement>(null);
@@ -106,7 +117,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'processing' ? 'processing' :
download.status === 'queued' ? 'queued' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
}`}
style={{ width: `${(download.fraction || 0) * 100}%` }}
@@ -115,8 +126,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<span
ref={statusTextRef}
title={
download.status === 'queued' && queueIndex !== -1
? `Queued #${queueIndex + 1}`
(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
: download.status === 'downloading'
? `${((download.fraction || 0) * 100).toFixed(0)}%`
: download.status === 'processing'
@@ -128,14 +139,16 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' ? 'download-status-queued' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
}`}
>
{download.status === 'queued' && queueIndex !== -1 ? (
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
<>
<Clock size={12} className="animate-pulse shrink-0" />
<span className="truncate">Queued #{queueIndex + 1}</span>
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
<span className="truncate">
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' ? (
`${((download.fraction || 0) * 100).toFixed(0)}%`
@@ -181,7 +194,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
onDoubleClick={(e) => e.stopPropagation()}
>
{download.status === 'queued' && queueIndex !== -1 && (
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => moveInQueue(download.id, 'up')}
@@ -193,7 +206,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</button>
<button
onClick={() => moveInQueue(download.id, 'down')}
disabled={queueIndex === pendingOrder.length - 1}
disabled={queueIndex === queueItems.length - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down"
>
+8 -13
View File
@@ -338,7 +338,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</div>
<div className="download-table-body">
<div className="h-full overflow-auto flex flex-col">
<div className="download-table-list">
{filteredDownloads.map((d, index) => (
<DownloadItemComponent
key={d.id}
@@ -354,16 +354,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
onClick={handleItemClick}
/>
))}
{Array.from({ length: Math.max(0, 50 - filteredDownloads.length) }).map((_, i) => {
const globalIndex = filteredDownloads.length + i;
return (
<div
key={`ghost-${i}`}
className={`download-ghost-row ${globalIndex % 2 !== 0 ? 'striped' : ''}`}
/>
);
})}
<div className="flex-1 bg-transparent pointer-events-none"></div>
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
</div>
</div>
</div>
@@ -411,7 +402,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => (
<button key={q.id} onClick={() => {
setContextMenu(null);
assignToQueue(Array.from(selectedIds), q.id);
void assignToQueue(Array.from(selectedIds), q.id).catch(error => {
showInteractionError('Could not move downloads to queue', error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
@@ -526,7 +519,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => (
<button key={q.id} onClick={() => {
setContextMenu(null);
assignToQueue([contextItem.id], q.id);
void assignToQueue([contextItem.id], q.id).catch(error => {
showInteractionError('Could not move download to queue', error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { attachLogger } from '@tauri-apps/plugin-log';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { FileDown, Trash2, Terminal, Filter } from 'lucide-react';
@@ -11,39 +10,46 @@ interface LogEntry {
message: string;
}
const getLevelStr = (level: number): LogEntry['level'] => {
switch (level) {
case 1: return 'Trace';
case 2: return 'Debug';
case 3: return 'Info';
case 4: return 'Warn';
case 5: return 'Error';
default: return 'Debug';
}
};
export default function DiagnosticsView() {
export default function LogsView() {
const { addToast } = useToast();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const scrollRef = useRef<HTMLDivElement>(null);
const rawLineCountRef = useRef(0);
const clearedThroughRef = useRef(0);
const lastSnapshotRef = useRef('');
const MAX_LOG_LINES = 2000;
useEffect(() => {
let active = true;
const unlistenPromise = attachLogger((logRecord) => {
if (!active) return;
const level = getLevelStr(logRecord.level);
const message = logRecord.message;
if (message.includes('[download]') && message.includes('%')) return;
setLogs(prev => {
const next = [...prev, { level, message }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
const refresh = async () => {
try {
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
if (!active) return;
if (lines.length < clearedThroughRef.current) {
clearedThroughRef.current = 0;
}
const snapshot = `${lines.length}:${lines[lines.length - 1] || ''}`;
if (snapshot === lastSnapshotRef.current) return;
lastSnapshotRef.current = snapshot;
rawLineCountRef.current = lines.length;
setLogs(lines.slice(clearedThroughRef.current).map(message => {
const level = message.includes('[ERROR]') ? 'Error'
: message.includes('[WARN]') ? 'Warn'
: message.includes('[INFO]') ? 'Info'
: message.includes('[TRACE]') ? 'Trace'
: 'Debug';
return { level, message };
}));
} catch {
if (active) setLogs([]);
}
};
void refresh();
const interval = window.setInterval(refresh, 2000);
return () => {
active = false;
void unlistenPromise.then(unlisten => unlisten()).catch(() => undefined);
window.clearInterval(interval);
};
}, []);
@@ -56,19 +62,22 @@ export default function DiagnosticsView() {
const handleExport = async () => {
try {
const path = await save({
defaultPath: 'Firelink-Diagnostics.log',
defaultPath: 'Firelink-Support-Logs.log',
filters: [{ name: 'Log Files', extensions: ['log'] }],
});
if (!path) return;
await invoke('export_logs', { destPath: path });
addToast({ message: 'Diagnostics exported', variant: 'success' });
addToast({ message: 'Support logs exported', variant: 'success' });
} catch (e) {
console.error('Export failed:', e);
addToast({ message: `Could not export diagnostics: ${String(e)}`, variant: 'error', isActionable: true });
addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
}
};
const handleClear = () => setLogs([]);
const handleClear = () => {
clearedThroughRef.current = rawLineCountRef.current;
setLogs([]);
};
const severityClass = (level: string) => {
switch (level) {
@@ -80,14 +89,14 @@ export default function DiagnosticsView() {
};
return (
<div className="diagnostics-view flex-1 flex flex-col h-full overflow-hidden">
<div className="logs-view flex-1 flex flex-col h-full overflow-hidden">
<WindowDragRegion />
{/* Toolbar */}
<div className="diagnostics-toolbar flex items-center justify-between px-4 py-2 shrink-0">
<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">Diagnostics Console</span>
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
</div>
<div className="flex items-center gap-3">
@@ -110,7 +119,7 @@ export default function DiagnosticsView() {
<button
onClick={handleClear}
className="app-icon-button"
title="Clear console"
title="Clear displayed logs"
>
<Trash2 size={14} />
</button>
@@ -126,9 +135,9 @@ export default function DiagnosticsView() {
</div>
{/* Console */}
<div ref={scrollRef} className="diagnostics-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5]">
<div ref={scrollRef} className="logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5]">
{logs.length === 0 && (
<div className="text-text-muted italic select-none">Waiting for log entries...</div>
<div className="text-text-muted italic select-none">No persisted log entries are available yet.</div>
)}
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
<div key={i} className={`log-line ${severityClass(entry.level)}`}>
+73 -11
View File
@@ -26,6 +26,11 @@ const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[
{ value: 'shutdown', label: 'Shut down', icon: Power },
];
const minuteOfDay = (value: string) => {
const [hour, minute] = value.split(':').map(Number);
return hour * 60 + minute;
};
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
@@ -55,6 +60,7 @@ export default function SchedulerView() {
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
const queues = useDownloadStore(state => state.queues);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
const { addToast } = useToast();
const [permissionMessage, setPermissionMessage] = useState('');
@@ -81,12 +87,41 @@ export default function SchedulerView() {
}));
};
const availableQueueIds = new Set(queues.map(queue => queue.id));
const selectedQueueIds = draft.selectedQueueIds.filter(queueId => availableQueueIds.has(queueId));
const effectiveSelectedQueueIds = selectedQueueIds.length > 0
? selectedQueueIds
: [MAIN_QUEUE_ID];
const toggleQueue = (queueId: string) => {
setDraft(current => {
const isSelected = current.selectedQueueIds.includes(queueId);
const availableSelectionCount = current.selectedQueueIds
.filter(id => availableQueueIds.has(id))
.length;
if (isSelected && availableSelectionCount === 1) return current;
return {
...current,
selectedQueueIds: isSelected
? current.selectedQueueIds.filter(id => id !== queueId)
: [...current.selectedQueueIds, queueId]
};
});
};
const save = () => {
if (!draft.everyday && draft.selectedDays.length === 0) {
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
return;
}
if (draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
return;
}
const normalized = {
...draft,
selectedDays: draft.everyday || draft.selectedDays.length > 0
? draft.selectedDays
: savedSettings.selectedDays
selectedDays: draft.selectedDays,
selectedQueueIds: effectiveSelectedQueueIds
};
setScheduler(normalized);
setDraft(normalized);
@@ -94,18 +129,27 @@ export default function SchedulerView() {
};
const runNow = async () => {
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
const results = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
const count = results.reduce((total, ids) => total + ids.length, 0);
const acceptedIds = results.flat();
if (count > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
useSettingsStore.getState().setSchedulerActiveDownloadIds(acceptedIds);
addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' });
} else {
addToast({ message: 'No paused or failed downloads to start', variant: 'info' });
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
}
};
const pauseNow = async () => {
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
const counts = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' });
};
@@ -230,6 +274,9 @@ export default function SchedulerView() {
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
</div>
</div>
<p className="mt-4 text-[11px] text-text-muted">
If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.
</p>
<div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
@@ -262,11 +309,26 @@ export default function SchedulerView() {
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
<List size={17} className="text-accent" /> Queues to Schedule
</div>
<label className="flex items-center gap-3 text-[13px] text-text-primary">
<input type="checkbox" checked readOnly disabled={!draft.enabled} className="accent-accent" />
Main Queue
<span className="text-[11px] text-text-muted">All paused and failed downloads</span>
</label>
<div className="space-y-3">
{queues.map(queue => {
const selected = draft.selectedQueueIds.includes(queue.id);
return (
<label key={queue.id} className="flex items-center gap-3 text-[13px] text-text-primary">
<input
type="checkbox"
checked={selected}
onChange={() => toggleQueue(queue.id)}
disabled={!draft.enabled || (selected && selectedQueueIds.length === 1)}
className="accent-accent"
/>
{queue.name}
{queue.isMain && (
<span className="text-[11px] text-text-muted">Default queue</span>
)}
</label>
);
})}
</div>
</section>
<section className="app-card p-5">
+1 -1
View File
@@ -177,7 +177,7 @@ export default function SettingsView() {
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
// Local state for engine diagnostics
// Local state for engine status
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
+14 -2
View File
@@ -8,6 +8,7 @@ import {
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -20,6 +21,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onSelectFilter } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
@@ -236,7 +238,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
<ToolItem icon={Bug} label="Diagnostics" view="diagnostics" />
<ToolItem icon={Bug} label="Logs" view="logs" />
</section>
</div>
@@ -294,7 +296,17 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
onClick={() => { removeQueue(contextMenu.id); setContextMenu(null); }}
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void removeQueue(queueId).catch(error => {
addToast({
message: `Could not delete queue: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}}
>
<Trash2 size={14} className="mr-2" />
Delete Queue