Merge pull request 'amalia/25-mei-26' (#26) from amalia/25-mei-26 into main

Reviewed-on: #26
This commit is contained in:
2026-05-25 17:33:49 +08:00
11 changed files with 552 additions and 314 deletions

3
.gitignore vendored
View File

@@ -30,6 +30,9 @@ src/frontend/routeTree.gen.ts
# IntelliJ based IDEs # IntelliJ based IDEs
.idea .idea
# Claude Code session data
.claude/
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store

View File

@@ -1,6 +1,6 @@
{ {
"name": "bun-react-template", "name": "bun-react-template",
"version": "0.1.15", "version": "0.1.16",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -8,7 +8,7 @@ import { prisma } from './lib/db'
import { env } from './lib/env' import { env } from './lib/env'
import { createSystemLog } from './lib/logger' import { createSystemLog } from './lib/logger'
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' 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' import { parseSchema } from './lib/schema-parser'
const isProduction = process.env.NODE_ENV === 'production' const isProduction = process.env.NODE_ENV === 'production'
@@ -805,6 +805,9 @@ export function createApp() {
const search = query.search || '' const search = query.search || ''
const app = query.app as any const app = query.app as any
const status = query.status 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 = {} const where: any = {}
if (search) { if (search) {
@@ -821,6 +824,18 @@ export function createApp() {
if (status && status !== 'all') { if (status && status !== 'all') {
where.status = status 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([ const [bugs, total] = await Promise.all([
prisma.bug.findMany({ prisma.bug.findMany({
@@ -852,10 +867,13 @@ export function createApp() {
search: t.Optional(t.String({ description: 'Cari berdasarkan deskripsi, device, OS, atau versi' })), 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"' })), 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' })), 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: { detail: {
summary: 'List Bug Reports', 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'], 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 return bug
}, { }, {
body: t.Object({ 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 ───────────────────────────── // ─── System Status API ─────────────────────────────
.get('/api/system/status', async () => { .get('/api/system/status', async () => {
try { try {
@@ -1223,9 +1335,11 @@ export function createApp() {
include: { user: { select: { id: true, role: true } } }, include: { user: { select: { id: true, role: true } } },
}) })
if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return } 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 ;(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) }, close(ws) { removeConnection(ws as any) },
message() {}, message() {},
@@ -1656,6 +1770,19 @@ export function createApp() {
return { keys: json.data ?? [] } 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 }) => { .post('/api/admin/api-keys', async ({ request, set }) => {
const auth = await requireDeveloper(request, set) const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }

View File

@@ -1,5 +1,6 @@
import { APP_CONFIGS } from '@/frontend/config/appMenus' import { APP_CONFIGS } from '@/frontend/config/appMenus'
import { useLogout, useSession } from '@/frontend/hooks/useAuth' import { useLogout, useSession } from '@/frontend/hooks/useAuth'
import { usePresence } from '@/frontend/hooks/usePresence'
import React from 'react' import React from 'react'
import { import {
ActionIcon, ActionIcon,
@@ -24,12 +25,14 @@ import {
useMantineColorScheme useMantineColorScheme
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router'
import { import {
TbAlertTriangle, TbAlertTriangle,
TbApps, TbApps,
TbArrowLeft, TbArrowLeft,
TbBug,
TbChevronRight, TbChevronRight,
TbClock, TbClock,
TbDashboard, TbDashboard,
@@ -64,6 +67,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const user = sessionData?.user const user = sessionData?.user
const logout = useLogout() 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) // Redirect USER role to profile (pending approval)
React.useEffect(() => { React.useEffect(() => {
if (!sessionLoading && user?.role === 'USER') { if (!sessionLoading && user?.role === 'USER') {

View File

@@ -7,8 +7,14 @@ export const API_URLS = {
`${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`, `${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`,
gridVillages: (id: string) => gridVillages: (id: string) =>
`${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`, `${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`,
graphLogVillages: (id: string, time: string) => graphLogVillages: (id: string, time: string, dateFrom?: string, dateTo?: string) => {
`${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, 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) => { getUsers: (page: number, search: string, isActive?: string, idUserRole?: string, idVillage?: string, orderBy?: string, orderDir?: string) => {
const params = new URLSearchParams({ page: String(page), search }) const params = new URLSearchParams({ page: String(page), search })
if (isActive !== undefined) params.set('isActive', isActive) if (isActive !== undefined) params.set('isActive', isActive)
@@ -51,9 +57,15 @@ export const API_URLS = {
createOperator: () => `/api/operators`, createOperator: () => `/api/operators`,
editOperator: (id: string) => `/api/operators/${id}`, editOperator: (id: string) => `/api/operators/${id}`,
deleteOperator: (id: string) => `/api/operators/${id}`, deleteOperator: (id: string) => `/api/operators/${id}`,
getBugs: (page: number, search: string, app: string, status: string) => getBugs: (page: number, search: string, app: string, status: string, source?: string, dateFrom?: string, dateTo?: string) => {
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`, 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`, createBug: () => `/api/bugs`,
getBugStats: (range: 7 | 30 | 90 = 30) => `/api/bugs/stats?range=${range}`,
uploadImage: () => `/api/upload/image`, uploadImage: () => `/api/upload/image`,
updateBugStatus: (id: string) => `/api/bugs/${id}/status`, updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`, updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,

View File

@@ -1,11 +1,22 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSession } from './useAuth' 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 { data } = useSession()
const [onlineUserIds, setOnlineUserIds] = useState<string[]>([]) const [onlineUserIds, setOnlineUserIds] = useState<string[]>([])
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const onNewBugRef = useRef(onNewBug)
onNewBugRef.current = onNewBug
useEffect(() => { useEffect(() => {
if (!data?.user) return if (!data?.user) return
@@ -18,6 +29,7 @@ export function usePresence() {
ws.onmessage = (e) => { ws.onmessage = (e) => {
const msg = JSON.parse(e.data) const msg = JSON.parse(e.data)
if (msg.type === 'presence') setOnlineUserIds(msg.online) if (msg.type === 'presence') setOnlineUserIds(msg.online)
if (msg.type === 'new_bug') onNewBugRef.current?.(msg.bug)
} }
ws.onclose = () => { ws.onclose = () => {
wsRef.current = null wsRef.current = null

View File

@@ -12,6 +12,7 @@ import {
SimpleGrid, SimpleGrid,
Stack, Stack,
Switch, Switch,
Table,
Text, Text,
Textarea, Textarea,
TextInput, TextInput,
@@ -19,8 +20,10 @@ import {
Title, Title,
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
import { notifications } from '@mantine/notifications' import { notifications } from '@mantine/notifications'
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import dayjs from 'dayjs'
import { useState } from 'react' import { useState } from 'react'
import { import {
TbArrowLeft, TbArrowLeft,
@@ -28,6 +31,7 @@ import {
TbCalendar, TbCalendar,
TbCalendarEvent, TbCalendarEvent,
TbChartBar, TbChartBar,
TbClock,
TbEdit, TbEdit,
TbHome2, TbHome2,
TbLayoutKanban, TbLayoutKanban,
@@ -65,11 +69,17 @@ type ChartPeriod = 'daily' | 'monthly' | 'yearly'
function ActivityChart({ villageId }: { villageId: string }) { function ActivityChart({ villageId }: { villageId: string }) {
const [period, setPeriod] = useState<ChartPeriod>('daily') const [period, setPeriod] = useState<ChartPeriod>('daily')
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
const { data: response, isLoading } = useSWR( const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined
API_URLS.graphLogVillages(villageId, period), const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined
fetcher 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> = { const labels: Record<ChartPeriod, string> = {
daily: 'Daily (last 14 days)', daily: 'Daily (last 14 days)',
@@ -79,7 +89,6 @@ function ActivityChart({ villageId }: { villageId: string }) {
const rawData: any[] = Array.isArray(response?.data) ? response.data : [] 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 data = rawData.map((item) => {
const label = item.label const label = item.label
const activity = item.aktivitas const activity = item.aktivitas
@@ -95,21 +104,37 @@ function ActivityChart({ villageId }: { villageId: string }) {
</ThemeIcon> </ThemeIcon>
<Stack gap={0}> <Stack gap={0}>
<Text fw={700} size="sm">Village Activity Log</Text> <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> </Stack>
</Group> </Group>
<SegmentedControl <Group gap="sm" wrap="wrap">
value={period} <DatePickerInput
onChange={(v) => setPeriod(v as ChartPeriod)} type="range"
size="xs" placeholder="Pick date range"
radius="md" size="xs"
data={[ radius="md"
{ value: 'daily', label: 'Daily' }, value={dateRange}
{ value: 'monthly', label: 'Monthly' }, onChange={setDateRange}
{ value: 'yearly', label: 'Yearly' }, 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> </Group>
{isLoading ? ( {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 ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
function VillageDetailPage() { function VillageDetailPage() {
@@ -474,21 +557,22 @@ function VillageDetailPage() {
))} ))}
</SimpleGrid> </SimpleGrid>
{/* ── Chart + Info Panels ── */} {/* ── Activity Chart ── */}
<ActivityChart villageId={villageId} />
{/* ── Recent Logs + System Info ── */}
<Box <Box
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '3fr 1fr', gridTemplateColumns: '2fr 1fr',
gap: '1rem', gap: '1rem',
alignItems: 'start', alignItems: 'start',
}} }}
> >
{/* Left (3/4): Activity Chart */}
<Box style={{ minWidth: 0 }}> <Box style={{ minWidth: 0 }}>
<ActivityChart villageId={villageId} /> <RecentVillageLogs villageId={villageId} />
</Box> </Box>
{/* Right (1/4): Informasi Sistem */}
<Paper withBorder radius="xl" p="lg"> <Paper withBorder radius="xl" p="lg">
<Group gap="xs" mb="md"> <Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" variant="light" color="teal"> <ThemeIcon size={28} radius="md" variant="light" color="teal">

View File

@@ -1,5 +1,7 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout' import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { SummaryCard } from '@/frontend/components/SummaryCard'
import { API_URLS } from '@/frontend/config/api' import { API_URLS } from '@/frontend/config/api'
import { AreaChart, BarChart } from '@mantine/charts'
import { import {
Accordion, Accordion,
Avatar, Avatar,
@@ -27,17 +29,20 @@ import {
Title, Title,
Tooltip, Tooltip,
} from '@mantine/core' } 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 { notifications } from '@mantine/notifications'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { import {
TbAlertTriangle, TbAlertTriangle,
TbBug, TbBug,
TbChartBar,
TbCircleCheck, TbCircleCheck,
TbCircleX, TbCircleX,
TbClock,
TbDeviceDesktop, TbDeviceDesktop,
TbDeviceMobile, TbDeviceMobile,
TbFilter, TbFilter,
@@ -45,7 +50,9 @@ import {
TbPhoto, TbPhoto,
TbPlus, TbPlus,
TbSearch, TbSearch,
TbTrendingUp,
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr'
export const Route = createFileRoute('/bug-reports')({ export const Route = createFileRoute('/bug-reports')({
component: ListErrorsPage, component: ListErrorsPage,
@@ -71,20 +78,40 @@ const STATUS_LABEL: Record<string, string> = {
function ListErrorsPage() { function ListErrorsPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [app, setApp] = useState('all') const [app, setApp] = useState('all')
const [status, setStatus] = 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 [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
const [showStackTrace, setShowStackTrace] = 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 toggleLogs = (bugId: string) => setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
const toggleStackTrace = (bugId: string) => setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] })) const toggleStackTrace = (bugId: string) => setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['bugs', { page, search, app, status }], queryKey: ['bugs', { page, searchQuery, app, status, source, dateFrom, dateTo }],
queryFn: () => fetch(API_URLS.getBugs(page, search, app, status)).then((r) => r.json()), 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({ const { data: appsList } = useQuery({
queryKey: ['apps-list'], queryKey: ['apps-list'],
queryFn: () => fetch('/api/apps').then((r) => r.json()), queryFn: () => fetch('/api/apps').then((r) => r.json()),
@@ -229,6 +256,177 @@ function ListErrorsPage() {
</Button> </Button>
</Group> </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 */} {/* Image Preview Modal */}
<Modal <Modal
opened={!!previewImage} opened={!!previewImage}
@@ -411,7 +609,7 @@ function ListErrorsPage() {
</Modal> </Modal>
<Paper withBorder radius="2xl" className="glass" p="md"> <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 <TextInput
label="Search" label="Search"
placeholder="Description, device, OS..." placeholder="Description, device, OS..."
@@ -444,12 +642,35 @@ function ListErrorsPage() {
onChange={(val) => setStatus(val || 'all')} onChange={(val) => setStatus(val || 'all')}
radius="md" 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"> <Stack justify="flex-end">
<Button <Button
variant="filled" variant="filled"
color="violet" color="violet"
size="sm" size="sm"
onClick={() => { setSearch(''); setApp('all'); setStatus('all') }} onClick={() => { setSearch(''); setApp('all'); setStatus('all'); setSource('all'); setDateRange([null, null]) }}
> >
Reset Filters Reset Filters
</Button> </Button>

View File

@@ -55,6 +55,7 @@ import {
TbApps, TbApps,
TbBug, TbBug,
TbChevronRight, TbChevronRight,
TbCheck,
TbCopy, TbCopy,
TbCircleFilled, TbCircleFilled,
TbCode, TbCode,
@@ -1833,15 +1834,23 @@ function ApiKeysPanel() {
const [createdKey, setCreatedKey] = useState<string | null>(null) const [createdKey, setCreatedKey] = useState<string | null>(null)
const [keyCopied, setKeyCopied] = useState(false) const [keyCopied, setKeyCopied] = useState(false)
const [revealedOpened, { open: openRevealed, close: closeRevealed }] = useDisclosure(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) => { const copyFullKey = async (id: string) => {
setVisibleKeys((prev) => { setCopyingId(id)
const next = new Set(prev) try {
if (next.has(keyId)) next.delete(keyId) const res = await fetch(`/api/admin/api-keys/${id}`, { credentials: 'include' })
else next.add(keyId) const json = await res.json()
return next 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({ const { data, isLoading } = useQuery({
@@ -1947,28 +1956,20 @@ function ApiKeysPanel() {
<Table.Td fw={500}>{k.name}</Table.Td> <Table.Td fw={500}>{k.name}</Table.Td>
<Table.Td> <Table.Td>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<Text size="xs" ff="monospace" c="dimmed" style={{ userSelect: visibleKeys.has(k.id) ? 'text' : 'none' }}> <Text size="xs" ff="monospace" c="dimmed">
{visibleKeys.has(k.id) ? k.key : '•'.repeat(32)} {k.key}
</Text> </Text>
<Tooltip label={visibleKeys.has(k.id) ? 'Sembunyikan' : 'Tampilkan'}> <Tooltip label={copiedId === k.id ? 'Tersalin!' : 'Salin full key'}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="xs" size="xs"
color="gray" color={copiedId === k.id ? 'green' : 'gray'}
onClick={() => toggleKeyVisibility(k.id)} 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> </ActionIcon>
</Tooltip> </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> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>

View File

@@ -2,6 +2,7 @@ import type { ServerWebSocket } from 'bun'
const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>() const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>()
const adminSubs = new Set<ServerWebSocket<{ userId: string }>>() const adminSubs = new Set<ServerWebSocket<{ userId: string }>>()
const notifSubs = new Set<ServerWebSocket<{ userId: string }>>()
export function getOnlineUserIds(): string[] { export function getOnlineUserIds(): string[] {
return Array.from(connections.keys()) return Array.from(connections.keys())
@@ -13,7 +14,12 @@ function broadcast() {
for (const ws of adminSubs) ws.send(msg) 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) let set = connections.get(userId)
if (!set) { if (!set) {
set = new Set() set = new Set()
@@ -24,6 +30,7 @@ export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: s
adminSubs.add(ws) adminSubs.add(ws)
ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() })) ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() }))
} }
if (canReceiveNotifs) notifSubs.add(ws)
broadcast() broadcast()
} }
@@ -32,6 +39,11 @@ export function broadcastToAdmins(message: object) {
for (const ws of adminSubs) ws.send(msg) 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 }>) { export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
const userId = ws.data.userId const userId = ws.data.userId
const set = connections.get(userId) const set = connections.get(userId)
@@ -40,5 +52,6 @@ export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
if (set.size === 0) connections.delete(userId) if (set.size === 0) connections.delete(userId)
} }
adminSubs.delete(ws) adminSubs.delete(ws)
notifSubs.delete(ws)
broadcast() broadcast()
} }

View File

@@ -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, '')}`
}