Skip to content

Commit

Permalink
feat: rotate static game servers (#119)
Browse files Browse the repository at this point in the history
  • Loading branch information
garrappachc authored Dec 16, 2024
1 parent 21a9e26 commit 374ab79
Show file tree
Hide file tree
Showing 17 changed files with 307 additions and 60 deletions.
8 changes: 6 additions & 2 deletions src/admin/game-servers/views/html/game-servers.page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { User } from '../../../../auth/types/user'
import { Admin } from '../../../views/html/admin'
import { StaticGameServerList } from './static-game-server-list'

export function GameServersPage(props: { user: User }) {
export async function GameServersPage(props: { user: User }) {
return (
<Admin activePage="game-servers" user={props.user}>
<div class="admin-panel-set"></div>
<div class="admin-panel-set">
<h4 class="pb-4">Static servers</h4>
<StaticGameServerList />
</div>
</Admin>
)
}
72 changes: 72 additions & 0 deletions src/admin/game-servers/views/html/static-game-server-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { collections } from '../../../../database/collections'
import type { StaticGameServerModel } from '../../../../database/models/static-game-server.model'
import { IconCheck, IconMinus, IconSquareXFilled, IconX } from '../../../../html/components/icons'

export async function StaticGameServerList() {
const staticGameServers = await collections.staticGameServers
.find()
.sort({ isOnline: -1, lastHeartbeatAt: -1, priority: -1 })
.toArray()

return (
<table class="w-full table-auto" id="admin-panel-static-game-server-list">
<thead>
<tr>
<th class="border-b border-ash/50 pb-3 text-left">Name</th>
<th class="border-b border-ash/50 pb-3 text-left">IP address</th>
<th class="border-b border-ash/50 pb-3 text-left">Internal IP address</th>
<th class="border-b border-ash/50 pb-3 text-left">RCON password</th>
<th class="border-b border-ash/50 pb-3 text-left">Online</th>
<th class="border-b border-ash/50 pb-3 text-left">Assigned to game</th>
</tr>
</thead>

<tbody>
{staticGameServers.map(gameServer => (
<StaticGameServerItem gameServer={gameServer} />
))}
</tbody>
</table>
)
}

function StaticGameServerItem(props: { gameServer: StaticGameServerModel }) {
return (
<tr>
<td class="border-b border-ash/20 py-4 font-bold" safe>
{props.gameServer.name}
</td>
<td class="border-b border-ash/20 py-4" safe>
{props.gameServer.address}:{props.gameServer.port}
</td>
<td class="border-b border-ash/20 py-4" safe>
{props.gameServer.internalIpAddress}:{props.gameServer.port}
</td>
<td class="border-b border-ash/20 py-4" safe>
{props.gameServer.rconPassword}
</td>
<td class="border-b border-ash/20 py-4">
{props.gameServer.isOnline ? (
<IconCheck class="text-green-600" />
) : (
<IconX class="text-red-600" />
)}
</td>
<td class="border-b border-ash/20 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>
</tr>
)
}
16 changes: 8 additions & 8 deletions src/game-servers/assign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,30 @@ const mutex = new Mutex()

export async function assign(game: GameModel) {
await mutex.runExclusive(async () => {
const freeServer = await staticGameServers.findFree()
if (!freeServer) {
const staticGameServer = await staticGameServers.assign(game)
if (!staticGameServer) {
throw new Error(`no free servers available for game ${game.number}`)
}

game = await games.update(game.number, {
$set: {
gameServer: {
id: freeServer.id,
name: freeServer.name,
address: freeServer.address,
port: freeServer.port,
id: staticGameServer.id,
name: staticGameServer.name,
address: staticGameServer.address,
port: staticGameServer.port,
provider: GameServerProvider.static,
},
},
$push: {
events: {
event: GameEventType.gameServerAssigned,
at: new Date(),
gameServerName: freeServer.name,
gameServerName: staticGameServer.name,
},
},
})
logger.info({ game }, `game ${game.number} assigned to game server ${freeServer.name}`)
logger.info({ game }, `game ${game.number} assigned to game server ${staticGameServer.name}`)
events.emit('game:gameServerAssigned', { game })
})
}
12 changes: 6 additions & 6 deletions src/game-servers/plugins/auto-assign-game-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ import { events } from '../../events'
import { assign } from '../assign'
import { getOrphanedGames } from '../get-orphaned-games'
import { logger } from '../../logger'
import { safe } from '../../utils/safe'

export default fp(
async () => {
events.on('game:created', async ({ game }) => {
try {
events.on(
'game:created',
safe(async ({ game }) => {
await assign(game)
} catch (error) {
logger.error(error)
}
})
}),
)

const orphanedGames = await getOrphanedGames()
for (const game of orphanedGames) {
Expand Down
9 changes: 9 additions & 0 deletions src/html/components/icons/icon-check.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { makeIcon } from './make-icon'

export const IconCheck = makeIcon(
'check',
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12l5 5l10 -10" />
</>,
)
9 changes: 9 additions & 0 deletions src/html/components/icons/icon-square-x-filled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { makeIconFilled } from './make-icon'

export const IconSquareXFilled = makeIconFilled(
'square-x-filled',
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M19 2h-14a3 3 0 0 0 -3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3 -3v-14a3 3 0 0 0 -3 -3zm-9.387 6.21l.094 .083l2.293 2.292l2.293 -2.292a1 1 0 0 1 1.497 1.32l-.083 .094l-2.292 2.293l2.292 2.293a1 1 0 0 1 -1.32 1.497l-.094 -.083l-2.293 -2.292l-2.293 2.292a1 1 0 0 1 -1.497 -1.32l.083 -.094l2.292 -2.293l-2.292 -2.293a1 1 0 0 1 1.32 -1.497z" />
</>,
)
10 changes: 10 additions & 0 deletions src/html/components/icons/icon-square-x.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { makeIcon } from './make-icon'

export const IconSquareX = makeIcon(
'square-x',
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14z" />
<path d="M9 9l6 6m0 -6l-6 6" />
</>,
)
3 changes: 3 additions & 0 deletions src/html/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { IconBrandDiscord } from './icon-brand-discord'
export { IconChartArrowsVertical } from './icon-chart-arrows-vertical'
export { IconBrandSteam } from './icon-brand-steam'
export { IconChartPie } from './icon-chart-pie'
export { IconCheck } from './icon-check'
export { IconChevronLeft } from './icon-chevron-left'
export { IconChevronRight } from './icon-chevron-right'
export { IconCoffee } from './icon-coffee'
Expand Down Expand Up @@ -39,6 +40,8 @@ export { IconServer } from './icon-server'
export { IconSettingsFilled } from './icon-settings-filled'
export { IconSettings } from './icon-settings'
export { IconSpy } from './icon-spy'
export { IconSquareXFilled } from './icon-square-x-filled'
export { IconSquareX } from './icon-square-x'
export { IconStars } from './icon-stars'
export { IconSum } from './icon-sum'
export { IconTable } from './icon-table'
Expand Down
26 changes: 26 additions & 0 deletions src/static-game-servers/assign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Mutex } from 'async-mutex'
import type { GameModel } from '../database/models/game.model'
import { findFree } from './find-free'
import { update } from './update'

const mutex = new Mutex()

export async function assign(game: GameModel) {
return await mutex.runExclusive(async () => {
const before = await findFree()
if (!before) {
throw new Error(`no free servers available for game ${game.number}`)
}

return await update(
{
id: before.id,
},
{
$set: {
game: game.number,
},
},
)
})
}
72 changes: 47 additions & 25 deletions src/static-game-servers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
import { z } from 'zod'
import { logger } from '../logger'
import { heartbeat } from './heartbeat'
import { resolve } from 'node:path'
import { assign } from './assign'
import { update } from './update'

export const staticGameServers = {
assign,
findFree,
} as const

Expand All @@ -16,36 +20,54 @@ export default fp(
address: z.string(),
port: z.string(),
rconPassword: z.string(),
priority: z.coerce.number().optional(),
priority: z.coerce.number().default(0),
internalIpAddress: z.string().optional(),
})

app.withTypeProvider<ZodTypeProvider>().post(
'/static-game-servers/',
{
schema: {
body: gameServerHeartbeatSchema,
app
.withTypeProvider<ZodTypeProvider>()
.post(
'/static-game-servers/',
{
schema: {
body: gameServerHeartbeatSchema,
},
},
},
async (req, reply) => {
const { name, address, port, rconPassword, priority, internalIpAddress } = req.body
logger.info(
{ name, address, port, rconPassword, priority, internalIpAddress },
'game server heartbeat',
)
await heartbeat({
name,
address,
port,
rconPassword,
priority: priority ?? 0,
internalIpAddress: internalIpAddress ?? req.ip,
})
await reply.status(200).send()
},
)
async (req, reply) => {
const { name, address, port, rconPassword, priority, internalIpAddress } = req.body
logger.info(
{ name, address, port, rconPassword, priority, internalIpAddress },
'game server heartbeat',
)
await heartbeat({
name,
address,
port,
rconPassword,
priority: priority,
internalIpAddress: internalIpAddress ?? req.ip,
})
await reply.status(200).send()
},
)
.delete(
'/static-game-servers/:id/game',
{
schema: {
params: z.object({
id: z.string(),
}),
},
},
async (request, reply) => {
await update({ id: request.params.id }, { $unset: { game: 1 } })
await reply.status(204).send()
},
)

await app.register((await import('./plugins/remove-dead-game-servers')).default)
await app.register((await import('@fastify/autoload')).default, {
dir: resolve(import.meta.dirname, 'plugins'),
})
},
{ name: 'static game servers' },
)
35 changes: 35 additions & 0 deletions src/static-game-servers/plugins/free-game-servers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import fp from 'fastify-plugin'
import { events } from '../../events'
import { update } from '../update'
import { tasks } from '../../tasks'
import { secondsToMilliseconds } from 'date-fns'
import { whenGameEnds } from '../../games/when-game-ends'
import { GameServerProvider, GameState } from '../../database/models/game.model'

const freeGameServerDelay = secondsToMilliseconds(30)

export default fp(
async () => {
tasks.register('staticGameServers:free', async ({ id }) => {
await update({ id }, { $unset: { game: 1 } })
})

events.on(
'game:updated',
whenGameEnds(async ({ after }) => {
if (after.gameServer?.provider !== GameServerProvider.static) {
return
}

if (after.state === GameState.interrupted) {
await update({ id: after.gameServer.id }, { $unset: { game: 1 } })
} else {
tasks.schedule('staticGameServers:free', freeGameServerDelay, { id: after.gameServer.id })
}
}),
)
},
{
name: 'free static game servers',
},
)
26 changes: 11 additions & 15 deletions src/static-game-servers/plugins/remove-dead-game-servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,21 @@ import { subMinutes } from 'date-fns'
import fp from 'fastify-plugin'
import { collections } from '../../database/collections'
import { Cron } from 'croner'

async function removeDeadGameServers() {
const fiveMinutesAgo = subMinutes(new Date(), 5)
await collections.staticGameServers.updateMany(
{
isOnline: true,
lastHeartbeatAt: { $lt: fiveMinutesAgo },
},
{
$set: {
isOnline: false,
},
},
)
}
import { update } from '../update'

export default fp(
// eslint-disable-next-line @typescript-eslint/require-await
async () => {
async function removeDeadGameServers() {
const fiveMinutesAgo = subMinutes(new Date(), 5)
const dead = await collections.staticGameServers
.find({ isOnline: true, lastHeartbeatAt: { $lt: fiveMinutesAgo } })
.toArray()
for (const server of dead) {
await update({ id: server.id }, { $set: { isOnline: false } })
}
}

// run every minute
new Cron('* * * * *', removeDeadGameServers)
},
Expand Down
Loading

0 comments on commit 374ab79

Please sign in to comment.