feat: admin dashboard for THE CREATOR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 00:12:36 +00:00
co-authored by Claude Opus 4.6
parent e3a4299dae
commit 2170e9275c
4 changed files with 362 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
const CREATOR_PUBKEY = 'da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39'
interface Stats {
uptime: number
rssBytes: number
heapUsed: number
heapTotal: number
dbSizeBytes: number
activeFights: number
activeSSE: number
totalBots: number
totalFights: number
totalSatsMoved: number
}
interface Bot {
id: string
name: string
eloRating: number
wins: number
losses: number
tier: number
isActive: boolean
archetype: string
botType: string
consecutiveErrors: number
lastFightAt: string | null
createdAt: string
}
interface Fight {
id: string
botAId: string
botBId: string
arena: string
status: string
winnerId: string | null
botAHp: number
botBHp: number
totalRounds: number
mode: string
potSats: number
createdAt: string
}
const { pubkey } = useNostr()
const router = useRouter()
const isAuthorized = computed(() => pubkey.value === CREATOR_PUBKEY)
const stats = ref<Stats | null>(null)
const bots = ref<Bot[]>([])
const fights = ref<Fight[]>([])
const tab = ref<'stats' | 'bots' | 'fights'>('stats')
const isLoading = ref(true)
let refreshTimer: ReturnType<typeof setInterval> | null = null
function headers(): Record<string, string> {
return { 'x-pubkey': pubkey.value || '' }
}
async function fetchStats() {
try {
const res = await fetch('/api/admin/stats', { headers: headers() })
if (res.ok) stats.value = await res.json()
} catch {}
}
async function fetchBots() {
try {
const res = await fetch('/api/admin/bots', { headers: headers() })
if (res.ok) {
const data = await res.json()
bots.value = data.bots
}
} catch {}
}
async function fetchFights() {
try {
const res = await fetch('/api/admin/fights?limit=50', { headers: headers() })
if (res.ok) {
const data = await res.json()
fights.value = data.fights
}
} catch {}
}
async function toggleBot(bot: Bot) {
const action = bot.isActive ? 'deactivate' : 'activate'
await fetch(`/api/admin/bots/${bot.id}/${action}`, { method: 'POST', headers: headers() })
bot.isActive = !bot.isActive
}
async function resetElo(bot: Bot) {
await fetch(`/api/admin/bots/${bot.id}/reset-elo`, { method: 'POST', headers: headers() })
bot.eloRating = 1200
bot.tier = 0
}
function fmtBytes(b: number): string {
if (b < 1024) return b + 'B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + 'KB'
return (b / (1024 * 1024)).toFixed(1) + 'MB'
}
function fmtUptime(s: number): string {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function botName(id: string): string {
return bots.value.find(b => b.id === id)?.name || id.slice(0, 8)
}
onMounted(async () => {
if (!isAuthorized.value) {
router.replace('/')
return
}
await Promise.all([fetchStats(), fetchBots(), fetchFights()])
isLoading.value = false
refreshTimer = setInterval(fetchStats, 15_000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<template>
<div class="min-h-screen bg-background text-text-primary p-4 max-w-5xl mx-auto">
<h1 class="font-display text-2xl text-neon-yellow mb-4 tracking-wider">ADMIN</h1>
<div v-if="!isAuthorized" class="text-red-500 font-pixel">ACCESS DENIED</div>
<div v-else-if="isLoading" class="text-text-muted font-pixel text-sm">Loading...</div>
<template v-else>
<!-- Tabs -->
<div class="flex gap-2 mb-4 border-b border-border pb-2">
<button
v-for="t in (['stats', 'bots', 'fights'] as const)"
:key="t"
class="px-4 py-1.5 font-pixel text-xs tracking-wider border transition-all"
:class="tab === t
? 'border-neon-yellow text-neon-yellow bg-neon-yellow/10'
: 'border-border/50 text-text-muted hover:text-neon-yellow hover:border-neon-yellow/50'"
@click="tab = t"
>
{{ t.toUpperCase() }}
</button>
</div>
<!-- Stats tab -->
<div v-if="tab === 'stats' && stats" class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-for="(val, key) in {
'Uptime': fmtUptime(stats.uptime),
'RSS': fmtBytes(stats.rssBytes),
'Heap': fmtBytes(stats.heapUsed) + ' / ' + fmtBytes(stats.heapTotal),
'DB Size': fmtBytes(stats.dbSizeBytes),
'Active Fights': stats.activeFights,
'SSE Streams': stats.activeSSE,
'Total Bots': stats.totalBots,
'Total Fights': stats.totalFights,
'Sats Moved': stats.totalSatsMoved.toLocaleString(),
}" :key="key"
class="border border-border/50 p-3 bg-surface-raised/30"
>
<div class="text-text-muted font-pixel text-[9px] tracking-wider mb-1">{{ key }}</div>
<div class="font-mono text-sm text-neon-cyan">{{ val }}</div>
</div>
</div>
<!-- Bots tab -->
<div v-if="tab === 'bots'" class="space-y-1">
<div class="text-text-muted font-pixel text-[9px] tracking-wider mb-2">{{ bots.length }} BOTS</div>
<div
v-for="bot in bots"
:key="bot.id"
class="flex items-center justify-between border border-border/30 px-3 py-2 bg-surface-raised/20 text-sm"
>
<div class="flex items-center gap-3 min-w-0">
<span class="font-pixel text-xs w-6 text-center"
:class="bot.isActive ? 'text-green-400' : 'text-red-400'">
{{ bot.isActive ? 'ON' : 'OFF' }}
</span>
<span class="font-display truncate" :class="bot.archetype === 'the_creator' ? 'text-neon-yellow' : 'text-text-primary'">
{{ bot.name }}
</span>
<span class="text-text-muted font-mono text-xs">{{ Math.round(bot.eloRating) }}</span>
<span class="text-text-muted font-mono text-[10px]">{{ bot.wins }}W {{ bot.losses }}L</span>
<span v-if="bot.consecutiveErrors > 0" class="text-red-400 font-mono text-[10px]">{{ bot.consecutiveErrors }}err</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<button
class="px-2 py-0.5 border text-[9px] font-pixel tracking-wider transition-all"
:class="bot.isActive
? 'border-red-400/50 text-red-400 hover:bg-red-400/10'
: 'border-green-400/50 text-green-400 hover:bg-green-400/10'"
@click="toggleBot(bot)"
>
{{ bot.isActive ? 'DEACTIVATE' : 'ACTIVATE' }}
</button>
<button
class="px-2 py-0.5 border border-neon-cyan/50 text-neon-cyan text-[9px] font-pixel tracking-wider hover:bg-neon-cyan/10 transition-all"
@click="resetElo(bot)"
>
RESET ELO
</button>
</div>
</div>
</div>
<!-- Fights tab -->
<div v-if="tab === 'fights'" class="space-y-1">
<div class="text-text-muted font-pixel text-[9px] tracking-wider mb-2">RECENT {{ fights.length }} FIGHTS</div>
<div
v-for="fight in fights"
:key="fight.id"
class="flex items-center justify-between border border-border/30 px-3 py-2 bg-surface-raised/20 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<span class="font-pixel text-[9px] w-14 text-center"
:class="{ 'text-green-400': fight.status === 'finished', 'text-neon-yellow': fight.status === 'live', 'text-text-muted': fight.status === 'scheduled' }">
{{ fight.status.toUpperCase() }}
</span>
<span class="font-display truncate">
<span :class="fight.winnerId === fight.botAId ? 'text-neon-cyan' : 'text-text-muted'">{{ botName(fight.botAId) }}</span>
<span class="text-text-muted mx-1">vs</span>
<span :class="fight.winnerId === fight.botBId ? 'text-neon-pink' : 'text-text-muted'">{{ botName(fight.botBId) }}</span>
</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0 text-text-muted font-mono">
<span>R{{ fight.totalRounds }}</span>
<span>{{ fight.arena }}</span>
<span v-if="fight.potSats > 0" class="text-neon-yellow">{{ fight.potSats }}sat</span>
</div>
</div>
</div>
</template>
</div>
</template>
+5
View File
@@ -86,6 +86,11 @@ const routes = [
name: 'practice', name: 'practice',
component: () => import('./pages/PracticePage.vue'), component: () => import('./pages/PracticePage.vue'),
}, },
{
path: '/admin',
name: 'admin',
component: () => import('./pages/AdminPage.vue'),
},
] ]
export const router = createRouter({ export const router = createRouter({
+2
View File
@@ -11,6 +11,7 @@ import { docsRouter } from './routes/docs.js'
import { betsRouter } from './routes/bets.js' import { betsRouter } from './routes/bets.js'
import { paymentsRouter } from './routes/payments.js' import { paymentsRouter } from './routes/payments.js'
import { tournamentsRouter } from './routes/tournaments.js' import { tournamentsRouter } from './routes/tournaments.js'
import { adminRouter } from './routes/admin.js'
import { rateLimit } from './middleware/rate-limit.js' import { rateLimit } from './middleware/rate-limit.js'
import { existsSync, readFileSync } from 'fs' import { existsSync, readFileSync } from 'fs'
@@ -60,6 +61,7 @@ app.route('/api/docs', docsRouter)
app.route('/api/bets', betsRouter) app.route('/api/bets', betsRouter)
app.route('/api/payments', paymentsRouter) app.route('/api/payments', paymentsRouter)
app.route('/api/tournaments', tournamentsRouter) app.route('/api/tournaments', tournamentsRouter)
app.route('/api/admin', adminRouter)
// In production, serve the frontend SPA // In production, serve the frontend SPA
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
+107
View File
@@ -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<number>`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 })
})