Skip to content

Commit 14b7cb0

Browse files
committed
feat(ui): Improve server list UI
- Implement concurrent server status fetching with rate limiting - Show offline status for servers that cannot be reached
1 parent 28f0546 commit 14b7cb0

File tree

4 files changed

+91
-26
lines changed

4 files changed

+91
-26
lines changed

src/react/AddServerOrConnect.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Screen from './Screen'
33
import Input from './Input'
44
import Button from './Button'
55
import SelectGameVersion from './SelectGameVersion'
6-
import { useIsSmallWidth } from './simpleHooks'
6+
import { useIsSmallWidth, usePassesWindowDimensions } from './simpleHooks'
77

88
export interface BaseServerInfo {
99
ip: string
@@ -32,6 +32,7 @@ interface Props {
3232
const ELEMENTS_WIDTH = 190
3333

3434
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => {
35+
const isSmallHeight = !usePassesWindowDimensions(null, 350)
3536
const qsParams = parseQs ? new URLSearchParams(window.location.search) : undefined
3637
const qsParamName = qsParams?.get('name')
3738
const qsParamIp = qsParams?.get('ip')
@@ -101,7 +102,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
101102
</>}
102103
<InputWithLabel required label="Server IP" value={serverIp} disabled={lockConnect && qsIpParts?.[0] !== null} onChange={({ target: { value } }) => setServerIp(value)} />
103104
<InputWithLabel label="Server Port" value={serverPort} disabled={lockConnect && qsIpParts?.[1] !== null} onChange={({ target: { value } }) => setServerPort(value)} placeholder='25565' />
104-
<div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>
105+
{isSmallHeight ? <div style={{ gridColumn: 'span 2', marginTop: 10, }} /> : <div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>}
105106
<div style={{
106107
display: 'flex',
107108
flexDirection: 'column',

src/react/ServersListProvider.tsx

+65-22
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type AdditionalDisplayData = {
4747
formattedText: string
4848
textNameRight: string
4949
icon?: string
50+
offline?: boolean
5051
}
5152

5253
export interface AuthenticatedAccount {
@@ -138,6 +139,9 @@ export const updateAuthenticatedAccountData = (callback: (data: AuthenticatedAcc
138139
// todo move to base
139140
const normalizeIp = (ip: string) => ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '')
140141

142+
const FETCH_DELAY = 100 // ms between each request
143+
const MAX_CONCURRENT_REQUESTS = 10
144+
141145
const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersList?: string[] }) => {
142146
const [proxies, setProxies] = useState<readonly string[]>(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies())
143147
const [selectedProxy, setSelectedProxy] = useState(proxyQs ?? localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '')
@@ -198,30 +202,69 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
198202

199203
useUtilsEffect(({ signal }) => {
200204
const update = async () => {
201-
for (const server of serversListSorted) {
202-
const isInLocalNetwork = server.ip.startsWith('192.168.') || server.ip.startsWith('10.') || server.ip.startsWith('172.') || server.ip.startsWith('127.') || server.ip.startsWith('localhost')
203-
if (isInLocalNetwork || signal.aborted) continue
204-
// eslint-disable-next-line no-await-in-loop
205-
await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, {
206-
// TODO: bounty for this who fix it
207-
// signal
208-
}).then(async r => r.json()).then((data: ServerResponse) => {
209-
const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '')
210-
if (!versionClean) return
211-
setAdditionalData(old => {
212-
return ({
213-
...old,
214-
[server.ip]: {
215-
formattedText: data.motd?.raw ?? '',
216-
textNameRight: `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}`,
217-
icon: data.icon,
218-
}
219-
})
220-
})
205+
const queue = serversListSorted
206+
.map(server => {
207+
const isInLocalNetwork = server.ip.startsWith('192.168.') ||
208+
server.ip.startsWith('10.') ||
209+
server.ip.startsWith('172.') ||
210+
server.ip.startsWith('127.') ||
211+
server.ip.startsWith('localhost') ||
212+
server.ip.startsWith(':')
213+
214+
const VALID_IP_OR_DOMAIN = server.ip.includes('.')
215+
if (isInLocalNetwork || signal.aborted || !VALID_IP_OR_DOMAIN) return null
216+
217+
return server
218+
})
219+
.filter(x => x !== null)
220+
221+
const activeRequests = new Set<Promise<void>>()
222+
223+
let lastRequestStart = 0
224+
for (const server of queue) {
225+
// Wait if at concurrency limit
226+
if (activeRequests.size >= MAX_CONCURRENT_REQUESTS) {
227+
// eslint-disable-next-line no-await-in-loop
228+
await Promise.race(activeRequests)
229+
}
230+
231+
// Create and track new request
232+
// eslint-disable-next-line @typescript-eslint/no-loop-func
233+
const request = new Promise<void>(resolve => {
234+
setTimeout(async () => {
235+
try {
236+
lastRequestStart = Date.now()
237+
if (signal.aborted) return
238+
const response = await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, { signal })
239+
const data: ServerResponse = await response.json()
240+
const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '')
241+
242+
setAdditionalData(old => ({
243+
...old,
244+
[server.ip]: {
245+
formattedText: data.motd?.raw ?? '',
246+
textNameRight: data.online ?
247+
`${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}` :
248+
'',
249+
icon: data.icon,
250+
offline: !data.online
251+
}
252+
}))
253+
} finally {
254+
activeRequests.delete(request)
255+
resolve()
256+
}
257+
}, lastRequestStart ? Math.max(0, FETCH_DELAY - (Date.now() - lastRequestStart)) : 0)
221258
})
259+
260+
activeRequests.add(request)
222261
}
262+
263+
// Wait for remaining requests
264+
await Promise.all(activeRequests)
223265
}
224-
void update().catch((err) => {})
266+
267+
void update()
225268
}, [serversListSorted])
226269

227270
const isEditScreenModal = useIsModalActive('editServer')
@@ -394,10 +437,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
394437
name: server.index.toString(),
395438
title: server.name || server.ip,
396439
detail: (server.versionOverride ?? '') + ' ' + (server.usernameOverride ?? ''),
397-
// lastPlayed: server.lastJoined,
398440
formattedTextOverride: additional?.formattedText,
399441
worldNameRight: additional?.textNameRight ?? '',
400442
iconSrc: additional?.icon,
443+
offline: additional?.offline
401444
}
402445
})}
403446
initialProxies={{

src/react/Singleplayer.tsx

+11-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Button from './Button'
1212
import Tabs from './Tabs'
1313
import MessageFormattedString from './MessageFormattedString'
1414
import { useIsSmallWidth } from './simpleHooks'
15+
import PixelartIcon from './PixelartIcon'
1516

1617
export interface WorldProps {
1718
name: string
@@ -26,9 +27,10 @@ export interface WorldProps {
2627
onFocus?: (name: string) => void
2728
onInteraction?(interaction: 'enter' | 'space')
2829
elemRef?: React.Ref<HTMLDivElement>
30+
offline?: boolean
2931
}
3032

31-
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
33+
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef, offline }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
3234
const timeRelativeFormatted = useMemo(() => {
3335
if (!lastPlayed) return ''
3436
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
@@ -60,7 +62,14 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
6062
<div className={styles.world_info}>
6163
<div className={styles.world_title}>
6264
<div>{title}</div>
63-
<div className={styles.world_title_right}>{worldNameRight}</div>
65+
<div className={styles.world_title_right}>
66+
{offline ? (
67+
<span style={{ color: 'red', display: 'flex', alignItems: 'center', gap: 4 }}>
68+
<PixelartIcon iconName="signal-off" width={12} />
69+
Offline
70+
</span>
71+
) : worldNameRight}
72+
</div>
6473
</div>
6574
{formattedTextOverride ? <div className={styles.world_info_formatted}>
6675
<MessageFormattedString message={formattedTextOverride} />

src/react/simpleHooks.ts

+12
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ export const useIsSmallWidth = () => {
77
return useMedia(SMALL_SCREEN_MEDIA.replace('@media ', ''))
88
}
99

10+
export const usePassesWindowDimensions = (minWidth: number | null = null, minHeight: number | null = null) => {
11+
let media = '('
12+
if (minWidth !== null) {
13+
media += `min-width: ${minWidth}px, `
14+
}
15+
if (minHeight !== null) {
16+
media += `min-height: ${minHeight}px, `
17+
}
18+
media += ')'
19+
return useMedia(media)
20+
}
21+
1022
export const useCopyKeybinding = (getCopyText: () => string | undefined) => {
1123
useUtilsEffect(({ signal }) => {
1224
addEventListener('keydown', (e) => {

0 commit comments

Comments
 (0)