upd: routing dev

This commit is contained in:
2026-04-28 17:34:45 +08:00
parent 94724a5081
commit b03f267743
13 changed files with 2289 additions and 7 deletions

View File

@@ -3,10 +3,13 @@ import { html } from '@elysiajs/html'
import { swagger } from '@elysiajs/swagger'
import { Elysia, t } from 'elysia'
import { BugSource } from '../generated/prisma'
import { appLog, clearAppLogs, getAppLogs } from './lib/applog'
import { prisma } from './lib/db'
import { env } from './lib/env'
import { createSystemLog } from './lib/logger'
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
import { addConnection, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence'
import { parseSchema } from './lib/schema-parser'
function getPublicOrigin(request: Request): string {
if (process.env.BUN_PUBLIC_BASE_URL) return process.env.BUN_PUBLIC_BASE_URL.replace(/\/$/, '')
@@ -43,6 +46,19 @@ async function checkAuth(request: Request): Promise<AuthResult | null> {
return null
}
async function requireDeveloper(request: Request, set: { status?: number | string }): Promise<{ userId: string; role: string } | null> {
const cookie = request.headers.get('cookie') ?? ''
const token = cookie.match(/session=([^;]+)/)?.[1]
if (!token) { set.status = 401; return null }
const session = await prisma.session.findUnique({
where: { token },
include: { user: { select: { id: true, role: true } } },
})
if (!session || session.expiresAt < new Date()) { set.status = 401; return null }
if (session.user.role !== 'DEVELOPER') { set.status = 403; return null }
return { userId: session.user.id, role: session.user.role }
}
export function createApp() {
return new Elysia()
.use(swagger({
@@ -63,6 +79,21 @@ export function createApp() {
.use(cors())
.use(html())
// ─── Request timing + app log broadcasting ────────
.onRequest(({ request }) => {
;(request as any).__startTime = performance.now()
})
.onAfterResponse(({ request, set }) => {
const url = new URL(request.url)
if (url.pathname.startsWith('/api/')) {
const status = typeof set.status === 'number' ? set.status : 200
const level = status >= 500 ? ('error' as const) : status >= 400 ? ('warn' as const) : ('info' as const)
appLog(level, `${request.method} ${url.pathname} ${status}`)
const duration = Math.round(performance.now() - ((request as any).__startTime || 0))
broadcastToAdmins({ type: 'request', method: request.method, path: url.pathname, status, duration, timestamp: new Date().toISOString() })
}
})
// ─── Global Error Handler ────────────────────────
.onError(({ code, error }) => {
if (code === 'NOT_FOUND') {
@@ -181,7 +212,7 @@ export function createApp() {
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
await createSystemLog(user.id, 'LOGIN', 'Logged in with Google')
const redirectPath = user.role === 'USER' ? '/profile' : '/dashboard'
const redirectPath = user.role === 'DEVELOPER' ? '/dev' : user.role === 'USER' ? '/profile' : '/dashboard'
const headers = new Headers()
headers.append('Location', redirectPath)
headers.append('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`)
@@ -971,4 +1002,489 @@ export function createApp() {
}),
detail: { summary: 'Hello by Name', tags: ['System'] },
})
// ─── Dev Console Admin API (DEVELOPER only) ────────
.get('/api/admin/stats', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const [totalApps, openBugs, totalOperators] = await Promise.all([
prisma.app.count(),
prisma.bug.count({ where: { status: 'OPEN' } }),
prisma.user.count(),
])
const onlineCount = getOnlineUserIds().length
return { totalApps, openBugs, totalOperators, onlineOperators: onlineCount }
})
.get('/api/admin/users', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const users = await prisma.user.findMany({
select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true },
orderBy: { createdAt: 'asc' },
})
return { users }
})
.put('/api/admin/users/:id/role', async ({ request, params, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah role sendiri' } }
const { role } = (await request.json()) as { role: string }
if (!['USER', 'ADMIN'].includes(role)) { set.status = 400; return { error: 'Role tidak valid (USER atau ADMIN)' } }
const target = await prisma.user.findUnique({ where: { id: params.id }, select: { role: true } })
if (target?.role === 'DEVELOPER') { set.status = 400; return { error: 'Tidak bisa mengubah role DEVELOPER' } }
const user = await prisma.user.update({
where: { id: params.id },
data: { role: role as 'USER' | 'ADMIN' },
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
})
await appLog('info', `Role changed: ${user.email} ${target?.role}${role}`)
return { user }
})
.put('/api/admin/users/:id/activate', async ({ request, params, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah status sendiri' } }
const { active } = (await request.json()) as { active: boolean }
const user = await prisma.user.update({
where: { id: params.id },
data: { active },
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
})
if (!active) await prisma.session.deleteMany({ where: { userId: params.id } })
await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`)
return { user }
})
.ws('/ws/presence', {
async open(ws) {
const cookie = ws.data.headers?.cookie ?? ''
const token = (cookie as string).match(/session=([^;]+)/)?.[1]
if (!token) { ws.close(4001, 'Unauthorized'); return }
const session = await prisma.session.findUnique({
where: { token },
include: { user: { select: { id: true, role: true } } },
})
if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return }
const isAdmin = session.user.role === 'DEVELOPER'
;(ws.data as unknown as { userId: string }).userId = session.user.id
addConnection(ws as any, session.user.id, isAdmin)
},
close(ws) { removeConnection(ws as any) },
message() {},
})
.get('/api/admin/presence', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
return { online: getOnlineUserIds() }
})
.get('/api/admin/logs/app', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const url = new URL(request.url)
const level = url.searchParams.get('level') as any
const limit = parseInt(url.searchParams.get('limit') ?? '100', 10)
const afterId = parseInt(url.searchParams.get('afterId') ?? '0', 10)
if (!env.REDIS_URL) return { logs: [], redisDisabled: true }
return { logs: await getAppLogs({ level: level || undefined, limit, afterId: afterId || undefined }) }
})
.delete('/api/admin/logs/app', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
await clearAppLogs()
return { ok: true }
})
.get('/api/admin/logs/audit', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const url = new URL(request.url)
const userId = url.searchParams.get('userId')
const type = url.searchParams.get('type')
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 500)
const where: Record<string, any> = {}
if (userId) where.userId = userId
if (type) where.type = type
const logs = await prisma.log.findMany({
where,
include: { user: { select: { name: true, email: true } } },
orderBy: { createdAt: 'desc' },
take: limit,
})
return { logs }
})
.delete('/api/admin/logs/audit', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const { count } = await prisma.log.deleteMany()
await appLog('info', `Activity logs cleared manually (${count} entries)`)
return { ok: true, deleted: count }
})
.get('/api/admin/schema', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const fs = await import('node:fs')
const schemaPath = `${process.cwd()}/prisma/schema.prisma`
if (!fs.existsSync(schemaPath)) { set.status = 404; return { error: 'Schema not found' } }
const raw = fs.readFileSync(schemaPath, 'utf-8')
return { schema: parseSchema(raw) }
})
.get('/api/admin/routes', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const routes: { method: string; path: string; auth: string; category: string; description: string }[] = [
{ method: 'PAGE', path: '/', auth: 'public', category: 'frontend', description: 'Landing page' },
{ method: 'PAGE', path: '/login', auth: 'public', category: 'frontend', description: 'Login page (email/password + Google OAuth)' },
{ method: 'PAGE', path: '/dev', auth: 'developer', category: 'frontend', description: 'Dev console (DEVELOPER only)' },
{ method: 'PAGE', path: '/dashboard', auth: 'admin', category: 'frontend', description: 'Admin dashboard (ADMIN/DEVELOPER)' },
{ method: 'PAGE', path: '/apps', auth: 'admin', category: 'frontend', description: 'App list' },
{ method: 'PAGE', path: '/apps/:appId', auth: 'admin', category: 'frontend', description: 'App detail (errors, logs, users, etc.)' },
{ method: 'PAGE', path: '/bug-reports', auth: 'admin', category: 'frontend', description: 'Bug reports management' },
{ method: 'PAGE', path: '/logs', auth: 'admin', category: 'frontend', description: 'Activity logs' },
{ method: 'PAGE', path: '/users', auth: 'admin', category: 'frontend', description: 'Operator management' },
{ method: 'PAGE', path: '/profile', auth: 'authenticated', category: 'frontend', description: 'User profile' },
{ method: 'POST', path: '/api/auth/login', auth: 'public', category: 'auth', description: 'Email/password login' },
{ method: 'POST', path: '/api/auth/logout', auth: 'authenticated', category: 'auth', description: 'Logout' },
{ method: 'GET', path: '/api/auth/session', auth: 'public', category: 'auth', description: 'Check current session' },
{ method: 'GET', path: '/api/auth/google', auth: 'public', category: 'auth', description: 'Google OAuth redirect' },
{ method: 'GET', path: '/api/auth/callback/google', auth: 'public', category: 'auth', description: 'Google OAuth callback' },
{ method: 'GET', path: '/api/dashboard/stats', auth: 'authenticated', category: 'dashboard', description: 'Dashboard statistics' },
{ method: 'GET', path: '/api/dashboard/recent-errors', auth: 'authenticated', category: 'dashboard', description: 'Recent bug reports' },
{ method: 'GET', path: '/api/apps', auth: 'authenticated', category: 'apps', description: 'List monitored apps' },
{ method: 'GET', path: '/api/apps/:appId', auth: 'authenticated', category: 'apps', description: 'Get app details' },
{ method: 'GET', path: '/api/bugs', auth: 'authenticated', category: 'bugs', description: 'List bug reports' },
{ method: 'POST', path: '/api/bugs', auth: 'apiKeyOrSession', category: 'bugs', description: 'Create bug report' },
{ method: 'PATCH', path: '/api/bugs/:id/status', auth: 'authenticated', category: 'bugs', description: 'Update bug status' },
{ method: 'PATCH', path: '/api/bugs/:id/feedback', auth: 'authenticated', category: 'bugs', description: 'Update bug feedback' },
{ method: 'POST', path: '/api/upload/image', auth: 'apiKeyOrSession', category: 'bugs', description: 'Upload bug screenshot' },
{ method: 'GET', path: '/api/bugs/images', auth: 'public', category: 'bugs', description: 'Proxy bug image from MinIO' },
{ method: 'GET', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'List activity logs' },
{ method: 'POST', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'Create activity log' },
{ method: 'GET', path: '/api/logs/operators', auth: 'authenticated', category: 'logs', description: 'Operators list for log filter' },
{ method: 'GET', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'List operators' },
{ method: 'GET', path: '/api/operators/stats', auth: 'authenticated', category: 'operators', description: 'Operator stats' },
{ method: 'POST', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'Create operator' },
{ method: 'PATCH', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Update operator' },
{ method: 'DELETE', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Deactivate operator' },
{ method: 'GET', path: '/api/admin/stats', auth: 'developer', category: 'admin', description: 'Dev console overview stats' },
{ method: 'GET', path: '/api/admin/users', auth: 'developer', category: 'admin', description: 'List all users' },
{ method: 'PUT', path: '/api/admin/users/:id/role', auth: 'developer', category: 'admin', description: 'Change user role' },
{ method: 'PUT', path: '/api/admin/users/:id/activate', auth: 'developer', category: 'admin', description: 'Activate/deactivate user' },
{ method: 'GET', path: '/api/admin/presence', auth: 'developer', category: 'admin', description: 'Online user IDs' },
{ method: 'GET', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'App logs (Redis)' },
{ method: 'GET', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Activity logs (DB)' },
{ method: 'DELETE', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'Clear app logs' },
{ method: 'DELETE', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Clear activity logs' },
{ method: 'GET', path: '/api/admin/schema', auth: 'developer', category: 'admin', description: 'Database schema (Prisma)' },
{ method: 'GET', path: '/api/admin/routes', auth: 'developer', category: 'admin', description: 'Routes metadata' },
{ method: 'GET', path: '/api/admin/project-structure', auth: 'developer', category: 'admin', description: 'Project file structure' },
{ method: 'GET', path: '/api/admin/env-map', auth: 'developer', category: 'admin', description: 'Environment variables map' },
{ method: 'GET', path: '/api/admin/test-coverage', auth: 'developer', category: 'admin', description: 'Test coverage mapping' },
{ method: 'GET', path: '/api/admin/dependencies', auth: 'developer', category: 'admin', description: 'NPM dependencies graph' },
{ method: 'GET', path: '/api/admin/migrations', auth: 'developer', category: 'admin', description: 'Migration timeline' },
{ method: 'GET', path: '/api/admin/sessions', auth: 'developer', category: 'admin', description: 'Active sessions' },
{ method: 'WS', path: '/ws/presence', auth: 'authenticated', category: 'realtime', description: 'Real-time presence tracking' },
{ method: 'GET', path: '/health', auth: 'public', category: 'utility', description: 'Health check' },
]
const byMethod: Record<string, number> = {}
const byAuth: Record<string, number> = {}
const byCategory: Record<string, number> = {}
for (const r of routes) {
byMethod[r.method] = (byMethod[r.method] || 0) + 1
byAuth[r.auth] = (byAuth[r.auth] || 0) + 1
byCategory[r.category] = (byCategory[r.category] || 0) + 1
}
return { routes, summary: { total: routes.length, byMethod, byAuth, byCategory } }
})
.get('/api/admin/project-structure', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const fs = await import('node:fs')
const path = await import('node:path')
const root = process.cwd()
const scanDirs = ['src', 'prisma', 'tests']
const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git', '.next'])
const exts = new Set(['.ts', '.tsx'])
interface FileInfo { path: string; category: string; lines: number; exports: string[]; imports: { from: string; names: string[] }[] }
interface DirInfo { path: string; category: string; fileCount: number }
const files: FileInfo[] = []
const dirs: DirInfo[] = []
function categorize(filePath: string): string {
if (filePath.startsWith('src/frontend/routes/')) return 'route'
if (filePath.startsWith('src/frontend/hooks/')) return 'hook'
if (filePath.startsWith('src/frontend/components/')) return 'component'
if (filePath.startsWith('src/frontend')) return 'frontend'
if (filePath.startsWith('src/lib/')) return 'lib'
if (filePath.startsWith('prisma/')) return 'prisma'
if (filePath.startsWith('tests/unit/')) return 'test-unit'
if (filePath.startsWith('tests/integration/')) return 'test-integration'
if (filePath.startsWith('tests/')) return 'test'
if (filePath.startsWith('src/')) return 'backend'
return 'config'
}
function parseFile(filePath: string, content: string): FileInfo {
const lines = content.split('\n').length
const exports: string[] = []
const imports: { from: string; names: string[] }[] = []
for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1])
for (const m of content.matchAll(/import\s+(?:\{([^}]+)\}|(\w+))(?:\s*,\s*\{([^}]+)\})?\s+from\s+['"]([^'"]+)['"]/g)) {
const names: string[] = []
if (m[1]) names.push(...m[1].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean))
if (m[2]) names.push(m[2])
if (m[3]) names.push(...m[3].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean))
let from = m[4]
if (from.startsWith('.')) {
const dir = path.dirname(filePath)
from = path.normalize(path.join(dir, from)).replace(/\\/g, '/')
for (const ext of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
if (fs.existsSync(path.join(root, from + ext))) { from = from + ext; break }
if (fs.existsSync(path.join(root, from))) break
}
}
imports.push({ from, names })
}
return { path: filePath, category: categorize(filePath), lines, exports, imports }
}
function scan(dir: string) {
const absDir = path.join(root, dir)
if (!fs.existsSync(absDir)) return
const entries = fs.readdirSync(absDir, { withFileTypes: true })
let fileCount = 0
for (const entry of entries) {
if (skipDirs.has(entry.name)) continue
const rel = path.join(dir, entry.name).replace(/\\/g, '/')
if (entry.isDirectory()) scan(rel)
else if (exts.has(path.extname(entry.name))) { files.push(parseFile(rel, fs.readFileSync(path.join(root, rel), 'utf-8'))); fileCount++ }
}
dirs.push({ path: dir, category: categorize(`${dir}/`), fileCount })
}
for (const d of scanDirs) scan(d)
files.sort((a, b) => a.path.localeCompare(b.path))
dirs.sort((a, b) => a.path.localeCompare(b.path))
const totalLines = files.reduce((s, f) => s + f.lines, 0)
const totalExports = files.reduce((s, f) => s + f.exports.length, 0)
const totalImports = files.reduce((s, f) => s + f.imports.length, 0)
const byCategory: Record<string, number> = {}
for (const f of files) byCategory[f.category] = (byCategory[f.category] || 0) + 1
return { files, directories: dirs, summary: { totalFiles: files.length, totalLines, totalExports, totalImports, byCategory } }
})
.get('/api/admin/env-map', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const fs = await import('node:fs')
const path = await import('node:path')
const root = process.cwd()
const envDefs: { name: string; envKey: string; required: boolean; default: string | null; category: string; description: string }[] = [
{ name: 'DATABASE_URL', envKey: 'DATABASE_URL', required: true, default: null, category: 'database', description: 'PostgreSQL connection string' },
{ name: 'REDIS_URL', envKey: 'REDIS_URL', required: false, default: '(empty)', category: 'cache', description: 'Redis connection string (optional, enables App Logs)' },
{ name: 'GOOGLE_CLIENT_ID', envKey: 'GOOGLE_CLIENT_ID', required: true, default: null, category: 'auth', description: 'Google OAuth client ID' },
{ name: 'GOOGLE_CLIENT_SECRET', envKey: 'GOOGLE_CLIENT_SECRET', required: true, default: null, category: 'auth', description: 'Google OAuth client secret' },
{ name: 'SUPER_ADMIN_EMAIL', envKey: 'SUPER_ADMIN_EMAIL', required: false, default: '(empty)', category: 'auth', description: 'Emails to auto-promote to DEVELOPER role' },
{ name: 'API_KEY', envKey: 'API_KEY', required: true, default: null, category: 'auth', description: 'API key for external clients (mobile app)' },
{ name: 'MINIO_ENDPOINT', envKey: 'MINIO_ENDPOINT', required: true, default: null, category: 'storage', description: 'MinIO server endpoint' },
{ name: 'MINIO_PORT', envKey: 'MINIO_PORT', required: false, default: '443', category: 'storage', description: 'MinIO server port' },
{ name: 'MINIO_USE_SSL', envKey: 'MINIO_USE_SSL', required: false, default: 'true', category: 'storage', description: 'Use SSL for MinIO connection' },
{ name: 'MINIO_ACCESS_KEY', envKey: 'MINIO_ACCESS_KEY', required: true, default: null, category: 'storage', description: 'MinIO access key' },
{ name: 'MINIO_SECRET_KEY', envKey: 'MINIO_SECRET_KEY', required: true, default: null, category: 'storage', description: 'MinIO secret key' },
{ name: 'MINIO_BUCKET', envKey: 'MINIO_BUCKET', required: true, default: null, category: 'storage', description: 'MinIO bucket name' },
{ name: 'MINIO_UPLOAD_DIR', envKey: 'MINIO_UPLOAD_DIR', required: false, default: 'bug-reports', category: 'storage', description: 'MinIO upload directory prefix' },
{ name: 'PORT', envKey: 'PORT', required: false, default: '3000', category: 'app', description: 'Server port' },
{ name: 'NODE_ENV', envKey: 'NODE_ENV', required: false, default: 'development', category: 'app', description: 'Environment mode' },
{ name: 'REACT_EDITOR', envKey: 'REACT_EDITOR', required: false, default: 'code', category: 'app', description: 'Editor for click-to-source' },
{ name: 'BUN_PUBLIC_BASE_URL', envKey: 'BUN_PUBLIC_BASE_URL', required: false, default: 'http://localhost:3000', category: 'app', description: 'Public base URL (for OAuth redirect)' },
]
const srcFiles = ['src/lib/env.ts', 'src/lib/db.ts', 'src/lib/redis.ts', 'src/app.ts', 'src/index.tsx']
const fileContents: Record<string, string> = {}
for (const f of srcFiles) {
const absPath = path.join(root, f)
if (fs.existsSync(absPath)) fileContents[f] = fs.readFileSync(absPath, 'utf-8')
}
const variables = envDefs.map((def) => {
const usedBy: string[] = []
for (const [file, content] of Object.entries(fileContents)) {
if (content.includes(def.envKey) || content.includes(`env.${def.name}`)) usedBy.push(file)
}
return { name: def.name, required: def.required, isSet: !!process.env[def.envKey], default: def.default, category: def.category, description: def.description, usedBy }
})
const byCategory: Record<string, number> = {}
let setCount = 0, requiredCount = 0
for (const v of variables) {
byCategory[v.category] = (byCategory[v.category] || 0) + 1
if (v.isSet) setCount++
if (v.required) requiredCount++
}
return { variables, summary: { total: variables.length, set: setCount, unset: variables.length - setCount, required: requiredCount, byCategory } }
})
.get('/api/admin/test-coverage', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const fs = await import('node:fs')
const pathMod = await import('node:path')
const root = process.cwd()
const exts = new Set(['.ts', '.tsx'])
const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git'])
interface SrcFile { path: string; lines: number; exports: string[]; testedBy: string[]; coverage: string }
interface TestFile { path: string; lines: number; type: string; targets: string[] }
function scanDir(dir: string, collect: string[]) {
const abs = pathMod.join(root, dir)
if (!fs.existsSync(abs)) return
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
if (skipDirs.has(entry.name)) continue
const rel = pathMod.join(dir, entry.name).replace(/\\/g, '/')
if (entry.isDirectory()) scanDir(rel, collect)
else if (exts.has(pathMod.extname(entry.name))) collect.push(rel)
}
}
const srcPaths: string[] = []
scanDir('src', srcPaths)
const srcFiltered = srcPaths.filter((f) => !f.includes('routeTree.gen'))
const testPaths: string[] = []
scanDir('tests', testPaths)
const testFiltered = testPaths.filter((f) => f.includes('.test.'))
const testFiles: TestFile[] = testFiltered.map((tp) => {
const content = fs.readFileSync(pathMod.join(root, tp), 'utf-8')
const lines = content.split('\n').length
const type = tp.includes('/unit/') ? 'unit' : tp.includes('/integration/') ? 'integration' : 'other'
const targets: string[] = []
for (const m of content.matchAll(/from\s+['"]([^'"]*(?:src|lib)[^'"]*)['"]/g)) {
let resolved = m[1].replace(/^.*?src\//, 'src/')
if (resolved.startsWith('.')) resolved = pathMod.normalize(pathMod.join(pathMod.dirname(tp), resolved)).replace(/\\/g, '/')
for (const ext of ['', '.ts', '.tsx']) {
const full = resolved + ext
if (srcFiltered.includes(full)) { targets.push(full); break }
}
}
if (/fetch\(['"`]\/api\//.test(content) || /createApp|createTestApp/.test(content)) {
if (!targets.includes('src/app.ts')) targets.push('src/app.ts')
}
return { path: tp, lines, type, targets: [...new Set(targets)] }
})
const testedByMap: Record<string, string[]> = {}
for (const t of testFiles) for (const target of t.targets) { if (!testedByMap[target]) testedByMap[target] = []; testedByMap[target].push(t.path) }
const sourceFiles: SrcFile[] = srcFiltered.map((sp) => {
const content = fs.readFileSync(pathMod.join(root, sp), 'utf-8')
const lines = content.split('\n').length
const exports: string[] = []
for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1])
const tb = testedByMap[sp] || []
const coverage = tb.length === 0 ? 'uncovered' : tb.some((t) => t.includes('/unit/')) ? 'covered' : 'partial'
return { path: sp, lines, exports, testedBy: tb, coverage }
})
const covered = sourceFiles.filter((f) => f.coverage === 'covered').length
const partial = sourceFiles.filter((f) => f.coverage === 'partial').length
const uncovered = sourceFiles.filter((f) => f.coverage === 'uncovered').length
return { sourceFiles, testFiles, summary: { totalSource: sourceFiles.length, totalTests: testFiles.length, covered, partial, uncovered, coveragePercent: Math.round(((covered + partial * 0.5) / sourceFiles.length) * 100) } }
})
.get('/api/admin/dependencies', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const fs = await import('node:fs')
const pathMod = await import('node:path')
const root = process.cwd()
const pkgPath = pathMod.join(root, 'package.json')
if (!fs.existsSync(pkgPath)) { set.status = 404; return { error: 'package.json not found' } }
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
const deps: Record<string, string> = pkg.dependencies || {}
const devDeps: Record<string, string> = pkg.devDependencies || {}
const catMap: Record<string, string> = {
elysia: 'server', '@elysiajs/cors': 'server', '@elysiajs/html': 'server', '@elysiajs/swagger': 'server',
react: 'ui', 'react-dom': 'ui', '@mantine/core': 'ui', '@mantine/hooks': 'ui', '@mantine/charts': 'ui',
'@mantine/notifications': 'ui', '@mantine/modals': 'ui', '@tanstack/react-router': 'ui',
'@tanstack/react-query': 'ui', '@xyflow/react': 'ui', 'react-icons': 'ui', recharts: 'ui', swr: 'ui',
'@prisma/client': 'database', prisma: 'database', minio: 'storage',
vite: 'build', typescript: 'build', '@biomejs/biome': 'build', '@vitejs/plugin-react': 'build', elkjs: 'build',
}
const srcFiles: string[] = []
function scanSrc(dir: string) {
const abs = pathMod.join(root, dir)
if (!fs.existsSync(abs)) return
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
if (['node_modules', 'dist', 'generated', '.git'].includes(e.name)) continue
const rel = pathMod.join(dir, e.name).replace(/\\/g, '/')
if (e.isDirectory()) scanSrc(rel)
else if (/\.(ts|tsx)$/.test(e.name)) srcFiles.push(rel)
}
}
scanSrc('src')
const fileContents: Record<string, string> = {}
for (const f of srcFiles) fileContents[f] = fs.readFileSync(pathMod.join(root, f), 'utf-8')
const allPkgs: { name: string; version: string; type: string; category: string; usedBy: string[] }[] = []
for (const [name, version] of Object.entries(deps)) {
const usedBy: string[] = []
const importPattern = new RegExp(`from\\s+['"]${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
for (const [file, content] of Object.entries(fileContents)) if (importPattern.test(content)) usedBy.push(file)
allPkgs.push({ name, version, type: 'runtime', category: catMap[name] || 'other', usedBy })
}
for (const [name, version] of Object.entries(devDeps)) allPkgs.push({ name, version, type: 'dev', category: catMap[name] || 'build', usedBy: [] })
const byCategory: Record<string, number> = {}
let runtime = 0, dev = 0
for (const p of allPkgs) { byCategory[p.category] = (byCategory[p.category] || 0) + 1; if (p.type === 'runtime') runtime++; else dev++ }
return { packages: allPkgs, summary: { total: allPkgs.length, runtime, dev, byCategory } }
})
.get('/api/admin/migrations', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const fs = await import('node:fs')
const pathMod = await import('node:path')
const root = process.cwd()
const migrationsDir = pathMod.join(root, 'prisma/migrations')
if (!fs.existsSync(migrationsDir)) return { migrations: [], summary: { totalMigrations: 0, firstMigration: null, lastMigration: null, totalChanges: 0 } }
const entries = fs.readdirSync(migrationsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name))
const migrations = entries.map((entry) => {
const sqlPath = pathMod.join(migrationsDir, entry.name, 'migration.sql')
let sql = ''
const changes: string[] = []
if (fs.existsSync(sqlPath)) {
sql = fs.readFileSync(sqlPath, 'utf-8')
for (const m of sql.matchAll(/^(CREATE TABLE|ALTER TABLE|CREATE INDEX|CREATE UNIQUE INDEX|DROP TABLE|DROP INDEX|CREATE TYPE|ALTER TYPE)\s+["']?(\w+)["']?/gim)) changes.push(`${m[1]} ${m[2]}`)
for (const m of sql.matchAll(/CREATE TYPE\s+"(\w+)"/g)) if (!changes.some((c) => c.includes(m[1]))) changes.push(`CREATE TYPE ${m[1]}`)
}
const dateStr = entry.name.substring(0, 14)
const createdAt = new Date(`${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}T${dateStr.slice(8, 10)}:${dateStr.slice(10, 12)}:${dateStr.slice(12, 14)}.000Z`).toISOString()
const name = entry.name.substring(15)
return { name, folder: entry.name, createdAt, changes, sql: sql.substring(0, 800) }
})
const totalChanges = migrations.reduce((s, m) => s + m.changes.length, 0)
return { migrations, summary: { totalMigrations: migrations.length, firstMigration: migrations[0]?.createdAt || null, lastMigration: migrations[migrations.length - 1]?.createdAt || null, totalChanges } }
})
.get('/api/admin/sessions', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const onlineIds = new Set(getOnlineUserIds())
const sessions = await prisma.session.findMany({
include: { user: { select: { id: true, name: true, email: true, role: true, active: true } } },
orderBy: { createdAt: 'desc' },
})
const now = new Date()
const result = sessions.map((s) => ({
id: s.id, userId: s.user.id, userName: s.user.name, userEmail: s.user.email,
userRole: s.user.role, userActive: s.user.active,
isOnline: onlineIds.has(s.user.id),
createdAt: s.createdAt.toISOString(), expiresAt: s.expiresAt.toISOString(), isExpired: s.expiresAt < now,
}))
const byRole: Record<string, number> = {}
const uniqueUsers = new Set<string>()
let active = 0, expired = 0
for (const s of result) {
uniqueUsers.add(s.userId)
byRole[s.userRole] = (byRole[s.userRole] || 0) + 1
if (s.isExpired) expired++; else active++
}
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
})
}