diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 48b2dcc18..e6f04efbf 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -692,6 +692,8 @@ impl Session { } }; + let (stats, down_updater, up_updater) = SessionStats::new(); + let session = Arc::new(Self { persistence, bitv_factory, @@ -711,7 +713,7 @@ impl Session { reqwest_client, connector: stream_connector, root_span: opts.root_span, - stats: Arc::new(SessionStats::new()), + stats: Arc::new(stats), concurrent_initialize_semaphore: Arc::new(tokio::sync::Semaphore::new( opts.concurrent_init_limit.unwrap_or(3), )), @@ -810,7 +812,7 @@ impl Session { } } - session.start_speed_estimator_updater(); + session.start_speed_estimator_updater(down_updater, up_updater); Ok(session) } diff --git a/crates/librqbit/src/session_stats/mod.rs b/crates/librqbit/src/session_stats/mod.rs index ba8b06205..f46205d2c 100644 --- a/crates/librqbit/src/session_stats/mod.rs +++ b/crates/librqbit/src/session_stats/mod.rs @@ -4,7 +4,7 @@ use std::{ }; use anyhow::Context; -use librqbit_core::speed_estimator::SpeedEstimator; +use librqbit_core::speed_estimator::{SpeedEstimator, Updater}; use snapshot::SessionStatsSnapshot; use tracing::debug_span; @@ -28,25 +28,29 @@ pub struct SessionStats { } impl SessionStats { - pub fn new() -> Self { - SessionStats { - counters: Default::default(), - peers: Default::default(), - down_speed_estimator: SpeedEstimator::new(5), - up_speed_estimator: SpeedEstimator::new(5), - startup_time: Instant::now(), - } - } -} - -impl Default for SessionStats { - fn default() -> Self { - Self::new() + pub fn new() -> (Self, Updater, Updater) { + let (down, down_upd) = SpeedEstimator::new(5); + let (up, up_upd) = SpeedEstimator::new(5); + ( + SessionStats { + counters: Default::default(), + peers: Default::default(), + down_speed_estimator: down, + up_speed_estimator: up, + startup_time: Instant::now(), + }, + down_upd, + up_upd, + ) } } impl Session { - pub(crate) fn start_speed_estimator_updater(self: &Arc) { + pub(crate) fn start_speed_estimator_updater( + self: &Arc, + mut down_upd: Updater, + mut up_upd: Updater, + ) { self.spawn( debug_span!(parent: self.rs(), "speed_estimator"), "speed_estimator", @@ -61,10 +65,8 @@ impl Session { let now = Instant::now(); let fetched = s.stats.counters.fetched_bytes.load(Ordering::Relaxed); let uploaded = s.stats.counters.uploaded_bytes.load(Ordering::Relaxed); - s.stats - .down_speed_estimator - .add_snapshot(fetched, None, now); - s.stats.up_speed_estimator.add_snapshot(uploaded, None, now); + down_upd.add_snapshot(fetched, None, now); + up_upd.add_snapshot(uploaded, None, now); } } }, diff --git a/crates/librqbit/src/torrent_state/live/mod.rs b/crates/librqbit/src/torrent_state/live/mod.rs index 8736f5889..47ee504f0 100644 --- a/crates/librqbit/src/torrent_state/live/mod.rs +++ b/crates/librqbit/src/torrent_state/live/mod.rs @@ -225,8 +225,8 @@ impl TorrentStateLive { .upgrade() .context("session is dead, cannot start torrent")?; let session_stats = session.stats.clone(); - let down_speed_estimator = SpeedEstimator::default(); - let up_speed_estimator = SpeedEstimator::default(); + let (down_speed_estimator, mut down_se_updater) = SpeedEstimator::new(10); + let (up_speed_estimator, mut up_se_updater) = SpeedEstimator::new(10); let have_bytes = paused.chunk_tracker.get_hns().have_bytes; let lengths = *paused.chunk_tracker.get_lengths(); @@ -308,13 +308,9 @@ impl TorrentStateLive { let stats = state.stats_snapshot(); let fetched = stats.fetched_bytes; let remaining = state.locked.read().get_chunks()?.get_remaining_bytes(); - state - .down_speed_estimator - .add_snapshot(fetched, Some(remaining), now); - state - .up_speed_estimator - .add_snapshot(stats.uploaded_bytes, None, now); - tokio::time::sleep(Duration::from_millis(100)).await; + down_se_updater.add_snapshot(fetched, Some(remaining), now); + up_se_updater.add_snapshot(stats.uploaded_bytes, None, now); + tokio::time::sleep(Duration::from_secs(500)).await; } } }, diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 930e017ce..39de98d83 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -2,6 +2,33 @@ export interface TorrentId { id: number; info_hash: string; + name: string; + output_folder: string; +} + +export interface PeerCounters { + incoming_connections: number; + fetched_bytes: number; + uploaded_bytes: number; + total_time_connecting_ms: number; + connection_attempts: number; + connections: number; + errors: number; + fetched_chunks: number; + downloaded_and_checked_pieces: number; + total_piece_download_ms: number; + times_stolen_from_me: number; + times_i_stole: number; +} + +export interface PeerStats { + counters: PeerCounters; + state: string; + conn_kind: string | null; +} + +export interface PeerStatsSnapshot { + peers: Record; } export interface TorrentFile { @@ -26,6 +53,10 @@ export interface TorrentDetails { files: Array; } +export interface TorrentIdWithStats extends TorrentId { + stats: TorrentStats; +} + export interface AddTorrentResponse { id: number | null; details: TorrentDetails; @@ -34,7 +65,7 @@ export interface AddTorrentResponse { } export interface ListTorrentsResponse { - torrents: Array; + torrents: Array; } export interface Speed { @@ -203,25 +234,29 @@ export interface JSONLogLine { } export interface RqbitAPI { - getPlaylistUrl: (index: number) => string | null; - getStreamLogsUrl: () => string | null; listTorrents: () => Promise; getTorrentDetails: (index: number) => Promise; getTorrentStats: (index: number) => Promise; - getTorrentStreamUrl: ( + + getTorrentPeerStats: ( index: number, - file_id: number, - filename?: string | null, - ) => string | null; + state?: "all" | "live", + ) => Promise; + stats: () => Promise; uploadTorrent: ( - data: string | File, + data: File | string, opts?: AddTorrentOptions, ) => Promise; - - pause: (index: number) => Promise; updateOnlyFiles: (index: number, files: number[]) => Promise; + pause: (index: number) => Promise; start: (index: number) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; - stats: () => Promise; + getTorrentStreamUrl: ( + index: number, + file_id: number, + filename?: string | null, + ) => string; + getPlaylistUrl: (index: number) => string; + getStreamLogsUrl: () => string; } diff --git a/crates/librqbit/webui/src/components/CompactTorrentRow.tsx b/crates/librqbit/webui/src/components/CompactTorrentRow.tsx new file mode 100644 index 000000000..b8483bbac --- /dev/null +++ b/crates/librqbit/webui/src/components/CompactTorrentRow.tsx @@ -0,0 +1,103 @@ +import { useMemo } from "react"; +import { TorrentIdWithStats, STATE_INITIALIZING } from "../api-types"; +import { formatBytes } from "../helper/formatBytes"; +import { getCompletionETA } from "../helper/getCompletionETA"; +import { useTorrentStore } from "../stores/torrentStore"; +import { ProgressBar } from "./ProgressBar"; +import { StatusIcon } from "./StatusIcon"; + +export const CompactTorrentRow: React.FC<{ + torrent: TorrentIdWithStats; +}> = ({ torrent }) => { + const selected = useTorrentStore((state) => + state.selectedTorrentIds.includes(torrent.id), + ); + const setSelectedTorrentId = useTorrentStore( + (state) => state.setSelectedTorrentId, + ); + const toggleSelectedTorrentId = useTorrentStore( + (state) => state.toggleSelectedTorrentId, + ); + const statsResponse = torrent.stats; + const error = statsResponse?.error ?? null; + const finished = statsResponse?.finished || false; + const totalBytes = statsResponse?.total_bytes ?? 1; + const progressBytes = statsResponse?.progress_bytes ?? 0; + const progressPercentage = error + ? 100 + : totalBytes == 0 + ? 100 + : (progressBytes / totalBytes) * 100; + + const formatPeersString = () => { + let peer_stats = statsResponse?.live?.snapshot.peer_stats; + if (!peer_stats) { + return ""; + } + return `${peer_stats.live} / ${peer_stats.seen}`; + }; + + const onClick = useMemo(() => { + return (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + if (event.metaKey || event.ctrlKey || event.shiftKey) { + toggleSelectedTorrentId(torrent.id); + } else { + setSelectedTorrentId(torrent.id); + } + }; + }, [torrent.id]); + + return ( + + {torrent.id} + + + + + {torrent.name} + + + {error ? ( + Error + ) : ( + + )} + + + {statsResponse.live?.download_speed.human_readable} + + + {statsResponse.live?.upload_speed.human_readable} + + + {getCompletionETA(statsResponse)} + + + {formatPeersString()} + + + {formatBytes(totalBytes)} + + + ); +}; diff --git a/crates/librqbit/webui/src/components/FileListInput.tsx b/crates/librqbit/webui/src/components/FileListInput.tsx index 120743d65..ba3c9dcfc 100644 --- a/crates/librqbit/webui/src/components/FileListInput.tsx +++ b/crates/librqbit/webui/src/components/FileListInput.tsx @@ -25,13 +25,13 @@ type FileTree = { const newFileTree = ( torrentDetails: TorrentDetails, - stats: TorrentStats | null + stats: TorrentStats | null, ): FileTree => { const newFileTreeInner = ( name: string, id: string, files: TorrentFileForCheckbox[], - depth: number + depth: number, ): FileTree => { let directFiles: TorrentFileForCheckbox[] = []; let groups: FileTree[] = []; @@ -54,7 +54,7 @@ const newFileTree = ( let sortedGroupsByName = sortBy( Object.entries(groupsByName), - ([k, _]) => k + ([k, _]) => k, ); let childId = 0; @@ -87,7 +87,7 @@ const newFileTree = ( }; }) .filter((f) => f !== null), - 0 + 0, ); }; @@ -177,7 +177,7 @@ const FileTreeComponent: React.FC<{ label={`${ tree.name ? tree.name + ", " : "" } ${getTotalSelectedFiles()} files, ${formatBytes( - getTotalSelectedBytes() + getTotalSelectedBytes(), )}`} name={tree.id} onChange={handleToggleTree} @@ -253,7 +253,7 @@ export const FileListInput: React.FC<{ }) => { let fileTree = useMemo( () => newFileTree(torrentDetails, torrentStats), - [torrentDetails, torrentStats] + [torrentDetails, torrentStats], ); return ( diff --git a/crates/librqbit/webui/src/components/LogLine.tsx b/crates/librqbit/webui/src/components/LogLine.tsx index ee14ed306..5e204a363 100644 --- a/crates/librqbit/webui/src/components/LogLine.tsx +++ b/crates/librqbit/webui/src/components/LogLine.tsx @@ -83,7 +83,9 @@ export const LogLine: React.FC<{ line: JSONLogLine }> = React.memo( - {parsed.spans?.map((span, i) => )} + {parsed.spans?.map((span, i) => ( + + ))} {parsed.target} @@ -91,5 +93,5 @@ export const LogLine: React.FC<{ line: JSONLogLine }> = React.memo(

); - } + }, ); diff --git a/crates/librqbit/webui/src/components/LogStream.tsx b/crates/librqbit/webui/src/components/LogStream.tsx index d4e2ee04c..c98c7002c 100644 --- a/crates/librqbit/webui/src/components/LogStream.tsx +++ b/crates/librqbit/webui/src/components/LogStream.tsx @@ -36,7 +36,7 @@ const mergeBuffers = (a1: Uint8Array, a2: Uint8Array): Uint8Array => { const streamLogs = ( url: string, addLine: (text: string) => void, - setError: (error: ErrorWithLabel | null) => void + setError: (error: ErrorWithLabel | null) => void, ): (() => void) => { const controller = new AbortController(); const signal = controller.signal; @@ -116,9 +116,9 @@ const streamLogs = ( }, }); throw e; - } + }, ), - 1000 + 1000, ); return () => { @@ -154,7 +154,7 @@ export const LogStream: React.FC = ({ url, maxLines }) => { return newLogLines; }); }, - [filterRegex.current, maxLines] + [filterRegex.current, maxLines], ); const addLineRef = useRef(addLine); diff --git a/crates/librqbit/webui/src/components/ManagedTorrentFileListInput.tsx b/crates/librqbit/webui/src/components/ManagedTorrentFileListInput.tsx new file mode 100644 index 000000000..16cf9b87e --- /dev/null +++ b/crates/librqbit/webui/src/components/ManagedTorrentFileListInput.tsx @@ -0,0 +1,78 @@ +import { useContext, useEffect, useState } from "react"; +import { FileListInput } from "./FileListInput"; +import { ErrorDetails, TorrentDetails, TorrentIdWithStats } from "../api-types"; +import { loopUntilSuccess } from "../helper/loopUntilSuccess"; +import { APIContext } from "../context"; +import { useErrorStore } from "../stores/errorStore"; +import { Spinner } from "./Spinner"; + +export const ManagedTorrentFileListInput: React.FC<{ + torrent: TorrentIdWithStats; +}> = ({ torrent }) => { + const [detailsResponse, setDetailsResponse] = useState( + null, + ); + const API = useContext(APIContext); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [savingSelectedFiles, setSavingSelectedFiles] = useState(false); + + // Update details once then when asked for. + useEffect(() => { + return loopUntilSuccess(async () => { + await API.getTorrentDetails(torrent.id).then(setDetailsResponse); + }, 1000); + }, [torrent.id]); + + // Update selected files whenever details are updated. + useEffect(() => { + setSelectedFiles( + new Set( + detailsResponse?.files + .map((f, id) => ({ f, id })) + .filter(({ f }) => f.included) + .map(({ id }) => id) ?? [], + ), + ); + }, [detailsResponse]); + + let setCloseableError = useErrorStore((state) => state.setCloseableError); + + const updateSelectedFiles = (selectedFiles: Set) => { + setSavingSelectedFiles(true); + API.updateOnlyFiles(torrent.id, Array.from(selectedFiles)) + .then( + () => { + setSelectedFiles(selectedFiles); + setCloseableError(null); + }, + (e) => { + setCloseableError({ + text: "Error configuring torrent", + details: e as ErrorDetails, + }); + }, + ) + .finally(() => setSavingSelectedFiles(false)); + }; + + if (!detailsResponse) { + return ( +
+ +
+ ); + } + + return ( + + ); +}; diff --git a/crates/librqbit/webui/src/components/PeerTable.tsx b/crates/librqbit/webui/src/components/PeerTable.tsx new file mode 100644 index 000000000..436eb9de4 --- /dev/null +++ b/crates/librqbit/webui/src/components/PeerTable.tsx @@ -0,0 +1,232 @@ +import { useContext, useEffect, useState, useMemo } from "react"; +import { PeerStatsSnapshot, TorrentIdWithStats } from "../api-types"; +import { APIContext } from "../context"; +import { customSetInterval } from "../helper/customSetInterval"; +import { formatBytes } from "../helper/formatBytes"; +import { FaArrowUp, FaArrowDown } from "react-icons/fa"; +import { Spinner } from "./Spinner"; + +export const PeerTable: React.FC<{ + torrent: TorrentIdWithStats; +}> = ({ torrent }) => { + const [peers, setPeers] = useState(null); + const [peerSpeeds, setPeerSpeeds] = useState< + Record + >({}); + const [_, setPeerHistory] = useState< + Record< + string, + Array<{ + timestamp: number; + fetched_bytes: number; + uploaded_bytes: number; + }> + > + >({}); + const [sortColumn, setSortColumn] = useState("downloaded"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + + const API = useContext(APIContext); + + useEffect(() => { + const SPEED_WINDOW_MS = 10 * 1000; // 10 seconds + + return customSetInterval(async () => { + const newPeers = await API.getTorrentPeerStats(torrent.id, "live"); + const now = Date.now(); + + const currentSpeeds: Record< + string, + { download: number; upload: number } + > = {}; + + setPeerHistory((prevHistory) => { + const newHistory: typeof prevHistory = {}; + Object.entries(newPeers.peers).forEach(([addr, peerStats]) => { + newHistory[addr] = prevHistory[addr] ?? []; + newHistory[addr].push({ + timestamp: now, + fetched_bytes: peerStats.counters.fetched_bytes, + uploaded_bytes: peerStats.counters.uploaded_bytes, + }); + + // Clean up old history entries + newHistory[addr] = newHistory[addr].filter( + (entry) => now - entry.timestamp <= SPEED_WINDOW_MS, + ); + + // Calculate speeds using sliding window + if (newHistory[addr].length > 1) { + const firstEntry = newHistory[addr][0]; + const lastEntry = newHistory[addr][newHistory[addr].length - 1]; + const timeDiff = + (lastEntry.timestamp - firstEntry.timestamp) / 1000; // in seconds + + if (timeDiff > 0) { + // Calculate download speed + const downloadedDiff = + lastEntry.fetched_bytes - firstEntry.fetched_bytes; + // Calculate upload speed + const uploadedDiff = + lastEntry.uploaded_bytes - firstEntry.uploaded_bytes; + + currentSpeeds[addr] = { + download: downloadedDiff / timeDiff, // bytes per second + upload: uploadedDiff / timeDiff, // bytes per second + }; + } else { + currentSpeeds[addr] = { download: 0, upload: 0 }; + } + } else { + currentSpeeds[addr] = { download: 0, upload: 0 }; + } + }); + return newHistory; + }); + + setPeers(newPeers); + setPeerSpeeds(currentSpeeds); + + return 1000; // Refresh every second while open + }, 0); + }, [torrent.id, torrent.stats.state]); + + const sortedPeers = useMemo(() => { + if (!peers) return []; + + let peersArray = Object.entries(peers.peers); + + peersArray.sort(([addrA, peerStatsA], [addrB, peerStatsB]) => { + let compareValue = 0; + switch (sortColumn) { + case "address": + compareValue = addrA.localeCompare(addrB); + break; + case "conn_kind": + compareValue = (peerStatsA.conn_kind || "").localeCompare( + peerStatsB.conn_kind || "", + ); + break; + case "downloaded": + compareValue = + peerStatsA.counters.fetched_bytes - + peerStatsB.counters.fetched_bytes; + break; + case "down_speed": + compareValue = + (peerSpeeds[addrA]?.download || 0) - + (peerSpeeds[addrB]?.download || 0); + break; + case "up_speed": + compareValue = + (peerSpeeds[addrA]?.upload || 0) - (peerSpeeds[addrB]?.upload || 0); + break; + default: + break; + } + return sortDirection === "asc" ? compareValue : -compareValue; + }); + return peersArray; + }, [peers, sortColumn, sortDirection, peerSpeeds]); + + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortColumn(column); + setSortDirection("asc"); // Default to ascending when changing column + } + }; + + const getSortIndicator = (column: string) => { + if (sortColumn === column) { + return sortDirection === "asc" ? ( + + ) : ( + + ); + } + return ""; + }; + + if (!peers) { + return ( +
+ +
+ ); + } + + return ( + <> + {peers && ( +
+ + + + + + + + + + + + + {sortedPeers.map(([addr, peerStats]) => ( + + + + + + + + + ))} + +
handleSort("address")} + > + Address{getSortIndicator("address")} + handleSort("conn_kind")} + > + Conn. Kind{getSortIndicator("conn_kind")} + handleSort("downloaded")} + > + Downloaded{getSortIndicator("downloaded")} + handleSort("down_speed")} + > + Down Speed{getSortIndicator("down_speed")} + handleSort("downloaded")} + > + Uploaded{getSortIndicator("uploaded")} + handleSort("up_speed")} + > + Up Speed{getSortIndicator("up_speed")} +
{addr} + {peerStats.conn_kind || "N/A"} + + {formatBytes(peerStats.counters.fetched_bytes)} + + {formatBytes(peerSpeeds[addr]?.download || 0)}/s + + {formatBytes(peerStats.counters.uploaded_bytes)} + + {formatBytes(peerSpeeds[addr]?.upload || 0)}/s +
+
+ )} + + ); +}; diff --git a/crates/librqbit/webui/src/components/ResizablePanes.tsx b/crates/librqbit/webui/src/components/ResizablePanes.tsx new file mode 100644 index 000000000..203bb3081 --- /dev/null +++ b/crates/librqbit/webui/src/components/ResizablePanes.tsx @@ -0,0 +1,68 @@ +import React, { useState, useRef, useCallback, useEffect } from "react"; + +interface ResizablePanesProps { + top: React.ReactNode; + bottom: React.ReactNode; + initialTopHeight?: string; + minTopHeight?: number; + minBottomHeight?: number; +} + +export const ResizablePanes: React.FC = ({ + top, + bottom, + initialTopHeight = "50%", + minTopHeight = 50, + minBottomHeight = 50, +}) => { + const [topHeight, setTopHeight] = useState(initialTopHeight); + const containerRef = useRef(null); + + const onMouseMove = useCallback( + (e: MouseEvent) => { + if (containerRef.current) { + const containerRect = containerRef.current.getBoundingClientRect(); + let newTopHeight = e.clientY - containerRect.top; + + if (newTopHeight < minTopHeight) { + newTopHeight = minTopHeight; + } + if (newTopHeight > containerRect.height - minBottomHeight) { + newTopHeight = containerRect.height - minBottomHeight; + } + + setTopHeight(`${newTopHeight}px`); + } + }, + [minTopHeight, minBottomHeight], + ); + + const onMouseUp = useCallback(() => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }, [onMouseMove]); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }, + [onMouseMove, onMouseUp], + ); + + return ( +
+
+ {top} +
+
+
{bottom}
+
+ ); +}; diff --git a/crates/librqbit/webui/src/components/RootContent.tsx b/crates/librqbit/webui/src/components/RootContent.tsx index f1387e41c..5bba2d315 100644 --- a/crates/librqbit/webui/src/components/RootContent.tsx +++ b/crates/librqbit/webui/src/components/RootContent.tsx @@ -1,17 +1,40 @@ +import { useContext } from "react"; import { TorrentsList } from "./TorrentsList"; import { ErrorComponent } from "./ErrorComponent"; import { useTorrentStore } from "../stores/torrentStore"; import { useErrorStore } from "../stores/errorStore"; +import { ViewModeContext } from "../stores/viewMode"; +import { TorrentDetailsPane } from "./TorrentDetailsPane"; +import { APIContext } from "../context"; +import { ResizablePanes } from "./ResizablePanes"; export const RootContent = (props: {}) => { + const { compact } = useContext(ViewModeContext); + let closeableError = useErrorStore((state) => state.closeableError); let setCloseableError = useErrorStore((state) => state.setCloseableError); let otherError = useErrorStore((state) => state.otherError); let torrents = useTorrentStore((state) => state.torrents); let torrentsInitiallyLoading = useTorrentStore( - (state) => state.torrentsInitiallyLoading + (state) => state.torrentsInitiallyLoading, ); + const API = useContext(APIContext); + + if (compact) { + return ( + + } + bottom={} + /> + ); + } + return (
= ({ - statsResponse, -}) => { - switch (statsResponse.state) { +export const Speed: React.FC<{ stats: TorrentStats }> = ({ stats }) => { + switch (stats.state) { case STATE_PAUSED: return "Paused"; case STATE_INITIALIZING: @@ -18,23 +16,21 @@ export const Speed: React.FC<{ statsResponse: TorrentStats }> = ({ return "Error"; } // Unknown state - if (statsResponse.state != "live" || statsResponse.live === null) { - return statsResponse.state; + if (stats.state != "live" || stats.live === null) { + return stats.state; } return ( <> - {!statsResponse.finished && ( + {!stats.finished && (
- ↓ {statsResponse.live.download_speed?.human_readable} + ↓ {stats.live.download_speed?.human_readable}
)}
- ↑ {statsResponse.live.upload_speed?.human_readable} - {statsResponse.live.snapshot.uploaded_bytes > 0 && ( - - ({formatBytes(statsResponse.live.snapshot.uploaded_bytes)}) - + ↑ {stats.live.upload_speed?.human_readable} + {stats.live.snapshot.uploaded_bytes > 0 && ( + ({formatBytes(stats.live.snapshot.uploaded_bytes)}) )}
diff --git a/crates/librqbit/webui/src/components/StatusIcon.tsx b/crates/librqbit/webui/src/components/StatusIcon.tsx index b9f19362d..b0e971166 100644 --- a/crates/librqbit/webui/src/components/StatusIcon.tsx +++ b/crates/librqbit/webui/src/components/StatusIcon.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { MdCheckCircle, MdDownload, @@ -13,11 +14,14 @@ type Props = { error: boolean; }; -export const StatusIcon = ({ className, finished, live, error }: Props) => { - const isSeeding = finished && live; - if (error) return ; - if (isSeeding) return ; - if (finished) return ; - if (live) return ; - else return ; -}; +export const StatusIcon: React.FC = memo( + ({ className, finished, live, error }) => { + const isSeeding = finished && live; + if (error) return ; + if (isSeeding) + return ; + if (finished) return ; + if (live) return ; + else return ; + }, +); diff --git a/crates/librqbit/webui/src/components/Torrent.tsx b/crates/librqbit/webui/src/components/Torrent.tsx index 380b05f3e..78c20dd8d 100644 --- a/crates/librqbit/webui/src/components/Torrent.tsx +++ b/crates/librqbit/webui/src/components/Torrent.tsx @@ -1,79 +1,21 @@ -import { useContext, useEffect, useState } from "react"; -import { - TorrentDetails, - TorrentId, - TorrentStats, - STATE_INITIALIZING, - STATE_LIVE, -} from "../api-types"; -import { APIContext, RefreshTorrentStatsContext } from "../context"; -import { customSetInterval } from "../helper/customSetInterval"; -import { loopUntilSuccess } from "../helper/loopUntilSuccess"; +import { useContext } from "react"; +import { TorrentIdWithStats } from "../api-types"; import { TorrentRow } from "./TorrentRow"; +import { CompactTorrentRow } from "./CompactTorrentRow"; +import { ViewModeContext } from "../stores/viewMode"; export const Torrent: React.FC<{ - id: number; - torrent: TorrentId; -}> = ({ id, torrent }) => { - const [detailsResponse, updateDetailsResponse] = - useState(null); - const [statsResponse, updateStatsResponse] = useState( - null, - ); - const [forceStatsRefresh, setForceStatsRefresh] = useState(0); - const API = useContext(APIContext); - - const forceStatsRefreshCallback = () => { - setForceStatsRefresh(forceStatsRefresh + 1); - }; - - // Update details once then when asked for. - useEffect(() => { - return loopUntilSuccess(async () => { - await API.getTorrentDetails(torrent.id).then(updateDetailsResponse); - }, 1000); - }, [forceStatsRefresh]); - - // Update stats once then forever. - useEffect( - () => - customSetInterval(async () => { - const errorInterval = 10000; - const liveInterval = 1000; - const nonLiveInterval = 10000; - - return API.getTorrentStats(torrent.id) - .then((stats) => { - updateStatsResponse(stats); - return stats; - }) - .then( - (stats) => { - if ( - stats.state == STATE_INITIALIZING || - stats.state == STATE_LIVE - ) { - return liveInterval; - } - return nonLiveInterval; - }, - () => { - return errorInterval; - }, - ); - }, 0), - [forceStatsRefresh], - ); + torrent: TorrentIdWithStats; +}> = ({ torrent }) => { + const { compact } = useContext(ViewModeContext); return ( - - - + <> + {compact ? ( + + ) : ( + + )} + ); }; diff --git a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx b/crates/librqbit/webui/src/components/TorrentActions.tsx similarity index 73% rename from crates/librqbit/webui/src/components/buttons/TorrentActions.tsx rename to crates/librqbit/webui/src/components/TorrentActions.tsx index 3354042d3..116643d88 100644 --- a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx +++ b/crates/librqbit/webui/src/components/TorrentActions.tsx @@ -1,8 +1,8 @@ import { useContext, useState } from "react"; -import { TorrentDetails, TorrentStats } from "../../api-types"; -import { APIContext, RefreshTorrentStatsContext } from "../../context"; -import { IconButton } from "./IconButton"; -import { DeleteTorrentModal } from "../modal/DeleteTorrentModal"; +import { TorrentDetails, TorrentIdWithStats, TorrentStats } from "../api-types"; +import { APIContext } from "../context"; +import { IconButton } from "./buttons/IconButton"; +import { DeleteTorrentModal } from "./modal/DeleteTorrentModal"; import { FaCog, FaPause, @@ -10,22 +10,23 @@ import { FaTrash, FaClipboardList, } from "react-icons/fa"; -import { useErrorStore } from "../../stores/errorStore"; -import { ErrorComponent } from "../ErrorComponent"; +import { useErrorStore } from "../stores/errorStore"; +import { ErrorComponent } from "./ErrorComponent"; +import { useTorrentStore } from "../stores/torrentStore"; +import { ViewModeContext } from "../stores/viewMode"; export const TorrentActions: React.FC<{ - id: number; - statsResponse: TorrentStats; - detailsResponse: TorrentDetails | null; + torrent: TorrentIdWithStats; extendedView: boolean; setExtendedView: (extendedView: boolean) => void; -}> = ({ id, statsResponse, detailsResponse, extendedView, setExtendedView }) => { - let state = statsResponse.state; +}> = ({ torrent, extendedView, setExtendedView }) => { + const { compact } = useContext(ViewModeContext); + let state = torrent.stats.state; let [disabled, setDisabled] = useState(false); let [deleting, setDeleting] = useState(false); - let refreshCtx = useContext(RefreshTorrentStatsContext); + let refresh = useTorrentStore((s) => s.refreshTorrents); const canPause = state == "live"; const canUnpause = state == "paused" || state == "error"; @@ -37,14 +38,14 @@ export const TorrentActions: React.FC<{ const unpause = () => { setDisabled(true); - API.start(id) + API.start(torrent.id) .then( () => { - refreshCtx.refresh(); + refresh(); }, (e) => { setCloseableError({ - text: `Error starting torrent id=${id}`, + text: `Error starting torrent id=${torrent.id} name=${torrent.name}`, details: e, }); }, @@ -54,14 +55,14 @@ export const TorrentActions: React.FC<{ const pause = () => { setDisabled(true); - API.pause(id) + API.pause(torrent.id) .then( () => { - refreshCtx.refresh(); + refresh(); }, (e) => { setCloseableError({ - text: `Error pausing torrent id=${id}`, + text: `Error pausing torrent id=${torrent.id} name=${torrent.name}`, details: e, }); }, @@ -79,7 +80,7 @@ export const TorrentActions: React.FC<{ setDeleting(false); }; - const playlistUrl = API.getPlaylistUrl(id); + const playlistUrl = API.getPlaylistUrl(torrent.id); const setAlert = useErrorStore((state) => state.setAlert); @@ -118,7 +119,7 @@ export const TorrentActions: React.FC<{ }; return ( -
+
{canUnpause && ( @@ -129,7 +130,7 @@ export const TorrentActions: React.FC<{ )} - {canConfigure && ( + {canConfigure && !compact && ( setExtendedView(!extendedView)} disabled={disabled} @@ -147,10 +148,9 @@ export const TorrentActions: React.FC<{
); diff --git a/crates/librqbit/webui/src/components/TorrentActionsMulti.tsx b/crates/librqbit/webui/src/components/TorrentActionsMulti.tsx new file mode 100644 index 000000000..08cead9b5 --- /dev/null +++ b/crates/librqbit/webui/src/components/TorrentActionsMulti.tsx @@ -0,0 +1,47 @@ +import { FaPause, FaPlay, FaTrash } from "react-icons/fa"; +import { TorrentIdWithStats } from "../api-types"; +import { IconButton } from "./buttons/IconButton"; +import { useTorrentStore } from "../stores/torrentStore"; +import { useContext, useState } from "react"; +import { APIContext } from "../context"; + +export const TorrentActionsMulti: React.FC<{ + torrentIds: number[]; +}> = ({ torrentIds }) => { + const refresh = useTorrentStore((state) => state.refreshTorrents); + const [disabled, setDisabled] = useState(false); + const API = useContext(APIContext); + + const pause = () => { + setDisabled(true); + Promise.all( + torrentIds.map((id) => { + API.pause(id).then(() => refresh()); + }), + ).finally(() => setDisabled(false)); + }; + + const unpause = () => { + setDisabled(true); + Promise.all( + torrentIds.map((id) => { + API.start(id).then(() => refresh()); + }), + ).finally(() => setDisabled(false)); + }; + + return ( +
+ + + + + + + + {/* + + */} +
+ ); +}; diff --git a/crates/librqbit/webui/src/components/TorrentDetailsOverviewTab.tsx b/crates/librqbit/webui/src/components/TorrentDetailsOverviewTab.tsx new file mode 100644 index 000000000..7c16bd6c0 --- /dev/null +++ b/crates/librqbit/webui/src/components/TorrentDetailsOverviewTab.tsx @@ -0,0 +1,30 @@ +import { TorrentDetails, TorrentIdWithStats } from "../api-types"; +import { formatBytes } from "../helper/formatBytes"; + +export const TorrentDetailsOverviewTab: React.FC<{ + torrent: TorrentIdWithStats; +}> = ({ torrent }) => { + return ( +
+

+ Name: {torrent.name} +

+

+ ID: {torrent.id} +

+

+ Size: {formatBytes(torrent.stats.total_bytes)} +

+

+ Info Hash: {torrent.info_hash} +

+

+ Output folder: {torrent.output_folder} +

+

+ Uploaded:{" "} + {formatBytes(torrent.stats.live?.snapshot.uploaded_bytes ?? 0)} +

+
+ ); +}; diff --git a/crates/librqbit/webui/src/components/TorrentDetailsPane.tsx b/crates/librqbit/webui/src/components/TorrentDetailsPane.tsx new file mode 100644 index 000000000..329b3b017 --- /dev/null +++ b/crates/librqbit/webui/src/components/TorrentDetailsPane.tsx @@ -0,0 +1,61 @@ +import { useContext, useMemo, useState } from "react"; +import { TorrentDetails, TorrentIdWithStats } from "../api-types"; +import { FileListInput } from "./FileListInput"; +import { TorrentActions } from "./TorrentActions"; +import { PeerTable } from "./PeerTable"; +import { Tab, Tabs } from "./tabs/Tabs"; +import { TorrentDetailsOverviewTab } from "./TorrentDetailsOverviewTab"; +import { ViewModeContext } from "../stores/viewMode"; +import { ManagedTorrentFileListInput } from "./ManagedTorrentFileListInput"; +import { useTorrentStore } from "../stores/torrentStore"; +import { TorrentActionsMulti } from "./TorrentActionsMulti"; + +const noop = () => {}; + +export const TorrentDetailsPaneSingleSelected: React.FC<{ id: number }> = ({ + id, +}) => { + const torrent = useTorrentStore((state) => + state.torrents?.find((t) => t.id === id), + ); + if (!torrent) return null; + return ( +
+
+ +
+ + + + + + + + + + + +
+ ); +}; + +export const TorrentDetailsPane: React.FC<{}> = () => { + const selectedTorrentIds = useTorrentStore( + (state) => state.selectedTorrentIds, + ); + if (selectedTorrentIds.length === 0) { + return null; + } else if (selectedTorrentIds.length === 1) { + return ; + } else { + return ( +
+ +
+ ); + } +}; diff --git a/crates/librqbit/webui/src/components/TorrentRow.tsx b/crates/librqbit/webui/src/components/TorrentRow.tsx index c55d16d2e..5925cd3ec 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -1,40 +1,34 @@ import { GoClock, GoFile, GoPeople } from "react-icons/go"; -import { - TorrentDetails, - TorrentStats, - STATE_INITIALIZING, - ErrorDetails, -} from "../api-types"; -import { TorrentActions } from "./buttons/TorrentActions"; +import { STATE_INITIALIZING, TorrentIdWithStats } from "../api-types"; +import { TorrentActions } from "./TorrentActions"; import { ProgressBar } from "./ProgressBar"; import { Speed } from "./Speed"; import { formatBytes } from "../helper/formatBytes"; -import { torrentDisplayName } from "../helper/getTorrentDisplayName"; import { getCompletionETA } from "../helper/getCompletionETA"; import { StatusIcon } from "./StatusIcon"; -import { FileListInput } from "./FileListInput"; -import { useContext, useEffect, useState } from "react"; -import { APIContext, RefreshTorrentStatsContext } from "../context"; -import { useErrorStore } from "../stores/errorStore"; +import { useContext, useState } from "react"; +import { APIContext } from "../context"; +import { useTorrentStore } from "../stores/torrentStore"; +import { ManagedTorrentFileListInput } from "./ManagedTorrentFileListInput"; export const TorrentRow: React.FC<{ - id: number; - detailsResponse: TorrentDetails | null; - statsResponse: TorrentStats | null; -}> = ({ id, detailsResponse, statsResponse }) => { - const state = statsResponse?.state ?? ""; - const error = statsResponse?.error ?? null; - const totalBytes = statsResponse?.total_bytes ?? 1; - const progressBytes = statsResponse?.progress_bytes ?? 0; - const finished = statsResponse?.finished || false; + torrent: TorrentIdWithStats; +}> = ({ torrent }) => { + const stats = torrent.stats; + const state = stats.state ?? ""; + const error = stats.error ?? null; + const totalBytes = stats.total_bytes ?? 1; + const progressBytes = stats.progress_bytes ?? 0; + const finished = stats.finished || false; const progressPercentage = error ? 100 : totalBytes == 0 ? 100 : (progressBytes / totalBytes) * 100; + const refresh = useTorrentStore((state) => state.refreshTorrents); const formatPeersString = () => { - let peer_stats = statsResponse?.live?.snapshot.peer_stats; + let peer_stats = stats?.live?.snapshot.peer_stats; if (!peer_stats) { return ""; } @@ -46,52 +40,13 @@ export const TorrentRow: React.FC<{ ); }; - const [selectedFiles, setSelectedFiles] = useState>(new Set()); - - // Update selected files whenever details are updated. - useEffect(() => { - setSelectedFiles( - new Set( - detailsResponse?.files - .map((f, id) => ({ f, id })) - .filter(({ f }) => f.included) - .map(({ id }) => id) ?? [] - ) - ); - }, [detailsResponse]); - const API = useContext(APIContext); - - const refreshCtx = useContext(RefreshTorrentStatsContext); - - const [savingSelectedFiles, setSavingSelectedFiles] = useState(false); - - let setCloseableError = useErrorStore((state) => state.setCloseableError); - - const updateSelectedFiles = (selectedFiles: Set) => { - setSavingSelectedFiles(true); - API.updateOnlyFiles(id, Array.from(selectedFiles)) - .then( - () => { - refreshCtx.refresh(); - setCloseableError(null); - }, - (e) => { - setCloseableError({ - text: "Error configuring torrent", - details: e as ErrorDetails, - }); - } - ) - .finally(() => setSavingSelectedFiles(false)); - }; - const [extendedView, setExtendedView] = useState(false); return ( @@ -101,14 +56,12 @@ export const TorrentRow: React.FC<{
{statusIcon("w-10 h-10")}
{/* Name, progress, stats */}
- {detailsResponse && ( -
-
{statusIcon("w-5 h-5")}
-
- {torrentDisplayName(detailsResponse)} -
+
+
{statusIcon("w-5 h-5")}
+
+ {torrent.name}
- )} +
{error ? (

Error: {error} @@ -130,7 +83,7 @@ export const TorrentRow: React.FC<{

- {formatPeersString().toString()} + {formatPeersString()}
@@ -138,50 +91,26 @@ export const TorrentRow: React.FC<{ {formatBytes(progressBytes)}/{formatBytes(totalBytes)}
- {statsResponse && ( - <> -
- - {getCompletionETA(statsResponse)} -
-
- -
- - )} +
+ + {getCompletionETA(torrent.stats)} +
+
+ +
)}
{/* Actions */} - {statsResponse && ( -
- -
- )} + - {/* extended view */} - {detailsResponse && extendedView && ( -
- -
- )} + {extendedView && }
); }; diff --git a/crates/librqbit/webui/src/components/TorrentsList.tsx b/crates/librqbit/webui/src/components/TorrentsList.tsx index 1b9410b7f..0a3464b30 100644 --- a/crates/librqbit/webui/src/components/TorrentsList.tsx +++ b/crates/librqbit/webui/src/components/TorrentsList.tsx @@ -1,27 +1,205 @@ -import { TorrentId } from "../api-types"; +import { TorrentIdWithStats, TorrentDetails } from "../api-types"; import { Spinner } from "./Spinner"; import { Torrent } from "./Torrent"; +import { useContext, useEffect, useState, useMemo, useCallback } from "react"; +import { APIContext } from "../context"; +import { loopUntilSuccess } from "../helper/loopUntilSuccess"; +import { torrentDisplayName } from "../helper/getTorrentDisplayName"; +import { formatBytes } from "../helper/formatBytes"; +import { getCompletionETA } from "../helper/getCompletionETA"; +import { Speed } from "./Speed"; +import { FaArrowUp, FaArrowDown } from "react-icons/fa"; +import { ViewModeContext } from "../stores/viewMode"; -export const TorrentsList = (props: { - torrents: Array | null; +import { useTorrentStore } from "../stores/torrentStore"; + +export const TorrentsList: React.FC<{ + torrents: Array | null; loading: boolean; -}) => { +}> = ({ torrents, loading }) => { + const { compact } = useContext(ViewModeContext); + + type SortColumn = + | "id" + | "name" + | "progress" + | "download" + | "upload" + | "eta" + | "peers" + | "size"; + + const [sortColumn, setSortColumn] = useState("id"); + const [sortDirectionIsAsc, setSortDirectionIsAsc] = useState(false); + + const API = useContext(APIContext); + + const sortedTorrentData = useMemo(() => { + if (!torrents) return []; + + const sortableData = [...torrents]; + + sortableData.sort((a, b) => { + const getSortValue = ( + torrent: TorrentIdWithStats, + column: SortColumn, + ) => { + switch (column) { + case "id": + return torrent.id; + case "name": + return torrent.name || ""; + case "progress": + const progress = torrent.stats?.progress_bytes || 0; + const total = torrent.stats?.total_bytes || 1; + return progress / total; + case "download": + return torrent.stats?.live?.download_speed.mbps || 0; + case "upload": + return torrent.stats?.live?.upload_speed.mbps || 0; + case "eta": + return ( + torrent.stats?.live?.time_remaining?.duration?.secs || Infinity + ); + case "peers": + return torrent.stats?.live?.snapshot.peer_stats.live || 0; + case "size": + return torrent.stats?.total_bytes || 0; + } + }; + + const valueA = getSortValue(a, sortColumn); + const valueB = getSortValue(b, sortColumn); + + let compareValue = 0; + if (typeof valueA === "string" && typeof valueB === "string") { + compareValue = valueA.localeCompare(valueB); + } else if (typeof valueA === "number" && typeof valueB === "number") { + compareValue = valueA - valueB; + } + return sortDirectionIsAsc ? compareValue : -compareValue; + }); + return sortableData; + }, [torrents, sortColumn, sortDirectionIsAsc]); + + const handleSort = (newColumn: SortColumn) => { + if (sortColumn === newColumn) { + setSortDirectionIsAsc(!sortDirectionIsAsc); + } else { + setSortColumn(newColumn); + setSortDirectionIsAsc(["name", "id"].indexOf(newColumn) !== -1); + } + }; + + const getSortIndicator = (column: SortColumn) => { + if (sortColumn === column) { + return sortDirectionIsAsc ? ( + + ) : ( + + ); + } + return null; + }; + + const thClassNames = + "px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"; + const thClickableClassNames = `${thClassNames} cursor-pointer`; + + if (compact) { + return ( +
+ {torrents === null ? ( + loading ? ( + + ) : null + ) : torrents.length === 0 ? ( +

No existing torrents found.

+ ) : ( +
+ + + + + + + + + + + + + + + + {sortedTorrentData.map((t) => ( + + ))} + +
handleSort("id")} + > + ID{getSortIndicator("id")} + handleSort("name")} + > + Name{getSortIndicator("name")} + handleSort("progress")} + > + Progress{getSortIndicator("progress")} + handleSort("download")} + > + Download{getSortIndicator("download")} + handleSort("upload")} + > + Upload{getSortIndicator("upload")} + handleSort("eta")} + > + ETA{getSortIndicator("eta")} + handleSort("peers")} + > + Peers{getSortIndicator("peers")} + handleSort("size")} + > + Size{getSortIndicator("size")} +
+
+ )} +
+ ); + } + return (
- {props.torrents === null ? ( - props.loading ? ( + {sortedTorrentData === null ? ( + loading ? ( ) : null - ) : props.torrents.length === 0 ? ( + ) : sortedTorrentData.length === 0 ? (

No existing torrents found.

) : ( - props.torrents.map((t: TorrentId) => ( - <> - - + sortedTorrentData.map((t: TorrentIdWithStats) => ( + )) )}
diff --git a/crates/librqbit/webui/src/components/buttons/FileInput.tsx b/crates/librqbit/webui/src/components/buttons/FileInput.tsx index c0a64f264..088d4b097 100644 --- a/crates/librqbit/webui/src/components/buttons/FileInput.tsx +++ b/crates/librqbit/webui/src/components/buttons/FileInput.tsx @@ -30,7 +30,7 @@ export const FileInput = ({ className }: { className?: string }) => { }, (err) => { console.error("error uploading file", err); - } + }, ); } reset(); diff --git a/crates/librqbit/webui/src/components/modal/DeleteTorrentModal.tsx b/crates/librqbit/webui/src/components/modal/DeleteTorrentModal.tsx index 6f4f0bcc8..33d2d08d8 100644 --- a/crates/librqbit/webui/src/components/modal/DeleteTorrentModal.tsx +++ b/crates/librqbit/webui/src/components/modal/DeleteTorrentModal.tsx @@ -1,5 +1,5 @@ import { useContext, useState } from "react"; -import { TorrentDetails } from "../../api-types"; +import { TorrentDetails, TorrentIdWithStats } from "../../api-types"; import { APIContext } from "../../context"; import { torrentDisplayName } from "../../helper/getTorrentDisplayName"; import { ErrorWithLabel } from "../../rqbit-web"; @@ -12,11 +12,10 @@ import { ModalBody } from "./ModalBody"; import { ModalFooter } from "./ModalFooter"; export const DeleteTorrentModal: React.FC<{ - id: number; show: boolean; onHide: () => void; - torrent: TorrentDetails | null; -}> = ({ id, show, onHide, torrent }) => { + torrent: TorrentIdWithStats; +}> = ({ show, onHide, torrent }) => { if (!show) { return null; } @@ -39,14 +38,14 @@ export const DeleteTorrentModal: React.FC<{ const call = deleteFiles ? API.delete : API.forget; - call(id) + call(torrent.id) .then(() => { refreshTorrents(); close(); }) .catch((e) => { setError({ - text: `Error deleting torrent id=${id}`, + text: `Error deleting torrent id=${torrent.id} name=${torrent.name}`, details: e, }); setDeleting(false); @@ -59,7 +58,7 @@ export const DeleteTorrentModal: React.FC<{

Are you sure you want to delete{" "} - "{torrentDisplayName(torrent)}"? + "{torrent.name}"?

diff --git a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx index e1ccd0752..9fb01c5db 100644 --- a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx +++ b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx @@ -46,8 +46,8 @@ export const FileSelectionModal = (props: { } else { return [idx]; } - }) - ) + }), + ), ); setOutputFolder(listTorrentResponse?.output_folder || ""); }, [listTorrentResponse]); @@ -87,7 +87,7 @@ export const FileSelectionModal = (props: { }, (e) => { setUploadError({ text: "Error starting torrent", details: e }); - } + }, ) .finally(() => setUploading(false)); }; diff --git a/crates/librqbit/webui/src/components/tabs/Tabs.tsx b/crates/librqbit/webui/src/components/tabs/Tabs.tsx new file mode 100644 index 000000000..e62576d2e --- /dev/null +++ b/crates/librqbit/webui/src/components/tabs/Tabs.tsx @@ -0,0 +1,48 @@ +import React, { ReactNode, useState } from "react"; + +export const Tab: React.FC<{ + children: ReactNode; +}> = ({ children }) => { + return <>{children}; +}; + +export const Tabs: React.FC<{ + tabs: readonly string[]; + children: ReactNode; +}> = ({ tabs, children }) => { + const [currentTab, setCurrentTab] = useState(tabs[0]); + + const tabChildren = React.Children.toArray(children); + + return ( +
+
+ {tabs.map((t, i) => { + const isActive = t === currentTab; + let classNames = "text-slate-300 text-sm"; + if (isActive) { + classNames = + "text-slate-800 text-sm border-b-2 border-blue-500 dark:border-blue-200 dark:text-white"; + } + return ( + + ); + })} +
+
+ {tabChildren.map((child, i) => { + if (tabs[i] === currentTab) { + return child; + } + return null; + })} +
+
+ ); +}; diff --git a/crates/librqbit/webui/src/context.tsx b/crates/librqbit/webui/src/context.tsx index 7bf10b804..addf610e8 100644 --- a/crates/librqbit/webui/src/context.tsx +++ b/crates/librqbit/webui/src/context.tsx @@ -11,6 +11,10 @@ export const APIContext = createContext({ getTorrentStats: () => { throw new Error("Function not implemented."); }, + + getTorrentPeerStats: () => { + throw new Error("Function not implemented."); + }, uploadTorrent: () => { throw new Error("Function not implemented."); }, @@ -32,14 +36,13 @@ export const APIContext = createContext({ getTorrentStreamUrl: () => { throw new Error("Function not implemented."); }, - getStreamLogsUrl: function (): string | null { + getStreamLogsUrl: function (): string { throw new Error("Function not implemented."); }, - getPlaylistUrl: function (index: number): string | null { + getPlaylistUrl: function (index: number): string { throw new Error("Function not implemented."); }, stats: function (): Promise { throw new Error("Function not implemented."); }, }); -export const RefreshTorrentStatsContext = createContext({ refresh: () => {} }); diff --git a/crates/librqbit/webui/src/helper/customSetInterval.ts b/crates/librqbit/webui/src/helper/customSetInterval.ts index 9554ffaf7..14230d5a6 100644 --- a/crates/librqbit/webui/src/helper/customSetInterval.ts +++ b/crates/librqbit/webui/src/helper/customSetInterval.ts @@ -4,7 +4,7 @@ export function customSetInterval( asyncCallback: () => Promise, - initialInterval: number + initialInterval: number, ): () => void { let timeoutId: any; let currentInterval: number = initialInterval; diff --git a/crates/librqbit/webui/src/helper/getCompletionETA.ts b/crates/librqbit/webui/src/helper/getCompletionETA.ts index 93d6eb928..bd183aeed 100644 --- a/crates/librqbit/webui/src/helper/getCompletionETA.ts +++ b/crates/librqbit/webui/src/helper/getCompletionETA.ts @@ -4,7 +4,7 @@ import { formatSecondsToTime } from "./formatSecondsToTime"; export function getCompletionETA(stats: TorrentStats): string { let duration = stats?.live?.time_remaining?.duration?.secs; if (duration == null) { - return "N/A"; + return ""; } return formatSecondsToTime(duration); } diff --git a/crates/librqbit/webui/src/helper/getTorrentDisplayName.ts b/crates/librqbit/webui/src/helper/getTorrentDisplayName.ts index 3d9c19b88..1c644d557 100644 --- a/crates/librqbit/webui/src/helper/getTorrentDisplayName.ts +++ b/crates/librqbit/webui/src/helper/getTorrentDisplayName.ts @@ -7,13 +7,13 @@ function getLargestFileName(torrentDetails: TorrentDetails): string | null { const largestFile = torrentDetails.files .filter((f) => f.included) .reduce((prev: any, current: any) => - prev.length > current.length ? prev : current + prev.length > current.length ? prev : current, ); return largestFile.name; } export function torrentDisplayName( - torrentDetails: TorrentDetails | null + torrentDetails: TorrentDetails | null, ): string { if (!torrentDetails) { return ""; diff --git a/crates/librqbit/webui/src/helper/loopUntilSuccess.ts b/crates/librqbit/webui/src/helper/loopUntilSuccess.ts index c1e204926..a71db6a65 100644 --- a/crates/librqbit/webui/src/helper/loopUntilSuccess.ts +++ b/crates/librqbit/webui/src/helper/loopUntilSuccess.ts @@ -1,13 +1,13 @@ export function loopUntilSuccess( callback: () => Promise, - interval: number + interval: number, ): () => void { let timeoutId: any; const executeCallback = async () => { let retry = await callback().then( () => false, - () => true + () => true, ); if (retry) { scheduleNext(); @@ -17,7 +17,7 @@ export function loopUntilSuccess( let scheduleNext = (overrideInterval?: number) => { timeoutId = setTimeout( executeCallback, - overrideInterval !== undefined ? overrideInterval : interval + overrideInterval !== undefined ? overrideInterval : interval, ); }; diff --git a/crates/librqbit/webui/src/http-api.ts b/crates/librqbit/webui/src/http-api.ts index 2e00df307..87b30c87e 100644 --- a/crates/librqbit/webui/src/http-api.ts +++ b/crates/librqbit/webui/src/http-api.ts @@ -6,6 +6,7 @@ import { SessionStats, TorrentDetails, TorrentStats, + PeerStatsSnapshot, } from "./api-types"; // Define API URL and base path @@ -17,7 +18,8 @@ const apiUrl = (() => { // assume Vite devserver if (url.port == "3031") { - return `${url.protocol}//${url.hostname}:3030`; + return "http://router.lan:3030"; + // return `${url.protocol}//${url.hostname}:3030`; } // Remove "/web" or "/web/" from the end and also ending slash. @@ -29,7 +31,7 @@ const makeRequest = async ( method: string, path: string, data?: any, - isJson?: boolean + isJson?: boolean, ): Promise => { console.log(method, path); const url = apiUrl + path; @@ -87,13 +89,24 @@ const makeRequest = async ( export const API: RqbitAPI & { getVersion: () => Promise } = { getStreamLogsUrl: () => apiUrl + "/stream_logs", listTorrents: (): Promise => - makeRequest("GET", "/torrents"), + makeRequest("GET", "/torrents?with_stats=true"), getTorrentDetails: (index: number): Promise => { return makeRequest("GET", `/torrents/${index}`); }, getTorrentStats: (index: number): Promise => { return makeRequest("GET", `/torrents/${index}/stats/v1`); }, + + getTorrentPeerStats: ( + index: number, + state?: "all" | "live", + ): Promise => { + let url = `/torrents/${index}/peer_stats`; + if (state) { + url += `?state=${state}`; + } + return makeRequest("GET", url); + }, stats: (): Promise => { return makeRequest("GET", "/stats"); }, @@ -132,7 +145,7 @@ export const API: RqbitAPI & { getVersion: () => Promise } = { { only_files: files, }, - true + true, ); }, @@ -158,7 +171,7 @@ export const API: RqbitAPI & { getVersion: () => Promise } = { getTorrentStreamUrl: ( index: number, file_id: number, - filename?: string | null + filename?: string | null, ) => { let url = apiUrl + `/torrents/${index}/stream/${file_id}`; if (!!filename) { diff --git a/crates/librqbit/webui/src/rqbit-web.tsx b/crates/librqbit/webui/src/rqbit-web.tsx index 2542cce45..ad232dc8c 100644 --- a/crates/librqbit/webui/src/rqbit-web.tsx +++ b/crates/librqbit/webui/src/rqbit-web.tsx @@ -1,10 +1,10 @@ -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { ErrorDetails as ApiErrorDetails } from "./api-types"; import { APIContext } from "./context"; import { RootContent } from "./components/RootContent"; import { customSetInterval } from "./helper/customSetInterval"; import { IconButton } from "./components/buttons/IconButton"; -import { BsBodyText, BsMoon } from "react-icons/bs"; +import { BsBodyText, BsMoon, BsList } from "react-icons/bs"; import { LogStreamModal } from "./components/modal/LogStreamModal"; import { Header } from "./components/Header"; import { DarkMode } from "./helper/darkMode"; @@ -13,6 +13,7 @@ import { useErrorStore } from "./stores/errorStore"; import { AlertModal } from "./components/modal/AlertModal"; import { useStatsStore } from "./stores/statsStore"; import { Footer } from "./components/Footer"; +import { ViewModeContext } from "./stores/viewMode"; export interface ErrorWithLabel { text: string; @@ -29,6 +30,8 @@ export const RqbitWebUI = (props: { version: string; menuButtons?: JSX.Element[]; }) => { + const [compact, setCompact] = useState(window.innerWidth >= 850); + let [logsOpened, setLogsOpened] = useState(false); const setOtherError = useErrorStore((state) => state.setOtherError); @@ -36,20 +39,23 @@ export const RqbitWebUI = (props: { const setTorrents = useTorrentStore((state) => state.setTorrents); const setTorrentsLoading = useTorrentStore( - (state) => state.setTorrentsLoading + (state) => state.setTorrentsLoading, ); const setRefreshTorrents = useTorrentStore( - (state) => state.setRefreshTorrents + (state) => state.setRefreshTorrents, ); - const refreshTorrents = async () => { + const refreshTorrents = useCallback(async () => { setTorrentsLoading(true); let torrents = await API.listTorrents().finally(() => - setTorrentsLoading(false) + setTorrentsLoading(false), ); setTorrents(torrents.torrents); - }; - setRefreshTorrents(refreshTorrents); + }, []); + + useEffect(() => { + setRefreshTorrents(refreshTorrents); + }, [refreshTorrents]); const setStats = useStatsStore((state) => state.setStats); @@ -59,15 +65,15 @@ export const RqbitWebUI = (props: { refreshTorrents().then( () => { setOtherError(null); - return 5000; + return 1000; }, (e) => { setOtherError({ text: "Error refreshing torrents", details: e }); console.error(e); - return 5000; - } + return 1000; + }, ), - 0 + 0, ); }, []); @@ -82,37 +88,45 @@ export const RqbitWebUI = (props: { (e) => { console.error(e); return 5000; - } + }, ), - 0 + 0, ); }, []); return ( -
-
-
- {/* Menu buttons */} -
- {props.menuButtons && - props.menuButtons.map((b, i) => {b})} - setLogsOpened(true)}> - - - - - + +
+
+
+ {/* Menu buttons */} +
+ {props.menuButtons && + props.menuButtons.map((b, i) => {b})} + setLogsOpened(true)}> + + + + + + setCompact(!compact)}> + + +
-
-
- -
+
+ +
-
+
- setLogsOpened(false)} /> - -
+ setLogsOpened(false)} + /> + +
+ ); }; diff --git a/crates/librqbit/webui/src/stores/errorStore.ts b/crates/librqbit/webui/src/stores/errorStore.ts index 99472113a..b43e5f74a 100644 --- a/crates/librqbit/webui/src/stores/errorStore.ts +++ b/crates/librqbit/webui/src/stores/errorStore.ts @@ -24,6 +24,6 @@ export const useErrorStore = create<{ setOtherError: (otherError) => set(() => ({ otherError })), alert: null, - setAlert: (alert) => set(() => ({alert})), - removeAlert: () => set(() => ({alert: null})) + setAlert: (alert) => set(() => ({ alert })), + removeAlert: () => set(() => ({ alert: null })), })); diff --git a/crates/librqbit/webui/src/stores/torrentStore.ts b/crates/librqbit/webui/src/stores/torrentStore.ts index 0fd9a7120..3b2fecd85 100644 --- a/crates/librqbit/webui/src/stores/torrentStore.ts +++ b/crates/librqbit/webui/src/stores/torrentStore.ts @@ -1,9 +1,9 @@ import { create } from "zustand"; -import { TorrentId } from "../api-types"; +import { TorrentIdWithStats } from "../api-types"; export interface TorrentStore { - torrents: Array | null; - setTorrents: (torrents: Array) => void; + torrents: Array | null; + setTorrents: (torrents: Array) => void; torrentsInitiallyLoading: boolean; torrentsLoading: boolean; @@ -11,30 +11,24 @@ export interface TorrentStore { refreshTorrents: () => void; setRefreshTorrents: (callback: () => void) => void; -} - -const torrentIdEquals = (t1: TorrentId, t2: TorrentId): boolean => { - return t1.id == t2.id && t1.info_hash == t2.info_hash; -}; - -const torrentsEquals = (t1: TorrentId[] | null, t2: TorrentId[] | null) => { - if (t1 === null && t2 === null) { - return true; - } - if (t1 === null || t2 === null) { - return false; - } - - return ( - t1.length === t2.length && t1.every((t, i) => torrentIdEquals(t, t2[i])) - ); -}; + selectedTorrentIds: number[]; + setSelectedTorrentId: (torrent: number) => void; + toggleSelectedTorrentId: (torrent: number) => void; +} export const useTorrentStore = create((set) => ({ torrents: null, torrentsLoading: false, torrentsInitiallyLoading: false, + selectedTorrentIds: [], + setSelectedTorrentId: (torrent) => set({ selectedTorrentIds: [torrent] }), + toggleSelectedTorrentId: (torrent) => + set((prev) => ({ + selectedTorrentIds: prev.selectedTorrentIds.includes(torrent) + ? prev.selectedTorrentIds.filter((id) => id !== torrent) + : [...prev.selectedTorrentIds, torrent], + })), setTorrentsLoading: (loading: boolean) => set((prev) => { if (prev.torrents == null) { @@ -44,9 +38,6 @@ export const useTorrentStore = create((set) => ({ }), setTorrents: (torrents) => set((prev) => { - if (torrentsEquals(prev.torrents, torrents)) { - return {}; - } return { torrents }; }), refreshTorrents: () => {}, diff --git a/crates/librqbit/webui/src/stores/viewMode.ts b/crates/librqbit/webui/src/stores/viewMode.ts new file mode 100644 index 000000000..60931c46b --- /dev/null +++ b/crates/librqbit/webui/src/stores/viewMode.ts @@ -0,0 +1,5 @@ +import { createContext } from "react"; + +export const ViewModeContext = createContext({ + compact: false, +}); diff --git a/crates/librqbit/webui/src/stores/viewModeStore.ts b/crates/librqbit/webui/src/stores/viewModeStore.ts new file mode 100644 index 000000000..7050d07dd --- /dev/null +++ b/crates/librqbit/webui/src/stores/viewModeStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +export interface ViewModeStore { + compact: boolean; + toggleCompact: () => void; +} + +export const useViewModeStore = create((set) => ({ + compact: false, + toggleCompact: () => set((state) => ({ compact: !state.compact })), +})); diff --git a/crates/librqbit_core/src/speed_estimator.rs b/crates/librqbit_core/src/speed_estimator.rs index 953d3b127..fee1341ed 100644 --- a/crates/librqbit_core/src/speed_estimator.rs +++ b/crates/librqbit_core/src/speed_estimator.rs @@ -1,42 +1,53 @@ use std::{ collections::VecDeque, - sync::atomic::{AtomicU64, Ordering}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; -use parking_lot::Mutex; - #[derive(Clone, Copy)] struct ProgressSnapshot { progress_bytes: u64, instant: Instant, } -/// Estimates download/upload speed in a sliding time window. -pub struct SpeedEstimator { - latest_per_second_snapshots: Mutex>, +struct Shared { bytes_per_second: AtomicU64, time_remaining_millis: AtomicU64, } -impl Default for SpeedEstimator { - fn default() -> Self { - Self::new(5) - } +pub struct Updater { + snapshots: VecDeque, + shared: Arc, +} + +/// Estimates download/upload speed in a sliding time window. +pub struct SpeedEstimator { + shared: Arc, } impl SpeedEstimator { - pub fn new(window_seconds: usize) -> Self { - assert!(window_seconds > 1); - Self { - latest_per_second_snapshots: Mutex::new(VecDeque::with_capacity(window_seconds)), + pub fn new(capacity: usize) -> (SpeedEstimator, Updater) { + assert!(capacity > 1); + let shared = Arc::new(Shared { bytes_per_second: Default::default(), time_remaining_millis: Default::default(), - } + }); + ( + SpeedEstimator { + shared: shared.clone(), + }, + Updater { + snapshots: VecDeque::with_capacity(capacity), + shared, + }, + ) } pub fn time_remaining(&self) -> Option { - let tr = self.time_remaining_millis.load(Ordering::Relaxed); + let tr = self.shared.time_remaining_millis.load(Ordering::Relaxed); if tr == 0 { return None; } @@ -44,36 +55,36 @@ impl SpeedEstimator { } pub fn bps(&self) -> u64 { - self.bytes_per_second.load(Ordering::Relaxed) + self.shared.bytes_per_second.load(Ordering::Relaxed) } pub fn mbps(&self) -> f64 { self.bps() as f64 / 1024f64 / 1024f64 } +} +impl Updater { pub fn add_snapshot( - &self, + &mut self, progress_bytes: u64, remaining_bytes: Option, instant: Instant, ) { let first = { - let mut g = self.latest_per_second_snapshots.lock(); - let current = ProgressSnapshot { progress_bytes, instant, }; - if g.is_empty() { - g.push_back(current); + if self.snapshots.is_empty() { + self.snapshots.push_back(current); return; - } else if g.len() < g.capacity() { - g.push_back(current); - g.front().copied().unwrap() + } else if self.snapshots.len() < self.snapshots.capacity() { + self.snapshots.push_back(current); + self.snapshots.front().copied().unwrap() } else { - let first = g.pop_front().unwrap(); - g.push_back(current); + let first = self.snapshots.pop_front().unwrap(); + self.snapshots.push_back(current); first } }; @@ -88,8 +99,11 @@ impl SpeedEstimator { } else { 0 }; - self.time_remaining_millis + self.shared + .time_remaining_millis .store(time_remaining_millis_rounded, Ordering::Relaxed); - self.bytes_per_second.store(bps as u64, Ordering::Relaxed); + self.shared + .bytes_per_second + .store(bps as u64, Ordering::Relaxed); } } diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index e92a8aab7..bf61a1670 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -19,7 +19,7 @@ interface InvokeErrorResponse { } function errorToUIError( - path: string + path: string, ): (e: InvokeErrorResponse) => Promise { return (e: InvokeErrorResponse) => { console.log(e); @@ -36,11 +36,11 @@ function errorToUIError( export async function invokeAPI( name: string, - params?: InvokeArgs + params?: InvokeArgs, ): Promise { console.log("invoking", name, params); const result = await invoke(name, params).catch( - errorToUIError(name) + errorToUIError(name), ); console.log(result); return result; @@ -86,7 +86,7 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { return { getStreamLogsUrl: () => { if (!httpBase) { - return null; + return ""; } return `${httpBase}/stream_logs`; }, @@ -107,7 +107,7 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { { contents, opts: opts ?? {}, - } + }, ); } return await invokeAPI("torrent_create_from_url", { @@ -138,12 +138,15 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { }, getPlaylistUrl: (index: number) => { if (!httpBase) { - return null; + return ""; } return `${httpBase}/torrents/${index}/playlist`; }, stats: () => { return invokeAPI("stats"); }, + getTorrentPeerStats: async function (id: number): Promise { + return []; + }, }; }; diff --git a/desktop/src/configure.tsx b/desktop/src/configure.tsx index ca69a0106..a93bf02ad 100644 --- a/desktop/src/configure.tsx +++ b/desktop/src/configure.tsx @@ -54,6 +54,8 @@ const FormInput: React.FC<{ ); }; +import { Tab, Tabs } from "rqbit-webui/src/components/tabs/Tabs"; + type TAB = | "Home" | "DHT" @@ -71,18 +73,6 @@ const TABS: readonly TAB[] = [ "UPnP Server", ] as const; -const Tab: React.FC<{ - name: TAB; - currentTab: TAB; - children: ReactNode; -}> = ({ name, currentTab, children }) => { - const show = name === currentTab; - if (!show) { - return; - } - return
{children}
; -}; - export const ConfigModal: React.FC<{ show: boolean; handleStartReconfigure: () => void; @@ -101,8 +91,6 @@ export const ConfigModal: React.FC<{ let [config, setConfig] = useState(initialConfig); let [loading, setLoading] = useState(false); - let [tab, setTab] = useState("Home"); - const [error, setError] = useState(null); const handleInputChange: React.ChangeEventHandler = (e) => { @@ -180,268 +168,250 @@ export const ConfigModal: React.FC<{ > -
- {TABS.map((t, i) => { - const isActive = t === tab; - let classNames = "text-slate-300"; - if (isActive) { - classNames = - "text-slate-800 border-b-2 border-blue-800 dark:border-blue-200 dark:text-white"; - } - return ( - - ); - })} -
- - - - - {defaultConfig.disable_upload !== undefined && - config.disable_upload !== undefined && ( + + + + + {defaultConfig.disable_upload !== undefined && + config.disable_upload !== undefined && ( + + )} + + + +
-Might be useful e.g. if rqbit upload consumes all your upload bandwidth and interferes with your other Internet usage." + - )} - - - -
- - + +
+
- -
-
- - -
- + +
+ - + - + - + - + - + - + - -
-
- - -
- + +
+
- -
-
- - -
- + +
+ - + +
+
- + +
+ - 0 - ? "current " + - formatBytes(config.ratelimits.download_bps ?? 0) + - " per second" - : "currently disabled" - })`} - /> + - 0 - ? "current " + - formatBytes(config.ratelimits.upload_bps ?? 0) + - " per second" - : "currently disabled" - })`} - /> -
-
- - -
- + - + 0 + ? "current " + + formatBytes(config.ratelimits.download_bps ?? 0) + + " per second" + : "currently disabled" + })`} + /> - -
-
+ 0 + ? "current " + + formatBytes(config.ratelimits.upload_bps ?? 0) + + " per second" + : "currently disabled" + })`} + /> +
+
+ + +
+ + + + + +
+
+
{!!handleCancel && ( diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 96b2d4b2d..15301d720 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -27,7 +27,7 @@ Promise.all([get_version(), get_default_config(), get_current_config()]).then( defaultConfig={defaultConfig} currentState={currentState} /> - + , ); - } + }, );