Compare commits
8 Commits
build/stg
...
amalia/25-
| Author | SHA1 | Date | |
|---|---|---|---|
| ed49f2e4d1 | |||
| f368e1d31b | |||
| 2921f604a9 | |||
| a19846f589 | |||
| e32addbc85 | |||
| 8c33003b17 | |||
| cc81c8b91e | |||
| 5515401614 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,9 @@ src/frontend/routeTree.gen.ts
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Claude Code session data
|
||||
.claude/
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bun-react-template",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -21,7 +21,7 @@ async function triggerWorkflow(workflow: string, inputs: Record<string, string>)
|
||||
const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/dispatches`, {
|
||||
method: 'POST',
|
||||
headers: ghHeaders,
|
||||
body: JSON.stringify({ ref: 'main', inputs }),
|
||||
body: JSON.stringify({ ref: 'stg', inputs }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`GitHub API error ${res.status}: ${await res.text()}`)
|
||||
}
|
||||
|
||||
135
src/app.ts
135
src/app.ts
@@ -8,7 +8,7 @@ import { prisma } from './lib/db'
|
||||
import { env } from './lib/env'
|
||||
import { createSystemLog } from './lib/logger'
|
||||
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
|
||||
import { addConnection, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence'
|
||||
import { addConnection, broadcastNotification, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence'
|
||||
import { parseSchema } from './lib/schema-parser'
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
@@ -805,6 +805,9 @@ export function createApp() {
|
||||
const search = query.search || ''
|
||||
const app = query.app as any
|
||||
const status = query.status as any
|
||||
const source = query.source as any
|
||||
const dateFrom = query.dateFrom
|
||||
const dateTo = query.dateTo
|
||||
|
||||
const where: any = {}
|
||||
if (search) {
|
||||
@@ -821,6 +824,18 @@ export function createApp() {
|
||||
if (status && status !== 'all') {
|
||||
where.status = status
|
||||
}
|
||||
if (source && source !== 'all') {
|
||||
where.source = source
|
||||
}
|
||||
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 [bugs, total] = await Promise.all([
|
||||
prisma.bug.findMany({
|
||||
@@ -852,10 +867,13 @@ export function createApp() {
|
||||
search: t.Optional(t.String({ description: 'Cari berdasarkan deskripsi, device, OS, atau versi' })),
|
||||
app: t.Optional(t.String({ description: 'Filter berdasarkan ID aplikasi, atau "all"' })),
|
||||
status: t.Optional(t.String({ description: 'Filter status: OPEN | ON_HOLD | IN_PROGRESS | RESOLVED | RELEASED | CLOSED | all' })),
|
||||
source: t.Optional(t.String({ description: 'Filter sumber: QC | SYSTEM | USER | all' })),
|
||||
dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (YYYY-MM-DD)' })),
|
||||
dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (YYYY-MM-DD)' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'List Bug Reports',
|
||||
description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi dan status.',
|
||||
description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi, status, source, dan tanggal.',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
@@ -903,6 +921,18 @@ export function createApp() {
|
||||
},
|
||||
})
|
||||
|
||||
broadcastNotification({
|
||||
type: 'new_bug',
|
||||
bug: {
|
||||
id: bug.id,
|
||||
description: bug.description,
|
||||
appId: bug.appId,
|
||||
source: bug.source,
|
||||
affectedVersion: bug.affectedVersion,
|
||||
createdAt: bug.createdAt,
|
||||
},
|
||||
})
|
||||
|
||||
return bug
|
||||
}, {
|
||||
body: t.Object({
|
||||
@@ -1070,6 +1100,88 @@ export function createApp() {
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Bug Statistics API ────────────────────────────
|
||||
.get('/api/bugs/stats', async ({ query }) => {
|
||||
const range = [7, 30, 90].includes(Number(query.range)) ? Number(query.range) : 7
|
||||
const now = new Date()
|
||||
const rangeStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [totalBugs, openBugs, statusGroups, appGroups, sourceGroups, resolvedBugs, trendData] = await Promise.all([
|
||||
prisma.bug.count(),
|
||||
prisma.bug.count({ where: { status: 'OPEN' } }),
|
||||
prisma.bug.groupBy({ by: ['status'], _count: { id: true } }),
|
||||
prisma.bug.groupBy({ by: ['appId'], _count: { id: true } }),
|
||||
prisma.bug.groupBy({ by: ['source'], _count: { id: true } }),
|
||||
prisma.bug.findMany({
|
||||
where: { status: { in: ['RESOLVED', 'CLOSED'] } },
|
||||
select: { createdAt: true, updatedAt: true },
|
||||
}),
|
||||
prisma.bug.findMany({
|
||||
where: { createdAt: { gte: rangeStart } },
|
||||
select: { createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const byStatus = Object.fromEntries(statusGroups.map((g) => [g.status, g._count.id]))
|
||||
const byApp = appGroups.map((g) => ({ appId: g.appId, count: g._count.id }))
|
||||
const bySource = Object.fromEntries(sourceGroups.map((g) => [g.source, g._count.id]))
|
||||
|
||||
const totalResolutionMs = resolvedBugs.reduce((sum, b) => sum + (b.updatedAt.getTime() - b.createdAt.getTime()), 0)
|
||||
const avgResolutionHours = resolvedBugs.length > 0
|
||||
? Math.round(totalResolutionMs / resolvedBugs.length / (1000 * 60 * 60) * 10) / 10
|
||||
: 0
|
||||
|
||||
const resolvedCount = (byStatus['RESOLVED'] || 0) + (byStatus['CLOSED'] || 0)
|
||||
const resolutionRate = totalBugs > 0 ? Math.round((resolvedCount / totalBugs) * 100) : 0
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
const trendMap: Record<string, number> = {}
|
||||
const keyToLabel: Record<string, string> = {}
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - i)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
const label = `${d.getDate()} ${months[d.getMonth()]}`
|
||||
keyToLabel[key] = label
|
||||
trendMap[key] = 0
|
||||
}
|
||||
for (const b of trendData) {
|
||||
const key = b.createdAt.toISOString().slice(0, 10)
|
||||
if (key in trendMap) trendMap[key]++
|
||||
}
|
||||
const trend: { date: string; count: number }[] = []
|
||||
for (let i = 0; i < range; i++) {
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - i)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
trend.push({ date: keyToLabel[key] ?? key, count: trendMap[key] ?? 0 })
|
||||
}
|
||||
trend.reverse()
|
||||
|
||||
return {
|
||||
totalBugs,
|
||||
openBugs,
|
||||
byStatus,
|
||||
byApp,
|
||||
bySource,
|
||||
avgResolutionHours,
|
||||
resolutionRate,
|
||||
trend,
|
||||
range,
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
range: t.Optional(t.String({ description: 'Rentang hari: 7, 30, atau 90 (default: 30)' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Bug Statistics',
|
||||
description: 'Statistik bug: total, distribusi status, per app, per source, avg resolution time, dan trend.',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── System Status API ─────────────────────────────
|
||||
.get('/api/system/status', async () => {
|
||||
try {
|
||||
@@ -1223,9 +1335,11 @@ export function createApp() {
|
||||
include: { user: { select: { id: true, role: true } } },
|
||||
})
|
||||
if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return }
|
||||
const isAdmin = session.user.role === 'DEVELOPER'
|
||||
const role = session.user.role
|
||||
const isAdmin = role === 'DEVELOPER'
|
||||
const canReceiveNotifs = role === 'DEVELOPER' || role === 'ADMIN'
|
||||
;(ws.data as unknown as { userId: string }).userId = session.user.id
|
||||
addConnection(ws as any, session.user.id, isAdmin)
|
||||
addConnection(ws as any, session.user.id, isAdmin, canReceiveNotifs)
|
||||
},
|
||||
close(ws) { removeConnection(ws as any) },
|
||||
message() {},
|
||||
@@ -1656,6 +1770,19 @@ export function createApp() {
|
||||
return { keys: json.data ?? [] }
|
||||
})
|
||||
|
||||
.get('/api/admin/api-keys/:id', async ({ request, set, params }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
||||
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
|
||||
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys/${params.id}`, {
|
||||
headers: { 'x-api-key': app.apiKey ?? '' },
|
||||
})
|
||||
const json = await res.json()
|
||||
set.status = res.status
|
||||
return json
|
||||
})
|
||||
|
||||
.post('/api/admin/api-keys', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { APP_CONFIGS } from '@/frontend/config/appMenus'
|
||||
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
||||
import { usePresence } from '@/frontend/hooks/usePresence'
|
||||
import React from 'react'
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -24,12 +25,14 @@ import {
|
||||
useMantineColorScheme
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
TbAlertTriangle,
|
||||
TbApps,
|
||||
TbArrowLeft,
|
||||
TbBug,
|
||||
TbChevronRight,
|
||||
TbClock,
|
||||
TbDashboard,
|
||||
@@ -64,6 +67,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const user = sessionData?.user
|
||||
const logout = useLogout()
|
||||
|
||||
// ─── Real-time bug notifications ─────────────────────
|
||||
usePresence((bug) => {
|
||||
const appLabel = bug.appId ? bug.appId.toUpperCase() : 'Unknown App'
|
||||
notifications.show({
|
||||
id: `new-bug-${bug.id}`,
|
||||
title: `New bug report — ${appLabel}`,
|
||||
message: bug.description.length > 80 ? `${bug.description.slice(0, 80)}…` : bug.description,
|
||||
color: 'red',
|
||||
icon: React.createElement(TbBug, { size: 18 }),
|
||||
autoClose: 8000,
|
||||
withBorder: true,
|
||||
})
|
||||
})
|
||||
|
||||
// Redirect USER role to profile (pending approval)
|
||||
React.useEffect(() => {
|
||||
if (!sessionLoading && user?.role === 'USER') {
|
||||
|
||||
@@ -7,8 +7,14 @@ export const API_URLS = {
|
||||
`${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`,
|
||||
gridVillages: (id: string) =>
|
||||
`${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`,
|
||||
graphLogVillages: (id: string, time: string) =>
|
||||
`${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
|
||||
graphLogVillages: (id: string, time: string, dateFrom?: string, dateTo?: string) => {
|
||||
const params = new URLSearchParams({ id, time })
|
||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||
if (dateTo) params.set('dateTo', dateTo)
|
||||
return `${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?${params}`
|
||||
},
|
||||
getRecentVillageLogs: (id: string) =>
|
||||
`${DESA_PLUS_PROXY}/api/monitoring/recent-village-logs?id=${id}`,
|
||||
getUsers: (page: number, search: string, isActive?: string, idUserRole?: string, idVillage?: string, orderBy?: string, orderDir?: string) => {
|
||||
const params = new URLSearchParams({ page: String(page), search })
|
||||
if (isActive !== undefined) params.set('isActive', isActive)
|
||||
@@ -51,9 +57,15 @@ export const API_URLS = {
|
||||
createOperator: () => `/api/operators`,
|
||||
editOperator: (id: string) => `/api/operators/${id}`,
|
||||
deleteOperator: (id: string) => `/api/operators/${id}`,
|
||||
getBugs: (page: number, search: string, app: string, status: string) =>
|
||||
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
|
||||
getBugs: (page: number, search: string, app: string, status: string, source?: string, dateFrom?: string, dateTo?: string) => {
|
||||
const params = new URLSearchParams({ page: String(page), search: encodeURIComponent(search), app, status })
|
||||
if (source && source !== 'all') params.set('source', source)
|
||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||
if (dateTo) params.set('dateTo', dateTo)
|
||||
return `/api/bugs?${params}`
|
||||
},
|
||||
createBug: () => `/api/bugs`,
|
||||
getBugStats: (range: 7 | 30 | 90 = 30) => `/api/bugs/stats?range=${range}`,
|
||||
uploadImage: () => `/api/upload/image`,
|
||||
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
|
||||
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSession } from './useAuth'
|
||||
|
||||
export function usePresence() {
|
||||
export interface NewBugPayload {
|
||||
id: string
|
||||
description: string
|
||||
appId: string | null
|
||||
source: string
|
||||
affectedVersion: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function usePresence(onNewBug?: (bug: NewBugPayload) => void) {
|
||||
const { data } = useSession()
|
||||
const [onlineUserIds, setOnlineUserIds] = useState<string[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const onNewBugRef = useRef(onNewBug)
|
||||
onNewBugRef.current = onNewBug
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.user) return
|
||||
@@ -18,6 +29,7 @@ export function usePresence() {
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'presence') setOnlineUserIds(msg.online)
|
||||
if (msg.type === 'new_bug') onNewBugRef.current?.(msg.bug)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
@@ -19,8 +20,10 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
TbArrowLeft,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
TbCalendar,
|
||||
TbCalendarEvent,
|
||||
TbChartBar,
|
||||
TbClock,
|
||||
TbEdit,
|
||||
TbHome2,
|
||||
TbLayoutKanban,
|
||||
@@ -65,11 +69,17 @@ type ChartPeriod = 'daily' | 'monthly' | 'yearly'
|
||||
|
||||
function ActivityChart({ villageId }: { villageId: string }) {
|
||||
const [period, setPeriod] = useState<ChartPeriod>('daily')
|
||||
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
|
||||
|
||||
const { data: response, isLoading } = useSWR(
|
||||
API_URLS.graphLogVillages(villageId, period),
|
||||
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 hasCustomRange = !!(dateFrom && dateTo)
|
||||
|
||||
const apiUrl = hasCustomRange
|
||||
? API_URLS.graphLogVillages(villageId, period, dateFrom, dateTo)
|
||||
: API_URLS.graphLogVillages(villageId, period)
|
||||
|
||||
const { data: response, isLoading } = useSWR(apiUrl, fetcher)
|
||||
|
||||
const labels: Record<ChartPeriod, string> = {
|
||||
daily: 'Daily (last 14 days)',
|
||||
@@ -79,7 +89,6 @@ function ActivityChart({ villageId }: { villageId: string }) {
|
||||
|
||||
const rawData: any[] = Array.isArray(response?.data) ? response.data : []
|
||||
|
||||
// Normalize: map any field names from external API → { label, activity }
|
||||
const data = rawData.map((item) => {
|
||||
const label = item.label
|
||||
const activity = item.aktivitas
|
||||
@@ -95,21 +104,37 @@ function ActivityChart({ villageId }: { villageId: string }) {
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="sm">Village Activity Log</Text>
|
||||
<Text size="xs" c="dimmed">{labels[period]}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{hasCustomRange ? `${dateFrom} — ${dateTo}` : labels[period]}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SegmentedControl
|
||||
value={period}
|
||||
onChange={(v) => setPeriod(v as ChartPeriod)}
|
||||
size="xs"
|
||||
radius="md"
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'yearly', label: 'Yearly' },
|
||||
]}
|
||||
/>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
placeholder="Pick date range"
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
{!hasCustomRange && (
|
||||
<SegmentedControl
|
||||
value={period}
|
||||
onChange={(v) => setPeriod(v as ChartPeriod)}
|
||||
size="xs"
|
||||
radius="md"
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'yearly', label: 'Yearly' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -168,6 +193,64 @@ function ActivityChart({ villageId }: { villageId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Recent Activity Logs ──────────────────────────────────────────────────────
|
||||
|
||||
function RecentVillageLogs({ villageId }: { villageId: string }) {
|
||||
const { data: response, isLoading } = useSWR(API_URLS.getRecentVillageLogs(villageId), fetcher)
|
||||
const logs: any[] = Array.isArray(response?.data) ? response.data : []
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="xl" p="lg">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="teal">
|
||||
<TbClock size={14} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="sm">Recent Activity</Text>
|
||||
<Text size="xs" c="dimmed">Latest user actions in this village</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack h={120} align="center" justify="center">
|
||||
<Loader type="dots" />
|
||||
</Stack>
|
||||
) : logs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">No recent activity.</Text>
|
||||
) : (
|
||||
<Table verticalSpacing="xs" className="data-table">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Time</Table.Th>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Action</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{logs.map((log: any, i: number) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Text size="xs">{dayjs(log.timestamp).format('D MMM YYYY, HH:mm')}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>{log.userName || 'Unknown'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{log.action || '-'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{log.desc || '-'}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function VillageDetailPage() {
|
||||
@@ -474,21 +557,22 @@ function VillageDetailPage() {
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* ── Chart + Info Panels ── */}
|
||||
{/* ── Activity Chart ── */}
|
||||
<ActivityChart villageId={villageId} />
|
||||
|
||||
{/* ── Recent Logs + System Info ── */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '3fr 1fr',
|
||||
gridTemplateColumns: '2fr 1fr',
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Left (3/4): Activity Chart */}
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<ActivityChart villageId={villageId} />
|
||||
<RecentVillageLogs villageId={villageId} />
|
||||
</Box>
|
||||
|
||||
{/* Right (1/4): Informasi Sistem */}
|
||||
<Paper withBorder radius="xl" p="lg">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="teal">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
||||
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
||||
import { API_URLS } from '@/frontend/config/api'
|
||||
import { AreaChart, BarChart } from '@mantine/charts'
|
||||
import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
@@ -27,17 +29,20 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { useDebouncedValue, useDisclosure } from '@mantine/hooks'
|
||||
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
TbAlertTriangle,
|
||||
TbBug,
|
||||
TbChartBar,
|
||||
TbCircleCheck,
|
||||
TbCircleX,
|
||||
TbClock,
|
||||
TbDeviceDesktop,
|
||||
TbDeviceMobile,
|
||||
TbFilter,
|
||||
@@ -45,7 +50,9 @@ import {
|
||||
TbPhoto,
|
||||
TbPlus,
|
||||
TbSearch,
|
||||
TbTrendingUp,
|
||||
} from 'react-icons/tb'
|
||||
import useSWR from 'swr'
|
||||
|
||||
export const Route = createFileRoute('/bug-reports')({
|
||||
component: ListErrorsPage,
|
||||
@@ -71,20 +78,40 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
function ListErrorsPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [app, setApp] = useState('all')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [source, setSource] = useState('all')
|
||||
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
|
||||
const [bugRange, setBugRange] = useState<7 | 30 | 90>(7)
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 400)
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) {
|
||||
setSearchQuery(debouncedSearch)
|
||||
setPage(1)
|
||||
}
|
||||
}, [debouncedSearch])
|
||||
|
||||
useEffect(() => { setPage(1) }, [app, status, source, dateRange])
|
||||
|
||||
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
|
||||
const [showStackTrace, setShowStackTrace] = useState<Record<string, boolean>>({})
|
||||
|
||||
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 toggleLogs = (bugId: string) => setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||
const toggleStackTrace = (bugId: string) => setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['bugs', { page, search, app, status }],
|
||||
queryFn: () => fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()),
|
||||
queryKey: ['bugs', { page, searchQuery, app, status, source, dateFrom, dateTo }],
|
||||
queryFn: () => fetch(API_URLS.getBugs(page, searchQuery, app, status, source, dateFrom, dateTo)).then((r) => r.json()),
|
||||
})
|
||||
|
||||
const { data: bugStats } = useSWR(API_URLS.getBugStats(bugRange), (url: string) => fetch(url).then((r) => r.json()))
|
||||
|
||||
const { data: appsList } = useQuery({
|
||||
queryKey: ['apps-list'],
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
@@ -229,6 +256,177 @@ function ListErrorsPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Bug Statistics Section */}
|
||||
{bugStats && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
<SummaryCard
|
||||
title="Total Bugs"
|
||||
value={bugStats.totalBugs?.toLocaleString() ?? '0'}
|
||||
icon={TbBug}
|
||||
color="brand-blue"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Open Bugs"
|
||||
value={bugStats.openBugs?.toLocaleString() ?? '0'}
|
||||
icon={TbAlertTriangle}
|
||||
color="red"
|
||||
isError={bugStats.openBugs > 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Avg Resolution Time"
|
||||
value={`${bugStats.avgResolutionHours ?? 0}h`}
|
||||
icon={TbClock}
|
||||
color="orange"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Resolution Rate"
|
||||
value={`${bugStats.resolutionRate ?? 0}%`}
|
||||
icon={TbTrendingUp}
|
||||
color="teal"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{bugStats && (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="brand-blue">
|
||||
<TbChartBar size={14} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="sm">Bugs per Application</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<BarChart
|
||||
h={220}
|
||||
data={(bugStats.byApp || []).map((item: { appId: string; count: number }) => ({
|
||||
...item,
|
||||
appId: item.appId.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
||||
}))}
|
||||
dataKey="appId"
|
||||
series={[{ name: 'count', color: 'blue.6' }]}
|
||||
withTooltip
|
||||
tickLine="none"
|
||||
gridAxis="x"
|
||||
barProps={{
|
||||
radius: [8, 8, 0, 0],
|
||||
fill: 'url(#bugBarGradient)',
|
||||
}}
|
||||
xAxisProps={{
|
||||
tick: { fontSize: 12, fill: '#909296' },
|
||||
}}
|
||||
tooltipProps={{
|
||||
content: ({ active, payload }: any) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#1A1B1E',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #373A40',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
|
||||
{payload[0]?.payload?.appId}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#2563EB' }}>
|
||||
Bugs: <span style={{ fontWeight: 700 }}>{payload[0]?.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="bugBarGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2563EB" stopOpacity={1} />
|
||||
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0.8} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</Paper>
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="violet">
|
||||
<TbTrendingUp size={14} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700} size="sm">Bug Trend</Text>
|
||||
<Text size="xs" c="dimmed">Last {bugRange} days</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
{([7, 30, 90] as const).map((r) => (
|
||||
<Button
|
||||
key={r}
|
||||
size="compact-xs"
|
||||
variant={bugRange === r ? 'filled' : 'subtle'}
|
||||
color="violet"
|
||||
radius="md"
|
||||
onClick={() => setBugRange(r)}
|
||||
>
|
||||
{r === 7 ? '7D' : r === 30 ? '1M' : '3M'}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
<AreaChart
|
||||
h={220}
|
||||
data={bugStats.trend || []}
|
||||
dataKey="date"
|
||||
series={[{ name: 'count', color: '#7C3AED' }]}
|
||||
curveType="monotone"
|
||||
withTooltip
|
||||
tickLine="none"
|
||||
gridAxis="x"
|
||||
fillOpacity={0.3}
|
||||
xAxisProps={{
|
||||
interval: bugRange === 7 ? 0 : bugRange === 30 ? 4 : 9,
|
||||
tick: { fontSize: 10, fill: '#909296' },
|
||||
angle: bugRange === 7 ? 0 : -45,
|
||||
textAnchor: 'end',
|
||||
height: bugRange === 7 ? 30 : 60,
|
||||
}}
|
||||
tooltipProps={{
|
||||
content: ({ active, payload }: any) => {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#1A1B1E',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #373A40',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
|
||||
{payload[0]?.payload?.date}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#7C3AED' }}>
|
||||
Bugs: <span style={{ fontWeight: 700 }}>{payload[0]?.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'.recharts-area-curve': {
|
||||
strokeWidth: 2.5,
|
||||
filter: 'drop-shadow(0 3px 6px rgba(124, 58, 237, 0.3))',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
<Modal
|
||||
opened={!!previewImage}
|
||||
@@ -411,7 +609,7 @@ function ListErrorsPage() {
|
||||
</Modal>
|
||||
|
||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 4 }} mb="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} mb="sm">
|
||||
<TextInput
|
||||
label="Search"
|
||||
placeholder="Description, device, OS..."
|
||||
@@ -444,12 +642,35 @@ function ListErrorsPage() {
|
||||
onChange={(val) => setStatus(val || 'all')}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
label="Source"
|
||||
size="sm"
|
||||
data={[
|
||||
{ value: 'all', label: 'All Sources' },
|
||||
{ value: 'QC', label: 'QC' },
|
||||
{ value: 'SYSTEM', label: 'System' },
|
||||
{ value: 'USER', label: 'User' },
|
||||
]}
|
||||
value={source}
|
||||
onChange={(val) => setSource(val || 'all')}
|
||||
radius="md"
|
||||
/>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
label="Date Range"
|
||||
placeholder="Pick date range"
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
clearable
|
||||
/>
|
||||
<Stack justify="flex-end">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="violet"
|
||||
size="sm"
|
||||
onClick={() => { setSearch(''); setApp('all'); setStatus('all') }}
|
||||
onClick={() => { setSearch(''); setApp('all'); setStatus('all'); setSource('all'); setDateRange([null, null]) }}
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
TbApps,
|
||||
TbBug,
|
||||
TbChevronRight,
|
||||
TbCheck,
|
||||
TbCopy,
|
||||
TbCircleFilled,
|
||||
TbCode,
|
||||
@@ -1833,15 +1834,23 @@ function ApiKeysPanel() {
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null)
|
||||
const [keyCopied, setKeyCopied] = useState(false)
|
||||
const [revealedOpened, { open: openRevealed, close: closeRevealed }] = useDisclosure(false)
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set())
|
||||
const [copyingId, setCopyingId] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
const toggleKeyVisibility = (keyId: string) => {
|
||||
setVisibleKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(keyId)) next.delete(keyId)
|
||||
else next.add(keyId)
|
||||
return next
|
||||
})
|
||||
const copyFullKey = async (id: string) => {
|
||||
setCopyingId(id)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/api-keys/${id}`, { credentials: 'include' })
|
||||
const json = await res.json()
|
||||
const fullKey = json.data?.key
|
||||
if (fullKey) {
|
||||
await navigator.clipboard.writeText(fullKey)
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
} finally {
|
||||
setCopyingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -1947,28 +1956,20 @@ function ApiKeysPanel() {
|
||||
<Table.Td fw={500}>{k.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" c="dimmed" style={{ userSelect: visibleKeys.has(k.id) ? 'text' : 'none' }}>
|
||||
{visibleKeys.has(k.id) ? k.key : '•'.repeat(32)}
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{k.key}
|
||||
</Text>
|
||||
<Tooltip label={visibleKeys.has(k.id) ? 'Sembunyikan' : 'Tampilkan'}>
|
||||
<Tooltip label={copiedId === k.id ? 'Tersalin!' : 'Salin full key'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="gray"
|
||||
onClick={() => toggleKeyVisibility(k.id)}
|
||||
color={copiedId === k.id ? 'green' : 'gray'}
|
||||
loading={copyingId === k.id}
|
||||
onClick={() => copyFullKey(k.id)}
|
||||
>
|
||||
{visibleKeys.has(k.id) ? <TbEyeOff size={12} /> : <TbEye size={12} />}
|
||||
{copiedId === k.id ? <TbCheck size={12} /> : <TbCopy size={12} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<CopyButton value={k.key}>
|
||||
{({ copy }) => (
|
||||
<Tooltip label="Salin">
|
||||
<ActionIcon variant="subtle" size="xs" color="gray" onClick={copy}>
|
||||
<TbCopy size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ServerWebSocket } from 'bun'
|
||||
|
||||
const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>()
|
||||
const adminSubs = new Set<ServerWebSocket<{ userId: string }>>()
|
||||
const notifSubs = new Set<ServerWebSocket<{ userId: string }>>()
|
||||
|
||||
export function getOnlineUserIds(): string[] {
|
||||
return Array.from(connections.keys())
|
||||
@@ -13,7 +14,12 @@ function broadcast() {
|
||||
for (const ws of adminSubs) ws.send(msg)
|
||||
}
|
||||
|
||||
export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: string, isAdmin: boolean) {
|
||||
export function addConnection(
|
||||
ws: ServerWebSocket<{ userId: string }>,
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
canReceiveNotifs: boolean,
|
||||
) {
|
||||
let set = connections.get(userId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
@@ -24,6 +30,7 @@ export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: s
|
||||
adminSubs.add(ws)
|
||||
ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() }))
|
||||
}
|
||||
if (canReceiveNotifs) notifSubs.add(ws)
|
||||
broadcast()
|
||||
}
|
||||
|
||||
@@ -32,6 +39,11 @@ export function broadcastToAdmins(message: object) {
|
||||
for (const ws of adminSubs) ws.send(msg)
|
||||
}
|
||||
|
||||
export function broadcastNotification(message: object) {
|
||||
const msg = JSON.stringify(message)
|
||||
for (const ws of notifSubs) ws.send(msg)
|
||||
}
|
||||
|
||||
export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
|
||||
const userId = ws.data.userId
|
||||
const set = connections.get(userId)
|
||||
@@ -40,5 +52,6 @@ export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
|
||||
if (set.size === 0) connections.delete(userId)
|
||||
}
|
||||
adminSubs.delete(ws)
|
||||
notifSubs.delete(ws)
|
||||
broadcast()
|
||||
}
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// --- Constants ---
|
||||
const CONFIG_FILE = path.join(os.homedir(), '.note.conf');
|
||||
|
||||
// --- Types ---
|
||||
interface Config {
|
||||
TOKEN?: string;
|
||||
REPO?: string;
|
||||
URL?: string;
|
||||
}
|
||||
|
||||
export const defaultConfigSF: Config = {
|
||||
TOKEN: process.env.SF_TOKEN,
|
||||
REPO: process.env.SF_REPO,
|
||||
URL: process.env.SF_URL,
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
if (!(await fs.stat(CONFIG_FILE)).isFile()) {
|
||||
console.error(`⚠️ Config file not found at ${CONFIG_FILE}`);
|
||||
console.error('Run: bun note.ts config to create/edit it.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(CONFIG_FILE, 'utf8');
|
||||
const config: Config = {};
|
||||
|
||||
configContent.split('\n').forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return;
|
||||
|
||||
const [key, ...valueParts] = trimmed.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
let value = valueParts.join('=').trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
config[key as keyof Config] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (!config.TOKEN || !config.REPO || !config.URL) {
|
||||
console.error(`❌ Config invalid. Please set TOKEN, REPO, and URL inside ${CONFIG_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// --- HTTP Helpers ---
|
||||
export async function fetchWithAuth(config: Config, url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const headers = {
|
||||
Authorization: `Token ${config.TOKEN}`,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
console.error(`❌ Request failed: ${response.status} ${response.statusText}`);
|
||||
console.error(`🔍 URL: ${url}`);
|
||||
console.error(`🔍 Headers:`, headers);
|
||||
|
||||
try {
|
||||
const errorText = await response.text();
|
||||
console.error(`🔍 Response body: ${errorText}`);
|
||||
} catch {
|
||||
console.error('🔍 Could not read response body');
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- Commands ---
|
||||
export async function testConnection(config: Config): Promise<string> {
|
||||
try {
|
||||
const response = await fetchWithAuth(config, `${config.URL}/ping/`);
|
||||
return `✅ API connection successful: ${await response.text()}`
|
||||
} catch {
|
||||
// return '⚠️ API ping failed, trying repo access...'
|
||||
try {
|
||||
await fetchWithAuth(config, `${config.URL}/${config.REPO}/`);
|
||||
return `✅ Repo access successful`
|
||||
} catch {
|
||||
return '❌ Both API ping and repo access failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFiles(config: Config): Promise<{ name: string }[]> {
|
||||
const url = `${config.URL}/${config.REPO}/dir/?p=/`;
|
||||
const response = await fetchWithAuth(config, url);
|
||||
|
||||
try {
|
||||
const files = (await response.json()) as { name: string }[];
|
||||
return files
|
||||
} catch {
|
||||
console.error('❌ Failed to parse response');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function catFile(config: Config, folder: string, fileName: string): Promise<ArrayBuffer> {
|
||||
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`);
|
||||
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
|
||||
|
||||
// Download file sebagai binary, BUKAN text
|
||||
const fileResponse = await fetchWithAuth(config, downloadUrl);
|
||||
const buffer = await fileResponse.arrayBuffer();
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function uploadFile(config: Config, file: File, folder: string): Promise<string> {
|
||||
const remoteName = path.basename(file.name);
|
||||
|
||||
// 1. Dapatkan upload link (pakai Authorization)
|
||||
const uploadUrlResponse = await fetchWithAuth(
|
||||
config,
|
||||
`${config.URL}/${config.REPO}/upload-link/`
|
||||
);
|
||||
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
|
||||
|
||||
// 2. Siapkan form-data
|
||||
const formData = new FormData();
|
||||
formData.append("parent_dir", "/");
|
||||
formData.append("relative_path", folder); // tanpa slash di akhir
|
||||
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
|
||||
|
||||
// 3. Upload file TANPA Authorization header, token di query param
|
||||
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) return 'gagal'
|
||||
return `✅ Uploaded ${file.name} successfully`;
|
||||
}
|
||||
|
||||
export async function uploadFileBase64(config: Config, base64File: { name: string; data: string; }): Promise<string> {
|
||||
const remoteName = path.basename(base64File.name);
|
||||
|
||||
// 1. Dapatkan upload link (pakai Authorization)
|
||||
const uploadUrlResponse = await fetchWithAuth(
|
||||
config,
|
||||
`${config.URL}/${config.REPO}/upload-link/`
|
||||
);
|
||||
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
|
||||
|
||||
// 2. Konversi base64 ke Blob
|
||||
const binary = Buffer.from(base64File.data, "base64");
|
||||
const blob = new Blob([binary]);
|
||||
|
||||
// 3. Siapkan form-data
|
||||
const formData = new FormData();
|
||||
formData.append("parent_dir", "/");
|
||||
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
|
||||
formData.append("file", blob, remoteName);
|
||||
|
||||
// 4. Upload file TANPA Authorization header, token di query param
|
||||
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) throw new Error(`Upload failed: ${text}`);
|
||||
return `✅ Uploaded ${base64File.name} successfully`;
|
||||
}
|
||||
|
||||
export async function uploadFileToFolder(config: Config, base64File: { name: string; data: string; }, folder: 'syarat-dokumen' | 'pengaduan'): Promise<string> {
|
||||
const remoteName = path.basename(base64File.name);
|
||||
|
||||
// 1. Dapatkan upload link (pakai Authorization)
|
||||
const uploadUrlResponse = await fetchWithAuth(
|
||||
config,
|
||||
`${config.URL}/${config.REPO}/upload-link/`
|
||||
);
|
||||
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
|
||||
|
||||
// 2. Konversi base64 ke Blob
|
||||
const binary = Buffer.from(base64File.data, "base64");
|
||||
const blob = new Blob([binary]);
|
||||
|
||||
// 3. Siapkan form-data
|
||||
const formData = new FormData();
|
||||
formData.append("parent_dir", "/");
|
||||
formData.append("relative_path", folder); // tanpa slash di akhir
|
||||
formData.append("file", blob, remoteName);
|
||||
|
||||
// 4. Upload file TANPA Authorization header, token di query param
|
||||
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) throw new Error(`Upload failed: ${text}`);
|
||||
return `✅ Uploaded ${base64File.name} successfully`;
|
||||
}
|
||||
|
||||
|
||||
export async function removeFile(config: Config, fileName: string, folder: string): Promise<string> {
|
||||
const res = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`, { method: 'DELETE' });
|
||||
|
||||
if (!res.ok) return 'gagal menghapus file';
|
||||
return `🗑️ Removed ${fileName}`
|
||||
}
|
||||
|
||||
export async function moveFile(config: Config, oldName: string, newName: string): Promise<string> {
|
||||
const url = `${config.URL}/${config.REPO}/file/?p=/${oldName}`;
|
||||
const formData = new FormData();
|
||||
formData.append('operation', 'rename');
|
||||
formData.append('newname', newName);
|
||||
|
||||
await fetchWithAuth(config, url, { method: 'POST', body: formData });
|
||||
return `✏️ Renamed ${oldName} → ${newName}`
|
||||
}
|
||||
|
||||
export async function downloadFile(config: Config, fileName: string, folder: string, localFile?: string): Promise<string> {
|
||||
const localName = localFile || fileName;
|
||||
// 🔹 gabungkan path folder + file
|
||||
const filePath = `/${folder}/${fileName}`.replace(/\/+/g, "/");
|
||||
|
||||
// 🔹 encode path agar aman (spasi, dll)
|
||||
const params = new URLSearchParams({
|
||||
p: filePath,
|
||||
});
|
||||
|
||||
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?${params.toString()}`);
|
||||
if (!downloadUrlResponse.ok)
|
||||
return 'gagal'
|
||||
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
|
||||
const buffer = Buffer.from(await (await fetchWithAuth(config, downloadUrl)).arrayBuffer());
|
||||
await fs.writeFile(localName, buffer);
|
||||
return `⬇️ Downloaded ${fileName} → ${localName}`
|
||||
}
|
||||
|
||||
export async function getFileLink(config: Config, fileName: string): Promise<string> {
|
||||
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`);
|
||||
return `🔗 Link for ${fileName}:\n${(await downloadUrlResponse.text()).replace(/"/g, '')}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user