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 && (
+
+
+
+
+ | 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")}
+ |
+
+
+
+ {sortedPeers.map(([addr, peerStats]) => (
+
+ | {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.
+ ) : (
+
+
+
+
+ | 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")}
+ |
+
+
+
+ {sortedTorrentData.map((t) => (
+
+ ))}
+
+
+
+ )}
+
+ );
+ }
+
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 && (
+
+ )}
+
+
+
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+
+
+
-
+
+
+
- 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}
/>
-
+ ,
);
- }
+ },
);