From 063a8054461e43adf92208de3b50c9a51fecf434 Mon Sep 17 00:00:00 2001 From: "MAINMETALGEAR\\ededd" Date: Sat, 14 Dec 2024 13:40:22 -0500 Subject: [PATCH 1/2] add retry logic --- frontend/src/components/PlayerMatches.tsx | 81 +++++++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/PlayerMatches.tsx b/frontend/src/components/PlayerMatches.tsx index de1c8ea..226971b 100644 --- a/frontend/src/components/PlayerMatches.tsx +++ b/frontend/src/components/PlayerMatches.tsx @@ -70,22 +70,89 @@ const PlayerMatches: React.FC = () => { const chartRef = useRef(null); useEffect(() => { + // Utility function for retrying failed requests + const fetchWithRetry = async (url: string, options: RequestInit, maxRetries = 3, delay = 500) => { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, options); + if (response.ok) { + return response; + } + console.log(`Attempt ${attempt} failed for ${url}:`, await response.text().catch(() => 'No error text')); + + if (attempt === maxRetries) { + return response; // Return the failed response on last attempt + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, delay * attempt)); + } catch (error) { + console.error(`Attempt ${attempt} error for ${url}:`, error); + if (attempt === maxRetries) { + throw error; + } + await new Promise(resolve => setTimeout(resolve, delay * attempt)); + } + } + throw new Error('All retry attempts failed'); + }; + const fetchPlayerAndMatches = async () => { setLoading(true); try { + const encodedName = encodeURIComponent(playerName || ''); + console.log('Making requests with:', { + originalName: playerName, + encodedName: encodedName + }); + + const requestOptions = { + method: 'GET', + mode: 'cors' as RequestMode, + credentials: 'include' as RequestCredentials, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }; + + const baseUrl = window.location.origin; + console.log('Base URL:', baseUrl); + console.log('Full URLs:', { + player: `${baseUrl}/api/players/name/${encodedName}`, + matches: `${baseUrl}/api/matches/player/${encodedName}`, + elo: `${baseUrl}/api/player_elo/${encodedName}` + }); + + // Make parallel requests with retry logic const [playerResponse, matchesResponse, eloHistoryResponse] = await Promise.all([ - fetch(`/api/players/name/${encodeURIComponent(playerName || '')}`), - fetch(`/api/matches/player/${encodeURIComponent(playerName || '')}`), - fetch(`/api/player_elo/${encodeURIComponent(playerName || '')}`) + fetchWithRetry(`${baseUrl}/api/players/name/${encodedName}`, requestOptions), + fetchWithRetry(`${baseUrl}/api/matches/player/${encodedName}`, requestOptions), + fetchWithRetry(`${baseUrl}/api/player_elo/${encodedName}`, requestOptions) ]); + // Check if any requests still failed after retries if (!playerResponse.ok || !matchesResponse.ok || !eloHistoryResponse.ok) { - throw new Error('Failed to fetch player data, matches, or ELO history'); + const errors = []; + if (!playerResponse.ok) { + errors.push(`Player data: ${await playerResponse.text().catch(() => 'Unknown error')}`); + } + if (!matchesResponse.ok) { + errors.push(`Matches data: ${await matchesResponse.text().catch(() => 'Unknown error')}`); + } + if (!eloHistoryResponse.ok) { + errors.push(`ELO data: ${await eloHistoryResponse.text().catch(() => 'Unknown error')}`); + } + throw new Error(`Requests failed after retries: ${errors.join(', ')}`); } - const playerData = await playerResponse.json(); - const matchesData = await matchesResponse.json(); - const eloHistoryData = await eloHistoryResponse.json(); + // Get all data in parallel + const [playerData, matchesData, eloHistoryData] = await Promise.all([ + playerResponse.json(), + matchesResponse.json(), + eloHistoryResponse.json() + ]); setPlayer(playerData); setMatches(matchesData); From 5a29bbc28cd6b0aac48642c7d3797e0ee50fef59 Mon Sep 17 00:00:00 2001 From: "MAINMETALGEAR\\ededd" Date: Mon, 16 Dec 2024 12:49:28 -0500 Subject: [PATCH 2/2] Update APIs to handle multiple jobs at once and update frontend to reduce number of API calls --- frontend/src/components/MatchesTable.tsx | 192 ++++++++++------------ frontend/src/components/PlayerMatches.tsx | 42 ++--- src/controllers/matches.rs | 70 +++++++- src/controllers/players.rs | 71 ++++++++ 4 files changed, 242 insertions(+), 133 deletions(-) diff --git a/frontend/src/components/MatchesTable.tsx b/frontend/src/components/MatchesTable.tsx index 340d0b4..a6a9ae9 100644 --- a/frontend/src/components/MatchesTable.tsx +++ b/frontend/src/components/MatchesTable.tsx @@ -7,21 +7,25 @@ interface Player { id: number; discord_id: string; player_name: string; - // Add other player fields as needed + current_elo: number; } interface Match { - id: number; - match_id: number | null; - blue_team: string | null; - red_team: string | null; - winning_score: number | null; - losing_score: number | null; - map: string | null; - server: string | null; - match_outcome: number | null; - stats_url: string | null; - created_at: string; + match_data: { + id: number; + match_id: number | null; + blue_team: string | null; + red_team: string | null; + winning_score: number | null; + losing_score: number | null; + map: string | null; + server: string | null; + match_outcome: number | null; + stats_url: string | null; + created_at: string; + }; + blue_team_players: Player[]; + red_team_players: Player[]; } type SortKey = 'created_at' | 'map'; @@ -29,7 +33,6 @@ type SortOrder = 'asc' | 'desc'; const MatchesTable: React.FC = () => { const [matches, setMatches] = useState([]); const [filteredMatches, setFilteredMatches] = useState([]); - const [players, setPlayers] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [mapFilter, setMapFilter] = useState(''); @@ -39,41 +42,60 @@ const MatchesTable: React.FC = () => { const [sortKey, setSortKey] = useState('created_at'); const [sortOrder, setSortOrder] = useState('desc'); const [playerSearch, setPlayerSearch] = useState(''); - const [fuse, setFuse] = useState | null>(null); const [searchResults, setSearchResults] = useState([]); const searchRef = useRef(null); const navigate = useNavigate(); + useEffect(() => { + if (playerSearch) { + const allPlayers = new Map(); + matches.forEach(match => { + [...match.blue_team_players, ...match.red_team_players].forEach(player => { + allPlayers.set(player.id, player); + }); + }); + + const uniquePlayers = Array.from(allPlayers.values()); + const results = uniquePlayers + .filter(player => + player.player_name.toLowerCase().includes(playerSearch.toLowerCase()) + ) + .slice(0, 5); // Limit to top 5 results + + setSearchResults(results); + } else { + setSearchResults([]); + } + }, [playerSearch, matches]); + const handlePlayerClick = (playerName: string) => { navigate(`/player/${encodeURIComponent(playerName)}`); }; + const handlePlayerSearch = (e: React.ChangeEvent) => { + setPlayerSearch(e.target.value); + }; + + const handlePlayerSelect = (playerName: string) => { + setPlayerSearch(''); + setSearchResults([]); + navigate(`/player/${encodeURIComponent(playerName)}`); + }; + useEffect(() => { const fetchData = async () => { setLoading(true); try { - const [matchesResponse, playersResponse] = await Promise.all([ - fetch('/api/matches'), - fetch('/api/players') - ]); + const matchesResponse = await fetch('/api/matches/with-players'); - if (!matchesResponse.ok || !playersResponse.ok) { + if (!matchesResponse.ok) { throw new Error('Failed to fetch data'); } const matchesData = await matchesResponse.json(); - const playersData = await playersResponse.json(); - setMatches(matchesData); setFilteredMatches(matchesData); - - const playersMap: Record = {}; - playersData.forEach((player: Player) => { - playersMap[player.discord_id] = player; - }); - setPlayers(playersMap); - setLoading(false); } catch (error) { console.error('Error fetching data:', error); @@ -85,27 +107,17 @@ const MatchesTable: React.FC = () => { fetchData(); }, []); - useEffect(() => { - if (Object.keys(players).length > 0) { - const fuseOptions = { - keys: ['player_name'], - threshold: 0.3, - }; - setFuse(new Fuse(Object.values(players), fuseOptions)); - } - }, [players]); - useEffect(() => { let filtered = matches.filter(match => { - const matchDate = new Date(match.created_at); - const matchPlayers = [...getTeamPlayers(match.blue_team), ...getTeamPlayers(match.red_team)]; + const matchDate = new Date(match.match_data.created_at); + const matchPlayers = [...match.blue_team_players, ...match.red_team_players]; return ( - (!mapFilter || match.map === mapFilter) && - (!serverFilter || match.server === serverFilter) && + (!mapFilter || match.match_data.map === mapFilter) && + (!serverFilter || match.match_data.server === serverFilter) && (!startDate || matchDate >= new Date(startDate)) && (!endDate || matchDate <= new Date(endDate)) && (!playerSearch || matchPlayers.some(player => - fuse?.search(playerSearch).some(result => result.item.player_name === player) + player.player_name.toLowerCase().includes(playerSearch.toLowerCase()) )) ); }); @@ -113,18 +125,18 @@ const MatchesTable: React.FC = () => { filtered.sort((a, b) => { if (sortKey === 'created_at') { return sortOrder === 'asc' - ? new Date(a.created_at).getTime() - new Date(b.created_at).getTime() - : new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + ? new Date(a.match_data.created_at).getTime() - new Date(b.match_data.created_at).getTime() + : new Date(b.match_data.created_at).getTime() - new Date(a.match_data.created_at).getTime(); } else if (sortKey === 'map') { return sortOrder === 'asc' - ? (a.map || '').localeCompare(b.map || '') - : (b.map || '').localeCompare(a.map || ''); + ? (a.match_data.map || '').localeCompare(b.match_data.map || '') + : (b.match_data.map || '').localeCompare(a.match_data.map || ''); } return 0; }); setFilteredMatches(filtered); - }, [matches, mapFilter, serverFilter, startDate, endDate, sortKey, sortOrder, playerSearch, fuse]); + }, [matches, mapFilter, serverFilter, startDate, endDate, sortKey, sortOrder, playerSearch]); const handleSort = (key: SortKey) => { if (sortKey === key) { @@ -135,12 +147,6 @@ const MatchesTable: React.FC = () => { } }; - const getTeamPlayers = (teamString: string | null) => { - if (!teamString) return []; - const discordIds = teamString.split(',').map(id => id.trim()); - return discordIds.map(id => players[id]?.player_name || id); - }; - const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleString(); @@ -160,34 +166,34 @@ const MatchesTable: React.FC = () => { return 'Draw'; }; - const getScores = (match: Match) => { - if (match.match_outcome === null) { + const getScores = (matchData: Match['match_data']) => { + if (matchData.match_outcome === null) { return { blueScore: '-', redScore: '-' }; } - const blueScore = match.match_outcome === 1 ? match.winning_score : match.losing_score; - const redScore = match.match_outcome === 2 ? match.winning_score : match.losing_score; + const blueScore = matchData.match_outcome === 1 ? matchData.winning_score : matchData.losing_score; + const redScore = matchData.match_outcome === 2 ? matchData.winning_score : matchData.losing_score; return { blueScore, redScore }; }; - const uniqueMaps = Array.from(new Set(matches.map(match => match.map).filter(Boolean))); - const uniqueServers = Array.from(new Set(matches.map(match => match.server).filter(Boolean))); + const uniqueMaps = Array.from(new Set(matches.map(match => match.match_data.map).filter(Boolean))); + const uniqueServers = Array.from(new Set(matches.map(match => match.match_data.server).filter(Boolean))); const downloadCSV = () => { const headers = ['Match ID', 'Date Played', 'Map', 'Server', 'Blue Team', 'Red Team', 'Blue Score', 'Red Score', 'Outcome']; const csvContent = [ headers.join(','), ...filteredMatches.map(match => { - const { blueScore, redScore } = getScores(match); + const { blueScore, redScore } = getScores(match.match_data); return [ - match.match_id, - formatDate(match.created_at), - match.map, - match.server, - getTeamPlayers(match.blue_team).join('; '), - getTeamPlayers(match.red_team).join('; '), + match.match_data.match_id, + formatDate(match.match_data.created_at), + match.match_data.map, + match.match_data.server, + match.blue_team_players.map(p => p.player_name).join('; '), + match.red_team_players.map(p => p.player_name).join('; '), blueScore, redScore, - getOutcomeText(match.match_outcome) + getOutcomeText(match.match_data.match_outcome) ].join(','); }) ].join('\n'); @@ -205,24 +211,6 @@ const MatchesTable: React.FC = () => { } }; - const handlePlayerSearch = (e: React.ChangeEvent) => { - const searchTerm = e.target.value; - setPlayerSearch(searchTerm); - - if (searchTerm && fuse) { - const results = fuse.search(searchTerm).slice(0, 5); // Limit to top 5 results - setSearchResults(results.map(result => result.item)); - } else { - setSearchResults([]); - } - }; - - const handlePlayerSelect = (playerName: string) => { - setPlayerSearch(''); - setSearchResults([]); - navigate(`/player/${encodeURIComponent(playerName)}`); - }; - useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(event.target as Node)) { @@ -316,43 +304,43 @@ const MatchesTable: React.FC = () => { {filteredMatches.map(match => { - const { blueScore, redScore } = getScores(match); + const { blueScore, redScore } = getScores(match.match_data); return ( - + - {match.stats_url ? ( - - {match.match_id} + {match.match_data.stats_url ? ( + + {match.match_data.match_id} ) : ( - match.match_id + match.match_data.match_id )} - {formatDate(match.created_at)} - {match.map} - {match.server} + {formatDate(match.match_data.created_at)} + {match.match_data.map} + {match.match_data.server}
- {getTeamPlayers(match.blue_team).map((player, index) => ( + {match.blue_team_players.map((player, index) => ( handlePlayerClick(player)} + onClick={() => handlePlayerClick(player.player_name)} > - {player} + {player.player_name} ))}
vs
- {getTeamPlayers(match.red_team).map((player, index) => ( + {match.red_team_players.map((player, index) => ( handlePlayerClick(player)} + onClick={() => handlePlayerClick(player.player_name)} > - {player} + {player.player_name} ))}
@@ -365,7 +353,7 @@ const MatchesTable: React.FC = () => { {redScore}
- {getOutcomeText(match.match_outcome)} + {getOutcomeText(match.match_data.match_outcome)} ); })} diff --git a/frontend/src/components/PlayerMatches.tsx b/frontend/src/components/PlayerMatches.tsx index 226971b..4977442 100644 --- a/frontend/src/components/PlayerMatches.tsx +++ b/frontend/src/components/PlayerMatches.tsx @@ -126,38 +126,20 @@ const PlayerMatches: React.FC = () => { }); // Make parallel requests with retry logic - const [playerResponse, matchesResponse, eloHistoryResponse] = await Promise.all([ - fetchWithRetry(`${baseUrl}/api/players/name/${encodedName}`, requestOptions), - fetchWithRetry(`${baseUrl}/api/matches/player/${encodedName}`, requestOptions), - fetchWithRetry(`${baseUrl}/api/player_elo/${encodedName}`, requestOptions) - ]); - - // Check if any requests still failed after retries - if (!playerResponse.ok || !matchesResponse.ok || !eloHistoryResponse.ok) { - const errors = []; - if (!playerResponse.ok) { - errors.push(`Player data: ${await playerResponse.text().catch(() => 'Unknown error')}`); - } - if (!matchesResponse.ok) { - errors.push(`Matches data: ${await matchesResponse.text().catch(() => 'Unknown error')}`); - } - if (!eloHistoryResponse.ok) { - errors.push(`ELO data: ${await eloHistoryResponse.text().catch(() => 'Unknown error')}`); - } - throw new Error(`Requests failed after retries: ${errors.join(', ')}`); + const response = await fetchWithRetry( + `${baseUrl}/api/players/combined/${encodedName}`, + requestOptions + ); + + if (!response.ok) { + throw new Error(`Failed to fetch combined data: ${await response.text()}`); } - // Get all data in parallel - const [playerData, matchesData, eloHistoryData] = await Promise.all([ - playerResponse.json(), - matchesResponse.json(), - eloHistoryResponse.json() - ]); - - setPlayer(playerData); - setMatches(matchesData); - setFilteredMatches(matchesData); - setEloHistory(eloHistoryData); + const combinedData = await response.json(); + setPlayer(combinedData.player); + setMatches(combinedData.matches); + setFilteredMatches(combinedData.matches); + setEloHistory(combinedData.elo_history); setLoading(false); } catch (error) { console.error('Error fetching data:', error); diff --git a/src/controllers/matches.rs b/src/controllers/matches.rs index fde12cc..5a847da 100644 --- a/src/controllers/matches.rs +++ b/src/controllers/matches.rs @@ -5,7 +5,7 @@ use sea_orm::{DbBackend, EntityTrait, QueryFilter, ColumnTrait, Condition, State use sea_orm::prelude::Expr; use serde::Serialize; use crate::models::_entities::matches::{Entity as Matches, Column as MatchesColumn}; -use crate::models::_entities::players::{Entity as Players, Column as PlayersColumn}; +use crate::models::_entities::players::{Entity as Players, Column as PlayersColumn, Model as PlayerModel}; #[debug_handler] pub async fn list(State(ctx): State) -> Result { @@ -156,11 +156,79 @@ pub async fn get_versus_winrate( })) }*/ +#[derive(Serialize)] +struct MatchWithPlayers { + match_data: crate::models::_entities::matches::Model, + blue_team_players: Vec, + red_team_players: Vec, +} + +#[debug_handler] +pub async fn list_with_players(State(ctx): State) -> Result { + // Get all matches + let matches = Matches::find() + .filter(MatchesColumn::GameType.eq("4v4")) + .filter(MatchesColumn::DeletedAt.is_null()) + .all(&ctx.db) + .await?; + + // Get all players + let players = Players::find() + .all(&ctx.db) + .await?; + + // Create a map of discord_id to player for quick lookups + let player_map: std::collections::HashMap = players + .into_iter() + .filter_map(|p| { + p.discord_id.clone().map(|id| (id, p)) + }) + .collect(); + + // Combine match data with player data + let matches_with_players: Vec = matches + .into_iter() + .map(|match_data| { + let blue_team_ids = match_data.blue_team + .clone() + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .collect::>(); + + let red_team_ids = match_data.red_team + .clone() + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .collect::>(); + + let blue_team_players = blue_team_ids + .iter() + .filter_map(|id| player_map.get(id).cloned()) + .collect(); + + let red_team_players = red_team_ids + .iter() + .filter_map(|id| player_map.get(id).cloned()) + .collect(); + + MatchWithPlayers { + match_data, + blue_team_players, + red_team_players, + } + }) + .collect(); + + format::json(matches_with_players) +} pub fn routes() -> Routes { Routes::new() .prefix("api/matches") .add("/", get(list)) + .add("/with-players", get(list_with_players)) .add("/:id", get(get_one)) .add("/echo", post(echo)) .add("/player/:player_name", get(get_matches_by_player_name)) diff --git a/src/controllers/players.rs b/src/controllers/players.rs index abcb917..b00bfb9 100644 --- a/src/controllers/players.rs +++ b/src/controllers/players.rs @@ -2,8 +2,17 @@ use axum::debug_handler; use loco_rs::prelude::*; use sea_orm::{DbBackend, EntityTrait, QueryOrder, Statement}; +use serde::Serialize; use crate::models::_entities::players::{Entity, Column}; +use crate::models::_entities::{matches, player_elo}; + +#[derive(Serialize)] +struct PlayerCombinedData { + player: Option, + matches: Vec, + elo_history: Vec, +} #[debug_handler] pub async fn list(State(ctx): State) -> Result { @@ -57,6 +66,67 @@ pub async fn list_by_elo(State(ctx): State) -> Result { .await?) } +#[debug_handler] +pub async fn get_player_combined_data( + Path(name): Path, + State(ctx): State +) -> Result { + // Get player data + let player_statement = Statement::from_sql_and_values( + DbBackend::MySql, + r#"SELECT * FROM players WHERE LOWER(player_name) = LOWER(?)"#, + [name.clone().into()] + ); + + let player = Entity::find() + .from_raw_sql(player_statement.clone()) + .one(&ctx.db) + .await?; + + // Get matches data + let player_discord_id = player + .as_ref() + .map(|p| p.discord_id.clone()) + .ok_or(Error::NotFound)?; + + let matches_statement = Statement::from_sql_and_values( + DbBackend::MySql, + r#"SELECT * FROM matches WHERE + FIND_IN_SET(?, blue_team) > 0 OR + FIND_IN_SET(?, red_team) > 0 + ORDER BY created_at DESC"#, + [player_discord_id.clone().into(), player_discord_id.into()] + ); + + let matches = matches::Entity::find() + .from_raw_sql(matches_statement) + .all(&ctx.db) + .await?; + + // Get ELO history + let elo_statement = Statement::from_sql_and_values( + DbBackend::MySql, + r#"SELECT * FROM player_elo + WHERE LOWER(player_name) = LOWER(?) + ORDER BY created_at ASC"#, + [name.clone().into()] + ); + + let elo_history = player_elo::Entity::find() + .from_raw_sql(elo_statement) + .all(&ctx.db) + .await?; + + // Combine all data + let combined_data = PlayerCombinedData { + player, + matches, + elo_history, + }; + + format::json(combined_data) +} + pub fn routes() -> Routes { Routes::new() .prefix("api/players") @@ -65,4 +135,5 @@ pub fn routes() -> Routes { .add("/:id", get(get_one)) .add("/discord/:discord_id", get(get_by_discord_id)) .add("/name/:name", get(get_by_name)) + .add("/combined/:name", get(get_player_combined_data)) } \ No newline at end of file