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', disabled: true }, // { 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 ( !item.disabled && setActive(item.key)} > ) } return ( } rightSection={active === item.key ? : undefined} active={active === item.key} disabled={item.disabled} onClick={() => !item.disabled && 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 ──────────────────────────────────────────────────────────── interface AppConfigEntry { key: string; value: string; updatedAt: string } const CONFIG_DEFINITIONS: { key: string; label: string; description: string; placeholder: string }[] = [ { key: 'URL_API_DESA_PLUS', label: 'URL API Desa Plus', description: 'Base URL untuk API eksternal Desa Plus. Semua request dari frontend akan diproxy melalui server ke URL ini.', placeholder: 'https://api.desa-plus.example.com', }, ] function SettingsPanel() { const qc = useQueryClient() const [values, setValues] = useState>({}) const [saved, setSaved] = useState>({}) const { data, isLoading } = useQuery({ queryKey: ['admin', 'config'], queryFn: () => fetch('/api/admin/config', { credentials: 'include' }).then((r) => r.json()), }) const configs: AppConfigEntry[] = data?.configs ?? [] useEffect(() => { const initial: Record = {} for (const def of CONFIG_DEFINITIONS) { const existing = configs.find((c) => c.key === def.key) initial[def.key] = existing?.value ?? '' } setValues(initial) }, [configs]) const saveMutation = useMutation({ mutationFn: ({ key, value }: { key: string; value: string }) => fetch('/api/admin/config', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }), }).then((r) => r.json()), onSuccess: (_, { key }) => { qc.invalidateQueries({ queryKey: ['admin', 'config'] }) setSaved((prev) => ({ ...prev, [key]: true })) setTimeout(() => setSaved((prev) => ({ ...prev, [key]: false })), 2000) }, }) return ( Settings Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy. {isLoading ?
: ( {CONFIG_DEFINITIONS.map((def) => { const existing = configs.find((c) => c.key === def.key) return (
{def.label} {def.key}
{existing && ( Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')} )}
{def.description} setValues((prev) => ({ ...prev, [def.key]: e.target.value }))} placeholder={def.placeholder} /> {!existing && ( Belum dikonfigurasi — data tidak akan ter-load )}
) })}
)}
) } // ─── 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