feat: admin dashboard for THE CREATOR
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e3a4299dae
commit
2170e9275c
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user