Implement centralized server data polling with context management
This commit is contained in:
70
SERVER_POLLING.md
Normal file
70
SERVER_POLLING.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Server Data Polling Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented a centralized server data management system that fetches and polls server status data globally across the entire application.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Created ServerContext (`src/contexts/ServerContext.tsx`)
|
||||||
|
- **Purpose**: Global state management for server data
|
||||||
|
- **Features**:
|
||||||
|
- Fetches A2S data for all servers on app initialization
|
||||||
|
- Polls server data every 2 seconds to keep status fresh
|
||||||
|
- Provides data to any component via React Context
|
||||||
|
- Handles loading states and error management
|
||||||
|
|
||||||
|
### 2. Updated Main App (`src/main.tsx`)
|
||||||
|
- Wrapped the entire app with `<ServerProvider>` to make server data available globally
|
||||||
|
- Server data starts loading immediately when the app opens, regardless of which page
|
||||||
|
|
||||||
|
### 3. Refactored Servers Page (`src/pages/Servers.tsx`)
|
||||||
|
- Removed local data fetching logic
|
||||||
|
- Now uses `useServers()` hook to access global server data
|
||||||
|
- No duplicate API calls - reuses data already being polled
|
||||||
|
- Maintains all existing functionality (filtering, loading skeletons, etc.)
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- ✅ Data loads immediately on app start (any page: index, blog, servers, etc.)
|
||||||
|
- ✅ No delay when navigating to /servers page - data already available
|
||||||
|
- ✅ Automatic updates every 2 seconds keep data fresh
|
||||||
|
- ✅ Single polling loop instead of multiple instances
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- ✅ Server data pre-loaded before user navigates to servers page
|
||||||
|
- ✅ Real-time status updates without manual refresh
|
||||||
|
- ✅ Consistent data across all pages that might display server info
|
||||||
|
- ✅ Smooth loading transitions with skeleton placeholders
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- ✅ Centralized data management (single source of truth)
|
||||||
|
- ✅ Reusable across multiple components
|
||||||
|
- ✅ Clean separation of concerns
|
||||||
|
- ✅ Easy to extend (add more server-related features)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
App Starts → ServerProvider initializes
|
||||||
|
↓
|
||||||
|
Fetches all server data via A2S API
|
||||||
|
↓
|
||||||
|
Sets up 2-second polling interval
|
||||||
|
↓
|
||||||
|
Any component can access data via useServers() hook
|
||||||
|
↓
|
||||||
|
Data automatically updates every 2 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Calls
|
||||||
|
- **Initial Load**: 9 parallel A2S queries (one per server)
|
||||||
|
- **Polling**: Same 9 queries every 2 seconds
|
||||||
|
- **Bandwidth**: ~4.5 requests/second (distributed)
|
||||||
|
|
||||||
|
## Future Enhancements (Optional)
|
||||||
|
- Add configurable polling interval
|
||||||
|
- Implement smart polling (slower when tab inactive)
|
||||||
|
- Add WebSocket support for real-time updates
|
||||||
|
- Cache data in localStorage for faster initial loads
|
||||||
|
- Add server health monitoring and alerts
|
||||||
118
src/contexts/ServerContext.tsx
Normal file
118
src/contexts/ServerContext.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ServerInfo {
|
||||||
|
name: string
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
category: 'Surf' | 'Kz' | 'Bhop'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface A2SResponse {
|
||||||
|
appID: number
|
||||||
|
botCount: number
|
||||||
|
environment: string
|
||||||
|
gameDescription: string
|
||||||
|
gameDirectory: string
|
||||||
|
mapName: string
|
||||||
|
maxPlayers: number
|
||||||
|
playerCount: number
|
||||||
|
serverName: string
|
||||||
|
serverType: string
|
||||||
|
vac: number
|
||||||
|
version: string
|
||||||
|
visibility: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerData extends ServerInfo {
|
||||||
|
a2sData?: A2SResponse
|
||||||
|
status: 'online' | 'offline' | 'loading'
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerContextType {
|
||||||
|
servers: ServerData[]
|
||||||
|
loading: boolean
|
||||||
|
lastUpdated: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerContext = createContext<ServerContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
// Server list from database export
|
||||||
|
const serverList: ServerInfo[] = [
|
||||||
|
{ name: 'Surf 66 Tick #1', ip: '14.103.233.1', port: 27015, category: 'Surf' },
|
||||||
|
{ name: 'Surf 66 Tick #2', ip: '14.103.233.1', port: 27016, category: 'Surf' },
|
||||||
|
{ name: 'Surf 66 Tick #3', ip: '14.103.233.1', port: 27017, category: 'Surf' },
|
||||||
|
{ name: 'Surf 100 Tick #1', ip: '14.103.233.1', port: 28015, category: 'Surf' },
|
||||||
|
{ name: 'Surf 100 Tick #2', ip: '14.103.233.1', port: 28016, category: 'Surf' },
|
||||||
|
{ name: 'Kz #1', ip: '14.103.233.1', port: 29015, category: 'Kz' },
|
||||||
|
{ name: 'Kz #2', ip: '14.103.233.1', port: 29016, category: 'Kz' },
|
||||||
|
{ name: 'Bhop #1', ip: '14.103.233.1', port: 30015, category: 'Bhop' },
|
||||||
|
{ name: 'Bhop #2', ip: '14.103.233.1', port: 30016, category: 'Bhop' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ServerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [servers, setServers] = useState<ServerData[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(Date.now())
|
||||||
|
|
||||||
|
const fetchServerData = async () => {
|
||||||
|
const serverPromises = serverList.map(async (server) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/server/statistics/a2s-query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverIP: server.ip,
|
||||||
|
serverPort: server.port,
|
||||||
|
timeout: 3000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const a2sData: A2SResponse = await response.json()
|
||||||
|
return { ...server, a2sData, status: 'online' as const }
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch data for ${server.name}:`, error)
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
status: 'offline' as const,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all(serverPromises)
|
||||||
|
setServers(results)
|
||||||
|
setLoading(false)
|
||||||
|
setLastUpdated(Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch and polling every 2 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
fetchServerData()
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchServerData()
|
||||||
|
}, 2000) // Poll every 2 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServerContext.Provider value={{ servers, loading, lastUpdated }}>
|
||||||
|
{children}
|
||||||
|
</ServerContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServers() {
|
||||||
|
const context = useContext(ServerContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useServers must be used within a ServerProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { ServerProvider } from './contexts/ServerContext'
|
||||||
import ScrollToTop from './components/ScrollToTop'
|
import ScrollToTop from './components/ScrollToTop'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import Friends from './pages/Friends.tsx'
|
import Friends from './pages/Friends.tsx'
|
||||||
@@ -14,6 +15,7 @@ import Forum from './pages/Forum.tsx'
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<ServerProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -24,6 +26,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="/forum" element={<Forum />} />
|
<Route path="/forum" element={<Forum />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</ServerProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,37 +1,9 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import Layout from '../components/Layout'
|
import Layout from '../components/Layout'
|
||||||
|
import { useServers } from '../contexts/ServerContext'
|
||||||
import '../App.css'
|
import '../App.css'
|
||||||
|
|
||||||
interface ServerInfo {
|
|
||||||
name: string
|
|
||||||
ip: string
|
|
||||||
port: number
|
|
||||||
category: 'Surf' | 'Kz' | 'Bhop'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface A2SResponse {
|
|
||||||
appID: number
|
|
||||||
botCount: number
|
|
||||||
environment: string
|
|
||||||
gameDescription: string
|
|
||||||
gameDirectory: string
|
|
||||||
mapName: string
|
|
||||||
maxPlayers: number
|
|
||||||
playerCount: number
|
|
||||||
serverName: string
|
|
||||||
serverType: string
|
|
||||||
vac: number
|
|
||||||
version: string
|
|
||||||
visibility: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerData extends ServerInfo {
|
|
||||||
a2sData?: A2SResponse
|
|
||||||
status: 'online' | 'offline' | 'loading'
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading Skeleton Component
|
// Loading Skeleton Component
|
||||||
const LoadingSkeleton = ({ width = '60px', height = '40px', className = '', style = {} }: { width?: string, height?: string, className?: string, style?: React.CSSProperties }) => (
|
const LoadingSkeleton = ({ width = '60px', height = '40px', className = '', style = {} }: { width?: string, height?: string, className?: string, style?: React.CSSProperties }) => (
|
||||||
<div
|
<div
|
||||||
@@ -65,69 +37,15 @@ const LoadingSkeleton = ({ width = '60px', height = '40px', className = '', styl
|
|||||||
|
|
||||||
function Servers() {
|
function Servers() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { servers, loading } = useServers()
|
||||||
// Server list from database export
|
|
||||||
const serverList: ServerInfo[] = [
|
|
||||||
{ name: 'Surf 66 Tick #1', ip: '14.103.233.1', port: 27015, category: 'Surf' },
|
|
||||||
{ name: 'Surf 66 Tick #2', ip: '14.103.233.1', port: 27016, category: 'Surf' },
|
|
||||||
{ name: 'Surf 66 Tick #3', ip: '14.103.233.1', port: 27017, category: 'Surf' },
|
|
||||||
{ name: 'Surf 100 Tick #1', ip: '14.103.233.1', port: 28015, category: 'Surf' },
|
|
||||||
{ name: 'Surf 100 Tick #2', ip: '14.103.233.1', port: 28016, category: 'Surf' },
|
|
||||||
{ name: 'Kz #1', ip: '14.103.233.1', port: 29015, category: 'Kz' },
|
|
||||||
{ name: 'Kz #2', ip: '14.103.233.1', port: 29016, category: 'Kz' },
|
|
||||||
{ name: 'Bhop #1', ip: '14.103.233.1', port: 30015, category: 'Bhop' },
|
|
||||||
{ name: 'Bhop #2', ip: '14.103.233.1', port: 30016, category: 'Bhop' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const [servers, setServers] = useState<ServerData[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('All')
|
const [selectedCategory, setSelectedCategory] = useState<string>('All')
|
||||||
|
|
||||||
// Fetch A2S data for all servers
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchServerData = async () => {
|
|
||||||
const serverPromises = serverList.map(async (server) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/server/statistics/a2s-query', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
serverIP: server.ip,
|
|
||||||
serverPort: server.port,
|
|
||||||
timeout: 3000
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const a2sData: A2SResponse = await response.json()
|
|
||||||
return { ...server, a2sData, status: 'online' as const }
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch data for ${server.name}:`, error)
|
|
||||||
return {
|
|
||||||
...server,
|
|
||||||
status: 'offline' as const,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const results = await Promise.all(serverPromises)
|
|
||||||
setServers(results)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchServerData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Filter servers by category
|
// Filter servers by category
|
||||||
const filteredServers = selectedCategory === 'All'
|
const filteredServers = selectedCategory === 'All'
|
||||||
? servers
|
? servers
|
||||||
: servers.filter(server => server.category === selectedCategory)
|
: servers.filter(server => server.category === selectedCategory)
|
||||||
|
|
||||||
const categories = ['All', ...Array.from(new Set(serverList.map(s => s.category)))]
|
const categories = ['All', ...Array.from(new Set(servers.map(s => s.category)))]
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -209,7 +127,7 @@ function Servers() {
|
|||||||
}}>
|
}}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
// Show loading skeletons
|
// Show loading skeletons
|
||||||
serverList.map((_, index) => (
|
servers.length > 0 ? servers.map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={`loading-${index}`}
|
key={`loading-${index}`}
|
||||||
className="server-card"
|
className="server-card"
|
||||||
@@ -267,7 +185,7 @@ function Servers() {
|
|||||||
{/* Join Button Skeleton */}
|
{/* Join Button Skeleton */}
|
||||||
<LoadingSkeleton width="100%" height="40px" />
|
<LoadingSkeleton width="100%" height="40px" />
|
||||||
</div>
|
</div>
|
||||||
))
|
)) : []
|
||||||
) : (
|
) : (
|
||||||
filteredServers.map((server) => (
|
filteredServers.map((server) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user