Merge pull request 'amalia/25-mei-26' (#26) from amalia/25-mei-26 into main
Reviewed-on: #26
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
135
src/app.ts
135
src/app.ts
@@ -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' }
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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