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

@@ -11,10 +11,13 @@
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
"@xyflow/react": "^12.6.4",
"elkjs": "^0.9.3",
"elysia": "^1.4.28",
"minio": "^8.0.7",
"postcss": "^8.5.8",
@@ -200,6 +203,8 @@
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
"@mantine/modals": ["@mantine/modals@8.3.18", "", { "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q=="],
"@mantine/notifications": ["@mantine/notifications@8.3.18", "", { "dependencies": { "@mantine/store": "8.3.18", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw=="],
"@mantine/store": ["@mantine/store@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg=="],
@@ -314,6 +319,8 @@
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
@@ -322,12 +329,18 @@
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -342,6 +355,10 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="],
"@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
@@ -406,6 +423,8 @@
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -432,6 +451,10 @@
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
@@ -442,6 +465,8 @@
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
@@ -450,6 +475,10 @@
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -484,6 +513,8 @@
"electron-to-chromium": ["electron-to-chromium@1.5.329", "", {}, "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ=="],
"elkjs": ["elkjs@0.9.3", "", {}, "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ=="],
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -888,6 +919,8 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],

View File

@@ -42,7 +42,10 @@
"react-dom": "^19",
"react-icons": "^5.6.0",
"recharts": "^3.8.1",
"swr": "^2.4.1"
"swr": "^2.4.1",
"@mantine/modals": "^8.3.18",
"@xyflow/react": "^12.6.4",
"elkjs": "^0.9.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",

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 } }
})
}

View File

@@ -1,6 +1,7 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { ModalsProvider } from '@mantine/modals'
import { Notifications } from '@mantine/notifications'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
@@ -64,9 +65,11 @@ export function App() {
<ColorSchemeScript defaultColorScheme="auto" />
<MantineProvider theme={theme} defaultColorScheme="auto">
<Notifications />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
<ModalsProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</>
)

View File

@@ -3,6 +3,12 @@ import { useNavigate } from '@tanstack/react-router'
export type Role = 'USER' | 'ADMIN' | 'DEVELOPER'
export function getDefaultRoute(role: Role): string {
if (role === 'DEVELOPER') return '/dev'
if (role === 'ADMIN') return '/dashboard'
return '/profile'
}
export interface User {
id: string
name: string
@@ -42,7 +48,7 @@ export function useLogin() {
}),
onSuccess: (data) => {
queryClient.setQueryData(['auth', 'session'], data)
navigate({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
navigate({ to: getDefaultRoute(data.user.role) })
},
})
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from 'react'
import { useSession } from './useAuth'
export function usePresence() {
const { data } = useSession()
const [onlineUserIds, setOnlineUserIds] = useState<string[]>([])
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => {
if (!data?.user) return
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${location.host}/ws/presence`)
wsRef.current = ws
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'presence') setOnlineUserIds(msg.online)
}
ws.onclose = () => {
wsRef.current = null
reconnectTimer.current = setTimeout(connect, 3000)
}
ws.onerror = () => ws.close()
}
connect()
return () => {
clearTimeout(reconnectTimer.current)
if (wsRef.current) {
wsRef.current.onclose = null
wsRef.current.close()
wsRef.current = null
}
}
}, [data?.user?.id, data?.user])
return { onlineUserIds }
}

1481
src/frontend/routes/dev.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,8 @@ export const Route = createFileRoute('/login')({
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (data?.user) {
throw redirect({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
const dest = data.user.role === 'DEVELOPER' ? '/dev' : data.user.role === 'USER' ? '/profile' : '/dashboard'
throw redirect({ to: dest })
}
} catch (e) {
if (e instanceof Error) return

45
src/lib/applog.ts Normal file
View File

@@ -0,0 +1,45 @@
import { redis } from './redis'
export type LogLevel = 'info' | 'warn' | 'error'
export interface AppLogEntry {
id: number
level: LogLevel
message: string
detail?: string
timestamp: string
}
const REDIS_KEY = 'app:logs'
const MAX_ENTRIES = 500
const ID_KEY = 'app:logs:next_id'
export async function appLog(level: LogLevel, message: string, detail?: string) {
if (!redis) return
const id = await redis.incr(ID_KEY)
const entry: AppLogEntry = { id, level, message, detail, timestamp: new Date().toISOString() }
await redis.lpush(REDIS_KEY, JSON.stringify(entry))
await redis.ltrim(REDIS_KEY, 0, MAX_ENTRIES - 1)
}
export async function getAppLogs(options?: {
level?: LogLevel
limit?: number
afterId?: number
}): Promise<AppLogEntry[]> {
if (!redis) return []
const limit = options?.limit ?? 100
const fetchCount = options?.level || options?.afterId ? MAX_ENTRIES : limit
const raw = await redis.lrange(REDIS_KEY, 0, fetchCount - 1)
let logs: AppLogEntry[] = raw.map((s: string) => JSON.parse(s))
if (options?.afterId) logs = logs.filter((l) => l.id > options.afterId!)
if (options?.level) logs = logs.filter((l) => l.level === options.level)
logs.reverse()
return logs.slice(-limit)
}
export async function clearAppLogs() {
if (!redis) return
await redis.del(REDIS_KEY)
await redis.del(ID_KEY)
}

View File

@@ -25,4 +25,5 @@ export const env = {
MINIO_SECRET_KEY: required('MINIO_SECRET_KEY'),
MINIO_BUCKET: required('MINIO_BUCKET'),
MINIO_UPLOAD_DIR: optional('MINIO_UPLOAD_DIR', 'bug-reports'),
REDIS_URL: optional('REDIS_URL', ''),
} as const

44
src/lib/presence.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { ServerWebSocket } from 'bun'
const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>()
const adminSubs = new Set<ServerWebSocket<{ userId: string }>>()
export function getOnlineUserIds(): string[] {
return Array.from(connections.keys())
}
function broadcast() {
const online = getOnlineUserIds()
const msg = JSON.stringify({ type: 'presence', online })
for (const ws of adminSubs) ws.send(msg)
}
export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: string, isAdmin: boolean) {
let set = connections.get(userId)
if (!set) {
set = new Set()
connections.set(userId, set)
}
set.add(ws)
if (isAdmin) {
adminSubs.add(ws)
ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() }))
}
broadcast()
}
export function broadcastToAdmins(message: object) {
const msg = JSON.stringify(message)
for (const ws of adminSubs) ws.send(msg)
}
export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
const userId = ws.data.userId
const set = connections.get(userId)
if (set) {
set.delete(ws)
if (set.size === 0) connections.delete(userId)
}
adminSubs.delete(ws)
broadcast()
}

3
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,3 @@
import { env } from './env'
export const redis = env.REDIS_URL ? new Bun.RedisClient(env.REDIS_URL) : null

104
src/lib/schema-parser.ts Normal file
View File

@@ -0,0 +1,104 @@
export interface SchemaField {
name: string
type: string
isId: boolean
isUnique: boolean
isOptional: boolean
isList: boolean
isRelation: boolean
default?: string
}
export interface SchemaRelation {
from: string
fromField: string
to: string
toField: string
onDelete?: string
}
export interface SchemaModel {
name: string
tableName: string
fields: SchemaField[]
}
export interface SchemaEnum {
name: string
values: string[]
}
export interface ParsedSchema {
models: SchemaModel[]
enums: SchemaEnum[]
relations: SchemaRelation[]
}
export function parseSchema(raw: string): ParsedSchema {
const models: SchemaModel[] = []
const enums: SchemaEnum[] = []
const relations: SchemaRelation[] = []
const blocks = raw.match(/(model|enum)\s+(\w+)\s*\{([^}]*)}/gs) ?? []
for (const block of blocks) {
const match = block.match(/(model|enum)\s+(\w+)\s*\{([^}]*)}/s)
if (!match) continue
const [, type, name, body] = match
const lines = body
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('//'))
if (type === 'enum') {
enums.push({ name, values: lines })
continue
}
let tableName = name
const fields: SchemaField[] = []
for (const line of lines) {
const mapMatch = line.match(/@@map\("(\w+)"\)/)
if (mapMatch) { tableName = mapMatch[1]; continue }
if (line.startsWith('@@')) continue
const fieldMatch = line.match(/^(\w+)\s+(\w+)(\?)?(\[\])?\s*(.*)$/)
if (!fieldMatch) continue
const [, fName, fType, optional, list, attrs] = fieldMatch
const isId = attrs.includes('@id')
const isUnique = attrs.includes('@unique')
const isRelation = attrs.includes('@relation')
const defaultMatch = attrs.match(/@default\(([^)]+)\)/)
const isModelRef =
/^[A-Z]/.test(fType) &&
!enums.some((e) => e.name === fType) &&
!['String', 'Int', 'Float', 'Boolean', 'DateTime', 'BigInt', 'Decimal', 'Bytes', 'Json'].includes(fType)
if (isRelation) {
const relMatch = attrs.match(
/@relation\(fields:\s*\[(\w+)],\s*references:\s*\[(\w+)](?:,\s*onDelete:\s*(\w+))?\)/,
)
if (relMatch) {
relations.push({ from: name, fromField: relMatch[1], to: fType, toField: relMatch[2], onDelete: relMatch[3] })
}
}
fields.push({
name: fName,
type: fType + (list ? '[]' : ''),
isId, isUnique,
isOptional: !!optional,
isList: !!list,
isRelation: isModelRef,
default: defaultMatch?.[1],
})
}
models.push({ name, tableName, fields })
}
return { models, enums, relations }
}