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
167 lines
5.4 KiB
Python
167 lines
5.4 KiB
Python
"""
|
|
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 |