feat: implement full backend + frontend server detail, settings, and create server pages
Backend: - Complete FastAPI backend with 42+ REST endpoints (auth, servers, config, players, bans, missions, mods, games, system) - Game adapter architecture with Arma 3 as first-class adapter - WebSocket real-time events for status, metrics, logs, players - Background thread system (process monitor, metrics, log tail, RCon poller) - Fernet encryption for sensitive config fields at rest - JWT auth with admin/viewer roles, bcrypt password hashing - SQLite with WAL mode, parameterized queries, migration system - APScheduler cleanup jobs for logs, metrics, events Frontend: - Server Detail page with 7 tabs (overview, config, players, bans, missions, mods, logs) - Settings page with password change and admin user management - Create Server wizard (4-step; known bug: silent validation failure) - New hooks: useServerDetail, useAuth, useGames - New components: ServerHeader, ConfigEditor, PlayerTable, BanTable, MissionList, ModList, LogViewer, PasswordChange, UserManager - WebSocket onEvent callback for real-time log accumulation - 120 unit tests passing (Vitest + React Testing Library) Docs: - Added .gitignore, CLAUDE.md, README.md - Updated FRONTEND.md, ARCHITECTURE.md with current implementation state - Added .env.example for backend configuration Known issues: - Create Server form: "Next" buttons don't validate before advancing, causing silent submit failure when fields are invalid - Config sub-tabs need UX redesign for non-technical users
This commit is contained in:
167
backend/core/threads/log_tail.py
Normal file
167
backend/core/threads/log_tail.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
LogTailThread — tails a server's log file, parses lines via LogParser,
|
||||
and persists parsed entries to the logs table.
|
||||
|
||||
Design notes:
|
||||
- Opens the log file in text mode with errors="replace" to handle encoding issues
|
||||
- Detects log rotation by checking if the inode changes (Unix) or file shrinks (Windows)
|
||||
- On rotation: closes old handle, reopens from position 0
|
||||
- Flushes inserts in batches of up to LOG_BATCH_SIZE per loop iteration
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.dal.log_repository import LogRepository
|
||||
from core.threads.base_thread import BaseServerThread
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOG_BATCH_SIZE = 50
|
||||
_POLL_INTERVAL = 1.0
|
||||
_REOPEN_DELAY = 2.0
|
||||
|
||||
|
||||
class LogTailThread(BaseServerThread):
|
||||
"""
|
||||
Tails a log file for a specific server.
|
||||
|
||||
Args:
|
||||
server_id: The database server ID.
|
||||
log_path: Absolute path to the log file to tail.
|
||||
log_parser: LogParser adapter instance for this game type.
|
||||
broadcast_queue: Optional queue.Queue to push parsed events to BroadcastThread.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_id: int,
|
||||
log_path: str,
|
||||
log_parser,
|
||||
broadcast_queue=None,
|
||||
) -> None:
|
||||
super().__init__(server_id, "LogTail")
|
||||
self._log_path = log_path
|
||||
self._log_parser = log_parser
|
||||
self._broadcast_queue = broadcast_queue
|
||||
self._file_handle = None
|
||||
self._last_inode = None
|
||||
self._last_size = 0
|
||||
|
||||
# ── Lifecycle ──
|
||||
|
||||
def _on_start(self) -> None:
|
||||
self._open_log_file()
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
self._close_file()
|
||||
|
||||
# ── Main loop ──
|
||||
|
||||
def _run_loop(self) -> None:
|
||||
if self._file_handle is None:
|
||||
self._stop_event.wait(timeout=_POLL_INTERVAL)
|
||||
self._open_log_file()
|
||||
return
|
||||
|
||||
if self._detect_rotation():
|
||||
logger.info("[%s] Log rotation detected, reopening", self.name)
|
||||
self._close_file()
|
||||
self._stop_event.wait(timeout=_REOPEN_DELAY)
|
||||
self._open_log_file()
|
||||
return
|
||||
|
||||
lines_read = 0
|
||||
entries_to_insert = []
|
||||
|
||||
while lines_read < _LOG_BATCH_SIZE:
|
||||
line = self._file_handle.readline()
|
||||
if not line:
|
||||
break
|
||||
lines_read += 1
|
||||
line = line.rstrip("\n").rstrip("\r")
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parsed = self._log_parser.parse_line(line)
|
||||
if parsed is not None:
|
||||
entries_to_insert.append(parsed)
|
||||
|
||||
if entries_to_insert and self._db is not None:
|
||||
log_repo = LogRepository(self._db)
|
||||
for entry in entries_to_insert:
|
||||
log_repo.insert(server_id=self.server_id, entry=entry)
|
||||
try:
|
||||
self._db.commit()
|
||||
except Exception as exc:
|
||||
logger.error("[%s] DB commit failed: %s", self.name, exc)
|
||||
self._db.rollback()
|
||||
|
||||
if self._broadcast_queue is not None:
|
||||
for entry in entries_to_insert:
|
||||
try:
|
||||
self._broadcast_queue.put_nowait({
|
||||
"type": "log",
|
||||
"server_id": self.server_id,
|
||||
"data": entry,
|
||||
})
|
||||
except queue.Full:
|
||||
logger.debug("[%s] Broadcast queue full, dropping log event", self.name)
|
||||
|
||||
if lines_read == 0:
|
||||
self._stop_event.wait(timeout=_POLL_INTERVAL)
|
||||
|
||||
# ── File management ──
|
||||
|
||||
def _open_log_file(self) -> None:
|
||||
if not os.path.exists(self._log_path):
|
||||
return
|
||||
try:
|
||||
self._file_handle = open(
|
||||
self._log_path, "r", encoding="utf-8", errors="replace"
|
||||
)
|
||||
# Start tailing from the end of the file
|
||||
self._file_handle.seek(0, 2)
|
||||
self._last_size = self._file_handle.tell()
|
||||
stat = os.stat(self._log_path)
|
||||
self._last_inode = getattr(stat, "st_ino", None)
|
||||
logger.debug("[%s] Opened log file: %s", self.name, self._log_path)
|
||||
except OSError as exc:
|
||||
logger.warning("[%s] Cannot open log file %s: %s", self.name, self._log_path, exc)
|
||||
self._file_handle = None
|
||||
|
||||
def _close_file(self) -> None:
|
||||
if self._file_handle is not None:
|
||||
try:
|
||||
self._file_handle.close()
|
||||
except OSError as exc:
|
||||
logger.debug("[%s] Error closing log file: %s", self.name, exc)
|
||||
self._file_handle = None
|
||||
self._last_inode = None
|
||||
self._last_size = 0
|
||||
|
||||
def _detect_rotation(self) -> bool:
|
||||
"""Returns True if the log file has been rotated."""
|
||||
try:
|
||||
stat = os.stat(self._log_path)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
current_inode = getattr(stat, "st_ino", None)
|
||||
if current_inode is not None and self._last_inode is not None:
|
||||
if current_inode != self._last_inode:
|
||||
return True
|
||||
|
||||
# Windows fallback: file shrunk
|
||||
current_size = stat.st_size
|
||||
if self._file_handle is not None:
|
||||
current_pos = self._file_handle.tell()
|
||||
if current_size < current_pos:
|
||||
return True
|
||||
self._last_size = current_size
|
||||
|
||||
return False
|
||||
Reference in New Issue
Block a user