From 36c31fa790685fe6d800fee1edbedaab45a09d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Mon, 23 Mar 2026 21:13:54 +0100 Subject: [PATCH] feat: add server log preview --- .../server/ServerConfigurationTabs.tsx | 7 +- src/components/server/ServerLogViewer.tsx | 153 ++++++++++++++++++ src/lib/actions/serverLog.ts | 20 +++ src/lib/api/server/serverLog.ts | 13 ++ src/lib/schemas/config.ts | 3 +- src/lib/schemas/websocket.ts | 13 ++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/components/server/ServerLogViewer.tsx create mode 100644 src/lib/actions/serverLog.ts create mode 100644 src/lib/api/server/serverLog.ts diff --git a/src/components/server/ServerConfigurationTabs.tsx b/src/components/server/ServerConfigurationTabs.tsx index a8f1229..8a06b9f 100755 --- a/src/components/server/ServerConfigurationTabs.tsx +++ b/src/components/server/ServerConfigurationTabs.tsx @@ -9,6 +9,7 @@ import { ServerSettingsEditor } from '@/components/configuration/ServerSettingsE import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard'; import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager'; import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable'; +import { ServerLogViewer } from '@/components/server/ServerLogViewer'; import { useState } from 'react'; import { StateHistoryStats } from '@/lib/schemas'; import { Leaderboard } from '@/lib/schemas/leaderboard'; @@ -28,7 +29,8 @@ const tabs = [ { id: ServerTab.eventRules, name: 'Event Rules', icon: 'πŸ“‹' }, { id: ServerTab.settings, name: 'Server Settings', icon: 'πŸ”§' }, { id: ServerTab.leaderboard, name: 'Leaderboard', icon: 'πŸ†' }, - { id: ServerTab.activityLog, name: 'History', icon: 'πŸ“‹' } + { id: ServerTab.activityLog, name: 'History', icon: 'πŸ“‹' }, + { id: ServerTab.logs, name: 'Logs', icon: 'πŸ“„' } ]; const configTabs = new Set([ @@ -143,6 +145,9 @@ export function ServerConfigurationTabs({ case ServerTab.activityLog: return ; + case ServerTab.logs: + return ; + default: return (
diff --git a/src/components/server/ServerLogViewer.tsx b/src/components/server/ServerLogViewer.tsx new file mode 100644 index 0000000..978ac5b --- /dev/null +++ b/src/components/server/ServerLogViewer.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useWebSocket } from '@/lib/websocket/context'; +import type { WebSocketMessage } from '@/lib/schemas/websocket'; +import { getServerLogAction } from '@/lib/actions/serverLog'; + +const MAX_BUFFER = 5000; +const LINE_OPTIONS = [100, 500, 1000, 5000] as const; + +interface ServerLogViewerProps { + serverId: string; +} + +export function ServerLogViewer({ serverId }: ServerLogViewerProps) { + const [lines, setLines] = useState([]); + const [initialLines, setInitialLines] = useState(100); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isPaused, setIsPaused] = useState(false); + + const { associateWithServer, addMessageHandler, removeMessageHandler } = useWebSocket(); + const scrollRef = useRef(null); + const isPausedRef = useRef(false); + + const appendLines = useCallback((newLines: string[]) => { + setLines((prev) => { + const combined = [...prev, ...newLines]; + return combined.length > MAX_BUFFER ? combined.slice(combined.length - MAX_BUFFER) : combined; + }); + }, []); + + const fetchInitial = useCallback( + async (count: number) => { + setIsLoading(true); + setError(null); + const result = await getServerLogAction(serverId, count); + setIsLoading(false); + if (!result.success) { + setError(result.message); + return; + } + setLines(result.lines); + }, + [serverId] + ); + + useEffect(() => { + associateWithServer(serverId); + fetchInitial(initialLines); + }, [serverId, associateWithServer, fetchInitial, initialLines]); + + const handleMessage = useCallback( + (message: WebSocketMessage) => { + if (message.type !== 'log_line') return; + if (message.server_id !== serverId) return; + appendLines([message.data.line]); + }, + [serverId, appendLines] + ); + + useEffect(() => { + addMessageHandler(handleMessage); + return () => { + removeMessageHandler(handleMessage); + }; + }, [addMessageHandler, removeMessageHandler, handleMessage]); + + // Auto-scroll to bottom when new lines arrive, unless paused + useEffect(() => { + if (!isPausedRef.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [lines]); + + const handleScroll = () => { + if (!scrollRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 40; + const shouldPause = !isAtBottom; + isPausedRef.current = shouldPause; + setIsPaused(shouldPause); + }; + + const jumpToBottom = () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + isPausedRef.current = false; + setIsPaused(false); + }; + + const handleLineLimitChange = (e: React.ChangeEvent) => { + const count = Number(e.target.value); + setInitialLines(count); + }; + + return ( +
+
+ + + {lines.length} lines buffered +
+ + {error &&

{error}

} + +
+
+ {isLoading ? ( + Loading… + ) : lines.length === 0 ? ( + No log output yet. + ) : ( + lines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+ + {isPaused && ( +
+
+ Paused + +
+
+ )} +
+
+ ); +} diff --git a/src/lib/actions/serverLog.ts b/src/lib/actions/serverLog.ts new file mode 100644 index 0000000..c50ce1b --- /dev/null +++ b/src/lib/actions/serverLog.ts @@ -0,0 +1,20 @@ +'use server'; + +import { requireAuth } from '@/lib/auth/server'; +import { getServerLog } from '@/lib/api/server/serverLog'; + +export async function getServerLogAction( + serverId: string, + lines: number = 100 +): Promise<{ success: true; lines: string[] } | { success: false; message: string }> { + try { + const session = await requireAuth(); + const data = await getServerLog(session.token!, serverId, lines); + return { success: true, lines: data }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to fetch server log' + }; + } +} diff --git a/src/lib/api/server/serverLog.ts b/src/lib/api/server/serverLog.ts new file mode 100644 index 0000000..584425a --- /dev/null +++ b/src/lib/api/server/serverLog.ts @@ -0,0 +1,13 @@ +import { fetchServerAPI } from './base'; + +export async function getServerLog( + token: string, + serverId: string, + lines: number = 100 +): Promise { + const response = await fetchServerAPI<{ lines: string[] }>( + `/server/${serverId}/log?lines=${lines}`, + token + ); + return response.data?.lines ?? []; +} diff --git a/src/lib/schemas/config.ts b/src/lib/schemas/config.ts index 7046ad5..171d9bd 100755 --- a/src/lib/schemas/config.ts +++ b/src/lib/schemas/config.ts @@ -17,7 +17,8 @@ export enum ServerTab { eventRules = 'eventRules', settings = 'settings', leaderboard = 'leaderboard', - activityLog = 'activityLog' + activityLog = 'activityLog', + logs = 'logs' } export const serverTabSchema = z.enum(ServerTab); diff --git a/src/lib/schemas/websocket.ts b/src/lib/schemas/websocket.ts index 44a11a1..789c5b9 100755 --- a/src/lib/schemas/websocket.ts +++ b/src/lib/schemas/websocket.ts @@ -44,6 +44,13 @@ export const completeDataSchema = z.object({ export type CompleteData = z.infer; +// Log line data schema +export const logLineDataSchema = z.object({ + line: z.string() +}); + +export type LogLineData = z.infer; + // WebSocket message schema using discriminated union export const webSocketMessageSchema = z.discriminatedUnion('type', [ z.object({ @@ -69,6 +76,12 @@ export const webSocketMessageSchema = z.discriminatedUnion('type', [ server_id: z.string(), timestamp: z.number(), data: completeDataSchema + }), + z.object({ + type: z.literal('log_line'), + server_id: z.string(), + timestamp: z.number(), + data: logLineDataSchema }) ]);