From 88ca5960dfdb6fd6e01997e9f9d09c8e6d9c09e7 Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Mon, 20 Nov 2023 20:15:40 +0000 Subject: [PATCH] Basic webui works --- crates/librqbit/Cargo.toml | 1 + crates/librqbit/src/http_api.rs | 54 +++++- crates/librqbit/webui/app.js | 212 +++++++++++++++++++++ crates/librqbit/webui/app.ts | 274 ++++++++++++++++++++++++++++ crates/librqbit/webui/index.html | 31 ++++ crates/librqbit/webui/tsconfig.json | 9 + crates/rqbit/Cargo.toml | 3 +- 7 files changed, 574 insertions(+), 10 deletions(-) create mode 100644 crates/librqbit/webui/app.js create mode 100644 crates/librqbit/webui/app.ts create mode 100644 crates/librqbit/webui/index.html create mode 100644 crates/librqbit/webui/tsconfig.json diff --git a/crates/librqbit/Cargo.toml b/crates/librqbit/Cargo.toml index 3f23817..00cafdd 100644 --- a/crates/librqbit/Cargo.toml +++ b/crates/librqbit/Cargo.toml @@ -13,6 +13,7 @@ readme = "README.md" [features] default = ["sha1-system", "default-tls"] +webui = [] timed_existence = [] sha1-system = ["sha1w/sha1-system"] sha1-openssl = ["sha1w/sha1-openssl"] diff --git a/crates/librqbit/src/http_api.rs b/crates/librqbit/src/http_api.rs index d073ab6..ffe2596 100644 --- a/crates/librqbit/src/http_api.rs +++ b/crates/librqbit/src/http_api.rs @@ -118,7 +118,7 @@ impl HttpApi { state.api_peer_stats(idx, filter).map(axum::Json) } - let app = Router::new() + let mut app = Router::new() .route("/", get(api_root)) .route("/dht/stats", get(dht_stats)) .route("/dht/table", get(dht_table)) @@ -126,19 +126,55 @@ impl HttpApi { .route("/torrents/:id", get(torrent_details)) .route("/torrents/:id/haves", get(torrent_haves)) .route("/torrents/:id/stats", get(torrent_stats)) - .route("/torrents/:id/peer_stats", get(peer_stats)) - .layer( - tower_http::cors::CorsLayer::default() - .allow_origin(AllowOrigin::predicate(|_, _| true)) - .allow_headers(AllowHeaders::any()), - ) + .route("/torrents/:id/peer_stats", get(peer_stats)); + + #[cfg(feature = "webui")] + { + let webui_router = Router::new() + .route( + "/", + get(|| async { + ( + [("Content-Type", "text/html")], + include_str!("../webui/index.html"), + ) + }), + ) + .route( + "/app.js", + get(|| async { + ( + [("Content-Type", "application/javascript")], + include_str!("../webui/app.js"), + ) + }), + ); + + let cors_layer = { + #[cfg(debug_assertions)] + { + tower_http::cors::CorsLayer::default() + .allow_origin(AllowOrigin::predicate(|_, _| true)) + .allow_headers(AllowHeaders::any()) + } + #[cfg(not(debug_assertions))] + { + tower_http::cors::CorsLayer::default() + } + }; + + app = app.nest("/web/", webui_router).layer(cors_layer); + } + + let app = app .layer(tower_http::trace::TraceLayer::new_for_http()) - .with_state(state); + .with_state(state) + .into_make_service(); info!("starting HTTP server on {}", addr); axum::Server::try_bind(&addr) .with_context(|| format!("error binding to {addr}"))? - .serve(app.into_make_service()) + .serve(app) .await?; Ok(()) } diff --git a/crates/librqbit/webui/app.js b/crates/librqbit/webui/app.js new file mode 100644 index 0000000..c6464e5 --- /dev/null +++ b/crates/librqbit/webui/app.js @@ -0,0 +1,212 @@ +// Define API URL and base path +const apiUrl = window.origin == null ? 'http://localhost:3030' : ''; + +// Helper function for making API requests (async/await) +async function makeRequest(method, path, data) { + const url = apiUrl + path; + const options = { + method, + headers: { + 'Accept': 'application/json', + }, + body: data, + }; + try { + const response = await fetch(url, options); + if (!response.ok) { + const errorBody = await response.text(); + try { + const json = JSON.parse(errorBody); + displayApiError({ + status: response.status, + statusText: response.statusText, + body: json.human_readable !== undefined ? json.human_readable : errorBody, + }); + } + catch (e) { + displayApiError({ + status: response.status, + statusText: response.statusText, + body: errorBody, + }); + } + return Promise.reject(errorBody); + } + const result = await response.json(); + return result; + } + catch (error) { + console.error(error); + displayApiError({ + status: error.status, + statusText: error.statusText, + body: error.toString(), + }); + return Promise.reject(`Error: ${error.message}`); + } +} +// Helper function to display the API response +function displayResult(result) { + const outputDiv = document.getElementById('output'); + if (outputDiv) { + outputDiv.innerHTML = `
${result}
`; + } +} +// Function to get detailed information about a torrent (async/await) +async function getTorrentDetails(index) { + return makeRequest('GET', `/torrents/${index}`); +} +// Function to get detailed statistics about a torrent (async/await) +async function getTorrentStats(index) { + return makeRequest('GET', `/torrents/${index}/stats`); +} +// Display function for listing all torrents with concise information (async/await) +async function displayTorrents() { + try { + const response = await makeRequest('GET', '/torrents'); + const torrents = response.torrents; + // Create a container for all torrents using Bootstrap classes + const torrentsContainer = document.createElement('div'); + torrentsContainer.classList.add('d-flex', 'flex-column', 'torrents-container'); + for (const torrent of torrents) { + const detailsResponse = await getTorrentDetails(torrent.id); + const statsResponse = await getTorrentStats(torrent.id); + const totalBytes = detailsResponse.files.reduce((total, file) => total + file.length, 0); + const downloadedBytes = statsResponse.snapshot.have_bytes; + // Calculate download percentage + const downloadPercentage = (downloadedBytes / totalBytes) * 100; + // Create a container for each torrent using Bootstrap classes + const torrentContainer = document.createElement('div'); + torrentContainer.classList.add('torrent-container', 'd-flex', 'flex-row', 'p-3', 'bg-light', 'rounded', 'mb-3'); + // Display basic information about the torrent + const largestFileName = getLargestFileName(detailsResponse); + const downloadSpeed = statsResponse.download_speed.human_readable; + const eta = getCompletionETA(statsResponse); + // Create and append divs for concise information as columns + const nameColumn = createColumn('Name', largestFileName); + const sizeColumn = createColumn('Size', `${formatBytesToGB(totalBytes)} GB`); + const progressColumn = createColumnWithProgressBar('Progress', downloadPercentage); + const downloadSpeedColumn = createColumn('Download Speed', downloadSpeed); + const etaColumn = createColumn('ETA', eta); + // Append columns to the torrent container + torrentContainer.appendChild(nameColumn); + torrentContainer.appendChild(sizeColumn); + torrentContainer.appendChild(progressColumn); + torrentContainer.appendChild(downloadSpeedColumn); + torrentContainer.appendChild(etaColumn); + // Append the torrent container to the torrentsContainer + torrentsContainer.appendChild(torrentContainer); + } + // Replace the old content with the new one + const outputDiv = document.getElementById('output'); + if (outputDiv) { + outputDiv.innerHTML = ''; + outputDiv.appendChild(torrentsContainer); + } + } + catch (error) { + console.error(error); + } +} +// Function to create a column div +function createColumn(label, value) { + const columnDiv = document.createElement('div'); + columnDiv.classList.add('me-3', 'p-2'); + columnDiv.innerHTML = `

${label}

${value}

`; + return columnDiv; +} +// Function to create a column div with a progress bar +function createColumnWithProgressBar(label, percentage) { + const columnDiv = document.createElement('div'); + columnDiv.classList.add('column', 'me-3', 'p-2'); + columnDiv.innerHTML = ` +

${label}

+
+
+
+

${percentage.toFixed(2)}%

`; + return columnDiv; +} +// Function to format bytes to GB +function formatBytesToGB(bytes) { + const GB = bytes / (1024 * 1024 * 1024); + return GB.toFixed(2); +} +// Function to get the name of the largest file in a torrent +function getLargestFileName(torrentDetails) { + const largestFile = torrentDetails.files.reduce((prev, current) => (prev.length > current.length) ? prev : current); + return largestFile.name; +} +// Function to get the completion ETA of a torrent +function getCompletionETA(stats) { + if (stats.time_remaining) { + return stats.time_remaining.human_readable; + } + else { + return 'N/A'; + } +} +// Helper function to display API errors in an alert +function displayApiError(error) { + const errorAlert = document.getElementById('error-alert'); + if (errorAlert) { + errorAlert.innerHTML = ` + + `; + } +} +// Helper function to clear the error alert +function clearErrorAlert() { + const errorAlert = document.getElementById('error-alert'); + if (errorAlert) { + errorAlert.innerHTML = ''; // Clear the content + } +} +// List all torrents on page load and set up auto-refresh +async function init() { + try { + await displayTorrents(); + autoRefreshTorrents(5000); // Set the interval (in milliseconds), e.g., 5000 for every 5 seconds + } + catch (error) { + console.error(error); + } +} +// Function to refresh torrents at a specified interval +function autoRefreshTorrents(interval) { + setInterval(async () => { + await displayTorrents(); + }, interval); +} +// Function to add a torrent from a magnet link +async function addTorrentFromMagnet() { + const magnetLink = prompt('Enter magnet link:'); + if (magnetLink) { + await makeRequest('POST', '/torrents?overwrite=true', magnetLink); + await displayTorrents(); // Refresh the torrent list after adding a new torrent + } +} +// Function to handle file input change +async function handleFileInputChange() { + const fileInput = document.getElementById('file-input'); + const file = fileInput.files?.[0]; + if (file) { + await makeRequest('POST', '/torrents?overwrite=true', file); + await displayTorrents(); // Refresh the torrent list after adding a new torrent + } +} +// Add event listeners for buttons +document.getElementById('add-magnet-button')?.addEventListener('click', addTorrentFromMagnet); +// Update the event listener for the file input button +const fileInputButton = document.getElementById('upload-file-button'); +fileInputButton?.addEventListener('click', () => { + const fileInput = document.getElementById('file-input'); + fileInput.click(); +}); +document.getElementById('file-input')?.addEventListener('change', handleFileInputChange); +// Call init function on page load +document.addEventListener('DOMContentLoaded', init); diff --git a/crates/librqbit/webui/app.ts b/crates/librqbit/webui/app.ts new file mode 100644 index 0000000..b81a2d2 --- /dev/null +++ b/crates/librqbit/webui/app.ts @@ -0,0 +1,274 @@ +// Define API URL and base path +const apiUrl = 'http://localhost:3030'; + +// Interface for the Torrent API response +interface Torrent { + id: number; + info_hash: string; +} + +// Interface for the Torrent Details API response +interface TorrentDetails { + files: { + name: string; + length: number; + included: boolean; + }[]; +} + +// Interface for the Torrent Stats API response +interface TorrentStats { + snapshot: { + have_bytes: number; + remaining_bytes: number; + }; + download_speed: { + mbps: number; + human_readable: string; + }; + time_remaining?: { + human_readable: string; + }; +} + +// Interface for the API error response +interface ApiError { + status: number; + statusText: string; + body: string; +} + +// Helper function for making API requests (async/await) +async function makeRequest(method: string, path: string, data?: any): Promise { + const url = apiUrl + path; + const options: RequestInit = { + method, + headers: { + 'Accept': 'application/json', + }, + body: data, + }; + + try { + const response = await fetch(url, options); + if (!response.ok) { + const errorBody = await response.text(); + try { + const json = JSON.parse(errorBody); + displayApiError({ + status: response.status, + statusText: response.statusText, + body: json.human_readable !== undefined ? json.human_readable : errorBody, + }); + } catch (e) { + displayApiError({ + status: response.status, + statusText: response.statusText, + body: errorBody, + }); + } + return Promise.reject(errorBody); + } + const result = await response.json(); + return result; + } catch (error) { + console.error(error); + displayApiError({ + status: error.status, + statusText: error.statusText, + body: error.toString(), + }); + return Promise.reject(`Error: ${error.message}`); + } +} + +// Helper function to display the API response +function displayResult(result: string): void { + const outputDiv = document.getElementById('output'); + if (outputDiv) { + outputDiv.innerHTML = `
${result}
`; + } +} + +// Function to get detailed information about a torrent (async/await) +async function getTorrentDetails(index: number): Promise { + return makeRequest('GET', `/torrents/${index}`); +} + +// Function to get detailed statistics about a torrent (async/await) +async function getTorrentStats(index: number): Promise { + return makeRequest('GET', `/torrents/${index}/stats`); +} + +// Display function for listing all torrents with concise information (async/await) +async function displayTorrents(): Promise { + try { + const response = await makeRequest('GET', '/torrents'); + const torrents: Torrent[] = response.torrents; + + // Create a container for all torrents using Bootstrap classes + const torrentsContainer = document.createElement('div'); + torrentsContainer.classList.add('d-flex', 'flex-column', 'torrents-container'); + + for (const torrent of torrents) { + const detailsResponse = await getTorrentDetails(torrent.id); + const statsResponse = await getTorrentStats(torrent.id); + + const totalBytes = detailsResponse.files.reduce((total: number, file: any) => total + file.length, 0); + const downloadedBytes = statsResponse.snapshot.have_bytes; + + // Calculate download percentage + const downloadPercentage = (downloadedBytes / totalBytes) * 100; + + // Create a container for each torrent using Bootstrap classes + const torrentContainer = document.createElement('div'); + torrentContainer.classList.add('torrent-container', 'd-flex', 'flex-row', 'p-3', 'bg-light', 'rounded', 'mb-3'); + + // Display basic information about the torrent + const largestFileName = getLargestFileName(detailsResponse); + const downloadSpeed = statsResponse.download_speed.human_readable; + const eta = getCompletionETA(statsResponse); + + // Create and append divs for concise information as columns + const nameColumn = createColumn('Name', largestFileName); + const sizeColumn = createColumn('Size', `${formatBytesToGB(totalBytes)} GB`); + const progressColumn = createColumnWithProgressBar('Progress', downloadPercentage); + const downloadSpeedColumn = createColumn('Download Speed', downloadSpeed); + const etaColumn = createColumn('ETA', eta); + + // Append columns to the torrent container + torrentContainer.appendChild(nameColumn); + torrentContainer.appendChild(sizeColumn); + torrentContainer.appendChild(progressColumn); + torrentContainer.appendChild(downloadSpeedColumn); + torrentContainer.appendChild(etaColumn); + + // Append the torrent container to the torrentsContainer + torrentsContainer.appendChild(torrentContainer); + } + + // Replace the old content with the new one + const outputDiv = document.getElementById('output'); + if (outputDiv) { + outputDiv.innerHTML = ''; + outputDiv.appendChild(torrentsContainer); + } + } catch (error) { + console.error(error); + } +} + +// Function to create a column div +function createColumn(label: string, value: string): HTMLDivElement { + const columnDiv = document.createElement('div'); + columnDiv.classList.add('me-3', 'p-2'); + columnDiv.innerHTML = `

${label}

${value}

`; + return columnDiv; +} + +// Function to create a column div with a progress bar +function createColumnWithProgressBar(label: string, percentage: number): HTMLDivElement { + const columnDiv = document.createElement('div'); + columnDiv.classList.add('column', 'me-3', 'p-2'); + columnDiv.innerHTML = ` +

${label}

+
+
+
+

${percentage.toFixed(2)}%

`; + return columnDiv; +} + +// Function to format bytes to GB +function formatBytesToGB(bytes: number): string { + const GB = bytes / (1024 * 1024 * 1024); + return GB.toFixed(2); +} + +// Function to get the name of the largest file in a torrent +function getLargestFileName(torrentDetails: TorrentDetails): string { + const largestFile = torrentDetails.files.reduce((prev: any, current: any) => (prev.length > current.length) ? prev : current); + return largestFile.name; +} + +// Function to get the completion ETA of a torrent +function getCompletionETA(stats: TorrentStats): string { + if (stats.time_remaining) { + return stats.time_remaining.human_readable; + } else { + return 'N/A'; + } +} + +// Helper function to display API errors in an alert +function displayApiError(error: ApiError): void { + const errorAlert = document.getElementById('error-alert'); + if (errorAlert) { + errorAlert.innerHTML = ` + + `; + } +} + +// Helper function to clear the error alert +function clearErrorAlert(): void { + const errorAlert = document.getElementById('error-alert'); + if (errorAlert) { + errorAlert.innerHTML = ''; // Clear the content + } +} + +// List all torrents on page load and set up auto-refresh +async function init(): Promise { + try { + await displayTorrents(); + autoRefreshTorrents(5000); // Set the interval (in milliseconds), e.g., 5000 for every 5 seconds + } catch (error) { + console.error(error); + } +} + +// Function to refresh torrents at a specified interval +function autoRefreshTorrents(interval: number): void { + setInterval(async () => { + await displayTorrents(); + }, interval); +} + +// Function to add a torrent from a magnet link +async function addTorrentFromMagnet(): Promise { + const magnetLink = prompt('Enter magnet link:'); + if (magnetLink) { + await makeRequest('POST', '/torrents?overwrite=true', magnetLink); + await displayTorrents(); // Refresh the torrent list after adding a new torrent + } +} + +// Function to handle file input change +async function handleFileInputChange(): Promise { + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const file = fileInput.files?.[0]; + if (file) { + await makeRequest('POST', '/torrents?overwrite=true', file); + await displayTorrents(); // Refresh the torrent list after adding a new torrent + } +} + +// Add event listeners for buttons +document.getElementById('add-magnet-button')?.addEventListener('click', addTorrentFromMagnet); + +// Update the event listener for the file input button +const fileInputButton = document.getElementById('upload-file-button'); +fileInputButton?.addEventListener('click', () => { + const fileInput = document.getElementById('file-input') as HTMLInputElement; + fileInput.click(); +}); + +document.getElementById('file-input')?.addEventListener('change', handleFileInputChange); + +// Call init function on page load +document.addEventListener('DOMContentLoaded', init); diff --git a/crates/librqbit/webui/index.html b/crates/librqbit/webui/index.html new file mode 100644 index 0000000..317b17e --- /dev/null +++ b/crates/librqbit/webui/index.html @@ -0,0 +1,31 @@ + + + + + + + rqbit web 0.0.1-alpha + + + + + +
+

rqbit web 0.0.1-alpha

+ +
+
+
+ + + +
+
+ + + + + + + + \ No newline at end of file diff --git a/crates/librqbit/webui/tsconfig.json b/crates/librqbit/webui/tsconfig.json new file mode 100644 index 0000000..ea31c01 --- /dev/null +++ b/crates/librqbit/webui/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "es2015" + ], + } +} \ No newline at end of file diff --git a/crates/rqbit/Cargo.toml b/crates/rqbit/Cargo.toml index 7dd4a84..00074f3 100644 --- a/crates/rqbit/Cargo.toml +++ b/crates/rqbit/Cargo.toml @@ -12,7 +12,8 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["sha1-system", "default-tls"] +default = ["sha1-system", "default-tls", "webui"] +webui = ["librqbit/webui"] timed_existence = ["librqbit/timed_existence"] sha1-system = ["librqbit/sha1-system"] sha1-openssl = ["librqbit/sha1-openssl"]