Files
monitoring-app/src/frontend/routes/dev.tsx
amaliadwiy ccc43e0c96 feat: runtime config via DB — ganti VITE_URL_API_DESA_PLUS dengan proxy
- Tambah model AppConfig (key-value) ke schema + migration
- Tambah GET/PUT /api/admin/config (DEVELOPER only)
- Tambah proxy /api/proxy/desa-plus/* yang baca URL dari DB
- Hapus VITE_URL_API_DESA_PLUS dari frontend, ganti semua URL desa-plus ke relative proxy path
- Aktifkan Settings tab di /dev dengan UI untuk set URL_API_DESA_PLUS

URL desa-plus kini bisa diubah via /dev → Settings tanpa rebuild image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:47:21 +08:00

1582 lines
66 KiB
TypeScript

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<string, unknown>) => ({
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: <Text size="sm">Yakin ingin logout?</Text>,
labels: { confirm: 'Logout', cancel: 'Batal' },
confirmProps: { color: 'red' },
onConfirm: () => logout.mutate(),
})
return (
<AppShell
header={{ height: 56, collapsed: !isMobile }}
navbar={{ width: collapsed ? 60 : 260, breakpoint: 'sm', collapsed: { mobile: !mobileOpened } }}
padding="md"
>
<AppShell.Header px="md" hiddenFrom="sm">
<Group h="100%" justify="space-between">
<Group gap="xs">
<Burger opened={mobileOpened} onClick={toggleMobile} size="sm" />
<ThemeIcon size="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
<TbCode size={16} />
</ThemeIcon>
<Text fw={700} size="sm">Dev Console</Text>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p={collapsed ? 'xs' : 'md'}>
<AppShell.Section>
<Group gap="xs" mb="md" justify={collapsed ? 'center' : 'space-between'}>
{collapsed ? (
<Tooltip label="Expand sidebar" position="right">
<ActionIcon variant="subtle" color="gray" size="lg" onClick={toggleSidebar}>
<TbLayoutSidebarLeftExpand size={18} />
</ActionIcon>
</Tooltip>
) : (
<>
<Group gap="xs">
<ThemeIcon size="lg" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
<TbCode size={18} />
</ThemeIcon>
<div>
<Text fw={700} size="sm">Dev Console</Text>
<Text size="xs" c="dimmed">Developer</Text>
</div>
</Group>
<Tooltip label="Minimize sidebar">
<ActionIcon variant="subtle" color="gray" size="sm" onClick={toggleSidebar}>
<TbLayoutSidebarLeftCollapse size={18} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
</AppShell.Section>
<AppShell.Section grow>
<Stack gap={4}>
{navItems.map((item) => {
const Icon = item.icon
if (collapsed) {
return (
<Tooltip key={item.key} label={item.label} position="right">
<ActionIcon
variant={active === item.key ? 'filled' : 'subtle'}
color={active === item.key ? 'blue' : 'gray'}
size="lg"
disabled={item.disabled}
onClick={() => !item.disabled && setActive(item.key)}
>
<Icon size={18} />
</ActionIcon>
</Tooltip>
)
}
return (
<NavLink
key={item.key}
label={item.label}
leftSection={<Icon size={16} />}
rightSection={active === item.key ? <TbChevronRight size={14} /> : undefined}
active={active === item.key}
disabled={item.disabled}
onClick={() => !item.disabled && setActive(item.key)}
style={{ borderRadius: 6 }}
/>
)
})}
</Stack>
</AppShell.Section>
<AppShell.Section>
{!collapsed && user && (
<Box mb="sm">
<Group gap="sm">
<Avatar size="sm" color="blue" radius="xl">
{user.name.charAt(0).toUpperCase()}
</Avatar>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{user.name}</Text>
<Text size="xs" c="dimmed" truncate>{user.email}</Text>
</Box>
</Group>
</Box>
)}
<Group gap="xs" justify={collapsed ? 'center' : 'space-between'}>
{!collapsed && (
<Tooltip label="Logout">
<ActionIcon variant="subtle" color="red" onClick={confirmLogout} loading={logout.isPending}>
<TbLogout size={18} />
</ActionIcon>
</Tooltip>
)}
{collapsed && (
<Tooltip label="Logout" position="right">
<ActionIcon variant="subtle" color="red" onClick={confirmLogout} loading={logout.isPending}>
<TbLogout size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Container size="xl" px={0}>
{active === 'overview' && <OverviewPanel />}
{active === 'operators' && <OperatorsPanel currentUserId={user?.id ?? ''} />}
{active === 'bugs' && <BugsPanel />}
{active === 'app-logs' && <AppLogsPanel />}
{active === 'activity-logs' && <ActivityLogsPanel />}
{active === 'database' && <DatabasePanel />}
{active === 'project' && <ProjectPanel />}
{active === 'settings' && <SettingsPanel />}
</Container>
</AppShell.Main>
</AppShell>
)
}
// ─── 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 (
<Stack>
<Title order={3}>Overview</Title>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
{cards.map((c) => {
const Icon = c.icon
return (
<Card key={c.label} withBorder shadow="sm" radius="md" p="lg">
<Group justify="space-between" mb="xs">
<Text size="sm" c="dimmed" fw={500}>{c.label}</Text>
<ThemeIcon color={c.color} variant="light" size="md">
<Icon size={16} />
</ThemeIcon>
</Group>
<Text fw={700} size="xl">{String(c.value)}</Text>
</Card>
)
})}
</SimpleGrid>
</Stack>
)
}
// ─── 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<string, string> = { DEVELOPER: 'violet', ADMIN: 'blue', USER: 'gray' }
return (
<Stack>
<Group justify="space-between">
<Title order={3}>Operators</Title>
<Text size="sm" c="dimmed">{users.length} total</Text>
</Group>
{isLoading ? <Center><Loader /></Center> : (
<Table.ScrollContainer minWidth={600}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Operator</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Joined</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((u) => {
const isOnline = onlineUserIds.includes(u.id)
const isSelf = u.id === currentUserId
const isDeveloper = u.role === 'DEVELOPER'
return (
<Table.Tr key={u.id}>
<Table.Td>
<Group gap="sm">
<Box pos="relative">
<Avatar size="sm" color="blue" radius="xl">{u.name.charAt(0).toUpperCase()}</Avatar>
{isOnline && (
<Box pos="absolute" bottom={0} right={0} w={8} h={8} bg="green" style={{ borderRadius: '50%', border: '1.5px solid white' }} />
)}
</Box>
<div>
<Text size="sm" fw={500}>{u.name} {isSelf && <Text span size="xs" c="dimmed">(you)</Text>}</Text>
<Text size="xs" c="dimmed">{u.email}</Text>
</div>
</Group>
</Table.Td>
<Table.Td><Badge color={roleColor[u.role] ?? 'gray'} variant="light">{u.role}</Badge></Table.Td>
<Table.Td>
{!u.active ? <Badge color="red" variant="light">Inactive</Badge>
: isOnline ? <Badge color="green" variant="light">Online</Badge>
: <Badge color="gray" variant="light">Offline</Badge>}
</Table.Td>
<Table.Td><Text size="xs" c="dimmed">{new Date(u.createdAt).toLocaleDateString('id-ID')}</Text></Table.Td>
<Table.Td>
{!isSelf && !isDeveloper && (
<Menu shadow="md" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray"><TbDots size={16} /></ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Ganti Role</Menu.Label>
{(['USER', 'ADMIN'] as const).filter((r) => r !== u.role).map((r) => (
<Menu.Item key={r} leftSection={<TbUser size={14} />} onClick={() => roleMutation.mutate({ id: u.id, role: r })}>
Jadikan {r}
</Menu.Item>
))}
<Menu.Divider />
{u.active ? (
<Menu.Item color="red" leftSection={<TbUserSearch size={14} />}
onClick={() => modals.openConfirmModal({
title: 'Nonaktifkan Operator',
children: <Text size="sm">Nonaktifkan {u.name}? Semua sesi aktif akan dihapus.</Text>,
labels: { confirm: 'Nonaktifkan', cancel: 'Batal' },
confirmProps: { color: 'red' },
onConfirm: () => activateMutation.mutate({ id: u.id, active: false }),
})}>
Nonaktifkan
</Menu.Item>
) : (
<Menu.Item color="green" leftSection={<TbUserSearch size={14} />}
onClick={() => activateMutation.mutate({ id: u.id, active: true })}>
Aktifkan
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
)
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
)
}
// ─── Bugs Panel ────────────────────────────────────────────────────────────────
const BUG_STATUSES = ['all', 'OPEN', 'ON_HOLD', 'IN_PROGRESS', 'RESOLVED', 'RELEASED', 'CLOSED'] as const
const BUG_STATUS_COLOR: Record<string, string> = {
OPEN: 'red', ON_HOLD: 'orange', IN_PROGRESS: 'blue', RESOLVED: 'teal', RELEASED: 'green', CLOSED: 'gray',
}
const BUG_SOURCE_COLOR: Record<string, string> = { 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 (
<Stack>
<Group justify="space-between">
<Title order={3}>Bugs</Title>
<Text size="sm" c="dimmed">{totalItems} total</Text>
</Group>
<SegmentedControl
value={status}
onChange={(v) => { setStatus(v); setPage(1) }}
data={BUG_STATUSES.map((s) => ({ label: s === 'all' ? 'All' : s.replace('_', ' '), value: s }))}
size="xs"
/>
{isLoading ? <Center><Loader /></Center> : (
<>
<Table.ScrollContainer minWidth={700}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Source</Table.Th>
<Table.Th>App</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Version</Table.Th>
<Table.Th>Reporter</Table.Th>
<Table.Th>Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bugs.map((b: any) => (
<Table.Tr key={b.id}>
<Table.Td><Badge color={BUG_STATUS_COLOR[b.status] ?? 'gray'} variant="light" size="sm">{b.status.replace('_', ' ')}</Badge></Table.Td>
<Table.Td><Badge color={BUG_SOURCE_COLOR[b.source] ?? 'gray'} variant="dot" size="sm">{b.source}</Badge></Table.Td>
<Table.Td><Text size="xs" c="dimmed">{b.app?.name ?? b.appId ?? '—'}</Text></Table.Td>
<Table.Td><Text size="sm" lineClamp={1} style={{ maxWidth: 240 }}>{b.description}</Text></Table.Td>
<Table.Td><Text size="xs" c="dimmed">{b.affectedVersion}</Text></Table.Td>
<Table.Td><Text size="xs">{b.user?.name ?? '—'}</Text></Table.Td>
<Table.Td><Text size="xs" c="dimmed">{new Date(b.createdAt).toLocaleDateString('id-ID')}</Text></Table.Td>
</Table.Tr>
))}
{bugs.length === 0 && (
<Table.Tr>
<Table.Td colSpan={7}><Center py="xl"><Text c="dimmed">Tidak ada bug ditemukan</Text></Center></Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{totalPages > 1 && <Center><Pagination total={totalPages} value={page} onChange={setPage} size="sm" /></Center>}
</>
)}
</Stack>
)
}
// ─── App Logs Panel ────────────────────────────────────────────────────────────
const LOG_LEVELS = ['all', 'info', 'warn', 'error'] as const
const LOG_LEVEL_COLOR: Record<string, string> = { 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 (
<Stack>
<Group justify="space-between">
<Title order={3}>App Logs</Title>
<Group gap="xs">
<ActionIcon variant="subtle" color="gray" onClick={() => qc.invalidateQueries({ queryKey: ['admin', 'logs', 'app'] })}>
<TbRefresh size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="red"
onClick={() => modals.openConfirmModal({
title: 'Hapus semua app logs',
children: <Text size="sm">Semua log Redis akan dihapus. Tindakan ini tidak bisa dibatalkan.</Text>,
labels: { confirm: 'Hapus', cancel: 'Batal' },
confirmProps: { color: 'red' },
onConfirm: () => clearMutation.mutate(),
})}>
<TbTrash size={16} />
</ActionIcon>
</Group>
</Group>
{redisDisabled && (
<Paper withBorder p="md" bg="yellow.0">
<Text size="sm" c="orange">Redis tidak dikonfigurasi (REDIS_URL kosong). App Logs tidak tersedia.</Text>
</Paper>
)}
<SegmentedControl
value={level}
onChange={(v) => { setLevel(v); setPage(1) }}
data={LOG_LEVELS.map((l) => ({ label: l === 'all' ? 'All' : l.toUpperCase(), value: l }))}
size="xs"
/>
{isLoading ? <Center><Loader /></Center> : (
<>
<Table.ScrollContainer minWidth={600}>
<Table striped highlightOnHover fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>Level</Table.Th>
<Table.Th>Message</Table.Th>
<Table.Th>Detail</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{pageLogs.map((log: any) => (
<Table.Tr key={log.id}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>{new Date(log.timestamp).toLocaleTimeString('id-ID')}</Table.Td>
<Table.Td><Badge color={LOG_LEVEL_COLOR[log.level] ?? 'gray'} variant="light" size="xs">{log.level.toUpperCase()}</Badge></Table.Td>
<Table.Td>{log.message}</Table.Td>
<Table.Td c="dimmed">{log.detail ?? ''}</Table.Td>
</Table.Tr>
))}
{pageLogs.length === 0 && !redisDisabled && (
<Table.Tr><Table.Td colSpan={4}><Center py="xl"><Text c="dimmed">Belum ada log</Text></Center></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{totalPages > 1 && <Center><Pagination total={totalPages} value={page} onChange={setPage} size="sm" /></Center>}
</>
)}
</Stack>
)
}
// ─── Activity Logs Panel ───────────────────────────────────────────────────────
const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const
const LOG_TYPE_COLOR: Record<string, string> = { 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 (
<Stack>
<Group justify="space-between">
<Title order={3}>Activity Logs</Title>
<Group gap="xs">
<ActionIcon variant="subtle" color="gray" onClick={() => qc.invalidateQueries({ queryKey: ['admin', 'logs', 'audit'] })}>
<TbRefresh size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="red"
onClick={() => modals.openConfirmModal({
title: 'Hapus semua activity logs',
children: <Text size="sm">Semua log aktivitas akan dihapus permanen.</Text>,
labels: { confirm: 'Hapus', cancel: 'Batal' },
confirmProps: { color: 'red' },
onConfirm: () => clearMutation.mutate(),
})}>
<TbTrash size={16} />
</ActionIcon>
</Group>
</Group>
<Group gap="sm">
<Select
placeholder="Filter operator"
value={userId}
onChange={(v) => { setUserId(v ?? 'all'); setPage(1) }}
data={[{ value: 'all', label: 'Semua operator' }, ...users.map((u) => ({ value: u.id, label: u.name }))]}
size="xs"
w={180}
clearable
/>
<SegmentedControl
value={type}
onChange={(v) => { setType(v); setPage(1) }}
data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))}
size="xs"
/>
</Group>
{isLoading ? <Center><Loader /></Center> : (
<>
<Table.ScrollContainer minWidth={600}>
<Table striped highlightOnHover fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>Operator</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Message</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{pageLogs.map((log: any) => (
<Table.Tr key={log.id}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>{new Date(log.createdAt).toLocaleString('id-ID')}</Table.Td>
<Table.Td>
{log.user ? (
<div>
<Text size="xs" fw={500}>{log.user.name}</Text>
<Text size="xs" c="dimmed">{log.user.email}</Text>
</div>
) : <Text size="xs" c="dimmed"></Text>}
</Table.Td>
<Table.Td><Badge color={LOG_TYPE_COLOR[log.type] ?? 'gray'} variant="light" size="xs">{log.type}</Badge></Table.Td>
<Table.Td><Text size="xs" lineClamp={1}>{log.message}</Text></Table.Td>
</Table.Tr>
))}
{pageLogs.length === 0 && (
<Table.Tr><Table.Td colSpan={4}><Center py="xl"><Text c="dimmed">Belum ada log aktivitas</Text></Center></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{totalPages > 1 && <Center><Pagination total={totalPages} value={page} onChange={setPage} size="sm" /></Center>}
</>
)}
</Stack>
)
}
// ─── ELK Layout Utility ────────────────────────────────────────────────────────
const elk = new ELK()
async function applyElkLayout(
nodes: Node[],
edges: Edge[],
direction: 'DOWN' | 'RIGHT' | 'UP' | 'LEFT' = 'RIGHT',
): Promise<{ nodes: Node[]; edges: Edge[] }> {
if (nodes.length === 0) return { nodes, edges }
const elkNodes = nodes.map((n) => ({ id: n.id, width: (n.measured?.width ?? n.data?.width ?? 200) as number, height: (n.measured?.height ?? n.data?.height ?? 60) as number }))
const elkEdges = edges.map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] }))
const graph = await elk.layout({
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': direction,
'elk.spacing.nodeNode': '40',
'elk.layered.spacing.nodeNodeBetweenLayers': '60',
},
children: elkNodes,
edges: elkEdges,
})
const positioned = new Map(graph.children?.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }]) ?? [])
return {
nodes: nodes.map((n) => ({ ...n, position: positioned.get(n.id) ?? n.position })),
edges,
}
}
function useFlowAutoSave(key: string) {
const savePositions = useCallback((nodes: Node[]) => {
const pos: Record<string, { x: number; y: number }> = {}
for (const n of nodes) pos[n.id] = n.position
localStorage.setItem(`dev:flow:${key}:positions`, JSON.stringify(pos))
}, [key])
const saveViewport = useCallback((vp: { x: number; y: number; zoom: number }) => {
localStorage.setItem(`dev:flow:${key}:viewport`, JSON.stringify(vp))
}, [key])
const loadPositions = useCallback((): Record<string, { x: number; y: number }> => {
try { return JSON.parse(localStorage.getItem(`dev:flow:${key}:positions`) ?? '{}') } catch { return {} }
}, [key])
const loadViewport = useCallback(() => {
try { return JSON.parse(localStorage.getItem(`dev:flow:${key}:viewport`) ?? 'null') } catch { return null }
}, [key])
return { savePositions, saveViewport, loadPositions, loadViewport }
}
// ─── Database Panel ────────────────────────────────────────────────────────────
interface SchemaField { name: string; type: string; isId: boolean; isUnique: boolean; isOptional: boolean; isList: boolean; isRelation: boolean; default?: string }
interface SchemaRelation { from: string; fromField: string; to: string; toField: string; onDelete?: string }
interface SchemaModel { name: string; tableName: string; fields: SchemaField[] }
interface SchemaEnum { name: string; values: string[] }
interface ParsedSchema { models: SchemaModel[]; enums: SchemaEnum[]; relations: SchemaRelation[] }
function ModelNode({ data }: { data: any }) {
return (
<Paper withBorder shadow="sm" p={0} style={{ minWidth: 180, fontSize: 12 }}>
<Handle type="target" position={Position.Left} />
<Box bg="blue.6" px="xs" py={4} style={{ borderRadius: '4px 4px 0 0' }}>
<Text size="xs" fw={700} c="white">{data.name}</Text>
{data.tableName !== data.name && <Text size="xs" c="blue.1">@map({data.tableName})</Text>}
</Box>
<Stack gap={0}>
{data.fields?.map((f: SchemaField) => (
<Box key={f.name} px="xs" py={2} style={{ borderBottom: '1px solid var(--mantine-color-default-border)', display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<Group gap={4}>
{f.isId && <Badge size="xs" color="yellow" variant="filled" p={2}>PK</Badge>}
{f.isUnique && !f.isId && <Badge size="xs" color="teal" variant="filled" p={2}>UQ</Badge>}
<Text size="xs" fw={f.isId ? 700 : 400} c={f.isRelation ? 'blue' : undefined}>{f.name}{f.isOptional ? '?' : ''}</Text>
</Group>
<Text size="xs" c="dimmed">{f.type}</Text>
</Box>
))}
</Stack>
<Handle type="source" position={Position.Right} />
</Paper>
)
}
function EnumNode({ data }: { data: any }) {
return (
<Paper withBorder shadow="sm" p={0} style={{ minWidth: 140, fontSize: 12 }}>
<Box bg="violet.6" px="xs" py={4} style={{ borderRadius: '4px 4px 0 0' }}>
<Text size="xs" fw={700} c="white">enum {data.name}</Text>
</Box>
<Stack gap={0}>
{data.values?.map((v: string) => (
<Box key={v} px="xs" py={2} style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Text size="xs">{v}</Text>
</Box>
))}
</Stack>
</Paper>
)
}
const DB_NODE_TYPES = { model: ModelNode, enum: EnumNode }
function DatabaseFlowInner({ schema }: { schema: ParsedSchema }) {
const { fitView, setViewport } = useReactFlow()
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave('db')
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => {
const savedPos = loadPositions()
const hasSaved = Object.keys(savedPos).length > 0
const newNodes: Node[] = [
...schema.models.map((m, i) => ({
id: m.name, type: 'model',
position: savedPos[m.name] ?? { x: i * 220, y: 0 },
data: { name: m.name, tableName: m.tableName, fields: m.fields },
})),
...schema.enums.map((e, i) => ({
id: `enum_${e.name}`, type: 'enum',
position: savedPos[`enum_${e.name}`] ?? { x: i * 160, y: 400 },
data: { name: e.name, values: e.values },
})),
]
const newEdges: Edge[] = schema.relations.map((r) => ({
id: `${r.from}-${r.fromField}-${r.to}`,
source: r.from, target: r.to,
label: `${r.fromField}${r.toField}${r.onDelete ? ` [${r.onDelete}]` : ''}`,
markerEnd: { type: MarkerType.ArrowClosed },
style: { stroke: 'var(--mantine-color-blue-5)' },
}))
if (hasSaved) {
setNodes(newNodes)
setEdges(newEdges)
const savedVp = loadViewport()
if (savedVp) setTimeout(() => setViewport(savedVp), 50)
else setTimeout(() => fitView({ padding: 0.2 }), 50)
} else {
applyElkLayout(newNodes, newEdges, 'RIGHT').then(({ nodes: ln, edges: le }) => {
setNodes(ln)
setEdges(le)
setTimeout(() => fitView({ padding: 0.2 }), 100)
})
}
}, [schema])
const onNodeDragStop = useCallback((_: any, __: any, allNodes: Node[]) => {
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => savePositions(allNodes), 500)
}, [savePositions])
const onMoveEnd = useCallback((_: any, vp: any) => {
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => saveViewport(vp), 500)
}, [saveViewport])
return (
<ReactFlow
nodes={nodes} edges={edges}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop} onMoveEnd={onMoveEnd}
nodeTypes={DB_NODE_TYPES}
fitView minZoom={0.05} maxZoom={5}
>
<Background /><Controls />
</ReactFlow>
)
}
function DatabasePanel() {
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'schema'],
queryFn: () => fetch('/api/admin/schema', { credentials: 'include' }).then((r) => r.json()),
})
const schema: ParsedSchema | null = data?.schema ?? null
return (
<Stack h="calc(100vh - 100px)">
<Group justify="space-between">
<Title order={3}>Database Schema</Title>
<ActionIcon variant="subtle" color="gray" onClick={() => refetch()}><TbRefresh size={16} /></ActionIcon>
</Group>
{isLoading ? <Center flex={1}><Loader /></Center> : schema ? (
<Box flex={1}>
<ReactFlowProvider>
<DatabaseFlowInner schema={schema} />
</ReactFlowProvider>
</Box>
) : <Text c="dimmed">Schema tidak tersedia</Text>}
</Stack>
)
}
// ─── Project Panel ─────────────────────────────────────────────────────────────
const PROJECT_VIEWS = [
{ group: 'Architecture', items: [
{ value: 'api-routes', label: 'API Routes' },
{ value: 'file-structure', label: 'File Structure' },
{ value: 'user-flow', label: 'User Flow' },
{ value: 'data-flow', label: 'Data Flow' },
]},
{ group: 'DevOps', items: [
{ value: 'env-map', label: 'Env Variables' },
{ value: 'test-coverage', label: 'Test Coverage' },
{ value: 'dependencies', label: 'Dependencies' },
{ value: 'migrations', label: 'Migrations' },
]},
{ group: 'Live', items: [
{ value: 'sessions', label: 'Sessions' },
{ value: 'live-requests', label: 'Live Requests' },
]},
]
function GenericFlowPanel({ queryKey, queryFn, buildGraph }: {
queryKey: string[]
queryFn: () => Promise<any>
buildGraph: (data: any) => { nodes: Node[]; edges: Edge[] }
}) {
const { data, isLoading, refetch } = useQuery({ queryKey, queryFn, refetchInterval: queryKey.includes('sessions') ? 10_000 : undefined })
const { fitView, setViewport } = useReactFlow()
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const flowKey = queryKey.join('-')
const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey)
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => {
if (!data) return
const { nodes: newNodes, edges: newEdges } = buildGraph(data)
const savedPos = loadPositions()
const hasSaved = Object.keys(savedPos).length > 0
if (hasSaved) {
const withPos = newNodes.map((n) => ({ ...n, position: savedPos[n.id] ?? n.position }))
setNodes(withPos); setEdges(newEdges)
const savedVp = loadViewport()
if (savedVp) setTimeout(() => setViewport(savedVp), 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 <Center flex={1}><Loader /></Center>
return (
<Box style={{ position: 'absolute', inset: 0 }}>
<Box pos="absolute" top={8} right={8} style={{ zIndex: 10 }}>
<ActionIcon variant="subtle" style={{ background: 'white', boxShadow: '0 1px 3px rgba(0,0,0,.15)' }} onClick={() => { refetch() }}><TbRefresh size={14} /></ActionIcon>
</Box>
<ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop} onMoveEnd={onMoveEnd} fitView minZoom={0.05} maxZoom={5}>
<Background /><Controls />
</ReactFlow>
</Box>
)
}
function SimpleNode({ data }: { data: any }) {
const methodColor: Record<string, string> = { GET: 'green', POST: 'blue', PUT: 'orange', PATCH: 'yellow', DELETE: 'red', WS: 'violet', PAGE: 'gray' }
const authColor: Record<string, string> = { public: 'gray', developer: 'violet', admin: 'blue', authenticated: 'teal', apiKeyOrSession: 'orange' }
return (
<Paper withBorder shadow="xs" px="sm" py="xs" style={{ minWidth: 200, maxWidth: 300, fontSize: 12 }}>
<Handle type="target" position={Position.Left} />
<Group gap={4} mb={2}>
{data.method && <Badge size="xs" color={methodColor[data.method] ?? 'gray'} variant="filled">{data.method}</Badge>}
{data.auth && <Badge size="xs" color={authColor[data.auth] ?? 'gray'} variant="light">{data.auth}</Badge>}
</Group>
<Text size="xs" fw={600} style={{ fontFamily: 'monospace' }}>{data.path ?? data.label}</Text>
{data.description && <Text size="xs" c="dimmed" lineClamp={1}>{data.description}</Text>}
<Handle type="source" position={Position.Right} />
</Paper>
)
}
const SIMPLE_NODE_TYPES = { simple: SimpleNode }
function buildApiRoutesGraph(data: any): { nodes: Node[]; edges: Edge[] } {
const routes: any[] = data?.routes ?? []
const nodes: Node[] = routes.map((r, i) => ({
id: r.path, type: 'simple',
position: { x: i * 30, y: i * 30 },
data: { label: r.path, method: r.method, path: r.path, auth: r.auth, description: r.description },
}))
const loginNode = nodes.find((n) => n.id === '/login')
const edges: Edge[] = []
if (loginNode) {
for (const n of nodes) {
if (['/dev', '/dashboard', '/profile'].includes(n.id)) {
edges.push({ id: `login-${n.id}`, source: '/login', target: n.id, label: '', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: 'var(--mantine-color-blue-5)', strokeDasharray: '4' } })
}
}
}
return { nodes, edges }
}
function buildFileStructureGraph(data: any): { nodes: Node[]; edges: Edge[] } {
const files: any[] = data?.files ?? []
const catColor: Record<string, string> = { route: 'blue', hook: 'cyan', component: 'teal', frontend: 'green', lib: 'orange', backend: 'red', prisma: 'violet', 'test-unit': 'yellow', 'test-integration': 'yellow', test: 'yellow', config: 'gray' }
const nodes: Node[] = files.map((f, i) => ({
id: f.path, type: 'simple',
position: { x: i * 30, y: i * 30 },
data: { label: f.path.split('/').pop(), path: f.path, description: `${f.lines} lines • ${f.exports.length} exports`, auth: f.category },
style: { '--badge-color': catColor[f.category] ?? 'gray' } as React.CSSProperties,
}))
const edges: Edge[] = []
for (const f of files) {
for (const imp of f.imports) {
if (files.find((x) => x.path === imp.from)) {
edges.push({ id: `${f.path}-${imp.from}`, source: f.path, target: imp.from, markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: 'var(--mantine-color-gray-5)', opacity: 0.5 } })
}
}
}
return { nodes, edges }
}
function buildEnvMapGraph(data: any): { nodes: Node[]; edges: Edge[] } {
const vars: any[] = data?.variables ?? []
const nodes: Node[] = [
...vars.map((v, i) => ({
id: `env_${v.name}`, type: 'simple',
position: { x: 0, y: i * 70 },
data: { label: v.name, path: v.name, method: v.required ? 'REQ' : 'OPT', auth: v.isSet ? 'set' : 'unset', description: v.description },
})),
...[...new Set(vars.flatMap((v) => v.usedBy))].map((f: string, i) => ({
id: `file_${f}`, type: 'simple',
position: { x: 400, y: i * 60 },
data: { label: f.split('/').pop(), path: f, description: f },
})),
]
const edges: Edge[] = vars.flatMap((v) =>
v.usedBy.map((f: string) => ({ id: `${v.name}-${f}`, source: `env_${v.name}`, target: `file_${f}`, markerEnd: { type: MarkerType.ArrowClosed } }))
)
return { nodes, edges }
}
function buildTestCoverageGraph(data: any): { nodes: Node[]; edges: Edge[] } {
const src: any[] = data?.sourceFiles ?? []
const tests: any[] = data?.testFiles ?? []
const covColor: Record<string, string> = { covered: 'green', partial: 'yellow', uncovered: 'red' }
const nodes: Node[] = [
...src.map((f, i) => ({
id: f.path, type: 'simple',
position: { x: 0, y: i * 60 },
data: { label: f.path.split('/').pop(), path: f.path, auth: f.coverage, description: `${f.lines} lines • ${f.coverage}` },
})),
...tests.map((t, i) => ({
id: t.path, type: 'simple',
position: { x: 400, y: i * 60 },
data: { label: t.path.split('/').pop(), path: t.path, description: `${t.lines} lines • ${t.type}` },
})),
]
const edges: Edge[] = tests.flatMap((t) =>
t.targets.map((target: string) => ({ id: `${t.path}-${target}`, source: target, target: t.path, markerEnd: { type: MarkerType.ArrowClosed } }))
)
return { nodes, edges }
}
function buildDependenciesGraph(data: any): { nodes: Node[]; edges: Edge[] } {
const pkgs: any[] = data?.packages ?? []
const catColor: Record<string, string> = { server: 'orange', ui: 'blue', database: 'green', storage: 'cyan', build: 'gray', other: 'gray' }
const nodes: Node[] = [
...pkgs.map((p, i) => ({
id: `pkg_${p.name}`, type: 'simple',
position: { x: 0, y: i * 55 },
data: { label: p.name, method: p.type === 'runtime' ? 'RT' : 'DEV', auth: p.category, description: p.version },
})),
...[...new Set(pkgs.flatMap((p) => p.usedBy))].map((f: string, i) => ({
id: `file_${f}`, type: 'simple',
position: { x: 350, y: i * 60 },
data: { label: f.split('/').pop(), path: f, description: f },
})),
]
const edges: Edge[] = pkgs.flatMap((p) =>
p.usedBy.map((f: string) => ({ id: `${p.name}-${f}`, source: `pkg_${p.name}`, target: `file_${f}`, markerEnd: { type: MarkerType.ArrowClosed } }))
)
return { nodes, edges }
}
function buildMigrationsGraph(data: any): { nodes: Node[]; edges: Edge[] } {
const migrations: any[] = data?.migrations ?? []
const nodes: Node[] = migrations.map((m, i) => ({
id: m.folder, type: 'simple',
position: { x: i * 250, y: 100 },
data: { label: m.name, description: `${m.changes.length} changes • ${new Date(m.createdAt).toLocaleDateString('id-ID')}` },
}))
const edges: Edge[] = migrations.slice(1).map((m, i) => ({
id: `mig-${i}`, source: migrations[i].folder, target: m.folder, markerEnd: { type: MarkerType.ArrowClosed },
}))
return { nodes, edges }
}
function buildSessionsGraph(data: any): { nodes: Node[]; edges: Edge[] } {
const sessions: any[] = data?.sessions ?? []
const roleColor: Record<string, string> = { DEVELOPER: 'violet', ADMIN: 'blue', USER: 'gray' }
const uniqueUsers = [...new Map(sessions.map((s) => [s.userId, s])).values()]
const roles = [...new Set(sessions.map((s) => s.userRole))]
const nodes: Node[] = [
...uniqueUsers.map((s, i) => ({
id: `user_${s.userId}`, type: 'simple',
position: { x: 0, y: i * 70 },
data: { label: s.userName, method: s.isOnline ? 'ON' : 'OFF', auth: s.userRole.toLowerCase(), description: s.userEmail },
})),
...roles.map((r, i) => ({
id: `role_${r}`, type: 'simple',
position: { x: 350, y: i * 100 },
data: { label: r, description: `Access: ${r === 'DEVELOPER' ? '/dev, /dashboard' : r === 'ADMIN' ? '/dashboard' : '/profile'}` },
})),
]
const edges: Edge[] = uniqueUsers.map((s) => ({
id: `${s.userId}-${s.userRole}`, source: `user_${s.userId}`, target: `role_${s.userRole}`, markerEnd: { type: MarkerType.ArrowClosed },
}))
return { nodes, edges }
}
// Static flow graphs
function buildUserFlowGraph(): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = [
{ id: 'visit', type: 'simple', position: { x: 0, y: 0 }, data: { label: 'Visit App', description: 'User opens browser' } },
{ id: 'login', type: 'simple', position: { x: 200, y: 0 }, data: { label: '/login', description: 'Authentication page' } },
{ id: 'auth-check', type: 'simple', position: { x: 400, y: 0 }, data: { label: 'Auth Check', description: 'Validate session' } },
{ id: 'dev', type: 'simple', position: { x: 600, y: -100 }, data: { label: '/dev', auth: 'developer', description: 'Dev Console (DEVELOPER)' } },
{ id: 'dashboard', type: 'simple', position: { x: 600, y: 0 }, data: { label: '/dashboard', auth: 'admin', description: 'Admin Dashboard (ADMIN+)' } },
{ id: 'profile', type: 'simple', position: { x: 600, y: 100 }, data: { label: '/profile', auth: 'authenticated', description: 'User Profile (all)' } },
]
const edges: Edge[] = [
{ id: 'v-l', source: 'visit', target: 'login', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'l-a', source: 'login', target: 'auth-check', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'a-d', source: 'auth-check', target: 'dev', label: 'DEVELOPER', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'a-da', source: 'auth-check', target: 'dashboard', label: 'ADMIN', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'a-p', source: 'auth-check', target: 'profile', label: 'USER', markerEnd: { type: MarkerType.ArrowClosed } },
]
return { nodes, edges }
}
function buildDataFlowGraph(): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = [
{ id: 'client', type: 'simple', position: { x: 0, y: 0 }, data: { label: 'Client (React)', description: 'TanStack Router + Query' } },
{ id: 'elysia', type: 'simple', position: { x: 200, y: 0 }, data: { label: 'Elysia.js', description: 'HTTP framework (Bun)' } },
{ id: 'auth-mw', type: 'simple', position: { x: 400, y: -80 }, data: { label: 'Auth Middleware', description: 'Session cookie / API Key' } },
{ id: 'handler', type: 'simple', position: { x: 400, y: 80 }, data: { label: 'Route Handler', description: 'Business logic' } },
{ id: 'prisma', type: 'simple', position: { x: 600, y: 0 }, data: { label: 'Prisma ORM', description: 'PostgreSQL queries' } },
{ id: 'minio', type: 'simple', position: { x: 600, y: 120 }, data: { label: 'MinIO', description: 'Object storage (images)' } },
{ id: 'redis', type: 'simple', position: { x: 600, y: -120 }, data: { label: 'Redis', description: 'App log ring buffer' } },
{ id: 'response', type: 'simple', position: { x: 800, y: 0 }, data: { label: 'JSON Response', description: 'Back to client' } },
]
const edges: Edge[] = [
{ id: 'c-e', source: 'client', target: 'elysia', label: 'HTTP / WS', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'e-a', source: 'elysia', target: 'auth-mw', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'e-h', source: 'elysia', target: 'handler', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'h-p', source: 'handler', target: 'prisma', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'h-m', source: 'handler', target: 'minio', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'h-r', source: 'handler', target: 'redis', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'p-res', source: 'prisma', target: 'response', markerEnd: { type: MarkerType.ArrowClosed } },
]
return { nodes, edges }
}
// Live Requests sub-view
function LiveRequestsPanel() {
const [requests, setRequests] = useState<any[]>([])
const [paused, setPaused] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
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 === 'request' && !paused) {
setRequests((prev) => [msg, ...prev].slice(0, 100))
}
}
return () => { ws.close() }
}, [paused])
const statusColor = (s: number) => s >= 500 ? 'red' : s >= 400 ? 'orange' : s >= 300 ? 'yellow' : 'green'
return (
<Stack>
<Group justify="space-between">
<Text fw={600}>Live Requests</Text>
<Group gap="xs">
<Button size="xs" variant={paused ? 'filled' : 'light'} color={paused ? 'green' : 'orange'} onClick={() => setPaused((p) => !p)}>
{paused ? 'Resume' : 'Pause'}
</Button>
<Button size="xs" variant="subtle" color="red" onClick={() => setRequests([])}>Clear</Button>
</Group>
</Group>
<Table.ScrollContainer minWidth={500}>
<Table fz="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>Path</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Duration</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{requests.map((r, i) => (
<Table.Tr key={i}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>{new Date(r.timestamp).toLocaleTimeString('id-ID')}</Table.Td>
<Table.Td><Badge size="xs" color={({ GET: 'green', POST: 'blue', PUT: 'orange', PATCH: 'yellow', DELETE: 'red' } as Record<string, string>)[r.method] ?? 'gray'} variant="filled">{r.method}</Badge></Table.Td>
<Table.Td style={{ fontFamily: 'monospace' }}>{r.path}</Table.Td>
<Table.Td><Badge size="xs" color={statusColor(r.status)} variant="light">{r.status}</Badge></Table.Td>
<Table.Td c="dimmed">{r.duration}ms</Table.Td>
</Table.Tr>
))}
{requests.length === 0 && (
<Table.Tr><Table.Td colSpan={5}><Center py="md"><Text c="dimmed" size="xs">Menunggu request masuk...</Text></Center></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
)
}
function ProjectPanel() {
const [view, setView] = useState('api-routes')
const viewData = useMemo(() => {
switch (view) {
case 'api-routes': return {
queryKey: ['admin', 'routes'],
queryFn: () => fetch('/api/admin/routes', { credentials: 'include' }).then((r) => r.json()),
buildGraph: buildApiRoutesGraph,
}
case 'file-structure': return {
queryKey: ['admin', 'project-structure'],
queryFn: () => fetch('/api/admin/project-structure', { credentials: 'include' }).then((r) => r.json()),
buildGraph: buildFileStructureGraph,
}
case 'env-map': return {
queryKey: ['admin', 'env-map'],
queryFn: () => fetch('/api/admin/env-map', { credentials: 'include' }).then((r) => r.json()),
buildGraph: buildEnvMapGraph,
}
case 'test-coverage': return {
queryKey: ['admin', 'test-coverage'],
queryFn: () => fetch('/api/admin/test-coverage', { credentials: 'include' }).then((r) => r.json()),
buildGraph: buildTestCoverageGraph,
}
case 'dependencies': return {
queryKey: ['admin', 'dependencies'],
queryFn: () => fetch('/api/admin/dependencies', { credentials: 'include' }).then((r) => r.json()),
buildGraph: buildDependenciesGraph,
}
case 'migrations': return {
queryKey: ['admin', 'migrations'],
queryFn: () => fetch('/api/admin/migrations', { credentials: 'include' }).then((r) => r.json()),
buildGraph: buildMigrationsGraph,
}
case 'sessions': return {
queryKey: ['admin', 'sessions'],
queryFn: () => fetch('/api/admin/sessions', { credentials: 'include' }).then((r) => r.json()),
buildGraph: buildSessionsGraph,
}
default: return null
}
}, [view])
const isStaticView = view === 'user-flow' || view === 'data-flow'
const isLiveView = view === 'live-requests'
const staticGraph = useMemo(() => {
if (view === 'user-flow') return buildUserFlowGraph()
if (view === 'data-flow') return buildDataFlowGraph()
return null
}, [view])
return (
<Stack h="calc(100vh - 100px)">
<Group justify="space-between">
<Title order={3}>Project</Title>
<Select
value={view}
onChange={(v) => v && setView(v)}
data={PROJECT_VIEWS.map((g) => ({ group: g.group, items: g.items }))}
size="xs"
w={200}
/>
</Group>
<Box flex={1} pos="relative">
{isLiveView && <LiveRequestsPanel />}
{isStaticView && staticGraph && (
<ReactFlowProvider>
<StaticFlowPanel graph={staticGraph} flowKey={view} />
</ReactFlowProvider>
)}
{!isLiveView && !isStaticView && viewData && (
<ReactFlowProvider>
<GenericFlowPanelWrapper key={view} {...viewData} />
</ReactFlowProvider>
)}
</Box>
</Stack>
)
}
function GenericFlowPanelWrapper(props: { queryKey: string[]; queryFn: () => Promise<any>; buildGraph: (d: any) => { nodes: Node[]; edges: Edge[] } }) {
return (
<Box style={{ position: 'absolute', inset: 0 }}>
<GenericFlowInner {...props} />
</Box>
)
}
function GenericFlowInner({ queryKey, queryFn, buildGraph }: { queryKey: string[]; queryFn: () => Promise<any>; buildGraph: (d: any) => { nodes: Node[]; edges: Edge[] } }) {
const { fitView, setViewport } = useReactFlow()
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const flowKey = queryKey.join('-')
const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey)
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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 <Center style={{ position: 'absolute', inset: 0 }}><Loader /></Center>
return (
<>
<Box pos="absolute" top={8} right={8} style={{ zIndex: 10 }}>
<ActionIcon variant="subtle" style={{ background: 'white', boxShadow: '0 1px 3px rgba(0,0,0,.15)' }} onClick={() => { refetch() }}><TbRefresh size={14} /></ActionIcon>
</Box>
<ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop} onMoveEnd={onMoveEnd} nodeTypes={SIMPLE_NODE_TYPES}
fitView minZoom={0.05} maxZoom={5}>
<Background /><Controls />
</ReactFlow>
</>
)
}
function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Edge[] }; flowKey: string }) {
const { fitView, setViewport } = useReactFlow()
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey)
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<Box style={{ position: 'absolute', inset: 0 }}>
<ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop} onMoveEnd={onMoveEnd} nodeTypes={SIMPLE_NODE_TYPES}
fitView minZoom={0.05} maxZoom={5}>
<Background /><Controls />
</ReactFlow>
</Box>
)
}
// ─── 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<Record<string, string>>({})
const [saved, setSaved] = useState<Record<string, boolean>>({})
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<string, string> = {}
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 (
<Stack>
<Title order={3}>Settings</Title>
<Text size="sm" c="dimmed">Konfigurasi runtime perubahan langsung berlaku tanpa rebuild atau redeploy.</Text>
{isLoading ? <Center><Loader /></Center> : (
<Stack gap="md">
{CONFIG_DEFINITIONS.map((def) => {
const existing = configs.find((c) => c.key === def.key)
return (
<Paper key={def.key} withBorder p="lg" radius="md">
<Stack gap="xs">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600} size="sm">{def.label}</Text>
<Text size="xs" c="dimmed" ff="monospace">{def.key}</Text>
</div>
{existing && (
<Text size="xs" c="dimmed">
Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')}
</Text>
)}
</Group>
<Text size="xs" c="dimmed">{def.description}</Text>
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<input
style={{
width: '100%',
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--mantine-color-default-border)',
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
fontSize: 13,
fontFamily: 'monospace',
}}
value={values[def.key] ?? ''}
onChange={(e) => setValues((prev) => ({ ...prev, [def.key]: e.target.value }))}
placeholder={def.placeholder}
/>
</Box>
<Button
size="sm"
color={saved[def.key] ? 'green' : 'blue'}
loading={saveMutation.isPending && saveMutation.variables?.key === def.key}
onClick={() => saveMutation.mutate({ key: def.key, value: values[def.key] ?? '' })}
>
{saved[def.key] ? 'Tersimpan' : 'Simpan'}
</Button>
</Group>
{!existing && (
<Badge color="orange" variant="light" size="xs">Belum dikonfigurasi data tidak akan ter-load</Badge>
)}
</Stack>
</Paper>
)
})}
</Stack>
)}
</Stack>
)
}
// ─── 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