Files
Tran G. (Revernomad) Khoa 6511353b55 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
2026-04-17 11:58:34 +07:00

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