- 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>
1582 lines
66 KiB
TypeScript
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
|