diff --git a/index.html b/index.html index 6e4a8f2d4..8ba7f825b 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,10 @@ My Library + + + Server Queue +
@@ -105,6 +109,100 @@

My Games

+ + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b246ab39..b0f5dc337 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,8 @@ mod discord; mod proxy; #[cfg(feature = "tauri-app")] mod cursor; +#[cfg(feature = "tauri-app")] +mod queue; #[cfg(feature = "tauri-app")] use tauri::Manager; @@ -112,6 +114,9 @@ pub fn run() { cursor::stop_mouse_polling, cursor::get_accumulated_mouse_delta, cursor::is_mouse_polling_active, + // Queue data commands + queue::fetch_queue_data, + queue::fetch_server_mapping, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs new file mode 100644 index 000000000..2b84f77dc --- /dev/null +++ b/src-tauri/src/queue.rs @@ -0,0 +1,184 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tauri::command; + +/// Queue data for a single server +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerQueueData { + #[serde(rename = "QueuePosition")] + pub queue_position: i32, + #[serde(rename = "Last Updated")] + pub last_updated: Option, + #[serde(rename = "Region")] + pub region: Option, + #[serde(default)] + pub eta: Option, +} + +/// Response from the queue API +#[derive(Debug, Deserialize)] +pub struct QueueApiResponse { + pub status: bool, + #[serde(default)] + pub errors: Vec, + pub data: HashMap, +} + +/// Server mapping info from the config API +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerMappingInfo { + pub title: String, + pub region: String, + #[serde(default)] + pub is4080_server: bool, + #[serde(default)] + pub is5080_server: bool, + #[serde(default)] + pub nuked: bool, +} + +/// Response from the server mapping API +#[derive(Debug, Deserialize)] +pub struct ServerMappingApiResponse { + pub status: bool, + #[serde(default)] + pub errors: Vec, + pub data: HashMap, +} + +/// Combined queue info for a single server (with mapping enrichment) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnrichedServerQueue { + pub server_id: String, + pub queue_position: i32, + pub last_updated: Option, + pub eta_seconds: Option, + pub api_region: Option, + // From server mapping + pub title: Option, + pub region: Option, + pub is_4080_server: bool, + pub is_5080_server: bool, + pub nuked: bool, +} + +/// Full queue data response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueueDataResponse { + pub servers: Vec, + pub last_fetched: i64, +} + +const QUEUE_API_URL: &str = "https://api.printedwaste.com/gfn/queue/"; +const SERVER_MAPPING_URL: &str = "https://remote.printedwaste.com/config/GFN_SERVERID_TO_REGION_MAPPING"; + +/// Fetch queue data from the external API +#[command] +pub async fn fetch_queue_data() -> Result { + let client = reqwest::Client::new(); + + // Fetch queue data + let queue_response = client + .get(QUEUE_API_URL) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch queue data: {}", e))?; + + if !queue_response.status().is_success() { + let status = queue_response.status(); + let body = queue_response.text().await.unwrap_or_default(); + return Err(format!("Queue API failed with status {}: {}", status, body)); + } + + let queue_data: QueueApiResponse = queue_response + .json() + .await + .map_err(|e| format!("Failed to parse queue response: {}", e))?; + + if !queue_data.status { + return Err(format!("Queue API returned error: {:?}", queue_data.errors)); + } + + // Fetch server mapping + let mapping_response = client + .get(SERVER_MAPPING_URL) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch server mapping: {}", e))?; + + let server_mapping: HashMap = if mapping_response.status().is_success() { + match mapping_response.json::().await { + Ok(resp) if resp.status => resp.data, + _ => HashMap::new(), + } + } else { + HashMap::new() + }; + + // Combine queue data with server mapping + let mut servers: Vec = queue_data + .data + .into_iter() + .map(|(server_id, queue_info)| { + let mapping = server_mapping.get(&server_id); + EnrichedServerQueue { + server_id: server_id.clone(), + queue_position: queue_info.queue_position, + last_updated: queue_info.last_updated, + eta_seconds: queue_info.eta.map(|e| e / 1000), // Convert ms to seconds + api_region: queue_info.region, + title: mapping.map(|m| m.title.clone()), + region: mapping.map(|m| m.region.clone()), + is_4080_server: mapping.map(|m| m.is4080_server).unwrap_or(false), + is_5080_server: mapping.map(|m| m.is5080_server).unwrap_or(false), + nuked: mapping.map(|m| m.nuked).unwrap_or(false), + } + }) + .collect(); + + // Sort by queue position (lowest first) + servers.sort_by(|a, b| a.queue_position.cmp(&b.queue_position)); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + Ok(QueueDataResponse { + servers, + last_fetched: now, + }) +} + +/// Fetch only server mapping (for caching or standalone use) +#[command] +pub async fn fetch_server_mapping() -> Result, String> { + let client = reqwest::Client::new(); + + let response = client + .get(SERVER_MAPPING_URL) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch server mapping: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Server mapping API failed with status {}: {}", status, body)); + } + + let mapping_response: ServerMappingApiResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse server mapping response: {}", e))?; + + if !mapping_response.status { + return Err(format!("Server mapping API returned error: {:?}", mapping_response.errors)); + } + + Ok(mapping_response.data) +} diff --git a/src/main.ts b/src/main.ts index 61e6252f6..7f883bf72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -363,6 +363,25 @@ interface ActiveSession { fps: number | null; } +// Queue data types +interface EnrichedServerQueue { + server_id: string; + queue_position: number; + last_updated: number | null; + eta_seconds: number | null; + api_region: string | null; + title: string | null; + region: string | null; + is_4080_server: boolean; + is_5080_server: boolean; + nuked: boolean; +} + +interface QueueDataResponse { + servers: EnrichedServerQueue[]; + last_fetched: number; +} + // Active session state let detectedActiveSessions: ActiveSession[] = []; let pendingGameLaunch: Game | null = null; @@ -963,6 +982,9 @@ document.addEventListener("DOMContentLoaded", async () => { // Setup search setupSearch(); + // Setup queue view + setupQueueView(); + // Load saved settings await loadSettings(); @@ -1122,6 +1144,8 @@ function switchView(view: string) { loadLibraryData(); } else if (view === "store") { loadStoreData(); + } else if (view === "queue") { + loadQueueData(); } } @@ -2315,6 +2339,259 @@ async function loadStoreData() { renderGamesGrid("all-games", placeholderGames); } +// Queue state +let cachedQueueData: QueueDataResponse | null = null; +let queueRegionFilter = "all"; +let queueGpuFilter = "all"; +let hideNukedServers = true; + +// Load queue data from API +async function loadQueueData() { + console.log("Loading queue data..."); + + const loadingEl = document.getElementById("queue-loading"); + const errorEl = document.getElementById("queue-error"); + const tableContainer = document.getElementById("queue-table-container"); + + // Show loading state + if (loadingEl) loadingEl.classList.remove("hidden"); + if (errorEl) errorEl.classList.add("hidden"); + if (tableContainer) tableContainer.classList.add("hidden"); + + try { + const queueData = await invoke("fetch_queue_data"); + cachedQueueData = queueData; + console.log("Queue data loaded:", queueData.servers.length, "servers"); + + // Hide loading, show table + if (loadingEl) loadingEl.classList.add("hidden"); + if (tableContainer) tableContainer.classList.remove("hidden"); + + // Render the queue data + renderQueueData(queueData); + updateQueueStats(queueData); + + } catch (error) { + console.error("Failed to load queue data:", error); + if (loadingEl) loadingEl.classList.add("hidden"); + if (errorEl) { + errorEl.classList.remove("hidden"); + const errorMsg = document.getElementById("queue-error-message"); + if (errorMsg) errorMsg.textContent = `Failed to load queue data: ${error}`; + } + } +} + +// Render queue data in the table +function renderQueueData(data: QueueDataResponse) { + const tbody = document.getElementById("queue-table-body"); + if (!tbody) return; + + // Clear existing rows + tbody.replaceChildren(); + + // Filter servers based on current filters + let filteredServers = data.servers; + + // Filter by region + if (queueRegionFilter !== "all") { + const filterUpper = queueRegionFilter.toUpperCase(); + filteredServers = filteredServers.filter(s => + s.api_region === queueRegionFilter || + (s.region && s.region.toUpperCase().includes(filterUpper)) + ); + } + + // Filter by GPU + if (queueGpuFilter !== "all") { + if (queueGpuFilter === "4080") { + filteredServers = filteredServers.filter(s => s.is_4080_server); + } else if (queueGpuFilter === "5080") { + filteredServers = filteredServers.filter(s => s.is_5080_server); + } else if (queueGpuFilter === "other") { + filteredServers = filteredServers.filter(s => !s.is_4080_server && !s.is_5080_server); + } + } + + // Hide nuked servers if enabled + if (hideNukedServers) { + filteredServers = filteredServers.filter(s => !s.nuked); + } + + // Render each server row + filteredServers.forEach(server => { + const row = document.createElement("tr"); + if (server.nuked) row.classList.add("nuked"); + + // Server ID + const serverIdCell = document.createElement("td"); + const serverIdSpan = document.createElement("span"); + serverIdSpan.className = "server-id"; + serverIdSpan.textContent = server.server_id; + serverIdCell.appendChild(serverIdSpan); + row.appendChild(serverIdCell); + + // Location (title from mapping) + const locationCell = document.createElement("td"); + locationCell.textContent = server.title || "--"; + row.appendChild(locationCell); + + // Region + const regionCell = document.createElement("td"); + regionCell.textContent = server.region || server.api_region || "--"; + row.appendChild(regionCell); + + // GPU Type + const gpuCell = document.createElement("td"); + const gpuBadge = document.createElement("span"); + gpuBadge.className = "gpu-badge"; + if (server.is_5080_server) { + gpuBadge.classList.add("gpu-5080"); + gpuBadge.textContent = "RTX 5080"; + } else if (server.is_4080_server) { + gpuBadge.classList.add("gpu-4080"); + gpuBadge.textContent = "RTX 4080"; + } else { + gpuBadge.classList.add("gpu-other"); + gpuBadge.textContent = "Other"; + } + gpuCell.appendChild(gpuBadge); + row.appendChild(gpuCell); + + // Queue Position + const queueCell = document.createElement("td"); + const queueSpan = document.createElement("span"); + queueSpan.className = "queue-position"; + queueSpan.textContent = String(server.queue_position); + + // Color code based on queue length + if (server.queue_position <= 10) { + queueSpan.classList.add("low"); + } else if (server.queue_position <= 30) { + queueSpan.classList.add("medium"); + } else if (server.queue_position <= 60) { + queueSpan.classList.add("high"); + } else { + queueSpan.classList.add("very-high"); + } + queueCell.appendChild(queueSpan); + row.appendChild(queueCell); + + // ETA + const etaCell = document.createElement("td"); + etaCell.className = "eta"; + if (server.eta_seconds !== null && server.eta_seconds > 0) { + etaCell.textContent = formatEta(server.eta_seconds); + } else { + etaCell.textContent = "--"; + } + row.appendChild(etaCell); + + // Status + const statusCell = document.createElement("td"); + const statusBadge = document.createElement("span"); + statusBadge.className = "status-badge"; + if (server.nuked) { + statusBadge.classList.add("inactive"); + statusBadge.textContent = "Inactive"; + } else { + statusBadge.classList.add("active"); + statusBadge.textContent = "Active"; + } + statusCell.appendChild(statusBadge); + row.appendChild(statusCell); + + tbody.appendChild(row); + }); + + // Re-init Lucide icons + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } +} + +// Format ETA in human-readable format +function formatEta(seconds: number): string { + if (seconds < 60) { + return `${seconds}s`; + } else if (seconds < 3600) { + const mins = Math.floor(seconds / 60); + return `${mins}m`; + } else { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + } +} + +// Update queue statistics display +function updateQueueStats(data: QueueDataResponse) { + const activeServers = data.servers.filter(s => !s.nuked); + + // Total active servers + const totalServersEl = document.getElementById("queue-total-servers"); + if (totalServersEl) { + totalServersEl.textContent = String(activeServers.length); + } + + // Average queue position + const avgPositionEl = document.getElementById("queue-avg-position"); + if (avgPositionEl && activeServers.length > 0) { + const avg = Math.round( + activeServers.reduce((sum, s) => sum + s.queue_position, 0) / activeServers.length + ); + avgPositionEl.textContent = String(avg); + } + + // Best server (shortest queue) + const bestServerEl = document.getElementById("queue-best-server"); + if (bestServerEl && activeServers.length > 0) { + const best = activeServers.reduce((min, s) => + s.queue_position < min.queue_position ? s : min + ); + bestServerEl.textContent = String(best.queue_position); + } + + // Last updated + const lastUpdatedEl = document.getElementById("queue-last-updated"); + if (lastUpdatedEl) { + const date = new Date(data.last_fetched * 1000); + lastUpdatedEl.textContent = date.toLocaleTimeString(); + } +} + +// Setup queue view event handlers +function setupQueueView() { + // Refresh button + document.getElementById("refresh-queue-btn")?.addEventListener("click", () => { + loadQueueData(); + }); + + // Region filter + onDropdownChange("queue-region-filter", (value) => { + queueRegionFilter = value; + if (cachedQueueData) { + renderQueueData(cachedQueueData); + } + }); + + // GPU filter + onDropdownChange("queue-gpu-filter", (value) => { + queueGpuFilter = value; + if (cachedQueueData) { + renderQueueData(cachedQueueData); + } + }); + + // Hide nuked servers checkbox + document.getElementById("hide-nuked-servers")?.addEventListener("change", (e) => { + hideNukedServers = (e.target as HTMLInputElement).checked; + if (cachedQueueData) { + renderQueueData(cachedQueueData); + } + }); +} + // Generate fallback placeholder SVG function getFallbackPlaceholder(title: string): string { const svg = ` diff --git a/src/styles/main.css b/src/styles/main.css index cca7b254d..6e6ba5043 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1622,3 +1622,224 @@ select { #submit-token-btn { width: 100%; } + +/* Queue View Styles */ +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 16px; +} + +.queue-header h1 { + margin: 0; + color: var(--accent-green); +} + +.queue-controls { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.queue-filters { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.queue-filter-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--text-secondary); + cursor: pointer; +} + +.queue-filter-checkbox input { + accent-color: var(--accent-green); +} + +.queue-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.queue-stat-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + text-align: center; +} + +.queue-stat-card .stat-value { + display: block; + font-size: 28px; + font-weight: bold; + color: var(--accent-green); + margin-bottom: 4px; +} + +.queue-stat-card .stat-label { + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.queue-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text-secondary); + gap: 12px; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color); + border-top-color: var(--accent-green); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.queue-error { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: #f44336; + gap: 8px; +} + +.queue-table-container { + overflow-x: auto; +} + +.queue-table { + width: 100%; + border-collapse: collapse; + background: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; +} + +.queue-table th, +.queue-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.queue-table th { + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.queue-table tbody tr:hover { + background: var(--bg-tertiary); +} + +.queue-table tbody tr.nuked { + opacity: 0.5; +} + +.queue-table .server-id { + font-family: monospace; + font-size: 13px; + color: var(--text-primary); +} + +.queue-table .queue-position { + font-weight: bold; + font-size: 16px; +} + +.queue-position.low { + color: #4caf50; +} + +.queue-position.medium { + color: #ffc107; +} + +.queue-position.high { + color: #ff9800; +} + +.queue-position.very-high { + color: #f44336; +} + +.queue-table .eta { + color: var(--text-secondary); + font-size: 13px; +} + +.queue-table .gpu-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.gpu-badge.gpu-4080 { + background: rgba(118, 185, 0, 0.2); + color: #76b900; +} + +.gpu-badge.gpu-5080 { + background: rgba(0, 168, 232, 0.2); + color: #00a8e8; +} + +.gpu-badge.gpu-other { + background: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); +} + +.status-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.status-badge.active { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; +} + +.status-badge.inactive { + background: rgba(244, 67, 54, 0.2); + color: #f44336; +} + +.btn-icon { + width: 14px; + height: 14px; + margin-right: 6px; +}