Compare commits
6 Commits
main
...
amalia/29-
| Author | SHA1 | Date | |
|---|---|---|---|
| b63117694b | |||
| dbbe53584c | |||
| 06794524fd | |||
| 73aa9729b8 | |||
| 7c5a491ba9 | |||
| 3c6fac1943 |
17
.env.example
17
.env.example
@@ -1,6 +1,7 @@
|
||||
# App
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
BUN_PUBLIC_BASE_URL=http://localhost:3000
|
||||
|
||||
# Dev Inspector
|
||||
REACT_EDITOR=code
|
||||
@@ -13,12 +14,20 @@ DIRECT_URL=postgresql://user:password@localhost:5432/base-template
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Role
|
||||
# Super Admin (comma-separated emails)
|
||||
SUPER_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# API Key for external clients (e.g. mobile apps)
|
||||
API_KEY=your-secret-api-key-here
|
||||
|
||||
# Telegram Notification (optional)
|
||||
TELEGRAM_NOTIFY_TOKEN=
|
||||
TELEGRAM_NOTIFY_CHAT_ID=
|
||||
# MinIO (object storage for bug report images)
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_PORT=443
|
||||
MINIO_USE_SSL=true
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_BUCKET=
|
||||
MINIO_UPLOAD_DIR=bug-reports
|
||||
|
||||
# Redis (optional — enables App Logs feature on /dev)
|
||||
REDIS_URL=
|
||||
|
||||
6
bun.lock
6
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=="],
|
||||
|
||||
21
compose.yml
21
compose.yml
@@ -4,17 +4,30 @@ services:
|
||||
container_name: monitoring-app-stg
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# App
|
||||
- PORT=${PORT:-3000}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- BUN_PUBLIC_BASE_URL=${BUN_PUBLIC_BASE_URL}
|
||||
# Database
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- DIRECT_URL=${DIRECT_URL}
|
||||
# Google OAuth
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
# App
|
||||
- PORT=${PORT:-3000}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
# Admin (initial Super Admin emails, comma-separated)
|
||||
# Super Admin
|
||||
- SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL}
|
||||
# API Key
|
||||
- API_KEY=${API_KEY}
|
||||
# MinIO (object storage)
|
||||
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||
- MINIO_PORT=${MINIO_PORT:-443}
|
||||
- MINIO_USE_SSL=${MINIO_USE_SSL:-true}
|
||||
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
- MINIO_BUCKET=${MINIO_BUCKET}
|
||||
- MINIO_UPLOAD_DIR=${MINIO_UPLOAD_DIR:-bug-reports}
|
||||
# Redis (optional — app logs feature)
|
||||
- REDIS_URL=${REDIS_URL:-}
|
||||
networks:
|
||||
- public-net
|
||||
- postgres-net-stg
|
||||
|
||||
10
package.json
10
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",
|
||||
|
||||
67
src/app.ts
67
src/app.ts
@@ -203,6 +203,10 @@ export function createApp() {
|
||||
})
|
||||
}
|
||||
|
||||
if (!user.active) {
|
||||
return new Response(null, { status: 302, headers: { Location: '/login?error=account_disabled' } })
|
||||
}
|
||||
|
||||
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
|
||||
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
|
||||
}
|
||||
@@ -233,6 +237,10 @@ export function createApp() {
|
||||
set.status = 401
|
||||
return { error: 'Email atau password salah' }
|
||||
}
|
||||
if (!user.active) {
|
||||
set.status = 403
|
||||
return { error: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.' }
|
||||
}
|
||||
// Auto-promote super admin from env
|
||||
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
|
||||
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
|
||||
@@ -281,13 +289,18 @@ export function createApp() {
|
||||
if (!token) { set.status = 401; return { user: null } }
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token },
|
||||
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
|
||||
include: { user: { select: { id: true, name: true, email: true, role: true, image: true, active: true } } },
|
||||
})
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
if (session) await prisma.session.delete({ where: { id: session.id } })
|
||||
set.status = 401
|
||||
return { user: null }
|
||||
}
|
||||
if (!session.user.active) {
|
||||
await prisma.session.deleteMany({ where: { userId: session.user.id } })
|
||||
set.status = 401
|
||||
return { user: null }
|
||||
}
|
||||
return { user: session.user }
|
||||
}, {
|
||||
detail: {
|
||||
@@ -414,6 +427,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 +443,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 +476,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',
|
||||
@@ -628,6 +654,10 @@ export function createApp() {
|
||||
},
|
||||
})
|
||||
|
||||
if (body.active === false) {
|
||||
await prisma.session.deleteMany({ where: { userId: id } })
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`)
|
||||
}
|
||||
@@ -980,6 +1010,39 @@ export function createApp() {
|
||||
tags: ['System'],
|
||||
},
|
||||
})
|
||||
.get('/api/system/version', async () => {
|
||||
const pkg = await Bun.file('./package.json').json()
|
||||
let commit = 'unknown'
|
||||
let branch = 'unknown'
|
||||
let changelog: { hash: string; date: string; author: string; message: string }[] = []
|
||||
try {
|
||||
const commitProc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' })
|
||||
commit = (await new Response(commitProc.stdout).text()).trim()
|
||||
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' })
|
||||
branch = (await new Response(branchProc.stdout).text()).trim()
|
||||
const logProc = Bun.spawn(
|
||||
['git', 'log', '--pretty=format:%h|%aI|%an|%s', '-20'],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
const logText = (await new Response(logProc.stdout).text()).trim()
|
||||
changelog = logText.split('\n').filter(Boolean).map(line => {
|
||||
const [hash, date, author, ...msgParts] = line.split('|')
|
||||
return { hash, date, author, message: msgParts.join('|') }
|
||||
})
|
||||
} catch { /* git not available */ }
|
||||
return {
|
||||
version: pkg.version as string,
|
||||
commit,
|
||||
branch,
|
||||
changelog,
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'Version Info',
|
||||
description: 'Mengembalikan versi aplikasi, git commit hash, branch aktif, dan 20 commit terakhir sebagai changelog.',
|
||||
tags: ['System'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Example API ───────────────────────────────────
|
||||
.get('/api/hello', () => ({
|
||||
@@ -1041,6 +1104,7 @@ export function createApp() {
|
||||
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
|
||||
})
|
||||
await appLog('info', `Role changed: ${user.email} ${target?.role} → ${role}`)
|
||||
await createSystemLog(auth.userId, 'UPDATE', `Role changed: ${user.name} (${user.email}) ${target?.role} → ${role}`)
|
||||
return { user }
|
||||
})
|
||||
|
||||
@@ -1056,6 +1120,7 @@ export function createApp() {
|
||||
})
|
||||
if (!active) await prisma.session.deleteMany({ where: { userId: params.id } })
|
||||
await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`)
|
||||
await createSystemLog(auth.userId, active ? 'UPDATE' : 'DELETE', `User ${active ? 'activated' : 'deactivated'}: ${user.name} (${user.email})`)
|
||||
return { user }
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -201,12 +201,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<TbSettings size={16} />}
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
>
|
||||
Settings
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>Danger Zone</Menu.Label>
|
||||
<Menu.Item
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</ActionIcon>
|
||||
@@ -218,7 +219,8 @@ function DevPage() {
|
||||
leftSection={<Icon size={16} />}
|
||||
rightSection={active === item.key ? <TbChevronRight size={14} /> : undefined}
|
||||
active={active === item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => !item.disabled && setActive(item.key)}
|
||||
style={{ borderRadius: 6 }}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ function LoginPage() {
|
||||
invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.',
|
||||
token_failed: 'Gagal menukar token Google, silakan coba lagi.',
|
||||
userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.',
|
||||
account_disabled: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.',
|
||||
}[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.'
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
@@ -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<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
|
||||
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 GlobalLogsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [logType, setLogType] = useState<string | null>('all')
|
||||
const [operatorId, setOperatorId] = useState<string | null>('all')
|
||||
const [type, setType] = useState('all')
|
||||
const [operatorId, setOperatorId] = useState('all')
|
||||
const [dateRange, setDateRange] = useState<DatesRangeValue>([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 (
|
||||
<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>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Activity Logs</Title>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => mutate()}>
|
||||
<TbRefresh size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="Filter user"
|
||||
value={operatorId}
|
||||
onChange={(v) => { setOperatorId(v ?? 'all'); setPage(1) }}
|
||||
data={operatorOptions}
|
||||
w={180}
|
||||
clearable
|
||||
/>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
placeholder="Filter tanggal"
|
||||
value={dateRange}
|
||||
onChange={(v) => { setDateRange(v); setPage(1) }}
|
||||
locale="id"
|
||||
valueFormat="DD MMM YYYY"
|
||||
clearable
|
||||
w={300}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={type}
|
||||
onChange={(v) => { setType(v); setPage(1) }}
|
||||
data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))}
|
||||
/>
|
||||
</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>
|
||||
<Center py="xl"><Loader /></Center>
|
||||
) : (
|
||||
<>
|
||||
{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"
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table striped highlightOnHover fz="xs" style={{ tableLayout: 'fixed', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 160 }} />
|
||||
<col style={{ width: 200 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<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>
|
||||
{logs.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 fw={500} truncate>{log.user.name}</Text>
|
||||
<Text c="dimmed" truncate>{log.user.email}</Text>
|
||||
</div>
|
||||
) : <Text c="dimmed">—</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={LOG_TYPE_COLOR[log.type] ?? 'gray'} variant="light">
|
||||
{log.type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text>{log.message}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{logs.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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
@@ -37,7 +39,8 @@ import {
|
||||
TbSearch,
|
||||
TbShieldCheck,
|
||||
TbTrash,
|
||||
TbUserCheck
|
||||
TbUserCheck,
|
||||
TbUserPlus,
|
||||
} from 'react-icons/tb'
|
||||
import useSWR from 'swr'
|
||||
import { API_URLS } from '../config/api'
|
||||
@@ -58,13 +61,37 @@ const getRoleColor = (role: string) => {
|
||||
const roles = [
|
||||
{
|
||||
name: 'DEVELOPER',
|
||||
color: 'red',
|
||||
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
|
||||
color: 'violet',
|
||||
description: 'Super admin dengan akses penuh ke seluruh sistem termasuk Dev Console.',
|
||||
permissions: [
|
||||
'Akses Dev Console (/dev)',
|
||||
'Manajemen user & role',
|
||||
'Kelola bug report & feedback',
|
||||
'Lihat semua app & log aktivitas',
|
||||
'Kelola versi & status aplikasi',
|
||||
'Hapus log sistem',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ADMIN',
|
||||
color: 'orange',
|
||||
permissions: ['View All Apps', 'View Logs', 'Report Errors']
|
||||
color: 'blue',
|
||||
description: 'Operator yang dapat mengelola aplikasi, bug, dan melihat log aktivitas.',
|
||||
permissions: [
|
||||
'Lihat & kelola semua aplikasi',
|
||||
'Kelola bug report',
|
||||
'Lihat log aktivitas',
|
||||
'Lihat data user, desa, orders',
|
||||
'Update status village & produk',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'USER',
|
||||
color: 'gray',
|
||||
description: 'Akun baru yang belum disetujui. Menunggu approval dari Admin atau Developer.',
|
||||
permissions: [
|
||||
'Akses halaman profil',
|
||||
'Lihat status persetujuan akun',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -205,6 +232,28 @@ function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activate User ──
|
||||
const handleActivateUser = async (user: any) => {
|
||||
try {
|
||||
const res = await fetch(`/api/operators/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ active: true }),
|
||||
})
|
||||
if (res.ok) {
|
||||
notifications.show({ title: 'Success', message: `${user.name} telah diaktifkan kembali.`, color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||
mutateOperators()
|
||||
mutateStats()
|
||||
} else {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Failed to activate user')
|
||||
}
|
||||
} catch (e: any) {
|
||||
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
@@ -282,33 +331,62 @@ function UsersPage() {
|
||||
) : (
|
||||
operators.map((user: any) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
|
||||
<Group gap="sm">
|
||||
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
|
||||
{user.name.charAt(0)}
|
||||
</Avatar>
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Avatar size="sm" radius="xl" color={user.active === false ? 'gray' : getRoleColor(user.role)} src={user.image}>
|
||||
{user.name.charAt(0)}
|
||||
</Avatar>
|
||||
{user.active === false && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: 'var(--mantine-color-red-6)',
|
||||
border: '1.5px solid var(--mantine-color-body)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">{user.name}</Text>
|
||||
<Group gap={6}>
|
||||
<Text fw={600} size="sm" c={user.active === false ? 'dimmed' : undefined}>{user.name}</Text>
|
||||
{user.active === false && (
|
||||
<Badge size="xs" color="red" variant="light">Inactive</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{user.email}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={getRoleColor(user.role)}>
|
||||
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
|
||||
<Badge variant="light" color={user.active === false ? 'gray' : getRoleColor(user.role)}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
|
||||
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
|
||||
<Text size="xs" fw={500} c={user.active === false ? 'dimmed' : undefined}>
|
||||
{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
{user.active === false ? (
|
||||
<Tooltip label="Aktifkan user" withArrow>
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="teal" onClick={() => handleActivateUser(user)}>
|
||||
<TbUserPlus size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -343,8 +421,8 @@ function UsersPage() {
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Title order={4}>{role.name.replace('_', ' ')}</Title>
|
||||
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
|
||||
<Title order={4}>{role.name}</Title>
|
||||
<Text size="sm" c="dimmed">{role.description}</Text>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
Reference in New Issue
Block a user