From 4850c16b87fe3e18eb32e262ccdc8a399e237efd Mon Sep 17 00:00:00 2001 From: jerick Date: Tue, 27 Jan 2026 14:48:46 -0500 Subject: [PATCH] User Log and Persistent Faction Information --- __pycache__/config.cpython-311.pyc | Bin 2385 -> 2344 bytes config.py | 2 +- main.py | 3 +- routers/__pycache__/activity.cpython-311.pyc | Bin 0 -> 3011 bytes routers/__pycache__/auth.cpython-311.pyc | Bin 7410 -> 7112 bytes routers/__pycache__/bot.cpython-311.pyc | Bin 2424 -> 2401 bytes routers/__pycache__/config.cpython-311.pyc | Bin 5876 -> 5740 bytes .../discord_mappings.cpython-311.pyc | Bin 4902 -> 4743 bytes routers/__pycache__/factions.cpython-311.pyc | Bin 6094 -> 8154 bytes routers/__pycache__/pages.cpython-311.pyc | Bin 2321 -> 2699 bytes routers/activity.py | 52 +++++ routers/auth.py | 18 +- routers/bot.py | 2 +- routers/config.py | 6 +- routers/discord_mappings.py | 8 +- routers/factions.py | 38 +++- routers/pages.py | 14 +- .../__pycache__/activity_log.cpython-311.pyc | Bin 0 -> 4626 bytes .../bot_assignment.cpython-311.pyc | Bin 23765 -> 25157 bytes .../__pycache__/ffscouter.cpython-311.pyc | Bin 2313 -> 2187 bytes .../__pycache__/server_state.cpython-311.pyc | Bin 9122 -> 9353 bytes services/__pycache__/torn_api.cpython-311.pyc | Bin 8389 -> 9568 bytes services/activity_log.py | 51 +++++ services/bot_assignment.py | 65 ++++-- services/ffscouter.py | 7 +- services/server_state.py | 7 + services/torn_api.py | 40 +++- static/dashboard.js | 148 +++++++++++- static/styles.css | 109 +++++++++ static/users_log.js | 211 ++++++++++++++++++ templates/config.html | 3 +- templates/dashboard.html | 1 + templates/users_log.html | 50 +++++ utils/__init__.py | 2 +- utils/__pycache__/__init__.cpython-311.pyc | Bin 374 -> 327 bytes utils/__pycache__/auth.cpython-311.pyc | Bin 1958 -> 1768 bytes .../__pycache__/file_helpers.cpython-311.pyc | Bin 2033 -> 1788 bytes utils/auth.py | 6 +- utils/file_helpers.py | 10 +- 39 files changed, 782 insertions(+), 71 deletions(-) create mode 100644 routers/__pycache__/activity.cpython-311.pyc create mode 100644 routers/activity.py create mode 100644 services/__pycache__/activity_log.cpython-311.pyc create mode 100644 services/activity_log.py create mode 100644 static/users_log.js create mode 100644 templates/users_log.html diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc index 5aae51a85004af7261d1bbee74412f17d08b46a1..b8ed7dcc4adc44ae25a4c726f57f656502601817 100644 GIT binary patch delta 80 zcmca8v_gn?IWI340}%XsQ;}(~kynG6l_3QPCtERVGBQj~VOC~jo7}-HE5%UDT*n*? elmXG@jO7d!%#jS`jGF9zlbbWw%>;eT*n;D zkOHR58Os?em?Ig=88tbo)P3?3QxuZ(^U^ZY71E0Ga}~UT{rwcuGILTDGSd_?OB7No UGK))!C+}xAVD#Jkp4pNW092_Vpa1{> diff --git a/config.py b/config.py index ddd330f..ff9fb72 100644 --- a/config.py +++ b/config.py @@ -2,7 +2,7 @@ from pathlib import Path import json def load_from_json(): - """Load config from JSON file if it exists""" + #Load config from JSON file if it exists path = Path("data/config.json") if not path.exists(): return {} diff --git a/main.py b/main.py index 4b1d9f7..0c3a68d 100644 --- a/main.py +++ b/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from services.bot_assignment import BotAssignmentManager # Import routers -from routers import pages, factions, assignments, discord_mappings, config, auth +from routers import pages, factions, assignments, discord_mappings, config, auth, activity from routers import bot as bot_router @@ -31,6 +31,7 @@ app.include_router(assignments.router) app.include_router(bot_router.router) app.include_router(discord_mappings.router) app.include_router(config.router) +app.include_router(activity.router) # Discord Bot Setup diff --git a/routers/__pycache__/activity.cpython-311.pyc b/routers/__pycache__/activity.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43df7ea2636dddc2b014edfcdf9ee26f4d7317c5 GIT binary patch literal 3011 zcmcIm&2Jk;6rcU{cN{xO(|i^8gBAl#MO!rp(xO60s8R}Q38hwGwRkt_w)U<&yKWof zNT!Xz;y@!V>Cp!if|^5*95}##u$J~xr44)TgdpcfmDD&EK*rTRDAQyx_^~L=pGa=rGhM)LP}r>DM3|Ocj*ER{ehzzA$?V972*pjeKP88&H zZ;5SdDDiD_h?E{R1X6mRIZaIIyMU7Yh5plNOS`UF3yF*|pHOurXK1=*jy@uSldKy! z{o$GOM&44f>pQPr%d4i9WZb~pvZ=mrC~5{e-Ses?rSll8x+UdJ6$39SH^fRHc3#B= z{)n8@Tz``dlEoZW=d_zHZ^`p!y6qmQsr8MQfV+ZJlw!cz>CCLp+Qr1xN8PX0*(zU#t zX^e2Dh25wm$+~V>G7(}*5{|;wKcWqC*r(^;d1H14Zfnk7QL&c3Iy+&c^I6b=IXj_V zS2ISAbY|zgBS<%7EGe3qldbe+>2t%H#Z;^2Y*QtpxdluRMhSTa<|{x-ZC@=MElroE zznrf1^p&Q{7i_){a+OkO;9bqtg~$;qRi)-IT_5%=m)=4t%;cfmhO*%RnMMp~I)JRU z8Aa5Ir$wg7RNBU2StF5m*!h+gSxab+B6Ou`QB-6*W!^>#_XYPcTH(Ip8KnF6!#OS? zox90hLN}NW8RWb)Dg3&yvy??n5sRG003v*aeN(EH$Y`dOFy;~sh-MZNo^lcdtETHG z6|uVOo6)c8#tr@4qdl+@L9wM}RWOs3)pU3QrYq1TNdXgQ?5t-d`^)qPu)_} zpx(o1H$;-hyUFwb5hH2(oPh_SkI8l>xnBYj?kB9hO+p}N-U0Fld?i%(3qs^U?}5t2 zYVTpE_i#B->lt1-`Q6FN;M%2X&zRFQR`x&W8gaUg-haK?HSTnc+y3Y0%{t2nk(xhT z<~KJtAIA4O@uR;Swd0f3_@on`w8PtT>D^kmXL-gB4cYwAMhMQd^wwz}{mQea{am5T z!yLF0^5)G)3XHo#M$2keddtTGkKFU$;f^h`{}^{%2WDEh<90^OKgC^0+FO85CpRLgW9&Q7qxalJ^BWcA^?P;lxhZq3yXe zQ47bHC(5U6egN->BNakN;F9#v;H)|DRvm~T7o>>!9vYsf`ddHspWvm)Tp>uM3EErU ztnIt|x> zUnYz#0Bmy_qhyef!%)o6fOy9KQ$(@7mHujX(&mjQqQ^?_ zJq!*w!2^{~*39+7Pse_Kvl^Uof>SpCoLmd{E-Ri7pb>`v`2cK{jJrNbQj9djQs0~m zH_U06JU~qJ==yUDimY2&8WUBz!Mp|0cvQ|?mpy0N2QKltmR5n+_UqueSJ8`=UU;NW zgt$kSgFh9DZI{m(5U=U=`^b(o5)wc9)IaV8_)~Zcx+Hp;9|5g%48zpWh>b>S=%D?) zTtj>8&Rj!>?9N<6ui2gXw?Mo+aQEO^uu`-G$E$(kPT+V+tcxtOtB#%}%L|VQ_epCg zh*@$YTz%BhAtJaAS=f9PU{vSYvE|#tQMPU{V#go$$bC- literal 0 HcmV?d00001 diff --git a/routers/__pycache__/auth.cpython-311.pyc b/routers/__pycache__/auth.cpython-311.pyc index c0cfca8358ecb65253d6823e61b2a99a78a77003..2a2b7ad97749db79704778dc4b295e9398786351 100644 GIT binary patch delta 176 zcmexldBU7`IWI340}$MKQ<1rNBkwB~M!(I{tXmj?)E72&MrMW-rpa;~`i#tz{Wwe~ z&tlK)9)oCP|@a4&RL9{FhR!62Hff(`zm;tnSj(RK2?w-9tr(n22$_E!a;J$68$Uy D*2O9B delta 499 zcmY*WF>4e-6u!CJ%Wh5}x#?t~x7 zk+>n4!@1e^SI0R@cS`sR<@Z_#vY0!N5jrR;y+}?%?mP0lCcGpB&jOH_W5VgALcNcu>b%7 diff --git a/routers/__pycache__/bot.cpython-311.pyc b/routers/__pycache__/bot.cpython-311.pyc index fd9df5581457063468b8c15628ec4fbad867ea5b..2fcb7a31f5221ce6df25f7c7748ef9c51c02b1cf 100644 GIT binary patch delta 29 kcmew%^iYU*IWI340}!lzQ<3>@BkwY1M!(Icnb)!c0F!PCCjbBd delta 52 zcmaDT^h1buIWI340}wpBR-XBJBkwY1i7GMo)Dnf{(xRf&yb^_^{1S!YlEjkI;>}x_ H*Rlct;k*+3 diff --git a/routers/__pycache__/config.cpython-311.pyc b/routers/__pycache__/config.cpython-311.pyc index b41968aa46a0eb363e73843fe7a59af42d0df7d7..c63c024bc70f79e2494da8e36fdbaaaab31eb418 100644 GIT binary patch delta 413 zcmeyO`$mU%IWI340}#x6Q;`|Gk@pLuBttL*CqoGuiR(Ov@M<7*+!@1TZp00@c^D*0H7l&4sYa z8Os?em?Ig=88zAcehH)`mL%#Y=jWwmrt4)D=jUy{%DkNgZcij5`{dUg*)|;6EFcq$ zcvCoQI8(S%7+RSmp>{AZQ~_Cej6fN-8V(yEyT=pAVo%|o!xYS*$>TTqIOna+i?}{B z!%fv>u212de2nk2Dc2lskh)ry8ip*mt7}+Nco3$SF&5dEsKYH|Na3CQjz5%d O$pVa0o4*VGWdQ)oL%}*)KNmWSC$xlkmDgGsp zl30?c57D8QRh*yaw|OD+b{1Cl6d(jTM{{x;hXEtU$pVa0oAU+#vH$=(bc@{p diff --git a/routers/__pycache__/discord_mappings.cpython-311.pyc b/routers/__pycache__/discord_mappings.cpython-311.pyc index c45200864333eb4c5590f9860aae6b62ee7d4355..226390685b25840f29aad78681ee51b59792b447 100644 GIT binary patch delta 291 zcmZ3c)~?FCoR^o20SMaPRAjbn#W*_|0VChuoco@~R*C&{^nYZ)^G!)hRg z07kGzu%Ve|+%*hY>@XQ1n+?WJ;hDn>bOo%Rn@J_zSD=fyhh6Pn) S6Qd@--(+Dv=gr}K=UD(nL_LfE delta 462 zcmaJ-J4*vW7~Fg1F3DXY_)NEr!2=^i1qCZfAlO7OmNuL0dS19k*t;ZfTxBEHYhjVX zDk7$lH2wi=J3IX!cJ3M@*f_&C?6M!j>|Cvn%htVR=Y z;|l^EFKi$zXZCCS}>N zUS%)l((jtr?&>1EKnl8<*$Dt_jTHWiLQ*-z7={$~dlwqGt`n zI5`WVyMQ7Ar;}n(B=&>L#YaxcN088oAO859iAKt5BukM_NOymWa!4p&SJksSJF`0m ziQ^FO@QP6fq%+HC^ha)x;X9%CvqZ5auIy< zNs{6tJi)dr=}HL^fwhHXUCJGCvvyswKIMscSlgZSrhE}!${+Ek8X^s;#zK}n8Uadpj3PEj?j>Gq zyeM1)#3yAm7ir&ign;7+O*JEQY&$};;|MJ^BXn*%LaXBlZ8alw!3aA{r7R7xy=sJx zi(Hijy0_)+tm5se;qBR$cSjX(cMWgvw!A%6yuCHNeZcEH?|oIgJ8O7%Zp*u?inqUp zch|Nu2dj7oYIyraa*PWP{tIX4Xqf22bFthRczfrcd-8?MN=~BiCvZ3x&m}VH7o=BK zBsm9|x?{)Zk3SK1>n+(#b|o3hNzo;mkkVrEoE|VyQd&x#(|hDxjONV#DE!T>$kAjb zlhwP7K1OKk=$3PttTRt1>#AWvIg^=~7(FXz($Qo>&aDa)v1~&3EE&?vVP1DSWgJ(vAQr0rpC=-3{qh`yUFYP_=7r0$r$p2!TT>f zzJKwAEKzy!tV9#>-!3j>;wvdBos$haLe9o=@iWoWncN~} zekPX*om&mVEJkZ)YJ4V{iN})i95l+L-5`)01hCF2frCcP4_tcX z-L-et-o9}8f{DBbG~8-ydDesF(Zx-d?lv4$=M$n{pOR9iL9=ijtp^U>S5_h+Qa2(z zeZz?Y2!JGoX_f4j^+70D9q!QSDw>LYC%)4ODSK zx)WHz;XLr7IF{jQb&QM-Az~PSRXn}5Rx%m}A{@%*Svxk`ulB3Hu;vRZfw|V_1b_h8CR@j$z)}!InU*?~bGp z+suT@o?cZ)Ra+FaaEUyNhpwZmedRS8G*)&Hw;pWMoCFJ=?gD9OKLFj6NW+c?3pwnf zUc@rbuHah8+i4GevHfE z4pS;3Z%-x8b2;?KQd#5dy|C@Oe)Mhsd!u1!S+ZnxC1X0p!)Xw^91oigDXixAy+^Jz z{ITKvz@GvJ2B0xRJ(z*j%+fffbr2PC*QJ99SR};xqKyC`M-d>UIAe9Jb|(obin9+M z`FQ}w;{3+Vc^WTXC~~B3o)nmXf2+F@xD%Sa9hy}`2er^a5KnD7q(PtO&{zH@rFl&C z@74T!*B7?@-5bNIzhCqBD?-1aLtw)2CJW=SXinu8CR{xz#K zVYT~L32Dqt7xORss8;6+!Eja1MIXXXSVC4yjbQWQ zDgT|&{@bDbYG_6a&48S0)2s%4p4lz*sj%wbqxttZ`&4sfO&GB@4j(cLt_v2p6lm1$ zts=39jPr01$eY2;90eNoiIVvQ2cfC1La_#yDx&L#a1EB;C%F7rndZ(DYXCbdlzY~y zz8vS_Dh7V`U7f&JhG1FB!fX8Nu2bA=gm9yJu6EnQf?)y{6?8)H*RAB1 z_C5kZz$2yO88MMw-mIf2o$i)ihvR{)3z@8x)&-ntUBH7PWa?8$K$dK&R2%L_<{n{u zhCTs=kHaJ5wosZ}oUjK}|Cr_| z-aV>!kLKNjeSOzsTTN}3&uz54y>@x6=mHEwSHRau^=||foKx%DZ@+ayS8(_6DDwCA z`Ciu-y~2Ed{TG7-;A&$jI{h5osME6gEj~o|gQY00Upj*TV+v(6j(d5ql=3yBZ`g8_ zUqbKG@KifSNcD#`fB2{B81yj6yxIX7W$i6X9Jg^Mn|a&z#Q0sK|~Hl1*vMGue!f!S?W78E_5=%cXKytI3L8Wttyy z%_Z`1X;vNp-e=8?=!cAE8woc|R%}=^2e)DIC|*lzX5?`7<^ew!Rv$eg<%01QO5wgM zXd0OvJEh=Kh6X8IpJpiRPQepT1~chkHnuFmaWp2@9OVAXOS|t)twu3}F-0`|z$v*} z2EEJ4Ro^oTt2fEU^B*mKxcI@#S6?w!Cy3%4g1 z)XBrzXpKC(3&)`lP38rZE3%xn#Xw4p;=Fm`Z%vBlfqE^-E_a~rb}r1Kzz zUXSOyWa6}u*VQk@z@pg%#Y|Iov-@e8Vtmj$;4=EvL|l@`u^~~zyNzqK7PAi)VmgWg z;F>pX+t{5CyRBe*5r$kJ8Yu z5|AZW!sec`T^8o-PH1tJGb7{m& z&#I(P0|alSgHMz&jE+skuu4WWGJ?uIj5O$roIfUdwg*h{c`LcVKVMHU0GM`B`3cr%+vm{NTnDd){OV(sBmd$__)Pi0dFhf>Y z3wv?Uj95`EYV~P-R!oaoil%t^kQuiUT7nWEF=Q5Ik!AWax&e+a;Ojj>q9u*wVN@uHn3T7O3p-&T_7k)%74EBk8?vZ3b zGKjV8VFO)~%x%(DMYcm8>>_7-$U`0SP!~DZL(a9yjODw?g&uOA`7V;;@anO7{hCv$ zRaK@tx>~Q*8>a59^(s?sM|T>wYUx#d*|3bNqZ(CKuT`p!T@qKM8R{1gq|40_zu!kw zVj^%S6^HBMy8vvwYgOf0@h*^;$6%p|F9Ak#%`?GKT1X+UA7KFCFBuaCd;}At0RL|4ru2HH{HHpj++t1wDI;dW za47L8{60`X<*?8r16gm^DOf+F980osI(2GIxj3NY-uFNv5s|L6EQOF+=8;MI`x&>G|ghaaZ-_7)6UzT_UCd*uDH{~o4XPdWDU+Bm& z2>Ee@69}^irvd(MB5tNL@;qeDi%02Z@*G6Pf*UNCS?yZ6%uk75`P!N*H=K%Tdkt~} zyw-3G4sEL04#(N#I13yv*1Hc^f?_!K%&URpaX4CVZ*Flac^2SjXx6H+hH1?4MOfmH w*e7A|ze$wRO)~e1%x#kBdUXB%@WSLKaX#(z*(7T7@$f6E5SbRfA#gPN4X7kE@c;k- diff --git a/routers/__pycache__/pages.cpython-311.pyc b/routers/__pycache__/pages.cpython-311.pyc index 61101591f454b07f90ff34ab95472c409ad65fac..a2c87e8a955a6528db5881da1827ebcbb0e4552f 100644 GIT binary patch delta 305 zcmbOz)Gf-loR^o20SI<|smRP@o5&}@*tSvKo^i4Qqa>ri#C4LAf-Nk|fby$BiohUB zFhwYsK~s2h4r3}Kqu=JcOqW2^O_nwwHMxy#4o?-Aera)PQL%1Le)?onCY#CsSrerE zinxK=iv&P~Adsly1FMP8$)7xjO;HNYOOG!|OiwLR04h-h;$rv7F|7KNXR#_U`)LYI zR%LHd1Iw*sD3Sz97l{LjUmP~M`6;D2sdh!0KrSOlTg~QE?BhuKe4aB?h1i{eU#B1xdbio}7$FAkgB{FKt1RJ$T|AeRw{ Wi|seFbDA>>Ft9LsGkyS*VD$hDwLP2w diff --git a/routers/activity.py b/routers/activity.py new file mode 100644 index 0000000..a5ab83d --- /dev/null +++ b/routers/activity.py @@ -0,0 +1,52 @@ +"""Activity log endpoints.""" +from fastapi import APIRouter, Request +from pydantic import BaseModel +from utils.auth import get_current_user +from services.activity_log import activity_logger + +router = APIRouter(prefix="/api", tags=["activity"]) + + +class LogActionRequest(BaseModel): + action: str + details: str = "" + + +@router.get("/active_users") +async def get_active_users(request: Request): + """Get list of currently active users""" + # Update current user's activity + try: + user_info = get_current_user(request) + username = user_info.get("username", "Unknown") + await activity_logger.update_user_activity(username) + except: + pass + + users = await activity_logger.get_active_users(timeout_minutes=30) + return {"users": users} + + +@router.get("/activity_logs") +async def get_activity_logs(request: Request, limit: int = 100): + """Get recent activity logs""" + # Update current user's activity + try: + user_info = get_current_user(request) + username = user_info.get("username", "Unknown") + await activity_logger.update_user_activity(username) + except: + pass + + logs = await activity_logger.get_logs(limit=limit) + return {"logs": logs} + + +@router.post("/log_action") +async def log_action(request: Request, req: LogActionRequest): + """Log a user action""" + user_info = get_current_user(request) + username = user_info.get("username", "Unknown") + + await activity_logger.log_action(username, req.action, req.details) + return {"status": "ok"} diff --git a/routers/auth.py b/routers/auth.py index cb82874..4243c3a 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -20,7 +20,7 @@ class LoginRequest(BaseModel): def get_client_ip(request: Request) -> str: - """Get client IP address from request""" + #Get client IP address from request # Check X-Forwarded-For header first (for proxy/load balancer) forwarded = request.headers.get("X-Forwarded-For") if forwarded: @@ -29,7 +29,7 @@ def get_client_ip(request: Request) -> str: def is_locked_out(ip: str) -> bool: - """Check if IP is currently locked out""" + #Check if IP is currently locked out if ip not in failed_attempts: return False @@ -49,7 +49,7 @@ def is_locked_out(ip: str) -> bool: def record_failed_attempt(ip: str): - """Record a failed login attempt""" + #Record a failed login attempt now = datetime.now() if ip not in failed_attempts: @@ -64,13 +64,13 @@ def record_failed_attempt(ip: str): def clear_failed_attempts(ip: str): - """Clear failed attempts for an IP after successful login""" + #Clear failed attempts for an IP after successful login if ip in failed_attempts: del failed_attempts[ip] def create_jwt_token(username: str) -> str: - """Create a JWT token for the user""" + #Create a JWT token for the user expiration = datetime.utcnow() + timedelta(days=7) # Token valid for 7 days payload = { "username": username, @@ -81,7 +81,7 @@ def create_jwt_token(username: str) -> str: def verify_jwt_token(token: str) -> dict: - """Verify and decode a JWT token""" + #Verify and decode a JWT token try: payload = jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"]) return payload @@ -93,7 +93,7 @@ def verify_jwt_token(token: str) -> dict: @router.post("/login") async def login(request: Request, response: Response, req: LoginRequest): - """Login endpoint with rate limiting""" + #Login endpoint with rate limiting client_ip = get_client_ip(request) # Check if IP is locked out @@ -144,14 +144,14 @@ async def login(request: Request, response: Response, req: LoginRequest): @router.post("/logout") async def logout(response: Response): - """Logout endpoint""" + #Logout endpoint response.delete_cookie("auth_token") return {"status": "success"} @router.get("/status") async def auth_status(request: Request): - """Check authentication status""" + #Check authentication status token = request.cookies.get("auth_token") if not token: diff --git a/routers/bot.py b/routers/bot.py index 7806d22..89135a8 100644 --- a/routers/bot.py +++ b/routers/bot.py @@ -20,7 +20,7 @@ def set_assignment_manager(manager: BotAssignmentManager): @router.get("/bot_status") async def api_bot_status(): - """Get current bot status""" + #Get current bot status active_count = len(assignment_manager.active_targets) if assignment_manager else 0 return { "bot_running": STATE.bot_running, diff --git a/routers/config.py b/routers/config.py index 91ac0d3..b210920 100644 --- a/routers/config.py +++ b/routers/config.py @@ -10,7 +10,7 @@ router = APIRouter(prefix="/api", tags=["config"]) def reload_config_from_file(): - """Reload config values from JSON into module globals""" + #Reload config values from JSON into module globals path = Path("data/config.json") if not path.exists(): return @@ -29,7 +29,7 @@ def reload_config_from_file(): @router.get("/config") async def get_config(): - """Get all config values (with sensitive values masked)""" + #Get all config values (with sensitive values masked) path = Path("data/config.json") # Default config values from config.py @@ -70,7 +70,7 @@ async def get_config(): @router.post("/config") async def update_config(req: ConfigUpdateRequest): - """Update a single config value""" + #Update a single config value path = Path("data/config.json") # Valid config keys (from config.py) diff --git a/routers/discord_mappings.py b/routers/discord_mappings.py index 1b1fc58..3085896 100644 --- a/routers/discord_mappings.py +++ b/routers/discord_mappings.py @@ -14,14 +14,14 @@ assignment_manager: Optional[BotAssignmentManager] = None def set_assignment_manager(manager: BotAssignmentManager): - """Set the global assignment manager reference.""" + #Set the global assignment manager reference. global assignment_manager assignment_manager = manager @router.get("/discord_mappings") async def get_discord_mappings(): - """Get all Torn ID to Discord ID mappings""" + #Get all Torn ID to Discord ID mappings path = Path("data/discord_mapping.json") if not path.exists(): return {"mappings": {}} @@ -33,7 +33,7 @@ async def get_discord_mappings(): @router.post("/discord_mapping") async def add_discord_mapping(req: DiscordMappingRequest): - """Add or update a Torn ID to Discord ID mapping""" + #Add or update a Torn ID to Discord ID mapping path = Path("data/discord_mapping.json") # Load existing mappings @@ -59,7 +59,7 @@ async def add_discord_mapping(req: DiscordMappingRequest): @router.delete("/discord_mapping/{torn_id}") async def remove_discord_mapping(torn_id: int): - """Remove a Discord mapping""" + #Remove a Discord mapping path = Path("data/discord_mapping.json") if not path.exists(): diff --git a/routers/factions.py b/routers/factions.py index 1a1c04f..0785d4f 100644 --- a/routers/factions.py +++ b/routers/factions.py @@ -1,11 +1,18 @@ -"""Faction data population and status management endpoints.""" +#Faction data population and status management endpoints. import json from pathlib import Path from fastapi import APIRouter from models import FactionRequest from services.server_state import STATE -from services.torn_api import populate_friendly, populate_enemy, start_friendly_status_loop, start_enemy_status_loop +from services.torn_api import ( + populate_friendly, + populate_enemy, + start_friendly_status_loop, + start_enemy_status_loop, + stop_friendly_status_loop, + stop_enemy_status_loop +) from utils import load_json_list router = APIRouter(prefix="/api", tags=["factions"]) @@ -73,3 +80,30 @@ async def api_enemy_status(): return {} with open(path, "r", encoding="utf-8") as f: return json.load(f) + + +@router.post("/stop_friendly_status") +async def api_stop_friendly_status(): + await stop_friendly_status_loop() + return {"status": "friendly status loop stopped"} + + +@router.post("/stop_enemy_status") +async def api_stop_enemy_status(): + await stop_enemy_status_loop() + return {"status": "enemy status loop stopped"} + + +@router.get("/dashboard_state") +async def get_dashboard_state(): + """Get current dashboard state for restoring UI on page load""" + return { + "friendly_faction_id": STATE.friendly_faction_id, + "enemy_faction_id": STATE.enemy_faction_id, + "friendly_members": [m.model_dump() for m in STATE.friendly.values()], + "enemy_members": [m.model_dump() for m in STATE.enemy.values()], + "friendly_status_interval": STATE.friendly_status_interval, + "enemy_status_interval": STATE.enemy_status_interval, + "friendly_status_running": STATE.friendly_status_running, + "enemy_status_running": STATE.enemy_status_running + } diff --git a/routers/pages.py b/routers/pages.py index 15be19c..d3ecd9a 100644 --- a/routers/pages.py +++ b/routers/pages.py @@ -10,7 +10,7 @@ templates = Jinja2Templates(directory="templates") @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): - """Login page""" + #Login page # If already authenticated, redirect to dashboard if check_auth(request): return RedirectResponse(url="/", status_code=302) @@ -19,7 +19,7 @@ async def login_page(request: Request): @router.get("/", response_class=HTMLResponse) async def dashboard(request: Request): - """Dashboard page - requires authentication""" + #Dashboard page - requires authentication if not check_auth(request): return RedirectResponse(url="/login", status_code=302) print(">>> DASHBOARD ROUTE LOADED") @@ -28,7 +28,15 @@ async def dashboard(request: Request): @router.get("/config", response_class=HTMLResponse) async def config_page(request: Request): - """Config page - requires authentication""" + #Config page - requires authentication if not check_auth(request): return RedirectResponse(url="/login", status_code=302) return templates.TemplateResponse("config.html", {"request": request}) + + +@router.get("/users-log", response_class=HTMLResponse) +async def users_log_page(request: Request): + #Users/Log page - requires authentication + if not check_auth(request): + return RedirectResponse(url="/login", status_code=302) + return templates.TemplateResponse("users_log.html", {"request": request}) diff --git a/services/__pycache__/activity_log.cpython-311.pyc b/services/__pycache__/activity_log.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..328fa5f0bea28425868dd836c5b0e29059885dd1 GIT binary patch literal 4626 zcmcH-T}&IvdG^nL7zi;5A;jq(q;MuJ1%et}P_A#@(SGeE(;Z6FYKH3%%{-~cN?-9jwk0{>9EFn+fbxJhA5;gD` zKAx7lNVHB7u91i@+dU#%+LUHZiwS*VLQ74Ev6LcaOw|xABPLH`k2!5xYO-eyM@LxO zr^GDP(vqs}N2sU?D;D8wepHh!o4=x&mhHQmwlqBzOGFr3P}Gkz>X=+v8yX-5S~jv! zJtQiLGO%-2IjpcM7iSgjo)G1gkRqsp;(^zz3UQC(gIFB!WdfjmsPB1BjT zF!bF>QKl`XvO38z&zQ&X{j}1TBT}WU5%{XiWR*7$!*7+3pSX_)=!Np^3IRshF_5GO z*h;9Wh{x~&(&mvvI~c6EPLa1OdWi6rtQ92sw_iMSDjQ6};sim!is z;d|pB;E?fKs-ek~GGgID6gLMwC$55Eu~qKlx=Z~wx^8nBOBiaFiTe3#3#Pxw!W3NzCv5y9Dm^n2Z7ksU&&20-;=EoMsuyYQc4H!UdI2+t(DhAr;ad@-6ifYBQgeg~q36vgq@4~y$ z0LSnn!hn|L$Svq_$^f~`D9q2e>*OK#bDkj<9`1IzRXHm6q0I0(KEH#mOiyYLkIAt) zPGKMMGXktx;VNG_(&|V;a)MiN7?R^*6!K3?jv0a)d;mOdyS~fr+=0SeBORoV?Cdr9 z+w2|g2D!~J9=jaX6kX>NX9Iq5j#DxbXrX*#v!Ek7|lrSp2G+uogcJB0>v_>P28bKi*W=|0Yo{> zWLs-gr;2I-QnMY-^ecE~$*WMy!nqQu^A8*VtR52|1%KP{sA2B%t8nMbaPNz7?@GA8 z5biIYI8{6@7TelOexbFYL;%cPE(J+_)BVdYgC|}DPb}V833e5NUCY6)l82lgm>Vqw z!;3eev((~ReI1$EJRnC0V0urIt*xy+aTH-523b8AX&M>emIeYN!`?3$2H={4(iRe0 zQnPH2p;{Rua_VU z04{B0{rx4fFZgGuWs!g6pFh8FKL5k~ck^fSXA7azPe$SS=*egy)K?OA&#{j4Ztg$% z0oLD>geunVrK|L26YTgw=ZK$M@&`sj-ld}qz_pXmYd2y_pl2*8siiWO>W-il%{mFX z@eA(lbZduZ!9vwkW@kKwILx};X%9FnMezWv?tW7piHHLiVzwd#RjyX0!t z`damu&-S4HqLzw!oVsdLME5wQidssvZmMD;2DmYCThf>zc*{};`Bn-BAavgwodABr zJR}nk3V&KZ4yr!G%&n7V|nVg#5~SGCiCuY0mv{7i#|Ip$Wjp(gefHIXlXSxYY`L6*$KPHe`-0UY z;f3+}4;McCRQtSfx%1LW=cNL`BbVkbme{f=szNCa4iyG3zZ|^!V({w9;QNKa_e&g5 zt)tX75zJluw~sU*U+kP8UKlRb?_Pf$dD`5cu>`s7-=hTg=S^udYq+@As?e(zF{0SNBVsDo~(jyV+?DtvWg z6~ucB-YO)?4oFf`S277~2PNs_Of0dD;WWrxj{?2TLGEC*L(9OJW}y2tA_&eRIEMfo zD0NEIkr?bdpDZrZE`L7xC_2>{Lr)fWSQ;r;~)Dw^>D8XGrLs2R$o?+Y5)=XB+XM68bMk?l)5a literal 0 HcmV?d00001 diff --git a/services/__pycache__/bot_assignment.cpython-311.pyc b/services/__pycache__/bot_assignment.cpython-311.pyc index 61e484ff87568464910f42abe999ee86eae09fd3..c325dfa7e41b0382f6ba2c22efa45d8dcfeb4e55 100644 GIT binary patch delta 8113 zcmb7Jc~G3kmH&F~6NX_9Zayxd(Pa>_B!eU*a|j~|AtCGFSQznrk_Ltu+%q7+-xxWz zw>F8^LFss%L&PNOSaR7ZQ$?j#*{ox!YP}_|mD(XiVr5*eB!Aeht=iP0#I=>UYP0)Z ze*?@QgyrU&*LU~p*WK^^`thrul6U`=WL&e`EdqpZ+Q$QbId&x@hs<87>^V&YNsx>| z`%p$_#!zNw=8&V)F_hJrHRSAc5f66tiwMmalosYsCB zl4TF%B+?=HtXJYYog|Uz?~evX1JUu`VEFubna=4+KufY%Sob2a&;32gr zWG2;jUk^H*@WSk9yt|Q-0&zdF@w?PHcx9`Bfu5>)PQSYjm9!! zVFjCV3xt^N8Jz^neegS>D=rWCKoB5^H?%9rENY9#lCb0 z5J(qe8U+$Tv56uT*|2Rld4#=gD}|}<*!CFt+9kp=?YTt5eD*pbFxkEf=(OE?N;{~9 zjsmSP^OZ|N`W`%|k%XtUCxw@_f2-rW2(8v{(IF_>86*3BiHpq`GHRcL4od^XKv4L% z-TftCtKcGxJfcQ8BWjP2PMVW_-q&+$Bh*?2?K7 zI`ai$XWfp1K9p&~5Q+MuBN4hCrLqf26%e0^i#nklp}~YPB1a;yVF?|TBg3>7yIJ}# z_yeI{$shI82JBS_Bw_+0CIf8EUB^CBGw03vo>q@1M#KKe6=t7!v)C@T9XM}t`||W_ zoi{k^hBo$^+sESWVzP}bx(f_0oTQ35J(~;NnB0j3kH+hSvl_t@i|3)!vw^g*XFZom z>0E|)6WPssVxNSe-yiCigR~7Z84WxWl%*z0!;~+Z9)p_Yk%n{1NypqvIggOqHhKzb zPBg<2%;qDF`hz2K#HUqHBvvM@Ad2&Hl*=T7rwjxE{M6^Q6F-~EYlZXqVcs4%hdKE_ zMR_P%_LSYTLn6-$Z)#RwIz=mKSJXgi8ib=`e<;|QRS8r0NP>(lGrXg)-Te%dMo=kT z60T=muYS{YNuZ_dJB1~ri~X!H&&qF5H`LP}_PfFykBsdNK-6ucA|w-etiC9ZZ7iy` zU%=|OfLsz5P3&CJo3M)P;t%_Pf1kGPb5fhog$MoPJ_DBvMU|7#%OQDaoL^abkQW5Q z7i9`{GGT{v4K00)fd5Y+s2|Pzss;W!(fBGyJgGpBi=S z*Co{;T~nzqo2(WmoV|Z3t1r|lLa!cL^B`#;hbvce0-Grjbe;)@X`^(5y$n8PCLhzW274$BklwAb+Areq9PpTx!490FEcl6yTC(k49&?0AxU*#b4oyB}##nBx&I zs_=qz0ZXRTQ-XW6119xKIqOZTs$Mjb>d%+Uj#o_7CJY_pk*GX0;c7tbDIN(+vUmWk zxj!mP6UGyPA(_^S6B&^$;)x5OY%hd^QmvRE5q77deyb;?SEI~P0Pp9Dh%y*eS&}6& zy3~zXHdIbd*wrpgW5WRoGq}}k6z35s3~>RvRQ)Hny*VjO{b--XQ+^r>gw9X6YO2JD z+#e1}5$H4ooI|P4Mi1ll@FGEzK~Y^L^!-8EPiY<&Tp)rLOT^$KoA{cZXI~ju;`Nc8zKvblbeY*V zAJ~zFTWCcq*MGqu3V|)P;?APlr;S*ri^w5KJr4*bIL7|iuBE2{qCpda4cFNNOnGlem-qEaLyJ5ZHv=Cd!a zXuPTdR`g2M%dnzd)g1u3zh2$2m!ztghvhC6H)f-v@tNBwYFa8G3A6C_$4DQ0eR~th zX8*mt_&7&Qcrg>YV7Px!wa<8IDaAsqUy>4bKOnvHpQQP!w?*&0XAJz2iN;IHO=IMIdR8Tz}D>T z-QG3r^ryS`0!DOdbuq$@)p;vdS<-ave7Qj(Gz(1$yH%TmkX^^vW4(6cAUDmlL=k3e zOW<6GeOX2AQJ8(Cwg%|ywI$hWjL$x*-CAbCgEV~~FazZ;1hZl~MK|N*179k4u(?{V zZ;f%59s)!-12f08&uBv?;NVJK@zM?SS=b-zenKv?>w7BU2^ZhvnXE!la?HxXl=W;l z+Dk`5sB=}|s*eO^d6=T@q6J6_fh6qFaMT~{P3mc?U{pEd0+2Hv z6yqL<@hWkwWMNg0KdQG%R>{r=9zV2eS>wb^iYZc&#%TuEH`+BGGdA|~#@w#VrI96v zdaPoAp%l|VMsjLgjdRs%vmu|EQtwjC(8?VZm&&*dJgl#wNk6VwXR_I`b~k&c!8y6i zDJc9@d3smPkfZ3*R+PE5*MSUx?58$JU2Pt1FSQ+aiXRL75WJRl%lQ(Vn zA_$end@+McngpezQdly`AS8>?*E zuAw2;-8AYNf>kx2@EK`0B>2;hzQ{gl+DB$sMf2Cl=WMpwT}DTsa>CXUh>8ce)&zj1 z6SwXYCrskT!}6#c6xqAY4g0;SdE{Do2abo29aXg+#MCg5gf8R8A*o~Y2R#N6 zlV4?jI9O?@h4)P)F_+geLWqY=J#kNKwAZo&TYT)tty?BP_Eao*s%CR;?7My8&Jduf z!=w>G6-IIYxds2=sYawzOKb?-iD|>b3u_*F!q((TLAuu8Y29Vs9OKmi&CK`tTEke2YDOWHX7f zYex>S>qkneR&Hm^tQb>rmgw@m1bt!Q_tL6ZSaq9ge-xEwG7LDDQmAZrvkb)o=X9M% z6Ps^ylR9>$t=gQKREjSmUzz36<M*;66AAM z@-drP08Q;=&mJvmQEcg}PDKPUdvxQ{AcBxO(FCkLmVtvT)w5eiJsMCSKRB9e&Qvn# zBTD9smp!}9J(;W62N3n}03yjt-F?XCD;YowR%^#pMgWNv;+jzJhm2(cLV%D~b(Mky zQkYCiphD5iBp1w1wP%qc7HF^b-BdJF${o8%{;Ci!o%kG=S2`DQctxt|aai)7a(N@x13R^tv z)r|_YoZUOT$CGkSbrNT0S`)LduOG?(lBZlJl`AIH`!RFc0xS3eH{5TIA9RwXN@*iM z#6EsYH>Dn9e87UbSn=x;IEym{xZcMcib+}5j7^ctEgYP0Zlm9aap(+?W_poz94~dF zf=-w&gd@WNz;3FY>0@6zzNMO*7CV}jhVamEP)7GahPc!+FaghY8p7VjCqsjw@Wl}O z=i^(qPFS%=BMlD^LwieQL{xD+TBNgbKUf%%z5>0tDPVs*USr_)EzCA|?94>_G+}N| zVs??q9U}M-a6oiRNaE3v=!EUKtUeF`jEeLJ*rQ0?I85cyKzJk~s&%p~qJu#a!y{3j zuW-el!Mh_6iK>pmN0E(}k#Lh(g(-v{s&P!(MX++J{gScfACausJk(_r-=p+ZBx@PS zA7RBgBrhNtK=M@}zHHT07C;)@w&7lIg>Vb5IwQG*+b9S9lfw~NQq3s0*=SW0_N8Y{ zFPiLxg*!Xdv`tMXJ?h-vUc)ZtWv_{9bh*^=wM2V>M9|FhXK^~AB4SwczhF9~ZT0e5-#`C&ohJfJL z-l-<$T|En~p17;$<2>I&-j3PRHzwm9eere$zu^frF`pM($cx2YYkc6MaOW<#Dz9tf zt}VZDWl|zkIEXsN~Iu|(EMU0u|R&r0sWbdE4JZZ4E zlDqW>fAQ#@x_JFb_@b)8x z4aj9OhtZUs3yJlecCVnYdT4WmoYBO)3Ji~h1$jAB_7kVe46BD^O&t~;dHysy_BGPAc&`zA~9_;Md0nmfu* zb3LZGw7 zmoR$(5CPAOu*iPf^;A}MTuA%wGJm(1^>sUY7Im#gf=hmIbWg4OEWJr*hOo0XE;y%J V;)T@=g#?$={{x0*JDLCh delta 7132 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 diff --git a/services/__pycache__/ffscouter.cpython-311.pyc b/services/__pycache__/ffscouter.cpython-311.pyc index 02e0f28003600a221ec982b569649ea9120919a5..d6485605de26f9ba6cdb6219ca84bd92488a11f8 100644 GIT binary patch delta 328 zcmeAa>K5c(&dbZi00h@wS7aXE$g9D~$S~QA(HuzTF^Y?`WHW;#i>yloK{N!Uuuh)C zsA$cQ0))$$7#LOqF$6R-r7%=8hcQfNZew9&NCxt-tC{?R(bOESvX&K>I)qtyg31hr z3?TC-uVRwc#$hfl^-^pt3^lAS49kGdTn%$9LokCTyWiv(X6wx}nB^E5MJI1(xyQ({ dc^a!bvyLF6(E^bL$x9?ZFaVi2i6VZWX#m%LKE?n5 delta 456 zcmeAc>=fc%&dbZi00c8q)iW<{g*;E zQLOe-5l?gSR=ju-r0lf^k0OW)J%l}oR}mqihk_oQx0_fSoQ2<;_uhZr2ki6nk1u#% zcsyMUJ)eAa=}ut7`;~Q!BJzvfEL50#Oi_RwoC-%hw|%D`OFiq|-(2^uTu*|bFZpV+ zk`fi!^UOzT{aVk$T(mE5CeDt0~84k@4TIa=qa+@Bcij$yk`|z?Qjls zj+T!6@aEBsk?VcyaKbQjfIa>}r&VN=eTz2lIb1O@aqIa$*5RyUK=l+tb7 zYWFBOi{_23;5LiK$h**jUG@aYh73Z>ok$W*O6qr2|r_<1qjcPrAPs-kj=>W zgw@en4>mKoTVh?ahj-e;&+QZffm>ILSX@?XA})|@e;(a8R{M1{HVU#nz!?CnkEZ|^ z0W5$6P8>HLM$-tT$VM!MMDjUSo7NDDG@1YN gyA}WWZ&qd*cVMl19eogDAB7nGMOJwDaEK1^2MKH4dH?_b delta 636 zcmeD5T;$HToR^o20SIy)muH3vZ{&N;%=C_7@^9und?kV)aR|ubocuvhOs|F^9;_H7 zn8FaupvmI*k`c&W$#jb&ttc}!FD0k)7Heu=YHp>QCimnJR=3GKY%z?jlQY>$H*>J} zG6`t`Ro~(YOUx-vbuB8&FDg=*yqr@y?g$-Uq;`}MZ)KqG$4{76(GBBvE*bHmq2a1#ZsJ?SWpDDE>kR! zF?{oGu^-Hg#*?>5B|wZdnJgu(3GtT~$Plo8gEUN1e9navL*95mTaK7_Gy z@?v>w8?cRF?}2=FizPd?vKZocHi+Yc5C(x1aF*o97p3Orm!%f@O_oydXDr*CrBK3X zsRvRf4kD~U1jzeE_8`IqL@!WG0ss1%midlf{(`6@*xoKQI7^1$-01+HO=LJ?v|z6wdAxD ziMP}QF`Aet`9k(UqWGc`)IfOA2NOv2$r!q&W@Jj74tZ{{06Y$1H)Y@!4llHdFH zGX{k%ut9Y-O1hLaY4{UOhW7+NmEejHgx`FMB`;5ryrNT0fuDsQ?;5XlX_y@UE^pB- zngs&l6j)p;ToC=#2G_(M!LB*rM{&L2M6H;6ysGA^&4F&!ZCVrTHTUu#LBTx6b?c+k zu^N@+MzWe?Fe~l2UXsqpYbZ18W`nXBM{{r0**wO#hB9%5vO_hLnc*wPKJLO!N|Yzz zD0gk9+REBHnCaM@Iw`h%EbFF_Y=dF-; zg=|)xVu{*}Lbb!^E}ug3)-%?$tP!~7>gOT2DS6-Yt{8R14l;gz7%%bb$D8^o6|Y}# zzhtISSoX!t!)T7fNb4wE_QlpkO)<6|Em46A~%zbC)2qEdvzkU=&c`NGwF0PJq6QkeU=w+ zqa;f9Bz)F3-cpuH_Xh4{*X{5+u7gG61zW)XFJ0!D<1W*7_lx_n&qg?G~l3O2)yT-f{340!%X^&bC;o=+$#xm7DK7z4?*7F!TZU-Y=w-=3 zio7O_kyBKoIpeCXOpRApX-Mg1Q|#Gj1KWpVtTs1`_T3Zd$wVrZ(DpEvVMcOT3@2VL z5F--i#*al`HkUaWuV*@y$(&@zaJ+z$J%>=K#JdgtGBKOgAHrJ~!UOZ+fw}NtDLiP{ z?#OKma%f%-&B>9H94X3?vPn?XJFf1cwdO5UznAYgJS9h@OsJx+!fmVb?cuV3w&msJ zrEqjjIo(&$m9K3>jpSydV<;fpY@>+(jFTJ;1vdVlq_89CB(ib}%uqZTowW&-{-4F~_4;}@7I?1iwU*aC9>cJz^r^=)XZkhJ*&sAuwbPY!T E0!DEassI20 delta 1469 zcmah|U2IfE6rR~T_wG;kf4gkEZQ-_yl(mFLp;@JfV4(a|7V9o5X^?Dp?!vYEgL7{a zTJIK;CO-K)w2r=@Y@xcgv_K7!Z)C8YQ@IlYq%Gx&Bbbh{bX3o!? znRDjpsqc@3zX*i_1Y7ap6|)#!40qAh#lcmT7DzS5sj{S2FAz$KLYYQIUvZqAKx6tqwh{JAcu&8#z)Bf^EI@c|`Aq?HD>xJTx;<3H1gK4wQ59k9GVH$N_ogL5{LLV^dMs0S$l|R=n z+4g1Y!0>SCvbh%>z-euW=9jN%^FGSpuW$}=IFd@K(~3;EIBR$Omw6;u7Uv@;kFyu~ zqr{J8Bm2fD!6I3w(@q2ah>T=fdQERzJy{EEtrprG=9Y_8*$4AVJmqsd`^f@b9|UIJ z2Y*M;Q9qoGy+Q*rw{V@s;M-VVYAdg4=#OVYA&2762)q9y0u%9WKWhf(gLNPN94nrC zzjYN#u6sUX+34JB?wh}32(!V>a;aLK)rC%Hn8T7iV_{Y|rm-mGM;7ww^UFWP zbCmj)|4B@#bP1-q^Xf6&FTvN{h2X2mjT1_45SzlD^vkg&PhO1TW|oQ(B;qK{r*|Yy zAX~v{?M7I|evQEW^c(asoap%swh z`g+!E4!JhRU7Md{Zh>7`yUPW;WKD~4l!&(_ye{Figu@bEl8}c7+p~dpk^LAa9`XD3 z$1H?x4|n!dDb3wR10 last_activity_time + self.lock = asyncio.Lock() + + async def log_action(self, username: str, action: str, details: str = ""): + """Log a user action""" + async with self.lock: + timestamp = datetime.now() + log_entry = { + "timestamp": timestamp.isoformat(), + "username": username, + "action": action, + "details": details + } + self.logs.append(log_entry) + + # Update user activity + self.active_users[username] = timestamp + + async def get_logs(self, limit: int = 100) -> List[Dict]: + """Get recent logs""" + async with self.lock: + # Return most recent logs first + return list(self.logs)[-limit:][::-1] + + async def get_active_users(self, timeout_minutes: int = 30) -> List[str]: + """Get list of users active in the last N minutes""" + async with self.lock: + cutoff = datetime.now() - timedelta(minutes=timeout_minutes) + active = [ + username for username, last_activity in self.active_users.items() + if last_activity >= cutoff + ] + return sorted(active) + + async def update_user_activity(self, username: str): + """Update user's last activity timestamp""" + async with self.lock: + self.active_users[username] = datetime.now() + +# Global instance +activity_logger = ActivityLogger() diff --git a/services/bot_assignment.py b/services/bot_assignment.py index 2757355..2263683 100644 --- a/services/bot_assignment.py +++ b/services/bot_assignment.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Dict, Optional from datetime import datetime from services.server_state import STATE +from services.activity_log import activity_logger from config import ASSIGNMENT_TIMEOUT, ASSIGNMENT_REMINDER, ALLOWED_CHANNEL_ID, CHAIN_TIMER_THRESHOLD, TORN_API_KEY class BotAssignmentManager: @@ -28,7 +29,7 @@ class BotAssignmentManager: self.load_discord_mapping() def load_discord_mapping(self): - """Load Torn ID to Discord ID mapping from JSON file""" + #Load Torn ID to Discord ID mapping from JSON file path = Path("data/discord_mapping.json") if not path.exists(): print("No discord_mapping.json found") @@ -44,11 +45,11 @@ class BotAssignmentManager: print(f"Error loading discord mapping: {e}") def get_discord_id(self, torn_id: int) -> Optional[int]: - """Get Discord user ID for a Torn player ID""" + #Get Discord user ID for a Torn player ID# return self.discord_mapping.get(torn_id) async def fetch_chain_timer(self) -> Optional[int]: - """Fetch chain timeout from Torn API. Returns seconds remaining, or None if no chain or error.""" + #Fetch chain timeout from Torn API. Returns seconds remaining, or None if no chain or error. if not STATE.friendly_faction_id: return None @@ -69,7 +70,7 @@ class BotAssignmentManager: return None async def start(self): - """Start the bot assignment loop""" + #Start the bot assignment loop if self.running: print("WARNING: Bot assignment already running") return @@ -94,18 +95,16 @@ class BotAssignmentManager: print("Bot assignment stopped") def friendly_has_active_target(self, friendly_id: int) -> bool: - """Check if a friendly player already has an active target assigned""" + #Check if a friendly player already has an active target assigned for target_data in self.active_targets.values(): if target_data["friendly_id"] == friendly_id: return True return False def get_next_friendly_in_group(self, group_id: str, friendly_ids: list) -> Optional[int]: - """ - Get the next friendly in the group who should receive a target. - Prioritizes members with fewer hits. - Only returns friendlies who DON'T already have an active assignment. - """ + #Get the next friendly in the group who should receive a target. + #Prioritizes members with fewer hits. + #Only returns friendlies who DON'T already have an active assignment. if not friendly_ids: return None @@ -130,10 +129,8 @@ class BotAssignmentManager: return friendly_hits[0][0] def get_next_enemy_in_group(self, group_id: str, enemy_ids: list) -> Optional[int]: - """ - Get the next enemy in the group who needs to be assigned. - Returns None if all enemies are already assigned or not attackable. - """ + #Get the next enemy in the group who needs to be assigned. + #Returns None if all enemies are already assigned or not attackable. for eid in enemy_ids: key = f"{group_id}:{eid}" # If enemy is already assigned, skip them @@ -150,7 +147,7 @@ class BotAssignmentManager: return None async def check_chain_timer(self): - """Check chain timer and update chain state""" + #Check chain timer and update chain state timeout = await self.fetch_chain_timer() if timeout is None: @@ -173,6 +170,8 @@ class BotAssignmentManager: self.assigned_friendlies.clear() self.current_group_index = 0 self.chain_warning_sent = False + # Log to activity + await activity_logger.log_action("System", "Chain Mode Activated", f"Timer: {timeout}s, Threshold: {threshold_seconds}s") # Check if chain expired elif timeout > threshold_seconds and self.chain_active: @@ -181,6 +180,8 @@ class BotAssignmentManager: self.assigned_friendlies.clear() self.current_group_index = 0 self.chain_warning_sent = False + # Log to activity + await activity_logger.log_action("System", "Chain Mode Deactivated", f"Timer: {timeout}s exceeded threshold") # Check if chain expired (timeout = 0) elif timeout == 0 and self.chain_active: @@ -189,14 +190,18 @@ class BotAssignmentManager: self.assigned_friendlies.clear() self.current_group_index = 0 self.chain_warning_sent = False + # Log to activity + await activity_logger.log_action("System", "Chain Expired", "Chain timer reached 0") # Send 30-second warning if self.chain_active and timeout <= 30 and not self.chain_warning_sent: await self.send_chain_expiration_warning() self.chain_warning_sent = True + # Log to activity + await activity_logger.log_action("System", "Chain Expiration Warning", "30 seconds remaining") async def send_chain_expiration_warning(self): - """Send @here alert that chain is about to expire""" + #Send @here alert that chain is about to expire try: channel = self.bot.get_channel(ALLOWED_CHANNEL_ID) if channel and STATE.friendly_faction_id: @@ -208,7 +213,7 @@ class BotAssignmentManager: print(f"Error sending chain warning: {e}") async def assign_next_chain_hit(self): - """Assign next hit in round-robin fashion through groups""" + #Assign next hit in round-robin fashion through groups # Only assign if there's no active assignment waiting if self.active_targets: return # Wait for current assignment to complete @@ -265,7 +270,7 @@ class BotAssignmentManager: self.current_group_index = 0 async def assignment_loop(self): - """Main loop that monitors chain timer and assigns targets""" + #Main loop that monitors chain timer and assigns targets await self.bot.wait_until_ready() print("Bot is ready, assignment loop running with chain timer monitoring") @@ -307,7 +312,7 @@ class BotAssignmentManager: await asyncio.sleep(5) async def assign_target(self, group_id: str, friendly_id: int, enemy_id: int): - """Assign an enemy target to a friendly player""" + #Assign an enemy target to a friendly player # Get member data friendly = STATE.friendly.get(friendly_id) enemy = STATE.enemy.get(enemy_id) @@ -367,6 +372,8 @@ class BotAssignmentManager: if channel: await channel.send(message) print(f"Assigned {enemy.name} to {friendly.name} (Discord: {discord_user.name})") + # Log to activity + await activity_logger.log_action("System", "Hit Assigned", f"{friendly.name} -> {enemy.name} (Level {enemy.level})") else: print(f"Assignment channel {ALLOWED_CHANNEL_ID} not found") self.active_targets[key]["failed"] = True @@ -375,7 +382,7 @@ class BotAssignmentManager: self.active_targets[key]["failed"] = True async def monitor_active_targets(self): - """Monitor active targets for status changes or timeouts""" + #Monitor active targets for status changes or timeouts now = datetime.now() to_reassign = [] @@ -404,7 +411,11 @@ class BotAssignmentManager: if friendly_id in STATE.friendly: # Increment hit count STATE.friendly[friendly_id].hits += 1 - print(f"SUCCESS: {STATE.friendly[friendly_id].name} successfully hospitalized {enemy.name}") + friendly_name = STATE.friendly[friendly_id].name + enemy_name = enemy.name + print(f"SUCCESS: {friendly_name} successfully hospitalized {enemy_name}") + # Log to activity + await activity_logger.log_action("System", "Hit Completed", f"{friendly_name} hospitalized {enemy_name}") # Remove from active targets del self.active_targets[key] @@ -412,7 +423,13 @@ class BotAssignmentManager: # Check if enemy is no longer attackable (traveling, etc.) if enemy.status.lower() != "okay": - print(f"Target {enemy.name} is now '{enemy.status}' - removing assignment") + friendly_id = data["friendly_id"] + friendly_name = STATE.friendly.get(friendly_id).name if friendly_id in STATE.friendly else "Unknown" + enemy_name = enemy.name + enemy_status = enemy.status + print(f"Target {enemy_name} is now '{enemy_status}' - removing assignment") + # Log to activity + await activity_logger.log_action("System", "Hit Dropped", f"{friendly_name}'s target {enemy_name} became {enemy_status}") del self.active_targets[key] continue @@ -445,7 +462,11 @@ class BotAssignmentManager: friendly_ids = STATE.groups[group_id].get("friendly", []) friendly_id = self.get_next_friendly_in_group(group_id, friendly_ids) if friendly_id: + enemy_name = STATE.enemy.get(enemy_id).name if enemy_id in STATE.enemy else f"Enemy {enemy_id}" + friendly_name = STATE.friendly.get(friendly_id).name if friendly_id in STATE.friendly else f"Friendly {friendly_id}" print(f"Reassigning enemy {enemy_id} (timeout)") + # Log to activity + await activity_logger.log_action("System", "Hit Timed Out", f"Reassigning {enemy_name} to {friendly_name} (previous assignee timed out)") await self.assign_target(group_id, friendly_id, enemy_id) # Global instance (will be initialized with bot in main.py) diff --git a/services/ffscouter.py b/services/ffscouter.py index 866949d..de02aa9 100644 --- a/services/ffscouter.py +++ b/services/ffscouter.py @@ -2,10 +2,9 @@ import aiohttp from config import FFSCOUTER_KEY async def fetch_batch_stats(ids: list[int]): - """ - Fetches predicted stats for a list of Torn IDs in a single FFScouter request. - Returns dict keyed by player_id. - """ + #Fetches predicted stats for a list of Torn IDs in a single FFScouter request. + #Returns dict keyed by player_id. + if not ids: return {} diff --git a/services/server_state.py b/services/server_state.py index e9075f2..06850e6 100644 --- a/services/server_state.py +++ b/services/server_state.py @@ -30,6 +30,13 @@ class ServerState: # faction IDs for API monitoring self.friendly_faction_id: Optional[int] = None + self.enemy_faction_id: Optional[int] = None + + # status refresh state + self.friendly_status_interval: int = 10 + self.friendly_status_running: bool = False + self.enemy_status_interval: int = 10 + self.enemy_status_running: bool = False # concurrency lock for async safety self.lock = asyncio.Lock() diff --git a/services/torn_api.py b/services/torn_api.py index 31e8606..9b84105 100644 --- a/services/torn_api.py +++ b/services/torn_api.py @@ -17,10 +17,10 @@ enemy_lock = asyncio.Lock() # Populate faction (memory only) async def populate_faction(faction_id: int, kind: str): - """ - Fetch members + FFScouter estimates once and store in STATE. - kind: "friendly" or "enemy" - """ + + #Fetch members + FFScouter estimates once and store in STATE. + #kind: "friendly" or "enemy" + url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}" async with aiohttp.ClientSession() as session: @@ -65,9 +65,7 @@ async def populate_faction(faction_id: int, kind: str): # Status refresh loop async def refresh_status_loop(faction_id: int, kind: str, lock: asyncio.Lock, interval: int): - """ - Periodically refresh member statuses in STATE. - """ + #Periodically refresh member statuses in STATE. while True: try: url = f"https://api.torn.com/v2/faction/{faction_id}?selections=members&key={TORN_API_KEY}" @@ -100,6 +98,8 @@ async def populate_friendly(faction_id: int): return await populate_faction(faction_id, "friendly") async def populate_enemy(faction_id: int): + # Store enemy faction ID + STATE.enemy_faction_id = faction_id return await populate_faction(faction_id, "enemy") async def start_friendly_status_loop(faction_id: int, interval: int): @@ -109,6 +109,9 @@ async def start_friendly_status_loop(faction_id: int, interval: int): friendly_status_task = asyncio.create_task( refresh_status_loop(faction_id, "friendly", friendly_lock, interval) ) + # Save state + STATE.friendly_status_interval = interval + STATE.friendly_status_running = True async def start_enemy_status_loop(faction_id: int, interval: int): global enemy_status_task @@ -117,3 +120,26 @@ async def start_enemy_status_loop(faction_id: int, interval: int): enemy_status_task = asyncio.create_task( refresh_status_loop(faction_id, "enemy", enemy_lock, interval) ) + # Save state + STATE.enemy_status_interval = interval + STATE.enemy_status_running = True + +async def stop_friendly_status_loop(): + global friendly_status_task + if friendly_status_task and not friendly_status_task.done(): + friendly_status_task.cancel() + try: + await friendly_status_task + except asyncio.CancelledError: + pass + STATE.friendly_status_running = False + +async def stop_enemy_status_loop(): + global enemy_status_task + if enemy_status_task and not enemy_status_task.done(): + enemy_status_task.cancel() + try: + await enemy_status_task + except asyncio.CancelledError: + pass + STATE.enemy_status_running = False diff --git a/static/dashboard.js b/static/dashboard.js index 77c1a3b..3bc9e41 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -23,6 +23,21 @@ function toInt(v) { return Number.isNaN(n) ? null : n; } +// --------------------------- +// Activity logging +// --------------------------- +async function logAction(action, details = "") { + try { + await fetch("/api/log_action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, details }) + }); + } catch (err) { + console.error("Failed to log action:", err); + } +} + // --------------------------- // Status CSS helpers // --------------------------- @@ -428,6 +443,11 @@ function setupDropZones() { if (prev) prev.removeChild(member.domElement); zone.appendChild(member.domElement); } + + // Log the assignment + if (member) { + await logAction("Assigned Member to Group", `${member.name} (${kind}) -> Group ${groupKey}`); + } } else { console.warn("Unexpected zone id format", zone.id); } @@ -443,6 +463,11 @@ function setupDropZones() { if (prev) prev.removeChild(member.domElement); container.appendChild(member.domElement); } + + // Log the removal + if (member) { + await logAction("Removed Member from Group", `${member.name} (${kind})`); + } } }); @@ -516,6 +541,9 @@ async function populateFriendly() { // Refresh assignments & status UI await loadMembers("enemy"); // in case population changed cross lists await pollAssignments(); + + // Log the action + await logAction("Populated Friendly Faction", `Faction ID: ${id}, Members: ${data.members ? data.members.length : 0}`); } catch (err) { console.error("populateFriendly error:", err); } @@ -583,6 +611,9 @@ async function populateEnemy() { // Refresh assignments & status UI await loadMembers("friendly"); await pollAssignments(); + + // Log the action + await logAction("Populated Enemy Faction", `Faction ID: ${id}, Members: ${data.members ? data.members.length : 0}`); } catch (err) { console.error("populateEnemy error:", err); } @@ -620,6 +651,9 @@ async function toggleFriendlyStatus() { btn.textContent = "Start"; btn.dataset.running = "false"; btn.style.backgroundColor = ""; + // Notify server that status refresh stopped + await fetch("/api/stop_friendly_status", { method: "POST" }); + await logAction("Stopped Friendly Status Refresh"); return; } @@ -637,6 +671,7 @@ async function toggleFriendlyStatus() { btn.textContent = "Stop"; btn.dataset.running = "true"; btn.style.backgroundColor = "#ff6b6b"; + await logAction("Started Friendly Status Refresh", `Interval: ${interval}s`); } async function toggleEnemyStatus() { @@ -647,6 +682,9 @@ async function toggleEnemyStatus() { btn.textContent = "Start"; btn.dataset.running = "false"; btn.style.backgroundColor = ""; + // Notify server that status refresh stopped + await fetch("/api/stop_enemy_status", { method: "POST" }); + await logAction("Stopped Enemy Status Refresh"); return; } @@ -664,6 +702,7 @@ async function toggleEnemyStatus() { btn.textContent = "Stop"; btn.dataset.running = "true"; btn.style.backgroundColor = "#ff6b6b"; + await logAction("Started Enemy Status Refresh", `Interval: ${interval}s`); } // --------------------------- @@ -694,6 +733,9 @@ async function toggleBotControl() { btn.style.backgroundColor = data.bot_running ? "#ff4444" : "#4CAF50"; console.log(`Bot ${data.bot_running ? "started" : "stopped"}`); + + // Log the action + await logAction(data.bot_running ? "Started Bot" : "Stopped Bot"); } catch (err) { console.error("toggleBotControl error:", err); } @@ -707,6 +749,8 @@ async function resetGroups() { await clearAssignmentsOnServer(); // reload assignments & UI await pollAssignments(); + // Log the action + await logAction("Reset All Groups"); } // --------------------------- @@ -765,6 +809,104 @@ async function handleLogout() { } } +// --------------------------- +// Restore dashboard state from server +// --------------------------- +async function restoreDashboardState() { + try { + const res = await fetch("/api/dashboard_state", { cache: "no-store" }); + if (!res.ok) { + console.log("No dashboard state to restore"); + return; + } + + const state = await res.json(); + console.log("Restoring dashboard state:", state); + + // Restore friendly faction + if (state.friendly_faction_id && state.friendly_members && state.friendly_members.length > 0) { + document.getElementById("friendly-id").value = state.friendly_faction_id; + + // Load members into UI + for (const m of state.friendly_members) { + const newMember = { + id: m.id, + name: m.name, + level: m.level, + estimate: m.estimate, + status: m.status || "Unknown", + hits: m.hits || 0, + domElement: null + }; + friendlyMembers.set(m.id, newMember); + const card = createMemberCard(newMember, "friendly"); + friendlyContainer.appendChild(card); + } + console.log(`Restored ${state.friendly_members.length} friendly members`); + + // Restore status refresh if it was running + if (state.friendly_status_running) { + const interval = state.friendly_status_interval || 10; + document.getElementById("friendly-refresh-interval").value = interval; + + const btn = document.getElementById("friendly-status-btn"); + friendlyStatusIntervalHandle = setInterval(() => refreshStatus("friendly"), interval * 1000); + refreshStatus("friendly"); + btn.textContent = "Stop"; + btn.dataset.running = "true"; + btn.style.backgroundColor = "#ff6b6b"; + console.log(`Restored friendly status refresh (${interval}s)`); + } + } + + // Restore enemy faction + if (state.enemy_faction_id && state.enemy_members && state.enemy_members.length > 0) { + document.getElementById("enemy-id").value = state.enemy_faction_id; + + // Load members into UI + for (const m of state.enemy_members) { + const newMember = { + id: m.id, + name: m.name, + level: m.level, + estimate: m.estimate, + status: m.status || "Unknown", + hits: m.hits || 0, + domElement: null + }; + enemyMembers.set(m.id, newMember); + const card = createMemberCard(newMember, "enemy"); + enemyContainer.appendChild(card); + } + console.log(`Restored ${state.enemy_members.length} enemy members`); + + // Restore status refresh if it was running + if (state.enemy_status_running) { + const interval = state.enemy_status_interval || 10; + document.getElementById("enemy-refresh-interval").value = interval; + + const btn = document.getElementById("enemy-status-btn"); + enemyStatusIntervalHandle = setInterval(() => refreshStatus("enemy"), interval * 1000); + refreshStatus("enemy"); + btn.textContent = "Stop"; + btn.dataset.running = "true"; + btn.style.backgroundColor = "#ff6b6b"; + console.log(`Restored enemy status refresh (${interval}s)`); + } + } + + // Refresh assignments after restoring members + if ((state.friendly_members && state.friendly_members.length > 0) || + (state.enemy_members && state.enemy_members.length > 0)) { + await pollAssignments(); + } + + } catch (err) { + console.error("Error restoring dashboard state:", err); + console.error("Error stack:", err.stack); + } +} + // --------------------------- // Initial load // --------------------------- @@ -772,9 +914,9 @@ document.addEventListener("DOMContentLoaded", async () => { console.log(">>> DOMContentLoaded fired"); wireUp(); - // DON'T load members on initial page load - wait for user to click Populate - // This prevents showing stale data from server STATE + // Restore previous state from server (faction IDs, members, status refresh) + await restoreDashboardState(); - // Start polling for assignments (but there won't be any until members are populated) + // Start polling for assignments startAssignmentsPolling(); }); diff --git a/static/styles.css b/static/styles.css index 1c2f208..6be99bb 100644 --- a/static/styles.css +++ b/static/styles.css @@ -388,3 +388,112 @@ button:hover { background-color: #3399ff; } .config-save-btn:hover { background-color: #45a049; } + +/* Users & Activity Log Page */ +.users-log-layout { + display: flex; + gap: 1.5rem; + height: calc(100vh - 150px); +} + +.users-panel { + width: 300px; + flex-shrink: 0; +} + +.log-panel { + flex: 1; + min-width: 0; +} + +.log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.users-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 600px; + overflow-y: auto; +} + +.user-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem; + background-color: #2a2a3d; + border-radius: 6px; +} + +.user-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #4CAF50; + box-shadow: 0 0 6px #4CAF50; +} + +.user-name { + color: #f0f0f0; + font-size: 0.95rem; +} + +.no-users, .no-logs { + color: #99a7bf; + font-style: italic; + text-align: center; + padding: 2rem; +} + +.log-container { + background-color: #1a1a26; + border-radius: 8px; + padding: 1rem; + height: calc(100vh - 220px); + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +.log-entry { + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + gap: 0.8rem; + flex-wrap: wrap; +} + +.log-entry:last-child { + border-bottom: none; +} + +.log-time { + color: #66ccff; + font-weight: bold; + min-width: 70px; +} + +.log-user { + color: #ffcc66; + font-weight: bold; + min-width: 100px; +} + +.log-action { + color: #4CAF50; +} + +.log-details { + color: #99a7bf; +} + +.info-text { + color: #99a7bf; + font-size: 0.85rem; + font-style: italic; +} diff --git a/static/users_log.js b/static/users_log.js new file mode 100644 index 0000000..9f3792f --- /dev/null +++ b/static/users_log.js @@ -0,0 +1,211 @@ +// users_log.js - Activity log and active users display + +let logsPollingInterval = null; +let usersPollingInterval = null; + +async function fetchActiveUsers() { + try { + const res = await fetch("/api/active_users", { cache: "no-store" }); + if (!res.ok) { + console.error("Failed to fetch active users:", res.status); + return; + } + + const data = await res.json(); + displayActiveUsers(data.users || []); + } catch (err) { + console.error("Error fetching active users:", err); + } +} + +function displayActiveUsers(users) { + const container = document.getElementById("active-users-list"); + + if (users.length === 0) { + container.innerHTML = '
No active users
'; + return; + } + + container.innerHTML = users.map(username => ` +
+ + ${escapeHtml(username)} +
+ `).join(''); +} + +async function fetchActivityLogs() { + try { + const res = await fetch("/api/activity_logs?limit=200", { cache: "no-store" }); + if (!res.ok) { + console.error("Failed to fetch activity logs:", res.status); + return; + } + + const data = await res.json(); + displayActivityLogs(data.logs || []); + } catch (err) { + console.error("Error fetching activity logs:", err); + } +} + +function displayActivityLogs(logs) { + const container = document.getElementById("activity-log-container"); + + if (logs.length === 0) { + container.innerHTML = '
No activity logs yet
'; + return; + } + + container.innerHTML = logs.map(log => { + const time = formatTimestamp(log.timestamp); + const details = log.details ? ` - ${escapeHtml(log.details)}` : ''; + + return ` +
+ ${time} + ${escapeHtml(log.username)} + ${escapeHtml(log.action)} + ${details ? `${details}` : ''} +
+ `; + }).join(''); + + // Auto-scroll to bottom on first load + if (!container.hasAttribute('data-scrolled')) { + container.scrollTop = container.scrollHeight; + container.setAttribute('data-scrolled', 'true'); + } +} + +function formatTimestamp(isoString) { + const date = new Date(isoString); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +async function copyLogsToClipboard() { + try { + const res = await fetch("/api/activity_logs?limit=1000", { cache: "no-store" }); + if (!res.ok) { + alert("Failed to fetch logs"); + return; + } + + const data = await res.json(); + const logs = data.logs || []; + + if (logs.length === 0) { + alert("No logs to copy"); + return; + } + + // Format logs as plain text + const logText = logs.map(log => { + const time = formatTimestamp(log.timestamp); + const details = log.details ? ` - ${log.details}` : ''; + return `[${time}] ${log.username}: ${log.action}${details}`; + }).join('\n'); + + // Copy to clipboard + await navigator.clipboard.writeText(logText); + + // Show success feedback + const btn = document.getElementById("copy-log-btn"); + const originalText = btn.textContent; + btn.textContent = "Copied!"; + btn.style.backgroundColor = "#4CAF50"; + + setTimeout(() => { + btn.textContent = originalText; + btn.style.backgroundColor = ""; + }, 2000); + } catch (err) { + console.error("Error copying logs:", err); + alert("Failed to copy logs to clipboard"); + } +} + +function startPolling() { + // Fetch immediately + fetchActiveUsers(); + fetchActivityLogs(); + + // Then poll at intervals + usersPollingInterval = setInterval(fetchActiveUsers, 10000); // Every 10 seconds + logsPollingInterval = setInterval(fetchActivityLogs, 5000); // Every 5 seconds +} + +function stopPolling() { + if (usersPollingInterval) { + clearInterval(usersPollingInterval); + usersPollingInterval = null; + } + if (logsPollingInterval) { + clearInterval(logsPollingInterval); + logsPollingInterval = null; + } +} + +async function handleLogout() { + console.log("handleLogout called"); + try { + console.log("Sending logout request to /auth/logout"); + const response = await fetch("/auth/logout", { + method: "POST" + }); + console.log("Logout response status:", response.status); + + if (response.ok) { + console.log("Logout successful, redirecting to /login"); + window.location.href = "/login"; + } else { + console.error("Logout failed with status:", response.status); + window.location.href = "/login"; + } + } catch (error) { + console.error("Error during logout:", error); + window.location.href = "/login"; + } +} + +function wireUp() { + // Attach copy button handler + const copyBtn = document.getElementById("copy-log-btn"); + if (copyBtn) { + copyBtn.addEventListener("click", copyLogsToClipboard); + } + + // Attach logout handler + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) { + logoutBtn.addEventListener("click", handleLogout); + } +} + +// Initialize when page loads +document.addEventListener("DOMContentLoaded", () => { + wireUp(); + startPolling(); +}); + +// Stop polling when page is hidden/unloaded +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stopPolling(); + } else { + startPolling(); + } +}); + +window.addEventListener("beforeunload", () => { + stopPolling(); +}); diff --git a/templates/config.html b/templates/config.html index c14ebd4..ea87a37 100644 --- a/templates/config.html +++ b/templates/config.html @@ -11,7 +11,8 @@

Configuration

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

Users & Activity Log

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

Active Users

+

Users active in the last 30 minutes

+
+ +
+
+
+ + +
+
+
+

Activity Log

+ +
+
+ +
+
+
+
+
+ + + + diff --git a/utils/__init__.py b/utils/__init__.py index 5ed71a2..abd8b28 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,4 +1,4 @@ -"""Utility functions package.""" +#Utility functions package from .file_helpers import load_json_list, sync_state_from_file __all__ = ["load_json_list", "sync_state_from_file"] diff --git a/utils/__pycache__/__init__.cpython-311.pyc b/utils/__pycache__/__init__.cpython-311.pyc index c9636fa5d33b0b14005d0caa65bdda829d85e559..7afdace96a585b21a562a977567fdb8bcf5367bc 100644 GIT binary patch delta 95 zcmeyybexHIIWI340}!l!U6DCuBCn*19FQ}eA%!7@F^3_SF^VylDT;}aA%!W0Ih`qr rIfW&dL6dc2mL((0#I>!$Agzo*T&x2mJ}@&fGCr8>&8W`80ptPz1>F-I delta 142 zcmX@k^o@ykIWI340}#x=R-QR)BCn*25|A^UA%!7|A%!u8DTgtaDT*nVIf|K)A%!`G zC7mgXC51JZL6faYDzqdsC$pqdA+0noxg;|`uUMfVF*!RiJyma_mn9eLE%x~Ml>FrQ n_=zp8lH5R38G*Ri2uOTjW@Kc%!Ju$~LE*+^8Af#$PM{zFD_bL+ diff --git a/utils/__pycache__/auth.cpython-311.pyc b/utils/__pycache__/auth.cpython-311.pyc index 748cd340049bda9f3cf669c231ef5eb7cbef6d83..5cfeff24ff1b3723c23cdb73a7050f7817f7f2b1 100644 GIT binary patch delta 150 zcmZ3+|ALoyIWI340}!;luE@-q$SdjN2INdu*M` h$=le1gq47HFamLL8<6Fm~xq;m~&a8 zSQsJVtWj(!EUC;Xtf}m2EGcX)EKwX#J|~dR4&-yCFa6s=9Oe7CzfR9 z=P8tyWaea+WTqA?B<7_kq@)(4=B1?OB?ATYUNSIjEG=WStTK0j30EqVVx#%O7i diff --git a/utils/__pycache__/file_helpers.cpython-311.pyc b/utils/__pycache__/file_helpers.cpython-311.pyc index e8bf3f8c62fe7151c7ea4739182a66a1df2e95ce..a5e0a091babc8636c750b41feeb4d065c2739c50 100644 GIT binary patch delta 288 zcmey!|A&`%IWI340}#xAU6GkTkyp~g3CNkwkiw9{n8OeSqM33SbD5%;7(s029Ohh> zC>9`_C6y_SDTTF#C5jcqPGL)BOJhl42lCld7=sx!IVSdPVFhVnnC#A|$;JQ_V)2{2 zfKhDocE&S|j0}@om<<^jCvRi+)?}(-nGH0$hIKZ>T$W{w3=FG*7y=j>B7r8RuK^RQoB#M-PIswkxK8^qY delta 532 zcmZ`$JBt)S5bm0u*%u6gMk@vd3Oca2i z>85o~m|!6jARGlDSmeq{c*voOxyntv(6hEjJa$Cm*bN(%Et=GfUD2|BOE^Oov=^5* zr6SxK?BbNvoN_FTRMJS&{bl;ebA>(SbqMac_Pe(T9%PZg2)Bp3I~Z5gC>5BKDe@Gh z!ARRjCAI0VAYXWeiPvrguxe2{$%IUw?wl(40s!SYzX?t`o)mr^ruMtk};O3}kKN~nixik^|`A!#F%$k<3WH!@2jrS?#p z$P{`ol}7(bSqAP8=$u~k+R$mQn&Yv^CgX8=rSYhH%`Ob=)kjvHb36VS!> dict: - """Dependency to check authentication and return user info""" + #Dependency to check authentication and return user info token = request.cookies.get("auth_token") if not token: @@ -21,7 +21,7 @@ def get_current_user(request: Request) -> dict: def check_auth(request: Request) -> bool: - """Check if user is authenticated (returns bool, doesn't raise exception)""" + #Check if user is authenticated (returns bool, doesn't raise exception) token = request.cookies.get("auth_token") if not token: return False diff --git a/utils/file_helpers.py b/utils/file_helpers.py index 09097df..6b53749 100644 --- a/utils/file_helpers.py +++ b/utils/file_helpers.py @@ -1,11 +1,11 @@ -"""File I/O helper utilities.""" +#File I/O helper utilities import json from pathlib import Path from services.server_state import STATE def load_json_list(path: Path): - """Load a JSON file and return it as a list.""" + #Load a JSON file and return it as a list if not path.exists(): return [] with open(path, "r", encoding="utf-8") as f: @@ -13,10 +13,8 @@ def load_json_list(path: Path): async def sync_state_from_file(path: Path, kind: str): - """ - Read JSON file (list of members dicts) and upsert into STATE. - Expected member dict keys: id, name, level, estimate, optionally status/hits. - """ + #Read JSON file (list of members dicts) and upsert into STATE. + #Expected member dict keys: id, name, level, estimate, optionally status/hits. arr = load_json_list(path) received_ids = [] for m in arr: