User Log and Persistent Faction Information

This commit is contained in:
2026-01-27 14:48:46 -05:00
parent 4ae3a9eb17
commit 4850c16b87
39 changed files with 782 additions and 71 deletions

Binary file not shown.

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import json import json
def load_from_json(): def load_from_json():
"""Load config from JSON file if it exists""" #Load config from JSON file if it exists
path = Path("data/config.json") path = Path("data/config.json")
if not path.exists(): if not path.exists():
return {} return {}

View File

@@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles
from services.bot_assignment import BotAssignmentManager from services.bot_assignment import BotAssignmentManager
# Import routers # Import routers
from routers import pages, factions, assignments, discord_mappings, config, auth from routers import pages, factions, assignments, discord_mappings, config, auth, activity
from routers import bot as bot_router from routers import bot as bot_router
@@ -31,6 +31,7 @@ app.include_router(assignments.router)
app.include_router(bot_router.router) app.include_router(bot_router.router)
app.include_router(discord_mappings.router) app.include_router(discord_mappings.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(activity.router)
# Discord Bot Setup # Discord Bot Setup

Binary file not shown.

52
routers/activity.py Normal file
View File

@@ -0,0 +1,52 @@
"""Activity log endpoints."""
from fastapi import APIRouter, Request
from pydantic import BaseModel
from utils.auth import get_current_user
from services.activity_log import activity_logger
router = APIRouter(prefix="/api", tags=["activity"])
class LogActionRequest(BaseModel):
action: str
details: str = ""
@router.get("/active_users")
async def get_active_users(request: Request):
"""Get list of currently active users"""
# Update current user's activity
try:
user_info = get_current_user(request)
username = user_info.get("username", "Unknown")
await activity_logger.update_user_activity(username)
except:
pass
users = await activity_logger.get_active_users(timeout_minutes=30)
return {"users": users}
@router.get("/activity_logs")
async def get_activity_logs(request: Request, limit: int = 100):
"""Get recent activity logs"""
# Update current user's activity
try:
user_info = get_current_user(request)
username = user_info.get("username", "Unknown")
await activity_logger.update_user_activity(username)
except:
pass
logs = await activity_logger.get_logs(limit=limit)
return {"logs": logs}
@router.post("/log_action")
async def log_action(request: Request, req: LogActionRequest):
"""Log a user action"""
user_info = get_current_user(request)
username = user_info.get("username", "Unknown")
await activity_logger.log_action(username, req.action, req.details)
return {"status": "ok"}

View File

@@ -20,7 +20,7 @@ class LoginRequest(BaseModel):
def get_client_ip(request: Request) -> str: def get_client_ip(request: Request) -> str:
"""Get client IP address from request""" #Get client IP address from request
# Check X-Forwarded-For header first (for proxy/load balancer) # Check X-Forwarded-For header first (for proxy/load balancer)
forwarded = request.headers.get("X-Forwarded-For") forwarded = request.headers.get("X-Forwarded-For")
if forwarded: if forwarded:
@@ -29,7 +29,7 @@ def get_client_ip(request: Request) -> str:
def is_locked_out(ip: str) -> bool: def is_locked_out(ip: str) -> bool:
"""Check if IP is currently locked out""" #Check if IP is currently locked out
if ip not in failed_attempts: if ip not in failed_attempts:
return False return False
@@ -49,7 +49,7 @@ def is_locked_out(ip: str) -> bool:
def record_failed_attempt(ip: str): def record_failed_attempt(ip: str):
"""Record a failed login attempt""" #Record a failed login attempt
now = datetime.now() now = datetime.now()
if ip not in failed_attempts: if ip not in failed_attempts:
@@ -64,13 +64,13 @@ def record_failed_attempt(ip: str):
def clear_failed_attempts(ip: str): def clear_failed_attempts(ip: str):
"""Clear failed attempts for an IP after successful login""" #Clear failed attempts for an IP after successful login
if ip in failed_attempts: if ip in failed_attempts:
del failed_attempts[ip] del failed_attempts[ip]
def create_jwt_token(username: str) -> str: def create_jwt_token(username: str) -> str:
"""Create a JWT token for the user""" #Create a JWT token for the user
expiration = datetime.utcnow() + timedelta(days=7) # Token valid for 7 days expiration = datetime.utcnow() + timedelta(days=7) # Token valid for 7 days
payload = { payload = {
"username": username, "username": username,
@@ -81,7 +81,7 @@ def create_jwt_token(username: str) -> str:
def verify_jwt_token(token: str) -> dict: def verify_jwt_token(token: str) -> dict:
"""Verify and decode a JWT token""" #Verify and decode a JWT token
try: try:
payload = jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"]) payload = jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"])
return payload return payload
@@ -93,7 +93,7 @@ def verify_jwt_token(token: str) -> dict:
@router.post("/login") @router.post("/login")
async def login(request: Request, response: Response, req: LoginRequest): async def login(request: Request, response: Response, req: LoginRequest):
"""Login endpoint with rate limiting""" #Login endpoint with rate limiting
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
# Check if IP is locked out # Check if IP is locked out
@@ -144,14 +144,14 @@ async def login(request: Request, response: Response, req: LoginRequest):
@router.post("/logout") @router.post("/logout")
async def logout(response: Response): async def logout(response: Response):
"""Logout endpoint""" #Logout endpoint
response.delete_cookie("auth_token") response.delete_cookie("auth_token")
return {"status": "success"} return {"status": "success"}
@router.get("/status") @router.get("/status")
async def auth_status(request: Request): async def auth_status(request: Request):
"""Check authentication status""" #Check authentication status
token = request.cookies.get("auth_token") token = request.cookies.get("auth_token")
if not token: if not token:

View File

@@ -20,7 +20,7 @@ def set_assignment_manager(manager: BotAssignmentManager):
@router.get("/bot_status") @router.get("/bot_status")
async def api_bot_status(): async def api_bot_status():
"""Get current bot status""" #Get current bot status
active_count = len(assignment_manager.active_targets) if assignment_manager else 0 active_count = len(assignment_manager.active_targets) if assignment_manager else 0
return { return {
"bot_running": STATE.bot_running, "bot_running": STATE.bot_running,

View File

@@ -10,7 +10,7 @@ router = APIRouter(prefix="/api", tags=["config"])
def reload_config_from_file(): def reload_config_from_file():
"""Reload config values from JSON into module globals""" #Reload config values from JSON into module globals
path = Path("data/config.json") path = Path("data/config.json")
if not path.exists(): if not path.exists():
return return
@@ -29,7 +29,7 @@ def reload_config_from_file():
@router.get("/config") @router.get("/config")
async def get_config(): async def get_config():
"""Get all config values (with sensitive values masked)""" #Get all config values (with sensitive values masked)
path = Path("data/config.json") path = Path("data/config.json")
# Default config values from config.py # Default config values from config.py
@@ -70,7 +70,7 @@ async def get_config():
@router.post("/config") @router.post("/config")
async def update_config(req: ConfigUpdateRequest): async def update_config(req: ConfigUpdateRequest):
"""Update a single config value""" #Update a single config value
path = Path("data/config.json") path = Path("data/config.json")
# Valid config keys (from config.py) # Valid config keys (from config.py)

View File

@@ -14,14 +14,14 @@ assignment_manager: Optional[BotAssignmentManager] = None
def set_assignment_manager(manager: BotAssignmentManager): def set_assignment_manager(manager: BotAssignmentManager):
"""Set the global assignment manager reference.""" #Set the global assignment manager reference.
global assignment_manager global assignment_manager
assignment_manager = manager assignment_manager = manager
@router.get("/discord_mappings") @router.get("/discord_mappings")
async def get_discord_mappings(): async def get_discord_mappings():
"""Get all Torn ID to Discord ID mappings""" #Get all Torn ID to Discord ID mappings
path = Path("data/discord_mapping.json") path = Path("data/discord_mapping.json")
if not path.exists(): if not path.exists():
return {"mappings": {}} return {"mappings": {}}
@@ -33,7 +33,7 @@ async def get_discord_mappings():
@router.post("/discord_mapping") @router.post("/discord_mapping")
async def add_discord_mapping(req: DiscordMappingRequest): async def add_discord_mapping(req: DiscordMappingRequest):
"""Add or update a Torn ID to Discord ID mapping""" #Add or update a Torn ID to Discord ID mapping
path = Path("data/discord_mapping.json") path = Path("data/discord_mapping.json")
# Load existing mappings # Load existing mappings
@@ -59,7 +59,7 @@ async def add_discord_mapping(req: DiscordMappingRequest):
@router.delete("/discord_mapping/{torn_id}") @router.delete("/discord_mapping/{torn_id}")
async def remove_discord_mapping(torn_id: int): async def remove_discord_mapping(torn_id: int):
"""Remove a Discord mapping""" #Remove a Discord mapping
path = Path("data/discord_mapping.json") path = Path("data/discord_mapping.json")
if not path.exists(): if not path.exists():

View File

@@ -1,11 +1,18 @@
"""Faction data population and status management endpoints.""" #Faction data population and status management endpoints.
import json import json
from pathlib import Path from pathlib import Path
from fastapi import APIRouter from fastapi import APIRouter
from models import FactionRequest from models import FactionRequest
from services.server_state import STATE from services.server_state import STATE
from services.torn_api import populate_friendly, populate_enemy, start_friendly_status_loop, start_enemy_status_loop from services.torn_api import (
populate_friendly,
populate_enemy,
start_friendly_status_loop,
start_enemy_status_loop,
stop_friendly_status_loop,
stop_enemy_status_loop
)
from utils import load_json_list from utils import load_json_list
router = APIRouter(prefix="/api", tags=["factions"]) router = APIRouter(prefix="/api", tags=["factions"])
@@ -73,3 +80,30 @@ async def api_enemy_status():
return {} return {}
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
@router.post("/stop_friendly_status")
async def api_stop_friendly_status():
await stop_friendly_status_loop()
return {"status": "friendly status loop stopped"}
@router.post("/stop_enemy_status")
async def api_stop_enemy_status():
await stop_enemy_status_loop()
return {"status": "enemy status loop stopped"}
@router.get("/dashboard_state")
async def get_dashboard_state():
"""Get current dashboard state for restoring UI on page load"""
return {
"friendly_faction_id": STATE.friendly_faction_id,
"enemy_faction_id": STATE.enemy_faction_id,
"friendly_members": [m.model_dump() for m in STATE.friendly.values()],
"enemy_members": [m.model_dump() for m in STATE.enemy.values()],
"friendly_status_interval": STATE.friendly_status_interval,
"enemy_status_interval": STATE.enemy_status_interval,
"friendly_status_running": STATE.friendly_status_running,
"enemy_status_running": STATE.enemy_status_running
}

View File

@@ -10,7 +10,7 @@ templates = Jinja2Templates(directory="templates")
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
"""Login page""" #Login page
# If already authenticated, redirect to dashboard # If already authenticated, redirect to dashboard
if check_auth(request): if check_auth(request):
return RedirectResponse(url="/", status_code=302) return RedirectResponse(url="/", status_code=302)
@@ -19,7 +19,7 @@ async def login_page(request: Request):
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def dashboard(request: Request):
"""Dashboard page - requires authentication""" #Dashboard page - requires authentication
if not check_auth(request): if not check_auth(request):
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
print(">>> DASHBOARD ROUTE LOADED") print(">>> DASHBOARD ROUTE LOADED")
@@ -28,7 +28,15 @@ async def dashboard(request: Request):
@router.get("/config", response_class=HTMLResponse) @router.get("/config", response_class=HTMLResponse)
async def config_page(request: Request): async def config_page(request: Request):
"""Config page - requires authentication""" #Config page - requires authentication
if not check_auth(request): if not check_auth(request):
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("config.html", {"request": request}) return templates.TemplateResponse("config.html", {"request": request})
@router.get("/users-log", response_class=HTMLResponse)
async def users_log_page(request: Request):
#Users/Log page - requires authentication
if not check_auth(request):
return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("users_log.html", {"request": request})

Binary file not shown.

51
services/activity_log.py Normal file
View File

@@ -0,0 +1,51 @@
# services/activity_log.py
"""Activity logging and user tracking system."""
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from collections import deque
import asyncio
class ActivityLogger:
def __init__(self, max_logs: int = 1000):
self.logs: deque = deque(maxlen=max_logs) # Keep last 1000 logs
self.active_users: Dict[str, datetime] = {} # username -> last_activity_time
self.lock = asyncio.Lock()
async def log_action(self, username: str, action: str, details: str = ""):
"""Log a user action"""
async with self.lock:
timestamp = datetime.now()
log_entry = {
"timestamp": timestamp.isoformat(),
"username": username,
"action": action,
"details": details
}
self.logs.append(log_entry)
# Update user activity
self.active_users[username] = timestamp
async def get_logs(self, limit: int = 100) -> List[Dict]:
"""Get recent logs"""
async with self.lock:
# Return most recent logs first
return list(self.logs)[-limit:][::-1]
async def get_active_users(self, timeout_minutes: int = 30) -> List[str]:
"""Get list of users active in the last N minutes"""
async with self.lock:
cutoff = datetime.now() - timedelta(minutes=timeout_minutes)
active = [
username for username, last_activity in self.active_users.items()
if last_activity >= cutoff
]
return sorted(active)
async def update_user_activity(self, username: str):
"""Update user's last activity timestamp"""
async with self.lock:
self.active_users[username] = datetime.now()
# Global instance
activity_logger = ActivityLogger()

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
from datetime import datetime from datetime import datetime
from services.server_state import STATE from services.server_state import STATE
from services.activity_log import activity_logger
from config import ASSIGNMENT_TIMEOUT, ASSIGNMENT_REMINDER, ALLOWED_CHANNEL_ID, CHAIN_TIMER_THRESHOLD, TORN_API_KEY from config import ASSIGNMENT_TIMEOUT, ASSIGNMENT_REMINDER, ALLOWED_CHANNEL_ID, CHAIN_TIMER_THRESHOLD, TORN_API_KEY
class BotAssignmentManager: class BotAssignmentManager:
@@ -28,7 +29,7 @@ class BotAssignmentManager:
self.load_discord_mapping() self.load_discord_mapping()
def load_discord_mapping(self): def load_discord_mapping(self):
"""Load Torn ID to Discord ID mapping from JSON file""" #Load Torn ID to Discord ID mapping from JSON file
path = Path("data/discord_mapping.json") path = Path("data/discord_mapping.json")
if not path.exists(): if not path.exists():
print("No discord_mapping.json found") print("No discord_mapping.json found")
@@ -44,11 +45,11 @@ class BotAssignmentManager:
print(f"Error loading discord mapping: {e}") print(f"Error loading discord mapping: {e}")
def get_discord_id(self, torn_id: int) -> Optional[int]: def get_discord_id(self, torn_id: int) -> Optional[int]:
"""Get Discord user ID for a Torn player ID""" #Get Discord user ID for a Torn player ID#
return self.discord_mapping.get(torn_id) return self.discord_mapping.get(torn_id)
async def fetch_chain_timer(self) -> Optional[int]: async def fetch_chain_timer(self) -> Optional[int]:
"""Fetch chain timeout from Torn API. Returns seconds remaining, or None if no chain or error.""" #Fetch chain timeout from Torn API. Returns seconds remaining, or None if no chain or error.
if not STATE.friendly_faction_id: if not STATE.friendly_faction_id:
return None return None
@@ -69,7 +70,7 @@ class BotAssignmentManager:
return None return None
async def start(self): async def start(self):
"""Start the bot assignment loop""" #Start the bot assignment loop
if self.running: if self.running:
print("WARNING: Bot assignment already running") print("WARNING: Bot assignment already running")
return return
@@ -94,18 +95,16 @@ class BotAssignmentManager:
print("Bot assignment stopped") print("Bot assignment stopped")
def friendly_has_active_target(self, friendly_id: int) -> bool: def friendly_has_active_target(self, friendly_id: int) -> bool:
"""Check if a friendly player already has an active target assigned""" #Check if a friendly player already has an active target assigned
for target_data in self.active_targets.values(): for target_data in self.active_targets.values():
if target_data["friendly_id"] == friendly_id: if target_data["friendly_id"] == friendly_id:
return True return True
return False return False
def get_next_friendly_in_group(self, group_id: str, friendly_ids: list) -> Optional[int]: def get_next_friendly_in_group(self, group_id: str, friendly_ids: list) -> Optional[int]:
""" #Get the next friendly in the group who should receive a target.
Get the next friendly in the group who should receive a target. #Prioritizes members with fewer hits.
Prioritizes members with fewer hits. #Only returns friendlies who DON'T already have an active assignment.
Only returns friendlies who DON'T already have an active assignment.
"""
if not friendly_ids: if not friendly_ids:
return None return None
@@ -130,10 +129,8 @@ class BotAssignmentManager:
return friendly_hits[0][0] return friendly_hits[0][0]
def get_next_enemy_in_group(self, group_id: str, enemy_ids: list) -> Optional[int]: def get_next_enemy_in_group(self, group_id: str, enemy_ids: list) -> Optional[int]:
""" #Get the next enemy in the group who needs to be assigned.
Get the next enemy in the group who needs to be assigned. #Returns None if all enemies are already assigned or not attackable.
Returns None if all enemies are already assigned or not attackable.
"""
for eid in enemy_ids: for eid in enemy_ids:
key = f"{group_id}:{eid}" key = f"{group_id}:{eid}"
# If enemy is already assigned, skip them # If enemy is already assigned, skip them
@@ -150,7 +147,7 @@ class BotAssignmentManager:
return None return None
async def check_chain_timer(self): async def check_chain_timer(self):
"""Check chain timer and update chain state""" #Check chain timer and update chain state
timeout = await self.fetch_chain_timer() timeout = await self.fetch_chain_timer()
if timeout is None: if timeout is None:
@@ -173,6 +170,8 @@ class BotAssignmentManager:
self.assigned_friendlies.clear() self.assigned_friendlies.clear()
self.current_group_index = 0 self.current_group_index = 0
self.chain_warning_sent = False self.chain_warning_sent = False
# Log to activity
await activity_logger.log_action("System", "Chain Mode Activated", f"Timer: {timeout}s, Threshold: {threshold_seconds}s")
# Check if chain expired # Check if chain expired
elif timeout > threshold_seconds and self.chain_active: elif timeout > threshold_seconds and self.chain_active:
@@ -181,6 +180,8 @@ class BotAssignmentManager:
self.assigned_friendlies.clear() self.assigned_friendlies.clear()
self.current_group_index = 0 self.current_group_index = 0
self.chain_warning_sent = False self.chain_warning_sent = False
# Log to activity
await activity_logger.log_action("System", "Chain Mode Deactivated", f"Timer: {timeout}s exceeded threshold")
# Check if chain expired (timeout = 0) # Check if chain expired (timeout = 0)
elif timeout == 0 and self.chain_active: elif timeout == 0 and self.chain_active:
@@ -189,14 +190,18 @@ class BotAssignmentManager:
self.assigned_friendlies.clear() self.assigned_friendlies.clear()
self.current_group_index = 0 self.current_group_index = 0
self.chain_warning_sent = False self.chain_warning_sent = False
# Log to activity
await activity_logger.log_action("System", "Chain Expired", "Chain timer reached 0")
# Send 30-second warning # Send 30-second warning
if self.chain_active and timeout <= 30 and not self.chain_warning_sent: if self.chain_active and timeout <= 30 and not self.chain_warning_sent:
await self.send_chain_expiration_warning() await self.send_chain_expiration_warning()
self.chain_warning_sent = True self.chain_warning_sent = True
# Log to activity
await activity_logger.log_action("System", "Chain Expiration Warning", "30 seconds remaining")
async def send_chain_expiration_warning(self): async def send_chain_expiration_warning(self):
"""Send @here alert that chain is about to expire""" #Send @here alert that chain is about to expire
try: try:
channel = self.bot.get_channel(ALLOWED_CHANNEL_ID) channel = self.bot.get_channel(ALLOWED_CHANNEL_ID)
if channel and STATE.friendly_faction_id: if channel and STATE.friendly_faction_id:
@@ -208,7 +213,7 @@ class BotAssignmentManager:
print(f"Error sending chain warning: {e}") print(f"Error sending chain warning: {e}")
async def assign_next_chain_hit(self): async def assign_next_chain_hit(self):
"""Assign next hit in round-robin fashion through groups""" #Assign next hit in round-robin fashion through groups
# Only assign if there's no active assignment waiting # Only assign if there's no active assignment waiting
if self.active_targets: if self.active_targets:
return # Wait for current assignment to complete return # Wait for current assignment to complete
@@ -265,7 +270,7 @@ class BotAssignmentManager:
self.current_group_index = 0 self.current_group_index = 0
async def assignment_loop(self): async def assignment_loop(self):
"""Main loop that monitors chain timer and assigns targets""" #Main loop that monitors chain timer and assigns targets
await self.bot.wait_until_ready() await self.bot.wait_until_ready()
print("Bot is ready, assignment loop running with chain timer monitoring") print("Bot is ready, assignment loop running with chain timer monitoring")
@@ -307,7 +312,7 @@ class BotAssignmentManager:
await asyncio.sleep(5) await asyncio.sleep(5)
async def assign_target(self, group_id: str, friendly_id: int, enemy_id: int): async def assign_target(self, group_id: str, friendly_id: int, enemy_id: int):
"""Assign an enemy target to a friendly player""" #Assign an enemy target to a friendly player
# Get member data # Get member data
friendly = STATE.friendly.get(friendly_id) friendly = STATE.friendly.get(friendly_id)
enemy = STATE.enemy.get(enemy_id) enemy = STATE.enemy.get(enemy_id)
@@ -367,6 +372,8 @@ class BotAssignmentManager:
if channel: if channel:
await channel.send(message) await channel.send(message)
print(f"Assigned {enemy.name} to {friendly.name} (Discord: {discord_user.name})") print(f"Assigned {enemy.name} to {friendly.name} (Discord: {discord_user.name})")
# Log to activity
await activity_logger.log_action("System", "Hit Assigned", f"{friendly.name} -> {enemy.name} (Level {enemy.level})")
else: else:
print(f"Assignment channel {ALLOWED_CHANNEL_ID} not found") print(f"Assignment channel {ALLOWED_CHANNEL_ID} not found")
self.active_targets[key]["failed"] = True self.active_targets[key]["failed"] = True
@@ -375,7 +382,7 @@ class BotAssignmentManager:
self.active_targets[key]["failed"] = True self.active_targets[key]["failed"] = True
async def monitor_active_targets(self): async def monitor_active_targets(self):
"""Monitor active targets for status changes or timeouts""" #Monitor active targets for status changes or timeouts
now = datetime.now() now = datetime.now()
to_reassign = [] to_reassign = []
@@ -404,7 +411,11 @@ class BotAssignmentManager:
if friendly_id in STATE.friendly: if friendly_id in STATE.friendly:
# Increment hit count # Increment hit count
STATE.friendly[friendly_id].hits += 1 STATE.friendly[friendly_id].hits += 1
print(f"SUCCESS: {STATE.friendly[friendly_id].name} successfully hospitalized {enemy.name}") friendly_name = STATE.friendly[friendly_id].name
enemy_name = enemy.name
print(f"SUCCESS: {friendly_name} successfully hospitalized {enemy_name}")
# Log to activity
await activity_logger.log_action("System", "Hit Completed", f"{friendly_name} hospitalized {enemy_name}")
# Remove from active targets # Remove from active targets
del self.active_targets[key] del self.active_targets[key]
@@ -412,7 +423,13 @@ class BotAssignmentManager:
# Check if enemy is no longer attackable (traveling, etc.) # Check if enemy is no longer attackable (traveling, etc.)
if enemy.status.lower() != "okay": if enemy.status.lower() != "okay":
print(f"Target {enemy.name} is now '{enemy.status}' - removing assignment") friendly_id = data["friendly_id"]
friendly_name = STATE.friendly.get(friendly_id).name if friendly_id in STATE.friendly else "Unknown"
enemy_name = enemy.name
enemy_status = enemy.status
print(f"Target {enemy_name} is now '{enemy_status}' - removing assignment")
# Log to activity
await activity_logger.log_action("System", "Hit Dropped", f"{friendly_name}'s target {enemy_name} became {enemy_status}")
del self.active_targets[key] del self.active_targets[key]
continue continue
@@ -445,7 +462,11 @@ class BotAssignmentManager:
friendly_ids = STATE.groups[group_id].get("friendly", []) friendly_ids = STATE.groups[group_id].get("friendly", [])
friendly_id = self.get_next_friendly_in_group(group_id, friendly_ids) friendly_id = self.get_next_friendly_in_group(group_id, friendly_ids)
if friendly_id: if friendly_id:
enemy_name = STATE.enemy.get(enemy_id).name if enemy_id in STATE.enemy else f"Enemy {enemy_id}"
friendly_name = STATE.friendly.get(friendly_id).name if friendly_id in STATE.friendly else f"Friendly {friendly_id}"
print(f"Reassigning enemy {enemy_id} (timeout)") print(f"Reassigning enemy {enemy_id} (timeout)")
# Log to activity
await activity_logger.log_action("System", "Hit Timed Out", f"Reassigning {enemy_name} to {friendly_name} (previous assignee timed out)")
await self.assign_target(group_id, friendly_id, enemy_id) await self.assign_target(group_id, friendly_id, enemy_id)
# Global instance (will be initialized with bot in main.py) # Global instance (will be initialized with bot in main.py)

View File

@@ -2,10 +2,9 @@ import aiohttp
from config import FFSCOUTER_KEY from config import FFSCOUTER_KEY
async def fetch_batch_stats(ids: list[int]): async def fetch_batch_stats(ids: list[int]):
""" #Fetches predicted stats for a list of Torn IDs in a single FFScouter request.
Fetches predicted stats for a list of Torn IDs in a single FFScouter request. #Returns dict keyed by player_id.
Returns dict keyed by player_id.
"""
if not ids: if not ids:
return {} return {}

View File

@@ -30,6 +30,13 @@ class ServerState:
# faction IDs for API monitoring # faction IDs for API monitoring
self.friendly_faction_id: Optional[int] = None self.friendly_faction_id: Optional[int] = None
self.enemy_faction_id: Optional[int] = None
# status refresh state
self.friendly_status_interval: int = 10
self.friendly_status_running: bool = False
self.enemy_status_interval: int = 10
self.enemy_status_running: bool = False
# concurrency lock for async safety # concurrency lock for async safety
self.lock = asyncio.Lock() self.lock = asyncio.Lock()

View File

@@ -17,10 +17,10 @@ enemy_lock = asyncio.Lock()
# Populate faction (memory only) # Populate faction (memory only)
async def populate_faction(faction_id: int, kind: str): async def populate_faction(faction_id: int, kind: str):
"""
Fetch members + FFScouter estimates once and store in STATE. #Fetch members + FFScouter estimates once and store in STATE.
kind: "friendly" or "enemy" #kind: "friendly" or "enemy"
"""
url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}" url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -65,9 +65,7 @@ async def populate_faction(faction_id: int, kind: str):
# Status refresh loop # Status refresh loop
async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, interval: int): async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, interval: int):
""" #Periodically refresh member statuses in STATE.
Periodically refresh member statuses in STATE.
"""
while True: while True:
try: try:
url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}" url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}"
@@ -100,6 +98,8 @@ async def populate_friendly(faction_id: int):
return await populate_faction(faction_id, "friendly") return await populate_faction(faction_id, "friendly")
async def populate_enemy(faction_id: int): async def populate_enemy(faction_id: int):
# Store enemy faction ID
STATE.enemy_faction_id = faction_id
return await populate_faction(faction_id, "enemy") return await populate_faction(faction_id, "enemy")
async def start_friendly_status_loop(faction_id: int, interval: int): async def start_friendly_status_loop(faction_id: int, interval: int):
@@ -109,6 +109,9 @@ async def start_friendly_status_loop(faction_id: int, interval: int):
friendly_status_task = asyncio.create_task( friendly_status_task = asyncio.create_task(
refresh_status_loop(faction_id, "friendly", friendly_lock, interval) refresh_status_loop(faction_id, "friendly", friendly_lock, interval)
) )
# Save state
STATE.friendly_status_interval = interval
STATE.friendly_status_running = True
async def start_enemy_status_loop(faction_id: int, interval: int): async def start_enemy_status_loop(faction_id: int, interval: int):
global enemy_status_task global enemy_status_task
@@ -117,3 +120,26 @@ async def start_enemy_status_loop(faction_id: int, interval: int):
enemy_status_task = asyncio.create_task( enemy_status_task = asyncio.create_task(
refresh_status_loop(faction_id, "enemy", enemy_lock, interval) refresh_status_loop(faction_id, "enemy", enemy_lock, interval)
) )
# Save state
STATE.enemy_status_interval = interval
STATE.enemy_status_running = True
async def stop_friendly_status_loop():
global friendly_status_task
if friendly_status_task and not friendly_status_task.done():
friendly_status_task.cancel()
try:
await friendly_status_task
except asyncio.CancelledError:
pass
STATE.friendly_status_running = False
async def stop_enemy_status_loop():
global enemy_status_task
if enemy_status_task and not enemy_status_task.done():
enemy_status_task.cancel()
try:
await enemy_status_task
except asyncio.CancelledError:
pass
STATE.enemy_status_running = False

View File

@@ -23,6 +23,21 @@ function toInt(v) {
return Number.isNaN(n) ? null : n; return Number.isNaN(n) ? null : n;
} }
// ---------------------------
// Activity logging
// ---------------------------
async function logAction(action, details = "") {
try {
await fetch("/api/log_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, details })
});
} catch (err) {
console.error("Failed to log action:", err);
}
}
// --------------------------- // ---------------------------
// Status CSS helpers // Status CSS helpers
// --------------------------- // ---------------------------
@@ -428,6 +443,11 @@ function setupDropZones() {
if (prev) prev.removeChild(member.domElement); if (prev) prev.removeChild(member.domElement);
zone.appendChild(member.domElement); zone.appendChild(member.domElement);
} }
// Log the assignment
if (member) {
await logAction("Assigned Member to Group", `${member.name} (${kind}) -> Group ${groupKey}`);
}
} else { } else {
console.warn("Unexpected zone id format", zone.id); console.warn("Unexpected zone id format", zone.id);
} }
@@ -443,6 +463,11 @@ function setupDropZones() {
if (prev) prev.removeChild(member.domElement); if (prev) prev.removeChild(member.domElement);
container.appendChild(member.domElement); container.appendChild(member.domElement);
} }
// Log the removal
if (member) {
await logAction("Removed Member from Group", `${member.name} (${kind})`);
}
} }
}); });
@@ -516,6 +541,9 @@ async function populateFriendly() {
// Refresh assignments & status UI // Refresh assignments & status UI
await loadMembers("enemy"); // in case population changed cross lists await loadMembers("enemy"); // in case population changed cross lists
await pollAssignments(); await pollAssignments();
// Log the action
await logAction("Populated Friendly Faction", `Faction ID: ${id}, Members: ${data.members ? data.members.length : 0}`);
} catch (err) { } catch (err) {
console.error("populateFriendly error:", err); console.error("populateFriendly error:", err);
} }
@@ -583,6 +611,9 @@ async function populateEnemy() {
// Refresh assignments & status UI // Refresh assignments & status UI
await loadMembers("friendly"); await loadMembers("friendly");
await pollAssignments(); await pollAssignments();
// Log the action
await logAction("Populated Enemy Faction", `Faction ID: ${id}, Members: ${data.members ? data.members.length : 0}`);
} catch (err) { } catch (err) {
console.error("populateEnemy error:", err); console.error("populateEnemy error:", err);
} }
@@ -620,6 +651,9 @@ async function toggleFriendlyStatus() {
btn.textContent = "Start"; btn.textContent = "Start";
btn.dataset.running = "false"; btn.dataset.running = "false";
btn.style.backgroundColor = ""; btn.style.backgroundColor = "";
// Notify server that status refresh stopped
await fetch("/api/stop_friendly_status", { method: "POST" });
await logAction("Stopped Friendly Status Refresh");
return; return;
} }
@@ -637,6 +671,7 @@ async function toggleFriendlyStatus() {
btn.textContent = "Stop"; btn.textContent = "Stop";
btn.dataset.running = "true"; btn.dataset.running = "true";
btn.style.backgroundColor = "#ff6b6b"; btn.style.backgroundColor = "#ff6b6b";
await logAction("Started Friendly Status Refresh", `Interval: ${interval}s`);
} }
async function toggleEnemyStatus() { async function toggleEnemyStatus() {
@@ -647,6 +682,9 @@ async function toggleEnemyStatus() {
btn.textContent = "Start"; btn.textContent = "Start";
btn.dataset.running = "false"; btn.dataset.running = "false";
btn.style.backgroundColor = ""; btn.style.backgroundColor = "";
// Notify server that status refresh stopped
await fetch("/api/stop_enemy_status", { method: "POST" });
await logAction("Stopped Enemy Status Refresh");
return; return;
} }
@@ -664,6 +702,7 @@ async function toggleEnemyStatus() {
btn.textContent = "Stop"; btn.textContent = "Stop";
btn.dataset.running = "true"; btn.dataset.running = "true";
btn.style.backgroundColor = "#ff6b6b"; btn.style.backgroundColor = "#ff6b6b";
await logAction("Started Enemy Status Refresh", `Interval: ${interval}s`);
} }
// --------------------------- // ---------------------------
@@ -694,6 +733,9 @@ async function toggleBotControl() {
btn.style.backgroundColor = data.bot_running ? "#ff4444" : "#4CAF50"; btn.style.backgroundColor = data.bot_running ? "#ff4444" : "#4CAF50";
console.log(`Bot ${data.bot_running ? "started" : "stopped"}`); console.log(`Bot ${data.bot_running ? "started" : "stopped"}`);
// Log the action
await logAction(data.bot_running ? "Started Bot" : "Stopped Bot");
} catch (err) { } catch (err) {
console.error("toggleBotControl error:", err); console.error("toggleBotControl error:", err);
} }
@@ -707,6 +749,8 @@ async function resetGroups() {
await clearAssignmentsOnServer(); await clearAssignmentsOnServer();
// reload assignments & UI // reload assignments & UI
await pollAssignments(); await pollAssignments();
// Log the action
await logAction("Reset All Groups");
} }
// --------------------------- // ---------------------------
@@ -765,6 +809,104 @@ async function handleLogout() {
} }
} }
// ---------------------------
// Restore dashboard state from server
// ---------------------------
async function restoreDashboardState() {
try {
const res = await fetch("/api/dashboard_state", { cache: "no-store" });
if (!res.ok) {
console.log("No dashboard state to restore");
return;
}
const state = await res.json();
console.log("Restoring dashboard state:", state);
// Restore friendly faction
if (state.friendly_faction_id && state.friendly_members && state.friendly_members.length > 0) {
document.getElementById("friendly-id").value = state.friendly_faction_id;
// Load members into UI
for (const m of state.friendly_members) {
const newMember = {
id: m.id,
name: m.name,
level: m.level,
estimate: m.estimate,
status: m.status || "Unknown",
hits: m.hits || 0,
domElement: null
};
friendlyMembers.set(m.id, newMember);
const card = createMemberCard(newMember, "friendly");
friendlyContainer.appendChild(card);
}
console.log(`Restored ${state.friendly_members.length} friendly members`);
// Restore status refresh if it was running
if (state.friendly_status_running) {
const interval = state.friendly_status_interval || 10;
document.getElementById("friendly-refresh-interval").value = interval;
const btn = document.getElementById("friendly-status-btn");
friendlyStatusIntervalHandle = setInterval(() => refreshStatus("friendly"), interval * 1000);
refreshStatus("friendly");
btn.textContent = "Stop";
btn.dataset.running = "true";
btn.style.backgroundColor = "#ff6b6b";
console.log(`Restored friendly status refresh (${interval}s)`);
}
}
// Restore enemy faction
if (state.enemy_faction_id && state.enemy_members && state.enemy_members.length > 0) {
document.getElementById("enemy-id").value = state.enemy_faction_id;
// Load members into UI
for (const m of state.enemy_members) {
const newMember = {
id: m.id,
name: m.name,
level: m.level,
estimate: m.estimate,
status: m.status || "Unknown",
hits: m.hits || 0,
domElement: null
};
enemyMembers.set(m.id, newMember);
const card = createMemberCard(newMember, "enemy");
enemyContainer.appendChild(card);
}
console.log(`Restored ${state.enemy_members.length} enemy members`);
// Restore status refresh if it was running
if (state.enemy_status_running) {
const interval = state.enemy_status_interval || 10;
document.getElementById("enemy-refresh-interval").value = interval;
const btn = document.getElementById("enemy-status-btn");
enemyStatusIntervalHandle = setInterval(() => refreshStatus("enemy"), interval * 1000);
refreshStatus("enemy");
btn.textContent = "Stop";
btn.dataset.running = "true";
btn.style.backgroundColor = "#ff6b6b";
console.log(`Restored enemy status refresh (${interval}s)`);
}
}
// Refresh assignments after restoring members
if ((state.friendly_members && state.friendly_members.length > 0) ||
(state.enemy_members && state.enemy_members.length > 0)) {
await pollAssignments();
}
} catch (err) {
console.error("Error restoring dashboard state:", err);
console.error("Error stack:", err.stack);
}
}
// --------------------------- // ---------------------------
// Initial load // Initial load
// --------------------------- // ---------------------------
@@ -772,9 +914,9 @@ document.addEventListener("DOMContentLoaded", async () => {
console.log(">>> DOMContentLoaded fired"); console.log(">>> DOMContentLoaded fired");
wireUp(); wireUp();
// DON'T load members on initial page load - wait for user to click Populate // Restore previous state from server (faction IDs, members, status refresh)
// This prevents showing stale data from server STATE await restoreDashboardState();
// Start polling for assignments (but there won't be any until members are populated) // Start polling for assignments
startAssignmentsPolling(); startAssignmentsPolling();
}); });

View File

@@ -388,3 +388,112 @@ button:hover { background-color: #3399ff; }
.config-save-btn:hover { .config-save-btn:hover {
background-color: #45a049; background-color: #45a049;
} }
/* Users & Activity Log Page */
.users-log-layout {
display: flex;
gap: 1.5rem;
height: calc(100vh - 150px);
}
.users-panel {
width: 300px;
flex-shrink: 0;
}
.log-panel {
flex: 1;
min-width: 0;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.users-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 600px;
overflow-y: auto;
}
.user-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem;
background-color: #2a2a3d;
border-radius: 6px;
}
.user-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4CAF50;
box-shadow: 0 0 6px #4CAF50;
}
.user-name {
color: #f0f0f0;
font-size: 0.95rem;
}
.no-users, .no-logs {
color: #99a7bf;
font-style: italic;
text-align: center;
padding: 2rem;
}
.log-container {
background-color: #1a1a26;
border-radius: 8px;
padding: 1rem;
height: calc(100vh - 220px);
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.log-entry {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #66ccff;
font-weight: bold;
min-width: 70px;
}
.log-user {
color: #ffcc66;
font-weight: bold;
min-width: 100px;
}
.log-action {
color: #4CAF50;
}
.log-details {
color: #99a7bf;
}
.info-text {
color: #99a7bf;
font-size: 0.85rem;
font-style: italic;
}

211
static/users_log.js Normal file
View File

@@ -0,0 +1,211 @@
// users_log.js - Activity log and active users display
let logsPollingInterval = null;
let usersPollingInterval = null;
async function fetchActiveUsers() {
try {
const res = await fetch("/api/active_users", { cache: "no-store" });
if (!res.ok) {
console.error("Failed to fetch active users:", res.status);
return;
}
const data = await res.json();
displayActiveUsers(data.users || []);
} catch (err) {
console.error("Error fetching active users:", err);
}
}
function displayActiveUsers(users) {
const container = document.getElementById("active-users-list");
if (users.length === 0) {
container.innerHTML = '<div class="no-users">No active users</div>';
return;
}
container.innerHTML = users.map(username => `
<div class="user-item">
<span class="user-indicator"></span>
<span class="user-name">${escapeHtml(username)}</span>
</div>
`).join('');
}
async function fetchActivityLogs() {
try {
const res = await fetch("/api/activity_logs?limit=200", { cache: "no-store" });
if (!res.ok) {
console.error("Failed to fetch activity logs:", res.status);
return;
}
const data = await res.json();
displayActivityLogs(data.logs || []);
} catch (err) {
console.error("Error fetching activity logs:", err);
}
}
function displayActivityLogs(logs) {
const container = document.getElementById("activity-log-container");
if (logs.length === 0) {
container.innerHTML = '<div class="no-logs">No activity logs yet</div>';
return;
}
container.innerHTML = logs.map(log => {
const time = formatTimestamp(log.timestamp);
const details = log.details ? ` - ${escapeHtml(log.details)}` : '';
return `
<div class="log-entry">
<span class="log-time">${time}</span>
<span class="log-user">${escapeHtml(log.username)}</span>
<span class="log-action">${escapeHtml(log.action)}</span>
${details ? `<span class="log-details">${details}</span>` : ''}
</div>
`;
}).join('');
// Auto-scroll to bottom on first load
if (!container.hasAttribute('data-scrolled')) {
container.scrollTop = container.scrollHeight;
container.setAttribute('data-scrolled', 'true');
}
}
function formatTimestamp(isoString) {
const date = new Date(isoString);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function copyLogsToClipboard() {
try {
const res = await fetch("/api/activity_logs?limit=1000", { cache: "no-store" });
if (!res.ok) {
alert("Failed to fetch logs");
return;
}
const data = await res.json();
const logs = data.logs || [];
if (logs.length === 0) {
alert("No logs to copy");
return;
}
// Format logs as plain text
const logText = logs.map(log => {
const time = formatTimestamp(log.timestamp);
const details = log.details ? ` - ${log.details}` : '';
return `[${time}] ${log.username}: ${log.action}${details}`;
}).join('\n');
// Copy to clipboard
await navigator.clipboard.writeText(logText);
// Show success feedback
const btn = document.getElementById("copy-log-btn");
const originalText = btn.textContent;
btn.textContent = "Copied!";
btn.style.backgroundColor = "#4CAF50";
setTimeout(() => {
btn.textContent = originalText;
btn.style.backgroundColor = "";
}, 2000);
} catch (err) {
console.error("Error copying logs:", err);
alert("Failed to copy logs to clipboard");
}
}
function startPolling() {
// Fetch immediately
fetchActiveUsers();
fetchActivityLogs();
// Then poll at intervals
usersPollingInterval = setInterval(fetchActiveUsers, 10000); // Every 10 seconds
logsPollingInterval = setInterval(fetchActivityLogs, 5000); // Every 5 seconds
}
function stopPolling() {
if (usersPollingInterval) {
clearInterval(usersPollingInterval);
usersPollingInterval = null;
}
if (logsPollingInterval) {
clearInterval(logsPollingInterval);
logsPollingInterval = null;
}
}
async function handleLogout() {
console.log("handleLogout called");
try {
console.log("Sending logout request to /auth/logout");
const response = await fetch("/auth/logout", {
method: "POST"
});
console.log("Logout response status:", response.status);
if (response.ok) {
console.log("Logout successful, redirecting to /login");
window.location.href = "/login";
} else {
console.error("Logout failed with status:", response.status);
window.location.href = "/login";
}
} catch (error) {
console.error("Error during logout:", error);
window.location.href = "/login";
}
}
function wireUp() {
// Attach copy button handler
const copyBtn = document.getElementById("copy-log-btn");
if (copyBtn) {
copyBtn.addEventListener("click", copyLogsToClipboard);
}
// Attach logout handler
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) {
logoutBtn.addEventListener("click", handleLogout);
}
}
// Initialize when page loads
document.addEventListener("DOMContentLoaded", () => {
wireUp();
startPolling();
});
// Stop polling when page is hidden/unloaded
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
}
});
window.addEventListener("beforeunload", () => {
stopPolling();
});

View File

@@ -11,7 +11,8 @@
<div class="top-bar"> <div class="top-bar">
<h1>Configuration</h1> <h1>Configuration</h1>
<div class="top-controls"> <div class="top-controls">
<a href="/" class="nav-link">Back to Dashboard</a> <a href="/" class="nav-link">Dashboard</a>
<a href="/users-log" class="nav-link">Users/Log</a>
<button id="logout-btn" class="nav-link" style="background:none;border:none;cursor:pointer;padding:0.6rem 1rem;">Logout</button> <button id="logout-btn" class="nav-link" style="background:none;border:none;cursor:pointer;padding:0.6rem 1rem;">Logout</button>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@
<div class="top-controls"> <div class="top-controls">
<a href="/config" class="nav-link">Settings</a> <a href="/config" class="nav-link">Settings</a>
<a href="/users-log" class="nav-link">Users/Log</a>
<button id="logout-btn" class="nav-link" style="background:none;border:none;cursor:pointer;padding:0.6rem 1rem;">Logout</button> <button id="logout-btn" class="nav-link" style="background:none;border:none;cursor:pointer;padding:0.6rem 1rem;">Logout</button>
<button id="bot-control-btn" class="bot-btn" data-running="false">Start Bot</button> <button id="bot-control-btn" class="bot-btn" data-running="false">Start Bot</button>
<button id="reset-groups-btn" class="reset-btn">Reset Groups</button> <button id="reset-groups-btn" class="reset-btn">Reset Groups</button>

50
templates/users_log.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Users & Activity Log - War Dashboard</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="container">
<!-- Navigation Bar -->
<div class="top-bar">
<h1>Users & Activity Log</h1>
<div class="top-controls">
<a href="/" class="nav-link">Dashboard</a>
<a href="/config" class="nav-link">Settings</a>
<button id="logout-btn" class="nav-link" style="background:none;border:none;cursor:pointer;padding:0.6rem 1rem;">Logout</button>
</div>
</div>
<!-- Main Content -->
<div class="users-log-layout">
<!-- Left Column: Active Users -->
<div class="users-panel">
<div class="faction-card small">
<h2>Active Users</h2>
<p class="info-text" style="margin-bottom: 1rem;">Users active in the last 30 minutes</p>
<div id="active-users-list" class="users-list">
<!-- Users will be populated here -->
</div>
</div>
</div>
<!-- Right Column: Activity Log -->
<div class="log-panel">
<div class="faction-card">
<div class="log-header">
<h2>Activity Log</h2>
<button id="copy-log-btn" class="config-save-btn">Copy All Logs</button>
</div>
<div id="activity-log-container" class="log-container">
<!-- Logs will be populated here -->
</div>
</div>
</div>
</div>
</div>
<script src="/static/users_log.js"></script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
"""Utility functions package.""" #Utility functions package
from .file_helpers import load_json_list, sync_state_from_file from .file_helpers import load_json_list, sync_state_from_file
__all__ = ["load_json_list", "sync_state_from_file"] __all__ = ["load_json_list", "sync_state_from_file"]

View File

@@ -1,11 +1,11 @@
"""Authentication utilities and dependencies.""" #Authentication utilities and dependencies
import jwt import jwt
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
import config as config_module import config as config_module
def get_current_user(request: Request) -> dict: def get_current_user(request: Request) -> dict:
"""Dependency to check authentication and return user info""" #Dependency to check authentication and return user info
token = request.cookies.get("auth_token") token = request.cookies.get("auth_token")
if not token: if not token:
@@ -21,7 +21,7 @@ def get_current_user(request: Request) -> dict:
def check_auth(request: Request) -> bool: def check_auth(request: Request) -> bool:
"""Check if user is authenticated (returns bool, doesn't raise exception)""" #Check if user is authenticated (returns bool, doesn't raise exception)
token = request.cookies.get("auth_token") token = request.cookies.get("auth_token")
if not token: if not token:
return False return False

View File

@@ -1,11 +1,11 @@
"""File I/O helper utilities.""" #File I/O helper utilities
import json import json
from pathlib import Path from pathlib import Path
from services.server_state import STATE from services.server_state import STATE
def load_json_list(path: Path): def load_json_list(path: Path):
"""Load a JSON file and return it as a list.""" #Load a JSON file and return it as a list
if not path.exists(): if not path.exists():
return [] return []
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
@@ -13,10 +13,8 @@ def load_json_list(path: Path):
async def sync_state_from_file(path: Path, kind: str): async def sync_state_from_file(path: Path, kind: str):
""" #Read JSON file (list of members dicts) and upsert into STATE.
Read JSON file (list of members dicts) and upsert into STATE. #Expected member dict keys: id, name, level, estimate, optionally status/hits.
Expected member dict keys: id, name, level, estimate, optionally status/hits.
"""
arr = load_json_list(path) arr = load_json_list(path)
received_ids = [] received_ids = []
for m in arr: for m in arr: