From b03f2677436b5c491b54b994cd14846e886d6c7f Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 28 Apr 2026 17:34:45 +0800 Subject: [PATCH] upd: routing dev --- bun.lock | 33 + package.json | 5 +- src/app.ts | 518 +++++++++- src/frontend/App.tsx | 9 +- src/frontend/hooks/useAuth.ts | 8 +- src/frontend/hooks/usePresence.ts | 42 + src/frontend/routes/dev.tsx | 1481 +++++++++++++++++++++++++++++ src/frontend/routes/login.tsx | 3 +- src/lib/applog.ts | 45 + src/lib/env.ts | 1 + src/lib/presence.ts | 44 + src/lib/redis.ts | 3 + src/lib/schema-parser.ts | 104 ++ 13 files changed, 2289 insertions(+), 7 deletions(-) create mode 100644 src/frontend/hooks/usePresence.ts create mode 100644 src/frontend/routes/dev.tsx create mode 100644 src/lib/applog.ts create mode 100644 src/lib/presence.ts create mode 100644 src/lib/redis.ts create mode 100644 src/lib/schema-parser.ts diff --git a/bun.lock b/bun.lock index ced6c08..6635a06 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 87e177c..eb220c5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.ts b/src/app.ts index 8eb4205..411693c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 { 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 = {} + 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 = {} + const byAuth: Record = {} + const byCategory: Record = {} + 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 = {} + 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 = {} + 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 = {} + 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 = {} + 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 = pkg.dependencies || {} + const devDeps: Record = pkg.devDependencies || {} + const catMap: Record = { + 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 = {} + 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 = {} + 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 = {} + const uniqueUsers = new Set() + 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 } } + }) } diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 453a205..3648078 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -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() { - - - + + + + + ) diff --git a/src/frontend/hooks/useAuth.ts b/src/frontend/hooks/useAuth.ts index f6995bd..de18fcd 100644 --- a/src/frontend/hooks/useAuth.ts +++ b/src/frontend/hooks/useAuth.ts @@ -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) }) }, }) } diff --git a/src/frontend/hooks/usePresence.ts b/src/frontend/hooks/usePresence.ts new file mode 100644 index 0000000..4ab296e --- /dev/null +++ b/src/frontend/hooks/usePresence.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from 'react' +import { useSession } from './useAuth' + +export function usePresence() { + const { data } = useSession() + const [onlineUserIds, setOnlineUserIds] = useState([]) + const wsRef = useRef(null) + const reconnectTimer = useRef | 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 } +} diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx new file mode 100644 index 0000000..4628dbb --- /dev/null +++ b/src/frontend/routes/dev.tsx @@ -0,0 +1,1481 @@ +import { + ActionIcon, + AppShell, + Avatar, + Badge, + Box, + Burger, + Button, + Card, + Center, + Container, + Group, + Loader, + Menu, + Modal, + NavLink, + Pagination, + Paper, + SegmentedControl, + Select, + SimpleGrid, + Stack, + Table, + Text, + ThemeIcon, + Title, + Tooltip, +} from '@mantine/core' +import { useDisclosure, useMediaQuery } from '@mantine/hooks' +import { modals } from '@mantine/modals' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router' +import { + Background, + Controls, + type Edge, + Handle, + MarkerType, + type Node, + Position, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import ELK from 'elkjs/lib/elk.bundled.js' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + TbActivity, + TbApps, + TbBug, + TbChevronRight, + TbCircleFilled, + TbCode, + TbDatabase, + TbDots, + TbFileText, + TbLayoutDashboard, + TbLayoutSidebarLeftCollapse, + TbLayoutSidebarLeftExpand, + TbLogout, + TbRefresh, + TbServer, + TbSettings, + TbSitemap, + TbTrash, + TbUser, + TbUserSearch, + TbUsers, +} from 'react-icons/tb' +import { type Role, useLogout, useSession } from '@/frontend/hooks/useAuth' +import { usePresence } from '@/frontend/hooks/usePresence' + +const validTabs = ['overview', 'operators', 'bugs', 'app-logs', 'activity-logs', 'database', 'project', 'settings'] as const + +export const Route = createFileRoute('/dev')({ + validateSearch: (search: Record) => ({ + tab: validTabs.includes(search.tab as any) ? (search.tab as string) : 'overview', + }), + beforeLoad: async ({ context }) => { + try { + const data = await context.queryClient.ensureQueryData({ + queryKey: ['auth', 'session'], + queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), + }) + if (!data?.user) throw redirect({ to: '/login' }) + if (data.user.role !== 'DEVELOPER') throw redirect({ to: '/dashboard' }) + } catch (e) { + if (e instanceof Error) throw redirect({ to: '/login' }) + throw e + } + }, + component: DevPage, +}) + +interface AdminUser { + id: string + name: string + email: string + role: Role + active: boolean + image?: string | null + createdAt: string +} + +const navItems = [ + { label: 'Overview', icon: TbLayoutDashboard, key: 'overview' }, + { label: 'Operators', icon: TbUsers, key: 'operators' }, + { label: 'Bugs', icon: TbBug, key: 'bugs' }, + { label: 'App Logs', icon: TbServer, key: 'app-logs' }, + { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' }, + { label: 'Database', icon: TbDatabase, key: 'database' }, + { label: 'Project', icon: TbSitemap, key: 'project' }, + { label: 'Settings', icon: TbSettings, key: 'settings' }, +] + +function DevPage() { + const { data } = useSession() + const logout = useLogout() + const user = data?.user + const { tab: active } = Route.useSearch() + const navigate = useNavigate() + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = useDisclosure(false) + const isMobile = useMediaQuery('(max-width: 48em)') + const setActive = (key: string) => { + navigate({ to: '/dev', search: { tab: key } }) + closeMobile() + } + const [collapsed, setCollapsed] = useState(() => localStorage.getItem('dev:sidebar') === 'collapsed') + const toggleSidebar = () => { + setCollapsed((prev) => { + const next = !prev + localStorage.setItem('dev:sidebar', next ? 'collapsed' : 'open') + return next + }) + } + const confirmLogout = () => + modals.openConfirmModal({ + title: 'Logout', + children: Yakin ingin logout?, + labels: { confirm: 'Logout', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => logout.mutate(), + }) + + return ( + + + + + + + + + Dev Console + + + + + + + + {collapsed ? ( + + + + + + ) : ( + <> + + + + +
+ Dev Console + Developer +
+
+ + + + + + + )} +
+
+ + + + {navItems.map((item) => { + const Icon = item.icon + if (collapsed) { + return ( + + setActive(item.key)} + > + + + + ) + } + return ( + } + rightSection={active === item.key ? : undefined} + active={active === item.key} + onClick={() => setActive(item.key)} + style={{ borderRadius: 6 }} + /> + ) + })} + + + + + {!collapsed && user && ( + + + + {user.name.charAt(0).toUpperCase()} + + + {user.name} + {user.email} + + + + )} + + {!collapsed && ( + + + + + + )} + {collapsed && ( + + + + + + )} + + +
+ + + + {active === 'overview' && } + {active === 'operators' && } + {active === 'bugs' && } + {active === 'app-logs' && } + {active === 'activity-logs' && } + {active === 'database' && } + {active === 'project' && } + {active === 'settings' && } + + +
+ ) +} + +// ─── Overview Panel ──────────────────────────────────────────────────────────── + +function OverviewPanel() { + const { onlineUserIds } = usePresence() + const { data } = useQuery({ + queryKey: ['admin', 'stats'], + queryFn: () => fetch('/api/admin/stats', { credentials: 'include' }).then((r) => r.json()), + refetchInterval: 10_000, + }) + const stats = data ?? {} + + const cards = [ + { label: 'Total Apps', value: stats.totalApps ?? '—', icon: TbApps, color: 'blue' }, + { label: 'Open Bugs', value: stats.openBugs ?? '—', icon: TbBug, color: 'red' }, + { label: 'Total Operators', value: stats.totalOperators ?? '—', icon: TbUsers, color: 'green' }, + { label: 'Online Now', value: onlineUserIds.length, icon: TbCircleFilled, color: 'teal' }, + ] + + return ( + + Overview + + {cards.map((c) => { + const Icon = c.icon + return ( + + + {c.label} + + + + + {String(c.value)} + + ) + })} + + + ) +} + +// ─── Operators Panel ─────────────────────────────────────────────────────────── + +function OperatorsPanel({ currentUserId }: { currentUserId: string }) { + const { onlineUserIds } = usePresence() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'users'], + queryFn: () => fetch('/api/admin/users', { credentials: 'include' }).then((r) => r.json()), + }) + const users: AdminUser[] = data?.users ?? [] + + const roleMutation = useMutation({ + mutationFn: ({ id, role }: { id: string; role: string }) => + fetch(`/api/admin/users/${id}/role`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role }) }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }), + }) + const activateMutation = useMutation({ + mutationFn: ({ id, active }: { id: string; active: boolean }) => + fetch(`/api/admin/users/${id}/activate`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active }) }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }), + }) + + const roleColor: Record = { DEVELOPER: 'violet', ADMIN: 'blue', USER: 'gray' } + + return ( + + + Operators + {users.length} total + + {isLoading ?
: ( + + + + + Operator + Role + Status + Joined + + + + + {users.map((u) => { + const isOnline = onlineUserIds.includes(u.id) + const isSelf = u.id === currentUserId + const isDeveloper = u.role === 'DEVELOPER' + return ( + + + + + {u.name.charAt(0).toUpperCase()} + {isOnline && ( + + )} + +
+ {u.name} {isSelf && (you)} + {u.email} +
+
+
+ {u.role} + + {!u.active ? Inactive + : isOnline ? Online + : Offline} + + {new Date(u.createdAt).toLocaleDateString('id-ID')} + + {!isSelf && !isDeveloper && ( + + + + + + Ganti Role + {(['USER', 'ADMIN'] as const).filter((r) => r !== u.role).map((r) => ( + } onClick={() => roleMutation.mutate({ id: u.id, role: r })}> + Jadikan {r} + + ))} + + {u.active ? ( + } + onClick={() => modals.openConfirmModal({ + title: 'Nonaktifkan Operator', + children: Nonaktifkan {u.name}? Semua sesi aktif akan dihapus., + labels: { confirm: 'Nonaktifkan', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => activateMutation.mutate({ id: u.id, active: false }), + })}> + Nonaktifkan + + ) : ( + } + onClick={() => activateMutation.mutate({ id: u.id, active: true })}> + Aktifkan + + )} + + + )} + +
+ ) + })} +
+
+
+ )} +
+ ) +} + +// ─── Bugs Panel ──────────────────────────────────────────────────────────────── + +const BUG_STATUSES = ['all', 'OPEN', 'ON_HOLD', 'IN_PROGRESS', 'RESOLVED', 'RELEASED', 'CLOSED'] as const +const BUG_STATUS_COLOR: Record = { + OPEN: 'red', ON_HOLD: 'orange', IN_PROGRESS: 'blue', RESOLVED: 'teal', RELEASED: 'green', CLOSED: 'gray', +} +const BUG_SOURCE_COLOR: Record = { QC: 'violet', SYSTEM: 'blue', USER: 'gray' } + +function BugsPanel() { + const [status, setStatus] = useState('all') + const [page, setPage] = useState(1) + const PER_PAGE = 25 + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'bugs', status, page], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(PER_PAGE) }) + if (status !== 'all') params.set('status', status) + return fetch(`/api/bugs?${params}`, { credentials: 'include' }).then((r) => r.json()) + }, + refetchInterval: 15_000, + }) + + const bugs = data?.data ?? [] + const totalPages = data?.totalPages ?? 1 + const totalItems = data?.totalItems ?? 0 + + return ( + + + Bugs + {totalItems} total + + { setStatus(v); setPage(1) }} + data={BUG_STATUSES.map((s) => ({ label: s === 'all' ? 'All' : s.replace('_', ' '), value: s }))} + size="xs" + /> + {isLoading ?
: ( + <> + + + + + Status + Source + App + Description + Version + Reporter + Date + + + + {bugs.map((b: any) => ( + + {b.status.replace('_', ' ')} + {b.source} + {b.app?.name ?? b.appId ?? '—'} + {b.description} + {b.affectedVersion} + {b.user?.name ?? '—'} + {new Date(b.createdAt).toLocaleDateString('id-ID')} + + ))} + {bugs.length === 0 && ( + +
Tidak ada bug ditemukan
+
+ )} +
+
+
+ {totalPages > 1 &&
} + + )} +
+ ) +} + +// ─── App Logs Panel ──────────────────────────────────────────────────────────── + +const LOG_LEVELS = ['all', 'info', 'warn', 'error'] as const +const LOG_LEVEL_COLOR: Record = { info: 'blue', warn: 'orange', error: 'red' } + +function AppLogsPanel() { + const [level, setLevel] = useState('all') + const [page, setPage] = useState(1) + const PER_PAGE = 25 + const qc = useQueryClient() + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'logs', 'app', level], + queryFn: () => { + const params = new URLSearchParams({ limit: '200' }) + if (level !== 'all') params.set('level', level) + return fetch(`/api/admin/logs/app?${params}`, { credentials: 'include' }).then((r) => r.json()) + }, + refetchInterval: 5_000, + }) + + const allLogs = data?.logs ?? [] + const redisDisabled = data?.redisDisabled + + const pageLogs = useMemo(() => { + const start = (page - 1) * PER_PAGE + return allLogs.slice(start, start + PER_PAGE) + }, [allLogs, page]) + + const totalPages = Math.ceil(allLogs.length / PER_PAGE) + + const clearMutation = useMutation({ + mutationFn: () => fetch('/api/admin/logs/app', { method: 'DELETE', credentials: 'include' }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'logs', 'app'] }), + }) + + return ( + + + App Logs + + qc.invalidateQueries({ queryKey: ['admin', 'logs', 'app'] })}> + + + modals.openConfirmModal({ + title: 'Hapus semua app logs', + children: Semua log Redis akan dihapus. Tindakan ini tidak bisa dibatalkan., + labels: { confirm: 'Hapus', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => clearMutation.mutate(), + })}> + + + + + + {redisDisabled && ( + + Redis tidak dikonfigurasi (REDIS_URL kosong). App Logs tidak tersedia. + + )} + + { setLevel(v); setPage(1) }} + data={LOG_LEVELS.map((l) => ({ label: l === 'all' ? 'All' : l.toUpperCase(), value: l }))} + size="xs" + /> + + {isLoading ?
: ( + <> + + + + + Time + Level + Message + Detail + + + + {pageLogs.map((log: any) => ( + + {new Date(log.timestamp).toLocaleTimeString('id-ID')} + {log.level.toUpperCase()} + {log.message} + {log.detail ?? ''} + + ))} + {pageLogs.length === 0 && !redisDisabled && ( +
Belum ada log
+ )} +
+
+
+ {totalPages > 1 &&
} + + )} +
+ ) +} + +// ─── Activity Logs Panel ─────────────────────────────────────────────────────── + +const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const +const LOG_TYPE_COLOR: Record = { LOGIN: 'green', LOGOUT: 'gray', CREATE: 'blue', UPDATE: 'yellow', DELETE: 'red' } + +function ActivityLogsPanel() { + const [type, setType] = useState('all') + const [userId, setUserId] = useState('all') + const [page, setPage] = useState(1) + const PER_PAGE = 25 + const qc = useQueryClient() + + const { data: usersData } = useQuery({ + queryKey: ['admin', 'users'], + queryFn: () => fetch('/api/admin/users', { credentials: 'include' }).then((r) => r.json()), + }) + const users: AdminUser[] = usersData?.users ?? [] + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'logs', 'audit', type, userId], + queryFn: () => { + const params = new URLSearchParams({ limit: '200' }) + if (type !== 'all') params.set('type', type) + if (userId !== 'all') params.set('userId', userId) + return fetch(`/api/admin/logs/audit?${params}`, { credentials: 'include' }).then((r) => r.json()) + }, + refetchInterval: 10_000, + }) + + const allLogs = data?.logs ?? [] + const pageLogs = useMemo(() => { + const start = (page - 1) * PER_PAGE + return allLogs.slice(start, start + PER_PAGE) + }, [allLogs, page]) + const totalPages = Math.ceil(allLogs.length / PER_PAGE) + + const clearMutation = useMutation({ + mutationFn: () => fetch('/api/admin/logs/audit', { method: 'DELETE', credentials: 'include' }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'logs', 'audit'] }), + }) + + return ( + + + Activity Logs + + qc.invalidateQueries({ queryKey: ['admin', 'logs', 'audit'] })}> + + + modals.openConfirmModal({ + title: 'Hapus semua activity logs', + children: Semua log aktivitas akan dihapus permanen., + labels: { confirm: 'Hapus', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => clearMutation.mutate(), + })}> + + + + + + v && setView(v)} + data={PROJECT_VIEWS.map((g) => ({ group: g.group, items: g.items }))} + size="xs" + w={200} + /> + + + {isLiveView && } + {isStaticView && staticGraph && ( + + + + )} + {!isLiveView && !isStaticView && viewData && ( + + + + )} + + + ) +} + +function GenericFlowPanelWrapper(props: { queryKey: string[]; queryFn: () => Promise; buildGraph: (d: any) => { nodes: Node[]; edges: Edge[] } }) { + return ( + + + + ) +} + +function GenericFlowInner({ queryKey, queryFn, buildGraph }: { queryKey: string[]; queryFn: () => Promise; buildGraph: (d: any) => { nodes: Node[]; edges: Edge[] } }) { + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const flowKey = queryKey.join('-') + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey) + const saveTimer = useRef | undefined>(undefined) + + const { data, isLoading, refetch } = useQuery({ queryKey, queryFn, refetchInterval: queryKey.includes('sessions') ? 10_000 : undefined }) + + useEffect(() => { + if (!data) return + const { nodes: newNodes, edges: newEdges } = buildGraph(data) + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + if (hasSaved) { + setNodes(newNodes.map((n) => ({ ...n, position: savedPos[n.id] ?? n.position }))) + setEdges(newEdges) + const vp = loadViewport() + if (vp) setTimeout(() => setViewport(vp), 50) + else setTimeout(() => fitView({ padding: 0.15 }), 50) + } else { + applyElkLayout(newNodes, newEdges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln); setEdges(le) + setTimeout(() => fitView({ padding: 0.15 }), 100) + }) + } + }, [data]) + + const onNodeDragStop = useCallback((_: any, __: any, all: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(all), 500) + }, [savePositions]) + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + if (isLoading) return
+ return ( + <> + + { refetch() }}> + + + + + + ) +} + +function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Edge[] }; flowKey: string }) { + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey) + const saveTimer = useRef | undefined>(undefined) + + useEffect(() => { + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + if (hasSaved) { + setNodes(graph.nodes.map((n) => ({ ...n, position: savedPos[n.id] ?? n.position }))) + setEdges(graph.edges) + const vp = loadViewport() + if (vp) setTimeout(() => setViewport(vp), 50) + else setTimeout(() => fitView({ padding: 0.15 }), 50) + } else { + applyElkLayout(graph.nodes, graph.edges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln); setEdges(le) + setTimeout(() => fitView({ padding: 0.15 }), 100) + }) + } + }, []) + + const onNodeDragStop = useCallback((_: any, __: any, all: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(all), 500) + }, [savePositions]) + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + return ( + + + + + + ) +} + +// ─── Settings Panel ──────────────────────────────────────────────────────────── + +function SettingsPanel() { + return ( + + Settings + +
+ Konfigurasi sistem akan ditampilkan di sini. +
+
+
+ ) +} + +// ─── Unused imports fix ──────────────────────────────────────────────────────── +// Box, Container, Card, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, ThemeIcon, Title, Tooltip — all used above +// TbDots is used in OperatorsPanel menu +void TbFileText +void TbCode +void TbUser +void TbUserSearch diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx index f534cfc..7432d4f 100644 --- a/src/frontend/routes/login.tsx +++ b/src/frontend/routes/login.tsx @@ -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 diff --git a/src/lib/applog.ts b/src/lib/applog.ts new file mode 100644 index 0000000..e0c3573 --- /dev/null +++ b/src/lib/applog.ts @@ -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 { + 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) +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 38cbabd..ec97ba9 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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 diff --git a/src/lib/presence.ts b/src/lib/presence.ts new file mode 100644 index 0000000..c6cf064 --- /dev/null +++ b/src/lib/presence.ts @@ -0,0 +1,44 @@ +import type { ServerWebSocket } from 'bun' + +const connections = new Map>>() +const adminSubs = new Set>() + +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() +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..6da9553 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,3 @@ +import { env } from './env' + +export const redis = env.REDIS_URL ? new Bun.RedisClient(env.REDIS_URL) : null diff --git a/src/lib/schema-parser.ts b/src/lib/schema-parser.ts new file mode 100644 index 0000000..77d44fe --- /dev/null +++ b/src/lib/schema-parser.ts @@ -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 } +}