This commit is contained in:
1 parent
21a61068fb
commit
60278b9e71
38 files changed
+3452
No files matched your search
@@ -0,0 +1,83 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export default async function EmulatorErrorsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const params = await searchParams
|
||||
const page = Math.max(1, Number(params.page) || 1)
|
||||
const perPage = 50
|
||||
|
||||
const [errors, total] = await Promise.all([
|
||||
prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: 'desc' },
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.emulatorErrors.count(),
|
||||
])
|
||||
|
||||
const lastPage = Math.ceil(total / perPage)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Emulator Errors</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{total} total errors — showing page {page} of {lastPage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{errors.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground">No emulator errors found</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{errors.map((error) => (
|
||||
<div key={error.id} className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">#{error.id}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(error.timestamp * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded">
|
||||
{Buffer.from(error.stacktrace).toString('utf-8')}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{lastPage > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{page > 1 && (
|
||||
<a href={`?page=${page - 1}`} className="text-sm text-primary hover:underline">
|
||||
Previous
|
||||
</a>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {lastPage}
|
||||
</span>
|
||||
{page < lastPage && (
|
||||
<a href={`?page=${page + 1}`} className="text-sm text-primary hover:underline">
|
||||
Next
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function HealthCheckClient() {
|
||||
const [status, setStatus] = useState<'checking' | 'online' | 'offline'>('checking')
|
||||
const [checking, setChecking] = useState(false)
|
||||
|
||||
async function checkEmulator() {
|
||||
setChecking(true)
|
||||
setStatus('checking')
|
||||
try {
|
||||
const res = await fetch('/api/admin/devops/health')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStatus(data.emulator ? 'online' : 'offline')
|
||||
} else {
|
||||
setStatus('offline')
|
||||
}
|
||||
} catch {
|
||||
setStatus('offline')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
checkEmulator()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
status === 'online' ? 'default' : status === 'offline' ? 'destructive' : 'secondary'
|
||||
}
|
||||
>
|
||||
{status === 'checking' ? 'Checking...' : status === 'online' ? 'Online' : 'Offline'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={checkEmulator}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { AlertTriangle, Database, HeartPulse, Server } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { HealthCheckClient } from './health-check-client'
|
||||
|
||||
async function getDevOpsData() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const dayAgo = now - 86400
|
||||
|
||||
// DB health — simple count query
|
||||
let dbStatus: 'healthy' | 'error' = 'healthy'
|
||||
let dbLatency = 0
|
||||
try {
|
||||
const start = Date.now()
|
||||
await prisma.user.count({ take: 1 })
|
||||
dbLatency = Date.now() - start
|
||||
} catch {
|
||||
dbStatus = 'error'
|
||||
}
|
||||
|
||||
// Recent emulator errors
|
||||
const recentErrors = await prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
|
||||
const errorsToday = await prisma.emulatorErrors.count({
|
||||
where: { timestamp: { gte: dayAgo } },
|
||||
})
|
||||
|
||||
// Emulator settings (version info)
|
||||
const emuVersion = await prisma.emulatorSettings.findFirst({
|
||||
where: { key: 'version' },
|
||||
})
|
||||
|
||||
// Online user count as proxy for emulator health
|
||||
const onlineUsers = await prisma.user.count({ where: { online: '1' } })
|
||||
|
||||
return {
|
||||
dbStatus,
|
||||
dbLatency,
|
||||
recentErrors,
|
||||
errorsToday,
|
||||
emuVersion: emuVersion?.value ?? 'Unknown',
|
||||
onlineUsers,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DevOpsPage() {
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const data = await getDevOpsData()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Health Status</h1>
|
||||
<p className="text-muted-foreground">System health, emulator status and error monitoring</p>
|
||||
</div>
|
||||
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={data.dbStatus === 'healthy' ? 'default' : 'destructive'}>
|
||||
{data.dbStatus === 'healthy' ? 'Healthy' : 'Error'}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">Latency: {data.dbLatency}ms</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HealthCheckClient />
|
||||
<p className="text-xs text-muted-foreground mt-1">Version: {data.emuVersion}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Online Users</CardTitle>
|
||||
<HeartPulse className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.onlineUsers}</div>
|
||||
<p className="text-xs text-muted-foreground">Current active connections</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Errors (24h)</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{data.errorsToday}</div>
|
||||
<p className="text-xs text-muted-foreground">Emulator errors today</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent errors */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" /> Recent Emulator Errors
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.recentErrors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent errors</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{data.recentErrors.map((error) => (
|
||||
<div key={error.id} className="rounded-md border p-3 text-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-mono text-xs text-muted-foreground">#{error.id}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(error.timestamp * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
|
||||
{Buffer.from(error.stacktrace).toString('utf-8')}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user