feat(tools): harden scheduler speed limiter logs

Modernize the Tools release surfaces for the cross-platform re-release checklist.

- make Speed Limiter presets unit-aware and persist custom removable quick presets

- keep diagnostic logging opt-in on clean installs and honor the saved log state on startup

- stage Scheduler enablement edits without discarding unsaved draft changes

- extend persisted settings and bindings for log opt-in and speed preset state

Verification:

- npm run build

- npm test -- --run src/store/useDownloadStore.test.ts

- cargo test --lib

- cargo test settings --lib
This commit is contained in:
NimBold
2026-07-03 22:21:16 +03:30
parent ef877463da
commit 84a4ff4bfc
10 changed files with 264 additions and 73 deletions
+112 -17
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { Gauge, Save, Zap } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
type SpeedUnit = 'KB/s' | 'MB/s';
const MAX_LIMIT_KIB = 10_485_760;
const MAX_LIMIT_MB = 10240;
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
const valueKiB = match
@@ -17,28 +20,57 @@ function parseLimit(limit: string, fallback: number): { value: number; unit: Spe
: { value: valueKiB, unit: 'KB/s' };
}
function sanitizePresetValues(values: number[]): number[] {
const cleaned = values
.map(value => Number(value))
.filter(value => Number.isFinite(value) && value > 0)
.map(value => Math.min(MAX_LIMIT_MB, Math.round(value * 100) / 100));
return Array.from(new Set(cleaned)).sort((a, b) => a - b);
}
function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : value / 1000;
}
function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : Math.round(value * 1000);
}
function formatPresetValue(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
}
export default function SpeedLimiterView() {
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const speedLimitPresetValues = useSettingsStore(state => state.speedLimitPresetValues);
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
const setSpeedLimitPresetValues = useSettingsStore(state => state.setSpeedLimitPresetValues);
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
const [value, setValue] = useState(initial.value);
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [customPresetValue, setCustomPresetValue] = useState(initial.value);
const { addToast } = useToast();
const presetValues = useMemo(
() => sanitizePresetValues(speedLimitPresetValues),
[speedLimitPresetValues]
);
useEffect(() => {
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
setEnabled(Boolean(globalSpeedLimit));
setValue(parsed.value);
setUnit(parsed.unit);
setCustomPresetValue(parsed.value);
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
const save = () => {
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : 10_485_760));
const valueKiB = Math.min(10_485_760, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : MAX_LIMIT_KIB));
const valueKiB = Math.min(MAX_LIMIT_KIB, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
addToast({
@@ -49,8 +81,33 @@ export default function SpeedLimiterView() {
const preset = (presetValue: number) => {
setEnabled(true);
setValue(presetValue);
setUnit('MB/s');
setValue(displayValueFromPresetBase(presetValue, unit));
};
const applyCustomPreset = () => {
const numericValue = Math.max(1, Math.min(Number(customPresetValue) || 1, unit === 'MB/s' ? MAX_LIMIT_MB : MAX_LIMIT_KIB));
const presetBaseValue = Math.min(MAX_LIMIT_MB, presetBaseFromDisplayValue(numericValue, unit));
const nextPresets = sanitizePresetValues([...presetValues, presetBaseValue]);
const alreadyExists = nextPresets.length === presetValues.length;
setSpeedLimitPresetValues(nextPresets);
setEnabled(true);
setValue(numericValue);
addToast({
message: alreadyExists
? `${formatPresetValue(numericValue)} ${unit} is already in quick presets`
: `Added ${formatPresetValue(numericValue)} ${unit} quick preset`,
variant: alreadyExists ? 'info' : 'success'
});
};
const removePreset = (presetValue: number) => {
const displayValue = displayValueFromPresetBase(presetValue, unit);
const nextPresets = presetValues.filter(value => value !== presetValue);
setSpeedLimitPresetValues(nextPresets);
addToast({
message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`,
variant: 'info'
});
};
return (
@@ -82,13 +139,12 @@ export default function SpeedLimiterView() {
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[720px] p-5 ${enabled ? '' : 'opacity-50'}`}>
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> Global Speed Limit
</div>
<p className="max-w-xl text-[12px] leading-relaxed text-text-muted">
This cap is shared by transfers running through the core downloader. A lower per-download limit still takes precedence.
Saving updates the core downloader immediately; media extraction keeps its existing per-download options.
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
Applies to new and active aria2 transfers, native fallback transfers, and yt-dlp media downloads. Per-download limits still take precedence.
</p>
<div className="mt-6 flex items-center gap-3">
@@ -121,18 +177,57 @@ export default function SpeedLimiterView() {
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
<Zap size={14} /> Quick Presets
</div>
<div className="flex flex-wrap gap-2">
{[1, 5, 10].map(presetValue => (
<div className="flex flex-wrap items-center gap-2">
{presetValues.map(presetValue => {
const displayValue = displayValueFromPresetBase(presetValue, unit);
return (
<div
key={presetValue}
className="group flex h-8 min-w-[92px] items-center overflow-hidden rounded-md border border-border-modal bg-bg-input text-[12px] text-text-primary transition-colors hover:bg-item-hover"
>
<button
type="button"
disabled={!enabled}
onClick={() => preset(presetValue)}
className="h-full flex-1 px-3 text-left disabled:opacity-50"
>
{formatPresetValue(displayValue)} {unit}
</button>
<button
type="button"
disabled={!enabled}
onClick={() => removePreset(presetValue)}
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
>
<X size={12} />
</button>
</div>
);
})}
<div className="ml-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
<input
type="number"
min="1"
value={customPresetValue}
disabled={!enabled}
onChange={event => setCustomPresetValue(Math.max(1, Number(event.target.value) || 1))}
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
aria-label={`Custom preset in ${unit}`}
/>
<span className="text-[11px] text-text-muted">{unit}</span>
<button
key={presetValue}
type="button"
disabled={!enabled}
onClick={() => preset(presetValue)}
className="app-button px-4 text-[12px] disabled:opacity-50"
onClick={applyCustomPreset}
className="app-icon-button h-6 w-6 disabled:opacity-50"
title="Add quick preset"
aria-label="Add quick preset"
>
{presetValue} MB/s
<Plus size={14} />
</button>
))}
</div>
</div>
</section>
</div>