mirror of
https://github.com/ershisan99/www.git
synced 2025-12-17 12:34:17 +00:00
persist logs
This commit is contained in:
145
src/app/(home)/admin/logs/logs-client.tsx
Normal file
145
src/app/(home)/admin/logs/logs-client.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
type LogFile = {
|
||||
id: number
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
createdAt: string
|
||||
userId: string | null
|
||||
userName: string | null
|
||||
userEmail: string | null
|
||||
}
|
||||
|
||||
export function LogsClient() {
|
||||
const [logs, setLogs] = useState<LogFile[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/logs')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs')
|
||||
}
|
||||
const data = await response.json()
|
||||
setLogs(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [])
|
||||
|
||||
const handleViewInParser = (id: number) => {
|
||||
// Navigate to the log parser page with the log ID as a query parameter
|
||||
router.push(`/log-parser?logId=${id}`)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('Are you sure you want to delete this log file?')) {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/logs?id=${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete log file')
|
||||
}
|
||||
|
||||
// Refresh the logs list
|
||||
await fetchLogs()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred while deleting')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Log Files</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage uploaded log files
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p>Loading logs...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : logs.length === 0 ? (
|
||||
<p>No logs found</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>File Name</TableHead>
|
||||
<TableHead>Uploaded By</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{log.fileName}</TableCell>
|
||||
<TableCell>
|
||||
{log.userName || log.userEmail || 'Anonymous'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleViewInParser(log.id)}
|
||||
>
|
||||
View in Parser
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(log.id)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
30
src/app/(home)/admin/logs/page.tsx
Normal file
30
src/app/(home)/admin/logs/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { LogsClient } from '@/app/(home)/admin/logs/logs-client'
|
||||
import { auth } from '@/server/auth'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default async function LogsPage() {
|
||||
const session = await auth()
|
||||
const isAdmin = session?.user.role === 'admin'
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className={'container mx-auto pt-8'}>
|
||||
<div className={'prose'}>
|
||||
<h1>Forbidden</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<div
|
||||
className={
|
||||
'mx-auto flex w-[calc(100%-1rem)] max-w-fd-container flex-col gap-4 pt-16'
|
||||
}
|
||||
>
|
||||
<LogsClient />
|
||||
</div>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,8 @@ import {
|
||||
import { jokers } from '@/shared/jokers'
|
||||
import { useFormatter } from 'next-intl'
|
||||
import Image from 'next/image'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { type PvpBlind, PvpBlindsCard } from './_components/pvp-blinds'
|
||||
// Define the structure for individual log events within a game
|
||||
type LogEvent = {
|
||||
@@ -171,19 +172,143 @@ function boolStrToText(str: string | boolean | undefined | null): string {
|
||||
return str
|
||||
}
|
||||
|
||||
// Helper function to convert date strings to Date objects recursively
|
||||
function convertDates<T>(obj: T): T {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => convertDates(item)) as unknown as T
|
||||
}
|
||||
|
||||
const result = { ...obj } as any
|
||||
|
||||
// Process each property
|
||||
for (const key in result) {
|
||||
const value = result[key]
|
||||
|
||||
// Check if the value is a date string (ISO format)
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)
|
||||
) {
|
||||
result[key] = new Date(value)
|
||||
}
|
||||
// Also handle date strings in the format used in the logs (YYYY-MM-DD HH:MM:SS)
|
||||
else if (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value)
|
||||
) {
|
||||
result[key] = new Date(value)
|
||||
}
|
||||
// Recursively process nested objects and arrays
|
||||
else if (value && typeof value === 'object') {
|
||||
result[key] = convertDates(value)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function LogParser() {
|
||||
const formatter = useFormatter()
|
||||
const searchParams = useSearchParams()
|
||||
const [parsedGames, setParsedGames] = useState<Game[]>([])
|
||||
console.log(parsedGames)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const parseLogFile = async (file: File) => {
|
||||
// Check for logId query parameter and load the parsed data if it exists
|
||||
useEffect(() => {
|
||||
const logId = searchParams.get('logId')
|
||||
const fileUrl = searchParams.get('fileUrl')
|
||||
|
||||
if (logId) {
|
||||
// If logId is provided, fetch the parsed data from the database
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setParsedGames([])
|
||||
|
||||
fetch(`/api/logs?id=${logId}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch log file')
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then((data) => {
|
||||
// Use the parsed JSON data directly from the database
|
||||
if (data.parsedJson && Array.isArray(data.parsedJson)) {
|
||||
const parsedGamesWithDates = convertDates(data.parsedJson)
|
||||
setParsedGames(parsedGamesWithDates)
|
||||
} else {
|
||||
setError('No parsed games found in the log file.')
|
||||
}
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error loading log file:', err)
|
||||
setError(`Failed to load log file: ${err.message}`)
|
||||
setIsLoading(false)
|
||||
})
|
||||
} else if (fileUrl) {
|
||||
// For backward compatibility, still support fileUrl
|
||||
// But this should be deprecated in favor of logId
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setParsedGames([])
|
||||
|
||||
fetch(fileUrl)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch log file')
|
||||
}
|
||||
return response.text()
|
||||
})
|
||||
.then((content) => {
|
||||
// Create a File object from the content
|
||||
const file = new File([content], 'log.txt', { type: 'text/plain' })
|
||||
// Parse the file
|
||||
parseLogFile(file, true)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error loading log file:', err)
|
||||
setError(`Failed to load log file: ${err.message}`)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const parseLogFile = async (file: File, skipUpload?: boolean) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setParsedGames([])
|
||||
let logFileId = null
|
||||
|
||||
try {
|
||||
// Create a FormData object to send the file
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
if (!skipUpload) {
|
||||
const response = await fetch('/api/logs/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to upload log file')
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
logFileId = responseData.id
|
||||
}
|
||||
// Upload the file to the server
|
||||
|
||||
// Get the file content
|
||||
const content = await file.text()
|
||||
const logLines = content.split('\n')
|
||||
|
||||
@@ -727,8 +852,10 @@ export default function LogParser() {
|
||||
lastEventTime ?? lastProcessedTimestamp ?? currentGame.startDate // Fallback chain
|
||||
}
|
||||
currentGame.durationSeconds = currentGame.endDate
|
||||
? (currentGame.endDate.getTime() - currentGame.startDate.getTime()) /
|
||||
1000
|
||||
? (currentGame.endDate instanceof Date
|
||||
? currentGame.endDate.getTime()
|
||||
: new Date(currentGame.endDate).getTime() -
|
||||
currentGame.startDate.getTime()) / 1000
|
||||
: null
|
||||
games.push(currentGame)
|
||||
}
|
||||
@@ -737,7 +864,26 @@ export default function LogParser() {
|
||||
setError('No games found in the log file.')
|
||||
}
|
||||
|
||||
setParsedGames(games)
|
||||
// Send the parsed games to the server
|
||||
if (!skipUpload) {
|
||||
console.log('Sending parsed games to server...')
|
||||
const uploadResponse = await fetch('/api/logs/upload', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
logFileId,
|
||||
parsedGames: games,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
console.error('Failed to save parsed games')
|
||||
}
|
||||
}
|
||||
|
||||
setParsedGames(convertDates(games))
|
||||
} catch (err) {
|
||||
console.error('Error parsing log:', err)
|
||||
setError(
|
||||
@@ -842,16 +988,26 @@ export default function LogParser() {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Started:{' '}
|
||||
{formatter.dateTime(game.startDate, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
})}{' '}
|
||||
{formatter.dateTime(
|
||||
game.startDate instanceof Date
|
||||
? game.startDate
|
||||
: new Date(game.startDate),
|
||||
{
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}
|
||||
)}{' '}
|
||||
| Ended:{' '}
|
||||
{game.endDate
|
||||
? formatter.dateTime(game.endDate, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
? formatter.dateTime(
|
||||
game.endDate instanceof Date
|
||||
? game.endDate
|
||||
: new Date(game.endDate),
|
||||
{
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}
|
||||
)
|
||||
: 'N/A'}{' '}
|
||||
| Duration: {formatDuration(game.durationSeconds)}
|
||||
</CardDescription>
|
||||
@@ -966,9 +1122,14 @@ export default function LogParser() {
|
||||
<span
|
||||
className={`${event.text.includes('Opponent') ? 'ml-2' : 'mr-2'} font-mono`}
|
||||
>
|
||||
{formatter.dateTime(event.timestamp, {
|
||||
timeStyle: 'medium',
|
||||
})}
|
||||
{formatter.dateTime(
|
||||
event.timestamp instanceof Date
|
||||
? event.timestamp
|
||||
: new Date(event.timestamp),
|
||||
{
|
||||
timeStyle: 'medium',
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
<span>{event.text}</span>
|
||||
</div>
|
||||
|
||||
111
src/app/api/logs/route.ts
Normal file
111
src/app/api/logs/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { auth } from '@/server/auth'
|
||||
import { db } from '@/server/db'
|
||||
import { logFiles, users } from '@/server/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Get the log file ID from the request
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
// Check if user is authenticated
|
||||
const session = await auth()
|
||||
|
||||
if (id) {
|
||||
// Fetching a specific log file by ID
|
||||
// For specific log files, we allow access to the owner or admins
|
||||
const logFile = await db
|
||||
.select({
|
||||
id: logFiles.id,
|
||||
fileName: logFiles.fileName,
|
||||
fileUrl: logFiles.fileUrl,
|
||||
parsedJson: logFiles.parsedJson,
|
||||
createdAt: logFiles.createdAt,
|
||||
userId: logFiles.userId,
|
||||
})
|
||||
.from(logFiles)
|
||||
.where(eq(logFiles.id, Number.parseInt(id)))
|
||||
.limit(1)
|
||||
|
||||
if (logFile.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Log file not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is authorized to access this log file
|
||||
// Allow access if user is admin or the owner of the log file
|
||||
if (
|
||||
!session ||
|
||||
(session.user.role !== 'admin' &&
|
||||
logFile?.[0]?.userId !== session.user.id)
|
||||
) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
return NextResponse.json(logFile[0])
|
||||
}
|
||||
// Fetching all log files (admin only)
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get all log files with user information
|
||||
const logs = await db
|
||||
.select({
|
||||
id: logFiles.id,
|
||||
fileName: logFiles.fileName,
|
||||
fileUrl: logFiles.fileUrl,
|
||||
createdAt: logFiles.createdAt,
|
||||
userId: logFiles.userId,
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
})
|
||||
.from(logFiles)
|
||||
.leftJoin(users, eq(logFiles.userId, users.id))
|
||||
.orderBy(logFiles.createdAt)
|
||||
|
||||
return NextResponse.json(logs)
|
||||
} catch (error) {
|
||||
console.error('Error fetching log files:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch log files' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// Check if user is authenticated and is an admin
|
||||
const session = await auth()
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the log file ID from the request
|
||||
const { searchParams } = new URL(req.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Log file ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete the log file from the database
|
||||
await db.delete(logFiles).where(eq(logFiles.id, Number.parseInt(id)))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting log file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete log file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
101
src/app/api/logs/upload/route.ts
Normal file
101
src/app/api/logs/upload/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { auth } from '@/server/auth'
|
||||
import { db } from '@/server/db'
|
||||
import { logFiles } from '@/server/db/schema'
|
||||
import { uploadFile } from '@/server/minio'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check if user is authenticated (optional)
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
|
||||
// Parse the multipart form data
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Convert the file to a buffer and text
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const fileContent = await file.text()
|
||||
|
||||
// Upload the file to MinIO
|
||||
const fileUrl = await uploadFile(buffer, file.name, file.type)
|
||||
|
||||
// Store the information in the database with an empty JSON object for now
|
||||
// The actual parsed games will be updated via PUT request
|
||||
const [logFile] = await db
|
||||
.insert(logFiles)
|
||||
.values({
|
||||
userId,
|
||||
fileName: file.name,
|
||||
fileUrl,
|
||||
parsedJson: {},
|
||||
})
|
||||
.returning()
|
||||
if (!logFile) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This should never happen, hopefully' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
// Return the log file information
|
||||
return NextResponse.json({
|
||||
id: logFile.id,
|
||||
fileName: logFile.fileName,
|
||||
fileUrl: logFile.fileUrl,
|
||||
createdAt: logFile.createdAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error uploading log file:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to upload log file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// Check if user is authenticated (optional)
|
||||
const session = await auth()
|
||||
|
||||
// Parse the JSON data
|
||||
const data = await req.json()
|
||||
const { logFileId, parsedGames } = data
|
||||
|
||||
if (!logFileId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No log file ID provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!parsedGames || !Array.isArray(parsedGames)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid parsed games data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update the log file record with the parsed games
|
||||
await db
|
||||
.update(logFiles)
|
||||
.set({
|
||||
parsedJson: parsedGames,
|
||||
})
|
||||
.where(eq(logFiles.id, logFileId))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating parsed games:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update parsed games' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -163,3 +163,19 @@ export const releasesRelations = relations(releases, ({ one }) => ({
|
||||
references: [branches.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const logFiles = pgTable('log_files', {
|
||||
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
fileName: text('file_name').notNull(),
|
||||
fileUrl: text('file_url').notNull(),
|
||||
parsedJson: json('parsed_json').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const logFilesRelations = relations(logFiles, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [logFiles.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user