diff --git a/bun.lock b/bun.lock index 6635a06..f80e9bd 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@elysiajs/swagger": "^1.3.1", "@mantine/charts": "^9.0.0", "@mantine/core": "^8.3.18", + "@mantine/dates": "^9.1.1", "@mantine/hooks": "^8.3.18", "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", @@ -17,6 +18,7 @@ "@tanstack/react-query": "^5.95.2", "@tanstack/react-router": "^1.168.10", "@xyflow/react": "^12.6.4", + "dayjs": "^1.11.20", "elkjs": "^0.9.3", "elysia": "^1.4.28", "minio": "^8.0.7", @@ -201,6 +203,8 @@ "@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="], + "@mantine/dates": ["@mantine/dates@9.1.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "9.1.1", "@mantine/hooks": "9.1.1", "dayjs": ">=1.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA=="], + "@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="], "@mantine/modals": ["@mantine/modals@8.3.18", "", { "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q=="], @@ -481,6 +485,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], diff --git a/package.json b/package.json index eb220c5..0404f50 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,16 @@ "@elysiajs/swagger": "^1.3.1", "@mantine/charts": "^9.0.0", "@mantine/core": "^8.3.18", + "@mantine/dates": "^9.1.1", "@mantine/hooks": "^8.3.18", + "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", "@prisma/client": "6", "@tanstack/react-query": "^5.95.2", "@tanstack/react-router": "^1.168.10", + "@xyflow/react": "^12.6.4", + "dayjs": "^1.11.20", + "elkjs": "^0.9.3", "elysia": "^1.4.28", "minio": "^8.0.7", "postcss": "^8.5.8", @@ -42,10 +47,7 @@ "react-dom": "^19", "react-icons": "^5.6.0", "recharts": "^3.8.1", - "swr": "^2.4.1", - "@mantine/modals": "^8.3.18", - "@xyflow/react": "^12.6.4", - "elkjs": "^0.9.3" + "swr": "^2.4.1" }, "devDependencies": { "@biomejs/biome": "^2.4.10", diff --git a/src/app.ts b/src/app.ts index 411693c..64982eb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -414,6 +414,8 @@ export function createApp() { const search = query.search || '' const type = query.type as any const userId = query.userId + const dateFrom = query.dateFrom + const dateTo = query.dateTo const where: any = {} if (search) { @@ -428,6 +430,15 @@ export function createApp() { if (userId && userId !== 'all') { where.userId = userId } + if (dateFrom || dateTo) { + where.createdAt = {} + if (dateFrom) where.createdAt.gte = new Date(dateFrom) + if (dateTo) { + const end = new Date(dateTo) + end.setHours(23, 59, 59, 999) + where.createdAt.lte = end + } + } const [logs, total] = await Promise.all([ prisma.log.findMany({ @@ -452,6 +463,8 @@ export function createApp() { search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })), type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })), userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })), + dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (ISO string atau YYYY-MM-DD)' })), + dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (ISO string atau YYYY-MM-DD)' })), }), detail: { summary: 'List Activity Logs', diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 3648078..7f8c651 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -1,5 +1,6 @@ import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core' import '@mantine/core/styles.css' +import '@mantine/dates/styles.css' import '@mantine/notifications/styles.css' import { ModalsProvider } from '@mantine/modals' import { Notifications } from '@mantine/notifications' diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts index 61e8981..0845254 100644 --- a/src/frontend/config/api.ts +++ b/src/frontend/config/api.ts @@ -25,8 +25,12 @@ export const API_URLS = { editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`, updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`, editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`, - getGlobalLogs: (page: number, search: string, type: string, userId: string) => - `/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`, + getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => { + const params = new URLSearchParams({ page: String(page), search, type, userId }) + if (dateFrom) params.set('dateFrom', dateFrom) + if (dateTo) params.set('dateTo', dateTo) + return `/api/logs?${params}` + }, getLogOperators: () => `/api/logs/operators`, getOperators: (page: number, search: string) => `/api/operators?page=${page}&search=${encodeURIComponent(search)}`, diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index 4628dbb..cceceea 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -109,11 +109,11 @@ const navItems = [ { label: 'Overview', icon: TbLayoutDashboard, key: 'overview' }, { label: 'Operators', icon: TbUsers, key: 'operators' }, { label: 'Bugs', icon: TbBug, key: 'bugs' }, - { label: 'App Logs', icon: TbServer, key: 'app-logs' }, - { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' }, + { label: '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' }, + // { label: 'Settings', icon: TbSettings, key: 'settings' }, ] function DevPage() { @@ -204,7 +204,8 @@ function DevPage() { variant={active === item.key ? 'filled' : 'subtle'} color={active === item.key ? 'blue' : 'gray'} size="lg" - onClick={() => setActive(item.key)} + disabled={item.disabled} + onClick={() => !item.disabled && setActive(item.key)} > @@ -218,7 +219,8 @@ function DevPage() { leftSection={} rightSection={active === item.key ? : undefined} active={active === item.key} - onClick={() => setActive(item.key)} + disabled={item.disabled} + onClick={() => !item.disabled && setActive(item.key)} style={{ borderRadius: 6 }} /> ) diff --git a/src/frontend/routes/logs.tsx b/src/frontend/routes/logs.tsx index d148033..d5075aa 100644 --- a/src/frontend/routes/logs.tsx +++ b/src/frontend/routes/logs.tsx @@ -1,22 +1,24 @@ import { + ActionIcon, Badge, + Center, Container, Group, - Stack, - Text, - Paper, - TextInput, - Select, - Avatar, - Box, - Divider, + Loader, Pagination, - Center, - Tooltip, + SegmentedControl, + Select, + Stack, + Table, + Text, + Title, } from '@mantine/core' -import { useState, useMemo, useEffect } from 'react' +import { DatePickerInput, type DatesRangeValue } from '@mantine/dates' +import dayjs from 'dayjs' +import 'dayjs/locale/id' +import { useMemo, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' -import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb' +import { TbRefresh } from 'react-icons/tb' import { DashboardLayout } from '@/frontend/components/DashboardLayout' import useSWR from 'swr' import { API_URLS } from '../config/api' @@ -25,263 +27,144 @@ export const Route = createFileRoute('/logs')({ component: GlobalLogsPage, }) -const fetcher = (url: string) => fetch(url).then((res) => res.json()) +const fetcher = (url: string) => fetch(url, { credentials: 'include' }).then((r) => r.json()) -const typeConfig: Record = { - CREATE: { color: 'blue', icon: TbCheck }, - UPDATE: { color: 'teal', icon: TbCheck }, - DELETE: { color: 'red', icon: TbX }, - LOGIN: { color: 'green', icon: TbClock }, - LOGOUT: { color: 'orange', icon: TbClock }, -} - -const getRoleColor = (role: string) => { - const r = (role || '').toLowerCase() - if (r.includes('super')) return 'red' - if (r.includes('admin')) return 'brand-blue' - if (r.includes('developer')) return 'violet' - return 'gray' -} - -function groupLogsByDate(logs: any[]) { - const groups: Record = {} - - const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - - logs.forEach(log => { - const dateObj = new Date(log.createdAt) - let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - - if (dateStr === today) dateStr = 'TODAY' - else if (dateStr === yesterday) dateStr = 'YESTERDAY' - - if (!groups[dateStr]) groups[dateStr] = [] - - const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - - groups[dateStr].push({ - id: log.id, - time: timeStr, - user: log.user, - type: log.type, - content: log.message, - color: log.user ? getRoleColor(log.user.role) : 'gray', - icon: typeConfig[log.type as string]?.icon - }) - }) - - // We want to keep the order as they came from the API (sorted by createdAt desc) - // but grouped by date. Object.entries might mess up the order if dates are not sequential. - // However, since the source logs are sorted, the first encounter of a date defines the group order. - const result: { date: string; logs: any[] }[] = [] - const seenDates = new Set() - - logs.forEach(log => { - const dateObj = new Date(log.createdAt) - let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - if (dateStr === today) dateStr = 'TODAY' - else if (dateStr === yesterday) dateStr = 'YESTERDAY' - - if (!seenDates.has(dateStr)) { - result.push({ date: dateStr, logs: groups[dateStr] }) - seenDates.add(dateStr) - } - }) - - return result +const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const +const LOG_TYPE_COLOR: Record = { + LOGIN: 'green', + LOGOUT: 'gray', + CREATE: 'blue', + UPDATE: 'yellow', + DELETE: 'red', } function GlobalLogsPage() { - const [search, setSearch] = useState('') - const [debouncedSearch, setDebouncedSearch] = useState('') - const [logType, setLogType] = useState('all') - const [operatorId, setOperatorId] = useState('all') + const [type, setType] = useState('all') + const [operatorId, setOperatorId] = useState('all') + const [dateRange, setDateRange] = useState([null, null]) const [page, setPage] = useState(1) - useEffect(() => { - const timer = setTimeout(() => setDebouncedSearch(search), 300) - return () => clearTimeout(timer) - }, [search]) - const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher) const operatorOptions = useMemo(() => { - if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }] + if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'Semua operator' }] return [ - { value: 'all', label: 'All Operators' }, - ...operatorsData.map((op: any) => ({ value: op.id, label: op.name })) + { value: 'all', label: 'Semua user' }, + ...operatorsData.map((op: any) => ({ value: op.id, label: op.name })), ] }, [operatorsData]) - const { data: response, isLoading } = useSWR( - API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'), - fetcher + const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined + const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined + + const { data, isLoading, mutate } = useSWR( + API_URLS.getGlobalLogs(page, '', type, operatorId, dateFrom, dateTo), + fetcher, + { refreshInterval: 10_000 }, ) - const filteredTimeline = useMemo(() => { - if (!response?.data) return [] - return groupLogsByDate(response.data) - }, [response?.data]) + const logs: any[] = data?.data ?? [] + const totalPages: number = data?.totalPages ?? 1 return ( - - {/* Header Controls */} - - } - radius="md" - w={250} - value={search} - onChange={(e) => { - setSearch(e.currentTarget.value) - setPage(1) - }} - /> - { - setOperatorId(val) - setPage(1) - }} - /> - + + + Activity Logs + mutate()}> + + + + + +