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
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 {}

View File

@@ -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

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:
"""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:

View File

@@ -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,

View File

@@ -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)

View File

@@ -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():

View File

@@ -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
}

View File

@@ -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})

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 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)

View File

@@ -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 {}

View File

@@ -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()

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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;
}

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">
<h1>Configuration</h1>
<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>
</div>
</div>

View File

@@ -14,6 +14,7 @@
<div class="top-controls">
<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="bot-control-btn" class="bot-btn" data-running="false">Start Bot</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
__all__ = ["load_json_list", "sync_state_from_file"]

View File

@@ -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

View File

@@ -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: