mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 17:06:40 +00:00
fix(torrent): use live peer telemetry in properties
- source the Properties peer card from live Aria2 status counts - distinguish connected peers from unavailable peer details - remove redundant peer-summary IPC and harden count parsing - add responsive, accessible peer/seeder presentation and regressions
This commit is contained in:
@@ -9,7 +9,6 @@ import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilit
|
||||
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
||||
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
|
||||
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
||||
import type { TorrentPeerSummary } from '../bindings/TorrentPeerSummary';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import {
|
||||
PROPERTIES_WINDOW_ACTION_RESULT,
|
||||
@@ -48,11 +47,12 @@ import {
|
||||
formatPropertiesDiagnosticCount,
|
||||
getPropertiesAvailabilityDiagnosticState,
|
||||
getPropertiesPeerDiagnosticState,
|
||||
hasLiveTorrentPeerWithoutDetails,
|
||||
} from '../utils/propertiesDiagnostics';
|
||||
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
||||
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
||||
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
||||
import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from '../utils/propertiesPeerSummary';
|
||||
import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
|
||||
import { WindowControls } from './WindowControls';
|
||||
import {
|
||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||
@@ -68,7 +68,7 @@ const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'
|
||||
const isTorrentDiagnosticsStatus = (status: string) =>
|
||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
|
||||
|
||||
const isTorrentPollingStatus = isTorrentPeerSummaryStatus;
|
||||
const isTorrentPollingStatus = isTorrentLiveStatus;
|
||||
|
||||
const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status);
|
||||
|
||||
@@ -215,7 +215,7 @@ export const PropertiesWindowApp = () => {
|
||||
const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | null>(null);
|
||||
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
||||
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [peerSummary, setPeerSummary] = useState<TorrentPeerSummary | null>(null);
|
||||
const [peerDetailsUnavailable, setPeerDetailsUnavailable] = useState(false);
|
||||
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||
const [details, setDetails] = useState<TorrentDetails | null>(null);
|
||||
const [diagnosticError, setDiagnosticError] = useState('');
|
||||
@@ -269,13 +269,11 @@ export const PropertiesWindowApp = () => {
|
||||
const revealInFlightRef = useRef(false);
|
||||
const readyRetryTimerRef = useRef<number | undefined>(undefined);
|
||||
const diagnosticsInFlightRef = useRef(new Set<string>());
|
||||
const peerSummaryInFlightRef = useRef(new Set<string>());
|
||||
const snapshotRef = useRef(snapshot);
|
||||
const activeTabRef = useRef(activeTab);
|
||||
const downloadIdRef = useRef(downloadId);
|
||||
const fileProgressRef = useRef(fileProgress);
|
||||
const peersRef = useRef(peers);
|
||||
const peerSummaryRef = useRef(peerSummary);
|
||||
const availabilityRef = useRef(availability);
|
||||
const detailsRef = useRef(details);
|
||||
const diagnosticAttemptsRef = useRef(new Set<string>());
|
||||
@@ -293,7 +291,6 @@ export const PropertiesWindowApp = () => {
|
||||
downloadIdRef.current = downloadId;
|
||||
fileProgressRef.current = fileProgress;
|
||||
peersRef.current = peers;
|
||||
peerSummaryRef.current = peerSummary;
|
||||
availabilityRef.current = availability;
|
||||
detailsRef.current = details;
|
||||
|
||||
@@ -513,16 +510,15 @@ export const PropertiesWindowApp = () => {
|
||||
if (isCurrent()) {
|
||||
if (peerResult.status === 'fulfilled') {
|
||||
setPeers(peerResult.value);
|
||||
if (isTorrentPollingStatus(snapshotRef.current?.status ?? '')) {
|
||||
peerSummaryRef.current = {
|
||||
totalPeers: peerResult.value.totalPeers,
|
||||
totalSeeders: peerResult.value.totalSeeders,
|
||||
};
|
||||
setPeerSummary(peerSummaryRef.current);
|
||||
}
|
||||
setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails(
|
||||
snapshotRef.current?.torrentConnectedPeers,
|
||||
peerResult.value.totalPeers,
|
||||
));
|
||||
} else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) {
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails(
|
||||
snapshotRef.current?.torrentConnectedPeers,
|
||||
0,
|
||||
));
|
||||
}
|
||||
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
|
||||
const peerOutcome = peerResult.status === 'fulfilled'
|
||||
@@ -588,40 +584,6 @@ export const PropertiesWindowApp = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPeerSummary = useCallback(async (id: string) => {
|
||||
if (!isTorrentPollingStatus(snapshotRef.current?.status ?? '')) return;
|
||||
const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current;
|
||||
const requestKey = `${id}:${requestLifecycleEpoch}`;
|
||||
if (peerSummaryInFlightRef.current.has(requestKey)) return;
|
||||
peerSummaryInFlightRef.current.add(requestKey);
|
||||
const isCurrent = () => isCurrentTorrentPeerSummary({
|
||||
currentDownloadId: downloadIdRef.current,
|
||||
requestDownloadId: id,
|
||||
currentLifecycleEpoch: diagnosticLifecycleEpochRef.current,
|
||||
requestLifecycleEpoch,
|
||||
currentStatus: snapshotRef.current?.status ?? '',
|
||||
});
|
||||
try {
|
||||
const nextSummary = await invoke('get_torrent_peer_summary', { id });
|
||||
if (isCurrent()) {
|
||||
peerSummaryRef.current = nextSummary;
|
||||
setPeerSummary(nextSummary);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
// A GID replacement or terminal transition can invalidate an in-flight
|
||||
// summary after Aria2 has already answered. The next fenced poll will
|
||||
// acquire the new GID; do not turn that expected transition into a
|
||||
// repeating Properties error.
|
||||
if (isExpectedPropertiesDiagnosticUnavailable(error)) {
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
}
|
||||
} finally {
|
||||
peerSummaryInFlightRef.current.delete(requestKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let readyHeartbeatTimer: number | undefined;
|
||||
@@ -646,8 +608,6 @@ export const PropertiesWindowApp = () => {
|
||||
diagnosticLifecycleEpochRef.current += 1;
|
||||
diagnosticLifecycleKeyRef.current = '';
|
||||
diagnosticAttemptsRef.current.clear();
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
const lostAction = pendingActionRef.current;
|
||||
const lostDraftAction = lostAction === 'apply-properties'
|
||||
|| lostAction === 'set-torrent-file-selection';
|
||||
@@ -689,9 +649,8 @@ export const PropertiesWindowApp = () => {
|
||||
setDetails(null);
|
||||
setFileProgress(null);
|
||||
setPeers(null);
|
||||
setPeerDetailsUnavailable(false);
|
||||
setAvailability(null);
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
setDiagnosticError('');
|
||||
setDiagnosticsLoading(false);
|
||||
setDiagnosticsRefreshing(false);
|
||||
@@ -778,8 +737,6 @@ export const PropertiesWindowApp = () => {
|
||||
diagnosticLifecycleEpochRef.current += 1;
|
||||
diagnosticLifecycleKeyRef.current = '';
|
||||
diagnosticAttemptsRef.current.clear();
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
setSnapshot(null);
|
||||
draftTabRef.current = null;
|
||||
isDirtyRef.current = false;
|
||||
@@ -853,9 +810,8 @@ export const PropertiesWindowApp = () => {
|
||||
setDetails(null);
|
||||
setFileProgress(null);
|
||||
setPeers(null);
|
||||
setPeerDetailsUnavailable(false);
|
||||
setAvailability(null);
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
setDiagnosticError('');
|
||||
setDiagnosticsLoading(false);
|
||||
setDiagnosticsRefreshing(false);
|
||||
@@ -870,9 +826,8 @@ export const PropertiesWindowApp = () => {
|
||||
if (!isTorrentPollingStatus(snapshot.status)) {
|
||||
setFileProgress(null);
|
||||
setPeers(null);
|
||||
setPeerDetailsUnavailable(false);
|
||||
setAvailability(null);
|
||||
peerSummaryRef.current = null;
|
||||
setPeerSummary(null);
|
||||
diagnosticLifecycleEpochRef.current += 1;
|
||||
diagnosticAttemptsRef.current.clear();
|
||||
setDiagnosticPhase('idle');
|
||||
@@ -880,21 +835,16 @@ export const PropertiesWindowApp = () => {
|
||||
setAvailabilityDiagnosticPhase('idle');
|
||||
}
|
||||
void refreshDiagnostics(activeTab, downloadId);
|
||||
if (isTorrentPollingStatus(snapshot.status) && activeTab !== 'peers') {
|
||||
void refreshPeerSummary(downloadId);
|
||||
}
|
||||
const shouldPollDiagnostics = ['files', 'peers'].includes(activeTab);
|
||||
const shouldPollSummary = activeTab !== 'peers';
|
||||
if (!isTorrentPollingStatus(snapshot.status) || (!shouldPollDiagnostics && !shouldPollSummary)) return;
|
||||
if (!isTorrentPollingStatus(snapshot.status) || !shouldPollDiagnostics) return;
|
||||
// Match the 1-second cadence of the normal Aria2 progress poll. The
|
||||
// diagnostics request itself is still single-flight, so a slow RPC cannot
|
||||
// create overlapping refreshes.
|
||||
const interval = window.setInterval(() => {
|
||||
if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId);
|
||||
if (shouldPollSummary) void refreshPeerSummary(downloadId);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, refreshPeerSummary, snapshot?.status]);
|
||||
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -1194,18 +1144,40 @@ export const PropertiesWindowApp = () => {
|
||||
? t($ => $.addDownloads.unknownSize)
|
||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||
const statusLabel = t($ => $.downloads.status[snapshot.status]);
|
||||
const connectionPresentation = getPropertiesConnectionPresentation(snapshot, peerSummary);
|
||||
const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
||||
const connectionPresentation = getPropertiesConnectionPresentation(snapshot);
|
||||
const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
||||
? t($ => $.properties.fragmentConcurrency)
|
||||
: connectionPresentation.labelKey === 'torrentConnectedPeers'
|
||||
? t($ => $.properties.torrentConnectedPeers)
|
||||
: connectionPresentation.labelKey === 'torrentPeersSeeders'
|
||||
? t($ => $.properties.torrentPeersSeeders)
|
||||
: t($ => $.properties.connections);
|
||||
const connectionValue = connectionPresentation.torrentPeerSummary
|
||||
? t($ => $.properties.torrentPeerSummary, {
|
||||
total: connectionPresentation.torrentPeerSummary.totalPeers,
|
||||
seeders: connectionPresentation.torrentPeerSummary.totalSeeders,
|
||||
})
|
||||
: connectionPresentation.value;
|
||||
const connectionControlLabel = snapshot.isTorrent === true
|
||||
? t($ => $.properties.torrentConnectedPeers)
|
||||
: connectionHeaderLabel;
|
||||
const connectionValue: ReactNode = connectionPresentation.torrentPeerCounts
|
||||
? (() => {
|
||||
const peersValue = formatPropertiesDiagnosticCount(
|
||||
connectionPresentation.torrentPeerCounts.connectedPeers ?? Number.NaN,
|
||||
snapshot.appearance.locale,
|
||||
);
|
||||
const seedersValue = formatPropertiesDiagnosticCount(
|
||||
connectionPresentation.torrentPeerCounts.connectedSeeders ?? Number.NaN,
|
||||
snapshot.appearance.locale,
|
||||
);
|
||||
return <strong
|
||||
className="properties-torrent-peer-count"
|
||||
aria-label={t($ => $.properties.torrentConnectedPeerMetric, {
|
||||
peers: peersValue,
|
||||
seeders: seedersValue,
|
||||
})}
|
||||
>
|
||||
<span className="properties-torrent-peer-count-primary">{peersValue}</span>
|
||||
<span aria-hidden="true"> / </span>
|
||||
<span>{seedersValue}</span>
|
||||
</strong>;
|
||||
})()
|
||||
: <strong>{connectionPresentation.value}</strong>;
|
||||
const peerDetailsNotice = peerDetailsUnavailable
|
||||
&& (snapshot.torrentConnectedPeers ?? 0) > 0;
|
||||
const queuePlacement = formatPropertiesQueuePlacement(
|
||||
snapshot.queueName,
|
||||
snapshot.queuePosition,
|
||||
@@ -1297,7 +1269,7 @@ export const PropertiesWindowApp = () => {
|
||||
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span>{connectionLabel}</span><strong>{connectionValue}</strong></div></div>}
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span className={connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : undefined}>{connectionHeaderLabel}</span>{connectionValue}</div></div>}
|
||||
{isTorrent && <>
|
||||
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
||||
@@ -1449,8 +1421,12 @@ export const PropertiesWindowApp = () => {
|
||||
<div className="properties-diagnostic-heading">
|
||||
<div className="min-w-0">
|
||||
<span className="properties-diagnostic-label">{t($ => $.properties.torrentPeerDiagnostics)}</span>
|
||||
<p className="properties-diagnostic-value" data-value-state={peerDiagnosticState} role="status">
|
||||
{peers
|
||||
<p className="properties-diagnostic-value" data-value-state={peerDetailsNotice ? 'unavailable' : peerDiagnosticState} role="status">
|
||||
{peerDetailsNotice
|
||||
? t($ => $.properties.torrentPeerDetailsUnavailable, {
|
||||
connected: formatPropertiesDiagnosticCount(snapshot.torrentConnectedPeers ?? 0, snapshot.appearance.locale),
|
||||
})
|
||||
: peers
|
||||
? t($ => $.properties.torrentPeerCount, {
|
||||
total: formatPropertiesDiagnosticCount(peers.totalPeers, snapshot.appearance.locale),
|
||||
seeders: formatPropertiesDiagnosticCount(peers.totalSeeders, snapshot.appearance.locale),
|
||||
@@ -1491,13 +1467,13 @@ export const PropertiesWindowApp = () => {
|
||||
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<PropertiesField
|
||||
label={connectionLabel}
|
||||
label={connectionControlLabel}
|
||||
controlId="properties-transfer-concurrency"
|
||||
hint={snapshot.isMedia === true ? t($ => $.properties.fragmentConcurrencyHint) : undefined}
|
||||
className="max-w-md"
|
||||
>
|
||||
<div className="mt-2 flex items-center gap-3" dir="ltr">
|
||||
<input id="properties-transfer-concurrency" type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionLabel} />
|
||||
<input id="properties-transfer-concurrency" type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionControlLabel} />
|
||||
<span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span>
|
||||
</div>
|
||||
</PropertiesField>
|
||||
@@ -1655,7 +1631,7 @@ export const PropertiesWindowApp = () => {
|
||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<div><span className="text-text-muted">{connectionLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
||||
<div><span className="text-text-muted">{connectionHeaderLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.username)}</span><p className="mt-1">{snapshot.hasUsername ? '✓' : '—'}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.password)}</span><p className="mt-1">{snapshot.hasPassword ? '✓' : '—'}</p></div>
|
||||
|
||||
Reference in New Issue
Block a user