User Log and Persistent Faction Information
This commit is contained in:
Binary file not shown.
@@ -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 {}
|
||||||
|
|||||||
3
main.py
3
main.py
@@ -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
|
||||||
|
|||||||
BIN
routers/__pycache__/activity.cpython-311.pyc
Normal file
BIN
routers/__pycache__/activity.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
52
routers/activity.py
Normal file
52
routers/activity.py
Normal 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"}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
BIN
services/__pycache__/activity_log.cpython-311.pyc
Normal file
BIN
services/__pycache__/activity_log.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
51
services/activity_log.py
Normal file
51
services/activity_log.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
211
static/users_log.js
Normal 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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
50
templates/users_log.html
Normal 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>
|
||||||
@@ -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"]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user