From 6be406c7da39c35c94aae3f389218fd9339dfd0b Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Oct 2025 13:37:18 +0200 Subject: [PATCH] push --- .gitignore | 2 + README.md | 42 ++ __pycache__/app.cpython-313.pyc | Bin 0 -> 27573 bytes app.py | 442 ++++++++++++++++++++ pve-ha-web.service | 18 + requirements.txt | 2 + static/main.js | 697 ++++++++++++++++++++++++++++++++ static/styles.css | 28 ++ templates/index.html | 206 ++++++++++ 9 files changed, 1437 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __pycache__/app.cpython-313.pyc create mode 100644 app.py create mode 100644 pve-ha-web.service create mode 100644 requirements.txt create mode 100644 static/main.js create mode 100644 static/styles.css create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c42e330 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv +env diff --git a/README.md b/README.md new file mode 100644 index 0000000..c71cd89 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# 1) katalog + venv +sudo mkdir -p /opt/pve-ha-web +sudo chown -R $USER:$USER /opt/pve-ha-web +cd /opt/pve-ha-web + +# 2) pliki aplikacji (app.py, templates/, static/, requirements.txt) — skopiuj tu +# …gdy już je masz w katalogu… + +# 3) virtualenv + deps +python3 -m venv venv +source venv/bin/activate +python -m pip install --upgrade pip +pip install -r requirements.txt +deactivate + +# 4) systemd unit +sudo tee /etc/systemd/system/pve-ha-web.service >/dev/null <<'UNIT' +[Unit] +Description=PVE HA Web Panel +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/pve-ha-web +Environment="PYTHONUNBUFFERED=1" +ExecStart=/opt/pve-ha-web/venv/bin/gunicorn -w 2 -b 0.0.0.0:8000 app:app +Restart=on-failure +RestartSec=3 +User=root +Group=root + +[Install] +WantedBy=multi-user.target +UNIT + +# 5) start + autostart +sudo systemctl daemon-reload +sudo systemctl enable --now pve-ha-web + +# 6) sprawdzenie +systemctl status pve-ha-web +ss -ltnp | grep :8000 diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3f4a123f897e3abb622fcba9dc76870ae6412ea GIT binary patch literal 27573 zcmd^o3vgT4ncls46956eUwVP!6QW3pCiS9TrYK4xB~lhIA=4&p1|mU;HVIM}AVtwm zLMQEpvUV#fP9i9FSJ2uSL(OgtC+UQl&2|;1nN=n`&4pkG2&{FylXTm3+AS5S$*yO* z)9*hQ_X4Cq%h}zuo#_>E?z!ju=XwA0zs`TI?q_G4Ib2trzdAeF&T&7d2j#LPBlmj^ z9QS2T-~|2{C(3tT)U&%mG~li|X6!PFrmie8tII5!yDXxm z%PLwORG0Qxc9%`Gb=gIGSB{v|l`G~VT_@%ZYJ01duB7gpx$VgZv7nI?^e1_-aIk1I zH&~2c;dWlmRPJKQpbhDzf>So+q7(j6?lUMRK zblV!x2UY0yH0Xn%Ifq*NInMnu=nPTLcCmf1y>~%rC3R1uXdQzcy?>C(QDQ=ea9B7Z z9Nn#TaBDzx4i*SqD#T@_Wa_R`&7*@yd%u`US7KrT(!ZoUrS82~Q*k9G98<HNF zxY&)k*{9^D?t{k%*Ms_YE6~Dmp;YMJt#fc^_%pooiC+?Ek5iw}9Ut@g6S_lV!~TS} z&3hqXIx*!RoAkQJ6Z*dCsd0}}m(U*`cl(}C7)8&yX%C={&-o_3W1|-mcG2S<@rXly z&&1TY+wYmPK5?qUakS0xgxl*GcWOVfo_G6)&yGx_k}&$mCOnhV{)9pF_@_nhFeXL4 zOo(amyZ;!1FLMKGMsGS)GIcZO0;&iaLF3{D?QVS~=hXEa?xWEUpY;qspV0X|v;Kr_ z*gb`YJVR)7YT7Rrpg1~f_D!Fj5+{c}K3_sBPJ5jiF&{ZB+efWA9EqaF$tizhath9S zPB*%zrW&R$i0cTDf&X!h zy{{2W#Z7s<30_Q&R$Na-OlLfPAjhm#P9qRE0CL4psf%o=1sLR)@xE%2K$p7+L%`c3qAqCIpt2Fuy0|@n zbQ@W&R?szS>H*>XZ71nG$Bk@SYm$0V`|3+s@Jjzi(2FzNMs9#Gl3l$_^i!VXJ1{Wg zYfxTeC0EW3;AP2|k#l1%9NZ?gbpI;oC5^Gp|e9}GQOJsG-4tp3O5H}rgbD}TS2AWSlEEqF=W%X z0y*4xnIIhcMh&G4oQ_MP86hjXR;@)sovsR~Ysub`LnyJXutD;M) zG()otEv!H@n*yH^%?vV{iKHX^l4!#(F{fO@Ff4k&1e~UXcHHA#g=8Z2lA)OBq}Ub; z9zw0GsY#!Ih~ZWf00<*}zmH&^`4FRh{OKzxvVi?y?fX{HshHy*4Lk#9`pDafdpGcP(Na*Z-(q^)|J>74kLj75awMlmM-Sp|bWGfXVD&sqPSG?#X7P@B&xi*A!Jv}! zV9Z%8_`+vj`s|wXP#vt4tTpeMYB3L{Vi{Kq9G{5iQDJ2r zD2U?53<|mvyr6gSje4)vrRgOMUUiX0p*OwOWWHc3<6PQ&l`blXL)2J_rz4n4MJ=z+ zr6Wtr>PyIC^c@wf(GxSunO2wLxL(D6la{3j1uM~=p|qvvBQwO}3AfjM#v{&IARpHc zPfp-@WG;v15LshhKW5cC>=7SFr3vH6n2+XoPE+Yf@QxK^hu8}(@B2LnDmVvYclq2= zPzNtRfHe8aL~@8ZC{s1VeOd3|x*>*HP{$c=rxp?h-ven^?>js0nN8@=O;3W{YZ97< zZ3%vM1!86GJ>&6uW~apc3CCIY5Dj984WeQ1_~fvA+_%3$g*b@*FuC`aSb5UMJ>T$! z%U_$m{=!#Z2sK^*OyD!$@cq4y#`L+0DxOigIekZOZ>z&;5Q(rts+k;1V>e7D^@t+l zI8HsAFR=}Pj10(fIaVC!qb2H~OD2%nEBYy(KW7l*H&y>d3PanIYn=*B(KqCFA&o_BS7GYUJ(VsZtVuJ5|G z>-zp{`=jgXq_VA2e*Jr?53by(P_~2{NYJ)(s#i-46ZgQPEYtJC&xe1)cfW%_2Z&| z%E8v2#4Vkz*=QIkaPev6cM{BI1oPat&PtEFelj#7Jvks9n2he2k{YL^=e%nCpEk{N zIckrGN6%1q#i!6L>#Il#S=xMCsG|zhAo9BKW0EVTxj`4){z7Ofm|;b8gyO_NO%UOp^#T{){*|J(bXp zPo9UpOAD3{!_w6Fn16iC>+vP@P~nHqirdhPGm9}ZR4{|zY=VCtW~Vc_PbBmZ)Q83s znsF~GqH;b`9T}fX@wyzEA9)Z%r^PYPsCWTDBro`42p|BPO9BN^^G0yE(slC(KP+g7 z=hnvaD&zUids%w3VgBHzsRJyNy(-Kw2`>63)MnDBH&cv>15j0(^E=(7u& zOZt0yE`P%V1DCxnusv!mpYMp5ZHkp`iIi=5w`^bg_5b@AZ0G~YIT!zArKA)ORr z+xjl|M#{F%cSu&(2b*iY)E>}(sVmese{lZ7OWkox_I&5x|4PqgpW=O_4!_gtXg{F) z{ze1BVLYoBEo54__Y(-x$qM$m_>-zww2xPil$NxEYfQ5h08;abA+?_vQum1=u_vc2 z<%jzTGDv1kV-E~@q`D`x-~@B_#^Ld4=$2xm%n*{9;tPP7%TqJYx-;b$rss z5jV8N&jD%(KOb?&dF~fwo9|Q{j+PyXm37~K>~7(PI~xy03p-+k$8P7{Epgl_?~0Zj ziLdOJ1-{pdOVQ08aEot*X_S@EFN#un5^Mj6{15s{Z$ z!bk>J_sp4uc6e$!VF06;6feM-#Emfup zu~h(-=kt_6=8OdIg%<9f0Gk={_}ydUtCb!r40U6}XPJWL6TgTeXYuptfqBq*9GgM^ zvx{Td8v>8r$=Fbs{J7PAx$9!r;>(cM(#}QZrF>UMOPPdC{NRs)MfuQ@ z7WhmkpAxB#XpR`U_DVH zWT!o6pU82UGpP!KO{vXg78v>J|05S8%SZTb7(5^Op6ve@$A9s6JpNDe{&O;qe*~)( zGx%5k2e9B0X5j^wNyu%`_SX>J9?^7{Ce^o;`p18q%BJ8zn!2d@B=ACB1MU9i_lVF` zxw&;D)Icw)s~r^ToTS{;)($-Ddy)y}Q}1IG=ZYF0n%0!BTLghZAqqC!8LBoHFO;ur-} z6pSN)O6T#9ct+jR<9^1u6FQO<6NVZ0IJ_&IR>o~uh2m=jZylZ%;b-AzJeq*Lo&@g` zr>R2L51MiD8i8s>&u~KH^D{+9RrwG}n*65RqR&INpQNc-{2EHl;pfW&WdXylaYEm! ztDo-vawePHl*(~o!la3|EgAGJp6PQ7b=l7s~)jH}CkWqrJ} z>&L}EES668{-|uBBVyeY%6+T&W^p+8>t$hA)Y*DNe@h>+9!@Xcehcj%k=x(oxPJcH z`QW)%=R!?y?Yy}&-1PO<@VRfFfBXE>xo^%bPA{~_%Ql2Pk-WW#)i$BZ+SY5kZ;wd> zpNjT;>U*Wq=vZuYA~HH5d8ebJGYjpD#*4?}8yt(n7kd(Eeg5Qa26X>iZSGq-7qzx6 z@ISLyFE+*3Z@7Ns+L2(}t6d@fE#pmNnE$#t-1hAwZy#A|`)1c-A6l)dUGhgZ99rmP zeZRMd%iZ!|FIQ9+sJg!O+SXSaqJ_@y>Xx30?dy*0>yGX_5#9Af#Bx&7os@;1KLTE? z5PFE$((1e41?ez-3tjeaNZFH8JAm-D0Ak5kCKnS3Ezp4!@qrvwRH3B``(_-7zBUg?D+Ft2e+#5I-%zd-?MsXyjJNsko0ndc_8yPq9+%9=;}+}u(T5GbB<(W9 zJID|(g=mkm%*bY77YCJkKB-buJ*%!1LXk=>X!oEM+KKF5kC&g(Xq-Z?$%C0>c0sRj zU6_D%r*lw`_8~OMa_MLKZ=z4&)U(40-ONOiL_6rJdL0Zp#0d#ECBoo-Y4e8?zp~r6B|1`uXSJwN%{b zkK1!E&s>~Y9KH0J1>KvYH%3FJUiSw4@%;xQ`+EX>V8^RjS9C#tI5*r9t`5!JvG0Dj zZ%}$>DE7>7~Ry=|2u@ABjyWc~r0|&FBMh&q4UASIL?IS`&Qu*T78GbfaK1X|f6#XPD^$ z9=1QK^cwjp-mj6LW}?Zs60OPz*C?N@_j<4tn5@RCNv#1DP{f<&#D9kX<{P$XbQ3Vxh~K81v?yMkxiS+NdF92Ztuki&RJc4m z^Ub=OmA6`M!xH-AnIFzbrv`uY8L8)4>DW-jIh0%%9~PH|#J6T{&V)z5{+W15*>%G; zLty4rTUhgL!`lW_VZ%bbUVW`PSQ0JU5*EKb^Y+ZrSaj#ndq!QUZQ;lZKJm6|N;JlV}AoWG0ymrkF-z1-r@El42s6%`PcBtAZe_ zck{Yo)k|Aw>XPyr`cDZwAyWUaOV1E$kAhHX%M87w%>(r3`G3CtxU`uTflCi5OTUd* za z`-qmnGFVHeU=qp1oK=&GLQ)uO#-?z8FQ!%QMw4^f>+@)PvEe^2p0r{ETXgyHlB zCSg@j3S0WL-!Fnm!zeqGX;02R;StYxQrjHj4*>fPem)-aJO^e?g(UucrSsyUg*MoN7dtM^{WPZpv4HtK z`(`+EFP6WeyO+fkZd^8V#U-rGLe9Pk*3|sct6Q&Z{YnEi3$>~l`SPbPeL9%`wN3F7 z$F*#jXh%T=u>V*7zyu<&Xt(+C*T zS`;pI&v$%a$q5vwDg_QhL``*2DM(96q9SP`!OCbs^~LH?$9y|nSr^V-K7aB2;<-z6 zmk!JyB7?=zi$@n-mrlg2rBmrue~PxI;~7X2xt0qWGNrjR(}169S;0qS2(1i(-8en%#>snfOg%7o4JjT& zj*l(_AWY4gkKL~`W+;>Oh%zQ@US#2C#?J!r&&=?*Bb~>o?OW38@@5UF*bE3O>tS{} z#A+<1Rx*^aJ)(>yZ3U?go&ADKmwA)#eYdTd?VXcmvt(8a(k~aX}p^= zHV#jEN5{@E$#Kro2v1M1zj4fqlNs3P;O26ZDcF*)pY&1s+=gV5*W*7wDL!9MS>y&P zj=I?E#M116)D~+I?q(M*o)79nm7${0d8uZnRJ1Fay*rkDV5xWhFl?jqpgfDS zVSmUP$Kiz0&wm2tt43S>V}Xeno}3gFN?eSrKdj?b$u$Z>Gqz=UL@7hYQr1V5GG;85{c)wR7j1IEaOu*xv>=aqm%#<+ zI%v3AVEoW%e}oq~XY@|W)C+oy%*`1c6Mv5~O^q{fSHn5bIejCYvHU2jF}XM5Ltc_1 z$uZJzLD1lk{=Bb|wle0j8*v=cH;gl7bdnUZF{4GYKAfx;1}4;+Fe#NLEXg{_MK)o? z167;Qvsx3@^!mh?&_Pt1(w*N&Lc&B9GJ5(~G#_>h9XMvzJ9&L{f4n&SK% zz~lkpt4BaqQG1{`=#)yfO1WDVqEH+z2phs@q?-Lw(Sd08`TV(ns&E$j*|S2c?qssQpmP4hITk=amJH1YMys z;gRsErCrk2L(;mAXx`yi-tpU;=Z`(AZEL~enZR_&7}mi9V?e5IlL`+;t?e;u=Phx* z<8J=Cz>`5w=zQ289$o5|>bs=%$D;YiWBI+(6TS1tKg=nJ=amq>fxus|K3=?;;<-if zlFEDeW}-Oeqyy9n+CriM6QlkGBF}2lg>&Wf2k40I!p zQ#d``oAXIehd1!zN`}r?( zeK=rpbQT&rn#$zXk+K`OIFg{4o?HewFf+8ei;yNG0ot(^ys8bS!9$O6U<$pOlsuZ! z5PBhDuD%orR?w3M?It3xB~MzCQ6H+;Am^|%AUZe?$`eI_Bl7O?U3_w1GN!lJ9LE3UxKSDuX(?hT%MbNa?~sQ>lP z;$dm?qULTzbx?eL=bQU(?0fxywBuN`;@INsot$B*w=dRvTIxTI_&p<+R|H0ylP5FJ zZ0fWP0S26f&1>eY=t*Q@Gh2>AP=rZ?6KS{@dK<<6D+Rwq;G?DBa6F#ek6k@F@o$m6 zjz)DH5sd0CT%n!}c!K9c{?KT+Q>xl86K9>)ni(2GOM8ofU6OBKZI^Iog?4c+cp zxv~v`!D#80@8mBHMt66m9pYPo*{Owz_P)$DfzdzDL#M%$e97MPjLmPYV|Knj2K#dt?7O z!fv+y_G10%aJEU^mg(PFgS_seij7LO48Zv9;${1n*EB#cn_VdnbOn;EP;kZJh3x)a ztgdy*x~MeB2pF)o*RGUL-NA;l6+4`R>n>J$0LE;ao&=ov3DGm^5vQMXBwYpf5|->& z*>8iRhYoX(kIezZ>z+G*!BKnKGv{bXE)l1B%rW7fLZ2Z}r!001etq;*h1Hc)5{076K6!y5djMh8|lgj87o5aUa zsC0Z>qeke?zg3NC!sQw@>VC@_(*(~oYSjOhHA15m41~RD>i`x{a0QKET$P^7(zSwV zRXXTCxI)tZHr?jHFc$*7K*FH8MQn_=X$D*Zhl4y(-ZjTghj~4EPs-~7b5B00GE?T%XR`^(?@UduN%Yyzx zYmrp8H)7rU8%~>D4g;!GR2Q+<{YHzY53L1((ulR#G-LCDZm$D}t1kCc9pl2bHy5DbG8bZIjGq-l4fn%{v+D;0rK)njMA~|1}YJ$C(b=tpud1i4U*c&Q|+8=`-lEwZ9`+vHwD(HT7-@@Tb z$6<1|<^I~nS@Yh{EnaB5TUj4+zj^$|@oT2=bI9+!r{(fW?wdGU0qQr|Vy4oFsT9_L zr+;FqMV_qyZ^W86-~F%|kSHUO$oo0K&vfZk5u(z~fNGj1Wq`xJqY>p-T>{^znxYg! zh{esejZ%gyrM{2by6|e#*pw zfJL}0>J!$E%c?w$9OCFRIwWwg%cah-H#=2kq!4AYGxoujdjHtil#g_zYG1pYeMLEY z4O(|)W%8oMVMmZdx-j3^OCj9q|DvFw5(5zS zUNcaozz&V1#J|N zSR;Og0-6)?%LpJtrLz7TrT%vcUZem^P&PfljPGTxy~fGWQHqEu$j=baYY3cXuxT<_ zxy3UH)7eR%pPlV7r%rPw^o&C%jLJ##lPH!j(D6C<`GiIE;5g|pzOXjwO=#g;kk z&H{G$lbwxYCxF-v20Ifj`!9cun*R<3trU=sDu2G`2NWX_0OG{t@beyjf)2SW-4X{W z+eLw!0&ovbyoH+2c$-$ z*;Qo!b3|>M=Z}1tlYiBC#Tc+eb1GvwTSA)o&JS?>spxvywX)!mSn1YC=~h@33d#aC zUp#Q%s4cAgm4Pd$Sa9PrDll3k<)2X7TOBt$!Zly-iI2Y-^+xNtDd4=z~rp1mfX zQyR;0L~-at9Y&(7F7o?M2nghwI9HFNOG7FUp*uinnTR)`S~S(Uy)|Gk-Dny*a5DU+i#4kDQi9 zo{yG|<2BkMy5e70%K|O2bvxhK5v$mKr(%1wVrSI4Yrf;df|55zW7WIvRPTybx5kS~ z?&fcNWnV16I+9-<&98m8xjow45o{7%g_-kh=X@X{e zHip=RU2N@c@*045OdC?>%*->h;jDjRoIVKS#~F1G%Yo8IH6(N(Q9jvW#(JpZaGUFR z-%!tqLmeXRt0%JBo_J!Yue0xX2afJih3TL4(92$*rC6Dtq18#e4N9FQ@ABS@dt;V$ z5zD&3;Wv&)Elt?Kvy=t){_zDkx?2uOxWUeIMl8>mh{*3^#s+9? z>`M8aD69o@2ah5eYI26eLX1r&wM7)8xn>keETKW;$0ZeE6FI@LoeeS#ex7nkAXnFMHy!TpOg#$R5l#z z8N#GZ2b+mF^6|MB0!yEZY?=#ok60uN@B&SU6L6RibMED>7q`aDWf5~3HhMQl%~j+S zlJ{fxmm)_21t_0gN?K;pqb!Tnx&S#u;zJFZCVbWd8cGYtP3C5-LDgAR-2gni8$>jP zgxGQ+1YdOlVUzt&!&7brvI@$sFmq> zR_F$#S>`0`#COb~ji*S1K22X&`;yYuQ%35vG}YZj(55sxRZ`_g(HjRg7rZ*J7N0@^ zk!B(zEgt%St#{IU)(v^lE&J0aEW?x2>^vDI+vErL_&C-Tj*^&UUy#qCtW2H$gkr>G zWD@m<6x)UXbnLqnuRuV4&gAasV-Akuf1vboOvpnr8d z?Zq0v4(1z&LdCCl-z^5kcxCQe!cxIEpSq{l78vexTB{M_l-@|+53x`3fB(L9uaA_t zw@TYJy6;&xQh319ZZy1OGaw|l9xxvH>vzL|Q!ka!R${P^BD5*JME)wM8yJY4m8!A#Kcbjp#%!&aF(Ify$(T?~xITiDw!I zSQv9*#Fg#RF=I93zbDXIBKv6DkgKP&PZ$#VPCUWBhImt#(6Miav;jceih9NWg_yEi zsTiw?2FovAd<-zJ;pdwH9FW?W^@X|5&3#_}(CuA(q_isd>4<%M*oUueN~TsIA%vPc zmUY3(5X6|4n5A_|1Ls7Goj!-WcsOcV7qis8_9BuBi^0uwxr>zn-MhNYKg%v#;9olt zYWvCh=8Mmun#GZzHe#uibd@qKw=#50ekt((1AGLo$(M?E*c_3sjCiMFrJz}Xzbeu*4xKEEy}(oC~hO9(4j-5naVn;h43c%hD2gW zZxYP!1aW?ry$lvS-G9-=6S(C}Kmr)4gJ`8T8Bl|!? zM@8Y@<2gMIONRd}z8s9tusZo)k`2qr_c?pysUE|lc!UBAO**Lp%!VM6x|A@$%b31e zs->-BfQY1%1??4HT>=DAV4F$&u-C&wjBRx%lIJZ{WQ_ z1?Ktt297ts&t<*OS>NZf-{&msFZX9$^Lt$L&$!ko*ZQHgY(6V)FO1pC@7T-nIq-RF zB6r`s4O;Y-{(u+ze9@eS`Rum z1G+7j@ceOpv7H(?&Ib-XVE1K%mfsS9p9XHhL!tZ|oh%xzd_XD7W-afObDgPNr=06t zHtYGuKqY)U_{QKcyM=XO;q5FIT^e~n88FB2hxkCnyIlKExv4;<#7%`N-s7g?T*spD zF4qy~Is?t`a-DImodGTD?7S^d8Svp~$LzI+2OJ)kHye3FdKw;=^X$Am&=;%>`auW*DbT#k(LLA_(uRbP>BjyC?0LD1 zH}ctwra=Cc>}3woU~Q=RM*RbdF6U*TnM1++;Bc_yS`SUIeYq%`&k3{ywZXnnMaUPb zz45H%XpXFFdB7oKd830bf2dA7h;2GD#< zyR_?VZ=|W?0VOUU=1u&DMNBbn!Ghp$s3oio3t`jE{ZjSrNO>#ESem`9zdbAor=b+;P;*!t+I7SGfF75(ng~x&jPB{> z=y|!In$HUWTXADOY7U;gvGD;tF8A{Fs1!JbTk8EhpvUD;@tj8gh2ocr7xF*9{+>p| zckuVMoW{1yqOdzAbJs=rLdxDkxkZ$_C6!qk%v@ATl&5( zKW@s2nKs=qZHk)8@xMpt^k3+Csb_JQRJDES*nCe!cT`OZ4lda!BwTiwf3Oy)y|M>sc-(s9o@m@ LTuxWUsM!Ao1N8sK literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..1d3d12f --- /dev/null +++ b/app.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +# /opt/pve-ha-web/app.py +import os +import re +import shlex +import socket +import json +import time +import subprocess +from typing import List, Dict, Any, Optional, Tuple +from flask import Flask, request, jsonify, render_template + +APP_TITLE = "PVE HA Panel" +DEFAULT_NODE = socket.gethostname() +HA_UNITS_START = ["watchdog-mux", "pve-ha-crm", "pve-ha-lrm"] +HA_UNITS_STOP = list(reversed(HA_UNITS_START)) + +app = Flask(__name__, template_folder="templates", static_folder="static") + +# ---------------- exec helpers ---------------- +def run(cmd: List[str], timeout: int = 25) -> subprocess.CompletedProcess: + return subprocess.run(cmd, check=False, text=True, capture_output=True, timeout=timeout) + +def get_text(cmd: List[str]) -> str: + r = run(cmd) + return r.stdout if r.returncode == 0 else "" + +def get_json(cmd: List[str]) -> Any: + if cmd and cmd[0] == "pvesh" and "--output-format" not in cmd: + cmd = cmd + ["--output-format", "json"] + r = run(cmd) + if r.returncode != 0 or not r.stdout.strip(): + return None + try: + return json.loads(r.stdout) + except Exception: + return None + +def post_json(cmd: List[str]) -> Any: + # force "create" for POST-like agent calls + if cmd and cmd[0] == "pvesh" and len(cmd) > 2 and cmd[1] != "create": + cmd = ["pvesh", "create"] + cmd[1:] + r = run(cmd) + if r.returncode != 0 or not r.stdout.strip(): + return None + try: + return json.loads(r.stdout) + except Exception: + return None + +def is_active(unit: str) -> bool: + return run(["systemctl", "is-active", "--quiet", unit]).returncode == 0 + +def start_if_needed(unit: str, out: List[str]) -> None: + if not is_active(unit): + out.append(f"+ start {unit}") + run(["systemctl", "start", unit]) + +def stop_if_running(unit: str, out: List[str]) -> None: + if is_active(unit): + out.append(f"- stop {unit}") + run(["systemctl", "stop", unit]) + +def ha_node_maint(enable: bool, node: str, out: List[str]) -> None: + cmd = ["ha-manager", "crm-command", "node-maintenance", "enable" if enable else "disable", node] + out.append("$ " + " ".join(shlex.quote(x) for x in cmd)) + r = run(cmd) + if r.returncode != 0: + out.append(f"ERR: {r.stderr.strip()}") + +# ---------------- collectors ---------------- +def get_pvecm_status() -> str: return get_text(["pvecm", "status"]) +def get_quorumtool(short: bool = True) -> str: return get_text(["corosync-quorumtool", "-s" if short else "-l"]) +def get_cfgtool() -> str: return get_text(["corosync-cfgtool", "-s"]) +def get_ha_status_raw() -> str: return get_text(["ha-manager", "status"]) +def get_pvesr_status() -> str: return get_text(["pvesr", "status"]) + +def votequorum_brief() -> Dict[str, Any]: + out = get_quorumtool(True) + rv: Dict[str, Any] = {} + rx = { + "expected": r"Expected votes:\s*(\d+)", + "total": r"Total votes:\s*(\d+)", + "quorum": r"Quorum:\s*(\d+)", + "quorate": r"Quorate:\s*(Yes|No)" + } + for k, rgx in rx.items(): + m = re.search(rgx, out, re.I) + rv[k] = (None if not m else (m.group(1).lower() if k == "quorate" else int(m.group(1)))) + out_l = get_quorumtool(False) + lines = [ln for ln in out_l.splitlines() if re.match(r"^\s*\d+\s+\d+\s+\S+", ln)] + rv["members"] = len(lines) if lines else None + return rv + +def api_cluster_data() -> Dict[str, Any]: + return { + "cluster_status": get_json(["pvesh", "get", "/cluster/status"]) or [], + "ha_status": get_json(["pvesh", "get", "/cluster/ha/status"]) or [], + "ha_resources": get_json(["pvesh", "get", "/cluster/ha/resources"]) or [], + "ha_groups": get_json(["pvesh", "get", "/cluster/ha/groups"]) or [], + "nodes": get_json(["pvesh", "get", "/nodes"]) or [], + } + +def enrich_nodes(nodes_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for n in nodes_list or []: + name = n.get("node") + if not name: + out.append(n) + continue + detail = get_json(["pvesh", "get", f"/nodes/{name}/status"]) or {} + if "loadavg" in detail: n["loadavg"] = detail["loadavg"] + if "cpu" in detail: n["cpu"] = detail["cpu"] + if "memory" in detail: + n["mem"] = detail["memory"].get("used"); n["maxmem"] = detail["memory"].get("total") + if "rootfs" in detail: + n["rootfs"] = detail["rootfs"].get("used"); n["maxrootfs"] = detail["rootfs"].get("total") + out.append(n) + return out + +# ---------------- ha-manager parser ---------------- +def parse_ha_manager(text: str) -> Dict[str, Any]: + nodes: Dict[str, Dict[str, str]] = {} + resources: Dict[str, Dict[str, str]] = {} + current_node: Optional[str] = None + for line in (text or "").splitlines(): + s = line.strip() + m = re.match(r"node:\s+(\S+)\s+\(([^)]+)\)", s) + if m: + current_node = m.group(1) + nodes.setdefault(current_node, {"node": current_node, "state": m.group(2)}) + continue + m = re.match(r"(lrm|crm)\s+status:\s+(\S+)", s) + if m and current_node: + nodes[current_node]["lrm" if m.group(1)=="lrm" else "crm"] = m.group(2) + continue + m = re.match(r"service:\s+(\S+)\s+on\s+(\S+)\s+\(([^)]+)\)", s) + if m: + sid, node, flags = m.group(1), m.group(2), m.group(3) + rec = {"sid": sid, "node": node, "flags": flags} + rec["state"] = "started" if "started" in flags else ("stopped" if "stopped" in flags else rec.get("state")) + resources[sid] = rec + continue + m = re.match(r"service:\s+(\S+)\s+\(([^)]*)\)\s+on\s+(\S+)", s) + if m: + sid, flags, node = m.group(1), m.group(2), m.group(3) + rec = {"sid": sid, "node": node, "flags": flags} + rec["state"] = "started" if "started" in flags else ("stopped" if "stopped" in flags else rec.get("state")) + resources[sid] = rec + continue + m = re.match(r"service\s+(\S+):\s+(\S+)\s+on\s+(\S+)", s) + if m: + sid, st, node = m.group(1), m.group(2), m.group(3) + resources[sid] = {"sid": sid, "state": st, "node": node} + continue + return {"nodes": list(nodes.values()), "resources": list(resources.values())} + +# ---------------- SID utils / indexes ---------------- +def norm_sid(s: Optional[str]) -> Optional[str]: + if not s: return None + s = str(s) + m = re.match(r"^(vm|ct):(\d+)$", s) + if m: return f"{m.group(1)}:{m.group(2)}" + m = re.match(r"^(qemu|lxc)/(\d+)$", s) + if m: return ("vm" if m.group(1) == "qemu" else "ct") + f":{m.group(2)}" + return s + +def cluster_vmct_index() -> Dict[str, str]: + items = get_json(["pvesh", "get", "/cluster/resources"]) or [] + idx: Dict[str, str] = {} + for it in items: + t = it.get("type") + if t not in ("qemu", "lxc"): continue + vmid = it.get("vmid"); node = it.get("node") + sid = (("vm" if t=="qemu" else "ct") + f":{vmid}") if vmid is not None else norm_sid(it.get("id")) + if sid and node: idx[sid] = node + return idx + +def cluster_vmct_meta() -> Dict[str, Dict[str, Any]]: + items = get_json(["pvesh", "get", "/cluster/resources"]) or [] + meta: Dict[str, Dict[str, Any]] = {} + for it in items: + t = it.get("type") + if t not in ("qemu", "lxc"): continue + sid = norm_sid(it.get("id")) or (("vm" if t == "qemu" else "ct") + f":{it.get('vmid')}") + if sid: + meta[sid] = { + "sid": sid, "type": t, "vmid": it.get("vmid"), + "node": it.get("node"), "name": it.get("name"), + "status": it.get("status"), "hastate": it.get("hastate") + } + return meta + +def merge_resources(api_res: List[Dict[str, Any]], + parsed_res: List[Dict[str, Any]], + vmct_idx: Dict[str, str]) -> List[Dict[str, Any]]: + by_sid: Dict[str, Dict[str, Any]] = {} + + # seed from /cluster/ha/resources + for r in (api_res or []): + sid = norm_sid(r.get("sid")) + if not sid: + continue + x = dict(r) + x["sid"] = sid + by_sid[sid] = x + + # merge runtime from ha-manager + for r in (parsed_res or []): + sid = norm_sid(r.get("sid")) + if not sid: + continue + x = by_sid.get(sid, {"sid": sid}) + for k, v in r.items(): + if k == "sid": + continue + if v not in (None, ""): + x[k] = v + by_sid[sid] = x + + # fill node from /cluster/resources + for sid, x in by_sid.items(): + if not x.get("node") and sid in vmct_idx: + x["node"] = vmct_idx[sid] + + return list(by_sid.values()) + +# ---------------- VM details ---------------- +def sid_to_tuple(sid: str, meta: Dict[str, Dict[str, Any]]) -> Optional[Tuple[str, int, str]]: + sid_n = norm_sid(sid) + if not sid_n: return None + m = re.match(r"^(vm|ct):(\d+)$", sid_n) + if not m: return None + typ = "qemu" if m.group(1) == "vm" else "lxc" + vmid = int(m.group(2)) + node = (meta.get(sid_n) or {}).get("node") + return (typ, vmid, node) + +def vm_detail_payload(sid: str) -> Dict[str, Any]: + meta = cluster_vmct_meta() + tup = sid_to_tuple(sid, meta) + if not tup: return {"sid": sid, "error": "bad sid"} + typ, vmid, node = tup + if not node: return {"sid": sid, "error": "unknown node"} + base = f"/nodes/{node}/{typ}/{vmid}" + current = get_json(["pvesh", "get", f"{base}/status/current"]) or {} + config = get_json(["pvesh", "get", f"{base}/config"]) or {} + agent_info = None; agent_os = None; agent_ifaces = None + if typ == "qemu": + agent_info = get_json(["pvesh", "get", f"{base}/agent/info"]) + agent_os = post_json(["pvesh", "create", f"{base}/agent/get-osinfo"]) or None + agent_ifaces = post_json(["pvesh", "create", f"{base}/agent/network-get-interfaces"]) or None + return { + "sid": norm_sid(sid), "node": node, "type": typ, "vmid": vmid, + "meta": meta.get(norm_sid(sid), {}), + "current": current, "config": config, + "agent": {"info": agent_info, "osinfo": agent_os, "ifaces": agent_ifaces} if typ=="qemu" else None + } + +# ---------------- Node details ---------------- +def node_detail_payload(name: str) -> Dict[str, Any]: + if not name: return {"error": "no node"} + status = get_json(["pvesh", "get", f"/nodes/{name}/status"]) or {} + version = get_json(["pvesh", "get", f"/nodes/{name}/version"]) or {} + timeinfo = get_json(["pvesh", "get", f"/nodes/{name}/time"]) or {} + services = get_json(["pvesh", "get", f"/nodes/{name}/services"]) or [] + network_cfg = get_json(["pvesh", "get", f"/nodes/{name}/network"]) or [] + netstat = get_json(["pvesh", "get", f"/nodes/{name}/netstat"]) or [] # may be empty on some versions + disks = get_json(["pvesh", "get", f"/nodes/{name}/disks/list"]) or [] + subscription = get_json(["pvesh", "get", f"/nodes/{name}/subscription"]) or {} + return { + "node": name, + "status": status, + "version": version, + "time": timeinfo, + "services": services, + "network_cfg": network_cfg, + "netstat": netstat, + "disks": disks, + "subscription": subscription + } + +def node_ha_services(node: str) -> Dict[str, str]: + svcs = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or [] + def one(name: str) -> str: + for s in svcs: + if s.get("name") == name: + st = s.get("state") or s.get("active") or "" + return "active" if str(st).lower() in ("running", "active") else (st or "inactive") + return "" + return {"crm_state": one("pve-ha-crm"), "lrm_state": one("pve-ha-lrm")} + +def units_for_node(node: str) -> Dict[str, str]: + """ + Preferuj /nodes//services. Normalizuj nazwy (bez .service) + i mapuj różne pola na 'active'/'inactive'. + """ + wanted = {"watchdog-mux", "pve-ha-crm", "pve-ha-lrm"} + svc = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or [] + states: Dict[str, str] = {} + + def norm_state(s: dict) -> str: + # Proxmox bywa: {"state":"enabled","active":"active"} albo {"active":1} albo {"status":"running"} itp. + raw_active = str(s.get("active", "")).lower() + status = str(s.get("status", "")).lower() + substate = str(s.get("substate", "")).lower() + state = str(s.get("state", "")).lower() + any_active = ( + raw_active in ("active", "running", "1", "true") or + status in ("active", "running") or + substate in ("running") or + ("running" in state) + ) + return "active" if any_active else "inactive" + + for s in svc: + name_raw = (s.get("name") or "") + name = re.sub(r"\.service$", "", name_raw) + if name in wanted: + states[name] = norm_state(s) + + # fallback lokalny tylko jeśli API nic nie zwróciło + if not states: + for u in wanted: + states[u] = "active" if is_active(u) else "inactive" + + # zawsze zwróć pełny zestaw + for u in wanted: + states.setdefault(u, "inactive") + + return states + +# ---------------- snapshot ---------------- +def status_snapshot(node: str) -> Dict[str, Any]: + vq = votequorum_brief() + api = api_cluster_data() + api["nodes"] = enrich_nodes(api.get("nodes", [])) + ha_raw = get_ha_status_raw().strip() + parsed = parse_ha_manager(ha_raw) + vmct_ix = cluster_vmct_index() + + ha_status = api.get("ha_status") or [] + if not ha_status and parsed.get("nodes"): + ha_status = [{ + "node": n.get("node"), "state": n.get("state"), + "crm_state": n.get("crm", ""), "lrm_state": n.get("lrm", "") + } for n in parsed["nodes"]] + if not ha_status: + for it in api.get("cluster_status", []): + if it.get("type") == "node": + ha_status.append({ + "node": it.get("name"), + "state": "online" if it.get("online") else "offline", + "crm_state": "", "lrm_state": "" + }) + + enriched = [] + for n in ha_status: + node_name = n.get("node"); crm = n.get("crm_state") or ""; lrm = n.get("lrm_state") or "" + if node_name and (not crm or not lrm): + try: + svc = node_ha_services(node_name) + if not crm: n["crm_state"] = svc.get("crm_state", "") + if not lrm: n["lrm_state"] = svc.get("lrm_state", "") + except Exception: + pass + enriched.append(n) + api["ha_status"] = enriched + + api["ha_resources"] = merge_resources(api.get("ha_resources", []), parsed.get("resources", []), vmct_ix) + + units = units_for_node(node or socket.gethostname()) + return { + "node_arg": node, "hostname": socket.gethostname(), + "votequorum": vq, "units": units, + "cfgtool": get_cfgtool().strip(), "pvecm": get_pvecm_status().strip(), + "ha_raw": ha_raw, "replication": get_pvesr_status().strip(), + "api": api, "ts": int(time.time()) + } + +# ---------------- web ---------------- +@app.get("/") +def index(): + node = request.args.get("node", DEFAULT_NODE) + return render_template("index.html", title=APP_TITLE, node=node) + +@app.get("/api/info") +def api_info(): + node = request.args.get("node", DEFAULT_NODE) + return jsonify(status_snapshot(node)) + +@app.get("/api/vm") +def api_vm_detail(): + sid = request.args.get("sid", "") + return jsonify(vm_detail_payload(sid)) + +@app.get("/api/node") +def api_node_detail(): + name = request.args.get("name", "") + return jsonify(node_detail_payload(name)) + +@app.get("/api/list-vmct") +def api_list_vmct(): + meta = cluster_vmct_meta() + ha_sids = {norm_sid(r.get("sid")) for r in (api_cluster_data().get("ha_resources") or []) if r.get("sid")} + nonha = [v for k, v in meta.items() if k not in ha_sids] + return jsonify({ + "nonha": nonha, "ha_index": list(ha_sids), + "count_nonha": len(nonha), "count_all_vmct": len(meta) + }) + +@app.post("/api/enable") +def api_enable(): + if os.geteuid() != 0: + return jsonify(ok=False, error="run as root"), 403 + data = request.get_json(force=True, silent=True) or {} + node = data.get("node") or DEFAULT_NODE + log: List[str] = [] + ha_node_maint(True, node, log) + for u in HA_UNITS_STOP: stop_if_running(u, log) + return jsonify(ok=True, log=log) + +@app.post("/api/disable") +def api_disable(): + if os.geteuid() != 0: + return jsonify(ok=False, error="run as root"), 403 + data = request.get_json(force=True, silent=True) or {} + node = data.get("node") or DEFAULT_NODE + log: List[str] = [] + for u in HA_UNITS_START: start_if_needed(u, log) + ha_node_maint(False, node, log) + return jsonify(ok=True, log=log) + +if __name__ == "__main__": + import argparse + p = argparse.ArgumentParser() + p.add_argument("--bind", default="127.0.0.1:8088", help="addr:port") + p.add_argument("--node", default=DEFAULT_NODE, help="default node") + args = p.parse_args() + DEFAULT_NODE = args.node + host, port = args.bind.split(":") + app.run(host=host, port=int(port), debug=False, threaded=True) diff --git a/pve-ha-web.service b/pve-ha-web.service new file mode 100644 index 0000000..9ffc1c0 --- /dev/null +++ b/pve-ha-web.service @@ -0,0 +1,18 @@ +[Unit] +Description=PVE HA Web Panel +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/pve-ha-web +Environment="PYTHONUNBUFFERED=1" +# Jeżeli chcesz zdefiniować domyślny node: +# Environment="DEFAULT_NODE=pve1" +ExecStart=/opt/pve-ha-web/venv/bin/gunicorn -w 2 -b 0.0.0.0:8000 app:app +Restart=on-failure +RestartSec=3 +User=root +Group=root + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e4a286c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask +gunicorn diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..75d0097 --- /dev/null +++ b/static/main.js @@ -0,0 +1,697 @@ +// ------ helpers ------ +const $ = (q)=>document.querySelector(q); +function safe(v){ return (v===undefined||v===null||v==='') ? '—' : String(v); } +function ensureArr(a){ return Array.isArray(a)?a:[]; } +function pct(p){ if(p==null) return '—'; return (p*100).toFixed(1)+'%'; } +function humanBytes(n){ if(n==null) return '—'; const u=['B','KiB','MiB','GiB','TiB','PiB']; let i=0,x=+n; while(x>=1024&&i${safe(txt)}`; +} +function rowHTML(cols, attrs=''){ return `${cols.map(c=>`${c??'—'}`).join('')}`; } +function setRows(tbody, rows){ tbody.innerHTML = rows.length ? rows.join('') : rowHTML(['—']); } +function fmtSeconds(s){ + if(s==null) return '—'; + s = Math.floor(s); + const d = Math.floor(s/86400); s%=86400; + const h = Math.floor(s/3600); s%=3600; + const m = Math.floor(s/60); s%=60; + const parts = [h.toString().padStart(2,'0'), m.toString().padStart(2,'0'), s.toString().padStart(2,'0')].join(':'); + return d>0 ? `${d}d ${parts}` : parts; +} +function parseNetConf(val){ + const out={}; if(!val) return out; + val.split(',').forEach(kv=>{ + const [k,v] = kv.split('='); + if(k && v!==undefined) out[k.trim()]=v.trim(); + }); + return out; +} +function parseVmNetworks(config){ + const nets=[]; + for(const [k,v] of Object.entries(config||{})){ + const m = k.match(/^net(\d+)$/); + if(m){ nets.push({idx:+m[1], raw:v, ...parseNetConf(v)}); } + } + nets.sort((a,b)=>a.idx-b.idx); + return nets; +} +function kvGrid(obj, keys, titleMap={}){ + return `
+ ${keys.map(k=>` +
+
+
${titleMap[k]||k}
+
${safe(obj[k])}
+
+
`).join('')} +
`; +} + +// ------ DOM refs ------ +const nodeInput=$('#node'), btnEnable=$('#btnEnable'), btnDisable=$('#btnDisable'), btnToggleAll=$('#btnToggleAll'); +const btnRefresh=$('#btnRefresh'), btnAuto=$('#btnAuto'), selInterval=$('#selInterval'); +const healthDot=$('#healthDot'), healthTitle=$('#healthTitle'), healthSub=$('#healthSub'); +const qSummary=$('#q-summary'), qCardsWrap=$('#q-cards'), unitsBox=$('#units'), replBox=$('#repl'); +const tblHaRes=$('#ha-res tbody'), tblHaStatus=$('#ha-status tbody'), tblNodes=$('#nodes tbody'), tblNonHA=$('#nonha tbody'); +const pvecmPre=$('#pvecm'), cfgtoolPre=$('#cfgtool'), footer=$('#footer'); + +// ------ actions ------ +async function callAction(act){ + const node = nodeInput.value || ''; + const r = await fetch('/api/'+act,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({node})}); + const d = await r.json(); alert(d.ok?'OK':('ERROR: '+(d.error||'unknown'))); +} +btnEnable.onclick=()=>callAction('enable'); +btnDisable.onclick=()=>callAction('disable'); +btnToggleAll.onclick=()=>{ + document.querySelectorAll('.accordion-collapse').forEach(el=>{ + const bs = bootstrap.Collapse.getOrCreateInstance(el,{toggle:false}); + el.classList.contains('show')?bs.hide():bs.show(); + }); +}; + +// ------ refresh control ------ +let REF_TIMER = null; +async function fetchSnapshot(){ + const r = await fetch('/api/info?node='+encodeURIComponent(nodeInput.value||'')); + return await r.json(); +} +async function doRefresh(){ + const d = await fetchSnapshot(); + renderSnap(d); + if (!doRefresh.didNonHA){ await renderNonHA(); doRefresh.didNonHA=true; } +} +btnRefresh.onclick = doRefresh; +btnAuto.onclick = ()=>{ + if(REF_TIMER){ + clearInterval(REF_TIMER); REF_TIMER=null; + btnAuto.textContent='OFF'; + btnAuto.classList.remove('btn-success'); btnAuto.classList.add('btn-outline-success'); + selInterval.disabled = true; + }else{ + const iv = parseInt(selInterval.value||'30000',10); + REF_TIMER = setInterval(doRefresh, iv); + btnAuto.textContent='ON'; + btnAuto.classList.remove('btn-outline-success'); btnAuto.classList.add('btn-success'); + selInterval.disabled = false; + } +}; +selInterval.onchange = ()=>{ + if(REF_TIMER){ + clearInterval(REF_TIMER); + REF_TIMER = setInterval(doRefresh, parseInt(selInterval.value||'30000',10)); + } +}; + +// ------ VM detail API ------ +async function fetchVmDetail(sid){ + const r = await fetch('/api/vm?sid='+encodeURIComponent(sid)); + return await r.json(); +} +// ------ Node detail API ------ +async function fetchNodeDetail(name){ + const r = await fetch('/api/node?name='+encodeURIComponent(name)); + return await r.json(); +} + +// ------ VM detail card ------ +function renderVmDetailCard(d){ + const meta = d.meta || {}; + const cur = d.current || {}; + const cfg = d.config || {}; + const ag = d.agent || {}; + const agInfo= ag.info || null; + const agOS = ag.osinfo && ag.osinfo.result ? ag.osinfo.result : null; + const agIfs = ag.ifaces && ag.ifaces.result ? ag.ifaces.result : null; + + const statusBadge = /running|online|started/i.test(meta.status||cur.status||'') + ? badge(meta.status || cur.status || 'running','ok') + : badge(meta.status || cur.status || 'stopped','err'); + + const maxmem = cur.maxmem ?? (cfg.memory? Number(cfg.memory)*1024*1024 : null); + const used = cur.mem ?? null; + const free = (maxmem!=null && used!=null) ? Math.max(0, maxmem-used) : null; + const balloonEnabled = (cfg.balloon !== undefined) ? (Number(cfg.balloon)!==0) : (cur.balloon !== undefined && Number(cur.balloon)!==0); + const binfo = cur.ballooninfo || null; + + const nets = parseVmNetworks(cfg); + + let guestName = agOS && (agOS.name || agOS.pretty_name) || (agInfo && agInfo.version) || ''; + let guestIPs = []; + if (Array.isArray(agIfs)){ + agIfs.forEach(i=>{ + (i['ip-addresses']||[]).forEach(ip=>{ + const a = ip['ip-address']; if (a && !a.startsWith('fe80')) guestIPs.push(a); + }); + }); + } + + const nicStats = cur.nics || {}; + const nicRows = Object.keys(nicStats).sort().map(ifn=>{ + const ns = nicStats[ifn]||{}; + return rowHTML([ifn, humanBytes(ns.netin||0), humanBytes(ns.netout||0)]); + }); + + const bstat = cur.blockstat || {}; + const bRows = Object.keys(bstat).sort().map(dev=>{ + const s=bstat[dev]||{}; + return rowHTML([ + dev, humanBytes(s.rd_bytes||0), safe(s.rd_operations||0), + humanBytes(s.wr_bytes||0), safe(s.wr_operations||0), + safe(s.flush_operations||0), humanBytes(s.wr_highest_offset||0) + ]); + }); + + const ha = cur.ha || {}; + const haBadge = ha.state ? (/started/i.test(ha.state)?badge(ha.state,'ok'):badge(ha.state,'warn')) : badge('—','dark'); + + const sysCards = { + 'QMP status': cur.qmpstatus, + 'QEMU': cur['running-qemu'], + 'Machine': cur['running-machine'], + 'PID': cur.pid, + 'Pressure CPU (some/full)': `${safe(cur.pressurecpusome)}/${safe(cur.pressurecpufull)}`, + 'Pressure IO (some/full)': `${safe(cur.pressureiosome)}/${safe(cur.pressureiofull)}`, + 'Pressure MEM (some/full)': `${safe(cur.pressurememorysome)}/${safe(cur.pressurememoryfull)}` + }; + + const head = ` +
+
${meta.name || cfg.name || d.sid}
+
${(d.type||'').toUpperCase()} / VMID ${d.vmid} @ ${d.node}
+
+
${statusBadge}
+ ${meta.hastate ? `
HA: ${badge(meta.hastate, /started/i.test(meta.hastate)?'ok':'warn')}
` : ''} + ${ha.state ? `
HA runtime: ${haBadge}
` : ''} +
`; + + const quick = ` +
+
+
CPU
${cur.cpu !== undefined ? (cur.cpu*100).toFixed(1)+'%' : '—'}
+
vCPUs: ${safe(cur.cpus ?? cfg.cores ?? cfg.sockets)}
+
+
+
Memory (used/free/total)
+
${ + (used!=null && maxmem!=null) + ? `${humanBytes(used)} / ${humanBytes(free)} / ${humanBytes(maxmem)}` + : '—' + }
+
Ballooning: ${balloonEnabled ? badge('enabled','ok') : badge('disabled','err')}
+ ${binfo ? `
Balloon actual: ${humanBytes(binfo.actual)} | guest free: ${humanBytes(binfo.free_mem)} | guest total: ${humanBytes(binfo.total_mem)}
` : ''} +
+
+
Disk (used/total)
+
${(cur.disk!=null && cur.maxdisk!=null) ? `${humanBytes(cur.disk)} / ${humanBytes(cur.maxdisk)}` : '—'}
+
R: ${humanBytes(cur.diskread||0)} | W: ${humanBytes(cur.diskwrite||0)}
+
+
+
Uptime
${fmtSeconds(cur.uptime)}
+
Tags: ${safe(cfg.tags||meta.tags||'—')}
+
+
`; + + const netRows = (parseVmNetworks(cfg)).map(n=>{ + const br = n.bridge || n.br || '—'; + const mdl = n.model || n.type || (n.raw?.split(',')[0]?.split('=')[0]) || 'virtio'; + const mac = n.hwaddr || n.mac || (n.raw?.match(/([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/)?.[0] || '—'); + const vlan = n.tag || n.vlan || '—'; + const fw = (n.firewall==='1') ? badge('on','warn') : badge('off','dark'); + return rowHTML([`net${n.idx}`, mdl, br, vlan, mac, fw]); + }); + const netTable = ` +
+ + + ${netRows.length?netRows.join(''):rowHTML(['—','—','—','—','—','—'])} +
IFModelBridgeVLANMACFW
+
`; + + const nicLive = ` +
+ + + ${nicRows.length?nicRows.join(''):rowHTML(['—','—','—'])} +
TAPRXTX
+
`; + + const blkTable = ` +
+ + + ${bRows.length?bRows.join(''):rowHTML(['—','—','—','—','—','—','—'])} +
DeviceRead bytesRead opsWrite bytesWrite opsFlush opsHighest offset
+
`; + + const agentSummary = (agInfo||agOS||guestIPs.length) + ? `
+ ${agOS ? `
Guest OS: ${safe(guestName)}
`:''} + ${guestIPs.length ? `
Guest IPs: ${guestIPs.map(ip=>badge(ip,'info')).join(' ')}
`:''} + ${agInfo ? `
Agent: ${badge('present','ok')}
`:`
Agent: ${badge('not available','err')}
`} +
` + : '
No guest agent data
'; + + const cfgFacts = { + 'BIOS': cfg.bios, 'UEFI/EFI disk': cfg.efidisk0 ? 'yes' : 'no', + 'CPU type': cfg.cpu, 'Sockets': cfg.sockets, 'Cores': cfg.cores, 'NUMA': cfg.numa, + 'On boot': cfg.onboot ? 'yes' : 'no', 'OS type': cfg.ostype, 'SCSI hw': cfg.scsihw + }; + + // Collapsible raw JSON + const rawId = `raw-${d.type}-${d.vmid}`; + const rawBtn = ` + `; + const rawBox = ` +
+ +
+
${JSON.stringify(cur, null, 2)}
+
${JSON.stringify(cfg,  null, 2)}
+
${JSON.stringify(ag, null, 2)}
+
+
`; + + return ` + ${head} + ${quick} + +
+
Network (config)
+ ${netTable} +
Network (live)
+ ${nicLive} +
+ +
+
Disks (block statistics)
+ ${blkTable} +
+ +
+
System / QEMU
+ ${kvGrid(sysCards, Object.keys(sysCards))} +
+ +
Config facts
${kvGrid(cfgFacts, Object.keys(cfgFacts))}
+ +
${agentSummary}
+ + ${rawBtn} + ${rawBox} + `; +} + +// ------ Node detail card ------ +function renderNodeDetailCard(d){ + const st = d.status || {}; + const ver = d.version || {}; + const tm = d.time || {}; + const svcs = ensureArr(d.services); + const netcfg = ensureArr(d.network_cfg); + const netstat = ensureArr(d.netstat); + const disks = ensureArr(d.disks); + const sub = d.subscription || {}; + + // robust online detection + const isOn = /online|running/i.test(st.status||'') || + /online/i.test(st.hastate||'') || + (st.uptime>0) || + (st.cpu!=null && st.maxcpu!=null) || + (st.memory && st.memory.total>0); + const statusTxt = isOn ? 'online' : (st.status||'offline'); + const sB = isOn ? badge(statusTxt,'ok') : badge(statusTxt,'err'); + + const mem = st.memory || {}; + const root = st.rootfs || {}; + const load = st.loadavg || ''; + + const top = ` +
+
${safe(d.node)}
+
+
${sB}
+
+
CPU: ${pct(st.cpu)}
+
Load: ${safe(load)}
+
Uptime: ${fmtSeconds(st.uptime)}
+
`; + + const memCard = ` +
+
+
Memory
+
${ + (mem.used!=null && mem.total!=null) ? `${humanBytes(mem.used)} / ${humanBytes(mem.total)} (${pct(mem.used/mem.total)})` : '—' + }
+
+
+
RootFS
+
${ + (root.used!=null && root.total!=null) ? `${humanBytes(root.used)} / ${humanBytes(root.total)} (${pct(root.used/root.total)})` : '—' + }
+
+
+
Kernel / QEMU
+
${safe(ver.kernel || (ver['release']||''))} / ${safe(ver.qemu||ver['running-qemu']||'')}
+
+
+
Time
+
${safe(tm.localtime)} ${tm.timezone?`(${tm.timezone})`:''}
+
+
`; + + // --- NORMALIZE service names + build badge + const svcMap = {}; + svcs.forEach(s => { + const raw = (s && s.name) ? s.name : ''; + const key = raw.replace(/\.service$/,''); // normalize + const st = (s && (s.state || s.active)) || ''; + svcMap[key] = st; + }); + function svcBadge(name){ + const st = String(svcMap[name]||'').toLowerCase(); + return (/running|active/.test(st)) ? badge('active','ok') : + (st ? badge(st,'err') : badge('inactive','err')); + } + const svcTable = ` +
+ + + + + + + + + +
ServiceStateServiceStateServiceState
watchdog-mux${svcBadge('watchdog-mux')}pve-ha-crm${svcBadge('pve-ha-crm')}pve-ha-lrm${svcBadge('pve-ha-lrm')}
+
`; + + // Network config + const netRows = netcfg.map(n=>{ + return rowHTML([safe(n.iface||n.ifname), safe(n.type), safe(n.method||n.autostart), safe(n.bridge_ports||n.address||'—'), safe(n.cidr||n.netmask||'—'), safe(n.comments||'')]); + }); + const netCfgTable = ` +
+ + + ${netRows.length?netRows.join(''):rowHTML(['—','—','—','—','—','—'])} +
IFTypeMethodPorts/AddressNetmask/CIDRComment
+
`; + + // Netstat live + const nsRows = netstat.map(x=> rowHTML([safe(x.dev||x.iface), humanBytes(x.rx_bytes||x['receive-bytes']||0), humanBytes(x.tx_bytes||x['transmit-bytes']||0)])); + const netLiveTable = ` +
+ + + ${nsRows.length?nsRows.join(''):rowHTML(['—','—','—'])} +
IFRXTX
+
`; + + // Disks + const diskRows = disks.map(dv=> rowHTML([safe(dv.devpath||dv.kname||dv.dev), safe(dv.model), safe(dv.size?humanBytes(dv.size):'—'), safe(dv.health||dv.wearout||'—'), safe(dv.serial||'—')])); + const diskTable = ` +
+ + + ${diskRows.length?diskRows.join(''):rowHTML(['—','—','—','—','—'])} +
DeviceModelSizeHealthSerial
+
`; + + // Subscription + const subBox = ` +
+
Status: ${badge(safe(sub.status||'unknown'), /active|valid/i.test(sub.status||'')?'ok':'warn')}
+ ${sub.productname?`
Product: ${safe(sub.productname)}
`:''} + ${sub.message?`
${safe(sub.message)}
`:''} +
`; + + // Collapsible raw JSON + const rawId = `raw-node-${safe(d.node)}`; + const rawBtn = ` + `; + const rawBox = ` +
+
${JSON.stringify(d, null, 2)}
+
`; + + return ` + ${top} + ${memCard} + +
+
HA services
+ ${svcTable} +
+ +
+
Network (config)
+ ${netCfgTable} +
Network (live)
+ ${netLiveTable} +
+ +
+
Disks
+ ${diskTable} +
+ +
+
Subscription
+ ${subBox} +
+ + ${rawBtn} + ${rawBox} + `; +} + +// ------ Sections ------ +function setHealth(ok, vq, unitsActive){ + healthDot.classList.toggle('ok',!!ok); + healthDot.classList.toggle('bad',!ok); + healthTitle.textContent = ok ? 'HA: OK' : 'HA: PROBLEM'; + healthSub.textContent = `Quorate=${String(vq.quorate)} | units=${unitsActive?'active':'inactive'} | members=${safe(vq.members)} | quorum=${safe(vq.quorum)}/${safe(vq.expected)}`; +} + +function renderClusterCards(arr){ + const a = ensureArr(arr); qCardsWrap.innerHTML=''; + if(!a.length){ qCardsWrap.innerHTML = badge('No data','dark'); return; } + const cluster = a.find(x=>x.type==='cluster')||{}; + const qB = cluster.quorate ? badge('Quorate: yes','ok') : badge('Quorate: no','err'); + qCardsWrap.insertAdjacentHTML('beforeend', ` +
+
+
${safe(cluster.name)}
+
id: ${safe(cluster.id)}
+
${qB}
+
nodes: ${safe(cluster.nodes)}
+
version: ${safe(cluster.version)}
+
+
`); + const nodes = a.filter(x=>x.type==='node'); + const rows = nodes.map(n=>{ + const online = n.online ? badge('online','ok') : badge('offline','err'); + const local = n.local ? ' '+badge('local','info') : ''; + return rowHTML([safe(n.name), online+local, safe(n.ip), safe(n.nodeid), safe(n.level)]); + }); + qCardsWrap.insertAdjacentHTML('beforeend', ` +
+ + + ${rows.join('')} +
NodeStatusIPNodeIDLevel
+
`); +} + +function renderUnits(units){ + unitsBox.innerHTML=''; + if(!units || !Object.keys(units).length){ unitsBox.innerHTML=badge('No data','dark'); return; } + const map={active:'ok',inactive:'err',failed:'err',activating:'warn'}; + Object.entries(units).forEach(([k,v])=> unitsBox.insertAdjacentHTML('beforeend', `${badge(k,map[v]||'dark')}`)); +} + +function parsePveSr(text){ + const lines=(text||'').split('\n').map(s=>s.trim()).filter(Boolean), out=[]; + for(const ln of lines){ + if(ln.startsWith('JobID')) continue; + const m=ln.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)$/); + if(m) out.push({job:m[1],enabled:m[2],target:m[3],last:m[4],next:m[5],dur:m[6],fail:+m[7],state:m[8]}); + } return out; +} +function renderReplication(text){ + const arr=parsePveSr(text); + if(!arr.length){ replBox.innerHTML='No replication jobs'; return; } + const rows=arr.map(x=>{ + const en=/^yes$/i.test(x.enabled)?badge('Yes','ok'):badge('No','err'); + const st=/^ok$/i.test(x.state)?badge(x.state,'ok'):badge(x.state,'err'); + const fc=x.fail>0?badge(String(x.fail),'err'):badge(String(x.fail),'ok'); + return rowHTML([x.job,en,x.target,x.last,x.next,x.dur,fc,st]); + }); + replBox.innerHTML=`
+ + ${rows.join('')}
JobIDEnabledTargetLastSyncNextSyncDurationFailCountState
`; +} + +// HA Resources table (expandable rows) +function renderHAResources(list){ + const arr=ensureArr(list); const rows=[]; + arr.forEach(x=>{ + const st = x.state||'—'; + const stB = /start/i.test(st)?badge(st,'ok'):(/stop/i.test(st)?badge(st,'err'):badge(st,'dark')); + const sid = safe(x.sid); + rows.push(rowHTML([sid, stB, safe(x.node), safe(x.group), safe(x.flags||x.comment)], `class="vm-row" data-sid="${sid}"`)); + rows.push(`
`); + }); + setRows(tblHaRes, rows.length?rows:[rowHTML(['—','—','—','—','—'])]); + Array.from(document.querySelectorAll('#ha-res tbody tr.vm-row')).forEach((tr,i)=>{ + tr.onclick = async ()=>{ + const detailRow = tblHaRes.querySelectorAll('tr.vm-detail')[i]; + const content = detailRow.querySelector('.vm-json'); + const spin = detailRow.querySelector('.spinner-border'); + const open = detailRow.classList.contains('d-none'); + document.querySelectorAll('#ha-res tbody tr.vm-detail').forEach(r=>r.classList.add('d-none')); + if(open){ + detailRow.classList.remove('d-none'); spin.classList.remove('d-none'); + const sid = tr.getAttribute('data-sid'); + try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); } + catch(e){ content.textContent='ERROR: '+e; } + spin.classList.add('d-none'); + } + }; + }); +} + +function renderHAStatus(list){ + const st=ensureArr(list); + if(!st.length){ setRows(tblHaStatus, [rowHTML(['—','—','—','—'])]); return; } + const rows=st.map(n=>{ + const sB=/active|online/i.test(n.state||'')?badge(n.state,'ok'):badge(safe(n.state||'—'),'warn'); + const crmB=/active/i.test(n.crm_state||'')?badge(n.crm_state,'ok'):badge(safe(n.crm_state||'—'),'err'); + const lrmB=/active/i.test(n.lrm_state||'')?badge(n.lrm_state,'ok'):badge(safe(n.lrm_state||'—'),'err'); + return rowHTML([safe(n.node), sB, crmB, lrmB]); + }); + setRows(tblHaStatus, rows); +} + +// Non-HA VM/CT table +async function renderNonHA(){ + const r = await fetch('/api/list-vmct'); + const d = await r.json(); + const arr = ensureArr(d.nonha); + if(!arr.length){ setRows(tblNonHA,[rowHTML(['No non-HA VMs/CTs'])]); return; } + const rows=[]; + arr.forEach(x=>{ + const sid=safe(x.sid), type=safe(x.type), name=safe(x.name), node=safe(x.node); + const st=/running/i.test(x.status||'')?badge(x.status,'ok'):badge(x.status,'dark'); + rows.push(rowHTML([sid,type,name,node,st], `class="vm-row" data-sid="${sid}`+"\"")); + rows.push(`
`); + }); + setRows(tblNonHA, rows); + Array.from(document.querySelectorAll('#nonha tbody tr.vm-row')).forEach((tr,i)=>{ + tr.onclick = async ()=>{ + const detailRow = tblNonHA.querySelectorAll('tr.vm-detail')[i]; + const content = detailRow.querySelector('.vm-json'); + const spin = detailRow.querySelector('.spinner-border'); + const open = detailRow.classList.contains('d-none'); + document.querySelectorAll('#nonha tbody tr.vm-detail').forEach(r=>r.classList.add('d-none')); + if(open){ + detailRow.classList.remove('d-none'); spin.classList.remove('d-none'); + const sid = tr.getAttribute('data-sid'); + try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); } + catch(e){ content.textContent='ERROR: '+e; } + spin.classList.add('d-none'); + } + }; + }); +} + +// Nodes table (expandable) + sticky first column + robust online detect +function renderNodesTable(nodes){ + const nrows = ensureArr(nodes).map(n=>{ + const isOn = /online|running/i.test(n.status||'') || + /online/i.test(n.hastate||'') || + (n.uptime>0) || + (n.cpu!=null && n.maxcpu!=null) || + (n.mem!=null && n.maxmem!=null); + const statusTxt = isOn ? 'online' : (n.status||'offline'); + const sB = isOn ? badge(statusTxt,'ok') : badge(statusTxt,'err'); + + const mem=(n.mem!=null&&n.maxmem)?`${humanBytes(n.mem)} / ${humanBytes(n.maxmem)} (${pct(n.mem/n.maxmem)})`:'—'; + const rfs=(n.rootfs!=null&&n.maxrootfs)?`${humanBytes(n.rootfs)} / ${humanBytes(n.maxrootfs)} (${pct(n.rootfs/n.maxrootfs)})`:'—'; + const load=(n.loadavg!=null)?String(n.loadavg):'—'; + const cpu=(n.cpu!=null)?pct(n.cpu):'—'; + + const main = ` + ${safe(n.node)} + ${sB}${cpu}${load}${mem}${rfs}${safe(n.uptime)} + `; + + const detail = ` + +
+
+ + `; + + return main + detail; + }); + setRows(tblNodes, nrows); + + Array.from(document.querySelectorAll('#nodes tbody tr.node-row')).forEach((tr,i)=>{ + tr.onclick = async ()=>{ + const detailRow = tblNodes.querySelectorAll('tr.node-detail')[i]; + const content = detailRow.querySelector('.node-json'); + const spin = detailRow.querySelector('.spinner-border'); + const open = detailRow.classList.contains('d-none'); + document.querySelectorAll('#nodes tbody tr.node-detail').forEach(r=>r.classList.add('d-none')); + if(open){ + detailRow.classList.remove('d-none'); spin.classList.remove('d-none'); + const name = tr.getAttribute('data-node'); + try { const d = await fetchNodeDetail(name); content.innerHTML = renderNodeDetailCard(d); } + catch(e){ content.textContent='ERROR: '+e; } + spin.classList.add('d-none'); + } + }; + }); +} + +// ------ main render ------ +function renderSnap(d){ + const vq=d.votequorum||{}; const units=d.units||{}; const allUnits=Object.values(units).every(v=>v==='active'); + const ok = (vq.quorate==='yes') && allUnits; + setHealth(ok, vq, allUnits); + + const gl = document.getElementById('global-loading'); if (gl) gl.remove(); + + qSummary.textContent = `Quorate: ${safe(vq.quorate)} | members: ${safe(vq.members)} | expected: ${safe(vq.expected)} | total: ${safe(vq.total)} | quorum: ${safe(vq.quorum)}`; + renderClusterCards(d.api && d.api.cluster_status); + renderUnits(units); + renderReplication(d.replication); + renderHAResources(d.api && d.api.ha_resources); + renderHAStatus(d.api && d.api.ha_status); + renderNodesTable(d.api && d.api.nodes); + + pvecmPre.textContent = safe(d.pvecm); + cfgtoolPre.textContent = safe(d.cfgtool); + + footer.textContent = `node_arg=${safe(d.node_arg)} | host=${safe(d.hostname)} | ts=${new Date((d.ts||0)*1000).toLocaleString()}`; +} + +// initial one-shot load (auto refresh OFF by default) +doRefresh().catch(console.error); diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..1f79d63 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,28 @@ +/* Dark theme */ +body { background-color: #0f1115; } +.card.health-card { background: #101520; } +.health-dot { width: 12px; height: 12px; border-radius: 50%; background: #dc3545; } +.health-dot.ok { background: #28a745; } +.health-dot.bad { background: #dc3545; } + +/* Tables */ +.table td, .table th { vertical-align: middle; } + +/* Dividers */ +.vr { width:1px; min-height:1rem; background: rgba(255,255,255,.15); } + +/* --- horizontal scroll & nowrap for wide tables --- */ +.table-responsive { overflow-x: auto; } +.table-nowrap { white-space: nowrap; } +@media (min-width: 992px){ + .table-nowrap-lg-normal { white-space: normal; } +} + +/* sticky first column (for wide tables) */ +.sticky-col { + position: sticky; + left: 0; + z-index: 2; + background: var(--bs-body-bg); + box-shadow: 1px 0 0 rgba(255,255,255,.08); +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b167b39 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,206 @@ + + + + + + PVE HA Panel + + + + +
+ +
+

PVE HA Panel

+
+ + + +
+ + +
+ Auto-refresh + + +
+ +
+ + + +
+
+ + +
+
+ Loading data… +
+ + +
+
+
+
+
Loading…
+
+
+
+
+ + + +
+ +
+
+ + +
+

+ +

+
+
+
Loading…
+
Loading…
+
+
+
+ + +
+

+ +

+
+
+
Loading…
+
+
+
+ + +
+

+ +

+
+
Loading…
+
+
+ + +
+

+ +

+
+
+ + + +
SIDStateNodeGroupFlags/Comment
+
Click a row to expand VM/CT details.
+
+
+
+ + +
+

+ +

+
+
+ + + +
NodeStatusCRMLRM
Loading…
+
+
+
+ + +
+

+ +

+
+
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+ + + +
SIDTypeNameNodeStatus
Loading…
+
Click a row to expand VM/CT details.
+
+
+
+ + +
+
+
+ + + +
NodeStatusCPULoadMemRootFSUptime
Loading…
+
Click a row to expand node details.
+
+
+
+ +
+ + +
+ + + + +