From 3080826806357952d6b41d72bfb2582d11202c50 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Mon, 18 May 2026 23:54:44 -0400 Subject: [PATCH] Feat : Better stats --- vps-monitor/agent/agent.py | 10 +- .../backend/__pycache__/main.cpython-313.pyc | Bin 26091 -> 29230 bytes vps-monitor/backend/main.py | 52 +++ vps-monitor/frontend/src/App.jsx | 13 + vps-monitor/frontend/src/api/client.js | 5 + .../frontend/src/components/StatsModal.jsx | 388 ++++++++++++++++++ .../frontend/src/components/VpsCard.jsx | 14 +- 7 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 vps-monitor/frontend/src/components/StatsModal.jsx diff --git a/vps-monitor/agent/agent.py b/vps-monitor/agent/agent.py index f916528..1662abd 100644 --- a/vps-monitor/agent/agent.py +++ b/vps-monitor/agent/agent.py @@ -122,12 +122,14 @@ def system_info(_: None = Depends(require_api_key)): net_recv_per_sec = (net2.bytes_recv - net1.bytes_recv) * 2 return { - "cpu_percent": cpu_percent, - "ram_used": mem.used, - "ram_total": mem.total, - "ram_percent": mem.percent, + "cpu_percent": cpu_percent, + "ram_used": mem.used, + "ram_total": mem.total, + "ram_percent": mem.percent, "net_sent_per_sec": net_sent_per_sec, "net_recv_per_sec": net_recv_per_sec, + "net_bytes_sent": net2.bytes_sent, + "net_bytes_recv": net2.bytes_recv, } diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc index 99e4c741d7696efa9aa697df0c2bda71c460eb18..ace01d1729499f79c0ae20ad5ea994cc0f0343dc 100644 GIT binary patch delta 9140 zcma($33L=!a@{?L=Fl8M=oV5-LJ|Y$09{BRj4lu(BtR`;8I2i6Gc7SNmw(T2*v~s) zJNS68GjwUB9Y+RsH()>wo?FXY7lgu+|$Ei;2PKU(Nd;?|;HtnDs*g!#u@QKe1|4 z+(?W(Bd~&Qz~nR$lhaJh&TNuRaryy^(@Lzgoi$){+KHXExq%$A1bDcCT#^fK!@yE! z9?5g&lYD0ZDWEjQfkI~yDWYxDK(VuglsHRCDg8DNEOVBTGG{p{&!>p&0p3|bDtN|a ztz=x9;a>Sy9ZNd|i(qZxsOC%ytG-EnGRnAxu-jjqN@rX@*E*0@SDzSb5pm5fkY%m~X|6)hT-h%`}b8DPsZu+0?91GXXq z+d{FGfUSyWpj#<=IiRaEux%7u1K8RO>^hfGSW$da#dcx8;Gn8^xH@hsOU+vEszFzS zaVsgs24R(}Mkp4zW~~rkO;Md-{Y|nuirXmEr^y;9<_@9JRST4xfN>4QbWzM&qywy8 zXrkC|p&95}C~YlZTPe0jXmiaxHydZ7n<_p@ZHYBbfRmvT5W+|Wlc1K{h;w6)FEH~d`;06EWWE8mjVVZYFi{pkQ2 z0=gfdo+Vq;G|r6K+X4O0Vt>VeuuT|j=Y%0w8_d`C1yY7nR8>3DQifdXfNtjkIw3V$ z`*5000N2-0*Y~Dys^@`wWC8a`ntL=wDeSrp_niy4M^ic$j3zCqcQs}5%m1V=*X;tT zm(bqMyV7G|X#MGNw8HKMyC}GIeavLdnVSIs<-6>l;UH@-A?p`2aJSCxh zPg=sCwt^Gf3%Fe=?#{hw?r-vgXMu#fQxXOzQWDCTGDZl{h3N&K`dlk-;}L@(qP+cV zI#&09L>h|&lQ$*8szJR_-jJ*Lw(_PH$#bXVZQ1uVc|)$&+sM)j{zbC(PH~Q)Y%n~k z*ZIVIVxs(Q!>{tL9fk#{Slv?86P4Wi{8BVbjwHgy->_`H>@vTltA~C^qAWX?Wjo}x zmdEXF$e{6uqND>_WWD^prJ`s9HX9JsAy|_F9kS8dr>7lj0#|KnJw$SmaY{%mL zNZ5=3=ZUl;Kr2W)f^`T~Q?Nz#aag`VAIjcy3wG3Hk+yc#C)=)wr&NJVxbpnkihx_4nW z0?KhGe$jxqD>1cXie+=<_jCJ~VAv@7DKYByO^_-0@FW~dH)_{lQ^^t!i{4mNBooLi z0+6sS;;CbmEPu#P+4ci2oH$*0itR!+;s>A@CdH_CpC=Gdv_8K#>fp#EqFEwgiNz;n zm;zys&yAjvNEq-Z62ao#Y){%eMN7f~5t!40p&bE2Ds6!%gU6>zA`MhXm9d|zzI<|4)87Ld=3c? z$hOkFL{~*0+mp6mF@SE~a4ZycXh=LIOVM~^M8dKzt+0uR!SF$mN_-GHUQWDQd4Y}J zT#s}uEn0;*Hx7M<0jN8<9|1Mx81`$`aUVh~8bTfbu*hAeSj6Xvs-qu=?!Q8kd!Xhp zRX=2=(@Oy3<>WO*OXZ(bzq12B6^(c};$Rh{ComNz{^-7-!$hzOR5YRRA(BN~v8b2m zv1N7MIpXdc+p%@H&po(xh~ywrFNVBfpGbK5d`(_cdLUTdo+CJe3w@OBB_e7W4IdCg zmC3W&~M-%&j{DxyN zt}5XZsS+}95+1=x1k{7Lw22*nq78|MqcrNlqm=Xs?4!|*W2QX1New_dE>f6fB8H-L z#gJ!#=x0cH3cwdfnR&fFaeU<#O;HPcD{Kc%wqF^?>biDpts4-JkahB>b?f5=*lXzt z_(6~$N)qk{sG4FY9={~IC&PqpOEHN?t|Lng*%y#R6C(lgDzwO7;%AnWW_iK+#6RQk1-o-$)88pRPDmpHK(CfvvO+L&T60mX!io~EG-^IXG~br5dk?;UmssDn zi*58K&%=OW7%TiRque+H*eZsHvwayeo~9Uu^Syj@q);DX$047{_Z1bd2Al@PkK-Dq z&p0}pK7zKLO&yFv+$bkMVOlDG(7|)#cGtcr_UZsG)My)jkf@yrxwV1|BF!HgFs)nl?44ha3BD6gUH81d?XC@8SfK$TGsK>5h;4{Y)}Mzj9~w+9Re># z`9N(PX6fQtBJw^@Na6z`PyC@Nej+wGDUy0}0mKf`kbq>U4|)y<#E@e4Mq=)WNW8G5 z6f^Mz(;o)>fIZZw7_k)%M?C?>9)ffs;qn6>Xn0i&5xoZi6IW4IB$_x96(veaF#RhT zNThZc6)p-}A`DM{0sUkS!6gJAAovi$Wdv6MKm~_^Nb&$ECP|F?#7R#q5GCM3M#*st zBV8kcdobQL=H9V=>(Gb*1!*KAhI};7lRrmZj1lrU(i{C!7}8PD6IGMv1Rx;~U?3CR zeiRM3Y+*`Y;--a%8+|VZX(A&Z18D`sr}PidxW+)HjB9N=%hkNH)6(?TxxD(zw)#i3 zSFNR&t+lh(+OwLoz2}Utwp`k@bGB)C&MG{jxneM%Fgq1|1c(_`MQp=H+=DVF_WElgJE@+UmCLKHSqqOgeAuVY)>}(kga4&TU3*IQdQp6VtT0zsuwz69pf!hZ& ze~^7jGo;IAc=>2;1t;j_Jwt_Yo*9Jsgw-}BaXiQfv6J|<3Vt@azVtDy!bdJXT6_*BE?2Ow-R69%3IDMXCN8Xu=GQkc;rTro-9^ zlHxzYOVLRIQH;>aj=T#98Ziz|F+e%y4#43|VGk}S*X+rITT&ND(Eko>m_;OvON_Bk zOgSg&9;-Xe&Y6lYYl~;J#aB$$tGOj|$0LTT*1Qw(&|{%r*0s!8TAv>~%U-hb(>Whn ztImzhT3Y8d8k_0kvdZf^c>k8s7)|pCJ~uG7{1d^)f~ULYtYw!CWiy7d>lVgryRKn$ zC0~AKV61J|H9&aXz!=NFl<+|JKykN?J;Qcuwco3P)_EqcTW>g@XX>^Mvgga$ZnO40 zzYIG%*=~#W{KhP3U(o9jT3CRv#Ya6P8jE;ASo+aI>p}S4=m$V|06MYbI{A8Uad3*k z={v^`!BVB}HK)ofk92@KYSGC`u_Om^iw7ZNk>7ySF7zet>eQE7^JQ(pFSG@h3Rlb( zuKYl|>PuQ3J-}DWC)d4IgGYzOGR>Y?bYH5FnFoS?S!!R-Hpr*j%h}V3*V{(*!lxLMA24J>exsB2h)Z&m--N93m$u z7D^w<1BvfxB$N<52>@L38Md*?Q*^ZMT$sNMM4We0T9*V$Fy zMFA{T5agUeIp0Ho5|dJ(8b!?aWzVK%?4%su)TXDF{e}GIre*Q(U>`LXEhGPn;I{yv zNTTLFgNUyHC|R)Pya&J-%8eEgYCE+-OGi|6>bZ>;tiJ~eIwgDgRQ0C|gs1!=qJ@c^ zz{xzy$YWg(!USCH8f{H00Vkq#TP?C#qC;}{G08fT3yLHQzJu~qHxJHuq?={qi_0lg zG4l|1tA1KLaq2oh1Pm~^0pyZTQF{&tOpa*4MNf}TCg_jCPuc_BN14yFnI*-iif?2k z^u5#UHXLLKrUc)qDM4X$i&``#fL4m}kP1nqenTMa^#r7i^&22g;M7Al9+N-o8xwJc z6fS%Kl8+vX2q?m-Y`voKgpR11(}}=CqGAfdStsDeqoqogJYJ$v)SkUbd$N+_l3eKd zmi*G@L70c%Z~jRyjz{c(BsKux4_wxCs?TxiF)uAEAKW0^+8s~IlCyf}G%ya4JQSLbfU1i}0yv~L!%ICf zv|$Ue8TUyyZ88%Y*7st6HXhiA>Ds0BIR*qd4ghundYGn*6HQhO;$esZD9dQ2L5pLI z?NorIR3~LXd_oomPL?--oO=rzJQ05b*wkQEW!pzLLwBJ>dm4b zt{q_qO>LLyy3|*jEJ{~o)StW<;)55@1;c(KI_lNRU%s-{9$yQhH?nHMkDGr#1Y$Tx zV6Z?K;W}V1ILJJugKZs30W$-oG%b@!IwVL5xat;Rz~jjlokbmWi>2{H9vt(={L3_~%!uyTOA?3)n@m0%yb=V9r=KyFr)otBDHaZoC?B1f zS}v9a8j(uqe;SGzM%n~KUqBYnGugIF)|MAmys&c4+H%?4GHY(RYArddnQ88uwQim< zY`$u?ow8oGSI*iir(0iW`ElEvy=l(eJfm$U4T(<%>}(AUNZcRQk^@g$1hs?P0GgEk z4H2Kp<%9Y8^p?&9muac}tQs^rmlzm)on3ZY_sSL9E7@1&-tE=9r$OA}r36D|tPLCw zwSYuM*8{Gx3A;AK@V7TC2s>UaUhrmp$l}8?;5U+ zFH(h`BPM`SJ-_NzrB)&TY6NNZ$j?znJpj5ulWN{P=(=GelE_-%c>{hDZhbV4AeWnM zmu&0LtUJ>&XIr267J_k+iEkqV z#1*67uy#QU5*u|swKJWK)FThIvv#qe_VkI~oxIy`g~8rUcz2CzzhPEB*j>}`FF<@7 zb6!O4FyoMy5VXlF$IIgBn;HKY2S~i0AqfQ3%Up1QQ@VANZi5F)8lOwdv%A2RgK00_dxjp$adB1s1rdL_ZSinw?gkPngn1nA(d z`S8QxfjZ9Z3wzyeTC>u-BT@-nq!z*50N@C&9zEd3Oi~ZDCiFdA;|2T^M2mEo3+eTw zdRqzCj?sPCX+VB`^hg9ZlvPIhrIwAf-lE=7vL-!{0waEc<`6T2Z29Fq%UFy2-k$up z6%jUM)$Rwo=q;jp>uHD_fs2Hw-%FMtO%;M%1XmH{AqXPC08{_5laH+)1O*6ABKS4{ zMIZ6t4Wpt99`N}IsmBf;J1pXa2wiY-0Hq-mWNWZz3j*A7=>J9NKQQRO9Ml`+{n$Yh zZWvn<0-E(`@uHmwhXZQ8fjclQkIA$0>ux?ym;T#`d=J6L2=IWV7{Z~9GlH7J=dqWD z(;u*f^(L(r36^fO;8FBy$wIJPQ8bZQl_2?L2&IjX{;sF!CHjFILm&f)v($dCWf_r#F#%~j2|&p`1@yOnS5;T9VPR6Ca-XY$+=Qh zebjnVdr~-EeRB7#ou6Z>6L0Tza4k8kWqSEM1Fy5qpX2NE6-KsvzL4Q`50@S*eZ)V< z7T(}kcJ&Pn!xhbAYd$-1ZQ@zBWyV-J$5zdATDIkM)jR{QX~)^#S88WEoO5djW>yc( YR&Kk2Xk=YZ>E^A8Kl4qnTC7+84%^cyxvkhhFxb#gd2P%T2BOo3*raWYb<&2=U7552(<;~~k1;lF(#|>8c4D`r zQT_S<&wtMO&pH2p&iUQv-s3;`HJ|%wPL7R(KifN5vrq0in_HG~#lmst&o*=yTA9_E z$ugZbX0vkwFJ!pRMsM*LTzM=XW(HRQD}Zl{tI%1*ik!u)*jd6#h|}u2#aYTqX`JaQ zbC$DmX9cUExy`l8xtgtZRrE#*$mD zVp|4S3C?XKj&@;_*d~+;h8B|$br5MAc)!F}OSBH5F3DC;lun^RbO2`;2sRRBJ5e?x zA5hIg6H#{v&A_*XcpX69O4OY~i>fK6KZ#{uVrk+!<#Y(0iK{U7bv#seS3jjOXf>(fI(w)K!iKUi}H_gJ+%xkvA^a~Od2v$$T}C-e(@It;>q z*bI^1i_*ypHJIR1FPUuR1L79oaHeoL6Z>?ylKTun_}VCZyAm`#dLTWVBJD~_k0dyS zeJehS<&#%t9F)<;ave>W1NtVD`Ub?t74;oU({~`DZ@_(>z5%gm1zl$0 z)-+uQrwtCDdf9rHI+^*W*a6#zJYS^l&c2jU2MN0_X38nz`Bt?o_e_2Z5HzDdsIYbz zv2E(b-0JcU9M&V$A~YtTRsATp-%JaQ>b1Ob_4a~|>dCyk>0Q8~nLS>`9||&*9=Tv) zoha6YupMCs!cGL_X1fv6HtItf0&>WZ`P?_Sx3lw3yL4mvV9$r5y~xyzumxc&0(!yP z5b${7uHcAlapc^@7838ifp`znQZ3EbV={;<2)$}~L9ZT@!FWt|^bWy-W(l*~7_;RfWaT$Rm#&K3pq}XuLG|v~R7mN26;tkQv z(~{!$O)?)$#8_U*PE#`uHH&mY@ByJd^RIVvp;v1wtO5J0eR=W&xmoGbXH>;CqwqX(kp5 zNFbb44C8rVByB0GtRA1PiG^YPzSyz|BG zRP`IOnt&X?^6Kw|qEWi(Xqh8pnNA!^pmxP@x&U5hfd9n)JZNr1{Wo5-D1Qg{NabSo<1p){_$~ zEt|lja+sGHiT1IR2xRUZxNg!nxf7{qEQ=Oj`4*UKd?(e}|70F+=ouYsa7hcSRehteD_V@JIlTctC=w)D z#wLKKY5C0KmnHX9h`D9OqeNu7o(_wCFE1dAF~S1uc^I)D;nX6>k|jhAc%MX+f@r9t<#TyH3-c>F=A zw{9kFmo=NbAS=>5Ok~i~NsYG^*rSB@)-*SHSSESoOApyn4Ln)eTs<(^{jJ z<%uXW3H~>MVYT{|-Rt;z^^Vnlq0+>>6m5LPtvj7OdcA7}T#l4gO6CVK)3dlVVN2$;=qc`oA^b4tsg zEKe$_LGF4LnVmx!a*HDHdz}6O0PZZg{T(E{4WL=P zGm>`>oFuL2DWjN>Zzi%t%h0oV6e!2mdzDboU8cMV#16k<@q%Z;HwpIXPhe$CeQ@6}MEm`H&-T%ge*{GK4*>9b5y!X#jHzcQbtQ_Y4Yrix zE%b9>Jfgle+6=L`?$0zhK16YR3SNlcO!65OrTg3|*!d~-h_Jrq5m+5zpP}SG5y&R2 zv(WMNLWXaE=#rILjBes_WTs#&mAe+Cp)8}!r0R!f%T576@gbKkBY}?BDxH7>^#`XJ zz8a|D;029G%_{leVTob;FUJ`;ui7ObFN6qP}r-qc_bI z><;=Po|=JKH_D9i76c5P-XDL7j++7KJmO|9bqzNZ51HkF%<0&{W9M&Xs@1c}{xEKE zV^eY0EOpdV63qrCXyj>TUqXgG2yTSyY)@XO6>QH>c070d2W|oEbsj!4zGJ_*id7`` zo@c;xld$id+OYYlm3?^{t=xj}3_^?g$kdu>^7-OF49{tXZy5GN1oAmM-RBWpiXps! za1r5EgclJQ!cs77X^ExB;U#2ByllET{b@6YL^@p?jP%omj9WV4d3fDOZvCCkO2E-4 zFh&);DjsW!JvIF#A_0bqEsxnhoO*58Hr$ zl0ip>tAKz{4E;e5FL7lC7xDiKG_$f0#dl zgfj?t18C;32jALS#{8Vm&-DKbsl(Muj+=)U^rQsq@KlB#4ehZJSNaeJ5rz;(5e_2w z5b$?16)*ZRs`vUZ(x}K$r!h^2LIJ%wU?rtT40}x7H)oGhPE&?Z5ma#bO$53vS8#+q zp87Pqila{eG_&4D7`7127>+1dWI2XakYNd; dict: } +# ─── Collecteur de stats (tâche de fond) ───────────────────────────────────── + +async def _fetch_system_stat(vps: dict) -> None: + """Collecte un point de stats système pour un VPS et l'insère dans le ring buffer.""" + try: + data = await agent_get(vps, "/system") + buf = _stats_history.setdefault(vps["id"], deque(maxlen=_STATS_MAX_POINTS)) + buf.append({ + "ts": datetime.now(timezone.utc).isoformat(), + "cpu": data["cpu_percent"], + "ram_percent": data["ram_percent"], + "ram_used": data["ram_used"], + "ram_total": data["ram_total"], + "net_sent_per_sec": data["net_sent_per_sec"], + "net_recv_per_sec": data["net_recv_per_sec"], + "net_bytes_sent": data.get("net_bytes_sent", 0), + "net_bytes_recv": data.get("net_bytes_recv", 0), + }) + except Exception: + pass # VPS hors ligne ou agent non mis à jour — ignoré silencieusement + + +async def _stats_collector() -> None: + """Tâche de fond : collecte les stats de tous les VPS toutes les 5 secondes.""" + await asyncio.sleep(3) # laisse l'app finir de démarrer + while True: + vps_list = load_vps() + await asyncio.gather( + *[_fetch_system_stat(v) for v in vps_list], + return_exceptions=True, + ) + await asyncio.sleep(5) + + +@app.on_event("startup") +async def startup_event() -> None: + asyncio.create_task(_stats_collector()) + + # ─── Routes Auth ────────────────────────────────────────────────────────────── @app.get("/api/auth/status") @@ -370,6 +414,14 @@ def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get return {"status": "ok"} +@app.get("/api/vps/{vps_id}/stats") +def get_vps_stats(vps_id: str, _: Annotated[dict, Depends(get_current_user)]): + """Retourne l'historique des stats système d'un VPS (ring buffer en mémoire).""" + if not any(v["id"] == vps_id for v in load_vps()): + raise HTTPException(status_code=404, detail="VPS introuvable") + return list(_stats_history.get(vps_id, deque())) + + @app.get("/api/status") async def all_status(_: Annotated[dict, Depends(get_current_user)]): """Retourne l'état de tous les VPS en parallèle.""" diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx index e3763be..e22ee06 100644 --- a/vps-monitor/frontend/src/App.jsx +++ b/vps-monitor/frontend/src/App.jsx @@ -5,6 +5,7 @@ import VpsCard from './components/VpsCard' import LogsModal from './components/LogsModal' import AddVpsModal from './components/AddVpsModal' import EditVpsModal from './components/EditVpsModal' +import StatsModal from './components/StatsModal' import LoginPage from './components/LoginPage' const INTERVAL_OPTIONS = [ @@ -46,6 +47,8 @@ export default function App() { const [updateContent, setUpdateContent] = useState('') const [updateLoading, setUpdateLoading] = useState(false) + const [statsModal, setStatsModal] = useState(null) // { vpsId, vpsName } + // Vérifie si des utilisateurs existent (pour afficher login ou register) useEffect(() => { authStatus() @@ -263,6 +266,7 @@ export default function App() { onDelete={handleDeleteVps} onUpdate={handleUpdate} onEdit={setEditVps} + onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })} /> ))} @@ -305,6 +309,15 @@ export default function App() { onClose={() => setEditVps(null)} /> )} + + {/* Modal statistiques */} + {statsModal && ( + setStatsModal(null)} + /> + )} ) } diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js index a56ef78..044a7a8 100644 --- a/vps-monitor/frontend/src/api/client.js +++ b/vps-monitor/frontend/src/api/client.js @@ -108,3 +108,8 @@ export async function updateVps(vpsId, data) { }) return handleResponse(res) } + +export async function fetchVpsStats(vpsId) { + const res = await fetch(`${BASE}/vps/${vpsId}/stats`, { headers: authHeaders() }) + return handleResponse(res) +} diff --git a/vps-monitor/frontend/src/components/StatsModal.jsx b/vps-monitor/frontend/src/components/StatsModal.jsx new file mode 100644 index 0000000..f3afdef --- /dev/null +++ b/vps-monitor/frontend/src/components/StatsModal.jsx @@ -0,0 +1,388 @@ +import { useEffect, useState, useCallback } from 'react' +import { X, BarChart2, Cpu, MemoryStick, ArrowUp, ArrowDown, TrendingUp } from 'lucide-react' +import { fetchVpsStats } from '../api/client' + +// ─── Formatters ─────────────────────────────────────────────────────────────── + +function fmtBytes(bytes) { + if (!bytes || bytes < 1) return '0 B' + if (bytes < 1024) return `${bytes.toFixed(0)} B` + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB` + return `${(bytes / 1024 ** 3).toFixed(2)} GB` +} + +function fmtBps(bps) { + if (bps === undefined || bps === null) return '—' + if (bps < 1024) return `${bps.toFixed(0)} B/s` + if (bps < 1024 ** 2) return `${(bps / 1024).toFixed(1)} KB/s` + return `${(bps / 1024 ** 2).toFixed(2)} MB/s` +} + +function fmtRam(bytes) { + if (!bytes) return '—' + if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB` + return `${(bytes / 1024 ** 3).toFixed(1)} GB` +} + +function avg(arr) { + if (!arr.length) return '—' + return (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(1) +} + +// ─── SVG Sparkline ──────────────────────────────────────────────────────────── + +function Sparkline({ data, min = 0, max = 100, color, fill, height = 60 }) { + if (!data.length) { + return ( +
+ En attente de données… +
+ ) + } + + const W = 500 + const H = height + const P = 3 + const range = (max - min) || 1 + + const sx = (i) => P + (i / Math.max(data.length - 1, 1)) * (W - P * 2) + const sy = (v) => H - P - (Math.max(0, Math.min(1, (v - min) / range)) * (H - P * 2)) + + const linePts = data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`).join(' ') + const areaPts = [ + `${sx(0).toFixed(1)},${(H - P).toFixed(1)}`, + ...data.map((v, i) => `${sx(i).toFixed(1)},${sy(v).toFixed(1)}`), + `${sx(data.length - 1).toFixed(1)},${(H - P).toFixed(1)}`, + ].join(' ') + + // Hover tooltip state + const [tooltip, setTooltip] = useState(null) + + const handleMouseMove = (e) => { + const rect = e.currentTarget.getBoundingClientRect() + const xRatio = (e.clientX - rect.left) / rect.width + const idx = Math.min(data.length - 1, Math.max(0, Math.round(xRatio * (data.length - 1)))) + setTooltip({ idx, value: data[idx] }) + } + + return ( +
+ setTooltip(null)} + > + {/* Grid lines */} + {[25, 50, 75].map(pct => { + const y = sy(min + range * pct / 100) + return ( + + ) + })} + + {/* Area fill */} + + + {/* Line */} + + + {/* Hover dot */} + {tooltip && ( + + )} + + + {/* Tooltip bubble */} + {tooltip && ( +
+ {typeof tooltip.value === 'number' ? tooltip.value.toFixed(1) : tooltip.value} +
+ )} +
+ ) +} + +// Dual sparkline (upload + download on same chart) +function DualSparkline({ sentData, recvData, height = 60 }) { + const allValues = [...sentData, ...recvData] + const maxVal = Math.max(...allValues, 1) * 1.15 + const W = 500, H = height, P = 3 + + const sx = (i, len) => P + (i / Math.max(len - 1, 1)) * (W - P * 2) + const sy = (v) => H - P - (Math.max(0, Math.min(1, v / maxVal)) * (H - P * 2)) + + const mkLine = (data) => data.map((v, i) => `${sx(i, data.length).toFixed(1)},${sy(v).toFixed(1)}`).join(' ') + const mkArea = (data) => [ + `${sx(0, data.length).toFixed(1)},${(H - P).toFixed(1)}`, + ...data.map((v, i) => `${sx(i, data.length).toFixed(1)},${sy(v).toFixed(1)}`), + `${sx(data.length - 1, data.length).toFixed(1)},${(H - P).toFixed(1)}`, + ].join(' ') + + const [tooltip, setTooltip] = useState(null) + + const handleMouseMove = (e) => { + const rect = e.currentTarget.getBoundingClientRect() + const xRatio = (e.clientX - rect.left) / rect.width + const idx = Math.min(sentData.length - 1, Math.max(0, Math.round(xRatio * (sentData.length - 1)))) + setTooltip({ idx, sent: sentData[idx], recv: recvData[idx] }) + } + + return ( +
+ setTooltip(null)} + > + {[25, 50, 75].map(pct => ( + + ))} + + + + + {tooltip && sentData.length > 0 && ( + <> + + + + )} + + {tooltip && ( +
+ ↑ {fmtBps((tooltip.sent ?? 0) * 1024)} + ↓ {fmtBps((tooltip.recv ?? 0) * 1024)} +
+ )} +
+ ) +} + +// ─── Carte de stat ──────────────────────────────────────────────────────────── + +function StatCard({ title, icon, current, average, unit, children }) { + return ( +
+
+
+ {icon} + {title} +
+ {average !== undefined && ( + moy. {average}{unit} + )} +
+
+ {current}{unit} +
+ {children} +
+ ) +} + +// ─── Modal principal ────────────────────────────────────────────────────────── + +export default function StatsModal({ vpsId, vpsName, onClose }) { + const [stats, setStats] = useState([]) + const [loading, setLoading] = useState(true) + + const load = useCallback(async () => { + try { + const data = await fetchVpsStats(vpsId) + setStats(data) + } catch { /* silencieux */ } + setLoading(false) + }, [vpsId]) + + useEffect(() => { + load() + const id = setInterval(load, 5000) + return () => clearInterval(id) + }, [load]) + + const last = stats[stats.length - 1] + + // Séries CPU / RAM + const cpuData = stats.map(s => s.cpu) + const ramData = stats.map(s => s.ram_percent) + + // Réseau : KB/s pour l'affichage du graphique + const sentKB = stats.map(s => s.net_sent_per_sec / 1024) + const recvKB = stats.map(s => s.net_recv_per_sec / 1024) + + // Trafic cumulé (delta first → last, bytes depuis démarrage du collecteur) + const sessionSent = stats.length > 1 + ? Math.max(0, stats.at(-1).net_bytes_sent - stats[0].net_bytes_sent) + : 0 + const sessionRecv = stats.length > 1 + ? Math.max(0, stats.at(-1).net_bytes_recv - stats[0].net_bytes_recv) + : 0 + + const points = stats.length + const windowMin = points > 0 ? `${(points * 5 / 60).toFixed(0)} min` : '—' + + return ( +
+
+ + {/* En-tête */} +
+
+ +
+

{vpsName}

+

+ {points > 0 + ? `${points} points sur ~${windowMin} · collecte toutes les 5 s` + : 'En attente de données…'} +

+
+
+ +
+ + {/* Contenu */} + {loading ? ( +
+ Chargement… +
+ ) : stats.length === 0 ? ( +
+ + Aucune donnée disponible — le collecteur démarre dans quelques secondes. +
+ ) : ( +
+ + {/* CPU + RAM */} +
+ } + current={last ? last.cpu.toFixed(1) : '—'} + average={avg(cpuData)} + unit="%" + > + + + + } + current={last ? last.ram_percent.toFixed(1) : '—'} + average={avg(ramData)} + unit="%" + > + + {last && ( +

+ {fmtRam(last.ram_used)} / {fmtRam(last.ram_total)} +

+ )} +
+
+ + {/* Bande passante */} +
+
+
+ + Bande passante +
+ {last && ( +
+ + {fmtBps(last.net_sent_per_sec)} + + + {fmtBps(last.net_recv_per_sec)} + +
+ )} +
+ +
+ + + Upload + + + + Download + +
+
+ + {/* Trafic total session */} +
+

+ Trafic total (depuis le début de la collecte) +

+
+
+
+ +
+
+

{fmtBytes(sessionSent)}

+

Envoyés

+
+
+
+
+ +
+
+

{fmtBytes(sessionRecv)}

+

Reçus

+
+
+
+
+ +
+ )} +
+
+ ) +} diff --git a/vps-monitor/frontend/src/components/VpsCard.jsx b/vps-monitor/frontend/src/components/VpsCard.jsx index 72f0590..60d49c8 100644 --- a/vps-monitor/frontend/src/components/VpsCard.jsx +++ b/vps-monitor/frontend/src/components/VpsCard.jsx @@ -1,4 +1,4 @@ -import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil } from 'lucide-react' +import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2 } from 'lucide-react' import { useState } from 'react' import ContainerRow from './ContainerRow' import { tagColor } from './TagInput' @@ -14,7 +14,7 @@ function formatRam(bytes) { return `${(bytes / 1024 ** 3).toFixed(1)} GB` } -export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit }) { +export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit, onStats }) { const [collapsed, setCollapsed] = useState(false) const [updatingProject, setUpdatingProject] = useState(null) @@ -62,6 +62,16 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE {collapsed ? : } + {vps.online && ( + + )} +