Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 66 additions & 36 deletions src/admin/game-servers/views/html/static-game-server-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export async function StaticGameServerList() {
<th class="border-ash/50 w-[22%] border-b pb-3 text-left">Internal IP address</th>
<th class="border-ash/50 w-[18%] border-b pb-3 text-left">RCON password</th>
<th class="border-ash/50 w-[8%] border-b pb-3 text-left">Online</th>
<th class="border-ash/50 border-b pb-3 text-left">Assigned to game</th>
<th class="border-ash/50 border-b pb-3 text-left">Game</th>
<th class="border-ash/50 border-b pb-3 text-left">Health</th>
</tr>
</thead>

Expand All @@ -32,42 +33,71 @@ export async function StaticGameServerList() {
}

function StaticGameServerItem(props: { gameServer: StaticGameServerModel }) {
const isFree = props.gameServer.isOnline && props.gameServer.game === undefined
const modalId = `healthcheck-modal-${props.gameServer.id}`

return (
<tr>
<td class="border-ash/20 truncate overflow-hidden border-b py-4 font-bold" safe>
{props.gameServer.name}
</td>
<td class="border-ash/20 truncate overflow-hidden border-b py-4" safe>
{props.gameServer.address}:{props.gameServer.port}
</td>
<td class="border-ash/20 truncate overflow-hidden border-b py-4" safe>
{props.gameServer.internalIpAddress}:{props.gameServer.port}
</td>
<td class="border-ash/20 border-b py-4" safe>
{props.gameServer.rconPassword}
</td>
<td class="border-ash/20 border-b py-4">
{props.gameServer.isOnline ? (
<IconCheck class="text-green-600" />
) : (
<IconX class="text-red-600" />
)}
</td>
<td class="border-ash/20 border-b py-4">
{props.gameServer.game ? (
<div class="flex flex-row gap-2 align-middle">
<a href={`/games/${props.gameServer.game}`} safe>
#{props.gameServer.game}
</a>
<button hx-delete={`/static-game-servers/${props.gameServer.id}/game`}>
<span class="sr-only">Remove game assignment</span>
<IconSquareXFilled />
<>
<tr>
<td class="border-ash/20 truncate overflow-hidden border-b py-4 font-bold" safe>
{props.gameServer.name}
</td>
<td class="border-ash/20 truncate overflow-hidden border-b py-4" safe>
{props.gameServer.address}:{props.gameServer.port}
</td>
<td class="border-ash/20 truncate overflow-hidden border-b py-4" safe>
{props.gameServer.internalIpAddress}:{props.gameServer.port}
</td>
<td class="border-ash/20 border-b py-4" safe>
{props.gameServer.rconPassword}
</td>
<td class="border-ash/20 border-b py-4">
{props.gameServer.isOnline ? (
<IconCheck class="text-green-600" />
) : (
<IconX class="text-red-600" />
)}
</td>
<td class="border-ash/20 border-b py-4">
{props.gameServer.game ? (
<div class="flex flex-row gap-2 align-middle">
<a href={`/games/${props.gameServer.game}`} safe>
#{props.gameServer.game}
</a>
<button hx-delete={`/static-game-servers/${props.gameServer.id}/game`}>
<span class="sr-only">Remove game assignment</span>
<IconSquareXFilled />
</button>
</div>
) : (
<IconMinus />
)}
</td>
<td class="border-ash/20 border-b py-4">
{isFree ? (
<button
class="rounded border border-white/20 bg-white/[0.08] px-2.5 py-0.5 text-xs text-white/80 hover:bg-white/[0.15]"
hx-post={`/admin/game-servers/${props.gameServer.id}/healthcheck`}
hx-target={`#${modalId}`}
hx-swap="innerHTML"
>
Check
</button>
) : (
<button
class="rounded border border-white/[0.08] bg-white/[0.03] px-2.5 py-0.5 text-xs text-white/20"
disabled
>
Check
</button>
</div>
) : (
<IconMinus />
)}
</td>
</tr>
)}
</td>
</tr>
<tr>
<td colspan="7">
<div id={modalId} />
</td>
</tr>
</>
)
}
18 changes: 0 additions & 18 deletions src/routes/admin/game-servers/index.ts

This file was deleted.

92 changes: 92 additions & 0 deletions src/routes/admin/game-servers/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { PlayerRole } from '../../../database/models/player.model'
import { GameServersPage } from '../../../admin/game-servers/views/html/game-servers.page'
import { HealthcheckModal } from '../../../static-game-servers/views/html/healthcheck-modal'
import { collections } from '../../../database/collections'
import {
createCheck,
getCheck,
getActiveCheckId,
} from '../../../static-game-servers/healthcheck-store'
import { runHealthcheck } from '../../../static-game-servers/run-healthcheck'
import { errors } from '../../../errors'
import { z } from 'zod'
import { routes } from '../../../utils/routes'

// eslint-disable-next-line @typescript-eslint/require-await
export default routes(async app => {
app
.get(
'/',
{
config: {
authorize: [PlayerRole.admin],
},
},
async (_request, reply) => {
await reply.status(200).html(GameServersPage())
},
)
.post(
'/:id/healthcheck',
{
config: { authorize: [PlayerRole.admin] },
schema: {
params: z.object({ id: z.string() }),
},
},
async (request, reply) => {
const { id } = request.params
const server = await collections.staticGameServers.findOne({ id })
if (server === null) {
throw errors.notFound(`game server not found: ${id}`)
}
if (!server.isOnline || server.game !== undefined) {
throw errors.badRequest('server is not available for healthcheck')
}

// Return existing check if one is already running
const existingCheckId = getActiveCheckId(id)
if (existingCheckId !== undefined) {
const existingResult = getCheck(existingCheckId)
if (existingResult !== undefined) {
return reply.html(
HealthcheckModal({ result: existingResult, checkId: existingCheckId, server }),
)
}
}

const checkId = createCheck(id)
void runHealthcheck(server, checkId)
const result = getCheck(checkId)!
return reply.html(HealthcheckModal({ result, checkId, server }))
},
)
.get(
'/healthcheck/:checkId',
{
config: { authorize: [PlayerRole.admin] },
schema: {
params: z.object({ checkId: z.string() }),
},
},
async (request, reply) => {
const { checkId } = request.params
const result = getCheck(checkId)

if (result === undefined) {
return reply.html(
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 text-xs text-white/40">
Check expired — please try again.
</div>,
)
}

const server = await collections.staticGameServers.findOne({ id: result.serverId })
if (server === null) {
throw errors.notFound(`game server not found: ${result.serverId}`)
}

return reply.html(HealthcheckModal({ result, checkId, server }))
},
)
})
80 changes: 80 additions & 0 deletions src/static-game-servers/healthcheck-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { minutesToMilliseconds } from 'date-fns'
import {
createCheck,
getCheck,
getActiveCheckId,
updatePhase,
completeCheck,
_resetForTesting,
} from './healthcheck-store'

beforeEach(() => {
vi.useFakeTimers()
_resetForTesting()
})

afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})

describe('createCheck', () => {
it('returns a checkId string', () => {
const checkId = createCheck('server-1')
expect(typeof checkId).toBe('string')
expect(checkId.length).toBeGreaterThan(0)
})

it('inserts a pending result', () => {
const checkId = createCheck('server-1')
const result = getCheck(checkId)
expect(result).toEqual({
serverId: 'server-1',
status: 'running',
phases: {
rconConnect: { status: 'pending' },
rconCommand: { status: 'pending' },
logRoundTrip: { status: 'pending' },
},
})
})

it('records the active check for the server', () => {
const checkId = createCheck('server-1')
expect(getActiveCheckId('server-1')).toBe(checkId)
})

it('auto-deletes result after 5 minutes', () => {
const checkId = createCheck('server-1')
vi.advanceTimersByTime(minutesToMilliseconds(5))
expect(getCheck(checkId)).toBeUndefined()
expect(getActiveCheckId('server-1')).toBeUndefined()
})
})

describe('updatePhase', () => {
it('updates the specified phase status', () => {
const checkId = createCheck('server-1')
updatePhase(checkId, 'rconConnect', { status: 'running' })
expect(getCheck(checkId)?.phases.rconConnect).toEqual({ status: 'running' })
})

it('is a no-op for unknown checkId', () => {
expect(() => updatePhase('nonexistent', 'rconConnect', { status: 'ok' })).not.toThrow()
})
})

describe('completeCheck', () => {
it('sets status to done', () => {
const checkId = createCheck('server-1')
completeCheck(checkId)
expect(getCheck(checkId)?.status).toBe('done')
})

it('removes the server from activeChecks', () => {
const checkId = createCheck('server-1')
completeCheck(checkId)
expect(getActiveCheckId('server-1')).toBeUndefined()
})
})
75 changes: 75 additions & 0 deletions src/static-game-servers/healthcheck-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { nanoid } from 'nanoid'
import { minutesToMilliseconds } from 'date-fns'

export type PhaseStatus =
| { status: 'pending' }
| { status: 'running' }
| { status: 'ok'; message?: string }
| { status: 'fail'; message: string }

export interface HealthcheckResult {
serverId: string
status: 'running' | 'done'
phases: {
rconConnect: PhaseStatus
rconCommand: PhaseStatus
logRoundTrip: PhaseStatus
}
}

const results = new Map<string, HealthcheckResult>()
const activeChecks = new Map<string, string>()

export function createCheck(serverId: string): string {
const checkId = nanoid()
results.set(checkId, {
serverId,
status: 'running',
phases: {
rconConnect: { status: 'pending' },
rconCommand: { status: 'pending' },
logRoundTrip: { status: 'pending' },
},
})
activeChecks.set(serverId, checkId)
setTimeout(() => {
results.delete(checkId)
if (activeChecks.get(serverId) === checkId) {
activeChecks.delete(serverId)
}
}, minutesToMilliseconds(5))
return checkId
}

export function getCheck(checkId: string): HealthcheckResult | undefined {
return results.get(checkId)
}

export function getActiveCheckId(serverId: string): string | undefined {
return activeChecks.get(serverId)
}

export function updatePhase(
checkId: string,
phase: keyof HealthcheckResult['phases'],
status: PhaseStatus,
): void {
const result = results.get(checkId)
if (result === undefined) return
result.phases[phase] = status
}

export function completeCheck(checkId: string): void {
const result = results.get(checkId)
if (result === undefined) return
result.status = 'done'
if (activeChecks.get(result.serverId) === checkId) {
activeChecks.delete(result.serverId)
}
}

/** Only for use in tests — clears all in-memory state */
export function _resetForTesting(): void {
results.clear()
activeChecks.clear()
}
Loading
Loading