Deskripi: - list error report general dan per apps - update status - update feedback No Issues
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import {
|
|
Badge,
|
|
Container,
|
|
Group,
|
|
Stack,
|
|
Text,
|
|
Paper,
|
|
TextInput,
|
|
Select,
|
|
Avatar,
|
|
Box,
|
|
Divider,
|
|
Pagination,
|
|
Center,
|
|
Tooltip,
|
|
} from '@mantine/core'
|
|
import { useState, useMemo, useEffect } from 'react'
|
|
import { createFileRoute } from '@tanstack/react-router'
|
|
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
|
|
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
|
import useSWR from 'swr'
|
|
import { API_URLS } from '../config/api'
|
|
|
|
export const Route = createFileRoute('/logs')({
|
|
component: GlobalLogsPage,
|
|
})
|
|
|
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
|
|
|
const typeConfig: Record<string, { color: string; icon?: any }> = {
|
|
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<string, any[]> = {}
|
|
|
|
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<string>()
|
|
|
|
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
|
|
}
|
|
|
|
function GlobalLogsPage() {
|
|
const [search, setSearch] = useState('')
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
const [logType, setLogType] = useState<string | null>('all')
|
|
const [operatorId, setOperatorId] = useState<string | null>('all')
|
|
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' }]
|
|
return [
|
|
{ value: 'all', label: 'All Operators' },
|
|
...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 filteredTimeline = useMemo(() => {
|
|
if (!response?.data) return []
|
|
return groupLogsByDate(response.data)
|
|
}, [response?.data])
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<Container size="xl" py="lg">
|
|
|
|
{/* Header Controls */}
|
|
<Group mb="xl" gap="md">
|
|
<TextInput
|
|
placeholder="Search operator or message..."
|
|
leftSection={<TbSearch size={16} />}
|
|
radius="md"
|
|
w={250}
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.currentTarget.value)
|
|
setPage(1)
|
|
}}
|
|
/>
|
|
<Select
|
|
placeholder="Log Type"
|
|
data={[
|
|
{ value: 'all', label: 'All Types' },
|
|
{ value: 'CREATE', label: 'Create' },
|
|
{ value: 'UPDATE', label: 'Update' },
|
|
{ value: 'DELETE', label: 'Delete' },
|
|
{ value: 'LOGIN', label: 'Login' },
|
|
{ value: 'LOGOUT', label: 'Logout' },
|
|
]}
|
|
radius="md"
|
|
w={160}
|
|
value={logType}
|
|
onChange={(val) => {
|
|
setLogType(val)
|
|
setPage(1)
|
|
}}
|
|
/>
|
|
<Select
|
|
placeholder="Operator"
|
|
data={operatorOptions}
|
|
searchable
|
|
radius="md"
|
|
w={200}
|
|
value={operatorId}
|
|
onChange={(val) => {
|
|
setOperatorId(val)
|
|
setPage(1)
|
|
}}
|
|
/>
|
|
</Group>
|
|
|
|
{/* Timeline Content */}
|
|
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
|
|
{isLoading ? (
|
|
<Center py="xl">
|
|
<Text c="dimmed">Loading logs...</Text>
|
|
</Center>
|
|
) : filteredTimeline.length === 0 ? (
|
|
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
|
|
) : (
|
|
<>
|
|
{filteredTimeline.map((group, groupIndex) => (
|
|
<Box key={group.date}>
|
|
<Text
|
|
size="xs"
|
|
fw={700}
|
|
c="dimmed"
|
|
mt={groupIndex > 0 ? "xl" : 0}
|
|
mb="md"
|
|
style={{ textTransform: 'uppercase' }}
|
|
>
|
|
{group.date}
|
|
</Text>
|
|
|
|
<Stack gap={0} pl={4}>
|
|
{group.logs.map((log, logIndex) => {
|
|
const isLastLog = logIndex === group.logs.length - 1;
|
|
|
|
return (
|
|
<Group
|
|
key={log.id}
|
|
wrap="nowrap"
|
|
align="flex-start"
|
|
gap="lg"
|
|
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
|
|
>
|
|
{/* Left: Time */}
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
w={70}
|
|
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
|
|
>
|
|
{log.time}
|
|
</Text>
|
|
|
|
{/* Middle: Line & Avatar */}
|
|
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
|
|
{/* Vertical Line */}
|
|
{!isLastLog && (
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
top: 24,
|
|
bottom: -8,
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
width: 1,
|
|
backgroundColor: 'rgba(128,128,128,0.2)'
|
|
}}
|
|
/>
|
|
)}
|
|
{/* Avatar */}
|
|
<Box style={{ position: 'relative', zIndex: 2 }}>
|
|
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
|
|
<Avatar
|
|
size={24}
|
|
radius="xl"
|
|
color={log.color}
|
|
variant="light"
|
|
src={log.user?.image}
|
|
style={{ cursor: 'help' }}
|
|
>
|
|
{log.icon ? <log.icon size={14} /> : (log.user?.name?.charAt(0) || '?')}
|
|
</Avatar>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Right: Content */}
|
|
<Box style={{ flexGrow: 1, marginTop: 2 }}>
|
|
<Text size="sm">
|
|
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
|
|
{log.content}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
)
|
|
})}
|
|
</Stack>
|
|
|
|
{groupIndex < filteredTimeline.length - 1 && (
|
|
<Divider my="xl" color="rgba(128,128,128,0.1)" />
|
|
)}
|
|
</Box>
|
|
))}
|
|
|
|
{response?.totalPages > 1 && (
|
|
<Center mt="xl">
|
|
<Pagination
|
|
total={response.totalPages}
|
|
value={page}
|
|
onChange={setPage}
|
|
radius="md"
|
|
/>
|
|
</Center>
|
|
)}
|
|
</>
|
|
)}
|
|
</Paper>
|
|
</Container>
|
|
</DashboardLayout>
|
|
)
|
|
}
|