diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue new file mode 100644 index 0000000..c304464 --- /dev/null +++ b/frontend/src/pages/AdminPage.vue @@ -0,0 +1,248 @@ + + + + + ADMIN + + ACCESS DENIED + + Loading... + + + + + + {{ t.toUpperCase() }} + + + + + + + {{ key }} + {{ val }} + + + + + + {{ bots.length }} BOTS + + + + {{ bot.isActive ? 'ON' : 'OFF' }} + + + {{ bot.name }} + + {{ Math.round(bot.eloRating) }} + {{ bot.wins }}W {{ bot.losses }}L + {{ bot.consecutiveErrors }}err + + + + {{ bot.isActive ? 'DEACTIVATE' : 'ACTIVATE' }} + + + RESET ELO + + + + + + + + RECENT {{ fights.length }} FIGHTS + + + + {{ fight.status.toUpperCase() }} + + + {{ botName(fight.botAId) }} + vs + {{ botName(fight.botBId) }} + + + + R{{ fight.totalRounds }} + {{ fight.arena }} + {{ fight.potSats }}sat + + + + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 1885371..c901e04 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -86,6 +86,11 @@ const routes = [ name: 'practice', component: () => import('./pages/PracticePage.vue'), }, + { + path: '/admin', + name: 'admin', + component: () => import('./pages/AdminPage.vue'), + }, ] export const router = createRouter({ diff --git a/server/src/app.ts b/server/src/app.ts index dd68076..8864388 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -11,6 +11,7 @@ import { docsRouter } from './routes/docs.js' import { betsRouter } from './routes/bets.js' import { paymentsRouter } from './routes/payments.js' import { tournamentsRouter } from './routes/tournaments.js' +import { adminRouter } from './routes/admin.js' import { rateLimit } from './middleware/rate-limit.js' import { existsSync, readFileSync } from 'fs' @@ -60,6 +61,7 @@ app.route('/api/docs', docsRouter) app.route('/api/bets', betsRouter) app.route('/api/payments', paymentsRouter) app.route('/api/tournaments', tournamentsRouter) +app.route('/api/admin', adminRouter) // In production, serve the frontend SPA const __dirname = dirname(fileURLToPath(import.meta.url)) diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts new file mode 100644 index 0000000..d0c82ba --- /dev/null +++ b/server/src/routes/admin.ts @@ -0,0 +1,107 @@ +import { Hono } from 'hono' +import { db, schema, sqlite } from '../db/index.js' +import { eq, desc, sql, count } from 'drizzle-orm' +import { getActiveSSECount } from './fights.js' + +const CREATOR_PUBKEY = 'da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39' +const startTime = Date.now() + +export const adminRouter = new Hono() + +function isCreator(pubkey: string | undefined): boolean { + return pubkey === CREATOR_PUBKEY +} + +// All admin endpoints require creator pubkey in header +adminRouter.use('*', async (c, next) => { + const pubkey = c.req.header('x-pubkey') + if (!isCreator(pubkey)) { + return c.json({ error: 'Forbidden' }, 403) + } + await next() +}) + +// GET /stats — live server stats +adminRouter.get('/stats', async (c) => { + const mem = process.memoryUsage() + const dbSizeRow = sqlite.prepare("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()").get() as { size: number } | undefined + const activeFights = await db.select({ count: count() }).from(schema.fights).where(eq(schema.fights.status, 'live')) + const totalBots = await db.select({ count: count() }).from(schema.bots) + const totalFights = await db.select({ count: count() }).from(schema.fights) + const totalSats = await db.select({ total: sql`coalesce(sum(${schema.fights.potSats}), 0)` }).from(schema.fights) + + return c.json({ + uptime: Math.round((Date.now() - startTime) / 1000), + rssBytes: mem.rss, + heapUsed: mem.heapUsed, + heapTotal: mem.heapTotal, + dbSizeBytes: dbSizeRow?.size || 0, + activeFights: activeFights[0]?.count || 0, + activeSSE: getActiveSSECount(), + totalBots: totalBots[0]?.count || 0, + totalFights: totalFights[0]?.count || 0, + totalSatsMoved: totalSats[0]?.total || 0, + }) +}) + +// GET /bots — list all bots with admin details +adminRouter.get('/bots', async (c) => { + const bots = await db.select({ + id: schema.bots.id, + name: schema.bots.name, + eloRating: schema.bots.eloRating, + wins: schema.bots.wins, + losses: schema.bots.losses, + tier: schema.bots.tier, + isActive: schema.bots.isActive, + archetype: schema.bots.archetype, + botType: schema.bots.botType, + consecutiveErrors: schema.bots.consecutiveErrors, + lastFightAt: schema.bots.lastFightAt, + createdAt: schema.bots.createdAt, + }).from(schema.bots).orderBy(desc(schema.bots.eloRating)) + + return c.json({ bots }) +}) + +// POST /bots/:id/deactivate — deactivate a bot +adminRouter.post('/bots/:id/deactivate', async (c) => { + const botId = c.req.param('id') + await db.update(schema.bots).set({ isActive: false }).where(eq(schema.bots.id, botId)) + return c.json({ ok: true }) +}) + +// POST /bots/:id/activate — reactivate a bot +adminRouter.post('/bots/:id/activate', async (c) => { + const botId = c.req.param('id') + await db.update(schema.bots).set({ isActive: true }).where(eq(schema.bots.id, botId)) + return c.json({ ok: true }) +}) + +// POST /bots/:id/reset-elo — reset elo to 1200 +adminRouter.post('/bots/:id/reset-elo', async (c) => { + const botId = c.req.param('id') + await db.update(schema.bots).set({ eloRating: 1200, tier: 0 }).where(eq(schema.bots.id, botId)) + return c.json({ ok: true }) +}) + +// GET /fights — recent fights with details +adminRouter.get('/fights', async (c) => { + const limit = Math.min(parseInt(c.req.query('limit') || '50'), 200) + const fights = await db.select({ + id: schema.fights.id, + botAId: schema.fights.botAId, + botBId: schema.fights.botBId, + arena: schema.fights.arena, + status: schema.fights.status, + winnerId: schema.fights.winnerId, + botAHp: schema.fights.botAHp, + botBHp: schema.fights.botBHp, + totalRounds: schema.fights.totalRounds, + mode: schema.fights.mode, + potSats: schema.fights.potSats, + createdAt: schema.fights.createdAt, + }).from(schema.fights).orderBy(desc(schema.fights.createdAt)).limit(limit) + + return c.json({ fights }) +})