From 1a68d34c8114a4e494af002471d8a0c9f6fa6e04 Mon Sep 17 00:00:00 2001 From: Andres Date: Sat, 21 Jun 2025 16:05:22 +0200 Subject: [PATCH] persist logs --- src/app/(home)/admin/logs/logs-client.tsx | 145 ++++++++++++++++ src/app/(home)/admin/logs/page.tsx | 30 ++++ src/app/(home)/log-parser/page.tsx | 193 ++++++++++++++++++++-- src/app/api/logs/route.ts | 111 +++++++++++++ src/app/api/logs/upload/route.ts | 101 +++++++++++ src/server/db/schema.ts | 16 ++ 6 files changed, 580 insertions(+), 16 deletions(-) create mode 100644 src/app/(home)/admin/logs/logs-client.tsx create mode 100644 src/app/(home)/admin/logs/page.tsx create mode 100644 src/app/api/logs/route.ts create mode 100644 src/app/api/logs/upload/route.ts diff --git a/src/app/(home)/admin/logs/logs-client.tsx b/src/app/(home)/admin/logs/logs-client.tsx new file mode 100644 index 0000000..c5e7259 --- /dev/null +++ b/src/app/(home)/admin/logs/logs-client.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( + + + Log Files + + View and manage uploaded log files + + + + {isLoading ? ( +

Loading logs...

+ ) : error ? ( +

{error}

+ ) : logs.length === 0 ? ( +

No logs found

+ ) : ( + + + + File Name + Uploaded By + Date + Actions + + + + {logs.map((log) => ( + + {log.fileName} + + {log.userName || log.userEmail || 'Anonymous'} + + + {new Date(log.createdAt).toLocaleString()} + + + + + + + ))} + +
+ )} +
+
+ ) +} diff --git a/src/app/(home)/admin/logs/page.tsx b/src/app/(home)/admin/logs/page.tsx new file mode 100644 index 0000000..77e7b85 --- /dev/null +++ b/src/app/(home)/admin/logs/page.tsx @@ -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 ( +
+
+

Forbidden

+
+
+ ) + } + + return ( + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/app/(home)/log-parser/page.tsx b/src/app/(home)/log-parser/page.tsx index 4ae369c..7d1e3cd 100644 --- a/src/app/(home)/log-parser/page.tsx +++ b/src/app/(home)/log-parser/page.tsx @@ -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(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([]) + console.log(parsedGames) const [error, setError] = useState(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() { 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)} @@ -966,9 +1122,14 @@ export default function LogParser() { - {formatter.dateTime(event.timestamp, { - timeStyle: 'medium', - })} + {formatter.dateTime( + event.timestamp instanceof Date + ? event.timestamp + : new Date(event.timestamp), + { + timeStyle: 'medium', + } + )} {event.text} diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts new file mode 100644 index 0000000..ac46c0b --- /dev/null +++ b/src/app/api/logs/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/logs/upload/route.ts b/src/app/api/logs/upload/route.ts new file mode 100644 index 0000000..25a2921 --- /dev/null +++ b/src/app/api/logs/upload/route.ts @@ -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 } + ) + } +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index cba7aae..8521e67 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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], + }), +}))