persist logs

This commit is contained in:
2025-06-21 16:05:22 +02:00
parent 3db5de8c26
commit 1a68d34c81
6 changed files with 580 additions and 16 deletions

View 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>
)
}

View 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>
)
}

View File

@@ -36,7 +36,8 @@ import {
import { jokers } from '@/shared/jokers' import { jokers } from '@/shared/jokers'
import { useFormatter } from 'next-intl' import { useFormatter } from 'next-intl'
import Image from 'next/image' 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' import { type PvpBlind, PvpBlindsCard } from './_components/pvp-blinds'
// Define the structure for individual log events within a game // Define the structure for individual log events within a game
type LogEvent = { type LogEvent = {
@@ -171,19 +172,143 @@ function boolStrToText(str: string | boolean | undefined | null): string {
return str 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 // Main component
export default function LogParser() { export default function LogParser() {
const formatter = useFormatter() const formatter = useFormatter()
const searchParams = useSearchParams()
const [parsedGames, setParsedGames] = useState<Game[]>([]) const [parsedGames, setParsedGames] = useState<Game[]>([])
console.log(parsedGames)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false) 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) setIsLoading(true)
setError(null) setError(null)
setParsedGames([]) setParsedGames([])
let logFileId = null
try { 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 content = await file.text()
const logLines = content.split('\n') const logLines = content.split('\n')
@@ -727,8 +852,10 @@ export default function LogParser() {
lastEventTime ?? lastProcessedTimestamp ?? currentGame.startDate // Fallback chain lastEventTime ?? lastProcessedTimestamp ?? currentGame.startDate // Fallback chain
} }
currentGame.durationSeconds = currentGame.endDate currentGame.durationSeconds = currentGame.endDate
? (currentGame.endDate.getTime() - currentGame.startDate.getTime()) / ? (currentGame.endDate instanceof Date
1000 ? currentGame.endDate.getTime()
: new Date(currentGame.endDate).getTime() -
currentGame.startDate.getTime()) / 1000
: null : null
games.push(currentGame) games.push(currentGame)
} }
@@ -737,7 +864,26 @@ export default function LogParser() {
setError('No games found in the log file.') 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) { } catch (err) {
console.error('Error parsing log:', err) console.error('Error parsing log:', err)
setError( setError(
@@ -842,16 +988,26 @@ export default function LogParser() {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Started:{' '} Started:{' '}
{formatter.dateTime(game.startDate, { {formatter.dateTime(
dateStyle: 'short', game.startDate instanceof Date
timeStyle: 'short', ? game.startDate
})}{' '} : new Date(game.startDate),
{
dateStyle: 'short',
timeStyle: 'short',
}
)}{' '}
| Ended:{' '} | Ended:{' '}
{game.endDate {game.endDate
? formatter.dateTime(game.endDate, { ? formatter.dateTime(
dateStyle: 'short', game.endDate instanceof Date
timeStyle: 'short', ? game.endDate
}) : new Date(game.endDate),
{
dateStyle: 'short',
timeStyle: 'short',
}
)
: 'N/A'}{' '} : 'N/A'}{' '}
| Duration: {formatDuration(game.durationSeconds)} | Duration: {formatDuration(game.durationSeconds)}
</CardDescription> </CardDescription>
@@ -966,9 +1122,14 @@ export default function LogParser() {
<span <span
className={`${event.text.includes('Opponent') ? 'ml-2' : 'mr-2'} font-mono`} className={`${event.text.includes('Opponent') ? 'ml-2' : 'mr-2'} font-mono`}
> >
{formatter.dateTime(event.timestamp, { {formatter.dateTime(
timeStyle: 'medium', event.timestamp instanceof Date
})} ? event.timestamp
: new Date(event.timestamp),
{
timeStyle: 'medium',
}
)}
</span> </span>
<span>{event.text}</span> <span>{event.text}</span>
</div> </div>

111
src/app/api/logs/route.ts Normal file
View 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 }
)
}
}

View 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 }
)
}
}

View File

@@ -163,3 +163,19 @@ export const releasesRelations = relations(releases, ({ one }) => ({
references: [branches.id], 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],
}),
}))