diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc index 5aae51a..b8ed7dc 100644 Binary files a/__pycache__/config.cpython-311.pyc and b/__pycache__/config.cpython-311.pyc differ diff --git a/config.py b/config.py index ddd330f..ff9fb72 100644 --- a/config.py +++ b/config.py @@ -2,7 +2,7 @@ from pathlib import Path import 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") if not path.exists(): return {} diff --git a/main.py b/main.py index 4b1d9f7..0c3a68d 100644 --- a/main.py +++ b/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from services.bot_assignment import BotAssignmentManager # 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 @@ -31,6 +31,7 @@ app.include_router(assignments.router) app.include_router(bot_router.router) app.include_router(discord_mappings.router) app.include_router(config.router) +app.include_router(activity.router) # Discord Bot Setup diff --git a/routers/__pycache__/activity.cpython-311.pyc b/routers/__pycache__/activity.cpython-311.pyc new file mode 100644 index 0000000..43df7ea Binary files /dev/null and b/routers/__pycache__/activity.cpython-311.pyc differ diff --git a/routers/__pycache__/auth.cpython-311.pyc b/routers/__pycache__/auth.cpython-311.pyc index c0cfca8..2a2b7ad 100644 Binary files a/routers/__pycache__/auth.cpython-311.pyc and b/routers/__pycache__/auth.cpython-311.pyc differ diff --git a/routers/__pycache__/bot.cpython-311.pyc b/routers/__pycache__/bot.cpython-311.pyc index fd9df55..2fcb7a3 100644 Binary files a/routers/__pycache__/bot.cpython-311.pyc and b/routers/__pycache__/bot.cpython-311.pyc differ diff --git a/routers/__pycache__/config.cpython-311.pyc b/routers/__pycache__/config.cpython-311.pyc index b41968a..c63c024 100644 Binary files a/routers/__pycache__/config.cpython-311.pyc and b/routers/__pycache__/config.cpython-311.pyc differ diff --git a/routers/__pycache__/discord_mappings.cpython-311.pyc b/routers/__pycache__/discord_mappings.cpython-311.pyc index c452008..2263906 100644 Binary files a/routers/__pycache__/discord_mappings.cpython-311.pyc and b/routers/__pycache__/discord_mappings.cpython-311.pyc differ diff --git a/routers/__pycache__/factions.cpython-311.pyc b/routers/__pycache__/factions.cpython-311.pyc index 9b89a46..61f79a2 100644 Binary files a/routers/__pycache__/factions.cpython-311.pyc and b/routers/__pycache__/factions.cpython-311.pyc differ diff --git a/routers/__pycache__/pages.cpython-311.pyc b/routers/__pycache__/pages.cpython-311.pyc index 6110159..a2c87e8 100644 Binary files a/routers/__pycache__/pages.cpython-311.pyc and b/routers/__pycache__/pages.cpython-311.pyc differ diff --git a/routers/activity.py b/routers/activity.py new file mode 100644 index 0000000..a5ab83d --- /dev/null +++ b/routers/activity.py @@ -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"} diff --git a/routers/auth.py b/routers/auth.py index cb82874..4243c3a 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -20,7 +20,7 @@ class LoginRequest(BaseModel): 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) forwarded = request.headers.get("X-Forwarded-For") if forwarded: @@ -29,7 +29,7 @@ def get_client_ip(request: Request) -> str: 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: return False @@ -49,7 +49,7 @@ def is_locked_out(ip: str) -> bool: def record_failed_attempt(ip: str): - """Record a failed login attempt""" + #Record a failed login attempt now = datetime.now() if ip not in failed_attempts: @@ -64,13 +64,13 @@ def record_failed_attempt(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: del failed_attempts[ip] 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 payload = { "username": username, @@ -81,7 +81,7 @@ def create_jwt_token(username: str) -> str: def verify_jwt_token(token: str) -> dict: - """Verify and decode a JWT token""" + #Verify and decode a JWT token try: payload = jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"]) return payload @@ -93,7 +93,7 @@ def verify_jwt_token(token: str) -> dict: @router.post("/login") 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) # Check if IP is locked out @@ -144,14 +144,14 @@ async def login(request: Request, response: Response, req: LoginRequest): @router.post("/logout") async def logout(response: Response): - """Logout endpoint""" + #Logout endpoint response.delete_cookie("auth_token") return {"status": "success"} @router.get("/status") async def auth_status(request: Request): - """Check authentication status""" + #Check authentication status token = request.cookies.get("auth_token") if not token: diff --git a/routers/bot.py b/routers/bot.py index 7806d22..89135a8 100644 --- a/routers/bot.py +++ b/routers/bot.py @@ -20,7 +20,7 @@ def set_assignment_manager(manager: BotAssignmentManager): @router.get("/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 return { "bot_running": STATE.bot_running, diff --git a/routers/config.py b/routers/config.py index 91ac0d3..b210920 100644 --- a/routers/config.py +++ b/routers/config.py @@ -10,7 +10,7 @@ router = APIRouter(prefix="/api", tags=["config"]) 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") if not path.exists(): return @@ -29,7 +29,7 @@ def reload_config_from_file(): @router.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") # Default config values from config.py @@ -70,7 +70,7 @@ async def get_config(): @router.post("/config") async def update_config(req: ConfigUpdateRequest): - """Update a single config value""" + #Update a single config value path = Path("data/config.json") # Valid config keys (from config.py) diff --git a/routers/discord_mappings.py b/routers/discord_mappings.py index 1b1fc58..3085896 100644 --- a/routers/discord_mappings.py +++ b/routers/discord_mappings.py @@ -14,14 +14,14 @@ assignment_manager: Optional[BotAssignmentManager] = None def set_assignment_manager(manager: BotAssignmentManager): - """Set the global assignment manager reference.""" + #Set the global assignment manager reference. global assignment_manager assignment_manager = manager @router.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") if not path.exists(): return {"mappings": {}} @@ -33,7 +33,7 @@ async def get_discord_mappings(): @router.post("/discord_mapping") 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") # Load existing mappings @@ -59,7 +59,7 @@ async def add_discord_mapping(req: DiscordMappingRequest): @router.delete("/discord_mapping/{torn_id}") async def remove_discord_mapping(torn_id: int): - """Remove a Discord mapping""" + #Remove a Discord mapping path = Path("data/discord_mapping.json") if not path.exists(): diff --git a/routers/factions.py b/routers/factions.py index 1a1c04f..0785d4f 100644 --- a/routers/factions.py +++ b/routers/factions.py @@ -1,11 +1,18 @@ -"""Faction data population and status management endpoints.""" +#Faction data population and status management endpoints. import json from pathlib import Path from fastapi import APIRouter from models import FactionRequest 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 router = APIRouter(prefix="/api", tags=["factions"]) @@ -73,3 +80,30 @@ async def api_enemy_status(): return {} with open(path, "r", encoding="utf-8") as 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 + } diff --git a/routers/pages.py b/routers/pages.py index 15be19c..d3ecd9a 100644 --- a/routers/pages.py +++ b/routers/pages.py @@ -10,7 +10,7 @@ templates = Jinja2Templates(directory="templates") @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): - """Login page""" + #Login page # If already authenticated, redirect to dashboard if check_auth(request): return RedirectResponse(url="/", status_code=302) @@ -19,7 +19,7 @@ async def login_page(request: Request): @router.get("/", response_class=HTMLResponse) async def dashboard(request: Request): - """Dashboard page - requires authentication""" + #Dashboard page - requires authentication if not check_auth(request): return RedirectResponse(url="/login", status_code=302) print(">>> DASHBOARD ROUTE LOADED") @@ -28,7 +28,15 @@ async def dashboard(request: Request): @router.get("/config", response_class=HTMLResponse) async def config_page(request: Request): - """Config page - requires authentication""" + #Config page - requires authentication if not check_auth(request): return RedirectResponse(url="/login", status_code=302) 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}) diff --git a/services/__pycache__/activity_log.cpython-311.pyc b/services/__pycache__/activity_log.cpython-311.pyc new file mode 100644 index 0000000..328fa5f Binary files /dev/null and b/services/__pycache__/activity_log.cpython-311.pyc differ diff --git a/services/__pycache__/bot_assignment.cpython-311.pyc b/services/__pycache__/bot_assignment.cpython-311.pyc index 61e484f..c325dfa 100644 Binary files a/services/__pycache__/bot_assignment.cpython-311.pyc and b/services/__pycache__/bot_assignment.cpython-311.pyc differ diff --git a/services/__pycache__/ffscouter.cpython-311.pyc b/services/__pycache__/ffscouter.cpython-311.pyc index 02e0f28..d648560 100644 Binary files a/services/__pycache__/ffscouter.cpython-311.pyc and b/services/__pycache__/ffscouter.cpython-311.pyc differ diff --git a/services/__pycache__/server_state.cpython-311.pyc b/services/__pycache__/server_state.cpython-311.pyc index c19bd1c..0f92c8e 100644 Binary files a/services/__pycache__/server_state.cpython-311.pyc and b/services/__pycache__/server_state.cpython-311.pyc differ diff --git a/services/__pycache__/torn_api.cpython-311.pyc b/services/__pycache__/torn_api.cpython-311.pyc index 0497d55..efb25a8 100644 Binary files a/services/__pycache__/torn_api.cpython-311.pyc and b/services/__pycache__/torn_api.cpython-311.pyc differ diff --git a/services/activity_log.py b/services/activity_log.py new file mode 100644 index 0000000..6be30d2 --- /dev/null +++ b/services/activity_log.py @@ -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() diff --git a/services/bot_assignment.py b/services/bot_assignment.py index 2757355..2263683 100644 --- a/services/bot_assignment.py +++ b/services/bot_assignment.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Dict, Optional from datetime import datetime 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 class BotAssignmentManager: @@ -28,7 +29,7 @@ class BotAssignmentManager: self.load_discord_mapping() 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") if not path.exists(): print("No discord_mapping.json found") @@ -44,11 +45,11 @@ class BotAssignmentManager: print(f"Error loading discord mapping: {e}") 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) 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: return None @@ -69,7 +70,7 @@ class BotAssignmentManager: return None async def start(self): - """Start the bot assignment loop""" + #Start the bot assignment loop if self.running: print("WARNING: Bot assignment already running") return @@ -94,18 +95,16 @@ class BotAssignmentManager: print("Bot assignment stopped") 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(): if target_data["friendly_id"] == friendly_id: return True return False 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. - Prioritizes members with fewer hits. - Only returns friendlies who DON'T already have an active assignment. - """ + #Get the next friendly in the group who should receive a target. + #Prioritizes members with fewer hits. + #Only returns friendlies who DON'T already have an active assignment. if not friendly_ids: return None @@ -130,10 +129,8 @@ class BotAssignmentManager: return friendly_hits[0][0] 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. - Returns None if all enemies are already assigned or not attackable. - """ + #Get the next enemy in the group who needs to be assigned. + #Returns None if all enemies are already assigned or not attackable. for eid in enemy_ids: key = f"{group_id}:{eid}" # If enemy is already assigned, skip them @@ -150,7 +147,7 @@ class BotAssignmentManager: return None 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() if timeout is None: @@ -173,6 +170,8 @@ class BotAssignmentManager: self.assigned_friendlies.clear() self.current_group_index = 0 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 elif timeout > threshold_seconds and self.chain_active: @@ -181,6 +180,8 @@ class BotAssignmentManager: self.assigned_friendlies.clear() self.current_group_index = 0 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) elif timeout == 0 and self.chain_active: @@ -189,14 +190,18 @@ class BotAssignmentManager: self.assigned_friendlies.clear() self.current_group_index = 0 self.chain_warning_sent = False + # Log to activity + await activity_logger.log_action("System", "Chain Expired", "Chain timer reached 0") # Send 30-second warning if self.chain_active and timeout <= 30 and not self.chain_warning_sent: await self.send_chain_expiration_warning() 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): - """Send @here alert that chain is about to expire""" + #Send @here alert that chain is about to expire try: channel = self.bot.get_channel(ALLOWED_CHANNEL_ID) if channel and STATE.friendly_faction_id: @@ -208,7 +213,7 @@ class BotAssignmentManager: print(f"Error sending chain warning: {e}") 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 if self.active_targets: return # Wait for current assignment to complete @@ -265,7 +270,7 @@ class BotAssignmentManager: self.current_group_index = 0 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() print("Bot is ready, assignment loop running with chain timer monitoring") @@ -307,7 +312,7 @@ class BotAssignmentManager: await asyncio.sleep(5) 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 friendly = STATE.friendly.get(friendly_id) enemy = STATE.enemy.get(enemy_id) @@ -367,6 +372,8 @@ class BotAssignmentManager: if channel: await channel.send(message) 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: print(f"Assignment channel {ALLOWED_CHANNEL_ID} not found") self.active_targets[key]["failed"] = True @@ -375,7 +382,7 @@ class BotAssignmentManager: self.active_targets[key]["failed"] = True 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() to_reassign = [] @@ -404,7 +411,11 @@ class BotAssignmentManager: if friendly_id in STATE.friendly: # Increment hit count 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 del self.active_targets[key] @@ -412,7 +423,13 @@ class BotAssignmentManager: # Check if enemy is no longer attackable (traveling, etc.) 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] continue @@ -445,7 +462,11 @@ class BotAssignmentManager: friendly_ids = STATE.groups[group_id].get("friendly", []) friendly_id = self.get_next_friendly_in_group(group_id, friendly_ids) 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)") + # 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) # Global instance (will be initialized with bot in main.py) diff --git a/services/ffscouter.py b/services/ffscouter.py index 866949d..de02aa9 100644 --- a/services/ffscouter.py +++ b/services/ffscouter.py @@ -2,10 +2,9 @@ import aiohttp from config import FFSCOUTER_KEY async def fetch_batch_stats(ids: list[int]): - """ - Fetches predicted stats for a list of Torn IDs in a single FFScouter request. - Returns dict keyed by player_id. - """ + #Fetches predicted stats for a list of Torn IDs in a single FFScouter request. + #Returns dict keyed by player_id. + if not ids: return {} diff --git a/services/server_state.py b/services/server_state.py index e9075f2..06850e6 100644 --- a/services/server_state.py +++ b/services/server_state.py @@ -30,6 +30,13 @@ class ServerState: # faction IDs for API monitoring 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 self.lock = asyncio.Lock() diff --git a/services/torn_api.py b/services/torn_api.py index 31e8606..9b84105 100644 --- a/services/torn_api.py +++ b/services/torn_api.py @@ -17,10 +17,10 @@ enemy_lock = asyncio.Lock() # Populate faction (memory only) async def populate_faction(faction_id: int, kind: str): - """ - Fetch members + FFScouter estimates once and store in STATE. - kind: "friendly" or "enemy" - """ + + #Fetch members + FFScouter estimates once and store in STATE. + #kind: "friendly" or "enemy" + url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}" async with aiohttp.ClientSession() as session: @@ -65,9 +65,7 @@ async def populate_faction(faction_id: int, kind: str): # Status refresh loop 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: try: 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") async def populate_enemy(faction_id: int): + # Store enemy faction ID + STATE.enemy_faction_id = faction_id return await populate_faction(faction_id, "enemy") 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( 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): 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( 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 diff --git a/static/dashboard.js b/static/dashboard.js index 77c1a3b..3bc9e41 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -23,6 +23,21 @@ function toInt(v) { 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 // --------------------------- @@ -428,6 +443,11 @@ function setupDropZones() { if (prev) prev.removeChild(member.domElement); zone.appendChild(member.domElement); } + + // Log the assignment + if (member) { + await logAction("Assigned Member to Group", `${member.name} (${kind}) -> Group ${groupKey}`); + } } else { console.warn("Unexpected zone id format", zone.id); } @@ -443,6 +463,11 @@ function setupDropZones() { if (prev) prev.removeChild(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 await loadMembers("enemy"); // in case population changed cross lists await pollAssignments(); + + // Log the action + await logAction("Populated Friendly Faction", `Faction ID: ${id}, Members: ${data.members ? data.members.length : 0}`); } catch (err) { console.error("populateFriendly error:", err); } @@ -583,6 +611,9 @@ async function populateEnemy() { // Refresh assignments & status UI await loadMembers("friendly"); await pollAssignments(); + + // Log the action + await logAction("Populated Enemy Faction", `Faction ID: ${id}, Members: ${data.members ? data.members.length : 0}`); } catch (err) { console.error("populateEnemy error:", err); } @@ -620,6 +651,9 @@ async function toggleFriendlyStatus() { btn.textContent = "Start"; btn.dataset.running = "false"; btn.style.backgroundColor = ""; + // Notify server that status refresh stopped + await fetch("/api/stop_friendly_status", { method: "POST" }); + await logAction("Stopped Friendly Status Refresh"); return; } @@ -637,6 +671,7 @@ async function toggleFriendlyStatus() { btn.textContent = "Stop"; btn.dataset.running = "true"; btn.style.backgroundColor = "#ff6b6b"; + await logAction("Started Friendly Status Refresh", `Interval: ${interval}s`); } async function toggleEnemyStatus() { @@ -647,6 +682,9 @@ async function toggleEnemyStatus() { btn.textContent = "Start"; btn.dataset.running = "false"; btn.style.backgroundColor = ""; + // Notify server that status refresh stopped + await fetch("/api/stop_enemy_status", { method: "POST" }); + await logAction("Stopped Enemy Status Refresh"); return; } @@ -664,6 +702,7 @@ async function toggleEnemyStatus() { btn.textContent = "Stop"; btn.dataset.running = "true"; 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"; console.log(`Bot ${data.bot_running ? "started" : "stopped"}`); + + // Log the action + await logAction(data.bot_running ? "Started Bot" : "Stopped Bot"); } catch (err) { console.error("toggleBotControl error:", err); } @@ -707,6 +749,8 @@ async function resetGroups() { await clearAssignmentsOnServer(); // reload assignments & UI 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 // --------------------------- @@ -772,9 +914,9 @@ document.addEventListener("DOMContentLoaded", async () => { console.log(">>> DOMContentLoaded fired"); wireUp(); - // DON'T load members on initial page load - wait for user to click Populate - // This prevents showing stale data from server STATE + // Restore previous state from server (faction IDs, members, status refresh) + await restoreDashboardState(); - // Start polling for assignments (but there won't be any until members are populated) + // Start polling for assignments startAssignmentsPolling(); }); diff --git a/static/styles.css b/static/styles.css index 1c2f208..6be99bb 100644 --- a/static/styles.css +++ b/static/styles.css @@ -388,3 +388,112 @@ button:hover { background-color: #3399ff; } .config-save-btn:hover { 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; +} diff --git a/static/users_log.js b/static/users_log.js new file mode 100644 index 0000000..9f3792f --- /dev/null +++ b/static/users_log.js @@ -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 = '
No active users
'; + return; + } + + container.innerHTML = users.map(username => ` +
+ + ${escapeHtml(username)} +
+ `).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 = '
No activity logs yet
'; + return; + } + + container.innerHTML = logs.map(log => { + const time = formatTimestamp(log.timestamp); + const details = log.details ? ` - ${escapeHtml(log.details)}` : ''; + + return ` +
+ ${time} + ${escapeHtml(log.username)} + ${escapeHtml(log.action)} + ${details ? `${details}` : ''} +
+ `; + }).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(); +}); diff --git a/templates/config.html b/templates/config.html index c14ebd4..ea87a37 100644 --- a/templates/config.html +++ b/templates/config.html @@ -11,7 +11,8 @@

Configuration

- Back to Dashboard + Dashboard + Users/Log
diff --git a/templates/dashboard.html b/templates/dashboard.html index 351119d..83c4c49 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -14,6 +14,7 @@
Settings + Users/Log diff --git a/templates/users_log.html b/templates/users_log.html new file mode 100644 index 0000000..5137a6d --- /dev/null +++ b/templates/users_log.html @@ -0,0 +1,50 @@ + + + + + Users & Activity Log - War Dashboard + + + +
+ +
+

Users & Activity Log

+
+ Dashboard + Settings + +
+
+ + +
+ +
+
+

Active Users

+

Users active in the last 30 minutes

+
+ +
+
+
+ + +
+
+
+

Activity Log

+ +
+
+ +
+
+
+
+
+ + + + diff --git a/utils/__init__.py b/utils/__init__.py index 5ed71a2..abd8b28 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,4 +1,4 @@ -"""Utility functions package.""" +#Utility functions package from .file_helpers import load_json_list, sync_state_from_file __all__ = ["load_json_list", "sync_state_from_file"] diff --git a/utils/__pycache__/__init__.cpython-311.pyc b/utils/__pycache__/__init__.cpython-311.pyc index c9636fa..7afdace 100644 Binary files a/utils/__pycache__/__init__.cpython-311.pyc and b/utils/__pycache__/__init__.cpython-311.pyc differ diff --git a/utils/__pycache__/auth.cpython-311.pyc b/utils/__pycache__/auth.cpython-311.pyc index 748cd34..5cfeff2 100644 Binary files a/utils/__pycache__/auth.cpython-311.pyc and b/utils/__pycache__/auth.cpython-311.pyc differ diff --git a/utils/__pycache__/file_helpers.cpython-311.pyc b/utils/__pycache__/file_helpers.cpython-311.pyc index e8bf3f8..a5e0a09 100644 Binary files a/utils/__pycache__/file_helpers.cpython-311.pyc and b/utils/__pycache__/file_helpers.cpython-311.pyc differ diff --git a/utils/auth.py b/utils/auth.py index 29b7aaf..11f7ec6 100644 --- a/utils/auth.py +++ b/utils/auth.py @@ -1,11 +1,11 @@ -"""Authentication utilities and dependencies.""" +#Authentication utilities and dependencies import jwt from fastapi import Request, HTTPException import config as config_module 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") if not token: @@ -21,7 +21,7 @@ def get_current_user(request: Request) -> dict: 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") if not token: return False diff --git a/utils/file_helpers.py b/utils/file_helpers.py index 09097df..6b53749 100644 --- a/utils/file_helpers.py +++ b/utils/file_helpers.py @@ -1,11 +1,11 @@ -"""File I/O helper utilities.""" +#File I/O helper utilities import json from pathlib import Path from services.server_state import STATE 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(): return [] 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): - """ - Read JSON file (list of members dicts) and upsert into STATE. - Expected member dict keys: id, name, level, estimate, optionally status/hits. - """ + #Read JSON file (list of members dicts) and upsert into STATE. + #Expected member dict keys: id, name, level, estimate, optionally status/hits. arr = load_json_list(path) received_ids = [] for m in arr: