From 88742892d08bfbd4258c20913730241c3244be8c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 3 Mar 2026 08:29:52 -0500 Subject: [PATCH] =?UTF-8?q?refactorisation=20pour=20correction=20de=20s?= =?UTF-8?q?=C3=A9curit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 69 + ansible.zip | Bin 0 -> 27209 bytes app/app_optimized.py | 6585 -------------------- app/containers_page.js | 218 +- app/core/config.py | 12 +- app/core/dependencies.py | 5 +- app/factory.py | 13 +- app/main.js | 2497 ++++---- app/requirements.txt | 9 +- app/routes/auth.py | 11 +- app/routes/terminal.py | 1232 +--- app/schemas/auth.py | 44 +- app/services/ansible_service.py | 41 +- app/services/auth_service.py | 13 +- app/templates/terminal/connect.html | 325 + app/templates/terminal/error.html | 48 + documentation/AUDIT_STRATEGIQUE_COMPLET.md | 899 +++ documentation/refactoring_status_report.md | 80 + 18 files changed, 3016 insertions(+), 9085 deletions(-) create mode 100644 .env.example create mode 100644 ansible.zip delete mode 100644 app/app_optimized.py create mode 100644 app/templates/terminal/connect.html create mode 100644 app/templates/terminal/error.html create mode 100644 documentation/AUDIT_STRATEGIQUE_COMPLET.md create mode 100644 documentation/refactoring_status_report.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a04ac8 --- /dev/null +++ b/.env.example @@ -0,0 +1,69 @@ +# ====================================================== +# Homelab Automation Dashboard — Environment Variables +# ====================================================== +# Copy this file to .env and fill in the values. +# DO NOT commit the .env file with real credentials! + +# --- General --- +TZ="America/Montreal" +DEBUG_MODE=NO + +# --- API Authentication --- +# REQUIRED: Set a strong, unique API key +API_KEY=CHANGE_ME_TO_A_STRONG_API_KEY + +# --- JWT Authentication --- +# REQUIRED: Set a strong secret key (min 32 chars) +JWT_SECRET_KEY=CHANGE_ME_TO_A_STRONG_SECRET_KEY_MIN_32_CHARS +JWT_EXPIRE_MINUTES=60 + +# --- Database --- +DATABASE_URL=sqlite+aiosqlite:///./data/homelab.db +DB_PATH=./data/homelab.db +# DB_ENGINE=mysql +# MYSQL_HOST=mysql +# MYSQL_USER=homelab +# MYSQL_PASSWORD=CHANGE_ME +# DB_AUTO_MIGRATE=true + +# --- Logging --- +LOGS_DIR=./logs/Server_log +DIR_LOGS_TASKS=./logs/tasks_logs + +# --- Ansible --- +ANSIBLE_INVENTORY=./ansible/inventory +ANSIBLE_PLAYBOOKS=./ansible/playbooks +ANSIBLE_GROUP_VARS=./ansible/inventory/group_vars +# ANSIBLE_CONFIG=/path/to/ansible.cfg + +# --- SSH --- +SSH_USER=automation +SSH_REMOTE_USER=automation +SSH_KEY_DIR=~/.ssh +SSH_KEY_PATH=~/.ssh/id_automation_ansible + +# --- CORS --- +# Comma-separated list of allowed origins (no wildcard in production!) +CORS_ORIGINS=http://localhost:3000,http://localhost:8008 + +# --- Notifications (ntfy) --- +NTFY_BASE_URL=https://ntfy.sh +NTFY_DEFAULT_TOPIC=homelab-events +NTFY_ENABLED=true +NTFY_MSG_TYPE=ERR +NTFY_TIMEOUT=5 +# NTFY_USERNAME= +# NTFY_PASSWORD=CHANGE_ME +# NTFY_TOKEN=CHANGE_ME + +# --- Terminal SSH Web --- +TERMINAL_SESSION_TTL_MINUTES=30 +TERMINAL_TTYD_INTERFACE=eth0 +TERMINAL_MAX_SESSIONS_PER_USER=3 +TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS=120 +TERMINAL_HEARTBEAT_INTERVAL_SECONDS=15 +TERMINAL_GC_INTERVAL_SECONDS=30 +TERMINAL_PORT_RANGE_START=7682 +TERMINAL_PORT_RANGE_END=7699 +TERMINAL_SSH_USER=automation +TERMINAL_COMMAND_RETENTION_DAYS=30 diff --git a/ansible.zip b/ansible.zip new file mode 100644 index 0000000000000000000000000000000000000000..699aae6c139fca9147fe72860b8162ff3e556680 GIT binary patch literal 27209 zcmc$_bChmPk}q7gZQFL8vTfU^Y}>YNoU(1(u2Z({Q@7rp?&+Ry^>=6HuluZ(d+)WL z%)JBoi^z<~ke31mK>_&d$1WVK^|yz=Um(8k1~!fshE^u@@_+9I0RYwLsm0qGVG;EG z@4$CM{+C{V?@ec9YQ`JW2RXn1BXXTXgiM0`uny?~EkF^oi9yY#?$EcP7Q9F<@x}Y1 z2@kcZZsN%3OJP~=?IC^7RDDQO!1NrsWg94r`yugT=!5M(oaTVa;u3)<5x`_KJ*H1M zb{>U?>G~w`(6w~K2y7Bb246j@=O^*;vM)$Wk4E%rko;NjPeZZyGw6R14FC{l6YyVH z`&XjP9BiHK^jr)a9RHI`0Dv3u!2dD;>0h|CuyHZ5ak6!A|Cj#cIsf$oxc|2Qzxfmd zfV5AfmPoZvq`~))n(s#Q-widfanUokwKlObFr;(0w(^defbt_i0JskLB5;yBIhmos zwr+fo*M@C>xWpvMKI0mIvFZO5MDUWHcFI;wHqJkRIXY02zF@DyQpz3$A-szT*DnDU z)g8r*BwQ`9AxQyuGjbcR|I*27rrrCsbO!VfF+uHv7UeUrHH-6;Mir2d`E z;AjCTAVwIG=iuM@JHPu|&rJluiIH(O;)?pMcsdYqH&4kTpWXPHD5t1_TIo;MAPN5wy z!UThUzTM|1nEiaEm1E=q{3rDy{~zip|0mSjIoKNiCFTG3Amp`ZV68ZhYX@^8A1+lhBDYsn{F4?)&6?gp!kLqP>!@TDs$FqaE ztx29r-OvFN&cWpS%0)o$?C3ym zWou+$MQ>~8}8VVnPHkIZcyo&NTzsidu# zKmvr|>u1W&b6lAV8<+hFj6n&LNM?}PMvJ#A?sZk8h^>rIi8h9Qc9>A$C>AnL2_zK@ zB`*Fy$6%{Rs7w0BeZbzoM!{#Yr4mI;t5$*YPksCB&Z%dv#f%ABI7kz zG}Tds4oGbJrVYq>8&<3t;rYnyRzE+crOZJW%H%1UF7#Q7ot1w(Gq-}7S|!TlX@Vi> zBuMm@{nT%n{w?*nwy3H;k6~B<{K9~mjU$JgF4W}(WG{D>yQd+iDC3gsMAYn0vT)K$XF*DZvR3J)~ zzJu4-WxgO<7q}~z$3X^UMcv??xf+`WAGH$ zr<^=CwE=wly8YHh$6Pyi_b3zV3G|4JvUgLM zOHY#q7v+6%T?49V845KtDM6u2>s)=Dq7V| zTYv!oDxm=YsQ*QK|LkvQU}WiRNBei%No!?;N_+}no47Ym=TJr8o|_H zel4-x06&6hS?^@3ZfIKXMklmUXiiniBRMGKa{^N&*O7r$QAua~POPMKI5gDQBKo9F zqg5d1+&|#PV3*E(fjQZoG|YdX3u)x;V9sF6zVQ08YPsM|ls>^&ZfZO?4Lrxk*YoyG z^?LvFczj&Lx6%~}yf$#LprxY&m?&BV;#S)~HC5r*P#|igu{HV01eDO)B#!a;ae7!t zVj_Aom?XL3a}QGfOIq-CMN-jc3%MVQRuBG|w#wAzXBwk#eOuLRh4m4cQoZyiXKhou zpfy}niBm4kDYQgnO1fV62k`B6kp=4;OPGR*|9Ipk5~!aRV*$ey5Van zVe|W&3`w-eSy_|vj2qMsxk7K8iEyL4E1*6qY0^4 z_ut~UfPTYyjR7jXu}uok9Ak}&t2)Yg!c!Gt7P=8r_KQ+=Q#YnX`y2O;t4P=U)P)}w zl=aeLL)(;w)lO3srnj!~?+YWbz_yF(2;ky6ZRqRAyE+b{c?}10;Y=Z^yBOAOfI+vv z2Tj?yau#ZKA-gHCP&+TEASLBsKnW!J1p8l^?36Qss>{VWEvY^6K+w@YA(gb(r72sO zI*hUyhldnE*~=^qVC-%ACCFnGMt&Uejz7~qc=&f1rFT?|Y)L`6+0TRyWKfn1{pLT}$mHN!QwSSPt)l`K%N$iNbsf?}1G zRg>4VlB%k}tSBjI^8&=jrWPp&bc^kXLh_SN<^{jmoF>r7LyO--fR&B$tqITmGI4c=M4>}%+ zQVOUOxjOx(=1LFu3K(%R5dB1AJE$@N@%x>pNVxJKtMteA+>2<-Ely`7tJ}$hEm!mC zDM#bdHm^C=W@aGJ7{3}Wbl^fZYrvaYTB&Z!Z?%!d;^7M92xg%;(kCadf=aV==u_+fhp-DF+lWNE8Jq5b38%u|-kFNs}@csQ1hHYb1LU*hj$&VUcBM zgh%b|r+6Ghw4e4&X#+P`SV} zNr>QU=Kc`KB3YohY^2<_%?*Cizadz~-t?Zk>L(O-60TQwUI!(gHxJW!`DinFPD~`2 z3Soo}q|64V1QP4}A)$ny`oMkKK!K3(5GQ@V6HDZ9#xhpr5=wG_Rc>~x%oicetl09v->f{d|VBf0c+jyGHaK>A2~?5kMcadJv@;x zf7(g~Ii2O-vJ?I710KWz=JTUEX`*MFyBKzf-C9hU#X35*2%%drnfe30sYuoxt1zUR zRU`7|f+*x>(~u-qBUm#eUdw@e3$Cyf+MDFET%4QR}v zetU+L%*T}>+@rmeiwp8eGY^YFX3+~8WhZB|Xw~OP>iVWOJ zR7k^~9&;c0u(av9{FJMp7(>A^^k_(*vU#RN(5T*P%c(;Gl`dgS7cwsJ7ll}m7>1Y` z2CYxWDg0ga=hbfzKv|cq!3ZyL}1@QNraBSpw3z_VeM#o^YtF zg9uhTCyJnX$D#JW&pos?SZzh;QSkTFcr09FgkVi7`QemOug<8dOvbKx*4ez6W5}4O zp2Vb36TzsrktgifG)H$OvG>&pvcNeXMAsPG{k;?Sk)P9bKD8FGA3(i#xk^hoyoP12 zu6K~lbVf3$%JwJk!)LmJ56@a87JXl4tR!}QS8vDwk8E06zFI5#`72KsWHlPw{nWbb zp_)@GrBP(ZbgH8P_ck1$`B+P+_xZ-Fowc*&bWr54x%4IWIX!g?Ll`Z zMO$~u*@{DDioOgsLBKv<(ZncwPH80XfRVgKjJIVi2lREiPW< z^dT=I2M5RZk#uqBeJMn&julu!XTmxqkHLK*hBr4clfAse7GlV?1|=ua zS>rU(X>pGPe7?F&FN_G}d+@iz7g7R{ce^^m3>T<1U}6jp@0cG-M3plYh1h-P7jnYO#R9u=?b zZbW18qzY+E_`47u%{Co)cPhGZiEc8@{(VPLl?-1^RyC3`O<`(393AQ68GFCUaRRI} z(&CT=aNm0S8pCxV&ajvzd_@YQCZsh?#rNw3{D@I2UC5>ba0m1o;pJ4jEwAewX{OnU%kQYLpx zc$aMz{|Rg7zK+*ckb6`N;yvsJeu~%K{dsE7m(qpa+c4ZEpAk+6WWAiwA`wh%_r$^% z;vzVQCKfznw_IB{6U0Mf`KP>%i-2B!&*12mkEib58uK>Pat?|VLgP#%n!WqlgF);G z4tOgVu6o}wP@TlXr=#n~e+G}fV_PBrb*YZ^?_IC6g_YCy4)~ugyC5%8>lbKg#}W_# z0MK_#lKuZWPS5Z^UZ7`WYh`6(ZVvu1jE`5YC31_j?-8T6cy}RXll{7*^)>V#Y4maPGOUJvX?XG1y zbGBov@q<0ZAV&alAI2~ip#UhSTyU0rHgxsQ!JUh54Rn@ltxSUehnDH(HDFZGJQdJ* z+9&&<`f5NDQ!rbc0iOreiF)eqs)cw3+wdZGMSKhhl|{p99Owtn9a8cQD9z41&L>Pc0I#$b${a zq{8Xtvohb`4k$MT*n|0bD+o-L%X@9~1vTzve4t}KdkY3W$r%(nMvv6nd^I}RLQpzg zyBHp0CV2vCx34=47QxR;^FQ%pLEJixC$&NrK2A^9bZ2C1&~oeve4l+!?7<6c%PT<_l8_kfQZ)79x*W ztRQ~!ZPctGm(K(eE{{~aUJPI7WceA)7CeYrt*aap+nYHx32pQfjN*W}j&TDW4ceBK zJwKxwHE;nlGC>Uk7TpjwL=sY>giI*ipDb-Y;dG!Xs^VjG?5dI^1b4%yV?&r^)WxX- zd$We%rNwJ<2>993?!4skPaL&YyrVRXem(3@1k5|>*c}Wo+~)@&=&qqUZ;?2=J@sb) zocVAKemPW{mC)>tKUgc>JvahC=O39z@pbv(^rCUPL3rI@8<~O1anEj2cia1@<<)e{ z;2L0;<7~FD>!wU>b$ec6x>;McFV<+?7NXIoMWcVYyfX9|q*rCgn}i(CEM)#_NVL4$ zf7{E_-u7y14>xUq4aC~gjMI~}Tj16F#lib!dh+ov0nUnVq|{lK5x0LMCHs4U%lW@U zim`>GrJl2+ftkr)Sm{!>mir4UF@wGw?n0onl)=&T>1-CA5R4WKfbYTz2PCPfOVP|5 zlt#s0-p7sZ7L-DUy;DcCZAOCyfipg`}?2;SKR2zWfRuX~Y(cJOk2&L$QS z^IlBaq~c3g>1WH$N-E9nBQUB+>SU4GD2R<$j4sZyL#fU)7$R}2x}hgeC$r!yOiV$pq& z5NxNAZ4h0h)d^mtq3ZegkFzOpnhj)e-nhgr|EPLy{4vXAem>(7)U}ggd~^l@p}Qaj z3bP99$*tcqsp|y*qXgNNxuKgaV343tdzXjhA-eu6wm<0@a<@y0(KhMPZBbHE2qI~$ z(y-6e?bgk8Mb@;&;xJvI@#8D;iuI!&t^r&pqC|%22sVpq!LTPQgr}i&v~jZ2Px?JT zxb|g8zxtUxU0CV!PFKhT_e9NqEASP04^c=dV>*am%K2I=w+gxe^Za*o>#kJaELGw7 zT!yzbQH7{d1jA7=!-lGc>?6lT9J{K+#n&Q}UKJoMpO4Hv&$EkTE!pc`5V#aD9#2M1 z_9Pbr_a|5QBh=WG((RJVkgWHY z2XA*5C*xlp&h}S&I=*e4PbCYe$s47D%SiVrM6%PdkkfkqbRAHIky^J##E!Zk-+3x{ z006H49U!bttiQ87|LHxt)O2DN7!Z8K_TYp7n?$UU(Sp9ik{{3@&;{Pwln%(Hu$g2E z7fu@UcwvrqjKi{qBN836KjK2GcynG&kM7+raAQYWY`22d+%aw$LTV7(bVe z3h+vIl)wydNY*lL7{W0teZMrx4Mc6Xv4o_Tl~4Q4+GXo4U-Fu4w}I42qnN?PQNq^( zJi==s#Rtm}Xdw0GCSk)uH8nd4F&*vvayuvr9hFdVXB^fwVZ6aRnGjrQf$c?BlT&ne zHBK0#dm%e2ahF~@OK~J2(m6pi>cOa|kY)tyP+)%q+y5~NbGajCghofqR_>EtjS!6o z)~eIB(AE*$77RIpB#&3IyJhZjZ3ZUvN>%uY3iwB*AQc-))EF^8E6kvcp@YaGxR)dd zEfquBzmwNjDbM%uFzQXof;q)+W>TcSNGab?A{iXsdP81+J}dcYKDPd9=OoqR1UJCbQ4Y{M`oc+VKS#F^K zl&UYqg;2M;8R;uDi8{$ApX-;EcYRGo_-dnv;gd2-UHnz)x^A(FB-gNd zG5VBjMTwkMn zq{k))tz{^myFK0G?Z)dU^2^TbK6U#@R-sWA>6>Fn(d!rk|1U-Z4&VQbX+r=k1xIR8 z#qS!He!KC@?+gR?|ITXJm^iuGI#~Xv*@{-~v)N#P=|Vr@Ls;vBoGt2gVU7dTQiH** zbussZ1`?_}Rgb2@OxR=*e;0U<`CO$~tG;Z79hWjpxW0`~;K-Jhll#&4oB%yR`tc1Q z#h=Li!AL+xk#Tu|bV|jJrfmse4?R6#8txuUpkoeRGQpOOTE*;kOQyLw0LJ6$$Uw;b z-c<~$a7j!Cvz*J5re%dmLrF{R_~~qfpM;UB$?ausDrU~%-~u^82x^i<8kFH#S6o$) zhvuxYGExTVYb?nbF+K&s8G#-xWfD5PV3H5RMZ47g4ecM3#uUO3GblCD=nUQfDI{3Z zCCxXl=k5Wq$Mq;cIr*z=(qr@t(FTis2f_1blhav!fw%X+q7JS)N0*UIF(TJ~;Adx0&qh((Kxq$o9edcvV&l{s!jzSwmL4lwLw9{$Y zMo8AoNi~txEv_F}vU$1+d6HU5Pg}u(%k)#K9>7sNwb*4Tb`XW1Inz6c#VQ`?oVmZC z>FEn?uI!K>-fmfVV>h^z6Pq@{nKmT114&LjmosEe&=14SO5Ti{O{(! zogT#T?^NbLS zIdx`bfie0pHS^24=sGW)Za~-{2`p`U>PyU4o9%0>39yUv{F4>KvoKWNazbf(WB?6I z#Agh4ZJLwC7djg+N4zdXMtHFT5$io{of%}f%)nSkjDyw8X{wB9Zd#?pT*T|<7t4s` z&z8^%SrIHdaz=W~QpPY?+iIe$NY+IcBWRYjfSUfmp+J^xB+Iz{1V+IHiGli+hG%lS znIPFh5!1RGrQt&}9Y#A)3RW9X7RvQdlDkHV%jrTimPgLgPv>z1P|oy0HBUd4ZjmGL zScktSVk7PF5eK*Pot5bmBI1;Wk=p$VH*A;_=+v4eK!q5RkH?8|fWA8Kgco-xEMM1E3n zv`S_$ZY<8y!Y5%kQb{Lt&NmRq^Fn+~9RyzUtVMFgaiJ^xH>~tzAX{#3uW#NUt8w@` z5+yytZxE+HJR0uXYhd}hFD(PNidc=~)E+>DbCvTYrD< zkZV%oj?F*Z`fUfUY<%YP!4F<4->8U&hyN;`P08cL#wvj>TS z59ir$GZ0%yAe-p!W$c0MS5$*AiQ;}pAkE{40UVl-ZyGnsm(>nc_QL-wvh5ZP0ehmh z3+JLsV$crUSd%^BRg2`GLF9hG=(TNC*9h$pkN)uu%}#WtOa5M_!kijvAPKGow;cT)MbeGIjrGt9|s zVn;9S2M=ZisW?O45@Vkf&-9vpF_atorq=}i!#_>Z6ad(DwTKi(+&PCHa~AfZ5gM^9 z2@HXLyVD)VpZRR7o?llI5dfaHp&g#KU_hw9;osz%vf;TqYF77)L8Zl|)BlQnZwb33jDsU~2lh$R zi_OQokI$TSTWoVV%_yN*AL8D6f9)o4>KqLLz@q?if=~MG?+}hhvF>lEw2F_SNkM}Z zJtq;gYj%wkoRVhvYt>M;FAFIiF{zCjD z*IScEZBVjcWI?#6i-Hj)MY4`&arHIYo2qcA1eHN8pF&MhVkw``d875%d2uE!Wfe3^ z%qTz-6SrnxwPAeNpqLXsSi6nkngG+IJnzMg*396U9Poc5nCjOsSb^baH1V~%-H$(B zVz*oot6ocN(5?+r|FGz!#rlE(&>#h<9|tf-1stsa=1<3>J+4c6et;)h2-7r+lx50K z*-s=f4^o0$AcH~~mj&DF>8+FrF>P2)Hk86fN$gbN7@qce1$6M9q`Ha-pw!t!%%4cT zM_Cx-f?_H@cU8#?E1e09#4S+@HEQXFC$`s*QipFG$^p*oo+HW%5f5#c1eTk!Edk-P ziZGRAIP@$OQr|jO4md0yKQ$hpDovD3Cb>_rT9;BhB`rPRz?XJE1A5Sv4yeNQ%eSEB zA*&)Ol#$M%K6fot44(f5*E8Ih44KDDN)R6Ob7lF`c5IPXYgD%`&$wwBpxnP56pj&` zQ&=5Gef9cnLnDOD#PMyr00D(DP<;ukJ9NXED4iC@auvW-N5gwU$L**;WipkPaq)d4 zbXIZG)D;sinKMoZ6Sb4Nc2MHO2au`(szx3vTBLZkUXk*FT|u&fhm=%q3`{ zAYfQYuI^IF-K5>`*C8P4)M1>hlPK82y7Zt_3-ZM$HI!HGZ1eq%>u{G38mckKEW#;$ z9A57&NsFKnwI%HE1imF34SHi8rn%TE$(({uMm)6HXSxSM`C4^Rrx`%HB^*U-wIQ;S zwiO`t9XLvVo@OZWWPQUbK+;ijlJVmtF_G_hTdASq7*lEAV!yS8Lj1P%PljcyM|$DU z;vwIR$x=QH&>1(B4QWdWWwxFhbhs3MQ_O(c=RtjAPlP&4nnC|EDE?I!?frK<+CetB zCM)Kex`lu@hCQ$oz%%X6DFUUrWIB%^l;Pq$yaFI2t_XgB;AGEdA8U*XXR7fjdNz}8 z657e)C4LZ}k4%1$(+rYv%-wjDZHNS6)h74oL`MqX62bHBe)3Ekxa=VMs^u*Wis>Cx z**#;}v~e9fdt6CeTcYaJI&Q5P$e39yv$FI^)@>R3?smFX>Gp^iGTAav1t?Sl-Qn&<)GEb&@2kxpROmhVK5DT(+l<2{YcaZw}7V>>KQ-MM>)~Ek@_~ z)oNL7OT~drhebs~Be>zsq8%m0=I{_W3$mswGnwXYd~!j8)>N@PeQ^#3LwgrW>@Y$^s`$;xwYF7S1JMb_ z4v3p8GcA$oQ;dwY7d^e`mr#N%4o@nQwTZiaYLc2=IURQo85QV(Qf)kf`=NA%1X9DK zUQYDW%R{`ZPz-r9kJV7j3v_$2BhrUnpj$$2+zH4-&lUXkgw89qO4Lq-*Ux^t3eux#?8%Yf0{Bu(0&}-?{z*UDKY-Z^@k>mhuZ}x z7tv*4Z>7gGNg9aWN$(3!DFg;j2}x4ZJiaJaE>~;N^D`8k1F>mz(V&O)=6xSCpD|qb ztFJ5@;Vf~mG-z(HIH=p}bA&`dV)OxgoI`VO*afBln2$^l6@X~>*^DnIjzOI0?rnwx z2=yXN0QnEnO5?z+GgPXjA zCnSQ~Nf%6!-pv?v7Oa^INW&q8{(LF~ir{u2TsB=b9M=6@6 zy*UmCGEC08sp=$xr5f8d(ZL*9+>5|Vq-q?3+t^FyJyHGwBo@Y-AoS*!XD0S+vpt^_vva^YP?@w}yv6|`mNPmnOUr$Gt~ zrv%AeS&3?c-Q7e-R8cr~!GSpQT$oGNfv%Mo)+BI zuask26U5r__s0Z&=S7>%A_gHOdUVe~N;NPvWf@>pLG(#ZzlZS^dc?cn(QPMB$Vd)EgptnCs_wo3=g)w6vy1rtfb-DT6_DSS1l99>UDQCSrJM_Y8U zn<~I8Cg9}#tb-DRgRe7G#sN*x&&3oD#NlqzbeB{*1KZ&&?uUl#(PFNf(w;&lDMPM2 zuR!%u_tH*?cW2nQCxgu%p17GadT}s-eTs1co$vvU3vjzZi|3GSej2DiE=9Wm3?PVGyUP}m6Hg0;^ z%P~|C-92fEmans+R}z<9aG`$A(GV%+{FUpdZ#G2+_%O_(4{4>%dcj{SNWvYtO&|lcVqE}aaMh2-XrMXT zCjgTI(fBxg?rlOGXuuyy9>)SO0uHGAGdnvsF$6c6Ve`{J!F-iwQ<;u{vqF96Zd8~u z=fa#-Q^%zFb$Ob}p}8(A$zG`S%~o6}4jJgg1A|FYBDj9MdL*;@04X{2T4-utD0Cxc zIr>rH#@rzo>cjclJCL2U#d$U8&mutNx@PLx1;9zQJ;v*;p{k;TiD7!CqKkfgmOC2| z7#yLQnAo)CG13$upN#+r>j5zULq|Dgp~yEZ)KZbThNz^GY)*Gw?TVqX=X4$nC$410 zim0O{TWDLLO+8IddTJfMnWeNS&s)5LIbRe6c4xoFF^uFI6#(vrv&v^ipmn&IA?2V zE(-TiV|p!qUL?Z6U#j9~)TId}JweO5xT{Oxa5k1JU(hCgY+mMbPOoJ=u=UbzeyLdQ8=K~S8#3Mr~ot@tvDTu3x`^M-7ibkXcJ_h~TZ^}~h zz12hYUk+V;0EUi?K{7rjORw`4RmJSfC3ONor}e z>+-7>X|F>qnOdG5ZN)8Gb$0>Ksx+NjckRCfOyG6~WYQT@8~#)pxDQ##6^4vjjZbMZ zvCTkF@QdacM4V1U=9b3~Y9* z6N9?PAHOVx2TkPf&qdT|vd7*N;_0+Em&|E~*N>V=yYKY&+9LFgdkw)xwvL*`;PVBsa_lMpK z$_9Ik8Ym(!q&p1dA{_mez60t0u&AbgvolNp{IPtDV6-SyP1wXhyu~dXD;2K^_5E{T zdDmtS;NSo{A0Y@wzX&Z2yV5|<_hlzG+NGnpZ~`ypF`Yu45D#9gJ=SH8Jt}k%H8HU% zV`vQ#_wtMfO0vzn?^ongC{Eyh5D4g1ADztIltoD2O8P5cP*iqfFkA(TyaM!C=sO5H zWNsP+E2g-LHz!i>IRHT1W);TB_wk^7!HB(^;GE*PY@a%dsKq6@pHSZ_Vpba*`3%R{ z+r|+dVe;}y2IJZ$!iWXhq*+S|Vm)M}5>TkD^HUQOs160%n470)T2Nr5WpuL(ehS*& zTu$XTn2JbaAf4xP_coyIZ{A+Oxo&&wsO&Tvq_2oEw-OH(%+VreVUo6+z7X_3s`999 z8FvD-?7mo<4`LE(iMmZ^kOdEu=07fCjD;sd+42Dzn+Hrxj2%rmfB5Yy`r^@N95~J| z#RfGccu=X7&utV9J9IZ-bvgfROZk(iy@YMa(Bd4lQ^q3zq{FVX z!F@fc_KyvU9n;aD)kZS{3&D*^tLcO&i`AK55%_?2BELU({74%cZzT` zkH6CgWQ72oo)2q_)m?xDL`b03oY}6aHlM87+%X^4xtCz?BhOY{Z-T2lIwu4}dtR?^v=Q z5mYQGt#06s$glS&Y?R`UZ>Q)p07^YFcSrqE3P>uy9utI&i7vktmlMQZ*QXPzN65D4 z0KcOqGP7^xKJhj8Ui51-5%?|`U&row3kcz|L(9*n5_Z@tq`Z?N+kqQJ0%fXuY$inn z=`r3YK}nsS^mqk zRGd<3FMEnn(LhzT-P3~CNyb16-G_bImKv%#gv$nUJNAAp=3->&wE44@Yikw0m_T{! zRRHYIaTs6h!K@foy9xWQf5}A|*rJOHqNXI#lSd|w9O51^Ii0lb@gcy0O(qe_+q$>t zdgt{Op40uq`|x(7WiXO5l-_feU5=+LM~yd=x=zMC(^HgPJ6%((q%8uX($F(}IAvse zT3P!7`IaHFOI17Rii~*;w=0iC0R+QM<*+Rz&hO5?%c+HHo98XF2w?c_hus70tXU!1 zy92{eCmO?El4_2!GFd*tv5W<|sWT}!NT)sc$hC7o-DBSGd3 z%jhc zY6rCanMr!~u%!_VB6r$|SMT2W?UVnHl>^M%Emo45OO3fA9+W=_{< z&*Yy-(bXKV4_a&9y|nCnR>=}8nL~O zv4DH^(kP^GbFyUHMe$G=9YM5LzP169DE$1?vDF#^aMq!pY9QydHzCsqH&K%}AwEbE zqfs*e5qU}?^XAMq*sLko*QmFl=#DCysZ1iP%d&a)RTjBBOxfTS7*)K^#549(9Yqjc zm=0A1S^L`GjF>%?iLHj9!_7`v4JObm-W51Tv*A=q#-EoM{<9t;(VRr@)8$QnyW>8P07ewbxGIAw>>oJ z?CpXGJb*nV)*R>vQe$&H;Txf%&2Vrr!q<|BzDvKTEa;p#> z*0$`i-fH}k!*?V01`w+TJ_Vc)_`Ew~UK z&+4lNqTL&@GWyyoDr=jV_1nh5!#Mzs9g=HPQ~rq5nvNRORCb3Qsm?)3*-2wUekr6 zx(Q)&b)MeMnw5P_Q|MZ}P2n8p{8frrJ?+$h!gI?U@LkiuU`)f28-1!a*Ja0Poc=3B z?p`5bN5kHsnu^&W468P#FzdB(ND?%DrN`0g8(C*0aRv7F<`#Y&^QsUK>z|phG1Ol_ zga(F6Tn`D=)9o^bFZRXLCfeE^KrgC!c;5W;O=De~E^g=Y4XJa{Y<52RKQq(oQ^BlY zHV*rjp{<}YucZF;*ezTCXd;-hhHL^L_+?hc(5y9FV}792lJU!YBPMnn`VI$$WuP!i za`BdC@s#5jsM2s`o^>U(TH9Uvb}^1=q;ap_^hJ)z-QJsQq*!7!rH(51`g7EgCAr=p zICSCuCo$i&X6B2`sR`4Jskar}IuGv;-=~AyH4NsF+qKu!nd$dmDul#?m7Mb7c6?%h z*I$7VU;mP~p0SJ8VqH0YCHk(dYKQqBA3w9Oada}UveGj&a5T|#vbFt>l--n?l?%MQ$#9T<1E09TbjJ+y=uHjrQ=f>^kjg?HXW$^ z@5kWc^XCFGz6nV&VEa@fVOC+Bc|;U~HnB*LR*893KPnXMeuxQ0v1pxmp}3ta@qn<4 zN5HJMc_A>!Hn^F#a1ghM9a}1KAIt?5G)!P0T)M<@mQIn6>9bg;NpmQWb_7oD$xS*dn z3vuz2?$fdGi>OT@fXY=41v46^zKdM?w~gi!5#yo>)XeB>@1lK0NXxVKg@7U?Qs{#1 z!pPm^6y4Gzw3LsmQ;qUf7{1d^c+QAs__Ya@z*)f&=9r4bstK=z6OlGyy2_~K?I#&n zqg3rRm2>DJAuF6YYP<2$4RH$!8l~|iWHn8JRPYO}-+>S4S#W&1^9Eh+a?SxHq!DQ$ zPN8%4d_9U1X2Uj2IIx`1Jog?rZGF15z7`pf{MlO3T`nu{PM@bv#7I3D_A*SEEc@u3 z1oLU8_Hb|{bU9^rUF?(BwuGD`7HD};LKYGl6R%)8x>@)5-}eiN{jH99zur`b+@3Mk zCs%)GAgob|LArJ8y2gM7x&K}bPy!xSR5;;ZIH`)-1rqD+#(6}yrLfTeln>U&daUpXqS*C?aGlI9H)b{J*DsPK-u`ROsNukh6H6fSO z80%3>8$hOsROv*LC9(1a04j2bD7(j9l%On+z@9ZRFHwl zoT#xiJsAu6Zd2J&@a|s5Qm`d%o3S>wGNJmZc78SV!iMINzZ8fho!c0c9VBj8<=mnQ zwucgsWEe`78}51szskLfaN}Sz1))`hSktZ|3seqdxm}E;gorb1#qgH$GD1%-bbP7Y zSVd4fQ|BOXI6^WhvP5h1x=ubSyA+EiRo5s(de4*sOA!8WC-}sX2T+sg8lg1TNC5<) zMBfadAN(RxCxEZC?ZqXe+eC!sXz$MO_{PwAz>LlfA8_+v!nz|YdPrc=VyVwx z46S%DF}qMvu`#MT?DGD-oV@*H;60Tw>cA(Lm4kCfvbE*YoSM zH{{{jKXL(+3j~LxtHL(MBFXzExEJ&co>^ZgL6jE==+c(`ae-Jk*N;ECP~1>fRd%NE zmQ=kg8HyBh=?Bdtm(oQ?AkdWwQv9R|BXEZhv8d8Xn+!w5I(Lp9qNs=4g8t}R2vxsr zUsluSmNs_{_q~|cG(8dV!jgUBGoq_KZdN}Yt5cIm`J|JqOfDzV)~7^$hFn{@m`%Ot z<7ObPalAb9Vgs%Mp+5dRaVCsW(=~H?j54%BO=5U~jC(O#YP|LB$#89}hH_cY>d1q& z4iqZY2~fy(9?>jkR@r^zmV zzKA#5D167Z^F?cqF zCS(ZSrMzup^q*?p05cZp6~uO-d#@|GO_nPlmi<>X!PV%GG_19D+xEAQ>KgL`^_3|% z&fIf3>JRp--|YWHGec>r77f)(qx(0SNg)9M82>Gr|KWDOi<`gC$=H}U=-Jsi{KwPZ zN+nb#{sQ;b|Ec7>{=!Dk&- zfogo4*CE$kHzt~O6A#bJy@C;J8Arz7!ex)4jgvBoF^~3HitHZwvh%A!&R!*~W+Ej< ziPSDPg-PKaVX%F#g(!BgU&peV>Ff)haC@VQE3)~)`ve$WWW`{qo}s+5io1?MT1w4Q zIey{i613F$&V_ZkDcdoxAc4yOrgnH?9K~|f7O+WVCwxb zG5okgMz{agt7ZSY!g+-yF=WeeC8+n zl@ul*umc+N3;pz}X8uI$dybb3(rbL~{4}i_DaV^1HJFo#{UO_S}Kc7vUo{e>MZ)g7CZCyF{niMbTh)s*yRgxDrBGFDB=^gD0 zZN#4*O(b3MbO@s%t`Fjt&!{qQm$kfpQ-0~T-E<0`hU<2c=dKW}Z8@n&iF4nCEc-Qw zQKEpjz?}5AI(>r6ToIT9Zo^wUUquoPeq4XDuh-%W9e(_vUYm~1NGo`Q`_jX& zK#J{~vziSxKUmKSP`;k`T6K%JC!tj2#j3Mz-o^Shn|?4?a(JT>SJvGUgPMj7#OB)Ah@wZV?;e40jKr z4x9RtAK;4!1ybkCzVbX@?ZGA{qM;5rRdpnau8vBkq)f=9(R zv)H>~t~dP-JnznoyfogWp#1V0$S>7?l>%BZ_fUwS$HCshRkj?sefvHms~(!EfWho8AUvIDrYPOr+MlX>c`kxGl^0pMHRI47n{B z*Oy$KU+6T?_F*g}#NNn_dpZ)2n+s)tr@V5pyvM~GG_vRpjrqy^8cdTrL^($5r$7zr z6^$dbfYn1R+N-%}9Bwa1CxAl6E=3F~fN51K(&qbA?0f?+%B`g=Jx*IuuNP{LS=Qm% zkqrA{D!1?jeMZTY+^pz1`N#e&i@ktCR>7;_-I%d;1C2M3{ueRiY6KROk#%1b8k>Ir zMibt?!8%((dc$Lg(mzC$o*rc_X6tO`!R~D8=e6M*ndrTV`jyuaf6E1pF&an=7_`)r-uf+nHrY}RY zeuBG|z{xLPpw(BF@V-f~zNU0DH-m0hT3Y^c5|$y0i0mzJ8lFoDhXe^gdDATmT> zt)?^g#}pzTA&9&%o{<+P7h_{nr@yvAy2Mr;XGYTP5#l&BMFPR#Y_#5oLRC@b1Uktg zJY}%qxu4%vLIR7J9H%Hs+w&tKfoNUfY~J3@k0j_zIT&?yq2}&!OG4pI_@H7obv8rU zhtd^{ETMia=4kP;MbX~xi~6nXTWMWEiQTfsHzF{h+#w~xW=Sp#{X&seTJpuqaMoTA z)_PUnu2T9Lte^`wYR`ETRr9Ra5?4zmTfpzjb8j8WJd3(tYx!iT?dwtN?ksNVc;K98 za1Zf(nSE%MGq`@nwVkT$c9O>O_s?N5TW^w+7TPn|MObW@ znz+1Z;a%EN6?uCcrVMZ??q@Y|_~1LZvWyu+(DtpOi>jC%;}$v8GBfs-o%O@lqr*uX zy3IVNce3VH>g~Xy}RRINi=`;V`>jG&*f?vGkR@zX*=ZMyyn6t(YHX zg4Hf_p(qjUTlRSEcew@OBv|sa&;0bAX^RAC$JjJ7MSsB5%I@*GX&$ZMLdnIq~;g#zRLH+4Z%gbksGYV`s9Ir4D zY{5*=-KD#t?8+)7YWOpe#UTkNZ~jI6%B@{zDJv>{NqwL`O-` zh`TxIfpw=EUZlpg*E%u#N@CGLy=yu_M|y#dHS!(aBfJ8h*i+{D39GeEqIV)UGO;MI zC)HF9rdHCvxULhj^QA){%sIaM1u2*_Z8Tc?GDxL*^y|Ic;$frDAxM0eU;7(6lt92C&qFa8y@|`UQGPVqhxMIjAX{2{+H?S?WyqV5K zb8lnet!(g(5x8oBqV#BzgIKf{qfLAyCa%Rs-?EdsqngR3H?9QVm*d5XkA7{a!@{t{ zpO^+XB(e6IbtS_T177&{&V7QHmq@mV%Hhkog}d@IC6@V?bU4SgHRV0!#FUS_G_PkM zL!UMktD3r>!LQC5bQ7(i()NP9gge`iiAh-%MPB2j%>kO;BJ$hh+gbJU9)+csvgWui zWy~GWE+%eIm9d7}sycsDh$pOOmAKkyrs`$0QRqJ&NZeg)Zudq`{!!cTxuiSAQX9f; zVn++k+95^sOP0@Wwx-5=4xu@`C+lyXFO9$Jv*wDE)mSDMxq8v7GGStdp^<%lsj4x8 zvE-IC(Q}6*99(_d*GH8j^P}82c4*Csq!*G_1Ebf1Rjv7I>qTDW)ZG-*W2l-0EPCgL?M6j=>B z4O*)tl)rh2_LuG$fE2RFRO*7T0a0Lt7v@eCAk1#QKjFX!{|L0+yhbp(H2=D4QeC$91rJwj_L>iAZ z313xa?=fe)>363+-pRxC&%;TB%&vcrF(4P&2kl>Lp19_=xti%xkO7wsZN1&MgI=6R z>ocmnxksH|<^Dz-ZxZmN^xDibh7*Q49Kv@mD+OYpU4q-o@{$oc>O;{oJ z?*G+V)x}rcYv;Ha;#SZS_)`80hU5yU?4o?)@*6)T7uRRu;ZFF%$xL6?2hH?!+Giya|@Sjwe~+O#&VZC=5Gi!jbqR?@_s+eTN2kexP6BfOz+_ z>ee{OsGP#~TjwhaOMw}~(L$Ntf)sIR*pn4>PL-y`{cEN5naQ?WNghesRx`29$LhV2Tvvp34BF;gefGh}qWkA< z9rX(YoMrl)8JYU(aPE9R!qMRf{Y-Aau27D#YlZfeX2-PwaS$Z21;!^Hmo2{@<%SNa z=;RYK`S?%o@qL27j>|c+1T@4AkVc#&0(#0$`io9C5jVim6d}p_OL9{fC#2ZHffw@q z5X^NoMB_F@@)c%gN{XRmiL7H$)D0>oSHDBAX??HI*o!hS`s(baU8@Gmv@-t^3>J6E z_{$ip({$~zVXP@sDml%`9XSdkicm~?pYc>R%*z^FyUa1*0M|IkiiHD%+B?!=nMkpf ziZ7Ki<6i_!YVo}8WMne0sSpYWM=wNnzbAxEb|%I4rH3Uia}%TpkX|=rrxIXX(RZ=; ztytQMk&$+J@Wei&W{coJpoI>M_WZ%x~ z+T^wjgt;j;?%8Sf4fOe-Wymnz#^e7AbJE(ie?vZntGM&p%lb8Z6JU88M`VwHO-)S{ zkv&7iIGXxTkFSfpx#I(qzvj~tW5uix-K${u&#OkESXf`a`cv}sPI%-OLmtnUgol47 z(V4|r>sE{!amGV0C&2X-hx@1~6KbOod9loWx)#IjFERE&0uAeYWD8A*D^5o1&UyMA zh>BW4$TrEojEZj_fhpD&At!ermE5wj4nu^-&?s)zE1q_e6we`2iQ6&yO1m8eBo8Sj zR#~bkD|QA5Yz7l^ZLZcmuzBgv%TJnR5IFrrL^k%;4M#`Hw#}es9%G)jk6t91($@`J zP~ofBl51`a9#ls?OLB<@tFzvt3$|ixNE!jx?2SEPzZue&$J+!uuYQQuNU;!3V_@oi z>z>Yi0cp9egpFpyoK-WK32T+a3R^MRxK)|wYOkp!(zI#H*Dd*`*6iILy=3x_lj7WJ zUe{&z%3PnkBGkNo2Yp4~lqh2N4I^Z=%fp zsj7#Fwz+4$sF|n?m7Iz0SRJ0Yjlx7e_$S0T4WH@gT4Y<@r?QOz<;9XNQq5XvL&2 zw0W?K%M0+|NHRS|&xuS8g@m*fbtf?28o(X9l5o8!w|}U*vXwNEW!aCHxB+|Gq}L*qz;U<6ng0>OKRbqBkU3>gXuP>f^i1)fe7}Ef&2rn(~_UJ^0F6|NfnA&Aj8wbS%M}%_CZtG2dv(iQlHd z)r@!#g&SG~DfHqDG-2ioD|=ImE*#l*9>X&41AZw7q(fV!JKs(c6mJtK$)TZ(fc~Uu zK9-mrUx?kue_bbHQ1D;X76^m}A_4i)O?R!DP9v(be_#K6Gb*+7$v0CY{)qwcd-BbH ze;$jTfsk7wxT(a3r$JDum`@+ze--k&Vkh zbT*<}aB87{_b&tSS&`4G&&oNnFByn8Jon$=|F$Os(RHJn^?yeH|B52A>llcpLiFd) zE;+z_48%K$-898G<5@?x9Ru+hDDnSs9|K|jeGd4)Y)*f`kcGZL7z3jFfn3)Az>f94 zK%gHLZPYi*(}1X)wI{&i8n70jxH4DxT~CXm`$-+8t8R4OXJ*a`JGtptI3vBtPO=l$ae9+aWu4k>1qL!u3(>+jqjp4AJ^=%1B|fdtNP+xY00j(1L@?gy ztWc0;JHSwe5xTcO=N~DQ-_$$6Sd23oak0)w3$j837)uypEc`#j^1Dz27>voZNFMBr zwiDTB@ULKK|EFM3*bRX3yqxL@D6&8O^+Z0c28@Rv;kExClJncyHDD~!lc8QiXWS^{ zJ!8OFL=ozT|00%?rR;z2AOnU%O!qO-*!oXt`O_{MU?>e@s6s*7Qv-xj!i)ROn@OQ9(b3zpHY7zFKq%t zh`oy{gx?oBflU1FDCTcxjBbz@6oJfWSyW6E%Zos40_XNGCbK_1R>#9NRO@v>Y?0jQ zSitHX5S-?xt9;gNMqUU5g1h8Vflpr+14dvSAeiQM#$8690|rK*pn@ub(`JN${G)++ z9Fj9C7vw2WARnsw2mU`3qClXp;Z_dW8U0q|SwkQYPyG)-z#Jlw_~esF-dRNCnKB^p pu?8w4Qez8LqyhtIl+k7=L0Oq#BD7Q>5FO$t8qv8b)IR>}{{S9=Cn*2` literal 0 HcmV?d00001 diff --git a/app/app_optimized.py b/app/app_optimized.py deleted file mode 100644 index 9211d47..0000000 --- a/app/app_optimized.py +++ /dev/null @@ -1,6585 +0,0 @@ -""" -Homelab Automation Dashboard - Backend Optimisé -API REST moderne avec FastAPI pour la gestion d'homelab -""" - -from datetime import datetime, timezone, timedelta -from pathlib import Path -from time import perf_counter, time -import os -import re -import shutil -import subprocess -import sqlite3 -import yaml -from abc import ABC, abstractmethod -from typing import Literal, Any, List, Dict, Optional -from threading import Lock -import asyncio -import json -import uuid - -# APScheduler imports -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger -from apscheduler.jobstores.memory import MemoryJobStore -from apscheduler.executors.asyncio import AsyncIOExecutor -from croniter import croniter -import pytz - -from fastapi import FastAPI, HTTPException, Depends, Request, Form, WebSocket, WebSocketDisconnect -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response -from fastapi.security import APIKeyHeader -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from io import BytesIO -from xml.sax.saxutils import escape as _xml_escape -from pydantic import BaseModel, Field, field_validator, ConfigDict -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy import select -from app.models.database import get_db, async_session_maker # type: ignore -from app.crud.host import HostRepository # type: ignore -from app.crud.bootstrap_status import BootstrapStatusRepository # type: ignore -from app.crud.log import LogRepository # type: ignore -from app.crud.task import TaskRepository # type: ignore -from app.crud.schedule import ScheduleRepository # type: ignore -from app.crud.schedule_run import ScheduleRunRepository # type: ignore -from app.schemas.notification import NotificationRequest, NotificationResponse # type: ignore - -BASE_DIR = Path(__file__).resolve().parent - -# Configuration avancée de l'application -app = FastAPI( - title="Homelab Automation Dashboard API", - version="1.0.0", - description="API REST moderne pour la gestion automatique d'homelab", - docs_url="/api/docs", - redoc_url="/api/redoc" -) - -# Middleware CORS pour le développement -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # À restreindre en production - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -app.mount("/static", StaticFiles(directory=BASE_DIR, html=False), name="static") - -# Configuration des chemins et variables d'environnement -LOGS_DIR = Path(os.environ.get("LOGS_DIR", "/logs")) -ANSIBLE_DIR = BASE_DIR.parent / "ansible" -SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", str(Path.home() / ".ssh" / "id_rsa")) -SSH_USER = os.environ.get("SSH_USER", "automation") -SSH_REMOTE_USER = os.environ.get("SSH_REMOTE_USER", "root") -DB_PATH = LOGS_DIR / "homelab.db" -API_KEY = os.environ.get("API_KEY", "dev-key-12345") -# Répertoire pour les logs de tâches en markdown (format YYYY/MM/JJ) -DIR_LOGS_TASKS = Path(os.environ.get("DIR_LOGS_TASKS", str(BASE_DIR.parent / "tasks_logs"))) -# Fichier JSON pour l'historique des commandes ad-hoc -ADHOC_HISTORY_FILE = DIR_LOGS_TASKS / ".adhoc_history.json" -# Fichier JSON pour les statuts persistés -BOOTSTRAP_STATUS_FILE = DIR_LOGS_TASKS / ".bootstrap_status.json" -HOST_STATUS_FILE = ANSIBLE_DIR / ".host_status.json" - -# Mapping des actions vers les playbooks -ACTION_PLAYBOOK_MAP = { - 'upgrade': 'vm-upgrade.yml', - 'reboot': 'vm-reboot.yml', - 'health-check': 'health-check.yml', - 'backup': 'backup-config.yml', - 'bootstrap': 'bootstrap-host.yml', -} - -# Gestionnaire de clés API -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - -# Modèles Pydantic améliorés -class CommandResult(BaseModel): - status: str - return_code: int - stdout: str - stderr: Optional[str] = None - execution_time: Optional[float] = None - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - -class Host(BaseModel): - id: str - name: str - ip: str - status: Literal["online", "offline", "warning"] - os: str - last_seen: Optional[datetime] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - groups: List[str] = [] # Groupes Ansible auxquels appartient l'hôte - bootstrap_ok: bool = False # Indique si le bootstrap a été effectué avec succès - bootstrap_date: Optional[datetime] = None # Date du dernier bootstrap réussi - - class Config: - json_encoders = { - datetime: lambda v: v.isoformat() - } - -class Task(BaseModel): - id: str - name: str - host: str - status: Literal["pending", "running", "completed", "failed", "cancelled"] - progress: int = Field(ge=0, le=100, default=0) - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - duration: Optional[str] = None - output: Optional[str] = None - error: Optional[str] = None - - class Config: - json_encoders = { - datetime: lambda v: v.isoformat() if v else None - } - -class LogEntry(BaseModel): - id: int - timestamp: datetime - level: Literal["DEBUG", "INFO", "WARN", "ERROR"] - message: str - source: Optional[str] = None - host: Optional[str] = None - - class Config: - json_encoders = { - datetime: lambda v: v.isoformat() - } - -class SystemMetrics(BaseModel): - online_hosts: int - total_tasks: int - success_rate: float - uptime: float - cpu_usage: float - memory_usage: float - disk_usage: float - -class HealthCheck(BaseModel): - host: str - ssh_ok: bool = False - ansible_ok: bool = False - sudo_ok: bool = False - reachable: bool = False - error_message: Optional[str] = None - response_time: Optional[float] = None - cached: bool = False - cache_age: int = 0 - -class AnsibleExecutionRequest(BaseModel): - playbook: str = Field(..., description="Nom du playbook à exécuter") - target: str = Field(default="all", description="Hôte ou groupe cible") - extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables supplémentaires") - check_mode: bool = Field(default=False, description="Mode dry-run (--check)") - verbose: bool = Field(default=False, description="Mode verbeux") - -class AnsibleInventoryHost(BaseModel): - name: str - ansible_host: str - group: str - groups: List[str] = [] # All groups this host belongs to - vars: Dict[str, Any] = {} - -class TaskRequest(BaseModel): - host: Optional[str] = Field(default=None, description="Hôte cible") - group: Optional[str] = Field(default=None, description="Groupe cible") - action: str = Field(..., description="Action à exécuter") - cmd: Optional[str] = Field(default=None, description="Commande personnalisée") - extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables Ansible") - tags: Optional[List[str]] = Field(default=None, description="Tags Ansible") - dry_run: bool = Field(default=False, description="Mode simulation") - ssh_user: Optional[str] = Field(default=None, description="Utilisateur SSH") - ssh_password: Optional[str] = Field(default=None, description="Mot de passe SSH") - - @field_validator('action') - @classmethod - def validate_action(cls, v: str) -> str: - valid_actions = ['upgrade', 'reboot', 'health-check', 'backup', 'deploy', 'rollback', 'maintenance', 'bootstrap'] - if v not in valid_actions: - raise ValueError(f'Action doit être l\'une de: {", ".join(valid_actions)}') - return v - -class HostRequest(BaseModel): - name: str = Field(..., min_length=3, max_length=100, description="Hostname (ex: server.domain.home)") - # ansible_host peut être soit une IPv4, soit un hostname résolvable → on enlève la contrainte de pattern - ip: Optional[str] = Field(default=None, description="Adresse IP ou hostname (optionnel si hostname résolvable)") - os: str = Field(default="Linux", min_length=3, max_length=50) - ssh_user: Optional[str] = Field(default="root", min_length=1, max_length=50) - ssh_port: int = Field(default=22, ge=1, le=65535) - description: Optional[str] = Field(default=None, max_length=200) - env_group: str = Field(..., description="Groupe d'environnement (ex: env_homelab, env_prod)") - role_groups: List[str] = Field(default=[], description="Groupes de rôles (ex: role_proxmox, role_sbc)") - -class HostUpdateRequest(BaseModel): - """Requête de mise à jour d'un hôte""" - env_group: Optional[str] = Field(default=None, description="Nouveau groupe d'environnement") - role_groups: Optional[List[str]] = Field(default=None, description="Nouveaux groupes de rôles") - ansible_host: Optional[str] = Field(default=None, description="Nouvelle adresse ansible_host") - -class GroupRequest(BaseModel): - """Requête pour créer un groupe""" - name: str = Field(..., min_length=3, max_length=50, description="Nom du groupe (ex: env_prod, role_web)") - type: str = Field(..., description="Type de groupe: 'env' ou 'role'") - - @field_validator('name') - @classmethod - def validate_name(cls, v: str) -> str: - import re - if not re.match(r'^[a-zA-Z0-9_-]+$', v): - raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores') - return v - - @field_validator('type') - @classmethod - def validate_type(cls, v: str) -> str: - if v not in ['env', 'role']: - raise ValueError("Le type doit être 'env' ou 'role'") - return v - -class GroupUpdateRequest(BaseModel): - """Requête pour modifier un groupe""" - new_name: str = Field(..., min_length=3, max_length=50, description="Nouveau nom du groupe") - - @field_validator('new_name') - @classmethod - def validate_new_name(cls, v: str) -> str: - import re - if not re.match(r'^[a-zA-Z0-9_-]+$', v): - raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores') - return v - -class GroupDeleteRequest(BaseModel): - """Requête pour supprimer un groupe""" - move_hosts_to: Optional[str] = Field(default=None, description="Groupe vers lequel déplacer les hôtes") - -class AdHocCommandRequest(BaseModel): - """Requête pour exécuter une commande ad-hoc Ansible""" - target: str = Field(..., description="Hôte ou groupe cible") - command: str = Field(..., description="Commande shell à exécuter") - module: str = Field(default="shell", description="Module Ansible (shell, command, raw)") - become: bool = Field(default=False, description="Exécuter avec sudo") - timeout: int = Field(default=60, ge=5, le=600, description="Timeout en secondes") - category: Optional[str] = Field(default="default", description="Catégorie d'historique pour cette commande") - -class AdHocCommandResult(BaseModel): - """Résultat d'une commande ad-hoc""" - target: str - command: str - success: bool - return_code: int - stdout: str - stderr: Optional[str] = None - duration: float - hosts_results: Optional[Dict[str, Any]] = None - -class AdHocHistoryEntry(BaseModel): - """Entrée dans l'historique des commandes ad-hoc""" - id: str - command: str - target: str - module: str - become: bool - category: str = "default" - description: Optional[str] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - last_used: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - use_count: int = 1 - -class AdHocHistoryCategory(BaseModel): - """Catégorie pour organiser les commandes ad-hoc""" - name: str - description: Optional[str] = None - color: str = "#7c3aed" - icon: str = "fa-folder" - -class TaskLogFile(BaseModel): - """Représentation d'un fichier de log de tâche""" - id: str - filename: str - path: str - task_name: str - target: str - status: str - date: str # Format YYYY-MM-DD - year: str - month: str - day: str - created_at: datetime - size_bytes: int - # Nouveaux champs pour affichage enrichi - start_time: Optional[str] = None # Format ISO ou HH:MM:SS - end_time: Optional[str] = None # Format ISO ou HH:MM:SS - duration: Optional[str] = None # Durée formatée - duration_seconds: Optional[int] = None # Durée en secondes - hosts: List[str] = [] # Liste des hôtes impliqués - category: Optional[str] = None # Catégorie (Playbook, Ad-hoc, etc.) - subcategory: Optional[str] = None # Sous-catégorie - target_type: Optional[str] = None # Type de cible: 'host', 'group', 'role' - source_type: Optional[str] = None # Source: 'scheduled', 'manual', 'adhoc' - -class TasksFilterParams(BaseModel): - """Paramètres de filtrage des tâches""" - status: Optional[str] = None # pending, running, completed, failed, all - year: Optional[str] = None - month: Optional[str] = None - day: Optional[str] = None - hour_start: Optional[str] = None # Heure de début HH:MM - hour_end: Optional[str] = None # Heure de fin HH:MM - target: Optional[str] = None - source_type: Optional[str] = None # scheduled, manual, adhoc - search: Optional[str] = None - limit: int = 50 # Pagination côté serveur - offset: int = 0 - -# ===== MODÈLES PLANIFICATEUR (SCHEDULER) ===== - -class ScheduleRecurrence(BaseModel): - """Configuration de récurrence pour un schedule""" - type: Literal["daily", "weekly", "monthly", "custom"] = "daily" - time: str = Field(default="02:00", description="Heure d'exécution HH:MM") - days: Optional[List[int]] = Field(default=None, description="Jours de la semaine (1-7, lundi=1) pour weekly") - day_of_month: Optional[int] = Field(default=None, ge=1, le=31, description="Jour du mois (1-31) pour monthly") - cron_expression: Optional[str] = Field(default=None, description="Expression cron pour custom") - -class Schedule(BaseModel): - """Modèle d'un schedule de playbook""" - id: str = Field(default_factory=lambda: f"sched_{uuid.uuid4().hex[:12]}") - name: str = Field(..., min_length=3, max_length=100, description="Nom du schedule") - description: Optional[str] = Field(default=None, max_length=500) - playbook: str = Field(..., description="Nom du playbook à exécuter") - target_type: Literal["group", "host"] = Field(default="group", description="Type de cible") - target: str = Field(default="all", description="Nom du groupe ou hôte cible") - extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables supplémentaires") - schedule_type: Literal["once", "recurring"] = Field(default="recurring") - recurrence: Optional[ScheduleRecurrence] = Field(default=None) - timezone: str = Field(default="America/Montreal", description="Fuseau horaire") - start_at: Optional[datetime] = Field(default=None, description="Date de début (optionnel)") - end_at: Optional[datetime] = Field(default=None, description="Date de fin (optionnel)") - next_run_at: Optional[datetime] = Field(default=None, description="Prochaine exécution calculée") - last_run_at: Optional[datetime] = Field(default=None, description="Dernière exécution") - last_status: Literal["success", "failed", "running", "never"] = Field(default="never") - enabled: bool = Field(default=True, description="Schedule actif ou en pause") - retry_on_failure: int = Field(default=0, ge=0, le=3, description="Nombre de tentatives en cas d'échec") - timeout: int = Field(default=3600, ge=60, le=86400, description="Timeout en secondes") - notification_type: Literal["none", "all", "errors"] = Field(default="all", description="Type de notification: none, all, errors") - tags: List[str] = Field(default=[], description="Tags pour catégorisation") - run_count: int = Field(default=0, description="Nombre total d'exécutions") - success_count: int = Field(default=0, description="Nombre de succès") - failure_count: int = Field(default=0, description="Nombre d'échecs") - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - - class Config: - json_encoders = { - datetime: lambda v: v.isoformat() if v else None - } - - @field_validator('recurrence', mode='before') - @classmethod - def validate_recurrence(cls, v, info): - # Si schedule_type est 'once', recurrence n'est pas obligatoire - return v - -class ScheduleRun(BaseModel): - """Historique d'une exécution de schedule""" - id: str = Field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}") - schedule_id: str = Field(..., description="ID du schedule parent") - task_id: Optional[str] = Field(default=None, description="ID de la tâche créée") - started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - finished_at: Optional[datetime] = Field(default=None) - status: Literal["running", "success", "failed", "canceled"] = Field(default="running") - duration_seconds: Optional[float] = Field(default=None) - hosts_impacted: int = Field(default=0) - error_message: Optional[str] = Field(default=None) - retry_attempt: int = Field(default=0, description="Numéro de la tentative (0 = première)") - - class Config: - json_encoders = { - datetime: lambda v: v.isoformat() if v else None - } - -class ScheduleCreateRequest(BaseModel): - """Requête de création d'un schedule""" - name: str = Field(..., min_length=3, max_length=100) - description: Optional[str] = Field(default=None, max_length=500) - playbook: str = Field(...) - target_type: Literal["group", "host"] = Field(default="group") - target: str = Field(default="all") - extra_vars: Optional[Dict[str, Any]] = Field(default=None) - schedule_type: Literal["once", "recurring"] = Field(default="recurring") - recurrence: Optional[ScheduleRecurrence] = Field(default=None) - timezone: str = Field(default="America/Montreal") - start_at: Optional[datetime] = Field(default=None) - end_at: Optional[datetime] = Field(default=None) - enabled: bool = Field(default=True) - retry_on_failure: int = Field(default=0, ge=0, le=3) - timeout: int = Field(default=3600, ge=60, le=86400) - notification_type: Literal["none", "all", "errors"] = Field(default="all") - tags: List[str] = Field(default=[]) - - @field_validator('timezone') - @classmethod - def validate_timezone(cls, v: str) -> str: - try: - pytz.timezone(v) - return v - except pytz.exceptions.UnknownTimeZoneError: - raise ValueError(f"Fuseau horaire invalide: {v}") - -class ScheduleUpdateRequest(BaseModel): - """Requête de mise à jour d'un schedule""" - name: Optional[str] = Field(default=None, min_length=3, max_length=100) - description: Optional[str] = Field(default=None, max_length=500) - playbook: Optional[str] = Field(default=None) - target_type: Optional[Literal["group", "host"]] = Field(default=None) - target: Optional[str] = Field(default=None) - extra_vars: Optional[Dict[str, Any]] = Field(default=None) - schedule_type: Optional[Literal["once", "recurring"]] = Field(default=None) - recurrence: Optional[ScheduleRecurrence] = Field(default=None) - timezone: Optional[str] = Field(default=None) - start_at: Optional[datetime] = Field(default=None) - end_at: Optional[datetime] = Field(default=None) - enabled: Optional[bool] = Field(default=None) - retry_on_failure: Optional[int] = Field(default=None, ge=0, le=3) - timeout: Optional[int] = Field(default=None, ge=60, le=86400) - notification_type: Optional[Literal["none", "all", "errors"]] = Field(default=None) - tags: Optional[List[str]] = Field(default=None) - -class ScheduleStats(BaseModel): - """Statistiques globales des schedules""" - total: int = 0 - active: int = 0 - paused: int = 0 - expired: int = 0 - next_execution: Optional[datetime] = None - next_schedule_name: Optional[str] = None - failures_24h: int = 0 - executions_24h: int = 0 - success_rate_7d: float = 0.0 - -# ===== SERVICE DE LOGGING MARKDOWN ===== - -class TaskLogService: - """Service pour gérer les logs de tâches en fichiers markdown""" - - def __init__(self, base_dir: Path): - self.base_dir = base_dir - self._ensure_base_dir() - # Cache des métadonnées pour éviter de relire les fichiers - self._metadata_cache: Dict[str, Dict[str, Any]] = {} - self._cache_file = base_dir / ".metadata_cache.json" - # Index complet des logs (construit une fois, mis à jour incrémentalement) - self._logs_index: List[Dict[str, Any]] = [] - self._index_built = False - self._last_scan_time = 0.0 - self._load_cache() - - def _ensure_base_dir(self): - """Crée le répertoire de base s'il n'existe pas""" - self.base_dir.mkdir(parents=True, exist_ok=True) - - def _load_cache(self): - """Charge le cache des métadonnées depuis le fichier""" - try: - if self._cache_file.exists(): - import json - with open(self._cache_file, 'r', encoding='utf-8') as f: - self._metadata_cache = json.load(f) - except Exception: - self._metadata_cache = {} - - def _save_cache(self): - """Sauvegarde le cache des métadonnées dans le fichier""" - try: - import json - with open(self._cache_file, 'w', encoding='utf-8') as f: - json.dump(self._metadata_cache, f, ensure_ascii=False) - except Exception: - pass - - def _get_cached_metadata(self, file_path: str, file_mtime: float) -> Optional[Dict[str, Any]]: - """Récupère les métadonnées du cache si elles sont valides""" - cached = self._metadata_cache.get(file_path) - if cached and cached.get('_mtime') == file_mtime: - return cached - return None - - def _cache_metadata(self, file_path: str, file_mtime: float, metadata: Dict[str, Any]): - """Met en cache les métadonnées d'un fichier""" - metadata['_mtime'] = file_mtime - self._metadata_cache[file_path] = metadata - - def _build_index(self, force: bool = False): - """Construit l'index complet des logs (appelé une seule fois au démarrage ou après 60s)""" - import time - current_time = time.time() - - # Ne reconstruire que si nécessaire (toutes les 60 secondes max ou si forcé) - if self._index_built and not force and (current_time - self._last_scan_time) < 60: - return - - self._logs_index = [] - cache_updated = False - - if not self.base_dir.exists(): - self._index_built = True - self._last_scan_time = current_time - return - - # Parcourir tous les fichiers - for year_dir in self.base_dir.iterdir(): - if not year_dir.is_dir() or not year_dir.name.isdigit(): - continue - for month_dir in year_dir.iterdir(): - if not month_dir.is_dir(): - continue - for day_dir in month_dir.iterdir(): - if not day_dir.is_dir(): - continue - for md_file in day_dir.glob("*.md"): - try: - entry = self._index_file(md_file) - if entry: - if entry.get('_cache_updated'): - cache_updated = True - del entry['_cache_updated'] - self._logs_index.append(entry) - except Exception: - continue - - # Trier par date décroissante - self._logs_index.sort(key=lambda x: x.get('created_at', 0), reverse=True) - - self._index_built = True - self._last_scan_time = current_time - - if cache_updated: - self._save_cache() - - def _index_file(self, md_file: Path) -> Optional[Dict[str, Any]]: - """Indexe un fichier markdown et retourne ses métadonnées""" - parts = md_file.stem.split("_") - if len(parts) < 4: - return None - - file_status = parts[-1] - file_hour_str = parts[1] if len(parts) > 1 else "000000" - - # Extraire la date du chemin - try: - rel_path = md_file.relative_to(self.base_dir) - path_parts = rel_path.parts - if len(path_parts) >= 3: - log_year, log_month, log_day = path_parts[0], path_parts[1], path_parts[2] - else: - return None - except: - return None - - stat = md_file.stat() - file_path_str = str(md_file) - file_mtime = stat.st_mtime - - # Vérifier le cache - cached = self._get_cached_metadata(file_path_str, file_mtime) - cache_updated = False - - if cached: - task_name = cached.get('task_name', '') - file_target = cached.get('target', '') - metadata = cached - else: - # Lire le fichier - if len(parts) >= 5: - file_target = parts[3] - task_name_from_file = "_".join(parts[4:-1]) if len(parts) > 5 else parts[4] if len(parts) > 4 else "unknown" - else: - file_target = "" - task_name_from_file = "_".join(parts[3:-1]) if len(parts) > 4 else parts[3] if len(parts) > 3 else "unknown" - - try: - content = md_file.read_text(encoding='utf-8') - metadata = self._parse_markdown_metadata(content) - - task_name_match = re.search(r'^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$', content, re.MULTILINE) - if task_name_match: - task_name = task_name_match.group(1).strip() - else: - task_name = task_name_from_file.replace("_", " ") - - target_match = re.search(r'\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`', content) - if target_match: - file_target = target_match.group(1).strip() - - detected_source = self._detect_source_type(task_name, content) - metadata['source_type'] = detected_source - metadata['task_name'] = task_name - metadata['target'] = file_target - - self._cache_metadata(file_path_str, file_mtime, metadata) - cache_updated = True - except Exception: - metadata = {'source_type': 'manual'} - task_name = task_name_from_file.replace("_", " ") - - return { - 'id': parts[0] + "_" + parts[1] + "_" + parts[2] if len(parts) > 2 else parts[0], - 'filename': md_file.name, - 'path': file_path_str, - 'task_name': task_name, - 'target': file_target, - 'status': file_status, - 'date': f"{log_year}-{log_month}-{log_day}", - 'year': log_year, - 'month': log_month, - 'day': log_day, - 'hour_str': file_hour_str, - 'created_at': stat.st_ctime, - 'size_bytes': stat.st_size, - 'start_time': metadata.get('start_time'), - 'end_time': metadata.get('end_time'), - 'duration': metadata.get('duration'), - 'duration_seconds': metadata.get('duration_seconds'), - 'hosts': metadata.get('hosts', []), - 'category': metadata.get('category'), - 'subcategory': metadata.get('subcategory'), - 'target_type': metadata.get('target_type'), - 'source_type': metadata.get('source_type'), - '_cache_updated': cache_updated - } - - def invalidate_index(self): - """Force la reconstruction de l'index au prochain appel""" - self._index_built = False - - def _get_date_path(self, dt: datetime = None) -> Path: - """Retourne le chemin du répertoire pour une date donnée (YYYY/MM/JJ)""" - if dt is None: - dt = datetime.now(timezone.utc) - - # IMPORTANT : on utilise le fuseau horaire local (America/Montreal) - # pour déterminer la date du dossier de log, afin que les tâches - # exécutées en soirée ne basculent pas au jour suivant à cause de l'UTC. - import pytz - local_tz = pytz.timezone("America/Montreal") - - if dt.tzinfo is None: - # Si la datetime est naïve, on considère qu'elle est déjà en heure locale - dt_local = local_tz.localize(dt) - else: - # Sinon on la convertit dans le fuseau local - dt_local = dt.astimezone(local_tz) - - year = dt_local.strftime("%Y") - month = dt_local.strftime("%m") - day = dt_local.strftime("%d") - return self.base_dir / year / month / day - - def _generate_task_id(self) -> str: - """Génère un ID unique pour une tâche""" - import uuid - return f"task_{datetime.now(timezone.utc).strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}" - - def save_task_log(self, task: 'Task', output: str = "", error: str = "", source_type: str = None) -> str: - """Sauvegarde un log de tâche en markdown et retourne le chemin. - - Args: - task: L'objet tâche - output: La sortie de la tâche - error: Les erreurs éventuelles - source_type: Type de source ('scheduled', 'manual', 'adhoc') - """ - dt = task.start_time or datetime.now(timezone.utc) - date_path = self._get_date_path(dt) - date_path.mkdir(parents=True, exist_ok=True) - - # Générer le nom du fichier - task_id = self._generate_task_id() - status_emoji = { - "completed": "✅", - "failed": "❌", - "running": "🔄", - "pending": "⏳", - "cancelled": "🚫" - }.get(task.status, "❓") - - # Détecter le type de source si non fourni - if not source_type: - task_name_lower = task.name.lower() - if '[planifié]' in task_name_lower or '[scheduled]' in task_name_lower: - source_type = 'scheduled' - elif 'ad-hoc' in task_name_lower or 'adhoc' in task_name_lower: - source_type = 'adhoc' - else: - source_type = 'manual' - - # Labels pour le type de source - source_labels = {'scheduled': 'Planifié', 'manual': 'Manuel', 'adhoc': 'Ad-hoc'} - source_label = source_labels.get(source_type, 'Manuel') - - # Sanitize task name and host for filename - safe_name = task.name.replace(' ', '_').replace(':', '').replace('/', '-')[:50] - safe_host = task.host.replace(' ', '_').replace(':', '').replace('/', '-')[:30] if task.host else 'unknown' - filename = f"{task_id}_{safe_host}_{safe_name}_{task.status}.md" - filepath = date_path / filename - - # Créer le contenu markdown - md_content = f"""# {status_emoji} {task.name} - -## Informations - -| Propriété | Valeur | -|-----------|--------| -| **ID** | `{task.id}` | -| **Nom** | {task.name} | -| **Cible** | `{task.host}` | -| **Statut** | {task.status} | -| **Type** | {source_label} | -| **Progression** | {task.progress}% | -| **Début** | {task.start_time.isoformat() if task.start_time else 'N/A'} | -| **Fin** | {task.end_time.isoformat() if task.end_time else 'N/A'} | -| **Durée** | {task.duration or 'N/A'} | - -## Sortie - -``` -{output or task.output or '(Aucune sortie)'} -``` - -""" - if error or task.error: - md_content += f"""## Erreurs - -``` -{error or task.error} -``` - -""" - - md_content += f"""--- -*Généré automatiquement par Homelab Automation Dashboard* -*Date: {datetime.now(timezone.utc).isoformat()}* -""" - - # Écrire le fichier - filepath.write_text(md_content, encoding='utf-8') - - # Invalider l'index pour qu'il soit reconstruit au prochain appel - self.invalidate_index() - - return str(filepath) - - def _parse_markdown_metadata(self, content: str) -> Dict[str, Any]: - """Parse le contenu markdown pour extraire les métadonnées enrichies""" - metadata = { - 'start_time': None, - 'end_time': None, - 'duration': None, - 'duration_seconds': None, - 'hosts': [], - 'category': None, - 'subcategory': None, - 'target_type': None, - 'source_type': None - } - - # Extraire les heures de début et fin - start_match = re.search(r'\|\s*\*\*Début\*\*\s*\|\s*([^|]+)', content) - if start_match: - start_val = start_match.group(1).strip() - if start_val and start_val != 'N/A': - metadata['start_time'] = start_val - - end_match = re.search(r'\|\s*\*\*Fin\*\*\s*\|\s*([^|]+)', content) - if end_match: - end_val = end_match.group(1).strip() - if end_val and end_val != 'N/A': - metadata['end_time'] = end_val - - duration_match = re.search(r'\|\s*\*\*Durée\*\*\s*\|\s*([^|]+)', content) - if duration_match: - dur_val = duration_match.group(1).strip() - if dur_val and dur_val != 'N/A': - metadata['duration'] = dur_val - # Convertir en secondes si possible - metadata['duration_seconds'] = self._parse_duration_to_seconds(dur_val) - - # Extraire les hôtes depuis la sortie Ansible - # Pattern pour les hôtes dans PLAY RECAP ou les résultats de tâches - host_patterns = [ - r'^([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*:\s*ok=', # PLAY RECAP format - r'^\s*([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)', # Ad-hoc format - ] - hosts_found = set() - for pattern in host_patterns: - for match in re.finditer(pattern, content, re.MULTILINE): - host = match.group(1).strip() - if host and len(host) > 2 and '.' in host or len(host) > 5: - hosts_found.add(host) - metadata['hosts'] = sorted(list(hosts_found)) - - # Détecter la catégorie - task_name_match = re.search(r'^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$', content, re.MULTILINE) - if task_name_match: - task_name = task_name_match.group(1).strip().lower() - if 'playbook' in task_name: - metadata['category'] = 'Playbook' - # Extraire sous-catégorie du nom - if 'health' in task_name: - metadata['subcategory'] = 'Health Check' - elif 'backup' in task_name: - metadata['subcategory'] = 'Backup' - elif 'upgrade' in task_name or 'update' in task_name: - metadata['subcategory'] = 'Upgrade' - elif 'bootstrap' in task_name: - metadata['subcategory'] = 'Bootstrap' - elif 'reboot' in task_name: - metadata['subcategory'] = 'Reboot' - elif 'ad-hoc' in task_name or 'adhoc' in task_name: - metadata['category'] = 'Ad-hoc' - else: - metadata['category'] = 'Autre' - - # Détecter le type de cible - target_match = re.search(r'\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`', content) - if target_match: - target_val = target_match.group(1).strip() - if target_val == 'all': - metadata['target_type'] = 'group' - elif target_val.startswith('env_') or target_val.startswith('role_'): - metadata['target_type'] = 'group' - elif '.' in target_val: - metadata['target_type'] = 'host' - else: - metadata['target_type'] = 'group' - - # Extraire le type de source depuis le markdown (si présent) - type_match = re.search(r'\|\s*\*\*Type\*\*\s*\|\s*([^|]+)', content) - if type_match: - type_val = type_match.group(1).strip().lower() - if 'planifié' in type_val or 'scheduled' in type_val: - metadata['source_type'] = 'scheduled' - elif 'ad-hoc' in type_val or 'adhoc' in type_val: - metadata['source_type'] = 'adhoc' - elif 'manuel' in type_val or 'manual' in type_val: - metadata['source_type'] = 'manual' - - return metadata - - def _parse_duration_to_seconds(self, duration_str: str) -> Optional[int]: - """Convertit une chaîne de durée en secondes""" - if not duration_str: - return None - - total_seconds = 0 - # Pattern: Xh Xm Xs ou X:XX:XX ou Xs - - s_clean = duration_str.strip() - - # Gérer explicitement les secondes seules (avec éventuellement des décimales), - # par ex. "1.69s" ou "2,5 s" - sec_only_match = re.match(r'^(\d+(?:[\.,]\d+)?)\s*s$', s_clean) - if sec_only_match: - sec_val_str = sec_only_match.group(1).replace(',', '.') - try: - sec_val = float(sec_val_str) - except ValueError: - sec_val = 0.0 - return int(round(sec_val)) if sec_val > 0 else None - - # Format HH:MM:SS - hms_match = re.match(r'^(\d+):(\d+):(\d+)$', s_clean) - if hms_match: - h, m, s = map(int, hms_match.groups()) - return h * 3600 + m * 60 + s - - # Format avec h, m, s (entiers uniquement, pour éviter de mal parser des décimales) - hours = re.search(r'(\d+)\s*h', s_clean) - minutes = re.search(r'(\d+)\s*m', s_clean) - seconds = re.search(r'(\d+)\s*s', s_clean) - - if hours: - total_seconds += int(hours.group(1)) * 3600 - if minutes: - total_seconds += int(minutes.group(1)) * 60 - if seconds: - total_seconds += int(seconds.group(1)) - - return total_seconds if total_seconds > 0 else None - - def get_task_logs(self, - year: str = None, - month: str = None, - day: str = None, - status: str = None, - target: str = None, - category: str = None, - source_type: str = None, - hour_start: str = None, - hour_end: str = None, - limit: int = 50, - offset: int = 0) -> tuple[List[TaskLogFile], int]: - """Récupère la liste des logs de tâches avec filtrage et pagination. - - OPTIMISATION: Utilise un index en mémoire construit une seule fois, - puis filtre rapidement sans relire les fichiers. - - Returns: - tuple: (logs paginés, total count avant pagination) - """ - # Construire l'index si nécessaire (une seule fois, puis toutes les 60s) - self._build_index() - - # Convertir les heures de filtrage en minutes pour comparaison - hour_start_minutes = None - hour_end_minutes = None - if hour_start: - try: - h, m = map(int, hour_start.split(':')) - hour_start_minutes = h * 60 + m - except: - pass - if hour_end: - try: - h, m = map(int, hour_end.split(':')) - hour_end_minutes = h * 60 + m - except: - pass - - # Filtrer l'index (très rapide, pas de lecture de fichiers) - filtered = [] - for entry in self._logs_index: - # Filtrer par date - if year and entry['year'] != year: - continue - if month and entry['month'] != month: - continue - if day and entry['day'] != day: - continue - - # Filtrer par statut - if status and status != "all" and entry['status'] != status: - continue - - # Filtrer par heure - if hour_start_minutes is not None or hour_end_minutes is not None: - try: - file_hour_str = entry.get('hour_str', '000000') - file_h = int(file_hour_str[:2]) - file_m = int(file_hour_str[2:4]) - file_minutes = file_h * 60 + file_m - if hour_start_minutes is not None and file_minutes < hour_start_minutes: - continue - if hour_end_minutes is not None and file_minutes > hour_end_minutes: - continue - except: - pass - - # Filtrer par target - if target and target != "all": - file_target = entry.get('target', '') - if file_target and target.lower() not in file_target.lower(): - continue - - # Filtrer par catégorie - if category and category != "all": - file_category = entry.get('category', '') - if file_category and category.lower() not in file_category.lower(): - continue - - # Filtrer par type de source - if source_type and source_type != "all": - file_source = entry.get('source_type', '') - if file_source != source_type: - continue - - filtered.append(entry) - - # Convertir en TaskLogFile - total_count = len(filtered) - paginated = filtered[offset:offset + limit] if limit > 0 else filtered - - logs = [ - TaskLogFile( - id=e['id'], - filename=e['filename'], - path=e['path'], - task_name=e['task_name'], - target=e['target'], - status=e['status'], - date=e['date'], - year=e['year'], - month=e['month'], - day=e['day'], - created_at=datetime.fromtimestamp(e['created_at'], tz=timezone.utc), - size_bytes=e['size_bytes'], - start_time=e.get('start_time'), - end_time=e.get('end_time'), - duration=e.get('duration'), - duration_seconds=e.get('duration_seconds'), - hosts=e.get('hosts', []), - category=e.get('category'), - subcategory=e.get('subcategory'), - target_type=e.get('target_type'), - source_type=e.get('source_type') - ) - for e in paginated - ] - - return logs, total_count - - def _detect_source_type(self, task_name: str, content: str) -> str: - """Détecte le type de source d'une tâche: scheduled, manual, adhoc""" - task_name_lower = task_name.lower() - content_lower = content.lower() - - # Détecter les tâches planifiées - if '[planifié]' in task_name_lower or '[scheduled]' in task_name_lower: - return 'scheduled' - if 'schedule_id' in content_lower or 'planifié' in content_lower: - return 'scheduled' - - # Détecter les commandes ad-hoc - if 'ad-hoc' in task_name_lower or 'adhoc' in task_name_lower: - return 'adhoc' - if 'commande ad-hoc' in content_lower or 'ansible ad-hoc' in content_lower: - return 'adhoc' - # Pattern ad-hoc: module ansible direct (ping, shell, command, etc.) - if re.search(r'\|\s*\*\*Module\*\*\s*\|', content): - return 'adhoc' - - # Par défaut, c'est une exécution manuelle de playbook - return 'manual' - - def get_available_dates(self) -> Dict[str, Any]: - """Retourne la structure des dates disponibles pour le filtrage""" - dates = {"years": {}} - - if not self.base_dir.exists(): - return dates - - for year_dir in sorted(self.base_dir.iterdir(), reverse=True): - if year_dir.is_dir() and year_dir.name.isdigit(): - year = year_dir.name - dates["years"][year] = {"months": {}} - - for month_dir in sorted(year_dir.iterdir(), reverse=True): - if month_dir.is_dir() and month_dir.name.isdigit(): - month = month_dir.name - dates["years"][year]["months"][month] = {"days": []} - - for day_dir in sorted(month_dir.iterdir(), reverse=True): - if day_dir.is_dir() and day_dir.name.isdigit(): - day = day_dir.name - count = len(list(day_dir.glob("*.md"))) - dates["years"][year]["months"][month]["days"].append({ - "day": day, - "count": count - }) - - return dates - - def get_stats(self) -> Dict[str, int]: - """Retourne les statistiques des tâches""" - stats = {"total": 0, "completed": 0, "failed": 0, "running": 0, "pending": 0} - - # Utiliser limit=0 pour récupérer tous les logs (sans pagination) - logs, _ = self.get_task_logs(limit=0) - for log in logs: - stats["total"] += 1 - if log.status in stats: - stats[log.status] += 1 - - return stats - - -# ===== SERVICE HISTORIQUE COMMANDES AD-HOC (VERSION BD) ===== - -class AdHocHistoryService: - """Service pour gérer l'historique des commandes ad-hoc avec catégories. - - Implémentation basée sur la BD (table ``logs``) via LogRepository, - sans aucun accès aux fichiers JSON (.adhoc_history.json). - """ - - def __init__(self) -> None: - # Pas de fichier, tout est stocké en BD - pass - - async def _get_commands_logs(self, session: AsyncSession) -> List["Log"]: - from app.models.log import Log - stmt = ( - select(Log) - .where(Log.source == "adhoc_history") - .order_by(Log.created_at.desc()) - ) - result = await session.execute(stmt) - return result.scalars().all() - - async def _get_categories_logs(self, session: AsyncSession) -> List["Log"]: - from app.models.log import Log - stmt = ( - select(Log) - .where(Log.source == "adhoc_category") - .order_by(Log.created_at.asc()) - ) - result = await session.execute(stmt) - return result.scalars().all() - - async def add_command( - self, - command: str, - target: str, - module: str, - become: bool, - category: str = "default", - description: str | None = None, - ) -> AdHocHistoryEntry: - """Ajoute ou met à jour une commande dans l'historique (stockée dans logs.details).""" - from app.models.log import Log - from app.crud.log import LogRepository - - async with async_session_maker() as session: - repo = LogRepository(session) - - # Charger tous les logs d'historique et chercher une entrée existante - logs = await self._get_commands_logs(session) - existing_log: Optional[Log] = None - for log in logs: - details = log.details or {} - if details.get("command") == command and details.get("target") == target: - existing_log = log - break - - now = datetime.now(timezone.utc) - - if existing_log is not None: - details = existing_log.details or {} - details.setdefault("id", details.get("id") or f"adhoc_{existing_log.id}") - details["command"] = command - details["target"] = target - details["module"] = module - details["become"] = bool(become) - details["category"] = category or details.get("category", "default") - if description is not None: - details["description"] = description - details["created_at"] = details.get("created_at") or now.isoformat() - details["last_used"] = now.isoformat() - details["use_count"] = int(details.get("use_count", 1)) + 1 - existing_log.details = details - await session.commit() - data = details - else: - import uuid - - entry_id = f"adhoc_{uuid.uuid4().hex[:8]}" - details = { - "id": entry_id, - "command": command, - "target": target, - "module": module, - "become": bool(become), - "category": category or "default", - "description": description, - "created_at": now.isoformat(), - "last_used": now.isoformat(), - "use_count": 1, - } - - log = await repo.create( - level="INFO", - source="adhoc_history", - message=command, - details=details, - ) - await session.commit() - data = log.details or details - - # Construire l'entrée Pydantic - return AdHocHistoryEntry( - id=data.get("id"), - command=data.get("command", command), - target=data.get("target", target), - module=data.get("module", module), - become=bool(data.get("become", become)), - category=data.get("category", category or "default"), - description=data.get("description", description), - created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")) - if isinstance(data.get("created_at"), str) - else now, - last_used=datetime.fromisoformat(data["last_used"].replace("Z", "+00:00")) - if isinstance(data.get("last_used"), str) - else now, - use_count=int(data.get("use_count", 1)), - ) - - async def get_commands( - self, - category: str | None = None, - search: str | None = None, - limit: int = 50, - ) -> List[AdHocHistoryEntry]: - """Récupère les commandes de l'historique depuis la BD.""" - async with async_session_maker() as session: - logs = await self._get_commands_logs(session) - - commands: List[AdHocHistoryEntry] = [] - for log in logs: - details = log.details or {} - cmd = details.get("command") or log.message or "" - if category and details.get("category", "default") != category: - continue - if search and search.lower() not in cmd.lower(): - continue - - created_at_raw = details.get("created_at") - last_used_raw = details.get("last_used") - try: - created_at = ( - datetime.fromisoformat(created_at_raw.replace("Z", "+00:00")) - if isinstance(created_at_raw, str) - else log.created_at - ) - except Exception: - created_at = log.created_at - try: - last_used = ( - datetime.fromisoformat(last_used_raw.replace("Z", "+00:00")) - if isinstance(last_used_raw, str) - else created_at - ) - except Exception: - last_used = created_at - - entry = AdHocHistoryEntry( - id=details.get("id") or f"adhoc_{log.id}", - command=cmd, - target=details.get("target", ""), - module=details.get("module", "shell"), - become=bool(details.get("become", False)), - category=details.get("category", "default"), - description=details.get("description"), - created_at=created_at, - last_used=last_used, - use_count=int(details.get("use_count", 1)), - ) - commands.append(entry) - - # Trier par last_used décroissant - commands.sort(key=lambda x: x.last_used, reverse=True) - return commands[:limit] - - async def get_categories(self) -> List[AdHocHistoryCategory]: - """Récupère la liste des catégories depuis la BD. - - Si aucune catégorie n'est présente, les catégories par défaut sont créées. - """ - from app.crud.log import LogRepository - - async with async_session_maker() as session: - logs = await self._get_categories_logs(session) - - if not logs: - # Initialiser avec les catégories par défaut - defaults = [ - {"name": "default", "description": "Commandes générales", "color": "#7c3aed", "icon": "fa-terminal"}, - {"name": "diagnostic", "description": "Commandes de diagnostic", "color": "#10b981", "icon": "fa-stethoscope"}, - {"name": "maintenance", "description": "Commandes de maintenance", "color": "#f59e0b", "icon": "fa-wrench"}, - {"name": "deployment", "description": "Commandes de déploiement", "color": "#3b82f6", "icon": "fa-rocket"}, - ] - repo = LogRepository(session) - for cat in defaults: - await repo.create( - level="INFO", - source="adhoc_category", - message=cat["name"], - details=cat, - ) - await session.commit() - logs = await self._get_categories_logs(session) - - categories: List[AdHocHistoryCategory] = [] - for log in logs: - data = log.details or {} - categories.append( - AdHocHistoryCategory( - name=data.get("name") or log.message, - description=data.get("description"), - color=data.get("color", "#7c3aed"), - icon=data.get("icon", "fa-folder"), - ) - ) - return categories - - async def add_category( - self, - name: str, - description: str | None = None, - color: str = "#7c3aed", - icon: str = "fa-folder", - ) -> AdHocHistoryCategory: - """Ajoute une nouvelle catégorie en BD (ou renvoie l'existante).""" - from app.crud.log import LogRepository - - async with async_session_maker() as session: - logs = await self._get_categories_logs(session) - for log in logs: - data = log.details or {} - if data.get("name") == name: - return AdHocHistoryCategory( - name=data.get("name"), - description=data.get("description"), - color=data.get("color", color), - icon=data.get("icon", icon), - ) - - repo = LogRepository(session) - details = { - "name": name, - "description": description, - "color": color, - "icon": icon, - } - await repo.create( - level="INFO", - source="adhoc_category", - message=name, - details=details, - ) - await session.commit() - return AdHocHistoryCategory(**details) - - async def delete_command(self, command_id: str) -> bool: - """Supprime une commande de l'historique (ligne dans logs).""" - from app.models.log import Log - - async with async_session_maker() as session: - stmt = select(Log).where(Log.source == "adhoc_history") - result = await session.execute(stmt) - logs = result.scalars().all() - - target_log: Optional[Log] = None - for log in logs: - details = log.details or {} - if details.get("id") == command_id: - target_log = log - break - - if not target_log: - return False - - await session.delete(target_log) - await session.commit() - return True - - async def update_command_category( - self, - command_id: str, - category: str, - description: str | None = None, - ) -> bool: - """Met à jour la catégorie d'une commande dans l'historique.""" - from app.models.log import Log - - async with async_session_maker() as session: - stmt = select(Log).where(Log.source == "adhoc_history") - result = await session.execute(stmt) - logs = result.scalars().all() - - for log in logs: - details = log.details or {} - if details.get("id") == command_id: - details["category"] = category - if description is not None: - details["description"] = description - log.details = details - await session.commit() - return True - return False - - async def update_category( - self, - category_name: str, - new_name: str, - description: str, - color: str, - icon: str, - ) -> bool: - """Met à jour une catégorie existante et les commandes associées.""" - from app.models.log import Log - - async with async_session_maker() as session: - # Mettre à jour la catégorie elle-même - logs_cat = await self._get_categories_logs(session) - target_log: Optional[Log] = None - for log in logs_cat: - data = log.details or {} - if data.get("name") == category_name: - target_log = log - break - - if not target_log: - return False - - data = target_log.details or {} - old_name = data.get("name", category_name) - data["name"] = new_name - data["description"] = description - data["color"] = color - data["icon"] = icon - target_log.details = data - - # Mettre à jour les commandes qui référencent cette catégorie - stmt_cmd = select(Log).where(Log.source == "adhoc_history") - result_cmd = await session.execute(stmt_cmd) - for cmd_log in result_cmd.scalars().all(): - det = cmd_log.details or {} - if det.get("category") == old_name: - det["category"] = new_name - cmd_log.details = det - - await session.commit() - return True - - async def delete_category(self, category_name: str) -> bool: - """Supprime une catégorie et déplace ses commandes vers 'default'.""" - if category_name == "default": - return False - - from app.models.log import Log - - async with async_session_maker() as session: - # Trouver la catégorie - logs_cat = await self._get_categories_logs(session) - target_log: Optional[Log] = None - for log in logs_cat: - data = log.details or {} - if data.get("name") == category_name: - target_log = log - break - - if not target_log: - return False - - # Déplacer les commandes vers "default" - stmt_cmd = select(Log).where(Log.source == "adhoc_history") - result_cmd = await session.execute(stmt_cmd) - for cmd_log in result_cmd.scalars().all(): - det = cmd_log.details or {} - if det.get("category") == category_name: - det["category"] = "default" - cmd_log.details = det - - # Supprimer la catégorie - await session.delete(target_log) - await session.commit() - return True - - -# ===== SERVICE BOOTSTRAP STATUS (VERSION BD) ===== - -class BootstrapStatusService: - """Service pour gérer le statut de bootstrap des hôtes. - - Cette version utilise la base de données SQLite via SQLAlchemy async. - Note: Le modèle BD utilise host_id (FK), mais ce service utilise host_name - pour la compatibilité avec le code existant. Il fait la correspondance via HostRepository. - """ - - def __init__(self): - # Cache en mémoire pour éviter les requêtes BD répétées - self._cache: Dict[str, Dict] = {} - - async def _get_host_id_by_name(self, session: AsyncSession, host_name: str) -> Optional[str]: - """Récupère l'ID d'un hôte par son nom""" - from crud.host import HostRepository - repo = HostRepository(session) - host = await repo.get_by_name(host_name) - return host.id if host else None - - def set_bootstrap_status(self, host_name: str, success: bool, details: str = None) -> Dict: - """Enregistre le statut de bootstrap d'un hôte (version synchrone avec cache)""" - status_data = { - "bootstrap_ok": success, - "bootstrap_date": datetime.now(timezone.utc).isoformat(), - "details": details - } - self._cache[host_name] = status_data - - # Planifier la sauvegarde en BD de manière asynchrone - asyncio.create_task(self._save_to_db(host_name, success, details)) - - return status_data - - async def _save_to_db(self, host_name: str, success: bool, details: str = None): - """Sauvegarde le statut dans la BD""" - try: - async with async_session_maker() as session: - host_id = await self._get_host_id_by_name(session, host_name) - if not host_id: - print(f"Host '{host_name}' non trouvé en BD pour bootstrap status") - return - - from crud.bootstrap_status import BootstrapStatusRepository - repo = BootstrapStatusRepository(session) - await repo.create( - host_id=host_id, - status="success" if success else "failed", - last_attempt=datetime.now(timezone.utc), - error_message=None if success else details, - ) - await session.commit() - except Exception as e: - print(f"Erreur sauvegarde bootstrap status en BD: {e}") - - def get_bootstrap_status(self, host_name: str) -> Dict: - """Récupère le statut de bootstrap d'un hôte depuis le cache""" - return self._cache.get(host_name, { - "bootstrap_ok": False, - "bootstrap_date": None, - "details": None - }) - - def get_all_status(self) -> Dict[str, Dict]: - """Récupère le statut de tous les hôtes depuis le cache""" - return self._cache.copy() - - def remove_host(self, host_name: str) -> bool: - """Supprime le statut d'un hôte du cache""" - if host_name in self._cache: - del self._cache[host_name] - return True - return False - - async def load_from_db(self): - """Charge tous les statuts depuis la BD dans le cache (appelé au démarrage)""" - try: - async with async_session_maker() as session: - from crud.bootstrap_status import BootstrapStatusRepository - from crud.host import HostRepository - from sqlalchemy import select - from models.bootstrap_status import BootstrapStatus - from models.host import Host - - # Récupérer tous les derniers statuts avec les noms d'hôtes - stmt = ( - select(BootstrapStatus, Host.name) - .join(Host, BootstrapStatus.host_id == Host.id) - .order_by(BootstrapStatus.created_at.desc()) - ) - result = await session.execute(stmt) - - # Garder seulement le dernier statut par hôte - seen_hosts = set() - for bs, host_name in result: - if host_name not in seen_hosts: - self._cache[host_name] = { - "bootstrap_ok": bs.status == "success", - "bootstrap_date": bs.last_attempt.isoformat() if bs.last_attempt else bs.created_at.isoformat(), - "details": bs.error_message - } - seen_hosts.add(host_name) - - print(f"📋 {len(self._cache)} statut(s) bootstrap chargé(s) depuis la BD") - except Exception as e: - print(f"Erreur chargement bootstrap status depuis BD: {e}") - - -# ===== SERVICE HOST STATUS ===== - -class HostStatusService: - """Service simple pour stocker le statut runtime des hôtes en mémoire. - - Cette implémentation ne persiste plus dans un fichier JSON ; les données - sont conservées uniquement pendant la vie du processus. - """ - - def __init__(self): - # Dictionnaire: host_name -> {"status": str, "last_seen": Optional[datetime|str], "os": Optional[str]} - self._hosts: Dict[str, Dict[str, Any]] = {} - - def set_status(self, host_name: str, status: str, last_seen: Optional[datetime], os_info: Optional[str]) -> Dict: - """Met à jour le statut d'un hôte en mémoire.""" - entry = { - "status": status, - "last_seen": last_seen if isinstance(last_seen, datetime) else last_seen, - "os": os_info, - } - self._hosts[host_name] = entry - return entry - - def get_status(self, host_name: str) -> Dict: - """Récupère le statut d'un hôte, avec valeurs par défaut si absent.""" - return self._hosts.get(host_name, {"status": "online", "last_seen": None, "os": None}) - - def get_all_status(self) -> Dict[str, Dict]: - """Retourne une copie de tous les statuts connus.""" - return dict(self._hosts) - - def remove_host(self, host_name: str) -> bool: - """Supprime le statut d'un hôte de la mémoire.""" - if host_name in self._hosts: - del self._hosts[host_name] - return True - return False - - -# ===== SERVICE PLANIFICATEUR (SCHEDULER) - VERSION BD ===== - -# Import du modèle SQLAlchemy Schedule (distinct du Pydantic Schedule) -from models.schedule import Schedule as ScheduleModel -from models.schedule_run import ScheduleRun as ScheduleRunModel - - -class SchedulerService: - """Service pour gérer les schedules de playbooks avec APScheduler. - - Cette version utilise uniquement la base de données SQLite (via SQLAlchemy async) - pour stocker les cédules et leur historique d'exécution. - """ - - def __init__(self): - # Configurer APScheduler - jobstores = {'default': MemoryJobStore()} - executors = {'default': AsyncIOExecutor()} - job_defaults = {'coalesce': True, 'max_instances': 1, 'misfire_grace_time': 300} - - self.scheduler = AsyncIOScheduler( - jobstores=jobstores, - executors=executors, - job_defaults=job_defaults, - timezone=pytz.UTC - ) - self._started = False - # Cache en mémoire des schedules (Pydantic) pour éviter les requêtes BD répétées - self._schedules_cache: Dict[str, Schedule] = {} - - async def start_async(self): - """Démarre le scheduler et charge tous les schedules actifs depuis la BD""" - if not self._started: - self.scheduler.start() - self._started = True - # Charger les schedules actifs depuis la BD - await self._load_active_schedules_from_db() - print("📅 Scheduler démarré avec succès (BD)") - - def start(self): - """Démarre le scheduler (version synchrone pour compatibilité)""" - if not self._started: - self.scheduler.start() - self._started = True - print("📅 Scheduler démarré (chargement BD différé)") - - def shutdown(self): - """Arrête le scheduler proprement""" - if self._started: - self.scheduler.shutdown(wait=False) - self._started = False - - async def _load_active_schedules_from_db(self): - """Charge tous les schedules actifs depuis la BD dans APScheduler""" - try: - async with async_session_maker() as session: - repo = ScheduleRepository(session) - db_schedules = await repo.list(limit=1000) - - for db_sched in db_schedules: - if db_sched.enabled: - try: - # Convertir le modèle SQLAlchemy en Pydantic Schedule - pydantic_sched = self._db_to_pydantic(db_sched) - self._schedules_cache[pydantic_sched.id] = pydantic_sched - self._add_job_for_schedule(pydantic_sched) - except Exception as e: - print(f"Erreur chargement schedule {db_sched.id}: {e}") - - print(f"📅 {len(self._schedules_cache)} schedule(s) chargé(s) depuis la BD") - except Exception as e: - print(f"Erreur chargement schedules depuis BD: {e}") - - def _db_to_pydantic(self, db_sched: ScheduleModel) -> Schedule: - """Convertit un modèle SQLAlchemy Schedule en Pydantic Schedule""" - # Reconstruire l'objet recurrence depuis les colonnes BD - recurrence = None - if db_sched.recurrence_type: - recurrence = ScheduleRecurrence( - type=db_sched.recurrence_type, - time=db_sched.recurrence_time or "02:00", - days=json.loads(db_sched.recurrence_days) if db_sched.recurrence_days else None, - cron_expression=db_sched.cron_expression, - ) - - return Schedule( - id=db_sched.id, - name=db_sched.name, - description=db_sched.description, - playbook=db_sched.playbook, - target_type=db_sched.target_type or "group", - target=db_sched.target, - extra_vars=db_sched.extra_vars, - schedule_type=db_sched.schedule_type, - recurrence=recurrence, - timezone=db_sched.timezone or "America/Montreal", - start_at=db_sched.start_at, - end_at=db_sched.end_at, - next_run_at=db_sched.next_run, - last_run_at=db_sched.last_run, - last_status=db_sched.last_status or "never", - enabled=db_sched.enabled, - retry_on_failure=db_sched.retry_on_failure or 0, - timeout=db_sched.timeout or 3600, - notification_type=db_sched.notification_type or "all", - tags=json.loads(db_sched.tags) if db_sched.tags else [], - run_count=db_sched.run_count or 0, - success_count=db_sched.success_count or 0, - failure_count=db_sched.failure_count or 0, - created_at=db_sched.created_at, - updated_at=db_sched.updated_at, - ) - - def _build_cron_trigger(self, schedule: Schedule) -> Optional[CronTrigger]: - """Construit un trigger cron à partir de la configuration du schedule""" - if schedule.schedule_type == "once": - return None - - recurrence = schedule.recurrence - if not recurrence: - return None - - tz = pytz.timezone(schedule.timezone) - hour, minute = recurrence.time.split(':') if recurrence.time else ("2", "0") - - try: - if recurrence.type == "daily": - return CronTrigger(hour=int(hour), minute=int(minute), timezone=tz) - - elif recurrence.type == "weekly": - # Convertir jours (1-7 lundi=1) en format cron (0-6 lundi=0) - days = recurrence.days or [1] - day_of_week = ','.join(str(d - 1) for d in days) - return CronTrigger(day_of_week=day_of_week, hour=int(hour), minute=int(minute), timezone=tz) - - elif recurrence.type == "monthly": - day = recurrence.day_of_month or 1 - return CronTrigger(day=day, hour=int(hour), minute=int(minute), timezone=tz) - - elif recurrence.type == "custom" and recurrence.cron_expression: - # Parser l'expression cron - parts = recurrence.cron_expression.split() - if len(parts) == 5: - return CronTrigger.from_crontab(recurrence.cron_expression, timezone=tz) - else: - # Expression cron étendue (6 champs avec secondes) - return CronTrigger( - second=parts[0] if len(parts) > 5 else '0', - minute=parts[0] if len(parts) == 5 else parts[1], - hour=parts[1] if len(parts) == 5 else parts[2], - day=parts[2] if len(parts) == 5 else parts[3], - month=parts[3] if len(parts) == 5 else parts[4], - day_of_week=parts[4] if len(parts) == 5 else parts[5], - timezone=tz - ) - except Exception as e: - print(f"Erreur construction trigger cron: {e}") - return None - - return None - - def _add_job_for_schedule(self, schedule: Schedule): - """Ajoute un job APScheduler pour un schedule""" - job_id = f"schedule_{schedule.id}" - - # Supprimer le job existant s'il existe - try: - self.scheduler.remove_job(job_id) - except: - pass - - if schedule.schedule_type == "once": - # Exécution unique - if schedule.start_at and schedule.start_at > datetime.now(timezone.utc): - trigger = DateTrigger(run_date=schedule.start_at, timezone=pytz.UTC) - self.scheduler.add_job( - self._execute_schedule, - trigger, - id=job_id, - args=[schedule.id], - replace_existing=True - ) - else: - # Exécution récurrente - trigger = self._build_cron_trigger(schedule) - if trigger: - self.scheduler.add_job( - self._execute_schedule, - trigger, - id=job_id, - args=[schedule.id], - replace_existing=True - ) - - # Calculer et mettre à jour next_run_at - self._update_next_run(schedule.id) - - def _update_next_run(self, schedule_id: str): - """Met à jour le champ next_run dans le cache et planifie la mise à jour BD""" - job_id = f"schedule_{schedule_id}" - try: - job = self.scheduler.get_job(job_id) - if job and job.next_run_time: - # Mettre à jour le cache - if schedule_id in self._schedules_cache: - self._schedules_cache[schedule_id].next_run_at = job.next_run_time - # Mettre à jour la BD de manière asynchrone - asyncio.create_task(self._update_next_run_in_db(schedule_id, job.next_run_time)) - except: - pass - - async def _update_next_run_in_db(self, schedule_id: str, next_run: datetime): - """Met à jour next_run dans la BD""" - try: - async with async_session_maker() as session: - repo = ScheduleRepository(session) - db_sched = await repo.get(schedule_id) - if db_sched: - await repo.update(db_sched, next_run=next_run) - await session.commit() - except Exception as e: - print(f"Erreur mise à jour next_run BD: {e}") - - async def _update_schedule_in_db(self, schedule: Schedule): - """Met à jour un schedule dans la BD""" - try: - async with async_session_maker() as session: - repo = ScheduleRepository(session) - db_sched = await repo.get(schedule.id) - if db_sched: - await repo.update( - db_sched, - enabled=schedule.enabled, - last_run=schedule.last_run_at, - last_status=schedule.last_status, - run_count=schedule.run_count, - success_count=schedule.success_count, - failure_count=schedule.failure_count, - ) - await session.commit() - except Exception as e: - print(f"Erreur mise à jour schedule BD: {e}") - - async def _execute_schedule(self, schedule_id: str): - """Exécute un schedule (appelé par APScheduler)""" - # Import circulaire évité en utilisant les variables globales - global ws_manager, ansible_service, db, task_log_service - - # Récupérer le schedule depuis le cache ou la BD - schedule = self._schedules_cache.get(schedule_id) - if not schedule: - # Charger depuis la BD - try: - async with async_session_maker() as session: - repo = ScheduleRepository(session) - db_sched = await repo.get(schedule_id) - if db_sched: - schedule = self._db_to_pydantic(db_sched) - self._schedules_cache[schedule_id] = schedule - except Exception as e: - print(f"Erreur chargement schedule {schedule_id}: {e}") - - if not schedule: - print(f"Schedule {schedule_id} non trouvé") - return - - # Vérifier si le schedule est encore actif - if not schedule.enabled: - return - - # Vérifier la fenêtre temporelle - now = datetime.now(timezone.utc) - if schedule.end_at and now > schedule.end_at: - # Schedule expiré, le désactiver - schedule.enabled = False - self._schedules_cache[schedule_id] = schedule - await self._update_schedule_in_db(schedule) - return - - # Créer un ScheduleRun Pydantic pour les notifications - run = ScheduleRun(schedule_id=schedule_id) - - # Mettre à jour le schedule - schedule.last_run_at = now - schedule.last_status = "running" - schedule.run_count += 1 - self._schedules_cache[schedule_id] = schedule - - # Notifier via WebSocket - try: - await ws_manager.broadcast({ - "type": "schedule_run_started", - "data": { - "schedule_id": schedule_id, - "schedule_name": schedule.name, - "run": run.dict(), - "status": "running" - } - }) - except: - pass - - # Créer une tâche - task_id = str(db.get_next_id("tasks")) - playbook_name = schedule.playbook.replace('.yml', '').replace('-', ' ').title() - task = Task( - id=task_id, - name=f"[Planifié] {playbook_name}", - host=schedule.target, - status="running", - progress=0, - start_time=now - ) - db.tasks.insert(0, task) - - # Mettre à jour le run avec le task_id - run.task_id = task_id - - # Notifier la création de tâche - try: - await ws_manager.broadcast({ - "type": "task_created", - "data": task.dict() - }) - except: - pass - - # Exécuter le playbook - start_time = perf_counter() - try: - result = await ansible_service.execute_playbook( - playbook=schedule.playbook, - target=schedule.target, - extra_vars=schedule.extra_vars, - check_mode=False, - verbose=True - ) - - execution_time = perf_counter() - start_time - success = result.get("success", False) - - # Mettre à jour la tâche - task.status = "completed" if success else "failed" - task.progress = 100 - task.end_time = datetime.now(timezone.utc) - task.duration = f"{execution_time:.1f}s" - task.output = result.get("stdout", "") - task.error = result.get("stderr", "") if not success else None - - # Mettre à jour le run - run.status = "success" if success else "failed" - run.finished_at = datetime.now(timezone.utc) - run.duration_seconds = execution_time - run.error_message = result.get("stderr", "") if not success else None - - # Compter les hôtes impactés - stdout = result.get("stdout", "") - host_count = len(re.findall(r'^[a-zA-Z0-9][a-zA-Z0-9._-]+\s*:\s*ok=', stdout, re.MULTILINE)) - run.hosts_impacted = host_count - - # Mettre à jour le schedule - schedule.last_status = "success" if success else "failed" - if success: - schedule.success_count += 1 - else: - schedule.failure_count += 1 - - # Sauvegarder le schedule dans le cache et la BD - self._schedules_cache[schedule_id] = schedule - await self._update_schedule_in_db(schedule) - - # Sauvegarder le log markdown (tâche planifiée) - try: - task_log_service.save_task_log( - task=task, - output=result.get("stdout", ""), - error=result.get("stderr", ""), - source_type='scheduled' - ) - except: - pass - - # Notifier - await ws_manager.broadcast({ - "type": "schedule_run_finished", - "data": { - "schedule_id": schedule_id, - "schedule_name": schedule.name, - "run": run.dict(), - "status": run.status, - "success": success - } - }) - - await ws_manager.broadcast({ - "type": "task_completed", - "data": { - "id": task_id, - "status": task.status, - "progress": 100, - "duration": task.duration, - "success": success - } - }) - - # Log - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="INFO" if success else "ERROR", - message=f"Schedule '{schedule.name}' exécuté: {'succès' if success else 'échec'}", - source="scheduler", - host=schedule.target - ) - db.logs.insert(0, log_entry) - - # Notification NTFY selon le type configuré - await self._send_schedule_notification(schedule, success, run.error_message) - - # Enregistrer l'exécution dans la base de données (schedule_runs) - try: - async with async_session_maker() as db_session: - run_repo = ScheduleRunRepository(db_session) - await run_repo.create( - schedule_id=schedule_id, - task_id=task_id, - status=run.status, - started_at=run.started_at, - completed_at=run.finished_at, - duration=run.duration_seconds, - error_message=run.error_message, - output=result.get("stdout", "") if success else result.get("stderr", ""), - ) - await db_session.commit() - except Exception: - # Ne jamais casser l'exécution du scheduler à cause de la persistance BD - pass - - except Exception as e: - # Échec de l'exécution - execution_time = perf_counter() - start_time - - task.status = "failed" - task.end_time = datetime.now(timezone.utc) - task.error = str(e) - - run.status = "failed" - run.finished_at = datetime.now(timezone.utc) - run.duration_seconds = execution_time - run.error_message = str(e) - - schedule.last_status = "failed" - schedule.failure_count += 1 - - # Sauvegarder le schedule dans le cache et la BD - self._schedules_cache[schedule_id] = schedule - await self._update_schedule_in_db(schedule) - - # Enregistrer l'échec dans la BD (schedule_runs) - try: - async with async_session_maker() as db_session: - run_repo = ScheduleRunRepository(db_session) - await run_repo.create( - schedule_id=schedule_id, - task_id=task_id, - status=run.status, - started_at=run.started_at, - completed_at=run.finished_at, - duration=run.duration_seconds, - error_message=run.error_message, - output=str(e), - ) - await db_session.commit() - except Exception: - pass - - try: - task_log_service.save_task_log(task=task, error=str(e), source_type='scheduled') - except: - pass - - try: - await ws_manager.broadcast({ - "type": "schedule_run_finished", - "data": { - "schedule_id": schedule_id, - "run": run.dict(), - "status": "failed", - "error": str(e) - } - }) - - await ws_manager.broadcast({ - "type": "task_failed", - "data": {"id": task_id, "status": "failed", "error": str(e)} - }) - except: - pass - - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="ERROR", - message=f"Erreur schedule '{schedule.name}': {str(e)}", - source="scheduler", - host=schedule.target - ) - db.logs.insert(0, log_entry) - - # Notification NTFY pour l'échec - await self._send_schedule_notification(schedule, False, str(e)) - - # Mettre à jour next_run_at - self._update_next_run(schedule_id) - - async def _send_schedule_notification(self, schedule: Schedule, success: bool, error_message: Optional[str] = None): - """Envoie une notification NTFY selon le type configuré pour le schedule. - - Args: - schedule: Le schedule exécuté - success: True si l'exécution a réussi - error_message: Message d'erreur en cas d'échec - """ - # Vérifier le type de notification configuré - notification_type = getattr(schedule, 'notification_type', 'all') - - # Ne pas notifier si "none" - if notification_type == "none": - return - - # Ne notifier que les erreurs si "errors" - if notification_type == "errors" and success: - return - - # Envoyer la notification - try: - if success: - await notification_service.notify_schedule_executed( - schedule_name=schedule.name, - success=True, - details=f"Cible: {schedule.target}" - ) - else: - await notification_service.notify_schedule_executed( - schedule_name=schedule.name, - success=False, - details=error_message or "Erreur inconnue" - ) - except Exception as notif_error: - print(f"Erreur envoi notification schedule: {notif_error}") - - # ===== MÉTHODES PUBLIQUES CRUD (VERSION BD) ===== - - def get_all_schedules(self, - enabled: Optional[bool] = None, - playbook: Optional[str] = None, - tag: Optional[str] = None) -> List[Schedule]: - """Récupère tous les schedules depuis le cache avec filtrage optionnel""" - schedules = list(self._schedules_cache.values()) - - # Filtres - if enabled is not None: - schedules = [s for s in schedules if s.enabled == enabled] - if playbook: - schedules = [s for s in schedules if playbook.lower() in s.playbook.lower()] - if tag: - schedules = [s for s in schedules if tag in s.tags] - - # Trier par prochaine exécution - schedules.sort(key=lambda x: x.next_run_at or datetime.max.replace(tzinfo=timezone.utc)) - return schedules - - def get_schedule(self, schedule_id: str) -> Optional[Schedule]: - """Récupère un schedule par ID depuis le cache""" - return self._schedules_cache.get(schedule_id) - - def create_schedule(self, request: ScheduleCreateRequest) -> Schedule: - """Crée un nouveau schedule (sauvegarde en BD via l'endpoint)""" - schedule = Schedule( - name=request.name, - description=request.description, - playbook=request.playbook, - target_type=request.target_type, - target=request.target, - extra_vars=request.extra_vars, - schedule_type=request.schedule_type, - recurrence=request.recurrence, - timezone=request.timezone, - start_at=request.start_at, - end_at=request.end_at, - enabled=request.enabled, - retry_on_failure=request.retry_on_failure, - timeout=request.timeout, - notification_type=request.notification_type, - tags=request.tags - ) - - # Ajouter au cache - self._schedules_cache[schedule.id] = schedule - - # Ajouter le job si actif - if schedule.enabled and self._started: - self._add_job_for_schedule(schedule) - - return schedule - - def update_schedule(self, schedule_id: str, request: ScheduleUpdateRequest) -> Optional[Schedule]: - """Met à jour un schedule existant""" - schedule = self.get_schedule(schedule_id) - if not schedule: - return None - - # Appliquer les modifications - update_data = request.dict(exclude_unset=True, exclude_none=True) - for key, value in update_data.items(): - # La récurrence arrive du frontend comme un dict, il faut la retransformer - # en objet ScheduleRecurrence pour que _build_cron_trigger fonctionne. - if key == "recurrence" and isinstance(value, dict): - try: - value = ScheduleRecurrence(**value) - except Exception: - pass - - if hasattr(schedule, key): - setattr(schedule, key, value) - - schedule.updated_at = datetime.now(timezone.utc) - - # Mettre à jour le cache - self._schedules_cache[schedule_id] = schedule - - # Mettre à jour le job - if self._started: - job_id = f"schedule_{schedule_id}" - try: - self.scheduler.remove_job(job_id) - except: - pass - - if schedule.enabled: - self._add_job_for_schedule(schedule) - - return schedule - - def delete_schedule(self, schedule_id: str) -> bool: - """Supprime un schedule du cache et du scheduler""" - if schedule_id in self._schedules_cache: - del self._schedules_cache[schedule_id] - - # Supprimer le job - job_id = f"schedule_{schedule_id}" - try: - self.scheduler.remove_job(job_id) - except: - pass - - return True - - def pause_schedule(self, schedule_id: str) -> Optional[Schedule]: - """Met en pause un schedule""" - schedule = self.get_schedule(schedule_id) - if not schedule: - return None - - schedule.enabled = False - self._schedules_cache[schedule_id] = schedule - - # Supprimer le job - job_id = f"schedule_{schedule_id}" - try: - self.scheduler.remove_job(job_id) - except: - pass - - return schedule - - def resume_schedule(self, schedule_id: str) -> Optional[Schedule]: - """Reprend un schedule en pause""" - schedule = self.get_schedule(schedule_id) - if not schedule: - return None - - schedule.enabled = True - self._schedules_cache[schedule_id] = schedule - - # Ajouter le job - if self._started: - self._add_job_for_schedule(schedule) - - return schedule - - async def run_now(self, schedule_id: str) -> Optional[ScheduleRun]: - """Exécute immédiatement un schedule""" - schedule = self.get_schedule(schedule_id) - if not schedule: - return None - - # Exécuter de manière asynchrone - await self._execute_schedule(schedule_id) - - # Retourner un ScheduleRun vide (le vrai est en BD) - return ScheduleRun(schedule_id=schedule_id, status="running") - - def get_stats(self) -> ScheduleStats: - """Calcule les statistiques globales des schedules depuis le cache""" - schedules = self.get_all_schedules() - - now = datetime.now(timezone.utc) - - stats = ScheduleStats() - stats.total = len(schedules) - stats.active = len([s for s in schedules if s.enabled]) - stats.paused = len([s for s in schedules if not s.enabled]) - - # Schedules expirés - stats.expired = len([s for s in schedules if s.end_at and s.end_at < now]) - - # Prochaine exécution - active_schedules = [s for s in schedules if s.enabled and s.next_run_at] - if active_schedules: - next_schedule = min(active_schedules, key=lambda x: x.next_run_at) - stats.next_execution = next_schedule.next_run_at - stats.next_schedule_name = next_schedule.name - - # Stats basées sur les compteurs des schedules (pas besoin de lire les runs) - total_runs = sum(s.run_count for s in schedules) - total_success = sum(s.success_count for s in schedules) - total_failures = sum(s.failure_count for s in schedules) - - # Approximation des stats 24h basée sur les compteurs - stats.executions_24h = total_runs # Approximation - stats.failures_24h = total_failures # Approximation - - if total_runs > 0: - stats.success_rate_7d = round((total_success / total_runs) * 100, 1) - - return stats - - def get_upcoming_executions(self, limit: int = 5) -> List[Dict]: - """Retourne les prochaines exécutions planifiées""" - schedules = self.get_all_schedules(enabled=True) - upcoming = [] - - for s in schedules: - if s.next_run_at: - upcoming.append({ - "schedule_id": s.id, - "schedule_name": s.name, - "playbook": s.playbook, - "target": s.target, - "next_run_at": s.next_run_at.isoformat() if s.next_run_at else None, - "tags": s.tags - }) - - upcoming.sort(key=lambda x: x['next_run_at'] or '') - return upcoming[:limit] - - def validate_cron_expression(self, expression: str) -> Dict: - """Valide une expression cron et retourne les prochaines exécutions""" - try: - cron = croniter(expression, datetime.now()) - next_runs = [cron.get_next(datetime).isoformat() for _ in range(5)] - return { - "valid": True, - "next_runs": next_runs, - "expression": expression - } - except Exception as e: - return { - "valid": False, - "error": str(e), - "expression": expression - } - - def add_schedule_to_cache(self, schedule: Schedule): - """Ajoute un schedule au cache (appelé après création en BD)""" - self._schedules_cache[schedule.id] = schedule - if schedule.enabled and self._started: - self._add_job_for_schedule(schedule) - - def remove_schedule_from_cache(self, schedule_id: str): - """Supprime un schedule du cache""" - if schedule_id in self._schedules_cache: - del self._schedules_cache[schedule_id] - - -# Instances globales des services -task_log_service = TaskLogService(DIR_LOGS_TASKS) -adhoc_history_service = AdHocHistoryService() # Stockage en BD via la table logs -bootstrap_status_service = BootstrapStatusService() # Plus de fichier JSON, utilise la BD -host_status_service = HostStatusService() # Ne persiste plus dans .host_status.json -scheduler_service = SchedulerService() # Plus de fichiers JSON - - -class WebSocketManager: - def __init__(self): - self.active_connections: List[WebSocket] = [] - self.lock = Lock() - - async def connect(self, websocket: WebSocket): - await websocket.accept() - with self.lock: - self.active_connections.append(websocket) - - def disconnect(self, websocket: WebSocket): - with self.lock: - if websocket in self.active_connections: - self.active_connections.remove(websocket) - - async def broadcast(self, message: dict): - with self.lock: - disconnected = [] - for connection in self.active_connections: - try: - await connection.send_json(message) - except: - disconnected.append(connection) - - # Nettoyer les connexions déconnectées - for conn in disconnected: - self.active_connections.remove(conn) - -# Instance globale du gestionnaire WebSocket -ws_manager = WebSocketManager() - -# Dictionnaire pour stocker les tâches asyncio et processus en cours (pour annulation) -# Format: {task_id: {"asyncio_task": Task, "process": Process, "cancelled": bool}} -running_task_handles: Dict[str, Dict] = {} - - -# Service Ansible -class AnsibleService: - """Service pour exécuter les playbooks Ansible""" - - def __init__(self, ansible_dir: Path): - self.ansible_dir = ansible_dir - self.playbooks_dir = ansible_dir / "playbooks" - self.inventory_path = ansible_dir / "inventory" / "hosts.yml" - self._inventory_cache: Optional[Dict] = None - - def get_playbooks(self) -> List[Dict[str, Any]]: - """Liste les playbooks disponibles avec leurs métadonnées (category/subcategory/hosts). - - Les métadonnées sont lues en priorité dans play['vars'] pour être compatibles - avec la syntaxe Ansible (category/subcategory ne sont pas des clés de Play). - Le champ 'hosts' est extrait pour permettre le filtrage par compatibilité. - """ - playbooks = [] - if self.playbooks_dir.exists(): - for pb in self.playbooks_dir.glob("*.yml"): - # Récupérer les infos du fichier - stat = pb.stat() - playbook_info = { - "name": pb.stem, - "filename": pb.name, - "path": str(pb), - "category": "general", - "subcategory": "other", - "hosts": "all", # Valeur par défaut - "size": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() - } - # Extract category/subcategory/hosts from playbook - try: - with open(pb, 'r', encoding='utf-8') as f: - content = yaml.safe_load(f) - if content and isinstance(content, list) and len(content) > 0: - play = content[0] - vars_ = play.get('vars', {}) or {} - - # Lecture de category avec fallback: play puis vars - if 'category' in play: - playbook_info['category'] = play['category'] - elif 'category' in vars_: - playbook_info['category'] = vars_['category'] - - # Lecture de subcategory avec fallback - if 'subcategory' in play: - playbook_info['subcategory'] = play['subcategory'] - elif 'subcategory' in vars_: - playbook_info['subcategory'] = vars_['subcategory'] - - # Lecture du champ 'hosts' (cible du playbook) - if 'hosts' in play: - playbook_info['hosts'] = play['hosts'] - - if 'name' in play: - playbook_info['description'] = play['name'] - except Exception: - # On ignore les erreurs de parsing individuelles pour ne pas - # casser l'ensemble de la liste de playbooks. - pass - playbooks.append(playbook_info) - return playbooks - - def get_playbook_categories(self) -> Dict[str, List[str]]: - """Retourne les catégories et sous-catégories des playbooks""" - categories = {} - for pb in self.get_playbooks(): - cat = pb.get('category', 'general') - subcat = pb.get('subcategory', 'other') - if cat not in categories: - categories[cat] = [] - if subcat not in categories[cat]: - categories[cat].append(subcat) - return categories - - def is_target_compatible_with_playbook(self, target: str, playbook_hosts: str) -> bool: - """Vérifie si une cible (host ou groupe) est compatible avec le champ 'hosts' d'un playbook. - - Args: - target: Nom de l'hôte ou du groupe cible - playbook_hosts: Valeur du champ 'hosts' du playbook - - Returns: - True si la cible est compatible avec le playbook - - Exemples: - - playbook_hosts='all' → compatible avec tout - - playbook_hosts='role_proxmox' → compatible avec le groupe role_proxmox et ses hôtes - - playbook_hosts='server.home' → compatible uniquement avec cet hôte - """ - # 'all' accepte tout - if playbook_hosts == 'all': - return True - - # Si la cible correspond exactement au champ hosts - if target == playbook_hosts: - return True - - # Charger l'inventaire pour vérifier les appartenances - inventory = self.load_inventory() - - # Si playbook_hosts est un groupe, vérifier si target est un hôte de ce groupe - if self.group_exists(playbook_hosts): - hosts_in_group = self.get_group_hosts(playbook_hosts) - if target in hosts_in_group: - return True - # Vérifier aussi si target est un sous-groupe du groupe playbook_hosts - if target in self.get_groups(): - # Vérifier si tous les hôtes du groupe target sont dans playbook_hosts - target_hosts = set(self.get_group_hosts(target)) - playbook_group_hosts = set(hosts_in_group) - if target_hosts and target_hosts.issubset(playbook_group_hosts): - return True - - # Si playbook_hosts est un hôte et target est un groupe contenant cet hôte - if target in self.get_groups(): - hosts_in_target = self.get_group_hosts(target) - if playbook_hosts in hosts_in_target: - return True - - return False - - def get_compatible_playbooks(self, target: str) -> List[Dict[str, Any]]: - """Retourne la liste des playbooks compatibles avec une cible donnée. - - Args: - target: Nom de l'hôte ou du groupe - - Returns: - Liste des playbooks compatibles avec leurs métadonnées - """ - all_playbooks = self.get_playbooks() - compatible = [] - - for pb in all_playbooks: - playbook_hosts = pb.get('hosts', 'all') - if self.is_target_compatible_with_playbook(target, playbook_hosts): - compatible.append(pb) - - return compatible - - def load_inventory(self) -> Dict: - """Charge l'inventaire Ansible depuis le fichier YAML""" - if self._inventory_cache: - return self._inventory_cache - - if not self.inventory_path.exists(): - return {} - - with open(self.inventory_path, 'r') as f: - self._inventory_cache = yaml.safe_load(f) - return self._inventory_cache - - def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]: - """Extrait la liste des hôtes de l'inventaire sans doublons. - - Args: - group_filter: Si spécifié, filtre les hôtes par ce groupe - """ - inventory = self.load_inventory() - # Use dict to track unique hosts and accumulate their groups - hosts_dict: Dict[str, AnsibleInventoryHost] = {} - - def extract_hosts(data: Dict, current_group: str = ""): - if not isinstance(data, dict): - return - - # Extraire les hôtes directs - if 'hosts' in data: - for host_name, host_data in data['hosts'].items(): - host_data = host_data or {} - - if host_name in hosts_dict: - # Host already exists, add group to its groups list - if current_group and current_group not in hosts_dict[host_name].groups: - hosts_dict[host_name].groups.append(current_group) - else: - # New host - hosts_dict[host_name] = AnsibleInventoryHost( - name=host_name, - ansible_host=host_data.get('ansible_host', host_name), - group=current_group, - groups=[current_group] if current_group else [], - vars=host_data - ) - - # Parcourir les enfants (sous-groupes) - if 'children' in data: - for child_name, child_data in data['children'].items(): - extract_hosts(child_data, child_name) - - extract_hosts(inventory.get('all', {})) - - # Convert to list - hosts = list(hosts_dict.values()) - - # Apply group filter if specified - if group_filter and group_filter != 'all': - hosts = [h for h in hosts if group_filter in h.groups] - - return hosts - - def invalidate_cache(self): - """Invalide le cache de l'inventaire pour forcer un rechargement""" - self._inventory_cache = None - - def get_groups(self) -> List[str]: - """Extrait la liste des groupes de l'inventaire""" - inventory = self.load_inventory() - groups = set() - - def extract_groups(data: Dict, parent: str = ""): - if not isinstance(data, dict): - return - if 'children' in data: - for child_name in data['children'].keys(): - groups.add(child_name) - extract_groups(data['children'][child_name], child_name) - - extract_groups(inventory.get('all', {})) - return sorted(list(groups)) - - def get_env_groups(self) -> List[str]: - """Retourne uniquement les groupes d'environnement (préfixés par env_)""" - return [g for g in self.get_groups() if g.startswith('env_')] - - def get_role_groups(self) -> List[str]: - """Retourne uniquement les groupes de rôles (préfixés par role_)""" - return [g for g in self.get_groups() if g.startswith('role_')] - - def _save_inventory(self, inventory: Dict): - """Sauvegarde l'inventaire dans le fichier YAML""" - # Créer une sauvegarde avant modification - backup_path = self.inventory_path.with_suffix('.yml.bak') - if self.inventory_path.exists(): - import shutil - shutil.copy2(self.inventory_path, backup_path) - - with open(self.inventory_path, 'w', encoding='utf-8') as f: - yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True, sort_keys=False) - - # Invalider le cache - self.invalidate_cache() - - def add_host_to_inventory(self, hostname: str, env_group: str, role_groups: List[str], ansible_host: str = None) -> bool: - """Ajoute un hôte à l'inventaire Ansible - - Args: - hostname: Nom de l'hôte (ex: server.domain.home) - env_group: Groupe d'environnement (ex: env_homelab) - role_groups: Liste des groupes de rôles (ex: ['role_proxmox', 'role_sbc']) - ansible_host: Adresse IP ou hostname pour ansible_host (optionnel) - - Returns: - True si l'ajout a réussi - """ - inventory = self.load_inventory() - - # S'assurer que la structure existe - if 'all' not in inventory: - inventory['all'] = {} - if 'children' not in inventory['all']: - inventory['all']['children'] = {} - - children = inventory['all']['children'] - - # Ajouter au groupe d'environnement - if env_group not in children: - children[env_group] = {'hosts': {}} - if 'hosts' not in children[env_group]: - children[env_group]['hosts'] = {} - - # Définir les variables de l'hôte - host_vars = None - if ansible_host and ansible_host != hostname: - host_vars = {'ansible_host': ansible_host} - - children[env_group]['hosts'][hostname] = host_vars - - # Ajouter aux groupes de rôles - for role_group in role_groups: - if role_group not in children: - children[role_group] = {'hosts': {}} - if 'hosts' not in children[role_group]: - children[role_group]['hosts'] = {} - children[role_group]['hosts'][hostname] = None - - self._save_inventory(inventory) - return True - - def remove_host_from_inventory(self, hostname: str) -> bool: - """Supprime un hôte de tous les groupes de l'inventaire - - Args: - hostname: Nom de l'hôte à supprimer - - Returns: - True si la suppression a réussi - """ - inventory = self.load_inventory() - - if 'all' not in inventory or 'children' not in inventory['all']: - return False - - children = inventory['all']['children'] - removed = False - - # Parcourir tous les groupes et supprimer l'hôte - for group_name, group_data in children.items(): - if isinstance(group_data, dict) and 'hosts' in group_data: - if hostname in group_data['hosts']: - del group_data['hosts'][hostname] - removed = True - - if removed: - self._save_inventory(inventory) - - # Supprimer aussi les statuts persistés (bootstrap + health) - bootstrap_status_service.remove_host(hostname) - try: - host_status_service.remove_host(hostname) - except Exception: - pass - - return removed - - def update_host_groups(self, hostname: str, env_group: str = None, role_groups: List[str] = None, ansible_host: str = None) -> bool: - """Met à jour les groupes d'un hôte existant - - Args: - hostname: Nom de l'hôte à modifier - env_group: Nouveau groupe d'environnement (None = pas de changement) - role_groups: Nouvelle liste de groupes de rôles (None = pas de changement) - ansible_host: Nouvelle adresse ansible_host (None = pas de changement) - - Returns: - True si la mise à jour a réussi - """ - inventory = self.load_inventory() - - if 'all' not in inventory or 'children' not in inventory['all']: - return False - - children = inventory['all']['children'] - - # Trouver le groupe d'environnement actuel - current_env_group = None - current_role_groups = [] - current_ansible_host = None - - for group_name, group_data in children.items(): - if isinstance(group_data, dict) and 'hosts' in group_data: - if hostname in group_data['hosts']: - if group_name.startswith('env_'): - current_env_group = group_name - # Récupérer ansible_host si défini - host_vars = group_data['hosts'][hostname] - if isinstance(host_vars, dict) and 'ansible_host' in host_vars: - current_ansible_host = host_vars['ansible_host'] - elif group_name.startswith('role_'): - current_role_groups.append(group_name) - - if not current_env_group: - return False # Hôte non trouvé - - # Appliquer les changements - new_env_group = env_group if env_group else current_env_group - new_role_groups = role_groups if role_groups is not None else current_role_groups - new_ansible_host = ansible_host if ansible_host else current_ansible_host - - # Supprimer l'hôte de tous les groupes actuels - for group_name, group_data in children.items(): - if isinstance(group_data, dict) and 'hosts' in group_data: - if hostname in group_data['hosts']: - del group_data['hosts'][hostname] - - # Ajouter au nouveau groupe d'environnement - if new_env_group not in children: - children[new_env_group] = {'hosts': {}} - if 'hosts' not in children[new_env_group]: - children[new_env_group]['hosts'] = {} - - host_vars = None - if new_ansible_host and new_ansible_host != hostname: - host_vars = {'ansible_host': new_ansible_host} - children[new_env_group]['hosts'][hostname] = host_vars - - # Ajouter aux nouveaux groupes de rôles - for role_group in new_role_groups: - if role_group not in children: - children[role_group] = {'hosts': {}} - if 'hosts' not in children[role_group]: - children[role_group]['hosts'] = {} - children[role_group]['hosts'][hostname] = None - - self._save_inventory(inventory) - return True - - def host_exists(self, hostname: str) -> bool: - """Vérifie si un hôte existe dans l'inventaire""" - hosts = self.get_hosts_from_inventory() - return any(h.name == hostname for h in hosts) - - def group_exists(self, group_name: str) -> bool: - """Vérifie si un groupe existe dans l'inventaire""" - return group_name in self.get_groups() - - def add_group(self, group_name: str) -> bool: - """Ajoute un nouveau groupe à l'inventaire - - Args: - group_name: Nom du groupe (doit commencer par env_ ou role_) - - Returns: - True si l'ajout a réussi - """ - if self.group_exists(group_name): - return False # Groupe existe déjà - - inventory = self.load_inventory() - - # S'assurer que la structure existe - if 'all' not in inventory: - inventory['all'] = {} - if 'children' not in inventory['all']: - inventory['all']['children'] = {} - - # Ajouter le groupe vide - inventory['all']['children'][group_name] = {'hosts': {}} - - self._save_inventory(inventory) - return True - - def rename_group(self, old_name: str, new_name: str) -> bool: - """Renomme un groupe dans l'inventaire - - Args: - old_name: Nom actuel du groupe - new_name: Nouveau nom du groupe - - Returns: - True si le renommage a réussi - """ - if not self.group_exists(old_name): - return False # Groupe source n'existe pas - - if self.group_exists(new_name): - return False # Groupe cible existe déjà - - inventory = self.load_inventory() - children = inventory.get('all', {}).get('children', {}) - - if old_name not in children: - return False - - # Copier les données du groupe vers le nouveau nom - children[new_name] = children[old_name] - del children[old_name] - - self._save_inventory(inventory) - return True - - def delete_group(self, group_name: str, move_hosts_to: str = None) -> Dict[str, Any]: - """Supprime un groupe de l'inventaire - - Args: - group_name: Nom du groupe à supprimer - move_hosts_to: Groupe vers lequel déplacer les hôtes (optionnel) - - Returns: - Dict avec le résultat de l'opération - """ - if not self.group_exists(group_name): - return {"success": False, "error": "Groupe non trouvé"} - - inventory = self.load_inventory() - children = inventory.get('all', {}).get('children', {}) - - if group_name not in children: - return {"success": False, "error": "Groupe non trouvé dans children"} - - group_data = children[group_name] - hosts_in_group = list(group_data.get('hosts', {}).keys()) if group_data else [] - - # Si des hôtes sont dans le groupe et qu'on veut les déplacer - if hosts_in_group and move_hosts_to: - if not self.group_exists(move_hosts_to) and move_hosts_to != group_name: - # Créer le groupe cible s'il n'existe pas - children[move_hosts_to] = {'hosts': {}} - - if move_hosts_to in children: - if 'hosts' not in children[move_hosts_to]: - children[move_hosts_to]['hosts'] = {} - - # Déplacer les hôtes - for hostname in hosts_in_group: - host_vars = group_data['hosts'].get(hostname) - children[move_hosts_to]['hosts'][hostname] = host_vars - - # Supprimer le groupe - del children[group_name] - - self._save_inventory(inventory) - return { - "success": True, - "hosts_affected": hosts_in_group, - "hosts_moved_to": move_hosts_to if hosts_in_group and move_hosts_to else None - } - - def get_group_hosts(self, group_name: str) -> List[str]: - """Retourne la liste des hôtes dans un groupe - - Args: - group_name: Nom du groupe - - Returns: - Liste des noms d'hôtes - """ - inventory = self.load_inventory() - children = inventory.get('all', {}).get('children', {}) - - if group_name not in children: - return [] - - group_data = children[group_name] - if not group_data or 'hosts' not in group_data: - return [] - - return list(group_data['hosts'].keys()) - - async def execute_playbook( - self, - playbook: str, - target: str = "all", - extra_vars: Optional[Dict[str, Any]] = None, - check_mode: bool = False, - verbose: bool = False - ) -> Dict[str, Any]: - """Exécute un playbook Ansible""" - # Résoudre le chemin du playbook - # On accepte soit un nom avec extension, soit un nom sans extension (ex: "health-check") - playbook_path = self.playbooks_dir / playbook - - # Si le fichier n'existe pas tel quel, essayer avec des extensions courantes - if not playbook_path.exists(): - from pathlib import Path - - pb_name = Path(playbook).name # enlever d'éventuels chemins - # Si aucune extension n'est fournie, tester .yml puis .yaml - if not Path(pb_name).suffix: - for ext in (".yml", ".yaml"): - candidate = self.playbooks_dir / f"{pb_name}{ext}" - if candidate.exists(): - playbook_path = candidate - break - - if not playbook_path.exists(): - # À ce stade, on n'a trouvé aucun fichier correspondant - raise FileNotFoundError(f"Playbook introuvable: {playbook}") - - # Construire la commande ansible-playbook - cmd = [ - "ansible-playbook", - str(playbook_path), - "-i", str(self.inventory_path), - "--limit", target - ] - - if check_mode: - cmd.append("--check") - - if verbose: - cmd.append("-v") - - if extra_vars: - cmd.extend(["--extra-vars", json.dumps(extra_vars)]) - - private_key = find_ssh_private_key() - if private_key: - cmd.extend(["--private-key", private_key]) - - if SSH_USER: - cmd.extend(["-u", SSH_USER]) - - start_time = perf_counter() - - try: - # Exécuter la commande - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=str(self.ansible_dir) - ) - - stdout, stderr = await process.communicate() - execution_time = perf_counter() - start_time - - return { - "success": process.returncode == 0, - "return_code": process.returncode, - "stdout": stdout.decode('utf-8', errors='replace'), - "stderr": stderr.decode('utf-8', errors='replace'), - "execution_time": round(execution_time, 2), - "command": " ".join(cmd) - } - except FileNotFoundError: - return { - "success": False, - "return_code": -1, - "stdout": "", - "stderr": "ansible-playbook non trouvé. Vérifiez que Ansible est installé.", - "execution_time": 0, - "command": " ".join(cmd) - } - except Exception as e: - return { - "success": False, - "return_code": -1, - "stdout": "", - "stderr": str(e), - "execution_time": perf_counter() - start_time, - "command": " ".join(cmd) - } - - -# Instance globale du service Ansible -ansible_service = AnsibleService(ANSIBLE_DIR) - - -# ===== SERVICE BOOTSTRAP SSH ===== - -class BootstrapRequest(BaseModel): - """Requête de bootstrap pour un hôte""" - host: str = Field(..., description="Adresse IP ou hostname de l'hôte") - root_password: str = Field(..., description="Mot de passe root pour la connexion initiale") - automation_user: str = Field(default="automation", description="Nom de l'utilisateur d'automatisation à créer") - - -class CommandResult(BaseModel): - """Résultat d'une commande SSH""" - status: str - return_code: int - stdout: str - stderr: Optional[str] = None - - -def find_ssh_private_key() -> Optional[str]: - """Trouve une clé privée SSH disponible en inspectant plusieurs répertoires.""" - candidate_dirs = [] - env_path = Path(SSH_KEY_PATH) - candidate_dirs.append(env_path.parent) - candidate_dirs.append(Path("/app/ssh_keys")) - candidate_dirs.append(Path.home() / ".ssh") - - seen = set() - key_paths: List[str] = [] - - for directory in candidate_dirs: - if not directory or not directory.exists(): - continue - for name in [ - env_path.name, - "id_automation_ansible", - "id_rsa", - "id_ed25519", - "id_ecdsa", - ]: - path = directory / name - if str(path) not in seen: - seen.add(str(path)) - key_paths.append(str(path)) - # Ajouter dynamiquement toutes les clés sans extension .pub - for file in directory.iterdir(): - if file.is_file() and not file.suffix and not file.name.startswith("known_hosts"): - if str(file) not in seen: - seen.add(str(file)) - key_paths.append(str(file)) - - for key_path in key_paths: - if key_path and Path(key_path).exists(): - return key_path - - return None - - -def run_ssh_command( - host: str, - command: str, - ssh_user: str = "root", - ssh_password: Optional[str] = None, - timeout: int = 60 -) -> tuple: - """Exécute une commande SSH sur un hôte distant. - - Returns: - tuple: (return_code, stdout, stderr) - """ - ssh_cmd = ["ssh"] - - # Options SSH communes - ssh_opts = [ - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "ConnectTimeout=10", - "-o", "BatchMode=no" if ssh_password else "BatchMode=yes", - ] - - # Si pas de mot de passe, utiliser la clé SSH - if not ssh_password: - private_key = find_ssh_private_key() - if private_key: - ssh_opts.extend(["-i", private_key]) - - ssh_cmd.extend(ssh_opts) - ssh_cmd.append(f"{ssh_user}@{host}") - ssh_cmd.append(command) - - try: - if ssh_password: - # Utiliser sshpass pour l'authentification par mot de passe - full_cmd = ["sshpass", "-p", ssh_password] + ssh_cmd - else: - full_cmd = ssh_cmd - - result = subprocess.run( - full_cmd, - capture_output=True, - text=True, - timeout=timeout - ) - return result.returncode, result.stdout, result.stderr - except subprocess.TimeoutExpired: - return -1, "", f"Timeout après {timeout} secondes" - except FileNotFoundError as e: - if "sshpass" in str(e): - return -1, "", "sshpass n'est pas installé. Installez-le avec: apt install sshpass" - return -1, "", str(e) - except Exception as e: - return -1, "", str(e) - - -def bootstrap_host(host: str, root_password: str, automation_user: str = "automation") -> CommandResult: - """Prépare un hôte pour Ansible (création user, clé SSH, sudo, python3) pour Debian/Alpine/FreeBSD. - - Utilise un script shell complet uploadé via heredoc pour éviter les problèmes de quoting. - """ - import logging - logger = logging.getLogger("bootstrap") - - # Chercher la clé publique dans plusieurs emplacements possibles - primary_dirs = [ - Path(SSH_KEY_PATH).parent, - Path("/app/ssh_keys"), - Path.home() / ".ssh", - ] - ssh_dir = primary_dirs[0] - pub_paths = [ - SSH_KEY_PATH + ".pub", - "/app/ssh_keys/id_rsa.pub", - "/app/ssh_keys/id_ed25519.pub", - "/app/ssh_keys/id_ecdsa.pub", - "/app/ssh_keys/id_automation_ansible.pub", - ] - - # Ajouter dynamiquement toutes les clés .pub trouvées dans le répertoire SSH - for directory in primary_dirs: - if not directory.exists(): - continue - for f in directory.iterdir(): - if f.is_file() and f.suffix == ".pub" and str(f) not in pub_paths: - pub_paths.append(str(f)) - - logger.info(f"SSH_KEY_PATH = {SSH_KEY_PATH}") - logger.info(f"Recherche de clé publique dans: {pub_paths}") - - pub_key = None - pub_path_used = None - - for pub_path in pub_paths: - try: - if Path(pub_path).exists(): - pub_key = Path(pub_path).read_text(encoding="utf-8").strip() - if pub_key: - pub_path_used = pub_path - logger.info(f"Clé publique trouvée: {pub_path}") - break - except Exception as e: - logger.warning(f"Erreur lecture {pub_path}: {e}") - continue - - if not pub_key: - # Lister les fichiers disponibles pour le debug - ssh_dir = Path(SSH_KEY_PATH).parent - available_files = [] - if ssh_dir.exists(): - available_files = [f.name for f in ssh_dir.iterdir()] - - raise HTTPException( - status_code=500, - detail=f"Clé publique SSH non trouvée. Chemins testés: {pub_paths}. Fichiers disponibles dans {ssh_dir}: {available_files}", - ) - - # Script shell complet, robuste, avec logs détaillés - bootstrap_script = f"""#!/bin/sh -set -e - -AUT_USER="{automation_user}" - -echo "=== Bootstrap Ansible Host ===" -echo "User: $AUT_USER" -echo "" - -# 1) Détection OS -if command -v apk >/dev/null 2>&1; then - OS_TYPE="alpine" - echo "[1/7] OS détecté: Alpine Linux" -elif [ "$(uname -s 2>/dev/null)" = "FreeBSD" ] || \ - command -v pkg >/dev/null 2>&1 || \ - ( [ -f /etc/os-release ] && grep -qi 'ID=freebsd' /etc/os-release ); then - OS_TYPE="freebsd" - echo "[1/7] OS détecté: FreeBSD" -else - OS_TYPE="debian" - echo "[1/7] OS détecté: Debian-like" -fi - -# 2) Vérification / préparation utilisateur -echo "[2/7] Vérification utilisateur/groupe..." -if id "$AUT_USER" >/dev/null 2>&1; then - echo " - Utilisateur déjà existant: $AUT_USER (aucune suppression)" -else - echo " - Utilisateur inexistant, il sera créé" -fi - -# 3) Création utilisateur (idempotent) -echo "[3/7] Création utilisateur $AUT_USER..." -if id "$AUT_USER" >/dev/null 2>&1; then - echo " - Utilisateur déjà présent, réutilisation" -elif [ "$OS_TYPE" = "alpine" ]; then - adduser -D "$AUT_USER" - echo " - Utilisateur créé (Alpine: adduser -D)" -elif [ "$OS_TYPE" = "freebsd" ]; then - pw useradd "$AUT_USER" -m -s /bin/sh - echo " - Utilisateur créé (FreeBSD: pw useradd)" -else - useradd -m -s /bin/bash "$AUT_USER" || useradd -m -s /bin/sh "$AUT_USER" - echo " - Utilisateur créé (Debian: useradd -m)" -fi - -# 3b) S'assurer que le compte n'est pas verrouillé -echo " - Vérification du verrouillage du compte..." -if command -v passwd >/dev/null 2>&1; then - passwd -u "$AUT_USER" 2>/dev/null || true -fi -if command -v usermod >/dev/null 2>&1; then - usermod -U "$AUT_USER" 2>/dev/null || true -fi - -# 4) Configuration clé SSH -echo "[4/7] Configuration clé SSH..." -HOME_DIR=$(getent passwd "$AUT_USER" | cut -d: -f6) -if [ -z "$HOME_DIR" ]; then - HOME_DIR="/home/$AUT_USER" -fi -echo " - HOME_DIR: $HOME_DIR" - -mkdir -p "$HOME_DIR/.ssh" -chown "$AUT_USER":"$AUT_USER" "$HOME_DIR/.ssh" -chmod 700 "$HOME_DIR/.ssh" -echo " - Répertoire .ssh créé et configuré" - -cat > "$HOME_DIR/.ssh/authorized_keys" << 'SSHKEY_EOF' -{pub_key} -SSHKEY_EOF - -chown "$AUT_USER":"$AUT_USER" "$HOME_DIR/.ssh/authorized_keys" -chmod 600 "$HOME_DIR/.ssh/authorized_keys" -echo " - Clé publique installée dans authorized_keys" - -if [ -s "$HOME_DIR/.ssh/authorized_keys" ]; then - KEY_COUNT=$(wc -l < "$HOME_DIR/.ssh/authorized_keys") - echo " - Vérification: $KEY_COUNT clé(s) dans authorized_keys" -else - echo " - ERREUR: authorized_keys vide ou absent!" - exit 1 -fi - -# 5) Installation sudo -echo "[5/7] Installation sudo..." -if command -v sudo >/dev/null 2>&1; then - echo " - sudo déjà installé" -else - if [ "$OS_TYPE" = "alpine" ]; then - apk add --no-cache sudo - echo " - sudo installé (apk)" - elif [ "$OS_TYPE" = "freebsd" ]; then - pkg install -y sudo - echo " - sudo installé (pkg)" - else - apt-get update -qq && apt-get install -y sudo - echo " - sudo installé (apt)" - fi -fi - -# 6) Configuration sudoers -echo "[6/7] Configuration sudoers..." -if [ ! -d /etc/sudoers.d ]; then - mkdir -p /etc/sudoers.d - chmod 750 /etc/sudoers.d 2>/dev/null || true - echo " - Répertoire /etc/sudoers.d créé" -fi -echo "$AUT_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/automation -chmod 440 /etc/sudoers.d/automation -echo " - Sudoers configuré: /etc/sudoers.d/automation" - -# 7) Installation Python3 -echo "[7/7] Installation Python3..." -if command -v python3 >/dev/null 2>&1; then - PYTHON_VERSION=$(python3 --version 2>&1) - echo " - Python3 déjà installé: $PYTHON_VERSION" -else - if [ "$OS_TYPE" = "alpine" ]; then - apk add --no-cache python3 - echo " - Python3 installé (apk)" - elif [ "$OS_TYPE" = "freebsd" ]; then - pkg install -y python3 - echo " - Python3 installé (pkg)" - else - apt-get update -qq && apt-get install -y python3 - echo " - Python3 installé (apt)" - fi -fi - -echo "" -echo "=== Bootstrap terminé avec succès ===" -echo "Utilisateur: $AUT_USER" -echo "HOME: $HOME_DIR" -echo "SSH: $HOME_DIR/.ssh/authorized_keys" -echo "Sudo: /etc/sudoers.d/automation" -""" - - # Envoyer le script de manière compatible avec tous les shells - lines = bootstrap_script.splitlines() - - def _sh_single_quote(s: str) -> str: - """Protège une chaîne pour un shell POSIX en simple quotes.""" - return "'" + s.replace("'", "'\"'\"'") + "'" - - quoted_lines = " ".join(_sh_single_quote(line) for line in lines) - remote_cmd = f"printf '%s\\n' {quoted_lines} | sh" - - rc, out, err = run_ssh_command( - host, - remote_cmd, - ssh_user="root", - ssh_password=root_password, - ) - - if rc != 0: - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "return_code": rc, - "stdout": out, - "stderr": err, - }, - ) - - # Vérification: tester la connexion SSH par clé avec l'utilisateur d'automatisation - verify_rc, verify_out, verify_err = run_ssh_command( - host, - "echo 'ssh_key_ok'", - ssh_user=automation_user, - ssh_password=None, - ) - - if verify_rc != 0: - combined_stdout = (out or "") + f"\n\n[SSH VERIFY] Échec de la connexion par clé pour {automation_user}@{host}\n" + (verify_out or "") - combined_stderr = (err or "") + f"\n\n[SSH VERIFY] " + (verify_err or "Aucune erreur détaillée") - raise HTTPException( - status_code=500, - detail={ - "status": "error", - "return_code": verify_rc, - "stdout": combined_stdout, - "stderr": combined_stderr, - }, - ) - - # Succès complet - final_stdout = (out or "") + f"\n\n[SSH VERIFY] Connexion par clé OK pour {automation_user}@{host}" - return CommandResult( - status="ok", - return_code=0, - stdout=final_stdout, - stderr=err, - ) - - -# Base de données hybride : hôtes depuis Ansible, tâches/logs en mémoire -class HybridDB: - """Base de données qui charge les hôtes depuis l'inventaire Ansible""" - - def __init__(self, ansible_svc: AnsibleService): - self.ansible_service = ansible_svc - self._hosts_cache: Optional[List[Host]] = None - self._hosts_cache_time: float = 0 - self._cache_ttl: float = 60 # Cache de 60 secondes - # Statuts runtime des hôtes (en mémoire) rechargés depuis le fichier JSON persistant - self._host_runtime_status: Dict[str, Dict[str, Any]] = {} - try: - persisted_hosts = host_status_service.get_all_status() - for host_name, info in persisted_hosts.items(): - last_seen_raw = info.get("last_seen") - last_seen_dt: Optional[datetime] = None - if isinstance(last_seen_raw, str): - try: - last_seen_dt = datetime.fromisoformat(last_seen_raw.replace("Z", "+00:00")) - except Exception: - last_seen_dt = None - elif isinstance(last_seen_raw, datetime): - last_seen_dt = last_seen_raw - - self._host_runtime_status[host_name] = { - "status": info.get("status", "online"), - "last_seen": last_seen_dt, - "os": info.get("os"), - } - except Exception: - # En cas de problème de lecture, on repartira d'un état en mémoire vierge - self._host_runtime_status = {} - - # Tâches et logs en mémoire (persistés pendant l'exécution) - self.tasks: List[Task] = [] - - self.logs: List[LogEntry] = [ - LogEntry(id=1, timestamp=datetime.now(timezone.utc), level="INFO", - message="Dashboard démarré - Inventaire Ansible chargé") - ] - - self._id_counters = {"hosts": 100, "tasks": 1, "logs": 2} - - @property - def hosts(self) -> List[Host]: - """Charge les hôtes depuis l'inventaire Ansible avec cache""" - current_time = time() - - # Retourner le cache si valide - if self._hosts_cache and (current_time - self._hosts_cache_time) < self._cache_ttl: - return self._hosts_cache - - # Recharger depuis Ansible - self._hosts_cache = self._load_hosts_from_ansible() - self._hosts_cache_time = current_time - return self._hosts_cache - - def _load_hosts_from_ansible(self) -> List[Host]: - """Convertit l'inventaire Ansible en liste d'hôtes (sans doublons)""" - hosts = [] - ansible_hosts = self.ansible_service.get_hosts_from_inventory() - - # Charger tous les statuts de bootstrap - all_bootstrap_status = bootstrap_status_service.get_all_status() - - for idx, ah in enumerate(ansible_hosts, start=1): - # Extraire le groupe principal depuis les groupes - primary_group = ah.groups[0] if ah.groups else "unknown" - - # Récupérer le statut bootstrap pour cet hôte - bootstrap_info = all_bootstrap_status.get(ah.name, {}) - bootstrap_ok = bootstrap_info.get("bootstrap_ok", False) - bootstrap_date_str = bootstrap_info.get("bootstrap_date") - bootstrap_date = None - if bootstrap_date_str: - try: - bootstrap_date = datetime.fromisoformat(bootstrap_date_str.replace("Z", "+00:00")) - except: - pass - - runtime_status = self._host_runtime_status.get(ah.name, {}) - status = runtime_status.get("status", "online") - last_seen = runtime_status.get("last_seen") - os_label = runtime_status.get("os", f"Linux ({primary_group})") - - host = Host( - id=str(idx), - name=ah.name, - ip=ah.ansible_host, - status=status, - os=os_label, - last_seen=last_seen, - groups=ah.groups, # Tous les groupes de l'hôte - bootstrap_ok=bootstrap_ok, - bootstrap_date=bootstrap_date - ) - hosts.append(host) - - return hosts - - def refresh_hosts(self): - """Force le rechargement des hôtes depuis Ansible""" - self._hosts_cache = None - return self.hosts - - def update_host_status(self, host_name: str, status: str, os_info: str = None): - """Met à jour le statut d'un hôte après un health-check""" - for host in self.hosts: - if host.name == host_name: - host.status = status - host.last_seen = datetime.now(timezone.utc) - if os_info: - host.os = os_info - self._host_runtime_status[host_name] = { - "status": host.status, - "last_seen": host.last_seen, - "os": host.os, - } - # Persister dans le fichier JSON partagé avec Ansible - try: - host_status_service.set_status(host_name, host.status, host.last_seen, host.os) - except Exception: - # Ne pas casser l'exécution si la persistance échoue - pass - break - - @property - def metrics(self) -> SystemMetrics: - """Calcule les métriques en temps réel basées sur les logs de tâches""" - hosts = self.hosts - - # Utiliser les statistiques des fichiers de logs de tâches - task_stats = task_log_service.get_stats() - total_tasks = task_stats.get("total", 0) - completed_tasks = task_stats.get("completed", 0) - failed_tasks = task_stats.get("failed", 0) - total_finished = completed_tasks + failed_tasks - - return SystemMetrics( - online_hosts=len([h for h in hosts if h.status == "online"]), - total_tasks=total_tasks, - success_rate=round((completed_tasks / total_finished * 100) if total_finished > 0 else 100, 1), - uptime=99.9, - cpu_usage=0, - memory_usage=0, - disk_usage=0 - ) - - def get_next_id(self, collection: str) -> int: - self._id_counters[collection] += 1 - return self._id_counters[collection] - 1 - - -# Instance globale de la base de données hybride -db = HybridDB(ansible_service) - -# Dépendances FastAPI -async def verify_api_key(api_key: str = Depends(api_key_header)) -> bool: - """Vérifie la clé API fournie""" - if not api_key or api_key != API_KEY: - raise HTTPException(status_code=401, detail="Clé API invalide ou manquante") - return True - -@app.get("/", response_class=HTMLResponse) -async def root(request: Request): - """Page principale du dashboard""" - return FileResponse(BASE_DIR / "index.html") - - -@app.get("/api", response_class=HTMLResponse) -async def api_home(request: Request): - """Page d'accueil de l'API Homelab Dashboard""" - return """ - - - - - - Homelab Dashboard API - - - -
-

Homelab Dashboard API

-

- API REST moderne pour la gestion automatique d'homelab -

- -
-

Documentation API

-

Explorez les endpoints disponibles et testez les fonctionnalités

- -
- -
-

Endpoints Principaux

-
-
- GET - /api/hosts - - Liste des hôtes -
-
- POST - /api/tasks - - Créer une tâche -
-
- GET - /api/metrics - - Métriques système -
-
- WS - /ws - - WebSocket temps réel -
-
-
- -
-

Version 1.0.0 | Développé avec FastAPI et technologies modernes

-
-
- - - """ - -# ===== ENDPOINTS HOSTS - Routes statiques d'abord ===== - -@app.get("/api/hosts/groups") -async def get_host_groups(api_key_valid: bool = Depends(verify_api_key)): - """Récupère les groupes disponibles pour les hôtes (environnements et rôles)""" - return { - "env_groups": ansible_service.get_env_groups(), - "role_groups": ansible_service.get_role_groups(), - "all_groups": ansible_service.get_groups() - } - - -# ===== ENDPOINTS GROUPS - Gestion des groupes d'environnement et de rôles ===== - -@app.get("/api/groups") -async def get_all_groups(api_key_valid: bool = Depends(verify_api_key)): - """Récupère tous les groupes avec leurs détails""" - env_groups = ansible_service.get_env_groups() - role_groups = ansible_service.get_role_groups() - - groups = [] - for g in env_groups: - hosts = ansible_service.get_group_hosts(g) - groups.append({ - "name": g, - "type": "env", - "display_name": g.replace('env_', ''), - "hosts_count": len(hosts), - "hosts": hosts - }) - - for g in role_groups: - hosts = ansible_service.get_group_hosts(g) - groups.append({ - "name": g, - "type": "role", - "display_name": g.replace('role_', ''), - "hosts_count": len(hosts), - "hosts": hosts - }) - - return { - "groups": groups, - "env_count": len(env_groups), - "role_count": len(role_groups) - } - - -@app.get("/api/groups/{group_name}") -async def get_group_details(group_name: str, api_key_valid: bool = Depends(verify_api_key)): - """Récupère les détails d'un groupe spécifique""" - if not ansible_service.group_exists(group_name): - raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé") - - hosts = ansible_service.get_group_hosts(group_name) - group_type = "env" if group_name.startswith("env_") else "role" if group_name.startswith("role_") else "other" - - return { - "name": group_name, - "type": group_type, - "display_name": group_name.replace('env_', '').replace('role_', ''), - "hosts_count": len(hosts), - "hosts": hosts - } - - -@app.post("/api/groups") -async def create_group(group_request: GroupRequest, api_key_valid: bool = Depends(verify_api_key)): - """Crée un nouveau groupe d'environnement ou de rôle""" - # Construire le nom complet du groupe - prefix = "env_" if group_request.type == "env" else "role_" - - # Si le nom ne commence pas déjà par le préfixe, l'ajouter - if group_request.name.startswith(prefix): - full_name = group_request.name - else: - full_name = f"{prefix}{group_request.name}" - - # Vérifier si le groupe existe déjà - if ansible_service.group_exists(full_name): - raise HTTPException(status_code=400, detail=f"Le groupe '{full_name}' existe déjà") - - # Créer le groupe - success = ansible_service.add_group(full_name) - - if not success: - raise HTTPException(status_code=500, detail="Erreur lors de la création du groupe") - - return { - "success": True, - "message": f"Groupe '{full_name}' créé avec succès", - "group": { - "name": full_name, - "type": group_request.type, - "display_name": full_name.replace('env_', '').replace('role_', ''), - "hosts_count": 0, - "hosts": [] - } - } - - -@app.put("/api/groups/{group_name}") -async def update_group(group_name: str, group_update: GroupUpdateRequest, api_key_valid: bool = Depends(verify_api_key)): - """Renomme un groupe existant""" - if not ansible_service.group_exists(group_name): - raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé") - - # Déterminer le type du groupe - if group_name.startswith("env_"): - prefix = "env_" - group_type = "env" - elif group_name.startswith("role_"): - prefix = "role_" - group_type = "role" - else: - raise HTTPException(status_code=400, detail="Seuls les groupes env_ et role_ peuvent être modifiés") - - # Construire le nouveau nom - if group_update.new_name.startswith(prefix): - new_full_name = group_update.new_name - else: - new_full_name = f"{prefix}{group_update.new_name}" - - # Vérifier si le nouveau nom existe déjà - if ansible_service.group_exists(new_full_name): - raise HTTPException(status_code=400, detail=f"Le groupe '{new_full_name}' existe déjà") - - # Renommer le groupe - success = ansible_service.rename_group(group_name, new_full_name) - - if not success: - raise HTTPException(status_code=500, detail="Erreur lors du renommage du groupe") - - hosts = ansible_service.get_group_hosts(new_full_name) - - return { - "success": True, - "message": f"Groupe renommé de '{group_name}' vers '{new_full_name}'", - "group": { - "name": new_full_name, - "type": group_type, - "display_name": new_full_name.replace('env_', '').replace('role_', ''), - "hosts_count": len(hosts), - "hosts": hosts - } - } - - -@app.delete("/api/groups/{group_name}") -async def delete_group( - group_name: str, - move_hosts_to: Optional[str] = None, - api_key_valid: bool = Depends(verify_api_key) -): - """Supprime un groupe existant - - Args: - group_name: Nom du groupe à supprimer - move_hosts_to: Groupe vers lequel déplacer les hôtes (optionnel, query param) - """ - if not ansible_service.group_exists(group_name): - raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé") - - # Vérifier si le groupe contient des hôtes - hosts_in_group = ansible_service.get_group_hosts(group_name) - - # Si le groupe contient des hôtes et qu'on ne spécifie pas où les déplacer - if hosts_in_group and not move_hosts_to: - # Pour les groupes d'environnement, c'est critique car les hôtes doivent avoir un env - if group_name.startswith("env_"): - raise HTTPException( - status_code=400, - detail=f"Le groupe contient {len(hosts_in_group)} hôte(s). Spécifiez 'move_hosts_to' pour les déplacer." - ) - - # Si on veut déplacer les hôtes, vérifier que le groupe cible est valide - if move_hosts_to: - # Vérifier que le groupe cible est du même type - if group_name.startswith("env_") and not move_hosts_to.startswith("env_"): - raise HTTPException(status_code=400, detail="Les hôtes doivent être déplacés vers un groupe d'environnement") - if group_name.startswith("role_") and not move_hosts_to.startswith("role_"): - raise HTTPException(status_code=400, detail="Les hôtes doivent être déplacés vers un groupe de rôle") - - # Supprimer le groupe - result = ansible_service.delete_group(group_name, move_hosts_to) - - if not result.get("success"): - raise HTTPException(status_code=500, detail=result.get("error", "Erreur lors de la suppression")) - - return { - "success": True, - "message": f"Groupe '{group_name}' supprimé avec succès", - "hosts_affected": result.get("hosts_affected", []), - "hosts_moved_to": result.get("hosts_moved_to") - } - - -def _host_to_response(host_obj, bootstrap_status: Optional["BootstrapStatus"] = None) -> Dict[str, Any]: - """Map DB host + latest bootstrap to API-compatible payload.""" - return { - "id": host_obj.id, - "name": host_obj.name, - "ip": getattr(host_obj, "ip_address", None), - "status": host_obj.status, - "os": "Linux", # valeur par défaut faute d'info stockée - "last_seen": host_obj.last_seen, - "created_at": host_obj.created_at, - "groups": [g for g in [getattr(host_obj, "ansible_group", None)] if g], - "bootstrap_ok": (bootstrap_status.status == "success") if bootstrap_status else False, - "bootstrap_date": bootstrap_status.last_attempt if bootstrap_status else None, - } - - -@app.get("/api/hosts/by-name/{host_name}") -async def get_host_by_name( - host_name: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - repo = HostRepository(db_session) - bs_repo = BootstrapStatusRepository(db_session) - host = await repo.get_by_ip(host_name) or await repo.get(host_name) - if not host: - raise HTTPException(status_code=404, detail="Hôte non trouvé") - bootstrap = await bs_repo.latest_for_host(host.id) - return _host_to_response(host, bootstrap) - - -@app.get("/api/hosts") -async def get_hosts( - bootstrap_status: Optional[str] = None, - limit: int = 100, - offset: int = 0, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - repo = HostRepository(db_session) - bs_repo = BootstrapStatusRepository(db_session) - hosts = await repo.list(limit=limit, offset=offset) - # Si la base ne contient encore aucun hôte, on retombe sur les hôtes Ansible via la DB hybride - if not hosts: - hybrid_hosts = db.hosts - fallback_results = [] - for h in hybrid_hosts: - # Appliquer les mêmes filtres de bootstrap que pour la version DB - if bootstrap_status == "ready" and not h.bootstrap_ok: - continue - if bootstrap_status == "not_configured" and h.bootstrap_ok: - continue - - fallback_results.append( - { - "id": h.id, - "name": h.name, - "ip": h.ip, - "status": h.status, - "os": h.os, - "last_seen": h.last_seen, - # created_at est déjà géré par le modèle Pydantic Host (default_factory) - "created_at": h.created_at, - "groups": h.groups, - "bootstrap_ok": h.bootstrap_ok, - "bootstrap_date": h.bootstrap_date, - } - ) - return fallback_results - - results = [] - for host in hosts: - bootstrap = await bs_repo.latest_for_host(host.id) - if bootstrap_status == "ready" and not (bootstrap and bootstrap.status == "success"): - continue - if bootstrap_status == "not_configured" and bootstrap and bootstrap.status == "success": - continue - results.append(_host_to_response(host, bootstrap)) - return results - - -@app.get("/api/hosts/{host_id}") -async def get_host( - host_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - repo = HostRepository(db_session) - bs_repo = BootstrapStatusRepository(db_session) - host = await repo.get(host_id) - if not host: - raise HTTPException(status_code=404, detail="Hôte non trouvé") - bootstrap = await bs_repo.latest_for_host(host.id) - return _host_to_response(host, bootstrap) - - -@app.post("/api/hosts") -async def create_host( - host_request: HostRequest, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - repo = HostRepository(db_session) - bs_repo = BootstrapStatusRepository(db_session) - - # Vérifier si l'hôte existe déjà - existing = await repo.get_by_ip(host_request.name) - if existing: - raise HTTPException(status_code=400, detail=f"L'hôte '{host_request.name}' existe déjà") - - # Valider le groupe d'environnement - env_groups = ansible_service.get_env_groups() - if host_request.env_group not in env_groups and not host_request.env_group.startswith("env_"): - raise HTTPException(status_code=400, detail=f"Le groupe d'environnement doit commencer par 'env_'. Groupes existants: {env_groups}") - - # Valider les groupes de rôles - role_groups = ansible_service.get_role_groups() - for role in host_request.role_groups: - if role not in role_groups and not role.startswith("role_"): - raise HTTPException(status_code=400, detail=f"Le groupe de rôle '{role}' doit commencer par 'role_'. Groupes existants: {role_groups}") - - try: - # Ajouter l'hôte à l'inventaire Ansible - ansible_service.add_host_to_inventory( - hostname=host_request.name, - env_group=host_request.env_group, - role_groups=host_request.role_groups, - ansible_host=host_request.ip, - ) - - # Créer en base - host = await repo.create( - id=uuid.uuid4().hex, - name=host_request.name, - ip_address=host_request.ip or host_request.name, - ansible_group=host_request.env_group, - status="unknown", - reachable=False, - last_seen=None, - ) - bootstrap = await bs_repo.latest_for_host(host.id) - - await db_session.commit() - - # Notifier les clients WebSocket - await ws_manager.broadcast( - { - "type": "host_created", - "data": _host_to_response(host, bootstrap), - } - ) - - return { - "message": f"Hôte '{host_request.name}' ajouté avec succès", - "host": _host_to_response(host, bootstrap), - "inventory_updated": True, - } - - except HTTPException: - raise - except Exception as e: - await db_session.rollback() - raise HTTPException(status_code=500, detail=f"Erreur lors de l'ajout de l'hôte: {str(e)}") - - -@app.put("/api/hosts/{host_name}") -async def update_host( - host_name: str, - update_request: HostUpdateRequest, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - repo = HostRepository(db_session) - bs_repo = BootstrapStatusRepository(db_session) - host = await repo.get_by_ip(host_name) or await repo.get(host_name) - if not host: - raise HTTPException(status_code=404, detail=f"Hôte '{host_name}' non trouvé") - - # Valider le groupe d'environnement si fourni - if update_request.env_group: - env_groups = ansible_service.get_env_groups() - if update_request.env_group not in env_groups and not update_request.env_group.startswith("env_"): - raise HTTPException(status_code=400, detail=f"Le groupe d'environnement doit commencer par 'env_'") - - # Valider les groupes de rôles si fournis - if update_request.role_groups: - for role in update_request.role_groups: - if not role.startswith("role_"): - raise HTTPException(status_code=400, detail=f"Le groupe de rôle '{role}' doit commencer par 'role_'") - - try: - ansible_service.update_host_groups( - hostname=host_name, - env_group=update_request.env_group, - role_groups=update_request.role_groups, - ansible_host=update_request.ansible_host, - ) - - await repo.update( - host, - ansible_group=update_request.env_group or host.ansible_group, - ) - await db_session.commit() - - bootstrap = await bs_repo.latest_for_host(host.id) - - await ws_manager.broadcast( - { - "type": "host_updated", - "data": _host_to_response(host, bootstrap), - } - ) - - return { - "message": f"Hôte '{host_name}' mis à jour avec succès", - "host": _host_to_response(host, bootstrap), - "inventory_updated": True, - } - - except HTTPException: - await db_session.rollback() - raise - except Exception as e: - await db_session.rollback() - raise HTTPException(status_code=500, detail=f"Erreur lors de la mise à jour: {str(e)}") - - -@app.delete("/api/hosts/by-name/{host_name}") -async def delete_host_by_name( - host_name: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - repo = HostRepository(db_session) - host = await repo.get_by_ip(host_name) or await repo.get(host_name) - if not host: - raise HTTPException(status_code=404, detail=f"Hôte '{host_name}' non trouvé") - - try: - ansible_service.remove_host_from_inventory(host_name) - await repo.soft_delete(host.id) - await db_session.commit() - - await ws_manager.broadcast( - { - "type": "host_deleted", - "data": {"name": host_name}, - } - ) - - return {"message": f"Hôte '{host_name}' supprimé avec succès", "inventory_updated": True} - except HTTPException: - await db_session.rollback() - raise - except Exception as e: - await db_session.rollback() - raise HTTPException(status_code=500, detail=f"Erreur lors de la suppression: {str(e)}") - - -@app.delete("/api/hosts/{host_id}") -async def delete_host( - host_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - repo = HostRepository(db_session) - host = await repo.get(host_id) - if not host: - raise HTTPException(status_code=404, detail="Hôte non trouvé") - - return await delete_host_by_name(host.name, api_key_valid, db_session) - -@app.get("/api/tasks") -async def get_tasks( - limit: int = 100, - offset: int = 0, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Récupère la liste de toutes les tâches""" - repo = TaskRepository(db_session) - tasks = await repo.list(limit=limit, offset=offset) - return [ - { - "id": t.id, - "name": t.action, - "host": t.target, - "status": t.status, - "progress": 100 if t.status == "completed" else (50 if t.status == "running" else 0), - "start_time": t.started_at, - "end_time": t.completed_at, - "duration": None, - "output": t.result_data.get("output") if t.result_data else None, - "error": t.error_message, - } - for t in tasks - ] - - -@app.post("/api/tasks") -async def create_task( - task_request: TaskRequest, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Crée une nouvelle tâche et exécute le playbook Ansible correspondant""" - task_names = { - 'upgrade': 'Mise à jour système', - 'reboot': 'Redémarrage système', - 'health-check': 'Vérification de santé', - 'backup': 'Sauvegarde', - 'deploy': 'Déploiement', - 'rollback': 'Rollback', - 'maintenance': 'Maintenance', - 'bootstrap': 'Bootstrap Ansible' - } - - repo = TaskRepository(db_session) - task_id = uuid.uuid4().hex - target = task_request.host or task_request.group or "all" - playbook = ACTION_PLAYBOOK_MAP.get(task_request.action) - - task_obj = await repo.create( - id=task_id, - action=task_request.action, - target=target, - playbook=playbook, - status="running", - ) - await repo.update(task_obj, started_at=datetime.now(timezone.utc)) - await db_session.commit() - - task_name = task_names.get(task_request.action, f"Tâche {task_request.action}") - - response_data = { - "id": task_obj.id, - "name": task_name, - "host": target, - "status": "running", - "progress": 0, - "start_time": task_obj.started_at, - "end_time": None, - "duration": None, - "output": None, - "error": None, - } - - # Ajouter aussi à db.tasks (mémoire) pour la compatibilité avec execute_ansible_task - mem_task = Task( - id=task_obj.id, - name=task_name, - host=target, - status="running", - progress=0, - start_time=task_obj.started_at - ) - db.tasks.insert(0, mem_task) - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "task_created", - "data": response_data - }) - - # Exécuter le playbook Ansible en arrière-plan et stocker le handle - if playbook: - async_task = asyncio.create_task(execute_ansible_task( - task_id=task_obj.id, - playbook=playbook, - target=target, - extra_vars=task_request.extra_vars, - check_mode=task_request.dry_run - )) - running_task_handles[task_obj.id] = {"asyncio_task": async_task, "process": None, "cancelled": False} - else: - # Pas de playbook correspondant, simuler - async_task = asyncio.create_task(simulate_task_execution(task_obj.id)) - running_task_handles[task_obj.id] = {"asyncio_task": async_task, "process": None, "cancelled": False} - - return response_data - - -# ===== ENDPOINTS LOGS DE TÂCHES (MARKDOWN) ===== -# IMPORTANT: Ces routes doivent être AVANT /api/tasks/{task_id} pour éviter les conflits - -@app.get("/api/tasks/logs") -async def get_task_logs( - status: Optional[str] = None, - year: Optional[str] = None, - month: Optional[str] = None, - day: Optional[str] = None, - hour_start: Optional[str] = None, - hour_end: Optional[str] = None, - target: Optional[str] = None, - category: Optional[str] = None, - source_type: Optional[str] = None, - limit: int = 50, - offset: int = 0, - api_key_valid: bool = Depends(verify_api_key) -): - """Récupère les logs de tâches depuis les fichiers markdown avec filtrage et pagination""" - logs, total_count = task_log_service.get_task_logs( - year=year, - month=month, - day=day, - status=status, - target=target, - category=category, - source_type=source_type, - hour_start=hour_start, - hour_end=hour_end, - limit=limit, - offset=offset - ) - return { - "logs": [log.dict() for log in logs], - "count": len(logs), - "total_count": total_count, - "has_more": offset + len(logs) < total_count, - "filters": { - "status": status, - "year": year, - "month": month, - "day": day, - "hour_start": hour_start, - "hour_end": hour_end, - "target": target, - "source_type": source_type - }, - "pagination": { - "limit": limit, - "offset": offset - } - } - - -@app.get("/api/tasks/logs/dates") -async def get_task_logs_dates(api_key_valid: bool = Depends(verify_api_key)): - """Récupère la structure des dates disponibles pour le filtrage""" - return task_log_service.get_available_dates() - - -@app.get("/api/tasks/logs/stats") -async def get_task_logs_stats(api_key_valid: bool = Depends(verify_api_key)): - """Récupère les statistiques des logs de tâches""" - return task_log_service.get_stats() - - -@app.get("/api/tasks/logs/{log_id}") -async def get_task_log_content(log_id: str, api_key_valid: bool = Depends(verify_api_key)): - """Récupère le contenu d'un log de tâche spécifique""" - logs, _ = task_log_service.get_task_logs(limit=0) - log = next((l for l in logs if l.id == log_id), None) - - if not log: - raise HTTPException(status_code=404, detail="Log non trouvé") - - try: - content = Path(log.path).read_text(encoding='utf-8') - return { - "log": log.dict(), - "content": content - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erreur lecture du fichier: {str(e)}") - - -@app.delete("/api/tasks/logs/{log_id}") -async def delete_task_log(log_id: str, api_key_valid: bool = Depends(verify_api_key)): - """Supprime un fichier markdown de log de tâche.""" - logs, _ = task_log_service.get_task_logs(limit=0) - log = next((l for l in logs if l.id == log_id), None) - - if not log: - raise HTTPException(status_code=404, detail="Log non trouvé") - - try: - log_path = Path(log.path) - if log_path.exists(): - log_path.unlink() - return {"message": "Log supprimé", "id": log_id} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erreur suppression du fichier: {str(e)}") - - -@app.get("/api/tasks/running") -async def get_running_tasks( - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Récupère uniquement les tâches en cours d'exécution (running ou pending)""" - repo = TaskRepository(db_session) - tasks = await repo.list(limit=100, offset=0) - running_tasks = [t for t in tasks if t.status in ("running", "pending")] - return { - "tasks": [ - { - "id": t.id, - "name": t.action, - "host": t.target, - "status": t.status, - "progress": 50 if t.status == "running" else 0, - "start_time": t.started_at, - "end_time": t.completed_at, - } - for t in running_tasks - ], - "count": len(running_tasks) - } - - -@app.post("/api/tasks/{task_id}/cancel") -async def cancel_task( - task_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Annule une tâche en cours d'exécution""" - repo = TaskRepository(db_session) - task = await repo.get(task_id) - - if not task: - raise HTTPException(status_code=404, detail="Tâche non trouvée") - - if task.status not in ("running", "pending"): - raise HTTPException(status_code=400, detail=f"La tâche n'est pas en cours (statut: {task.status})") - - # Marquer comme annulée dans le dictionnaire des handles - if task_id in running_task_handles: - running_task_handles[task_id]["cancelled"] = True - - # Annuler la tâche asyncio - async_task = running_task_handles[task_id].get("asyncio_task") - if async_task and not async_task.done(): - async_task.cancel() - - # Tuer le processus Ansible si présent - process = running_task_handles[task_id].get("process") - if process: - try: - process.terminate() - # Attendre un peu puis forcer si nécessaire - await asyncio.sleep(0.5) - if process.returncode is None: - process.kill() - except Exception: - pass - - # Nettoyer le handle - del running_task_handles[task_id] - - # Mettre à jour le statut en BD - await repo.update( - task, - status="cancelled", - completed_at=datetime.now(timezone.utc), - error_message="Tâche annulée par l'utilisateur" - ) - await db_session.commit() - - # Mettre à jour aussi dans db.tasks (mémoire) si présent - for t in db.tasks: - if str(t.id) == str(task_id): - t.status = "cancelled" - t.end_time = datetime.now(timezone.utc) - t.error = "Tâche annulée par l'utilisateur" - break - - # Log - log_repo = LogRepository(db_session) - await log_repo.create( - level="WARNING", - message=f"Tâche '{task.action}' annulée manuellement", - source="task", - task_id=task_id, - ) - await db_session.commit() - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "task_cancelled", - "data": { - "id": task_id, - "status": "cancelled", - "message": "Tâche annulée par l'utilisateur" - } - }) - - return { - "success": True, - "message": f"Tâche {task_id} annulée avec succès", - "task_id": task_id - } - - -@app.get("/api/tasks/{task_id}") -async def get_task( - task_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Récupère une tâche spécifique""" - repo = TaskRepository(db_session) - task = await repo.get(task_id) - if not task: - raise HTTPException(status_code=404, detail="Tâche non trouvée") - return { - "id": task.id, - "name": task.action, - "host": task.target, - "status": task.status, - "progress": 100 if task.status == "completed" else (50 if task.status == "running" else 0), - "start_time": task.started_at, - "end_time": task.completed_at, - "duration": None, - "output": task.result_data.get("output") if task.result_data else None, - "error": task.error_message, - } - - -@app.delete("/api/tasks/{task_id}") -async def delete_task( - task_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Supprime une tâche (soft delete non implémenté pour tasks, suppression directe)""" - repo = TaskRepository(db_session) - task = await repo.get(task_id) - if not task: - raise HTTPException(status_code=404, detail="Tâche non trouvée") - - await db_session.delete(task) - await db_session.commit() - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "task_deleted", - "data": {"id": task_id} - }) - - return {"message": "Tâche supprimée avec succès"} - -@app.get("/api/logs") -async def get_logs( - limit: int = 50, - offset: int = 0, - level: Optional[str] = None, - source: Optional[str] = None, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Récupère les logs récents avec filtrage optionnel""" - repo = LogRepository(db_session) - logs = await repo.list(limit=limit, offset=offset, level=level, source=source) - return [ - { - "id": log.id, - "timestamp": log.created_at, - "level": log.level, - "message": log.message, - "source": log.source, - "host": log.host_id, - } - for log in logs - ] - - -@app.post("/api/logs") -async def create_log( - level: str, - message: str, - source: Optional[str] = None, - host_id: Optional[str] = None, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Ajoute une nouvelle entrée de log""" - repo = LogRepository(db_session) - log = await repo.create( - level=level.upper(), - message=message, - source=source, - host_id=host_id, - ) - await db_session.commit() - - response_data = { - "id": log.id, - "timestamp": log.created_at, - "level": log.level, - "message": log.message, - "source": log.source, - "host": log.host_id, - } - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "new_log", - "data": response_data - }) - - return response_data - - -@app.delete("/api/logs") -async def clear_logs( - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Efface tous les logs (attention: opération destructive)""" - from sqlalchemy import delete - from models.log import Log as LogModel - await db_session.execute(delete(LogModel)) - await db_session.commit() - return {"message": "Tous les logs ont été supprimés"} - -@app.get("/api/metrics", response_model=SystemMetrics) -async def get_metrics(api_key_valid: bool = Depends(verify_api_key)): - """Récupère les métriques système calculées dynamiquement""" - return db.metrics - - -@app.post("/api/hosts/refresh") -async def refresh_hosts(api_key_valid: bool = Depends(verify_api_key)): - """Force le rechargement des hôtes depuis l'inventaire Ansible""" - ansible_service.invalidate_cache() # Clear ansible inventory cache first - hosts = db.refresh_hosts() - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "hosts_refreshed", - "data": {"count": len(hosts)} - }) - - return {"message": f"{len(hosts)} hôtes rechargés depuis l'inventaire Ansible"} - - -# ===== ENDPOINTS ANSIBLE ===== - -@app.get("/api/ansible/playbooks") -async def get_ansible_playbooks( - target: Optional[str] = None, - api_key_valid: bool = Depends(verify_api_key) -): - """Liste les playbooks Ansible disponibles avec leurs catégories. - - Args: - target: Filtrer les playbooks compatibles avec cet hôte ou groupe (optionnel) - """ - if target: - playbooks = ansible_service.get_compatible_playbooks(target) - else: - playbooks = ansible_service.get_playbooks() - - return { - "playbooks": playbooks, - "categories": ansible_service.get_playbook_categories(), - "ansible_dir": str(ANSIBLE_DIR), - "filter": target - } - -@app.get("/api/ansible/inventory") -async def get_ansible_inventory( - group: Optional[str] = None, - api_key_valid: bool = Depends(verify_api_key) -): - """Récupère l'inventaire Ansible avec les hôtes et groupes. - - Args: - group: Filtrer les hôtes par groupe (optionnel) - """ - return { - "hosts": [h.dict() for h in ansible_service.get_hosts_from_inventory(group_filter=group)], - "groups": ansible_service.get_groups(), - "inventory_path": str(ansible_service.inventory_path), - "filter": group - } - -@app.post("/api/ansible/execute") -async def execute_ansible_playbook( - request: AnsibleExecutionRequest, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db) -): - """Exécute un playbook Ansible directement avec validation de compatibilité""" - start_time_dt = datetime.now(timezone.utc) - - # Valider la compatibilité playbook-target - playbooks = ansible_service.get_playbooks() - playbook_info = next((pb for pb in playbooks if pb['filename'] == request.playbook or pb['name'] == request.playbook.replace('.yml', '').replace('.yaml', '')), None) - - if playbook_info: - playbook_hosts = playbook_info.get('hosts', 'all') - if not ansible_service.is_target_compatible_with_playbook(request.target, playbook_hosts): - raise HTTPException( - status_code=400, - detail=f"Le playbook '{request.playbook}' (hosts: {playbook_hosts}) n'est pas compatible avec la cible '{request.target}'. " - f"Ce playbook ne peut être exécuté que sur: {playbook_hosts}" - ) - - # Créer une tâche en BD - task_repo = TaskRepository(db_session) - task_id = f"pb_{uuid.uuid4().hex[:12]}" - playbook_name = request.playbook.replace('.yml', '').replace('-', ' ').title() - - db_task = await task_repo.create( - id=task_id, - action=f"playbook:{request.playbook}", - target=request.target, - playbook=request.playbook, - status="running", - ) - await task_repo.update(db_task, started_at=start_time_dt) - await db_session.commit() - - # Créer aussi en mémoire pour la compatibilité - task = Task( - id=task_id, - name=f"Playbook: {playbook_name}", - host=request.target, - status="running", - progress=0, - start_time=start_time_dt - ) - db.tasks.insert(0, task) - - try: - result = await ansible_service.execute_playbook( - playbook=request.playbook, - target=request.target, - extra_vars=request.extra_vars, - check_mode=request.check_mode, - verbose=request.verbose - ) - - # Mettre à jour la tâche - task.status = "completed" if result["success"] else "failed" - task.progress = 100 - task.end_time = datetime.now(timezone.utc) - task.duration = f"{result.get('execution_time', 0):.1f}s" - task.output = result.get("stdout", "") - task.error = result.get("stderr", "") if not result["success"] else None - - # Ajouter un log - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="INFO" if result["success"] else "ERROR", - message=f"Playbook {request.playbook} exécuté sur {request.target}: {'succès' if result['success'] else 'échec'}", - source="ansible", - host=request.target - ) - db.logs.insert(0, log_entry) - - # Sauvegarder le log markdown - try: - task_log_service.save_task_log( - task=task, - output=result.get("stdout", ""), - error=result.get("stderr", "") - ) - except Exception as log_error: - print(f"Erreur sauvegarde log markdown: {log_error}") - - await ws_manager.broadcast({ - "type": "ansible_execution", - "data": result - }) - - # Mettre à jour la BD - await task_repo.update( - db_task, - status=task.status, - completed_at=task.end_time, - error_message=task.error, - result_data={"output": result.get("stdout", "")[:5000]} - ) - await db_session.commit() - - # Envoyer notification ntfy (non-bloquant) - if result["success"]: - asyncio.create_task(notification_service.notify_task_completed( - task_name=task.name, - target=request.target, - duration=task.duration - )) - else: - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=request.target, - error=result.get("stderr", "Erreur inconnue")[:200] - )) - - # Ajouter task_id au résultat - result["task_id"] = task_id - - return result - except FileNotFoundError as e: - task.status = "failed" - task.end_time = datetime.now(timezone.utc) - task.error = str(e) - task_log_service.save_task_log(task=task, error=str(e)) - await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=str(e)) - await db_session.commit() - # Envoyer notification ntfy (non-bloquant) - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=request.target, - error=str(e)[:200] - )) - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - task.status = "failed" - task.end_time = datetime.now(timezone.utc) - task.error = str(e) - task_log_service.save_task_log(task=task, error=str(e)) - await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=str(e)) - await db_session.commit() - # Envoyer notification ntfy (non-bloquant) - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=request.target, - error=str(e)[:200] - )) - raise HTTPException(status_code=500, detail=str(e)) - -@app.get("/api/ansible/groups") -async def get_ansible_groups(api_key_valid: bool = Depends(verify_api_key)): - """Récupère la liste des groupes Ansible""" - return {"groups": ansible_service.get_groups()} - - -# ===== ENDPOINTS PLAYBOOKS CRUD ===== - -class PlaybookContentRequest(BaseModel): - """Requête pour sauvegarder le contenu d'un playbook""" - content: str = Field(..., description="Contenu YAML du playbook") - - -@app.get("/api/playbooks/{filename}/content") -async def get_playbook_content( - filename: str, - api_key_valid: bool = Depends(verify_api_key) -): - """Récupère le contenu d'un playbook""" - playbook_path = ansible_service.playbooks_dir / filename - - # Vérifier les extensions valides - if not filename.endswith(('.yml', '.yaml')): - raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml") - - if not playbook_path.exists(): - raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}") - - # Vérifier que le fichier est bien dans le répertoire playbooks (sécurité) - try: - playbook_path.resolve().relative_to(ansible_service.playbooks_dir.resolve()) - except ValueError: - raise HTTPException(status_code=403, detail="Accès non autorisé") - - try: - content = playbook_path.read_text(encoding='utf-8') - stat = playbook_path.stat() - return { - "filename": filename, - "content": content, - "size": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erreur lecture fichier: {str(e)}") - - -@app.put("/api/playbooks/{filename}/content") -async def save_playbook_content( - filename: str, - request: PlaybookContentRequest, - api_key_valid: bool = Depends(verify_api_key) -): - """Sauvegarde le contenu d'un playbook (création ou modification)""" - # Vérifier les extensions valides - if not filename.endswith(('.yml', '.yaml')): - raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml") - - # Valider le nom de fichier (sécurité) - import re - if not re.match(r'^[a-zA-Z0-9_-]+\.(yml|yaml)$', filename): - raise HTTPException(status_code=400, detail="Nom de fichier invalide") - - playbook_path = ansible_service.playbooks_dir / filename - - # S'assurer que le répertoire existe - ansible_service.playbooks_dir.mkdir(parents=True, exist_ok=True) - - # Valider le contenu YAML - try: - parsed = yaml.safe_load(request.content) - if parsed is None: - raise HTTPException(status_code=400, detail="Contenu YAML vide ou invalide") - except yaml.YAMLError as e: - raise HTTPException(status_code=400, detail=f"Erreur de syntaxe YAML: {str(e)}") - - is_new = not playbook_path.exists() - - try: - playbook_path.write_text(request.content, encoding='utf-8') - stat = playbook_path.stat() - - # Log l'action - action = "créé" if is_new else "modifié" - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="INFO", - message=f"Playbook {filename} {action}", - source="playbook_editor" - ) - db.logs.insert(0, log_entry) - - return { - "success": True, - "message": f"Playbook {filename} {'créé' if is_new else 'sauvegardé'} avec succès", - "filename": filename, - "size": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), - "is_new": is_new - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erreur sauvegarde fichier: {str(e)}") - - -@app.delete("/api/playbooks/{filename}") -async def delete_playbook( - filename: str, - api_key_valid: bool = Depends(verify_api_key) -): - """Supprime un playbook""" - # Vérifier les extensions valides - if not filename.endswith(('.yml', '.yaml')): - raise HTTPException(status_code=400, detail="Extension de fichier invalide") - - playbook_path = ansible_service.playbooks_dir / filename - - if not playbook_path.exists(): - raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}") - - # Vérifier que le fichier est bien dans le répertoire playbooks (sécurité) - try: - playbook_path.resolve().relative_to(ansible_service.playbooks_dir.resolve()) - except ValueError: - raise HTTPException(status_code=403, detail="Accès non autorisé") - - try: - playbook_path.unlink() - - # Log l'action - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="WARN", - message=f"Playbook {filename} supprimé", - source="playbook_editor" - ) - db.logs.insert(0, log_entry) - - return { - "success": True, - "message": f"Playbook {filename} supprimé avec succès" - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erreur suppression fichier: {str(e)}") - - -@app.get("/api/ansible/ssh-config") -async def get_ssh_config(api_key_valid: bool = Depends(verify_api_key)): - """Diagnostic de la configuration SSH pour le bootstrap""" - ssh_key_path = Path(SSH_KEY_PATH) - ssh_dir = ssh_key_path.parent - - # Lister les fichiers dans le répertoire SSH - available_files = [] - if ssh_dir.exists(): - available_files = [f.name for f in ssh_dir.iterdir()] - - # Vérifier les clés - private_key_exists = ssh_key_path.exists() - public_key_exists = Path(SSH_KEY_PATH + ".pub").exists() - - # Chercher d'autres clés publiques - pub_keys_found = [] - for ext in [".pub"]: - for key_type in ["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]: - key_path = ssh_dir / f"{key_type}{ext}" - if key_path.exists(): - pub_keys_found.append(str(key_path)) - - # Trouver la clé privée qui sera utilisée - active_private_key = find_ssh_private_key() - - return { - "ssh_key_path": SSH_KEY_PATH, - "ssh_dir": str(ssh_dir), - "ssh_dir_exists": ssh_dir.exists(), - "private_key_exists": private_key_exists, - "public_key_exists": public_key_exists, - "available_files": available_files, - "public_keys_found": pub_keys_found, - "active_private_key": active_private_key, - "ssh_user": SSH_USER, - "sshpass_available": shutil.which("sshpass") is not None, - } - - -@app.post("/api/ansible/adhoc", response_model=AdHocCommandResult) -async def execute_adhoc_command( - request: AdHocCommandRequest, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db) -): - """Exécute une commande ad-hoc Ansible sur un ou plusieurs hôtes. - - Exemples: - - Lister les fichiers: {"target": "all", "command": "ls -la /tmp"} - - Vérifier l'espace disque: {"target": "proxmox", "command": "df -h", "become": true} - - Redémarrer un service: {"target": "web-servers", "command": "systemctl restart nginx", "become": true} - """ - start_time_perf = perf_counter() - start_time_dt = datetime.now(timezone.utc) - - # Créer une tâche en BD - task_repo = TaskRepository(db_session) - task_id = f"adhoc_{uuid.uuid4().hex[:12]}" - task_name = f"Ad-hoc: {request.command[:40]}{'...' if len(request.command) > 40 else ''}" - - db_task = await task_repo.create( - id=task_id, - action=f"adhoc:{request.module}", - target=request.target, - playbook=None, - status="running", - ) - await task_repo.update(db_task, started_at=start_time_dt) - await db_session.commit() - - # Créer aussi en mémoire pour la compatibilité - task = Task( - id=task_id, - name=task_name, - host=request.target, - status="running", - progress=0, - start_time=start_time_dt - ) - db.tasks.insert(0, task) - - # Construire la commande ansible - ansible_cmd = [ - "ansible", - request.target, - "-i", str(ANSIBLE_DIR / "inventory" / "hosts.yml"), - "-m", request.module, - "-a", request.command, - "--timeout", str(request.timeout), - ] - - # Ajouter les options - if request.become: - ansible_cmd.append("--become") - - private_key = find_ssh_private_key() - if private_key: - ansible_cmd.extend(["--private-key", private_key]) - - if SSH_USER: - ansible_cmd.extend(["-u", SSH_USER]) - - try: - result = subprocess.run( - ansible_cmd, - capture_output=True, - text=True, - timeout=request.timeout + 10, - cwd=str(ANSIBLE_DIR) - ) - - duration = perf_counter() - start_time_perf - success = result.returncode == 0 - - # Mettre à jour la tâche - task.status = "completed" if success else "failed" - task.progress = 100 - task.end_time = datetime.now(timezone.utc) - task.duration = f"{round(duration, 2)}s" - task.output = result.stdout - task.error = result.stderr if result.stderr else None - - # Sauvegarder le log de tâche en markdown (commande ad-hoc) - task_log_service.save_task_log(task, output=result.stdout, error=result.stderr or "", source_type='adhoc') - - # Log de l'exécution - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="INFO" if success else "WARN", - message=f"Ad-hoc [{request.module}] sur {request.target}: {request.command[:50]}{'...' if len(request.command) > 50 else ''}", - source="ansible-adhoc", - host=request.target - ) - db.logs.insert(0, log_entry) - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "adhoc_executed", - "data": { - "target": request.target, - "command": request.command, - "success": success, - "task_id": task_id - } - }) - - # Sauvegarder dans l'historique des commandes ad-hoc (pour réutilisation) - await adhoc_history_service.add_command( - command=request.command, - target=request.target, - module=request.module, - become=request.become, - category=request.category or "default" - ) - - # Mettre à jour la BD - await task_repo.update( - db_task, - status=task.status, - completed_at=task.end_time, - error_message=task.error, - result_data={"output": result.stdout[:5000] if result.stdout else None} - ) - await db_session.commit() - - # Envoyer notification ntfy (non-bloquant) - if success: - asyncio.create_task(notification_service.notify_task_completed( - task_name=task.name, - target=request.target, - duration=task.duration - )) - else: - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=request.target, - error=(result.stderr or "Erreur inconnue")[:200] - )) - - return AdHocCommandResult( - target=request.target, - command=request.command, - success=success, - return_code=result.returncode, - stdout=result.stdout, - stderr=result.stderr if result.stderr else None, - duration=round(duration, 2) - ) - - except subprocess.TimeoutExpired: - duration = perf_counter() - start_time_perf - # Mettre à jour la tâche en échec - task.status = "failed" - task.progress = 100 - task.end_time = datetime.now(timezone.utc) - task.duration = f"{round(duration, 2)}s" - task.error = f"Timeout après {request.timeout} secondes" - - # Sauvegarder le log de tâche (ad-hoc timeout) - task_log_service.save_task_log(task, error=task.error, source_type='adhoc') - - # Mettre à jour la BD - await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=task.error) - await db_session.commit() - - # Envoyer notification ntfy (non-bloquant) - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=request.target, - error=task.error[:200] - )) - - return AdHocCommandResult( - target=request.target, - command=request.command, - success=False, - return_code=-1, - stdout="", - stderr=f"Timeout après {request.timeout} secondes", - duration=round(duration, 2) - ) - except FileNotFoundError: - duration = perf_counter() - start_time_perf - error_msg = "ansible non trouvé. Vérifiez que Ansible est installé et accessible." - # Mettre à jour la tâche en échec - task.status = "failed" - task.progress = 100 - task.end_time = datetime.now(timezone.utc) - task.duration = f"{round(duration, 2)}s" - task.error = error_msg - - # Sauvegarder le log de tâche (ad-hoc file not found) - task_log_service.save_task_log(task, error=error_msg, source_type='adhoc') - - # Mettre à jour la BD - await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=error_msg) - await db_session.commit() - - # Envoyer notification ntfy (non-bloquant) - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=request.target, - error=error_msg[:200] - )) - - return AdHocCommandResult( - target=request.target, - command=request.command, - success=False, - return_code=-1, - stdout="", - stderr=error_msg, - duration=round(duration, 2) - ) - except Exception as e: - duration = perf_counter() - start_time_perf - error_msg = f"Erreur interne: {str(e)}" - # Mettre à jour la tâche en échec - task.status = "failed" - task.progress = 100 - task.end_time = datetime.now(timezone.utc) - task.duration = f"{round(duration, 2)}s" - task.error = error_msg - - # Sauvegarder le log de tâche (ad-hoc exception) - task_log_service.save_task_log(task, error=error_msg, source_type='adhoc') - - # Mettre à jour la BD - await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=error_msg) - await db_session.commit() - - # Envoyer notification ntfy (non-bloquant) - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=request.target, - error=error_msg[:200] - )) - - # Return a proper result instead of raising HTTP 500 - return AdHocCommandResult( - target=request.target, - command=request.command, - success=False, - return_code=-1, - stdout="", - stderr=error_msg, - duration=round(duration, 2) - ) - - -@app.post("/api/ansible/bootstrap", response_model=CommandResult) -async def bootstrap_ansible_host( - request: BootstrapRequest, - api_key_valid: bool = Depends(verify_api_key) -): - """Bootstrap un hôte pour Ansible. - - Cette opération: - 1. Se connecte à l'hôte via SSH avec le mot de passe root - 2. Crée l'utilisateur d'automatisation (par défaut: automation) - 3. Configure la clé SSH publique pour l'authentification sans mot de passe - 4. Installe et configure sudo pour cet utilisateur - 5. Installe Python3 (requis par Ansible) - 6. Vérifie la connexion SSH par clé - - Supporte: Debian/Ubuntu, Alpine Linux, FreeBSD - """ - import logging - import traceback - logger = logging.getLogger("bootstrap_endpoint") - - try: - logger.info(f"Bootstrap request for host={request.host}, user={request.automation_user}") - result = bootstrap_host( - host=request.host, - root_password=request.root_password, - automation_user=request.automation_user - ) - logger.info(f"Bootstrap result: status={result.status}, return_code={result.return_code}") - - # Si le bootstrap a échoué (return_code != 0), lever une exception avec les détails - if result.return_code != 0: - raise HTTPException( - status_code=500, - detail={ - "status": result.status, - "return_code": result.return_code, - "stdout": result.stdout, - "stderr": result.stderr - } - ) - - # Trouver le nom de l'hôte (peut être IP ou hostname) - host_name = request.host - for h in db.hosts: - if h.ip == request.host or h.name == request.host: - host_name = h.name - break - - # Enregistrer le statut de bootstrap réussi - bootstrap_status_service.set_bootstrap_status( - host_name=host_name, - success=True, - details=f"Bootstrap réussi via API (user: {request.automation_user})" - ) - - # Invalider le cache des hôtes pour recharger avec le nouveau statut - db._hosts_cache = None - - # Ajouter un log de succès - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="INFO", - message=f"Bootstrap réussi pour {host_name} (user: {request.automation_user})", - source="bootstrap", - host=host_name - ) - db.logs.insert(0, log_entry) - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "bootstrap_success", - "data": { - "host": host_name, - "user": request.automation_user, - "status": "ok", - "bootstrap_ok": True - } - }) - - # Envoyer notification ntfy (non-bloquant) - asyncio.create_task(notification_service.notify_bootstrap_success(host_name)) - - return result - - except HTTPException as http_exc: - # Envoyer notification d'échec ntfy - error_detail = str(http_exc.detail) if http_exc.detail else "Erreur inconnue" - asyncio.create_task(notification_service.notify_bootstrap_failed( - hostname=request.host, - error=error_detail[:200] - )) - raise - except Exception as e: - logger.error(f"Bootstrap exception: {e}") - logger.error(traceback.format_exc()) - # Ajouter un log d'erreur - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="ERROR", - message=f"Échec bootstrap pour {request.host}: {str(e)}", - source="bootstrap", - host=request.host - ) - db.logs.insert(0, log_entry) - - # Envoyer notification d'échec ntfy - asyncio.create_task(notification_service.notify_bootstrap_failed( - hostname=request.host, - error=str(e)[:200] - )) - - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/api/health") -async def global_health_check(): - """Endpoint de healthcheck global utilisé par Docker. - - Ne nécessite pas de clé API pour permettre aux orchestrateurs - de vérifier l'état du service facilement. - """ - return { - "status": "ok", - "service": "homelab-automation-api", - "timestamp": datetime.now(timezone.utc).isoformat() - } - - -# ===== ENDPOINTS BOOTSTRAP STATUS ===== - -@app.get("/api/bootstrap/status") -async def get_all_bootstrap_status(api_key_valid: bool = Depends(verify_api_key)): - """Récupère le statut de bootstrap de tous les hôtes""" - return { - "hosts": bootstrap_status_service.get_all_status() - } - - -@app.get("/api/bootstrap/status/{host_name}") -async def get_host_bootstrap_status( - host_name: str, - api_key_valid: bool = Depends(verify_api_key) -): - """Récupère le statut de bootstrap d'un hôte spécifique""" - status = bootstrap_status_service.get_bootstrap_status(host_name) - return { - "host": host_name, - **status - } - - -@app.post("/api/bootstrap/status/{host_name}") -async def set_host_bootstrap_status( - host_name: str, - success: bool = True, - details: Optional[str] = None, - api_key_valid: bool = Depends(verify_api_key) -): - """Définit manuellement le statut de bootstrap d'un hôte""" - result = bootstrap_status_service.set_bootstrap_status( - host_name=host_name, - success=success, - details=details or f"Status défini manuellement" - ) - - # Invalider le cache des hôtes - db._hosts_cache = None - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "bootstrap_status_updated", - "data": { - "host": host_name, - "bootstrap_ok": success - } - }) - - return { - "host": host_name, - "status": "updated", - **result - } - - -# ===== ENDPOINTS HISTORIQUE AD-HOC ===== - -@app.get("/api/adhoc/history") -async def get_adhoc_history( - category: Optional[str] = None, - search: Optional[str] = None, - limit: int = 50, - api_key_valid: bool = Depends(verify_api_key) -): - """Récupère l'historique des commandes ad-hoc""" - commands = await adhoc_history_service.get_commands( - category=category, - search=search, - limit=limit, - ) - return { - "commands": [cmd.dict() for cmd in commands], - "count": len(commands) - } - - -@app.get("/api/adhoc/categories") -async def get_adhoc_categories(api_key_valid: bool = Depends(verify_api_key)): - """Récupère la liste des catégories de commandes ad-hoc""" - categories = await adhoc_history_service.get_categories() - return {"categories": [cat.dict() for cat in categories]} - - -@app.post("/api/adhoc/categories") -async def create_adhoc_category( - name: str, - description: Optional[str] = None, - color: str = "#7c3aed", - icon: str = "fa-folder", - api_key_valid: bool = Depends(verify_api_key) -): - """Crée une nouvelle catégorie de commandes ad-hoc""" - category = await adhoc_history_service.add_category(name, description, color, icon) - return {"category": category.dict(), "message": "Catégorie créée"} - - -@app.put("/api/adhoc/categories/{category_name}") -async def update_adhoc_category( - category_name: str, - request: Request, - api_key_valid: bool = Depends(verify_api_key) -): - """Met à jour une catégorie existante""" - try: - data = await request.json() - new_name = data.get("name", category_name) - description = data.get("description", "") - color = data.get("color", "#7c3aed") - icon = data.get("icon", "fa-folder") - - success = await adhoc_history_service.update_category(category_name, new_name, description, color, icon) - if not success: - raise HTTPException(status_code=404, detail="Catégorie non trouvée") - return {"message": "Catégorie mise à jour", "category": new_name} - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.delete("/api/adhoc/categories/{category_name}") -async def delete_adhoc_category( - category_name: str, - api_key_valid: bool = Depends(verify_api_key) -): - """Supprime une catégorie et déplace ses commandes vers 'default'""" - if category_name == "default": - raise HTTPException(status_code=400, detail="La catégorie 'default' ne peut pas être supprimée") - - success = await adhoc_history_service.delete_category(category_name) - if not success: - raise HTTPException(status_code=404, detail="Catégorie non trouvée") - return {"message": "Catégorie supprimée", "category": category_name} - - -@app.put("/api/adhoc/history/{command_id}/category") -async def update_adhoc_command_category( - command_id: str, - category: str, - description: Optional[str] = None, - api_key_valid: bool = Depends(verify_api_key) -): - """Met à jour la catégorie d'une commande dans l'historique""" - success = await adhoc_history_service.update_command_category(command_id, category, description) - if not success: - raise HTTPException(status_code=404, detail="Commande non trouvée") - return {"message": "Catégorie mise à jour", "command_id": command_id, "category": category} - - -@app.delete("/api/adhoc/history/{command_id}") -async def delete_adhoc_command(command_id: str, api_key_valid: bool = Depends(verify_api_key)): - """Supprime une commande de l'historique""" - success = await adhoc_history_service.delete_command(command_id) - if not success: - raise HTTPException(status_code=404, detail="Commande non trouvée") - return {"message": "Commande supprimée", "command_id": command_id} - - -@app.get("/api/health/{host_name}", response_model=HealthCheck) -async def check_host_health(host_name: str, api_key_valid: bool = Depends(verify_api_key)): - """Effectue un health check sur un hôte spécifique et met à jour son last_seen""" - host = next((h for h in db.hosts if h.name == host_name), None) - if not host: - raise HTTPException(status_code=404, detail="Hôte non trouvé") - - # Simuler un health check à partir du statut actuel - health_check = HealthCheck( - host=host_name, - ssh_ok=host.status == "online", - ansible_ok=host.status == "online", - sudo_ok=host.status == "online", - reachable=host.status != "offline", - response_time=0.123 if host.status == "online" else None, - error_message=None if host.status != "offline" else "Hôte injoignable" - ) - - # Mettre à jour le statut runtime + persistant - new_status = "online" if health_check.reachable else "offline" - db.update_host_status(host_name, new_status, host.os) - - # Ajouter un log pour le health check - log_entry = LogEntry( - timestamp=datetime.now(timezone.utc), - level="INFO" if health_check.reachable else "ERROR", - message=f"Health check {'réussi' if health_check.reachable else 'échoué'} pour {host_name}", - source="health_check", - host=host_name - ) - - db.logs.insert(0, log_entry) - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "health_check", - "data": health_check.dict() - }) - - return health_check - -# WebSocket pour les mises à jour en temps réel -@app.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket): - await ws_manager.connect(websocket) - try: - while True: - # Garder la connexion ouverte - data = await websocket.receive_text() - # Traiter les messages entrants si nécessaire - except WebSocketDisconnect: - ws_manager.disconnect(websocket) - -# Fonctions utilitaires -async def simulate_task_execution(task_id: str): - """Simule l'exécution d'une tâche en arrière-plan""" - task = next((t for t in db.tasks if str(t.id) == str(task_id)), None) - if not task: - return - - try: - # Simuler la progression - for progress in range(0, 101, 10): - task.progress = progress - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "task_progress", - "data": { - "id": task_id, - "progress": progress - } - }) - - await asyncio.sleep(0.5) # Attendre 500ms entre chaque mise à jour - - # Marquer la tâche comme terminée - task.status = "completed" - task.end_time = datetime.now(timezone.utc) - task.duration = "5s" - - # Ajouter un log - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="INFO", - message=f"Tâche '{task.name}' terminée avec succès sur {task.host}", - source="task_manager", - host=task.host - ) - db.logs.insert(0, log_entry) - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "task_completed", - "data": { - "id": task_id, - "status": "completed", - "progress": 100 - } - }) - - # Sauvegarder le log markdown - try: - task_log_service.save_task_log(task=task, output="Tâche simulée terminée avec succès") - except Exception as log_error: - print(f"Erreur sauvegarde log markdown: {log_error}") - - except asyncio.CancelledError: - # Tâche annulée - task.status = "cancelled" - task.end_time = datetime.now(timezone.utc) - task.error = "Tâche annulée par l'utilisateur" - - await ws_manager.broadcast({ - "type": "task_cancelled", - "data": { - "id": task_id, - "status": "cancelled", - "message": "Tâche annulée par l'utilisateur" - } - }) - - finally: - # Nettoyer le handle de la tâche - if str(task_id) in running_task_handles: - del running_task_handles[str(task_id)] - - -async def execute_ansible_task( - task_id: str, - playbook: str, - target: str, - extra_vars: Optional[Dict[str, Any]] = None, - check_mode: bool = False -): - """Exécute un playbook Ansible pour une tâche""" - task = next((t for t in db.tasks if str(t.id) == str(task_id)), None) - if not task: - return - - # Notifier le début - task.progress = 10 - await ws_manager.broadcast({ - "type": "task_progress", - "data": {"id": task_id, "progress": 10, "message": "Démarrage du playbook Ansible..."} - }) - - start_time = perf_counter() - - try: - # Exécuter le playbook - result = await ansible_service.execute_playbook( - playbook=playbook, - target=target, - extra_vars=extra_vars, - check_mode=check_mode, - verbose=True - ) - - execution_time = perf_counter() - start_time - - # Mettre à jour la tâche - task.progress = 100 - task.status = "completed" if result["success"] else "failed" - task.end_time = datetime.now(timezone.utc) - task.duration = f"{execution_time:.1f}s" - task.output = result.get("stdout", "") - task.error = result.get("stderr", "") if not result["success"] else None - - # Si c'est un health-check ciblé, mettre à jour le statut/last_seen de l'hôte - if "health-check" in playbook and target and target != "all": - try: - new_status = "online" if result["success"] else "offline" - db.update_host_status(target, new_status) - except Exception: - # Ne pas interrompre la gestion de la tâche si la MAJ de statut échoue - pass - - # Ajouter un log - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="INFO" if result["success"] else "ERROR", - message=f"Tâche '{task.name}' {'terminée avec succès' if result['success'] else 'échouée'} sur {target}", - source="ansible", - host=target - ) - db.logs.insert(0, log_entry) - - # Notifier les clients WebSocket - await ws_manager.broadcast({ - "type": "task_completed", - "data": { - "id": task_id, - "status": task.status, - "progress": 100, - "duration": task.duration, - "success": result["success"], - "output": result.get("stdout", "")[:500] # Limiter la taille - } - }) - - # Envoyer notification ntfy (non-bloquant) - if result["success"]: - asyncio.create_task(notification_service.notify_task_completed( - task_name=task.name, - target=target, - duration=task.duration - )) - else: - asyncio.create_task(notification_service.notify_task_failed( - task_name=task.name, - target=target, - error=result.get("stderr", "Erreur inconnue")[:200] - )) - - # Sauvegarder le log markdown - try: - log_path = task_log_service.save_task_log( - task=task, - output=result.get("stdout", ""), - error=result.get("stderr", "") - ) - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="DEBUG", - message=f"Log de tâche sauvegardé: {log_path}", - source="task_log", - host=target - ) - db.logs.insert(0, log_entry) - except Exception as log_error: - print(f"Erreur sauvegarde log markdown: {log_error}") - - except Exception as e: - task.status = "failed" - task.end_time = datetime.now(timezone.utc) - task.error = str(e) - - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="ERROR", - message=f"Erreur lors de l'exécution de '{task.name}': {str(e)}", - source="ansible", - host=target - ) - db.logs.insert(0, log_entry) - - # Sauvegarder le log markdown même en cas d'échec - try: - task_log_service.save_task_log(task=task, error=str(e)) - except Exception: - pass - - await ws_manager.broadcast({ - "type": "task_failed", - "data": { - "id": task_id, - "status": "failed", - "error": str(e) - } - }) - - except asyncio.CancelledError: - # Tâche annulée par l'utilisateur - task.status = "cancelled" - task.end_time = datetime.now(timezone.utc) - task.error = "Tâche annulée par l'utilisateur" - - log_entry = LogEntry( - id=db.get_next_id("logs"), - timestamp=datetime.now(timezone.utc), - level="WARNING", - message=f"Tâche '{task.name}' annulée par l'utilisateur", - source="ansible", - host=target - ) - db.logs.insert(0, log_entry) - - await ws_manager.broadcast({ - "type": "task_cancelled", - "data": { - "id": task_id, - "status": "cancelled", - "message": "Tâche annulée par l'utilisateur" - } - }) - - finally: - # Nettoyer le handle de la tâche - if str(task_id) in running_task_handles: - del running_task_handles[str(task_id)] - - # Mettre à jour la BD avec le statut final - try: - async with async_session_maker() as session: - from app.crud.task import TaskRepository - repo = TaskRepository(session) - db_task = await repo.get(task_id) - if db_task: - await repo.update( - db_task, - status=task.status if task else "failed", - completed_at=datetime.now(timezone.utc), - error_message=task.error if task else None, - result_data={"output": task.output[:5000] if task and task.output else None} - ) - await session.commit() - except Exception as db_error: - print(f"Erreur mise à jour BD pour tâche {task_id}: {db_error}") - - -# ===== ENDPOINTS PLANIFICATEUR (SCHEDULER) ===== - -@app.get("/api/schedules") -async def get_schedules( - enabled: Optional[bool] = None, - playbook: Optional[str] = None, - tag: Optional[str] = None, - limit: int = 100, - offset: int = 0, - api_key_valid: bool = Depends(verify_api_key), -): - """Liste tous les schedules avec filtrage optionnel (via SchedulerService).""" - # Utiliser le SchedulerService comme source de vérité pour next_run_at / last_run_at - schedules = scheduler_service.get_all_schedules( - enabled=enabled, - playbook=playbook, - tag=tag, - ) - - # Pagination simple côté API (les schedules sont déjà triés par next_run_at) - paginated = schedules[offset : offset + limit] - - results = [] - for s in paginated: - rec = s.recurrence - results.append( - { - "id": s.id, - "name": s.name, - "playbook": s.playbook, - "target": s.target, - "schedule_type": s.schedule_type, - "recurrence": rec.model_dump() if rec else None, - "enabled": s.enabled, - "notification_type": getattr(s, 'notification_type', 'all'), - "tags": s.tags, - # Champs utilisés par le frontend pour "Prochaine" et historique - "next_run_at": s.next_run_at, - "last_run_at": s.last_run_at, - "last_status": s.last_status, - "run_count": s.run_count, - "success_count": s.success_count, - "failure_count": s.failure_count, - "created_at": s.created_at, - "updated_at": s.updated_at, - } - ) - - return {"schedules": results, "count": len(schedules)} - - -@app.post("/api/schedules") -async def create_schedule( - request: ScheduleCreateRequest, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Crée un nouveau schedule (stocké en DB) avec validation de compatibilité playbook-target""" - # Vérifier que le playbook existe - playbooks = ansible_service.get_playbooks() - playbook_names = [p['filename'] for p in playbooks] + [p['name'] for p in playbooks] - - playbook_file = request.playbook - if not playbook_file.endswith(('.yml', '.yaml')): - playbook_file = f"{playbook_file}.yml" - - if playbook_file not in playbook_names and request.playbook not in playbook_names: - raise HTTPException(status_code=400, detail=f"Playbook '{request.playbook}' non trouvé") - - # Récupérer les infos du playbook pour validation - playbook_info = next((pb for pb in playbooks if pb['filename'] == playbook_file or pb['name'] == request.playbook), None) - - # Vérifier la cible - if request.target_type == "group": - groups = ansible_service.get_groups() - if request.target not in groups and request.target != "all": - raise HTTPException(status_code=400, detail=f"Groupe '{request.target}' non trouvé") - else: - if not ansible_service.host_exists(request.target): - raise HTTPException(status_code=400, detail=f"Hôte '{request.target}' non trouvé") - - # Valider la compatibilité playbook-target - if playbook_info: - playbook_hosts = playbook_info.get('hosts', 'all') - if not ansible_service.is_target_compatible_with_playbook(request.target, playbook_hosts): - raise HTTPException( - status_code=400, - detail=f"Le playbook '{request.playbook}' (hosts: {playbook_hosts}) n'est pas compatible avec la cible '{request.target}'. " - f"Ce playbook ne peut être exécuté que sur: {playbook_hosts}" - ) - - # Valider la récurrence - if request.schedule_type == "recurring" and not request.recurrence: - raise HTTPException(status_code=400, detail="La récurrence est requise pour un schedule récurrent") - - if request.recurrence and request.recurrence.type == "custom": - if not request.recurrence.cron_expression: - raise HTTPException(status_code=400, detail="Expression cron requise pour le type 'custom'") - validation = scheduler_service.validate_cron_expression(request.recurrence.cron_expression) - if not validation["valid"]: - raise HTTPException(status_code=400, detail=f"Expression cron invalide: {validation.get('error')}") - - # Créer en DB - repo = ScheduleRepository(db_session) - schedule_id = f"sched_{uuid.uuid4().hex[:12]}" - - recurrence = request.recurrence - schedule_obj = await repo.create( - id=schedule_id, - name=request.name, - description=request.description, - playbook=playbook_file, - target_type=request.target_type, - target=request.target, - extra_vars=request.extra_vars, - schedule_type=request.schedule_type, - schedule_time=request.start_at, - recurrence_type=recurrence.type if recurrence else None, - recurrence_time=recurrence.time if recurrence else None, - recurrence_days=json.dumps(recurrence.days) if recurrence and recurrence.days else None, - cron_expression=recurrence.cron_expression if recurrence else None, - timezone=request.timezone, - start_at=request.start_at, - end_at=request.end_at, - enabled=request.enabled, - retry_on_failure=request.retry_on_failure, - timeout=request.timeout, - notification_type=request.notification_type, - tags=json.dumps(request.tags) if request.tags else None, - ) - await db_session.commit() - - # Créer le schedule Pydantic et l'ajouter au cache du scheduler - pydantic_schedule = Schedule( - id=schedule_id, - name=request.name, - description=request.description, - playbook=playbook_file, - target_type=request.target_type, - target=request.target, - extra_vars=request.extra_vars, - schedule_type=request.schedule_type, - recurrence=request.recurrence, - timezone=request.timezone, - start_at=request.start_at, - end_at=request.end_at, - enabled=request.enabled, - retry_on_failure=request.retry_on_failure, - timeout=request.timeout, - notification_type=request.notification_type, - tags=request.tags or [], - ) - scheduler_service.add_schedule_to_cache(pydantic_schedule) - - # Log en DB - log_repo = LogRepository(db_session) - await log_repo.create( - level="INFO", - message=f"Schedule '{request.name}' créé pour {playbook_file} sur {request.target}", - source="scheduler", - ) - await db_session.commit() - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "schedule_created", - "data": { - "id": schedule_obj.id, - "name": schedule_obj.name, - "playbook": schedule_obj.playbook, - "target": schedule_obj.target, - } - }) - - return { - "success": True, - "message": f"Schedule '{request.name}' créé avec succès", - "schedule": { - "id": schedule_obj.id, - "name": schedule_obj.name, - "playbook": schedule_obj.playbook, - "target": schedule_obj.target, - "enabled": schedule_obj.enabled, - } - } - - -@app.get("/api/schedules/stats") -async def get_schedules_stats(api_key_valid: bool = Depends(verify_api_key)): - """Récupère les statistiques globales des schedules""" - stats = scheduler_service.get_stats() - upcoming = scheduler_service.get_upcoming_executions(limit=5) - - return { - "stats": stats.dict(), - "upcoming": upcoming - } - - -@app.get("/api/schedules/upcoming") -async def get_upcoming_schedules( - limit: int = 10, - api_key_valid: bool = Depends(verify_api_key) -): - """Récupère les prochaines exécutions planifiées""" - upcoming = scheduler_service.get_upcoming_executions(limit=limit) - return { - "upcoming": upcoming, - "count": len(upcoming) - } - - -@app.get("/api/schedules/validate-cron") -async def validate_cron_expression( - expression: str, - api_key_valid: bool = Depends(verify_api_key) -): - """Valide une expression cron et retourne les 5 prochaines exécutions""" - result = scheduler_service.validate_cron_expression(expression) - return result - - -@app.get("/api/schedules/{schedule_id}") -async def get_schedule( - schedule_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Récupère les détails d'un schedule spécifique (depuis DB)""" - repo = ScheduleRepository(db_session) - schedule = await repo.get(schedule_id) - if not schedule: - raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") - - return { - "id": schedule.id, - "name": schedule.name, - "playbook": schedule.playbook, - "target": schedule.target, - "schedule_type": schedule.schedule_type, - "recurrence_type": schedule.recurrence_type, - "recurrence_time": schedule.recurrence_time, - "recurrence_days": json.loads(schedule.recurrence_days) if schedule.recurrence_days else None, - "cron_expression": schedule.cron_expression, - "enabled": schedule.enabled, - "notification_type": schedule.notification_type or "all", - "tags": json.loads(schedule.tags) if schedule.tags else [], - "next_run": schedule.next_run, - "last_run": schedule.last_run, - "created_at": schedule.created_at, - "updated_at": schedule.updated_at, - } - - -@app.put("/api/schedules/{schedule_id}") -async def update_schedule( - schedule_id: str, - request: ScheduleUpdateRequest, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Met à jour un schedule existant (DB + scheduler_service)""" - # Essayer d'abord via SchedulerService (source de vérité) - sched = scheduler_service.get_schedule(schedule_id) - repo = ScheduleRepository(db_session) - schedule = await repo.get(schedule_id) - - if not sched and not schedule: - raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") - - schedule_name = sched.name if sched else schedule.name - - # Valider le playbook si modifié - if request.playbook: - playbooks = ansible_service.get_playbooks() - playbook_names = [p['filename'] for p in playbooks] + [p['name'] for p in playbooks] - playbook_file = request.playbook - if not playbook_file.endswith(('.yml', '.yaml')): - playbook_file = f"{playbook_file}.yml" - if playbook_file not in playbook_names and request.playbook not in playbook_names: - raise HTTPException(status_code=400, detail=f"Playbook '{request.playbook}' non trouvé") - - # Valider l'expression cron si modifiée - if request.recurrence and request.recurrence.type == "custom": - if request.recurrence.cron_expression: - validation = scheduler_service.validate_cron_expression(request.recurrence.cron_expression) - if not validation["valid"]: - raise HTTPException(status_code=400, detail=f"Expression cron invalide: {validation.get('error')}") - - # Mettre à jour en DB - update_fields = {} - if request.name: - update_fields["name"] = request.name - if request.description: - update_fields["description"] = request.description - if request.playbook: - update_fields["playbook"] = request.playbook - if request.target: - update_fields["target"] = request.target - if request.schedule_type: - update_fields["schedule_type"] = request.schedule_type - if request.timezone: - update_fields["timezone"] = request.timezone - if request.enabled is not None: - update_fields["enabled"] = request.enabled - if request.retry_on_failure is not None: - update_fields["retry_on_failure"] = request.retry_on_failure - if request.timeout is not None: - update_fields["timeout"] = request.timeout - if request.notification_type: - update_fields["notification_type"] = request.notification_type - if request.tags: - update_fields["tags"] = json.dumps(request.tags) - if request.recurrence: - update_fields["recurrence_type"] = request.recurrence.type - update_fields["recurrence_time"] = request.recurrence.time - update_fields["recurrence_days"] = json.dumps(request.recurrence.days) if request.recurrence.days else None - update_fields["cron_expression"] = request.recurrence.cron_expression - - # Mettre à jour en DB si présent - if schedule: - await repo.update(schedule, **update_fields) - await db_session.commit() - - # Aussi mettre à jour dans scheduler_service pour APScheduler - scheduler_service.update_schedule(schedule_id, request) - - # Log en DB - log_repo = LogRepository(db_session) - await log_repo.create( - level="INFO", - message=f"Schedule '{schedule_name}' mis à jour", - source="scheduler", - ) - await db_session.commit() - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "schedule_updated", - "data": {"id": schedule_id, "name": schedule_name} - }) - - return { - "success": True, - "message": f"Schedule '{schedule_name}' mis à jour", - "schedule": {"id": schedule_id, "name": schedule_name} - } - - -@app.delete("/api/schedules/{schedule_id}") -async def delete_schedule( - schedule_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Supprime un schedule (soft delete en DB + suppression scheduler_service)""" - repo = ScheduleRepository(db_session) - schedule = await repo.get(schedule_id) - if not schedule: - # Aucun enregistrement en DB, mais on tente tout de même de le supprimer - # du SchedulerService (cas des anciens IDs internes du scheduler). - try: - scheduler_service.delete_schedule(schedule_id) - except Exception: - pass - return { - "success": True, - "message": f"Schedule '{schedule_id}' déjà supprimé ou inexistant en base, nettoyage scheduler effectué." - } - - schedule_name = schedule.name - - # Soft delete en DB - await repo.soft_delete(schedule_id) - await db_session.commit() - - # Supprimer du scheduler_service - scheduler_service.delete_schedule(schedule_id) - - # Log en DB - log_repo = LogRepository(db_session) - await log_repo.create( - level="WARN", - message=f"Schedule '{schedule_name}' supprimé", - source="scheduler", - ) - await db_session.commit() - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "schedule_deleted", - "data": {"id": schedule_id, "name": schedule_name} - }) - - return { - "success": True, - "message": f"Schedule '{schedule_name}' supprimé" - } - - -@app.post("/api/schedules/{schedule_id}/run") -async def run_schedule_now( - schedule_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Exécute immédiatement un schedule (exécution forcée)""" - # Essayer d'abord via SchedulerService (source de vérité) - sched = scheduler_service.get_schedule(schedule_id) - if not sched: - # Fallback sur la DB - repo = ScheduleRepository(db_session) - schedule = await repo.get(schedule_id) - if not schedule: - raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") - schedule_name = schedule.name - else: - schedule_name = sched.name - - # Lancer l'exécution via scheduler_service - run = await scheduler_service.run_now(schedule_id) - - return { - "success": True, - "message": f"Schedule '{schedule_name}' lancé", - "run": run.dict() if run else None - } - - -@app.post("/api/schedules/{schedule_id}/pause") -async def pause_schedule( - schedule_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Met en pause un schedule""" - # Essayer d'abord via SchedulerService (source de vérité) - sched = scheduler_service.get_schedule(schedule_id) - repo = ScheduleRepository(db_session) - schedule = await repo.get(schedule_id) - - if not sched and not schedule: - raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") - - schedule_name = sched.name if sched else schedule.name - - # Mettre à jour en DB si présent - if schedule: - await repo.update(schedule, enabled=False) - await db_session.commit() - - # Mettre à jour dans scheduler_service - scheduler_service.pause_schedule(schedule_id) - - # Log en DB - log_repo = LogRepository(db_session) - await log_repo.create( - level="INFO", - message=f"Schedule '{schedule_name}' mis en pause", - source="scheduler", - ) - await db_session.commit() - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "schedule_updated", - "data": {"id": schedule_id, "name": schedule_name, "enabled": False} - }) - - return { - "success": True, - "message": f"Schedule '{schedule_name}' mis en pause", - "schedule": {"id": schedule_id, "name": schedule_name, "enabled": False} - } - - -@app.post("/api/schedules/{schedule_id}/resume") -async def resume_schedule( - schedule_id: str, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Reprend un schedule en pause""" - # Essayer d'abord via SchedulerService (source de vérité) - sched = scheduler_service.get_schedule(schedule_id) - repo = ScheduleRepository(db_session) - schedule = await repo.get(schedule_id) - - if not sched and not schedule: - raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") - - schedule_name = sched.name if sched else schedule.name - - # Mettre à jour en DB si présent - if schedule: - await repo.update(schedule, enabled=True) - await db_session.commit() - - # Mettre à jour dans scheduler_service - scheduler_service.resume_schedule(schedule_id) - - # Log en DB - log_repo = LogRepository(db_session) - await log_repo.create( - level="INFO", - message=f"Schedule '{schedule_name}' repris", - source="scheduler", - ) - await db_session.commit() - - # Notifier via WebSocket - await ws_manager.broadcast({ - "type": "schedule_updated", - "data": {"id": schedule_id, "name": schedule_name, "enabled": True} - }) - - return { - "success": True, - "message": f"Schedule '{schedule_name}' repris", - "schedule": {"id": schedule_id, "name": schedule_name, "enabled": True} - } - - -@app.get("/api/schedules/{schedule_id}/runs") -async def get_schedule_runs( - schedule_id: str, - limit: int = 50, - offset: int = 0, - api_key_valid: bool = Depends(verify_api_key), - db_session: AsyncSession = Depends(get_db), -): - """Récupère l'historique des exécutions d'un schedule (depuis la base de données)""" - # Vérifier que le schedule existe soit dans le SchedulerService, soit en BD - sched = scheduler_service.get_schedule(schedule_id) - repo = ScheduleRepository(db_session) - schedule = await repo.get(schedule_id) - - if not sched and not schedule: - raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé") - - schedule_name = sched.name if sched else schedule.name - - # Récupérer les runs depuis la BD - run_repo = ScheduleRunRepository(db_session) - runs = await run_repo.list_for_schedule(schedule_id, limit=limit, offset=offset) - - return { - "schedule_id": schedule_id, - "schedule_name": schedule_name, - "runs": [ - { - "id": r.id, - "status": r.status, - "started_at": r.started_at, - "finished_at": r.completed_at, - "duration_seconds": r.duration, - "error_message": r.error_message, - } - for r in runs - ], - "count": len(runs) - } - - -# ===== ENDPOINTS NOTIFICATIONS NTFY ===== - -@app.get("/api/notifications/config") -async def get_notification_config(api_key_valid: bool = Depends(verify_api_key)): - """Récupère la configuration actuelle des notifications ntfy.""" - config = notification_service.config - return { - "enabled": config.enabled, - "base_url": config.base_url, - "default_topic": config.default_topic, - "timeout": config.timeout, - "has_auth": config.has_auth, - } - - -@app.post("/api/notifications/test") -async def test_notification( - topic: Optional[str] = None, - message: str = "🧪 Test de notification depuis Homelab Automation API", - api_key_valid: bool = Depends(verify_api_key) -): - """Envoie une notification de test pour vérifier la configuration ntfy.""" - success = await notification_service.send( - topic=topic, - message=message, - title="🔔 Test Notification", - priority=3, - tags=["test_tube", "robot"] - ) - - return { - "success": success, - "topic": topic or notification_service.config.default_topic, - "message": "Notification envoyée" if success else "Échec de l'envoi (voir logs serveur)" - } - - -@app.post("/api/notifications/send", response_model=NotificationResponse) -async def send_custom_notification( - request: NotificationRequest, - api_key_valid: bool = Depends(verify_api_key) -): - """Envoie une notification personnalisée via ntfy.""" - return await notification_service.send_request(request) - - -@app.post("/api/notifications/toggle") -async def toggle_notifications( - enabled: bool, - api_key_valid: bool = Depends(verify_api_key) -): - """Active ou désactive les notifications ntfy.""" - from schemas.notification import NtfyConfig - - # Reconfigurer le service avec le nouveau statut - current_config = notification_service.config - new_config = NtfyConfig( - base_url=current_config.base_url, - default_topic=current_config.default_topic, - enabled=enabled, - timeout=current_config.timeout, - username=current_config.username, - password=current_config.password, - token=current_config.token, - ) - notification_service.reconfigure(new_config) - - return { - "enabled": enabled, - "message": f"Notifications {'activées' if enabled else 'désactivées'}" - } - - -# ===== ÉVÉNEMENTS STARTUP/SHUTDOWN ===== - -@app.on_event("startup") -async def startup_event(): - """Événement de démarrage de l'application""" - print("🚀 Homelab Automation Dashboard démarré") - - # Initialiser la base de données (créer les tables si nécessaire) - await init_db() - print("📦 Base de données SQLite initialisée") - - # Charger les statuts bootstrap depuis la BD - await bootstrap_status_service.load_from_db() - - # Démarrer le scheduler et charger les schedules depuis la BD - await scheduler_service.start_async() - - # Afficher l'état du service de notification - ntfy_status = "activé" if notification_service.enabled else "désactivé" - print(f"🔔 Service de notification ntfy: {ntfy_status} ({notification_service.config.base_url})") - - # Log de démarrage en base - async with async_session_maker() as session: - repo = LogRepository(session) - await repo.create( - level="INFO", - message="Application démarrée - Services initialisés (BD)", - source="system", - ) - await session.commit() - - # Notification ntfy au démarrage de l'application - startup_notif = notification_service.templates.app_started() - await notification_service.send( - message=startup_notif.message, - topic=startup_notif.topic, - title=startup_notif.title, - priority=startup_notif.priority, - tags=startup_notif.tags, - ) - - -@app.on_event("shutdown") -async def shutdown_event(): - """Événement d'arrêt de l'application""" - print("👋 Arrêt de l'application...") - - # Arrêter le scheduler - scheduler_service.shutdown() - - # Notification ntfy à l'arrêt de l'application - shutdown_notif = notification_service.templates.app_stopped() - await notification_service.send( - message=shutdown_notif.message, - topic=shutdown_notif.topic, - title=shutdown_notif.title, - priority=shutdown_notif.priority, - tags=shutdown_notif.tags, - ) - - # Fermer le client HTTP du service de notification - await notification_service.close() - print("✅ Services arrêtés proprement") - - -# Démarrer l'application -if __name__ == "__main__": - uvicorn.run( - "app_optimized:app", - host="0.0.0.0", - port=8008, - reload=True, - log_level="info" - ) \ No newline at end of file diff --git a/app/containers_page.js b/app/containers_page.js index 0bd89a5..a67943d 100644 --- a/app/containers_page.js +++ b/app/containers_page.js @@ -13,12 +13,12 @@ const containersPage = { inspectData: null, _initialized: false, _initPromise: null, - + // View settings viewMode: 'comfortable', // 'comfortable', 'compact', 'grouped' currentPage: 1, perPage: 50, - + // Filter state filters: { search: '', @@ -30,10 +30,10 @@ const containersPage = { favoritesOnly: false, // ========== INITIALIZATION ========== - + async init() { if (this._initPromise) return await this._initPromise; - + this._initPromise = (async () => { this.setupEventListeners(); this.setupKeyboardShortcuts(); @@ -43,7 +43,7 @@ const containersPage = { await this.loadData(); this._initialized = true; })(); - + return await this._initPromise; }, @@ -150,7 +150,7 @@ const containersPage = { document.addEventListener('keydown', (e) => { // Only active when on containers page if (currentPage !== 'docker-containers') return; - + // Ignore if in input if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') { // Escape clears search @@ -184,7 +184,7 @@ const containersPage = { async loadData() { this.showLoading(); - + try { const [containersRes, hostsRes] = await Promise.all([ this.fetchAPI('/api/docker/containers'), @@ -193,16 +193,16 @@ const containersPage = { this.containers = containersRes.containers || []; this.hosts = hostsRes.hosts || []; - + // Update host filter dropdown this.populateHostFilter(); - + // Update stats this.updateStats(containersRes); - + // Apply filters and render this.applyFilters(); - + } catch (error) { console.error('Error loading containers:', error); this.showError(error.message); @@ -212,9 +212,9 @@ const containersPage = { async refresh() { const icon = document.getElementById('containers-refresh-icon'); if (icon) icon.classList.add('fa-spin'); - + await this.loadData(); - + if (icon) icon.classList.remove('fa-spin'); this.showToast('Données actualisées', 'success'); }, @@ -225,16 +225,16 @@ const containersPage = { 'Content-Type': 'application/json', ...options.headers }; - + if (token) { headers['Authorization'] = `Bearer ${token}`; } - + const response = await fetch(`${window.location.origin}${endpoint}`, { ...options, headers }); - + if (!response.ok) { if (response.status === 401) { this.showToast('Session expirée', 'error'); @@ -243,7 +243,7 @@ const containersPage = { } throw new Error(`API Error: ${response.status}`); } - + return response.json(); }, @@ -251,22 +251,22 @@ const containersPage = { applyFilters() { let result = [...this.containers]; - + // Text search with smart tokens if (this.filters.search) { result = this.smartSearch(result, this.filters.search); } - + // Status filter if (this.filters.status) { result = result.filter(c => c.state === this.filters.status); } - + // Host filter if (this.filters.host) { result = result.filter(c => c.host_id === this.filters.host); } - + // Health filter if (this.filters.health) { if (this.filters.health === 'none') { @@ -280,10 +280,10 @@ const containersPage = { if (this.favoritesOnly && window.favoritesManager) { result = result.filter(c => window.favoritesManager.isFavorite(c.host_id, c.container_id)); } - + // Sort result = this.sortContainers(result, this.sortBy); - + this.filteredContainers = result; this.currentPage = 1; this.updateActiveFilters(); @@ -292,7 +292,7 @@ const containersPage = { smartSearch(containers, query) { const tokens = this.parseSearchTokens(query); - + return containers.filter(c => { // Check token filters for (const token of tokens.filters) { @@ -319,7 +319,7 @@ const containersPage = { break; } } - + // Free text search if (tokens.freeText) { const searchStr = tokens.freeText.toLowerCase(); @@ -330,10 +330,10 @@ const containersPage = { c.compose_project, c.container_id?.substring(0, 12) ].filter(Boolean).join(' ').toLowerCase(); - + if (!searchable.includes(searchStr)) return false; } - + return true; }); }, @@ -341,16 +341,16 @@ const containersPage = { parseSearchTokens(query) { const filters = []; let freeText = query; - + // Match tokens like "host:value" or "status:running" const tokenRegex = /(\w+):(\S+)/g; let match; - + while ((match = tokenRegex.exec(query)) !== null) { filters.push({ key: match[1], value: match[2] }); freeText = freeText.replace(match[0], '').trim(); } - + return { filters, freeText }; }, @@ -363,10 +363,10 @@ const containersPage = { sortContainers(containers, sortBy) { const [field, direction] = sortBy.split('-'); const mult = direction === 'desc' ? -1 : 1; - + return containers.sort((a, b) => { let valA, valB; - + switch (field) { case 'name': valA = a.name.toLowerCase(); @@ -389,7 +389,7 @@ const containersPage = { valA = a.name.toLowerCase(); valB = b.name.toLowerCase(); } - + if (valA < valB) return -1 * mult; if (valA > valB) return 1 * mult; return 0; @@ -403,10 +403,10 @@ const containersPage = { const empty = document.getElementById('containers-empty'); const error = document.getElementById('containers-error'); const pagination = document.getElementById('containers-pagination'); - + // Hide error error?.classList.add('hidden'); - + // Check empty if (this.filteredContainers.length === 0) { list.innerHTML = ''; @@ -414,24 +414,24 @@ const containersPage = { pagination?.classList.add('hidden'); return; } - + empty?.classList.add('hidden'); - + // Paginate const start = (this.currentPage - 1) * this.perPage; const end = Math.min(start + this.perPage, this.filteredContainers.length); const pageContainers = this.filteredContainers.slice(start, end); - + // Render based on view mode if (this.viewMode === 'grouped') { list.innerHTML = this.renderGroupedView(pageContainers); } else { list.innerHTML = pageContainers.map(c => this.renderContainerRow(c)).join(''); } - + // Update pagination this.updatePagination(start, end); - + // Update search clear button visibility const clearBtn = document.getElementById('containers-search-clear'); if (clearBtn) { @@ -462,19 +462,19 @@ const containersPage = { const iconHtml = iconKey ? `` : ''; - + const healthBadge = c.health && c.health !== 'none' ? ` ${c.health} ` : ''; - + const projectBadge = c.compose_project ? ` ${this.escapeHtml(c.compose_project)} ` : ''; - + const portLinks = this.renderPortLinks(c); - + if (isCompact) { return `
`; } - + return `
`
@@ -586,7 +586,7 @@ const containersPage = { renderQuickActions(c) { const isRunning = c.state === 'running'; - + return ` ${!isRunning ? `
${palette.map(c => { - const label = c || 'Aucune'; - const style = c ? `background:${c}` : 'background:transparent'; - return ``; - }).join('')} + const label = c || 'Aucune'; + const style = c ? `background:${c}` : 'background:transparent'; + return ``; + }).join('')}
`; @@ -435,7 +435,7 @@ class DashboardManager { renderFavoriteGroupIconPicker(initialIconKey) { const iconKey = initialIconKey || ''; const color = document.getElementById('fav-group-color-text')?.value || '#7c3aed'; - + return `
@@ -463,7 +463,7 @@ class DashboardManager { const input = document.getElementById('fav-group-icon-key'); const preview = document.getElementById('fav-group-icon-preview'); const color = document.getElementById('fav-group-color-text')?.value || '#7c3aed'; - + if (input) input.value = iconKey; if (preview) { preview.innerHTML = ``; @@ -473,29 +473,29 @@ class DashboardManager { clearFavGroupIcon() { const input = document.getElementById('fav-group-icon-key'); const preview = document.getElementById('fav-group-icon-preview'); - + if (input) input.value = ''; if (preview) { preview.innerHTML = ''; } } - + async init() { this.setupEventListeners(); this.setupScrollAnimations(); this.startAnimations(); this.loadThemePreference(); this.setupTerminalCleanupHandlers(); - + // Check authentication status first const authOk = await this.checkAuthStatus(); - + if (!authOk) { // Show login screen this.showLoginScreen(); return; } - + // Hide login screen if visible this.hideLoginScreen(); @@ -520,7 +520,7 @@ class DashboardManager { } catch (e) { } } - + await this.loadAppConfig(); this.setDebugBadgeVisible(this.isDebugEnabled()); @@ -528,10 +528,10 @@ class DashboardManager { await this.loadAllData(); this.renderFavoriteContainersWidget(); - + // Connecter WebSocket pour les mises à jour temps réel this.connectWebSocket(); - + // Rafraîchir périodiquement les métriques setInterval(() => this.loadMetrics(), 30000); @@ -540,7 +540,7 @@ class DashboardManager { window.favoritesManager.load().catch(() => null); }, 30000); } - + // Démarrer le polling des tâches en cours this.startRunningTasksPolling(); } @@ -610,7 +610,7 @@ class DashboardManager { desktopNav.appendChild(badge); } } - + setActiveNav(pageName) { if (typeof navigateTo === 'function') { navigateTo(pageName); @@ -624,33 +624,33 @@ class DashboardManager { const target = document.getElementById(`page-${pageName}`); if (target) target.classList.add('active'); } - + // ===== AUTHENTICATION ===== - + async checkAuthStatus() { try { const response = await fetch(`${this.apiBase}/api/auth/status`, { headers: this.getAuthHeaders() }); - + if (!response.ok) { return false; } - + const data = await response.json(); this.setupRequired = data.setup_required; - + if (data.setup_required) { this.showSetupScreen(); return false; } - + if (data.authenticated && data.user) { this.currentUser = data.user; this.updateUserDisplay(); return true; } - + return false; } catch (error) { console.error('Auth status check failed:', error); @@ -680,20 +680,20 @@ class DashboardManager { this.taskLogs = [log, ...current]; this.renderTasks(); } - + getAuthHeaders() { const headers = { 'Content-Type': 'application/json' }; - + if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } // No fallback - require JWT authentication - + return headers; } - + async login(username, password) { try { const response = await fetch(`${this.apiBase}/api/auth/login/json`, { @@ -701,19 +701,19 @@ class DashboardManager { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); - + if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Échec de connexion'); } - + const data = await response.json(); this.accessToken = data.access_token; localStorage.setItem('accessToken', data.access_token); - + // Get user info await this.checkAuthStatus(); - + // Re-initialize dashboard this.hideLoginScreen(); await this.loadAppConfig(); @@ -721,7 +721,7 @@ class DashboardManager { await this.loadAllData(); this.connectWebSocket(); this.startRunningTasksPolling(); - + this.showNotification('Connexion réussie', 'success'); return true; } catch (error) { @@ -730,7 +730,7 @@ class DashboardManager { return false; } } - + async setupAdmin(username, password, email = null, displayName = null) { try { const response = await fetch(`${this.apiBase}/api/auth/setup`, { @@ -743,12 +743,12 @@ class DashboardManager { display_name: displayName || null }) }); - + if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Échec de configuration'); } - + // Auto-login after setup return await this.login(username, password); } catch (error) { @@ -757,30 +757,30 @@ class DashboardManager { return false; } } - + logout() { this.accessToken = null; this.currentUser = null; localStorage.removeItem('accessToken'); - + // Stop polling if (this.runningTasksPollingInterval) { clearInterval(this.runningTasksPollingInterval); } - + // Close WebSocket if (this.ws) { this.ws.close(); } - + this.showLoginScreen(); this.showNotification('Déconnexion réussie', 'success'); } - + showLoginScreen() { const loginScreen = document.getElementById('login-screen'); const mainContent = document.getElementById('main-content'); - + if (loginScreen) { loginScreen.classList.remove('hidden'); if (this.setupRequired) { @@ -795,16 +795,16 @@ class DashboardManager { mainContent.classList.add('hidden'); } } - + showSetupScreen() { this.setupRequired = true; this.showLoginScreen(); } - + hideLoginScreen() { const loginScreen = document.getElementById('login-screen'); const mainContent = document.getElementById('main-content'); - + if (loginScreen) { loginScreen.classList.add('hidden'); } @@ -812,12 +812,12 @@ class DashboardManager { mainContent.classList.remove('hidden'); } } - + updateUserDisplay() { const userNameEl = document.getElementById('current-user-name'); const userMenuNameEl = document.getElementById('user-menu-name'); const userRoleEl = document.getElementById('current-user-role'); - + if (this.currentUser) { const displayName = this.currentUser.display_name || this.currentUser.username; if (userNameEl) { @@ -836,15 +836,15 @@ class DashboardManager { } } } - + // ===== API CALLS ===== - + async apiCall(endpoint, options = {}) { const url = `${this.apiBase}${endpoint}`; const defaultOptions = { headers: this.getAuthHeaders() }; - + try { const response = await fetch(url, { ...defaultOptions, ...options }); if (!response.ok) { @@ -856,7 +856,7 @@ class DashboardManager { err.status = 401; throw err; } - + let errorDetail = null; try { const contentType = response.headers.get('content-type') || ''; @@ -872,8 +872,8 @@ class DashboardManager { const serverMessage = (errorDetail && (errorDetail.detail || errorDetail.message || errorDetail.error)) - ? (errorDetail.detail || errorDetail.message || errorDetail.error) - : response.statusText; + ? (errorDetail.detail || errorDetail.message || errorDetail.error) + : response.statusText; const err = new Error(`HTTP ${response.status}: ${serverMessage || 'Erreur inconnue'}`); err.status = response.status; @@ -886,7 +886,7 @@ class DashboardManager { throw error; } } - + async loadAllData() { try { // Charger en parallèle @@ -910,7 +910,7 @@ class DashboardManager { this.apiCall('/api/server/logs?limit=500&offset=0').catch(() => ({ logs: [] })), this.apiCall('/api/alerts/unread-count').catch(() => ({ unread: 0 })) ]); - + this.hosts = hostsData; this.tasks = tasksData; this.logs = logsData; @@ -922,12 +922,12 @@ class DashboardManager { this.alertsUnread = alertsUnreadData.unread || 0; this.updateAlertsBadge(); - + // Logs de tâches markdown this.taskLogs = taskLogsData.logs || []; this.taskLogsStats = taskStatsData; this.taskLogsDates = taskDatesData; - + // Historique ad-hoc this.adhocHistory = adhocHistoryData.commands || []; this.adhocCategories = adhocCategoriesData.categories || []; @@ -936,16 +936,16 @@ class DashboardManager { this.adhocWidgetLogs = adhocTaskLogsData.logs || []; this.adhocWidgetTotalCount = Number(adhocTaskLogsData.total_count || this.adhocWidgetLogs.length || 0); this.adhocWidgetHasMore = Boolean(adhocTaskLogsData.has_more); - + // Schedules (Planificateur) this.schedules = schedulesData.schedules || []; this.schedulesStats = schedulesStatsData.stats || { total: 0, active: 0, paused: 0, failures_24h: 0 }; this.schedulesUpcoming = schedulesStatsData.upcoming || []; - + // Host metrics (builtin playbooks data) this.hostMetrics = hostMetricsData || {}; this.builtinPlaybooks = builtinPlaybooksData || []; - + console.log('Data loaded:', { taskLogs: this.taskLogs.length, taskLogsStats: this.taskLogsStats, @@ -953,10 +953,10 @@ class DashboardManager { adhocCategories: this.adhocCategories.length, schedules: this.schedules.length }); - + // Charger les résultats de lint depuis l'API await this.loadPlaybookLintResults(); - + // Mettre à jour l'affichage this.renderHosts(); this.renderTasks(); @@ -968,13 +968,13 @@ class DashboardManager { this.updateMetricsDisplay(metricsData); this.updateDateFilters(); this.updateTaskCounts(); - + } catch (error) { console.error('Erreur chargement données:', error); this.showNotification('Erreur de connexion à l\'API', 'error'); } } - + async loadMetrics() { try { const metrics = await this.apiCall('/api/metrics'); @@ -983,17 +983,17 @@ class DashboardManager { console.error('Erreur chargement métriques:', error); } } - + updateMetricsDisplay(metrics) { if (!metrics) return; - + const elements = { 'online-hosts': metrics.online_hosts, 'total-tasks': metrics.total_tasks, 'success-rate': `${metrics.success_rate}%`, 'uptime': `${metrics.uptime}%` }; - + Object.entries(elements).forEach(([id, value]) => { const el = document.getElementById(id); if (el && value !== undefined) { @@ -1001,30 +1001,30 @@ class DashboardManager { } }); } - + // ===== WEBSOCKET ===== - + connectWebSocket() { const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${wsProtocol}//${window.location.host}/ws`; - + try { this.ws = new WebSocket(wsUrl); - + this.ws.onopen = () => { console.log('WebSocket connecté'); }; - + this.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleWebSocketMessage(data); }; - + this.ws.onclose = () => { console.log('WebSocket déconnecté, reconnexion dans 5s...'); setTimeout(() => this.connectWebSocket(), 5000); }; - + this.ws.onerror = (error) => { console.error('WebSocket erreur:', error); }; @@ -1032,7 +1032,7 @@ class DashboardManager { console.error('Erreur WebSocket:', error); } } - + handleWebSocketMessage(data) { switch (data.type) { case 'task_created': @@ -1120,15 +1120,15 @@ class DashboardManager { break; } } - + // ===== HANDLERS WEBSOCKET SCHEDULES ===== - + handleScheduleCreated(schedule) { this.schedules.unshift(schedule); this.renderSchedules(); this.showNotification(`Schedule "${schedule.name}" créé`, 'success'); } - + handleScheduleUpdated(schedule) { const index = this.schedules.findIndex(s => s.id === schedule.id); if (index !== -1) { @@ -1136,13 +1136,13 @@ class DashboardManager { } this.renderSchedules(); } - + handleScheduleDeleted(data) { this.schedules = this.schedules.filter(s => s.id !== data.id); this.renderSchedules(); this.showNotification(`Schedule "${data.name}" supprimé`, 'warning'); } - + handleScheduleRunStarted(data) { this.showNotification(`Schedule "${data.schedule_name}" démarré`, 'info'); // Mettre à jour le statut du schedule @@ -1152,12 +1152,12 @@ class DashboardManager { this.renderSchedules(); } } - + handleScheduleRunFinished(data) { const statusMsg = data.success ? 'terminé avec succès' : 'échoué'; const notifType = data.success ? 'success' : 'error'; this.showNotification(`Schedule "${data.schedule_name}" ${statusMsg}`, notifType); - + // Mettre à jour le schedule const schedule = this.schedules.find(s => s.id === data.schedule_id); if (schedule && data.run) { @@ -1168,24 +1168,24 @@ class DashboardManager { // Rafraîchir les stats this.refreshSchedulesStats(); } - + // ===== POLLING DES TÂCHES EN COURS ===== - + startRunningTasksPolling() { // Arrêter le polling existant si présent this.stopRunningTasksPolling(); - + // Démarrer le polling this.runningTasksPollingInterval = setInterval(() => { this.pollRunningTasks(); }, this.pollingIntervalMs); - + // Exécuter immédiatement une première fois this.pollRunningTasks(); - + console.log('Polling des tâches en cours démarré'); } - + stopRunningTasksPolling() { if (this.runningTasksPollingInterval) { clearInterval(this.runningTasksPollingInterval); @@ -1193,52 +1193,52 @@ class DashboardManager { console.log('Polling des tâches en cours arrêté'); } } - + async pollRunningTasks() { try { const result = await this.apiCall('/api/tasks/running'); const runningTasks = result.tasks || []; - + // Vérifier si des tâches ont changé de statut const previousRunningIds = this.tasks .filter(t => t.status === 'running' || t.status === 'pending') .map(t => t.id); const currentRunningIds = runningTasks.map(t => t.id); - + // Détecter les tâches terminées const completedTaskIds = previousRunningIds.filter(id => !currentRunningIds.includes(id)); - + if (completedTaskIds.length > 0) { // Des tâches ont été terminées - rafraîchir les logs console.log('Tâches terminées détectées:', completedTaskIds); await this.refreshTaskLogs(); } - + // Mettre à jour les tâches en cours this.updateRunningTasks(runningTasks); - + } catch (error) { console.error('Erreur polling tâches:', error); } } - + updateRunningTasks(runningTasks) { // Mettre à jour la liste des tâches en mémoire const nonRunningTasks = this.tasks.filter(t => t.status !== 'running' && t.status !== 'pending'); this.tasks = [...runningTasks, ...nonRunningTasks]; - + // Mettre à jour l'affichage dynamiquement this.updateRunningTasksUI(runningTasks); this.updateTaskCounts(); } - + updateRunningTasksUI(runningTasks) { const container = document.getElementById('tasks-list'); if (!container) return; - + // Trouver ou créer la section des tâches en cours let runningSection = container.querySelector('.running-tasks-section'); - + if (runningTasks.length === 0) { // Supprimer la section si plus de tâches en cours if (runningSection) { @@ -1246,13 +1246,13 @@ class DashboardManager { } return; } - + // Créer la section si elle n'existe pas if (!runningSection) { runningSection = document.createElement('div'); runningSection.className = 'running-tasks-section mb-4'; runningSection.innerHTML = '

En cours

'; - + // Insérer au début du container (après le header) const header = container.querySelector('.flex.flex-col'); if (header && header.nextSibling) { @@ -1261,32 +1261,32 @@ class DashboardManager { container.prepend(runningSection); } } - + // Mettre à jour le contenu des tâches en cours const tasksContainer = runningSection.querySelector('.running-tasks-list') || document.createElement('div'); tasksContainer.className = 'running-tasks-list space-y-2'; - + tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join(''); - + if (!runningSection.querySelector('.running-tasks-list')) { runningSection.appendChild(tasksContainer); } - + // Mettre à jour le badge "en cours" dans le header const runningBadge = container.querySelector('.running-badge'); if (runningBadge) { runningBadge.textContent = `${runningTasks.length} en cours`; } } - + createRunningTaskHTML(task) { - const startTime = task.start_time + const startTime = task.start_time ? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--'; - + const duration = task.duration || this.calculateDuration(task.start_time); const progress = task.progress || 0; - + return `
@@ -1318,14 +1318,14 @@ class DashboardManager {
`; } - + calculateDuration(startTime) { if (!startTime) return '--'; const start = new Date(startTime); const now = new Date(); const diffMs = now - start; const diffSec = Math.floor(diffMs / 1000); - + if (diffSec < 60) return `${diffSec}s`; const diffMin = Math.floor(diffSec / 60); const remainingSec = diffSec % 60; @@ -1334,12 +1334,12 @@ class DashboardManager { const remainingMin = diffMin % 60; return `${diffHour}h ${remainingMin}m`; } - + // ===== HANDLERS WEBSOCKET POUR LES TÂCHES ===== - + handleTaskCreated(taskData) { console.log('Nouvelle tâche créée:', taskData); - + // Ajouter la tâche à la liste const existingIndex = this.tasks.findIndex(t => t.id === taskData.id); if (existingIndex === -1) { @@ -1347,26 +1347,26 @@ class DashboardManager { } else { this.tasks[existingIndex] = taskData; } - + // Mettre à jour l'UI immédiatement this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending')); this.updateTaskCounts(); - + // Notification this.showNotification(`Tâche "${taskData.name}" démarrée`, 'info'); } - + handleTaskProgress(progressData) { console.log('Progression tâche:', progressData); const taskId = progressData && (progressData.task_id || progressData.id); if (!taskId) return; - + // Mettre à jour la tâche dans la liste const task = this.tasks.find(t => String(t.id) === String(taskId)); if (task) { task.progress = progressData.progress; - + // Mettre à jour l'UI de cette tâche spécifique const taskCard = document.querySelector(`.task-card-${taskId}`); if (taskCard) { @@ -1381,19 +1381,19 @@ class DashboardManager { } } } - + handleTaskCompleted(taskData) { console.log('Tâche terminée:', taskData); const taskId = taskData && (taskData.task_id || taskData.id); if (!taskId) return; - + // Retirer la tâche de la liste des tâches en cours this.tasks = this.tasks.filter(t => String(t.id) !== String(taskId)); - + // Mettre à jour l'UI this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending')); - + // Rafraîchir les logs de tâches pour voir la tâche terminée this.refreshTaskLogs(); @@ -1402,7 +1402,7 @@ class DashboardManager { this.loadAllData().catch((e) => { console.error('Erreur rafraîchissement données après fin de tâche:', e); }); - + // Notification const status = taskData.status || 'completed'; const isSuccess = status === 'completed'; @@ -1411,23 +1411,23 @@ class DashboardManager { isSuccess ? 'success' : 'error' ); } - + handleTaskCancelled(taskData) { console.log('Tâche annulée:', taskData); - + // Retirer la tâche de la liste des tâches en cours this.tasks = this.tasks.filter(t => String(t.id) !== String(taskData.id)); - + // Mettre à jour l'UI this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending')); - + // Rafraîchir les logs de tâches this.refreshTaskLogs(); - + // Notification this.showNotification('Tâche annulée', 'warning'); } - + async loadLogs() { try { const logsData = await this.apiCall('/api/logs'); @@ -1477,7 +1477,7 @@ class DashboardManager { if (!container) return; container.scrollTop = 0; } - + setupEventListeners() { // Theme toggle (desktop + mobile) const onToggleTheme = () => { @@ -1536,28 +1536,28 @@ class DashboardManager { }); obs.observe(alertsPage, { attributes: true, attributeFilter: ['class'] }); } - + // Initialiser le calendrier de filtrage des tâches this.setupTaskDateCalendar(); - + // Event delegation for terminal buttons (avoids inline onclick issues) document.addEventListener('click', (e) => { const btn = e.target.closest('[data-action="terminal"], [data-action="terminal-popout"]'); if (!btn || btn.disabled) return; - + e.stopPropagation(); const action = btn.dataset.action; const hostId = btn.dataset.hostId; const hostName = btn.dataset.hostName; const hostIp = btn.dataset.hostIp; - + if (action === 'terminal') { this.openTerminal(hostId, hostName, hostIp); } else if (action === 'terminal-popout') { this.openTerminalPopout(hostId, hostName, hostIp); } }); - + // Navigation est gérée par le script de navigation des pages dans index.html } @@ -1721,8 +1721,8 @@ class DashboardManager { const today = new Date(); today.setHours(0, 0, 0, 0); const isToday = date.getFullYear() === today.getFullYear() && - date.getMonth() === today.getMonth() && - date.getDate() === today.getDate(); + date.getMonth() === today.getMonth() && + date.getDate() === today.getDate(); let classes = 'w-9 h-9 flex items-center justify-center rounded-full text-xs transition-colors duration-150 '; @@ -1776,7 +1776,7 @@ class DashboardManager { const [y, m, d] = key.split('-').map(v => parseInt(v, 10)); return new Date(y, (m || 1) - 1, d || 1); } - + toggleTheme() { const body = document.body; const isLight = body.classList.toggle('light-theme'); @@ -1793,7 +1793,7 @@ class DashboardManager { localStorage.setItem('theme', isLight ? 'light' : 'dark'); } - + loadThemePreference() { const savedTheme = localStorage.getItem('theme'); if (savedTheme === 'light') { @@ -1810,7 +1810,7 @@ class DashboardManager { } } } - + renderHosts() { const container = document.getElementById('hosts-list'); const hostsPageContainer = document.getElementById('hosts-page-list'); @@ -1824,15 +1824,15 @@ class DashboardManager { const focusedContainer = wasSearchFocused ? ([container, hostsPageContainer].filter(c => c).find(c => c.contains(activeEl)) || null) : null; - + // Filtrer les hôtes par groupe si un filtre est actif let filteredHosts = this.hosts; if (this.currentGroupFilter && this.currentGroupFilter !== 'all') { - filteredHosts = this.hosts.filter(h => + filteredHosts = this.hosts.filter(h => h.groups && h.groups.includes(this.currentGroupFilter) ); } - + // Filtrer par statut bootstrap si un filtre est actif if (this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all') { if (this.currentBootstrapFilter === 'ready') { @@ -1853,16 +1853,16 @@ class DashboardManager { return name.includes(q) || ip.includes(q) || os.includes(q) || groups.includes(q); }); } - + // Compter les hôtes par statut bootstrap const readyCount = this.hosts.filter(h => h.bootstrap_ok).length; const notConfiguredCount = this.hosts.filter(h => !h.bootstrap_ok).length; - + // Options des groupes pour le filtre - const groupOptions = this.ansibleGroups.map(g => + const groupOptions = this.ansibleGroups.map(g => `` ).join(''); - + // Header avec filtres et boutons - Design professionnel const headerHtml = `
@@ -1943,7 +1943,7 @@ class DashboardManager {
`; - + // Apply to both containers const containers = [container, hostsPageContainer].filter(c => c); containers.forEach(c => c.innerHTML = headerHtml); @@ -1972,7 +1972,7 @@ class DashboardManager { } } } - + if (filteredHosts.length === 0) { const emptyHtml = `
@@ -1988,18 +1988,18 @@ class DashboardManager { containers.forEach(c => c.innerHTML += emptyHtml); return; } - + filteredHosts.forEach(host => { const statusClass = `status-${host.status}`; - + // Formater last_seen - const lastSeen = host.last_seen + const lastSeen = host.last_seen ? new Date(host.last_seen).toLocaleString('fr-FR') : 'Jamais vérifié'; - + // Indicateur de bootstrap const bootstrapOk = host.bootstrap_ok || false; - const bootstrapDate = host.bootstrap_date + const bootstrapDate = host.bootstrap_date ? new Date(host.bootstrap_date).toLocaleDateString('fr-FR') : null; const bootstrapIndicator = bootstrapOk @@ -2009,13 +2009,13 @@ class DashboardManager { : ` Non configuré `; - + // Indicateur de qualité de communication const commQuality = this.getHostCommunicationQuality(host); const commIndicator = `
- ${[1,2,3,4,5].map(i => ` + ${[1, 2, 3, 4, 5].map(i => `
`).join('')} @@ -2023,26 +2023,26 @@ class DashboardManager { ${commQuality.label}
`; - + const hostCard = document.createElement('div'); hostCard.className = 'host-card group'; - + // Séparer les groupes env et role const hostGroups = host.groups || []; const envGroup = hostGroups.find(g => g.startsWith('env_')); const roleGroups = hostGroups.filter(g => g.startsWith('role_')); - - const envBadge = envGroup + + const envBadge = envGroup ? `${envGroup.replace('env_', '')}` : ''; - const roleBadges = roleGroups.map(g => + const roleBadges = roleGroups.map(g => `${g.replace('role_', '')}` ).join(''); - + // Get metrics for this host const hostMetrics = this.hostMetrics[host.id] || null; const metricsHtml = this.renderHostMetricsSection(hostMetrics, host.id); - + hostCard.innerHTML = `
@@ -2126,7 +2126,7 @@ class DashboardManager { }); }); } - + filterHostsByBootstrap(status) { this.currentBootstrapFilter = status; this.renderHosts(); @@ -2146,7 +2146,7 @@ class DashboardManager { } this.renderHosts(); } - + // Render metrics section for a host card renderHostMetricsSection(metrics, hostId) { if (!metrics || metrics.collection_status === 'unknown') { @@ -2159,14 +2159,14 @@ class DashboardManager {
`; } - + // Format the last collected time - const lastCollected = metrics.last_collected - ? new Date(metrics.last_collected).toLocaleString('fr-FR', { - day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' - }) + const lastCollected = metrics.last_collected + ? new Date(metrics.last_collected).toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' + }) : 'N/A'; - + // CPU gauge const cpuPercent = metrics.cpu_load_1m ? Math.min(100, metrics.cpu_load_1m * 25) : 0; const cpuColor = cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'; @@ -2182,20 +2182,20 @@ class DashboardManager { if (cpuFreqText) cpuDetailParts.push(cpuFreqText); const cpuDetailLine = cpuDetailParts.length ? cpuDetailParts.join(' • ') : ''; const cpuTitle = metrics.cpu_model ? this.escapeHtml(metrics.cpu_model) : ''; - + // Memory gauge const memPercent = metrics.memory_usage_percent || 0; const memColor = memPercent > 80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'; const memText = memPercent ? `${memPercent.toFixed(0)}%` : 'N/A'; - const memDetail = metrics.memory_total_mb + const memDetail = metrics.memory_total_mb ? `${Math.round(metrics.memory_used_mb / 1024 * 10) / 10}/${Math.round(metrics.memory_total_mb / 1024 * 10) / 10} GB` : ''; - + // Disk gauge const diskPercent = metrics.disk_root_usage_percent || 0; const diskColor = diskPercent > 90 ? 'bg-red-500' : diskPercent > 70 ? 'bg-yellow-500' : 'bg-green-500'; const diskText = diskPercent ? `${diskPercent.toFixed(0)}%` : 'N/A'; - const diskDetail = metrics.disk_root_total_gb + const diskDetail = metrics.disk_root_total_gb ? `${metrics.disk_root_used_gb?.toFixed(0) || 0}/${metrics.disk_root_total_gb?.toFixed(0) || 0} GB` : ''; @@ -2621,27 +2621,27 @@ class DashboardManager { Used / Free / Total
${(diskCards || mountCards) - ? `
${diskCards || mountCards}
` - : `
Aucun détail disponible
`} + ? `
${diskCards || mountCards}
` + : `
Aucun détail disponible
`} ${renderLvmSection()} ${renderZfsSection()}
`; }; - + // Temperature (if available) - const tempHtml = metrics.cpu_temperature + const tempHtml = metrics.cpu_temperature ? `
${metrics.cpu_temperature}°C
` : ''; - + // Uptime - const uptimeHtml = metrics.uptime_human + const uptimeHtml = metrics.uptime_human ? `${metrics.uptime_human}` : ''; - + return `
@@ -2694,19 +2694,19 @@ class DashboardManager {
`; } - + // Render detailed storage section (accordion) renderStorageDetailsSection(metrics, hostId) { const storageDetails = metrics?.storage_details; const isExpanded = this.expandedStorageDetails?.has(hostId) || false; - + // Use storage_details if available, otherwise fallback to existing metrics fields const hasStorageDetails = !!storageDetails; const status = storageDetails?.status || 'unknown'; const osType = storageDetails?.os_type || metrics?.os_name || 'unknown'; const flags = storageDetails?.feature_flags || {}; const summary = storageDetails?.summary || {}; - + // Filesystems: from storage_details or build from disk_info let filesystems = storageDetails?.filesystems || []; if (!filesystems.length && metrics?.disk_info?.length) { @@ -2720,43 +2720,43 @@ class DashboardManager { use_pct: d.usage_percent || 0 })); } - + // Block devices from storage_details or disk_devices const blockDevices = storageDetails?.block_devices || metrics?.disk_devices || []; - + // ZFS from storage_details or zfs_info const zfsPools = storageDetails?.zfs?.pools || metrics?.zfs_info?.pools || []; const zfsDatasets = storageDetails?.zfs?.datasets || metrics?.zfs_info?.datasets || []; - + // LVM from storage_details or lvm_info const lvmVgs = storageDetails?.lvm?.vgs || metrics?.lvm_info?.vgs || []; - + const commandsRun = storageDetails?.commands_run || []; const partialFailures = storageDetails?.partial_failures || []; const collectedAt = storageDetails?.collected_at || ''; - + // Check if we have any data to show const hasData = filesystems.length > 0 || blockDevices.length > 0 || zfsPools.length > 0 || lvmVgs.length > 0; - + // Build summary chips const chips = []; if (filesystems.length) chips.push(`${filesystems.length} FS`); if (blockDevices.length) chips.push(`${blockDevices.length} disques`); if (zfsPools.length || flags.has_zfs) chips.push('ZFS'); if (lvmVgs.length || flags.has_lvm) chips.push('LVM'); - + // Calculate usage from summary or from filesystems let usedPct = summary.used_pct ? Number(summary.used_pct).toFixed(0) : null; let totalBytes = summary.total_bytes || 0; let usedBytes = summary.used_bytes || 0; - + // If no summary, calculate from filesystems (exclude virtual fs) if (!totalBytes && filesystems.length) { const realFs = filesystems.filter(fs => { const mp = (fs.mountpoint || '').toLowerCase(); const dev = (fs.device || '').toLowerCase(); - return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') && - !dev.includes('tmpfs') && !dev.includes('devtmpfs'); + return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') && + !dev.includes('tmpfs') && !dev.includes('devtmpfs'); }); totalBytes = realFs.reduce((sum, fs) => sum + (fs.size_bytes || 0), 0); usedBytes = realFs.reduce((sum, fs) => sum + (fs.used_bytes || 0), 0); @@ -2764,7 +2764,7 @@ class DashboardManager { usedPct = ((usedBytes / totalBytes) * 100).toFixed(0); } } - + const formatBytes = (bytes) => { if (!bytes || bytes <= 0) return ''; const gb = bytes / (1024 * 1024 * 1024); @@ -2772,31 +2772,31 @@ class DashboardManager { if (gb >= 100) return `${gb.toFixed(0)} GB`; return `${gb.toFixed(1)} GB`; }; - + const summaryLine = chips.length ? chips.join(' • ') : 'Aucune donnée'; const usageLine = usedPct !== null ? `${usedPct}% utilisé` : ''; const sizeLine = totalBytes > 0 ? `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)}` : ''; - - const statusBadge = status === 'ok' ? 'bg-green-500/20 text-green-400' : - status === 'partial' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400'; + + const statusBadge = status === 'ok' ? 'bg-green-500/20 text-green-400' : + status === 'partial' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400'; const statusText = status === 'ok' ? 'OK' : status === 'partial' ? 'Partiel' : 'Erreur'; - + const getPctColor = (pct) => { if (pct === null || pct === undefined) return 'bg-gray-600'; return pct >= 90 ? 'bg-red-500' : pct >= 75 ? 'bg-yellow-500' : 'bg-green-500'; }; - + const renderFilesystemsTable = () => { if (!filesystems.length) return '
Aucun filesystem détecté
'; - + // Filter out virtual filesystems const filtered = filesystems.filter(fs => { const mp = (fs.mountpoint || '').toLowerCase(); const dev = (fs.device || '').toLowerCase(); - return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') && - !dev.includes('tmpfs') && !dev.includes('devtmpfs'); + return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') && + !dev.includes('tmpfs') && !dev.includes('devtmpfs'); }); - + return `
@@ -2812,14 +2812,14 @@ class DashboardManager { ${filtered.slice(0, 20).map(fs => { - const pct = fs.use_pct !== undefined ? Number(fs.use_pct) : null; - const pctColor = pct >= 90 ? 'text-red-400' : pct >= 75 ? 'text-yellow-400' : 'text-green-400'; - const rowClass = pct >= 85 ? 'bg-red-500/10' : ''; - const rawDevice = (fs.device || '-'); - const deviceDisplay = (typeof rawDevice === 'string' && rawDevice.startsWith('/dev/')) - ? rawDevice.slice('/dev/'.length) - : rawDevice; - return ` + const pct = fs.use_pct !== undefined ? Number(fs.use_pct) : null; + const pctColor = pct >= 90 ? 'text-red-400' : pct >= 75 ? 'text-yellow-400' : 'text-green-400'; + const rowClass = pct >= 85 ? 'bg-red-500/10' : ''; + const rawDevice = (fs.device || '-'); + const deviceDisplay = (typeof rawDevice === 'string' && rawDevice.startsWith('/dev/')) + ? rawDevice.slice('/dev/'.length) + : rawDevice; + return ` @@ -2829,21 +2829,21 @@ class DashboardManager { `; - }).join('')} + }).join('')}
${this.escapeHtml(fs.mountpoint || '-')} ${this.escapeHtml(deviceDisplay || '-')}${pct !== null ? pct + '%' : '-'}
`; }; - + const renderZfsSection = () => { if (!zfsPools.length && !zfsDatasets.length) return ''; - + const poolCards = zfsPools.map(pool => { const pct = pool.cap_pct !== undefined ? Number(pool.cap_pct) : null; const color = getPctColor(pct); - const healthColor = pool.health === 'ONLINE' ? 'text-green-400' : - pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400'; + const healthColor = pool.health === 'ONLINE' ? 'text-green-400' : + pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400'; return `
@@ -2860,14 +2860,14 @@ class DashboardManager {
`; }).join(''); - + const datasetsList = zfsDatasets.slice(0, 15).map(ds => `
${this.escapeHtml(ds.name || '')} ${formatBytes(ds.used_bytes)} used
`).join(''); - + return `
@@ -2879,10 +2879,10 @@ class DashboardManager {
`; }; - + const renderLvmSection = () => { if (!lvmVgs.length) return ''; - + const vgCards = lvmVgs.map(vg => { const name = vg.vg_name || vg.name || 'VG'; const size = vg.vg_size || vg.size || ''; @@ -2894,7 +2894,7 @@ class DashboardManager {
`; }).join(''); - + return `
@@ -2905,21 +2905,21 @@ class DashboardManager {
`; }; - + const renderInspectorDrawer = () => { if (!this.storageInspectorOpen?.has(hostId)) return ''; - + const cmdList = commandsRun.map(cmd => `
${this.escapeHtml(cmd.cmd || '')} ${cmd.status || ''}
`).join('') || '
Aucune commande
'; - + const failuresList = partialFailures.length ? partialFailures.map(f => `
${this.escapeHtml(f)}
`).join('') : ''; - + return `
@@ -2955,16 +2955,16 @@ class DashboardManager {
`; }; - + // Don't show section if no data at all if (!hasData) { return ''; } - + // Status badge - show "Données existantes" if using fallback data const displayStatusBadge = hasStorageDetails ? statusBadge : 'bg-blue-500/20 text-blue-400'; const displayStatusText = hasStorageDetails ? statusText : 'Données'; - + return `
@@ -2995,7 +2995,7 @@ class DashboardManager {
`; } - + // Toggle storage details accordion toggleStorageDetails(hostId) { if (!this.expandedStorageDetails) this.expandedStorageDetails = new Set(); @@ -3006,7 +3006,7 @@ class DashboardManager { } this.renderHosts(); } - + // Toggle storage inspector drawer toggleStorageInspector(hostId) { if (!this.storageInspectorOpen) this.storageInspectorOpen = new Set(); @@ -3017,22 +3017,22 @@ class DashboardManager { } this.renderHosts(); } - + // Collect metrics for all hosts async collectAllHostMetrics() { if (this.metricsLoading) { this.showNotification('Collecte déjà en cours...', 'warning'); return; } - + this.metricsLoading = true; this.showNotification('Collecte des métriques en cours...', 'info'); - + try { const result = await this.apiCall('/api/builtin-playbooks/collect-all', { method: 'POST' }); - + if (result.success) { const message = result.message || `Collecte des métriques lancée pour ${result.hosts_count || 0} hôte(s)`; this.showNotification(message, 'success'); @@ -3078,7 +3078,7 @@ class DashboardManager { this.showNotification(`Erreur lors de l'installation: ${error.detail || error.message || 'Erreur inconnue'}`, 'error'); } } - + // Load host metrics from API async loadHostMetrics() { try { @@ -3088,11 +3088,11 @@ class DashboardManager { this.hostMetrics = {}; } } - + // Collect metrics for a single host async collectHostMetrics(hostName) { this.showNotification(`Collecte des métriques pour ${hostName}...`, 'info'); - + try { const result = await this.apiCall('/api/builtin-playbooks/execute', { method: 'POST', @@ -3101,7 +3101,7 @@ class DashboardManager { target: hostName }) }); - + if (result.success) { this.showNotification(`Métriques collectées pour ${hostName}`, 'success'); await this.loadHostMetrics(); @@ -3114,7 +3114,7 @@ class DashboardManager { this.showNotification('Erreur lors de la collecte des métriques', 'error'); } } - + // Calcul de la qualité de communication d'un hôte getHostCommunicationQuality(host) { // Facteurs de qualité: @@ -3122,10 +3122,10 @@ class DashboardManager { // - Dernière vérification (last_seen) // - Bootstrap configuré // - Historique des tâches récentes (si disponible) - + let score = 0; let factors = []; - + // Statut online = +2 points if (host.status === 'online') { score += 2; @@ -3133,19 +3133,19 @@ class DashboardManager { } else if (host.status === 'offline') { factors.push('Hors ligne'); } - + // Bootstrap OK = +1 point if (host.bootstrap_ok) { score += 1; factors.push('Ansible configuré'); } - + // Last seen récent = +2 points (moins de 1h), +1 point (moins de 24h) if (host.last_seen) { const lastSeenDate = new Date(host.last_seen); const now = new Date(); const hoursDiff = (now - lastSeenDate) / (1000 * 60 * 60); - + if (hoursDiff < 1) { score += 2; factors.push('Vérifié récemment'); @@ -3158,10 +3158,10 @@ class DashboardManager { } else { factors.push('Jamais vérifié'); } - + // Convertir le score en niveau (1-5) const level = Math.min(5, Math.max(1, Math.round(score))); - + // Déterminer couleur et label selon le niveau let colorClass, textClass, label; if (level >= 4) { @@ -3181,7 +3181,7 @@ class DashboardManager { textClass = 'text-red-400'; label = 'Faible'; } - + return { level, colorClass, @@ -3190,18 +3190,18 @@ class DashboardManager { tooltip: factors.join(' • ') }; } - + // Modal pour exécuter un playbook sur un hôte spécifique async showPlaybookModalForHost(hostName) { // Récupérer la liste des playbooks compatibles avec cet hôte try { const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(hostName)}`); const playbooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : []; - + const playbookOptions = playbooks.map(p => ` `).join(''); - + const modalContent = `
@@ -3254,24 +3254,24 @@ class DashboardManager {
`; - + this.showModal('Exécuter un Playbook', modalContent); } catch (error) { this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error'); } } - + async executePlaybookOnHost(hostName) { const playbookSelect = document.getElementById('playbook-select'); const extraVarsInput = document.getElementById('playbook-extra-vars'); const checkModeInput = document.getElementById('playbook-check-mode'); - + const playbook = playbookSelect?.value; if (!playbook) { this.showNotification('Veuillez sélectionner un playbook', 'warning'); return; } - + let extraVars = {}; if (extraVarsInput?.value.trim()) { try { @@ -3281,9 +3281,9 @@ class DashboardManager { return; } } - + const checkMode = checkModeInput?.checked || false; - + this.closeModal(); if (this._playbookLaunchInFlight) { this.showNotification('Une exécution de playbook est déjà en cours de lancement', 'info'); @@ -3291,7 +3291,7 @@ class DashboardManager { } this._playbookLaunchInFlight = true; this.showNotification('Lancement du playbook en arrière-plan...', 'info'); - + try { const result = await this.apiCall('/api/ansible/execute', { method: 'POST', @@ -3302,16 +3302,16 @@ class DashboardManager { check_mode: checkMode }) }); - + this.showNotification(`Playbook "${playbook}" lancé sur ${hostName} (tâche ${result.task_id})`, 'success'); - + } catch (error) { this.showNotification(`Erreur: ${error.message}`, 'error'); } finally { this._playbookLaunchInFlight = false; } } - + async refreshHosts() { this.showLoading(); try { @@ -3324,19 +3324,19 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async syncHostsFromAnsible() { this.showLoading(); try { const result = await this.apiCall('/api/hosts/sync', { method: 'POST' }); await this.loadAllData(); this.hideLoading(); - + // Afficher un résumé détaillé const created = result.created?.length || 0; const skipped = result.skipped?.length || 0; const errors = result.errors?.length || 0; - + if (created > 0) { this.showNotification( `Import réussi: ${created} hôte(s) importé(s), ${skipped} déjà existant(s)`, @@ -3350,7 +3350,7 @@ class DashboardManager { } else { this.showNotification('Aucun hôte trouvé dans l\'inventaire Ansible', 'warning'); } - + if (errors > 0) { console.error('Erreurs lors de l\'import:', result.errors); this.showNotification(`${errors} erreur(s) lors de l'import`, 'error'); @@ -3360,14 +3360,14 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + filterHostsByGroup(group) { this.currentGroupFilter = group; this.renderHosts(); } - + // ===== GESTION DES HÔTES (CRUD) ===== - + async loadHostGroups() { try { const result = await this.apiCall('/api/hosts/groups'); @@ -3379,22 +3379,22 @@ class DashboardManager { return { env_groups: [], role_groups: [] }; } } - + async showAddHostModal() { // Charger les groupes disponibles await this.loadHostGroups(); - - const envOptions = this.envGroups.map(g => + + const envOptions = this.envGroups.map(g => `` ).join(''); - + const roleCheckboxes = this.roleGroups.map(g => ` `).join(''); - + this.showModal('Ajouter un Host', `
@@ -3450,51 +3450,51 @@ class DashboardManager { `); } - + async createHost(event) { event.preventDefault(); const formData = new FormData(event.target); - + const envGroup = formData.get('env_group'); if (!envGroup) { this.showNotification('Veuillez sélectionner un groupe d\'environnement', 'error'); return; } - + // Récupérer les rôles sélectionnés const roleGroups = []; document.querySelectorAll('input[name="role_groups"]:checked').forEach(cb => { roleGroups.push(cb.value); }); - + const payload = { name: formData.get('name'), ip: formData.get('ip') || null, env_group: envGroup, role_groups: roleGroups }; - + this.closeModal(); this.showLoading(); - + try { const result = await this.apiCall('/api/hosts', { method: 'POST', body: JSON.stringify(payload) }); - + this.hideLoading(); this.showNotification(`Hôte "${payload.name}" ajouté avec succès dans hosts.yml`, 'success'); - + // Recharger les données await this.loadAllData(); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async showEditHostModal(hostName) { // Trouver l'hôte const host = this.hosts.find(h => h.name === hostName); @@ -3502,7 +3502,7 @@ class DashboardManager { this.showNotification('Hôte non trouvé', 'error'); return; } - + // Charger les groupes disponibles await this.loadHostGroups(); @@ -3519,11 +3519,11 @@ class DashboardManager { // Identifier le groupe d'environnement et les groupes de rôles actuels const currentEnvGroup = inventoryGroups.find(g => g.startsWith('env_')) || ''; const currentRoleGroups = inventoryGroups.filter(g => g.startsWith('role_')); - - const envOptions = this.envGroups.map(g => + + const envOptions = this.envGroups.map(g => `` ).join(''); - + const roleCheckboxes = this.roleGroups.map(g => ` `).join(''); - + this.showModal(`Modifier: ${hostName}`, `
@@ -3590,44 +3590,44 @@ class DashboardManager { `); } - + async updateHost(event, hostName) { event.preventDefault(); const formData = new FormData(event.target); - + // Récupérer les rôles sélectionnés const roleGroups = []; document.querySelectorAll('input[name="role_groups"]:checked').forEach(cb => { roleGroups.push(cb.value); }); - + const payload = { env_group: formData.get('env_group') || null, role_groups: roleGroups, ansible_host: formData.get('ansible_host') || null }; - + this.closeModal(); this.showLoading(); - + try { const result = await this.apiCall(`/api/hosts/${encodeURIComponent(hostName)}`, { method: 'PUT', body: JSON.stringify(payload) }); - + this.hideLoading(); this.showNotification(`Hôte "${hostName}" mis à jour avec succès`, 'success'); - + // Recharger les données await this.loadAllData(); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + confirmDeleteHost(hostName) { this.showModal('Confirmer la suppression', `
@@ -3657,30 +3657,30 @@ class DashboardManager {
`); } - + async deleteHost(hostName) { this.closeModal(); this.showLoading(); - + try { await this.apiCall(`/api/hosts/by-name/${encodeURIComponent(hostName)}`, { method: 'DELETE' }); - + this.hideLoading(); this.showNotification(`Hôte "${hostName}" supprimé avec succès`, 'success'); - + // Recharger les données await this.loadAllData(); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + // ===== GESTION DES GROUPES (CRUD) ===== - + async loadGroups() { try { const result = await this.apiCall('/api/groups'); @@ -3690,13 +3690,13 @@ class DashboardManager { return { groups: [], env_count: 0, role_count: 0 }; } } - + showAddGroupModal(type) { const typeLabel = type === 'env' ? 'environnement' : 'rôle'; const prefix = type === 'env' ? 'env_' : 'role_'; const icon = type === 'env' ? 'fa-globe' : 'fa-tags'; const color = type === 'env' ? 'green' : 'blue'; - + this.showModal(`Ajouter un groupe d'${typeLabel}`, `
@@ -3729,48 +3729,48 @@ class DashboardManager { `); } - + async createGroup(event, type) { event.preventDefault(); const formData = new FormData(event.target); - + const payload = { name: formData.get('name'), type: type }; - + this.closeModal(); this.showLoading(); - + try { const result = await this.apiCall('/api/groups', { method: 'POST', body: JSON.stringify(payload) }); - + this.hideLoading(); this.showNotification(result.message || `Groupe créé avec succès`, 'success'); - + // Recharger les groupes await this.loadHostGroups(); await this.loadAllData(); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async showManageGroupsModal(type) { const typeLabel = type === 'env' ? 'environnement' : 'rôle'; const typeLabelPlural = type === 'env' ? 'environnements' : 'rôles'; const icon = type === 'env' ? 'fa-globe' : 'fa-tags'; const color = type === 'env' ? 'green' : 'blue'; - + // Charger les groupes const groupsData = await this.loadGroups(); const groups = groupsData.groups.filter(g => g.type === type); - + let groupsHtml = ''; if (groups.length === 0) { groupsHtml = ` @@ -3815,7 +3815,7 @@ class DashboardManager {
`; } - + this.showModal(`Gérer les ${typeLabelPlural}`, `
@@ -3836,14 +3836,14 @@ class DashboardManager {
`); } - + async showEditGroupModal(groupName, type) { const typeLabel = type === 'env' ? 'environnement' : 'rôle'; const prefix = type === 'env' ? 'env_' : 'role_'; const icon = type === 'env' ? 'fa-globe' : 'fa-tags'; const color = type === 'env' ? 'green' : 'blue'; const displayName = groupName.replace(prefix, ''); - + this.showModal(`Modifier le groupe: ${displayName}`, `
@@ -3875,47 +3875,47 @@ class DashboardManager { `); } - + async updateGroup(event, groupName, type) { event.preventDefault(); const formData = new FormData(event.target); - + const payload = { new_name: formData.get('new_name') }; - + this.closeModal(); this.showLoading(); - + try { const result = await this.apiCall(`/api/groups/${encodeURIComponent(groupName)}`, { method: 'PUT', body: JSON.stringify(payload) }); - + this.hideLoading(); this.showNotification(result.message || `Groupe modifié avec succès`, 'success'); - + // Recharger les groupes et afficher la liste await this.loadHostGroups(); await this.loadAllData(); this.showManageGroupsModal(type); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async confirmDeleteGroup(groupName, type, hostsCount) { const typeLabel = type === 'env' ? 'environnement' : 'rôle'; const prefix = type === 'env' ? 'env_' : 'role_'; const displayName = groupName.replace(prefix, ''); - + // Charger les autres groupes du même type pour le déplacement const groupsData = await this.loadGroups(); const otherGroups = groupsData.groups.filter(g => g.type === type && g.name !== groupName); - + let moveOptions = ''; if (hostsCount > 0 && type === 'env') { // Pour les groupes d'environnement avec des hôtes, on doit proposer un déplacement @@ -3925,16 +3925,16 @@ class DashboardManager { Déplacer les hôtes vers:

Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.

`; } - + this.showModal('Confirmer la suppression', `
@@ -3969,42 +3969,42 @@ class DashboardManager {
`); } - + async deleteGroup(groupName, type) { // Récupérer le groupe de destination si spécifié const moveSelect = document.getElementById('move-hosts-to'); const moveHostsTo = moveSelect ? moveSelect.value : null; - + this.closeModal(); this.showLoading(); - + try { let url = `/api/groups/${encodeURIComponent(groupName)}`; if (moveHostsTo) { url += `?move_hosts_to=${encodeURIComponent(moveHostsTo)}`; } - + const result = await this.apiCall(url, { method: 'DELETE' }); - + this.hideLoading(); this.showNotification(result.message || `Groupe supprimé avec succès`, 'success'); - + // Recharger les groupes et afficher la liste await this.loadHostGroups(); await this.loadAllData(); this.showManageGroupsModal(type); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async executePlaybookOnGroup() { const currentGroup = this.currentGroupFilter; - + // Charger les playbooks compatibles avec ce groupe let compatiblePlaybooks = []; try { @@ -4014,7 +4014,7 @@ class DashboardManager { this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error'); return; } - + // Générer la liste des playbooks groupés par catégorie const categoryColors = { 'maintenance': 'text-orange-400', @@ -4023,14 +4023,14 @@ class DashboardManager { 'general': 'text-gray-400', 'testing': 'text-purple-400' }; - + let playbooksByCategory = {}; compatiblePlaybooks.forEach(pb => { const cat = pb.category || 'general'; if (!playbooksByCategory[cat]) playbooksByCategory[cat] = []; playbooksByCategory[cat].push(pb); }); - + let playbooksHtml = ''; Object.entries(playbooksByCategory).forEach(([category, playbooks]) => { const colorClass = categoryColors[category] || 'text-gray-400'; @@ -4056,7 +4056,7 @@ class DashboardManager {
`; }); - + this.showModal(`Exécuter un Playbook sur "${currentGroup === 'all' ? 'Tous les hôtes' : currentGroup}"`, `
@@ -4076,7 +4076,7 @@ class DashboardManager {
`); } - + async runPlaybookOnTarget(playbook, target) { this.closeModal(); if (this._playbookLaunchInFlight) { @@ -4085,7 +4085,7 @@ class DashboardManager { } this._playbookLaunchInFlight = true; this.showNotification('Lancement du playbook en arrière-plan...', 'info'); - + try { const result = await this.apiCall('/api/ansible/execute', { method: 'POST', @@ -4096,75 +4096,75 @@ class DashboardManager { verbose: true }) }); - + this.showNotification(`Playbook ${playbook} lancé sur ${target} (tâche ${result.task_id})`, 'success'); - + } catch (error) { this.showNotification(`Erreur: ${error.message}`, 'error'); } finally { this._playbookLaunchInFlight = false; } } - + renderTasks() { const container = document.getElementById('tasks-list'); if (!container) return; - + // Combiner les tâches en mémoire avec les logs markdown const runningTasks = this.tasks.filter(t => t.status === 'running' || t.status === 'pending'); - + // Filtrer les logs selon les filtres actifs let filteredLogs = this.taskLogs; if (this.currentStatusFilter && this.currentStatusFilter !== 'all') { filteredLogs = filteredLogs.filter(log => log.status === this.currentStatusFilter); } - + // Générer les options de catégories - const categoryOptions = Object.keys(this.playbookCategories).map(cat => + const categoryOptions = Object.keys(this.playbookCategories).map(cat => `` ).join(''); - + // Générer les options de sous-catégories selon la catégorie sélectionnée let subcategoryOptions = ''; if (this.currentCategoryFilter !== 'all' && this.playbookCategories[this.currentCategoryFilter]) { - subcategoryOptions = this.playbookCategories[this.currentCategoryFilter].map(sub => + subcategoryOptions = this.playbookCategories[this.currentCategoryFilter].map(sub => `` ).join(''); } - + // Générer les options de target (groupes + hôtes) - const groupOptions = this.ansibleGroups.map(g => + const groupOptions = this.ansibleGroups.map(g => `` ).join(''); - const hostOptions = this.hosts.map(h => + const hostOptions = this.hosts.map(h => `` ).join(''); - + // Catégories dynamiques pour le filtre const taskCategories = ['Playbook', 'Ad-hoc', 'Autre']; - const taskCategoryOptions = taskCategories.map(cat => + const taskCategoryOptions = taskCategories.map(cat => `` ).join(''); - + // Types de source pour le filtre const sourceTypes = [ { value: 'scheduled', label: 'Planifiés' }, { value: 'manual', label: 'Manuels' }, { value: 'adhoc', label: 'Ad-hoc' } ]; - const sourceTypeOptions = sourceTypes.map(st => + const sourceTypeOptions = sourceTypes.map(st => `` ).join(''); - + // Vérifier si des filtres sont actifs const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') || - (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') || - (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') || - (this.currentHourStart || this.currentHourEnd); - + (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') || + (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') || + (this.currentHourStart || this.currentHourEnd); + // Labels pour les types de source const sourceTypeLabels = { scheduled: 'Planifiés', manual: 'Manuels', adhoc: 'Ad-hoc' }; - + // Générer les badges de filtres actifs const activeFiltersHtml = hasActiveFilters ? `
@@ -4206,7 +4206,7 @@ class DashboardManager {
` : ''; - + // Header avec filtres de catégorie, target et bouton console const headerHtml = `
@@ -4250,35 +4250,35 @@ class DashboardManager { ${activeFiltersHtml}
`; - + container.innerHTML = headerHtml; - + // Afficher d'abord les tâches en cours (section dynamique) if (runningTasks.length > 0) { const runningSection = document.createElement('div'); runningSection.className = 'running-tasks-section mb-4'; runningSection.innerHTML = '

En cours

'; - + const tasksContainer = document.createElement('div'); tasksContainer.className = 'running-tasks-list space-y-2'; tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join(''); runningSection.appendChild(tasksContainer); - + container.appendChild(runningSection); } - + // Afficher les logs markdown if (filteredLogs.length > 0) { const logsSection = document.createElement('div'); logsSection.id = 'task-logs-section'; logsSection.innerHTML = '

Historique des tâches

'; - + // Afficher tous les logs chargés (pagination côté serveur) filteredLogs.forEach(log => { logsSection.appendChild(this.createTaskLogCard(log)); }); container.appendChild(logsSection); - + // Afficher la pagination si nécessaire (basée sur la pagination serveur) const paginationEl = document.getElementById('tasks-pagination'); if (paginationEl) { @@ -4304,7 +4304,7 @@ class DashboardManager { `; } } - + async loadMoreTasks() { // Charger plus de tâches depuis le serveur (pagination côté serveur) const params = new URLSearchParams(); @@ -4332,33 +4332,33 @@ class DashboardManager { if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') { params.append('source_type', this.currentSourceTypeFilter); } - + // Pagination: charger la page suivante params.append('limit', this.tasksPerPage); params.append('offset', this.taskLogs.length); - + try { const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`); const newLogs = result.logs || []; - + // Ajouter les nouveaux logs à la liste existante this.taskLogs = [...this.taskLogs, ...newLogs]; this.tasksTotalCount = result.total_count || this.tasksTotalCount; this.tasksHasMore = result.has_more || false; this.tasksDisplayedCount = this.taskLogs.length; - + // Récupérer la section des logs const logsSection = document.getElementById('task-logs-section'); if (!logsSection) { this.renderTasks(); return; } - + // Ajouter les nouvelles tâches au DOM for (const log of newLogs) { logsSection.appendChild(this.createTaskLogCard(log)); } - + // Mettre à jour le bouton de pagination const paginationEl = document.getElementById('tasks-pagination'); if (paginationEl) { @@ -4377,7 +4377,7 @@ class DashboardManager { console.error('Erreur chargement logs supplémentaires:', error); } } - + createTaskLogCard(log) { const statusColors = { 'completed': 'border-green-500 bg-green-500/10', @@ -4385,14 +4385,14 @@ class DashboardManager { 'running': 'border-blue-500 bg-blue-500/10', 'pending': 'border-yellow-500 bg-yellow-500/10' }; - + const statusIcons = { 'completed': '', 'failed': '', 'running': '', 'pending': '' }; - + // Formater les heures de début et fin const formatTime = (isoString) => { if (!isoString || isoString === 'N/A') return null; @@ -4404,7 +4404,7 @@ class DashboardManager { return null; } }; - + // Formater la durée const formatDuration = (seconds) => { if (!seconds || seconds <= 0) return null; @@ -4422,13 +4422,13 @@ class DashboardManager { if (secs > 0) result += ` ${secs} seconde${secs > 1 ? 's' : ''}`; return result; }; - + const startTime = formatTime(log.start_time); const endTime = formatTime(log.end_time); const duration = log.duration_seconds ? formatDuration(log.duration_seconds) : (log.duration && log.duration !== 'N/A' ? log.duration : null); - + // Générer les badges d'hôtes - const hostsHtml = log.hosts && log.hosts.length > 0 + const hostsHtml = log.hosts && log.hosts.length > 0 ? `
${log.hosts.slice(0, 8).map(host => ` 8 ? `+${log.hosts.length - 8} autres` : ''}
` : ''; - + // Badge de catégorie - const categoryBadge = log.category - ? ` + 'bg-gray-600/30 text-gray-300 border border-gray-500/50' + }" onclick="event.stopPropagation(); dashboard.filterByCategory('${this.escapeHtml(log.category)}')" title="Filtrer par catégorie"> ${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''} ` : ''; - + // Cible cliquable - const targetHtml = log.target + const targetHtml = log.target ? ` ${this.escapeHtml(log.target)} ` : ''; - + const card = document.createElement('div'); card.className = `host-card border-l-4 ${statusColors[log.status] || 'border-gray-500'} cursor-pointer hover:bg-opacity-20 transition-all`; card.onclick = () => this.viewTaskLogContent(log.id); @@ -4504,7 +4503,7 @@ class DashboardManager { `; return card; } - + // Nouvelles fonctions de filtrage par clic filterByHost(host) { this.currentTargetFilter = host; @@ -4512,14 +4511,14 @@ class DashboardManager { this.loadTaskLogsWithFilters(); this.showNotification(`Filtre appliqué: ${host}`, 'info'); } - + filterByTarget(target) { this.currentTargetFilter = target; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification(`Filtre appliqué: ${target}`, 'info'); } - + filterByCategory(category) { this.currentCategoryFilter = category; this.currentSubcategoryFilter = 'all'; @@ -4527,25 +4526,25 @@ class DashboardManager { this.loadTaskLogsWithFilters(); this.showNotification(`Filtre catégorie: ${category}`, 'info'); } - + async viewTaskLogContent(logId) { try { const result = await this.apiCall(`/api/tasks/logs/${logId}`); const parsed = this.parseTaskLogMarkdown(result.content); - + // Détecter si c'est une sortie de playbook structurée const isPlaybookOutput = parsed.output && ( - parsed.output.includes('PLAY [') || + parsed.output.includes('PLAY [') || parsed.output.includes('TASK [') || parsed.output.includes('PLAY RECAP') ); - + // Si c'est un playbook, utiliser la vue structurée if (isPlaybookOutput) { const parsedPlaybook = this.parseAnsiblePlaybookOutput(parsed.output); this.currentParsedOutput = parsedPlaybook; this.currentTaskLogRawOutput = parsed.output; - + // Mémoriser les métadonnées et le titre pour pouvoir revenir au résumé this.currentStructuredPlaybookMetadata = { duration: parsed.duration || 'N/A', @@ -4553,20 +4552,20 @@ class DashboardManager { target: result.log.target || parsed.target || 'N/A' }; this.currentStructuredPlaybookTitle = `Log: ${result.log.task_name}`; - + this.showStructuredPlaybookViewModal(); return; } - + // Sinon, utiliser la vue structurée ad-hoc (similaire aux playbooks) const hostOutputs = this.parseOutputByHost(parsed.output); this.currentTaskLogHostOutputs = hostOutputs; - + // Compter les succès/échecs const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length; const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length; const totalHosts = hostOutputs.length; - + // Utiliser la vue structurée ad-hoc si plusieurs hôtes if (totalHosts > 0) { const isSuccess = result.log.status === 'completed'; @@ -4579,7 +4578,7 @@ class DashboardManager { isSuccess: isSuccess, error: parsed.error }); - + this.currentAdHocMetadata = { taskName: result.log.task_name, target: result.log.target || parsed.target || 'N/A', @@ -4590,7 +4589,7 @@ class DashboardManager { error: parsed.error }; this.currentAdHocTitle = `Log: ${result.log.task_name}`; - + this.showModal(this.currentAdHocTitle, `
${adHocView} @@ -4603,7 +4602,7 @@ class DashboardManager { `); return; } - + // Déterminer le statut global const isSuccess = result.log.status === 'completed'; const statusConfig = { @@ -4613,7 +4612,7 @@ class DashboardManager { pending: { icon: 'fa-clock', color: 'yellow', text: 'En attente' } }; const status = statusConfig[result.log.status] || statusConfig.failed; - + // Générer les onglets des hôtes let hostTabsHtml = ''; if (hostOutputs.length > 1 || (hostOutputs.length === 1 && hostOutputs[0].hostname !== 'output')) { @@ -4633,17 +4632,17 @@ class DashboardManager {
${hostOutputs.map((host, index) => { - const hostStatusColor = (host.status === 'changed' || host.status === 'success') - ? 'bg-green-600/80 hover:bg-green-500 border-green-500' - : (host.status === 'failed' || host.status === 'unreachable') - ? 'bg-red-600/80 hover:bg-red-500 border-red-500' - : 'bg-gray-600/80 hover:bg-gray-500 border-gray-500'; - const hostStatusIcon = (host.status === 'changed' || host.status === 'success') - ? 'fa-check' - : (host.status === 'failed' || host.status === 'unreachable') - ? 'fa-times' - : 'fa-minus'; - return ` + const hostStatusColor = (host.status === 'changed' || host.status === 'success') + ? 'bg-green-600/80 hover:bg-green-500 border-green-500' + : (host.status === 'failed' || host.status === 'unreachable') + ? 'bg-red-600/80 hover:bg-red-500 border-red-500' + : 'bg-gray-600/80 hover:bg-gray-500 border-gray-500'; + const hostStatusIcon = (host.status === 'changed' || host.status === 'success') + ? 'fa-check' + : (host.status === 'failed' || host.status === 'unreachable') + ? 'fa-times' + : 'fa-minus'; + return ` `; - }).join('')} + }).join('')}
`; } - + // Contenu du modal amélioré const modalContent = `
@@ -4732,12 +4731,12 @@ class DashboardManager {
`; - + this.showModal(`Log: ${result.log.task_name}`, modalContent); - + // Stocker la sortie brute pour la copie this.currentTaskLogRawOutput = parsed.output; - + } catch (error) { this.showNotification(`Erreur: ${error.message}`, 'error'); } @@ -4745,12 +4744,12 @@ class DashboardManager { showStructuredPlaybookViewModal() { if (!this.currentParsedOutput) return; - + const metadata = this.currentStructuredPlaybookMetadata || {}; const structuredView = this.renderStructuredPlaybookView(this.currentParsedOutput, metadata); const title = this.currentStructuredPlaybookTitle || 'Résultat Playbook'; const rawOutput = this.currentTaskLogRawOutput || ''; - + this.showModal(title, `
${structuredView} @@ -4761,7 +4760,7 @@ class DashboardManager {
`); - + // Remplir la sortie brute formatée setTimeout(() => { const rawEl = document.getElementById('ansible-raw-output'); @@ -4774,7 +4773,7 @@ class DashboardManager { returnToStructuredPlaybookView() { this.showStructuredPlaybookViewModal(); } - + renderAdHocStructuredView(hostOutputs, metadata) { /** * Génère une vue structurée pour les commandes ad-hoc (similaire aux playbooks) @@ -4784,12 +4783,12 @@ class DashboardManager { const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length; const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length; const successRate = totalHosts > 0 ? Math.round((successCount / totalHosts) * 100) : 0; - + // Générer les cartes d'hôtes const hostCardsHtml = hostOutputs.map(host => { const isFailed = host.status === 'failed' || host.status === 'unreachable'; const hasChanges = host.status === 'changed'; - + let statusClass, statusIcon; if (isFailed) { statusClass = 'border-red-500/50 bg-red-900/20'; @@ -4801,9 +4800,9 @@ class DashboardManager { statusClass = 'border-green-500/50 bg-green-900/20'; statusIcon = ''; } - + const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok'); - + return `
`; }).join(''); - + return `
@@ -4950,7 +4949,7 @@ class DashboardManager {
`; } - + showAdHocHostDetails(hostname) { const hostOutputs = this.currentTaskLogHostOutputs || []; const host = hostOutputs.find(h => h.hostname === hostname); @@ -4958,10 +4957,10 @@ class DashboardManager { this.showNotification('Hôte non trouvé', 'error'); return; } - + const isFailed = host.status === 'failed' || host.status === 'unreachable'; const hasChanges = host.status === 'changed'; - + let statusClass, statusIcon, statusText; if (isFailed) { statusClass = 'bg-red-900/30 border-red-700/50'; @@ -4976,7 +4975,7 @@ class DashboardManager { statusIcon = ''; statusText = 'OK'; } - + const content = `
@@ -5020,16 +5019,16 @@ class DashboardManager {
`; - + this.showModal(`Détails: ${hostname}`, content); } - + returnToAdHocView() { if (!this.currentTaskLogHostOutputs || !this.currentAdHocMetadata) return; - + const adHocView = this.renderAdHocStructuredView(this.currentTaskLogHostOutputs, this.currentAdHocMetadata); const title = this.currentAdHocTitle || 'Résultat Ad-Hoc'; - + this.showModal(title, `
${adHocView} @@ -5041,22 +5040,22 @@ class DashboardManager {
`); } - + filterAdHocViewByStatus(status) { const cards = document.querySelectorAll('.adhoc-host-cards-grid .host-card-item'); const buttons = document.querySelectorAll('.host-status-section .av-filter-btn'); - + buttons.forEach(btn => { btn.classList.remove('active', 'bg-purple-600/50', 'text-white'); btn.classList.add('bg-gray-700/50', 'text-gray-400'); }); - + const activeBtn = document.querySelector(`.host-status-section .av-filter-btn[data-filter="${status}"]`); if (activeBtn) { activeBtn.classList.remove('bg-gray-700/50', 'text-gray-400'); activeBtn.classList.add('active', 'bg-purple-600/50', 'text-white'); } - + cards.forEach(card => { const cardStatus = card.dataset.status; if (status === 'all' || cardStatus === status) { @@ -5066,7 +5065,7 @@ class DashboardManager { } }); } - + parseTaskLogMarkdown(content) { // Parser le contenu markdown pour extraire les métadonnées const result = { @@ -5082,11 +5081,11 @@ class DashboardManager { error: '', returnCode: undefined }; - + // Extraire le nom de la tâche du titre const titleMatch = content.match(/^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$/m); if (titleMatch) result.taskName = titleMatch[1].trim(); - + // Extraire les valeurs de la table d'informations const tablePatterns = { id: /\|\s*\*\*ID\*\*\s*\|\s*`([^`]+)`/, @@ -5097,7 +5096,7 @@ class DashboardManager { endTime: /\|\s*\*\*Fin\*\*\s*\|\s*([^|]+)/, duration: /\|\s*\*\*Durée\*\*\s*\|\s*([^|]+)/ }; - + for (const [key, pattern] of Object.entries(tablePatterns)) { const match = content.match(pattern); if (match) { @@ -5108,30 +5107,30 @@ class DashboardManager { } } } - + // Extraire la sortie const outputMatch = content.match(/## Sortie\s*```([\s\S]*?)```/m); if (outputMatch) { result.output = outputMatch[1].trim(); - + // Essayer d'extraire le return code de la sortie const rcMatch = result.output.match(/rc=(\d+)/); if (rcMatch) { result.returnCode = parseInt(rcMatch[1]); } } - + // Extraire les erreurs const errorMatch = content.match(/## Erreurs\s*```([\s\S]*?)```/m); if (errorMatch) { result.error = errorMatch[1].trim(); } - + return result; } - + // ===== PARSER ANSIBLE INTELLIGENT ===== - + parseAnsiblePlaybookOutput(output) { /** * Parser intelligent pour extraire la structure complète d'une exécution Ansible @@ -5154,12 +5153,12 @@ class DashboardManager { changedRate: 0 } }; - + const lines = output.split('\n'); let currentPlay = null; let currentTask = null; let inRecap = false; - + // Patterns de détection const patterns = { config: /^Using\s+(.+)\s+as config file$/, @@ -5170,18 +5169,18 @@ class DashboardManager { recap: /^PLAY RECAP\s*\*+$/, recapLine: /^([\w\.\-]+)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)(?:\s+skipped=(\d+))?(?:\s+rescued=(\d+))?(?:\s+ignored=(\d+))?/ }; - + for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; - + // Détecter le fichier de configuration let match = line.match(patterns.config); if (match) { result.metadata.configFile = match[1]; continue; } - + // Détecter un PLAY match = line.match(patterns.play); if (match) { @@ -5196,7 +5195,7 @@ class DashboardManager { } continue; } - + // Détecter une TASK match = line.match(patterns.task); if (match) { @@ -5211,13 +5210,13 @@ class DashboardManager { result.stats.totalTasks++; continue; } - + // Détecter PLAY RECAP if (patterns.recap.test(line)) { inRecap = true; continue; } - + // Parser les lignes de RECAP if (inRecap) { match = line.match(patterns.recapLine); @@ -5232,7 +5231,7 @@ class DashboardManager { rescued: parseInt(match[7]) || 0, ignored: parseInt(match[8]) || 0 }; - + // Déterminer le statut global de l'hôte const stats = result.recap[hostname]; if (stats.failed > 0 || stats.unreachable > 0) { @@ -5245,14 +5244,14 @@ class DashboardManager { } continue; } - + // Détecter les résultats par hôte (format standard) match = line.match(patterns.hostResult); if (match && currentTask) { const status = match[1].toLowerCase(); const hostname = match[2]; let outputData = match[3] || ''; - + // Collecter les lignes suivantes si c'est un JSON multi-lignes if (outputData.includes('{') && !outputData.includes('}')) { let braceCount = (outputData.match(/{/g) || []).length - (outputData.match(/}/g) || []).length; @@ -5262,14 +5261,14 @@ class DashboardManager { braceCount += (lines[i].match(/{/g) || []).length - (lines[i].match(/}/g) || []).length; } } - + const hostResult = { hostname, status, output: outputData, taskName: currentTask.name }; - + // Parser le JSON si présent try { const jsonMatch = outputData.match(/=>\s*({[\s\S]*})/m) || outputData.match(/^({[\s\S]*})$/m); @@ -5279,9 +5278,9 @@ class DashboardManager { } catch (e) { // Ignorer les erreurs de parsing JSON } - + currentTask.hostResults.push(hostResult); - + // Enregistrer l'hôte s'il n'existe pas if (!result.hosts[hostname]) { result.hosts[hostname] = { taskResults: [], globalStatus: 'unknown' }; @@ -5289,20 +5288,20 @@ class DashboardManager { result.hosts[hostname].taskResults.push(hostResult); continue; } - + // Format alternatif (hostname | STATUS | rc=X >>) match = line.match(patterns.hostResultAlt); if (match && currentTask) { const hostname = match[1]; const status = match[2].toLowerCase(); const rc = match[3] ? parseInt(match[3]) : 0; - + // Collecter la sortie sur les lignes suivantes let outputLines = []; while (i + 1 < lines.length) { const nextLine = lines[i + 1]; - if (nextLine.match(patterns.hostResultAlt) || - nextLine.match(patterns.task) || + if (nextLine.match(patterns.hostResultAlt) || + nextLine.match(patterns.task) || nextLine.match(patterns.play) || nextLine.match(patterns.recap)) { break; @@ -5310,7 +5309,7 @@ class DashboardManager { i++; outputLines.push(lines[i]); } - + const hostResult = { hostname, status, @@ -5318,19 +5317,19 @@ class DashboardManager { output: outputLines.join('\n'), taskName: currentTask.name }; - + currentTask.hostResults.push(hostResult); - + if (!result.hosts[hostname]) { result.hosts[hostname] = { taskResults: [], globalStatus: 'unknown' }; } result.hosts[hostname].taskResults.push(hostResult); } } - + // Calculer les statistiques result.stats.totalHosts = Object.keys(result.hosts).length; - + if (Object.keys(result.recap).length > 0) { let totalOk = 0, totalChanged = 0, totalFailed = 0, totalTasks = 0; for (const stats of Object.values(result.recap)) { @@ -5342,17 +5341,17 @@ class DashboardManager { result.stats.successRate = totalTasks > 0 ? Math.round(((totalOk + totalChanged) / totalTasks) * 100) : 0; result.stats.changedRate = totalTasks > 0 ? Math.round((totalChanged / totalTasks) * 100) : 0; } - + return result; } - + detectPlaybookType(parsedOutput) { /** * Détecte automatiquement le type de playbook basé sur le contenu */ const taskNames = parsedOutput.plays.flatMap(p => p.tasks.map(t => t.name.toLowerCase())); const playName = (parsedOutput.metadata.playbookName || '').toLowerCase(); - + // Patterns de détection const patterns = { healthCheck: ['health', 'check', 'ping', 'uptime', 'status', 'monitor', 'disk', 'memory', 'cpu'], @@ -5362,19 +5361,19 @@ class DashboardManager { security: ['security', 'firewall', 'ssl', 'certificate', 'password', 'key'], maintenance: ['clean', 'prune', 'update', 'patch', 'restart', 'reboot'] }; - + for (const [type, keywords] of Object.entries(patterns)) { - const matchScore = keywords.filter(kw => + const matchScore = keywords.filter(kw => playName.includes(kw) || taskNames.some(t => t.includes(kw)) ).length; if (matchScore >= 2 || playName.includes(type.toLowerCase())) { return type; } } - + return 'general'; } - + renderStructuredPlaybookView(parsedOutput, metadata = {}) { /** * Génère le HTML structuré pour l'affichage du playbook @@ -5382,7 +5381,7 @@ class DashboardManager { const playbookType = this.detectPlaybookType(parsedOutput); const isSuccess = !Object.values(parsedOutput.recap).some(r => r.failed > 0 || r.unreachable > 0); const hasChanges = Object.values(parsedOutput.recap).some(r => r.changed > 0); - + // Icônes par type de playbook const typeIcons = { healthCheck: 'fa-heartbeat', @@ -5393,7 +5392,7 @@ class DashboardManager { maintenance: 'fa-tools', general: 'fa-play-circle' }; - + const typeLabels = { healthCheck: 'Health Check', deployment: 'Déploiement', @@ -5403,16 +5402,16 @@ class DashboardManager { maintenance: 'Maintenance', general: 'Playbook' }; - + // Générer les cartes d'hôtes const hostCardsHtml = this.renderHostStatusCards(parsedOutput); - + // Générer l'arborescence des tâches const taskTreeHtml = this.renderTaskHierarchy(parsedOutput); - + // Générer les statistiques const statsHtml = this.renderExecutionStats(parsedOutput); - + return `
@@ -5553,19 +5552,19 @@ class DashboardManager { } }, 50); } - + renderHostStatusCards(parsedOutput) { const hosts = Object.entries(parsedOutput.recap); if (hosts.length === 0) { return '
Aucun hôte détecté
'; } - + return hosts.map(([hostname, stats]) => { const total = stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped; const successPercent = total > 0 ? Math.round(((stats.ok + stats.changed) / total) * 100) : 0; const isFailed = stats.failed > 0 || stats.unreachable > 0; const hasChanges = stats.changed > 0; - + let statusClass, statusIcon, statusBg; if (isFailed) { statusClass = 'border-red-500/50 bg-red-900/20'; @@ -5580,9 +5579,9 @@ class DashboardManager { statusIcon = ''; statusBg = 'bg-green-500'; } - + const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok'); - + return `
- ${stats.ok > 0 ? `
` : ''} - ${stats.changed > 0 ? `
` : ''} - ${stats.skipped > 0 ? `
` : ''} - ${stats.failed > 0 ? `
` : ''} - ${stats.unreachable > 0 ? `
` : ''} + ${stats.ok > 0 ? `
` : ''} + ${stats.changed > 0 ? `
` : ''} + ${stats.skipped > 0 ? `
` : ''} + ${stats.failed > 0 ? `
` : ''} + ${stats.unreachable > 0 ? `
` : ''}
${stats.ok} ok @@ -5615,30 +5614,30 @@ class DashboardManager { `; }).join(''); } - + renderTaskHierarchy(parsedOutput) { if (parsedOutput.plays.length === 0) { return '
Aucune tâche détectée
'; } - + return parsedOutput.plays.map((play, playIndex) => { const playTasks = play.tasks; - const allTasksSuccess = playTasks.every(t => + const allTasksSuccess = playTasks.every(t => t.hostResults.every(r => r.status === 'ok' || r.status === 'changed' || r.status === 'skipping') ); - const hasFailedTasks = playTasks.some(t => + const hasFailedTasks = playTasks.some(t => t.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable') ); - - const playStatusIcon = hasFailedTasks - ? '' + + const playStatusIcon = hasFailedTasks + ? '' : ''; - + const tasksHtml = playTasks.map((task, taskIndex) => { const hasFailures = task.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable'); const hasChanges = task.hostResults.some(r => r.status === 'changed'); const allSkipped = task.hostResults.every(r => r.status === 'skipping' || r.status === 'skipped'); - + let taskIcon, taskColor; if (hasFailures) { taskIcon = 'fa-times-circle'; @@ -5653,10 +5652,10 @@ class DashboardManager { taskIcon = 'fa-check-circle'; taskColor = 'text-green-400'; } - + const hostResultsHtml = task.hostResults.map(result => { let resultIcon, resultColor, resultBg; - switch(result.status) { + switch (result.status) { case 'ok': resultIcon = 'fa-check'; resultColor = 'text-green-400'; resultBg = 'bg-green-900/30'; break; @@ -5677,7 +5676,7 @@ class DashboardManager { default: resultIcon = 'fa-question'; resultColor = 'text-gray-400'; resultBg = 'bg-gray-800/50'; } - + // Extraire les données importantes de l'output const toPreviewString = (value) => { if (value === null || value === undefined) return ''; @@ -5702,7 +5701,7 @@ class DashboardManager { outputPreview = toPreviewString(Array.isArray(po.cmd) ? po.cmd.join(' ') : po.cmd); } } - + return `
@@ -5719,7 +5718,7 @@ class DashboardManager {
`; }).join(''); - + return `
@@ -5732,11 +5731,11 @@ class DashboardManager { ${task.hostResults.length} hôte(s)
${task.hostResults.slice(0, 5).map(r => { - const dotColor = r.status === 'ok' ? 'bg-green-500' : - r.status === 'changed' ? 'bg-yellow-500' : - r.status === 'failed' || r.status === 'fatal' ? 'bg-red-500' : 'bg-gray-500'; - return `
`; - }).join('')} + const dotColor = r.status === 'ok' ? 'bg-green-500' : + r.status === 'changed' ? 'bg-yellow-500' : + r.status === 'failed' || r.status === 'fatal' ? 'bg-red-500' : 'bg-gray-500'; + return `
`; + }).join('')} ${task.hostResults.length > 5 ? `+${task.hostResults.length - 5}` : ''}
@@ -5747,7 +5746,7 @@ class DashboardManager { `; }).join(''); - + return `
@@ -5762,13 +5761,13 @@ class DashboardManager { `; }).join(''); } - + renderExecutionStats(parsedOutput) { const recap = parsedOutput.recap; const hosts = Object.keys(recap); - + if (hosts.length === 0) return ''; - + let totalOk = 0, totalChanged = 0, totalFailed = 0, totalSkipped = 0, totalUnreachable = 0; for (const stats of Object.values(recap)) { totalOk += stats.ok; @@ -5777,10 +5776,10 @@ class DashboardManager { totalSkipped += stats.skipped; totalUnreachable += stats.unreachable; } - + const total = totalOk + totalChanged + totalFailed + totalSkipped + totalUnreachable; const successRate = total > 0 ? Math.round(((totalOk + totalChanged) / total) * 100) : 0; - + return `
@@ -5814,7 +5813,7 @@ class DashboardManager {
`; } - + // Méthodes d'interaction pour la vue structurée filterAnsibleViewByStatus(status) { document.querySelectorAll('.av-filter-btn').forEach(btn => { @@ -5823,7 +5822,7 @@ class DashboardManager { }); document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.add('active', 'bg-gray-700', 'text-gray-300'); document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.remove('bg-gray-700/50', 'text-gray-400'); - + document.querySelectorAll('.host-card-item').forEach(card => { if (status === 'all' || card.dataset.status === status) { card.style.display = ''; @@ -5832,34 +5831,34 @@ class DashboardManager { } }); } - + expandAllTasks() { document.querySelectorAll('.task-item').forEach(item => { item.setAttribute('open', 'open'); }); } - + collapseAllTasks() { document.querySelectorAll('.task-item').forEach(item => { item.removeAttribute('open'); }); } - + showHostDetails(hostname) { if (!this.currentParsedOutput || !this.currentParsedOutput.hosts[hostname]) return; - + const hostData = this.currentParsedOutput.hosts[hostname]; const recapData = this.currentParsedOutput.recap[hostname] || {}; - + const tasksHtml = hostData.taskResults.map(result => { let statusIcon, statusColor; - switch(result.status) { + switch (result.status) { case 'ok': statusIcon = 'fa-check'; statusColor = 'text-green-400'; break; case 'changed': statusIcon = 'fa-exchange-alt'; statusColor = 'text-yellow-400'; break; case 'failed': case 'fatal': statusIcon = 'fa-times'; statusColor = 'text-red-400'; break; default: statusIcon = 'fa-minus'; statusColor = 'text-gray-400'; } - + return `
@@ -5877,7 +5876,7 @@ class DashboardManager {
`; }).join(''); - + const content = `
@@ -5912,23 +5911,23 @@ class DashboardManager {
`; - + this.showModal(`Détails: ${hostname}`, content); } - + switchTaskLogHostTab(index) { if (!this.currentTaskLogHostOutputs || !this.currentTaskLogHostOutputs[index]) return; - + const hostOutput = this.currentTaskLogHostOutputs[index]; const outputPre = document.getElementById('tasklog-output'); const tabs = document.querySelectorAll('.tasklog-host-tab'); - + // Mettre à jour l'onglet actif tabs.forEach((tab, i) => { if (i === index) { const host = this.currentTaskLogHostOutputs[i]; - const statusColor = (host.status === 'changed' || host.status === 'success') - ? 'bg-green-600/80 border-green-500' + const statusColor = (host.status === 'changed' || host.status === 'success') + ? 'bg-green-600/80 border-green-500' : (host.status === 'failed' || host.status === 'unreachable') ? 'bg-red-600/80 border-red-500' : 'bg-gray-600/80 border-gray-500'; @@ -5937,28 +5936,28 @@ class DashboardManager { tab.className = 'tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700'; } }); - + // Afficher le contenu if (outputPre) { outputPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === 'changed' || hostOutput.status === 'success'); } } - + showAllTaskLogHostsOutput() { if (!this.currentTaskLogHostOutputs) return; - + const outputPre = document.getElementById('tasklog-output'); if (outputPre) { const allOutput = this.currentTaskLogHostOutputs.map(h => h.output).join('\n\n'); outputPre.innerHTML = this.formatAnsibleOutput(allOutput, true); } - + // Désélectionner tous les onglets document.querySelectorAll('.tasklog-host-tab').forEach(tab => { tab.className = 'tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700'; }); } - + copyTaskLogOutput() { const text = this.currentTaskLogRawOutput || ''; this.copyTextToClipboard(text) @@ -5969,7 +5968,7 @@ class DashboardManager { this.showNotification('Erreur lors de la copie', 'error'); }); } - + downloadTaskLog(path) { // Créer un lien pour télécharger le fichier this.showNotification('Fonctionnalité de téléchargement à implémenter', 'info'); @@ -5994,19 +5993,19 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + // ===== FILTRAGE DES TÂCHES ===== - + filterTasksByStatus(status) { this.currentStatusFilter = status; this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination - + // Mettre à jour l'apparence des boutons document.querySelectorAll('.task-filter-btn').forEach(btn => { btn.classList.remove('active', 'bg-purple-600', 'bg-blue-600', 'bg-green-600', 'bg-red-600'); btn.classList.add('bg-gray-700'); }); - + const activeBtn = document.querySelector(`.task-filter-btn[data-status="${status}"]`); if (activeBtn) { activeBtn.classList.remove('bg-gray-700'); @@ -6018,30 +6017,30 @@ class DashboardManager { }; activeBtn.classList.add('active', colorMap[status] || 'bg-purple-600'); } - + // Recharger les logs avec le filtre this.loadTaskLogsWithFilters(); } - + filterTasksByCategory(category) { this.currentCategoryFilter = category; this.currentSubcategoryFilter = 'all'; // Reset subcategory when category changes this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination this.loadTaskLogsWithFilters(); } - + filterTasksBySubcategory(subcategory) { this.currentSubcategoryFilter = subcategory; this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination this.loadTaskLogsWithFilters(); } - + filterTasksByTarget(target) { this.currentTargetFilter = target; this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination this.loadTaskLogsWithFilters(); } - + // Fonctions pour effacer les filtres individuellement clearTargetFilter() { this.currentTargetFilter = 'all'; @@ -6049,7 +6048,7 @@ class DashboardManager { this.loadTaskLogsWithFilters(); this.showNotification('Filtre cible effacé', 'info'); } - + clearCategoryFilter() { this.currentCategoryFilter = 'all'; this.currentSubcategoryFilter = 'all'; @@ -6057,7 +6056,7 @@ class DashboardManager { this.loadTaskLogsWithFilters(); this.showNotification('Filtre catégorie effacé', 'info'); } - + clearAllTaskFilters() { this.currentTargetFilter = 'all'; this.currentCategoryFilter = 'all'; @@ -6067,45 +6066,45 @@ class DashboardManager { this.currentHourStart = ''; this.currentHourEnd = ''; this.tasksDisplayedCount = this.tasksPerPage; - + // Réinitialiser les inputs d'heure const hourStartInput = document.getElementById('task-cal-hour-start'); const hourEndInput = document.getElementById('task-cal-hour-end'); if (hourStartInput) hourStartInput.value = ''; if (hourEndInput) hourEndInput.value = ''; - + this.loadTaskLogsWithFilters(); this.showNotification('Tous les filtres effacés', 'info'); } - + filterTasksBySourceType(sourceType) { this.currentSourceTypeFilter = sourceType; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); } - + clearSourceTypeFilter() { this.currentSourceTypeFilter = 'all'; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification('Filtre type effacé', 'info'); } - + clearHourFilter() { this.currentHourStart = ''; this.currentHourEnd = ''; - + // Réinitialiser les inputs d'heure const hourStartInput = document.getElementById('task-cal-hour-start'); const hourEndInput = document.getElementById('task-cal-hour-end'); if (hourStartInput) hourStartInput.value = ''; if (hourEndInput) hourEndInput.value = ''; - + this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification('Filtre horaire effacé', 'info'); } - + async loadTaskLogsWithFilters() { // Afficher un indicateur de chargement inline (pas le loader global) const container = document.getElementById('tasks-list'); @@ -6126,7 +6125,7 @@ class DashboardManager { `; } } - + const params = new URLSearchParams(); if (this.currentStatusFilter && this.currentStatusFilter !== 'all') { params.append('status', this.currentStatusFilter); @@ -6165,7 +6164,7 @@ class DashboardManager { // Pagination côté serveur params.append('limit', this.tasksPerPage); params.append('offset', 0); // Toujours commencer à 0 lors d'un nouveau filtre - + try { const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`); this.taskLogs = result.logs || []; @@ -6178,29 +6177,29 @@ class DashboardManager { console.error('Erreur chargement logs:', error); } } - + updateTaskCounts() { // Mettre à jour les compteurs dans les boutons const stats = this.taskLogsStats || { total: 0, completed: 0, failed: 0, running: 0, pending: 0 }; const running = this.tasks.filter(t => t.status === 'running').length; - + const countAll = document.getElementById('count-all'); const countRunning = document.getElementById('count-running'); const countCompleted = document.getElementById('count-completed'); const countFailed = document.getElementById('count-failed'); - + if (countAll) countAll.textContent = (stats.total || 0) + running; if (countRunning) countRunning.textContent = running + (stats.running || 0); if (countCompleted) countCompleted.textContent = stats.completed || 0; if (countFailed) countFailed.textContent = stats.failed || 0; - + // Mettre à jour le badge principal const badge = document.getElementById('tasks-count-badge'); if (badge) badge.textContent = (stats.total || 0) + running; - + console.log('Task counts updated:', { stats, running }); } - + updateDateFilters() { // Mettre à jour le libellé du bouton et le résumé sous le calendrier const label = document.getElementById('task-date-filter-label'); @@ -6219,7 +6218,7 @@ class DashboardManager { if (label) label.textContent = text; if (summary) summary.textContent = text; } - + applyDateFilter() { // Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l'API if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { @@ -6230,34 +6229,34 @@ class DashboardManager { } else { this.currentDateFilter = { year: '', month: '', day: '' }; } - + // Récupérer les heures depuis les inputs const hourStartInput = document.getElementById('task-cal-hour-start'); const hourEndInput = document.getElementById('task-cal-hour-end'); this.currentHourStart = hourStartInput ? hourStartInput.value : ''; this.currentHourEnd = hourEndInput ? hourEndInput.value : ''; - + this.updateDateFilters(); this.loadTaskLogsWithFilters(); } - + clearDateFilters() { this.currentDateFilter = { year: '', month: '', day: '' }; this.selectedTaskDates = []; this.currentHourStart = ''; this.currentHourEnd = ''; - + // Réinitialiser les inputs d'heure const hourStartInput = document.getElementById('task-cal-hour-start'); const hourEndInput = document.getElementById('task-cal-hour-end'); if (hourStartInput) hourStartInput.value = ''; if (hourEndInput) hourEndInput.value = ''; - + this.updateDateFilters(); this.renderTaskCalendar(); this.loadTaskLogsWithFilters(); } - + async refreshTaskLogs() { // Ne pas utiliser showLoading() pour éviter le message "Exécution de la tâche..." // Afficher un indicateur de chargement inline à la place @@ -6270,20 +6269,20 @@ class DashboardManager {
`; } - + try { const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([ this.apiCall(`/api/tasks/logs?limit=${this.tasksPerPage}&offset=0`), this.apiCall('/api/tasks/logs/stats'), this.apiCall('/api/tasks/logs/dates') ]); - + this.taskLogs = taskLogsData.logs || []; this.tasksTotalCount = taskLogsData.total_count || 0; this.tasksHasMore = taskLogsData.has_more || false; this.taskLogsStats = taskStatsData; this.taskLogsDates = taskDatesData; - + this.renderTasks(); this.updateDateFilters(); this.updateTaskCounts(); @@ -6300,7 +6299,7 @@ class DashboardManager { } } } - + createTaskCard(task, isRunning) { const statusBadge = this.getStatusBadge(task.status); const progressBar = isRunning ? ` @@ -6308,12 +6307,12 @@ class DashboardManager {
` : ''; - - const startTime = task.start_time + + const startTime = task.start_time ? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--'; const duration = task.duration || '--'; - + // Icône selon le statut const statusIcon = { 'completed': '', @@ -6321,7 +6320,7 @@ class DashboardManager { 'running': '', 'pending': '' }[task.status] || ''; - + const taskCard = document.createElement('div'); taskCard.className = `host-card ${isRunning ? 'border-l-4 border-blue-500' : ''} ${task.status === 'failed' ? 'border-l-4 border-red-500' : ''}`; taskCard.innerHTML = ` @@ -6360,25 +6359,25 @@ class DashboardManager { `; return taskCard; } - + escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } - + viewTaskDetails(taskId) { const task = this.tasks.find(t => t.id === taskId); if (!task) { this.showNotification('Tâche non trouvée', 'error'); return; } - + const statusBadge = this.getStatusBadge(task.status); - const startTime = task.start_time + const startTime = task.start_time ? new Date(task.start_time).toLocaleString('fr-FR') : '--'; - + this.showModal(`Détails de la tâche #${task.id}`, `
@@ -6441,7 +6440,7 @@ class DashboardManager {
`); } - + copyToClipboard(text) { this.copyTextToClipboard(text) .then(() => { @@ -6479,24 +6478,24 @@ class DashboardManager { throw new Error('copy_failed'); } } - + async clearCompletedTasks() { const completedTasks = this.tasks.filter(t => t.status === 'completed' || t.status === 'failed'); if (completedTasks.length === 0) { this.showNotification('Aucune tâche à nettoyer', 'info'); return; } - + // Supprimer localement les tâches terminées this.tasks = this.tasks.filter(t => t.status === 'running' || t.status === 'pending'); this.renderTasks(); this.showNotification(`${completedTasks.length} tâche(s) nettoyée(s)`, 'success'); } - + async retryTask(taskId) { const task = this.tasks.find(t => t.id === taskId); if (!task) return; - + // Extraire l'action du nom de la tâche const actionMap = { 'Mise à jour système': 'upgrade', @@ -6504,16 +6503,16 @@ class DashboardManager { 'Vérification de santé': 'health-check', 'Sauvegarde': 'backup' }; - + const action = actionMap[task.name] || 'health-check'; await this.executeTask(action, task.host); } - + async cancelTask(taskId) { if (!confirm('Êtes-vous sûr de vouloir annuler cette tâche ?')) { return; } - + try { // Utiliser l'API centralisée avec JWT (Authorization: Bearer ) await this.apiCall(`/api/tasks/${taskId}/cancel`, { @@ -6521,38 +6520,38 @@ class DashboardManager { }); this.showNotification('Tâche annulée avec succès', 'success'); - + // Mettre à jour la liste des tâches const task = this.tasks.find(t => String(t.id) === String(taskId)); if (task) { task.status = 'cancelled'; task.error = 'Tâche annulée par l\'utilisateur'; } - + // Rafraîchir l'affichage this.pollRunningTasks(); this.renderTasks(); - + } catch (error) { console.error('Erreur annulation tâche:', error); this.showNotification(error.message || 'Erreur lors de l\'annulation de la tâche', 'error'); } } - + showAdHocConsole() { console.log('Opening Ad-Hoc Console with:', { adhocHistory: this.adhocHistory, adhocCategories: this.adhocCategories }); - - const hostOptions = this.hosts.map(h => + + const hostOptions = this.hosts.map(h => `` ).join(''); - - const groupOptions = this.ansibleGroups.map(g => + + const groupOptions = this.ansibleGroups.map(g => `` ).join(''); - + // Catégories par défaut si aucune n'est chargée const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [ { name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' }, @@ -6560,14 +6559,14 @@ class DashboardManager { { name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' }, { name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' } ]; - - const categoryOptions = categories.map(c => + + const categoryOptions = categories.map(c => `` ).join(''); - + // Stocker le filtre actuel (utiliser la propriété de classe) this.currentHistoryCategoryFilter = this.currentHistoryCategoryFilter || 'all'; - + // Générer la liste de l'historique groupé par catégorie const historyByCategory = {}; (this.adhocHistory || []).forEach(cmd => { @@ -6575,7 +6574,7 @@ class DashboardManager { if (!historyByCategory[cat]) historyByCategory[cat] = []; historyByCategory[cat].push(cmd); }); - + let historyHtml = ''; if (Object.keys(historyByCategory).length > 0) { Object.entries(historyByCategory).forEach(([category, commands]) => { @@ -6583,7 +6582,7 @@ class DashboardManager { if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) { return; // Skip cette catégorie } - + const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' }; historyHtml += `
@@ -6617,7 +6616,7 @@ class DashboardManager { `; }); } - + // Afficher les catégories disponibles avec filtrage et actions // Ajouter "toutes" comme option de filtrage let categoriesListHtml = ` @@ -6654,7 +6653,7 @@ class DashboardManager {
`; }); - + this.showModal('Console Ad-Hoc Ansible', `
@@ -6814,10 +6813,10 @@ class DashboardManager {
`); - + // Initialiser l'aperçu des hôtes ciblés this.updateTargetHostsPreview('all'); - + // Ajouter l'event listener pour mettre à jour l'aperçu quand la cible change const targetSelect = document.getElementById('adhoc-target'); if (targetSelect) { @@ -6826,7 +6825,7 @@ class DashboardManager { }); } } - + /** * Récupère la liste des hôtes pour une cible donnée (groupe, hôte individuel ou "all") */ @@ -6835,17 +6834,17 @@ class DashboardManager { // Tous les hôtes return this.hosts; } - + // Vérifier si c'est un groupe if (this.ansibleGroups.includes(target)) { return this.hosts.filter(h => h.groups && h.groups.includes(target)); } - + // Sinon c'est un hôte individuel const host = this.hosts.find(h => h.name === target); return host ? [host] : []; } - + /** * Met à jour l'aperçu des hôtes ciblés dans la console Ad-Hoc */ @@ -6853,14 +6852,14 @@ class DashboardManager { const listContainer = document.getElementById('adhoc-target-hosts-list'); const countSpan = document.getElementById('adhoc-target-hosts-count'); const previewContainer = document.getElementById('adhoc-target-hosts-preview'); - + if (!listContainer || !countSpan || !previewContainer) return; - + const hosts = this.getHostsForTarget(target); - + // Mettre à jour le compteur countSpan.textContent = `${hosts.length} hôte${hosts.length > 1 ? 's' : ''}`; - + // Générer les badges des hôtes if (hosts.length === 0) { listContainer.innerHTML = 'Aucun hôte trouvé'; @@ -6869,7 +6868,7 @@ class DashboardManager { } else { previewContainer.classList.remove('border-amber-700/50'); previewContainer.classList.add('border-gray-700'); - + listContainer.innerHTML = hosts.map(h => { const statusColor = h.bootstrap_ok ? 'bg-green-900/40 text-green-400 border-green-700/50' : 'bg-gray-700/50 text-gray-400 border-gray-600/50'; const statusIcon = h.bootstrap_ok ? 'fa-check-circle' : 'fa-circle'; @@ -6883,7 +6882,7 @@ class DashboardManager { }).join(''); } } - + loadHistoryCommand(command, target, module, become) { document.getElementById('adhoc-command').value = command; document.getElementById('adhoc-target').value = target; @@ -6893,7 +6892,7 @@ class DashboardManager { this.updateTargetHostsPreview(target); this.showNotification('Commande chargée depuis l\'historique', 'info'); } - + /** * Rafraîchit dynamiquement la section historique des commandes Ad-Hoc * sans recharger toute la modale @@ -6903,10 +6902,10 @@ class DashboardManager { // Récupérer l'historique mis à jour depuis l'API const historyData = await this.apiCall('/api/adhoc/history'); this.adhocHistory = historyData.commands || []; - + const historyContainer = document.getElementById('adhoc-history-container'); if (!historyContainer) return; - + // Catégories pour le rendu const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [ { name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' }, @@ -6914,7 +6913,7 @@ class DashboardManager { { name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' }, { name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' } ]; - + // Générer la liste de l'historique groupé par catégorie const historyByCategory = {}; (this.adhocHistory || []).forEach(cmd => { @@ -6922,7 +6921,7 @@ class DashboardManager { if (!historyByCategory[cat]) historyByCategory[cat] = []; historyByCategory[cat].push(cmd); }); - + let historyHtml = ''; if (Object.keys(historyByCategory).length > 0) { Object.entries(historyByCategory).forEach(([category, commands]) => { @@ -6930,7 +6929,7 @@ class DashboardManager { if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) { return; } - + const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' }; historyHtml += `
@@ -6964,28 +6963,28 @@ class DashboardManager { `; }); } - + // Mettre à jour le contenu avec animation historyContainer.style.opacity = '0.5'; historyContainer.innerHTML = historyHtml || '

Aucune commande dans l\'historique
Exécutez une commande pour la sauvegarder

'; - + // Animation de mise à jour setTimeout(() => { historyContainer.style.opacity = '1'; historyContainer.style.transition = 'opacity 0.3s ease'; }, 50); - + // Mettre à jour aussi le widget sur le dashboard this.renderAdhocWidget(); - + } catch (error) { console.error('Erreur lors du rafraîchissement de l\'historique:', error); } } - + async deleteHistoryCommand(commandId) { if (!confirm('Supprimer cette commande de l\'historique ?')) return; - + try { await this.apiCall(`/api/adhoc/history/${commandId}`, { method: 'DELETE' }); this.showNotification('Commande supprimée', 'success'); @@ -6997,12 +6996,12 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async editHistoryCommand(commandId) { - const categoryOptions = this.adhocCategories.map(c => + const categoryOptions = this.adhocCategories.map(c => `` ).join(''); - + this.showModal('Modifier la catégorie', `
@@ -7023,11 +7022,11 @@ class DashboardManager { `); } - + async updateCommandCategory(event, commandId) { event.preventDefault(); const formData = new FormData(event.target); - + try { await this.apiCall(`/api/adhoc/history/${commandId}/category?category=${encodeURIComponent(formData.get('category'))}&description=${encodeURIComponent(formData.get('description') || '')}`, { method: 'PUT' @@ -7040,7 +7039,7 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + showAddCategoryModal() { this.showModal('Ajouter une catégorie', `
@@ -7079,11 +7078,11 @@ class DashboardManager {
`); } - + async createCategory(event) { event.preventDefault(); const formData = new FormData(event.target); - + try { await this.apiCall(`/api/adhoc/categories?name=${encodeURIComponent(formData.get('name'))}&description=${encodeURIComponent(formData.get('description') || '')}&color=${encodeURIComponent(formData.get('color'))}&icon=${encodeURIComponent(formData.get('icon'))}`, { method: 'POST' @@ -7096,10 +7095,10 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + filterHistoryByCategory(category) { this.currentHistoryCategoryFilter = category; - + // Mettre à jour les boutons de filtre visuellement document.querySelectorAll('.category-filter-btn').forEach(btn => { const btnCategory = btn.getAttribute('data-category-filter'); @@ -7116,7 +7115,7 @@ class DashboardManager { } } }); - + // Filtrer les sections de l'historique document.querySelectorAll('.history-category-section').forEach(section => { const sectionCategory = section.getAttribute('data-category'); @@ -7126,7 +7125,7 @@ class DashboardManager { section.classList.add('hidden'); } }); - + // Si aucun résultat visible, afficher un message const visibleSections = document.querySelectorAll('.history-category-section:not(.hidden)'); const historyContainer = document.querySelector('.overflow-y-auto[style*="max-height: 400px"]'); @@ -7144,14 +7143,14 @@ class DashboardManager { if (emptyMsg) emptyMsg.remove(); } } - + editCategory(categoryName) { const category = this.adhocCategories.find(c => c.name === categoryName); if (!category) { this.showNotification('Catégorie non trouvée', 'error'); return; } - + this.showModal(`Modifier la catégorie: ${categoryName}`, `
@@ -7212,12 +7211,12 @@ class DashboardManager { `); } - + async updateCategory(event, originalName) { event.preventDefault(); const formData = new FormData(event.target); const newName = formData.get('name') || originalName; - + try { await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(originalName)}`, { method: 'PUT', @@ -7236,28 +7235,28 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async deleteCategory(categoryName) { if (categoryName === 'default') { this.showNotification('La catégorie par défaut ne peut pas être supprimée', 'error'); return; } - + // Compter les commandes dans cette catégorie const commandsInCategory = (this.adhocHistory || []).filter(cmd => cmd.category === categoryName).length; - + const confirmMsg = commandsInCategory > 0 ? `Supprimer la catégorie "${categoryName}" ?\n\n${commandsInCategory} commande(s) seront déplacées vers "default".` : `Supprimer la catégorie "${categoryName}" ?`; - + if (!confirm(confirmMsg)) return; - + try { await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(categoryName)}`, { method: 'DELETE' }); this.showNotification('Catégorie supprimée', 'success'); - + // Recharger les données const [categoriesData, historyData] = await Promise.all([ this.apiCall('/api/adhoc/categories'), @@ -7265,22 +7264,22 @@ class DashboardManager { ]); this.adhocCategories = categoriesData.categories || []; this.adhocHistory = historyData.commands || []; - + // Réinitialiser le filtre si on filtrait sur cette catégorie if (this.currentHistoryCategoryFilter === categoryName) { this.currentHistoryCategoryFilter = 'all'; } - + this.showAdHocConsole(); } catch (error) { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async executeAdHocCommand(event) { event.preventDefault(); const formData = new FormData(event.target); - + const payload = { target: formData.get('target'), command: formData.get('command'), @@ -7290,7 +7289,7 @@ class DashboardManager { // catégorie d'historique choisie dans le select category: formData.get('save_category') || 'default' }; - + const resultDiv = document.getElementById('adhoc-result'); const stdoutPre = document.getElementById('adhoc-stdout'); const stderrPre = document.getElementById('adhoc-stderr'); @@ -7299,7 +7298,7 @@ class DashboardManager { const resultMeta = document.getElementById('adhoc-result-meta'); const resultStats = document.getElementById('adhoc-result-stats'); const resultHeader = document.getElementById('adhoc-result-header'); - + // Reset et afficher resultDiv.classList.remove('hidden'); stderrSection.classList.add('hidden'); @@ -7309,13 +7308,13 @@ class DashboardManager { resultMeta.textContent = 'Exécution en cours...'; resultStats.innerHTML = ''; stdoutPre.innerHTML = '⏳ Exécution de la commande...'; - + try { const result = await this.apiCall('/api/ansible/adhoc', { method: 'POST', body: JSON.stringify(payload) }); - + // Mise à jour du header avec le statut if (result.success) { resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-green-900/30 border-b border-green-800/50'; @@ -7328,7 +7327,7 @@ class DashboardManager { statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50'; resultMeta.innerHTML = `Échec • Cible: ${this.escapeHtml(result.target)}`; } - + // Stats dans le header resultStats.innerHTML = `
@@ -7340,11 +7339,11 @@ class DashboardManager { Code: ${result.return_code}
`; - + // Parser et afficher le résultat avec onglets par hôte const stdoutContent = result.stdout || '(pas de sortie)'; const hostOutputs = this.parseOutputByHost(stdoutContent); - + // Si plusieurs hôtes, afficher avec onglets if (hostOutputs.length > 1) { this.renderHostTabs(hostOutputs, result.success); @@ -7352,21 +7351,21 @@ class DashboardManager { // Un seul hôte ou output non parsable stdoutPre.innerHTML = this.formatAnsibleOutput(stdoutContent, result.success); } - + // Afficher STDERR si présent if (result.stderr && result.stderr.trim()) { stderrSection.classList.remove('hidden'); stderrPre.innerHTML = this.formatAnsibleWarnings(result.stderr); } - + this.showNotification( result.success ? 'Commande exécutée avec succès' : 'Commande échouée', result.success ? 'success' : 'error' ); - + // Mettre à jour dynamiquement l'historique des commandes await this.refreshAdHocHistory(); - + } catch (error) { resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50'; statusIcon.innerHTML = ''; @@ -7377,51 +7376,51 @@ class DashboardManager { this.showNotification('Erreur lors de l\'exécution', 'error'); } } - + formatAnsibleOutput(output, isSuccess) { // Formater la sortie Ansible pour une meilleure lisibilité let formatted = this.escapeHtml(output); - + // Colorer les hosts avec statut - formatted = formatted.replace(/^(\S+)\s*\|\s*(CHANGED|SUCCESS)\s*=>/gm, + formatted = formatted.replace(/^(\S+)\s*\|\s*(CHANGED|SUCCESS)\s*=>/gm, '$1 $2 =>'); - formatted = formatted.replace(/^(\S+)\s*\|\s*(FAILED|UNREACHABLE)\s*(!)?\s*=>/gm, + formatted = formatted.replace(/^(\S+)\s*\|\s*(FAILED|UNREACHABLE)\s*(!)?\s*=>/gm, '$1 $2 =>'); - + // Colorer les clés JSON formatted = formatted.replace(/"(\w+)"\s*:/g, '"$1":'); - + // Colorer les valeurs importantes formatted = formatted.replace(/: "([^"]+)"/g, ': "$1"'); formatted = formatted.replace(/: (true|false)/g, ': $1'); formatted = formatted.replace(/: (\d+)/g, ': $1'); - + // Mettre en évidence les lignes de résumé formatted = formatted.replace(/^(PLAY RECAP \*+)$/gm, '$1'); formatted = formatted.replace(/(ok=\d+)/g, '$1'); formatted = formatted.replace(/(changed=\d+)/g, '$1'); formatted = formatted.replace(/(unreachable=\d+)/g, '$1'); formatted = formatted.replace(/(failed=\d+)/g, '$1'); - + return formatted; } - + formatAnsibleWarnings(stderr) { let formatted = this.escapeHtml(stderr); - + // Mettre en évidence les warnings formatted = formatted.replace(/\[WARNING\]:/g, '[WARNING]:'); formatted = formatted.replace(/\[DEPRECATION WARNING\]:/g, '[DEPRECATION WARNING]:'); - + // Colorer les URLs formatted = formatted.replace(/(https?:\/\/[^\s<]+)/g, '$1'); - + // Mettre en évidence les chemins de fichiers formatted = formatted.replace(/(\/[\w\-\.\/]+)/g, '$1'); - + return formatted; } - + parseOutputByHost(output) { // Parser la sortie Ansible pour séparer par hôte // Format typique: "hostname | STATUS | rc=X >>" @@ -7430,9 +7429,9 @@ class DashboardManager { let currentHost = null; let currentOutput = []; let currentStatus = 'unknown'; - + const hostPattern = /^(\S+)\s*\|\s*(CHANGED|SUCCESS|FAILED|UNREACHABLE)\s*\|?\s*rc=(\d+)\s*>>?/; - + for (const line of lines) { const match = line.match(hostPattern); if (match) { @@ -7457,7 +7456,7 @@ class DashboardManager { } } } - + // Ajouter le dernier hôte if (currentHost) { hostOutputs.push({ @@ -7466,7 +7465,7 @@ class DashboardManager { output: currentOutput.join('\n').trim() }); } - + // Si aucun hôte trouvé, retourner l'output brut if (hostOutputs.length === 0) { return [{ @@ -7475,21 +7474,21 @@ class DashboardManager { output: output }]; } - + return hostOutputs; } - + renderHostTabs(hostOutputs, isSuccess) { const stdoutSection = document.getElementById('adhoc-stdout-section'); if (!stdoutSection) return; - + // Stocker les outputs pour référence this.currentHostOutputs = hostOutputs; - + // Générer les onglets const tabsHtml = hostOutputs.map((host, index) => { - const statusColor = host.status === 'changed' || host.status === 'success' - ? 'bg-green-600 hover:bg-green-500' + const statusColor = host.status === 'changed' || host.status === 'success' + ? 'bg-green-600 hover:bg-green-500' : host.status === 'failed' || host.status === 'unreachable' ? 'bg-red-600 hover:bg-red-500' : 'bg-gray-600 hover:bg-gray-500'; @@ -7498,7 +7497,7 @@ class DashboardManager { : host.status === 'failed' || host.status === 'unreachable' ? 'fa-times' : 'fa-question'; - + return `
`); - + this.showNotification(`Bootstrap réussi pour ${host}`, 'success'); await this.loadAllData(); - + } catch (error) { this.hideLoading(); - + // Extraire les détails de l'erreur let errorDetail = error.message; let stdout = ''; let stderr = ''; - + if (error.detail && typeof error.detail === 'object') { stdout = error.detail.stdout || ''; stderr = error.detail.stderr || ''; } - + this.showModal('Erreur Bootstrap', `
@@ -8009,7 +8008,7 @@ class DashboardManager { `); } } - + manageHost(hostNameOrId) { // Support both host name and ID let host; @@ -8019,14 +8018,14 @@ class DashboardManager { host = this.hosts.find(h => h.name === hostNameOrId); } if (!host) return; - - const lastSeen = host.last_seen + + const lastSeen = host.last_seen ? new Date(host.last_seen).toLocaleString('fr-FR') : 'Jamais vérifié'; - + // Bootstrap status const bootstrapOk = host.bootstrap_ok || false; - const bootstrapDate = host.bootstrap_date + const bootstrapDate = host.bootstrap_date ? new Date(host.bootstrap_date).toLocaleString('fr-FR') : null; const bootstrapStatusHtml = bootstrapOk @@ -8039,7 +8038,7 @@ class DashboardManager { Non configuré - Bootstrap requis
`; - + this.showModal(`Gérer ${host.name}`, `
@@ -8081,7 +8080,7 @@ class DashboardManager {
`); } - + removeHost(hostId) { if (confirm('Êtes-vous sûr de vouloir supprimer cet hôte?')) { this.hosts = this.hosts.filter(h => h.id !== hostId); @@ -8090,7 +8089,7 @@ class DashboardManager { this.showNotification('Hôte supprimé avec succès!', 'success'); } } - + addTaskToList(taskType) { const taskNames = { 'upgrade-all': 'Mise à jour système', @@ -8098,7 +8097,7 @@ class DashboardManager { 'health-check': 'Vérification de santé', 'backup': 'Sauvegarde' }; - + const newTask = { id: Math.max(...this.tasks.map(t => t.id)) + 1, name: taskNames[taskType] || 'Tâche inconnue', @@ -8108,10 +8107,10 @@ class DashboardManager { startTime: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }), duration: '0s' }; - + this.tasks.unshift(newTask); this.renderTasks(); - + // Simulate task completion setTimeout(() => { const task = this.tasks.find(t => t.id === newTask.id); @@ -8122,7 +8121,7 @@ class DashboardManager { } }, 5000); } - + stopTask(taskId) { const task = this.tasks.find(t => t.id === taskId); if (task && task.status === 'running') { @@ -8132,11 +8131,11 @@ class DashboardManager { this.showNotification('Tâche arrêtée', 'warning'); } } - + viewTaskDetails(taskId) { const task = this.tasks.find(t => t.id === taskId); if (!task) return; - + this.showModal(`Détails de la tâche`, `
@@ -8158,7 +8157,7 @@ class DashboardManager {
`); } - + refreshTasks() { this.showLoading(); setTimeout(() => { @@ -8166,7 +8165,7 @@ class DashboardManager { this.showNotification('Tâches rafraîchies', 'success'); }, 1000); } - + clearLogs() { if (confirm('Êtes-vous sûr de vouloir effacer tous les logs?')) { this.logs = []; @@ -8174,18 +8173,18 @@ class DashboardManager { this.showNotification('Logs effacés avec succès!', 'success'); } } - + exportLogs() { const items = this.logsView === 'db' ? (this.logs || []) : (this.serverLogs || []); const logText = items.map(log => `${log.timestamp} [${log.level}] ${log.message}`).join('\n'); const blob = new Blob([logText], { type: 'text/plain' }); const url = URL.createObjectURL(blob); - + const a = document.createElement('a'); a.href = url; a.download = `homelab-logs-${new Date().toISOString().slice(0, 10)}.txt`; a.click(); - + URL.revokeObjectURL(url); this.showNotification('Logs exportés avec succès!', 'success'); } @@ -8231,7 +8230,7 @@ class DashboardManager { this.showNotification(error.message || 'Échec du téléchargement', 'error'); } } - + // ===== GESTION DES PLAYBOOKS ===== // Cache des résultats lint (chargé depuis l'API backend) @@ -8284,7 +8283,7 @@ class DashboardManager { ...lintResult, updated_at: new Date().toISOString() }; - + // Aussi sauvegarder en localStorage comme fallback try { const key = this.getPlaybookLintStorageKey(); @@ -8343,50 +8342,50 @@ class DashboardManager { this.showNotification('Impossible de copier dans le presse-papiers', 'error'); } } - + renderPlaybooks() { const container = document.getElementById('playbooks-list'); if (!container) return; - + // Filtrer les playbooks let filteredPlaybooks = this.playbooks; - + // Filtre par catégorie if (this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter !== 'all') { - filteredPlaybooks = filteredPlaybooks.filter(pb => + filteredPlaybooks = filteredPlaybooks.filter(pb => (pb.category || 'general').toLowerCase() === this.currentPlaybookCategoryFilter.toLowerCase() ); } - + // Filtre par recherche if (this.currentPlaybookSearch) { const search = this.currentPlaybookSearch.toLowerCase(); - filteredPlaybooks = filteredPlaybooks.filter(pb => - pb.name.toLowerCase().includes(search) || + filteredPlaybooks = filteredPlaybooks.filter(pb => + pb.name.toLowerCase().includes(search) || pb.filename.toLowerCase().includes(search) || (pb.description || '').toLowerCase().includes(search) ); } - + // Mettre à jour le compteur const countEl = document.getElementById('playbooks-count'); if (countEl) { countEl.innerHTML = `${filteredPlaybooks.length} playbook${filteredPlaybooks.length > 1 ? 's' : ''}`; } - + if (filteredPlaybooks.length === 0) { container.innerHTML = `

Aucun playbook trouvé

- ${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all' - ? '

Essayez de modifier vos filtres

' - : ''} + ${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all' + ? '

Essayez de modifier vos filtres

' + : ''}
`; return; } - + container.innerHTML = filteredPlaybooks.map(pb => this.createPlaybookCardHTML(pb)).join(''); // Mettre à jour dynamiquement les boutons de filtre de catégorie en fonction des playbooks présents @@ -8431,15 +8430,15 @@ class DashboardManager { ${buttonsHtml} `; } - + createPlaybookCardHTML(playbook) { const category = (playbook.category || 'general').toLowerCase(); const categoryClass = `playbook-category-${category}`; const categoryLabel = this.getCategoryLabel(category); - + // Calculer la taille formatée const sizeKb = playbook.size ? (playbook.size / 1024).toFixed(1) : '?'; - + // Calculer le temps relatif const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue'; @@ -8461,7 +8460,7 @@ class DashboardManager { else if (qualityScore < 90) cls = 'ok'; lintBadgeHtml = `Q ${qualityScore}`; } - + return `
@@ -8500,7 +8499,7 @@ class DashboardManager {
`; } - + getCategoryLabel(category) { const labels = { 'maintenance': 'Maintenance', @@ -8525,7 +8524,7 @@ class DashboardManager { }; return icons[category] || null; } - + getRelativeTime(dateString) { if (!dateString) return 'Date inconnue'; const date = new Date(dateString); @@ -8535,7 +8534,7 @@ class DashboardManager { const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); - + if (diffSec < 60) return 'À l\'instant'; if (diffMin < 60) return `il y a ${diffMin} min`; if (diffHour < 24) return `il y a ${diffHour}h`; @@ -8544,15 +8543,15 @@ class DashboardManager { if (diffDay < 30) return `il y a ${Math.floor(diffDay / 7)} sem.`; return date.toLocaleDateString('fr-FR'); } - + filterPlaybooks(searchText) { this.currentPlaybookSearch = searchText; this.renderPlaybooks(); } - + filterPlaybooksByCategory(category) { this.currentPlaybookCategoryFilter = category; - + // Mettre à jour les boutons de filtre document.querySelectorAll('.playbook-filter-btn').forEach(btn => { const btnCategory = btn.dataset.category; @@ -8564,10 +8563,10 @@ class DashboardManager { btn.classList.add('bg-gray-700', 'text-gray-300'); } }); - + this.renderPlaybooks(); } - + async refreshPlaybooks() { this.showLoading(); try { @@ -8582,7 +8581,7 @@ class DashboardManager { this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + async editPlaybook(filename) { this.showLoading(); try { @@ -8594,7 +8593,7 @@ class DashboardManager { this.showNotification(`Erreur chargement playbook: ${error.message}`, 'error'); } } - + showCreatePlaybookModal() { const defaultContent = `--- # Nouveau Playbook Ansible @@ -8612,7 +8611,7 @@ class DashboardManager { ansible.builtin.debug: msg: "Hello from Ansible!" `; - + this.showModal('Créer un Playbook', `
@@ -8639,30 +8638,30 @@ class DashboardManager {
`); } - + async createNewPlaybook() { const nameInput = document.getElementById('new-playbook-name'); const name = nameInput?.value.trim(); - + if (!name) { this.showNotification('Veuillez saisir un nom de fichier', 'warning'); return; } - + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { this.showNotification('Nom invalide: utilisez uniquement lettres, chiffres, tirets et underscores', 'error'); return; } - + const filename = `${name}.yml`; - + // Vérifier si le fichier existe déjà const exists = this.playbooks.some(pb => pb.filename.toLowerCase() === filename.toLowerCase()); if (exists) { this.showNotification(`Le playbook "${filename}" existe déjà`, 'error'); return; } - + const defaultContent = `--- # ${filename} # Créé le ${new Date().toLocaleDateString('fr-FR')} @@ -8679,14 +8678,14 @@ class DashboardManager { ansible.builtin.debug: msg: "Playbook ${name} exécuté avec succès!" `; - + // On ouvre directement l'éditeur avec le contenu par défaut this.showPlaybookEditor(filename, defaultContent, true); } - + showPlaybookEditor(filename, content, isNew = false) { const title = isNew ? `Créer: ${filename}` : `Modifier: ${filename}`; - + const modalContent = `
@@ -8761,9 +8760,9 @@ class DashboardManager {
`; - + this.showModal(title, modalContent); - + // Ajouter classe pour modal large setTimeout(() => { const modalCard = document.querySelector('#modal .glass-card'); @@ -8771,7 +8770,7 @@ class DashboardManager { modalCard.classList.add('playbook-editor-modal'); } }, 10); - + // Initialiser l'éditeur CodeMirror setTimeout(() => { if (window.PlaybookEditor && window.PlaybookEditor.init) { @@ -8787,7 +8786,7 @@ class DashboardManager { class="playbook-code-editor" spellcheck="false" wrap="off">${this.escapeHtml(content)}`; - + const textarea = document.getElementById('playbook-editor-content'); if (textarea) { textarea.addEventListener('input', () => this.validateYamlContent(textarea.value)); @@ -8803,7 +8802,7 @@ class DashboardManager { } } } - + // Setup du bouton lint - appeler directement PlaybookEditor si initialisé const lintBtn = document.getElementById('lint-button'); if (lintBtn) { @@ -8817,16 +8816,16 @@ class DashboardManager { } }, 100); } - + switchEditorTab(tabName) { const tabs = document.querySelectorAll('.editor-tab'); tabs.forEach(tab => { tab.classList.toggle('active', tab.dataset.tab === tabName); }); - + const editorPanel = document.getElementById('editor-panel'); const problemsPanel = document.getElementById('problems-panel-container'); - + if (tabName === 'editor') { editorPanel?.classList.remove('hidden'); problemsPanel?.classList.add('hidden'); @@ -8835,7 +8834,7 @@ class DashboardManager { problemsPanel?.classList.remove('hidden'); } } - + async runAnsibleLint(filename) { // Utiliser PlaybookEditor si initialisé if (window.PlaybookEditor?.state?.initialized) { @@ -8843,54 +8842,54 @@ class DashboardManager { await window.PlaybookEditor.runLint(); return; } - + // Fallback: récupérer le contenu du textarea const textarea = document.getElementById('playbook-editor-content'); const content = textarea?.value || ''; - + if (!content.trim()) { this.showNotification('Le contenu est vide', 'warning'); return; } - + console.log('[Dashboard] Fallback lint, content length:', content.length); - + // Fallback: appel API direct const lintBtn = document.getElementById('lint-button'); if (lintBtn) { lintBtn.innerHTML = ' Analyse...'; lintBtn.disabled = true; } - + try { const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/lint`, { method: 'POST', body: JSON.stringify({ content }) }); - + // Mettre à jour l'UI this.updateLintResults(result); - + } catch (error) { console.error('Lint error:', error); if (lintBtn) { lintBtn.innerHTML = ' Lint'; lintBtn.disabled = false; } - + if (!error.message.includes('503')) { this.showNotification(`Erreur lint: ${error.message}`, 'error'); } } } - + updateLintResults(result) { const lintBtn = document.getElementById('lint-button'); const problemsCount = document.getElementById('problems-count'); const problemsTab = document.getElementById('problems-tab'); const problemsPanel = document.getElementById('problems-panel'); const qualityBadge = document.getElementById('quality-badge'); - + const { summary, quality_score, issues, execution_time_ms } = result; // Persist last lint for this playbook (so playbooks list can show score) @@ -8905,7 +8904,7 @@ class DashboardManager { } catch (e) { // no-op } - + // Mettre à jour le bouton if (lintBtn) { lintBtn.disabled = false; @@ -8920,12 +8919,12 @@ class DashboardManager { lintBtn.className = 'lint-button success'; } } - + // Mettre à jour le compteur de problèmes if (problemsCount) { problemsCount.textContent = summary.total; } - + // Mettre à jour l'onglet problèmes if (problemsTab) { problemsTab.classList.remove('errors', 'warnings'); @@ -8935,18 +8934,18 @@ class DashboardManager { problemsTab.classList.add('warnings'); } } - + // Mettre à jour le badge de qualité if (qualityBadge) { let colorClass = 'excellent'; if (quality_score < 50) colorClass = 'poor'; else if (quality_score < 70) colorClass = 'warning'; else if (quality_score < 90) colorClass = 'good'; - + qualityBadge.innerHTML = `Quality: ${quality_score}/100`; qualityBadge.style.display = 'block'; } - + // Mettre à jour le panneau des problèmes if (problemsPanel) { if (issues.length === 0) { @@ -8959,14 +8958,14 @@ class DashboardManager { `; } else { let html = '
'; - + for (const issue of issues) { const severityIcon = { error: '', warning: '', info: '', }[issue.severity] || ''; - + html += `
@@ -8995,14 +8994,14 @@ class DashboardManager {
`; } - + html += '
'; html += `
Temps d'exécution: ${execution_time_ms}ms
`; problemsPanel.innerHTML = html; } } } - + goToEditorLine(lineNumber) { // Utiliser PlaybookEditor si disponible if (window.PlaybookEditor?.goToLine) { @@ -9010,7 +9009,7 @@ class DashboardManager { this.switchEditorTab('editor'); return; } - + // Fallback textarea const textarea = document.getElementById('playbook-editor-content'); if (textarea) { @@ -9024,7 +9023,7 @@ class DashboardManager { this.switchEditorTab('editor'); } } - + async savePlaybookEnhanced(filename, isNew = false) { // Utiliser PlaybookEditor si initialisé if (window.PlaybookEditor?.state?.initialized) { @@ -9036,7 +9035,7 @@ class DashboardManager { } return; } - + // Fallback vers la méthode originale console.log('[Dashboard] Fallback to savePlaybook()'); await this.savePlaybook(filename, isNew); @@ -9167,13 +9166,13 @@ class DashboardManager {
`); } - + async executePlaybookFromModal(filename) { const target = document.getElementById('run-playbook-target')?.value || 'all'; const varsText = document.getElementById('run-playbook-vars')?.value || ''; const checkMode = document.getElementById('run-playbook-check')?.checked || false; const verbose = document.getElementById('run-playbook-verbose')?.checked || false; - + let extraVars = {}; if (varsText.trim()) { try { @@ -9183,10 +9182,10 @@ class DashboardManager { return; } } - + this.closeModal(); this.showLoading(); - + try { const result = await this.apiCall('/api/ansible/execute', { method: 'POST', @@ -9198,7 +9197,7 @@ class DashboardManager { verbose: verbose }) }); - + this.hideLoading(); this.showNotification( `Playbook exécuté sur ${target} (tâche ${result.task_id})`, @@ -9208,13 +9207,13 @@ class DashboardManager { // Aller sur l'onglet Tâches et rafraîchir this.setActiveNav('tasks'); await this.loadTaskLogsWithFilters(); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } - + confirmDeletePlaybook(filename) { this.showModal('Confirmer la suppression', `
@@ -9244,41 +9243,41 @@ class DashboardManager {
`); } - + async deletePlaybook(filename) { this.closeModal(); this.showLoading(); - + try { await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}`, { method: 'DELETE' }); - + this.hideLoading(); this.showNotification(`Playbook "${filename}" supprimé`, 'success'); - + // Rafraîchir la liste await this.refreshPlaybooks(); - + } catch (error) { this.hideLoading(); this.showNotification(`Erreur suppression: ${error.message}`, 'error'); } } - + showModal(title, content, options = {}) { const modalCard = document.querySelector('#modal .glass-card'); document.getElementById('modal-title').textContent = title; document.getElementById('modal-content').innerHTML = content; document.getElementById('modal').classList.remove('hidden'); - + // Appliquer classe spéciale pour Ad-Hoc console if (title.includes('Ad-Hoc')) { modalCard.classList.add('adhoc-modal'); } else { modalCard.classList.remove('adhoc-modal'); } - + // Animate modal appearance anime({ targets: '#modal .glass-card', @@ -9288,12 +9287,12 @@ class DashboardManager { easing: 'easeOutExpo' }); } - + // ===== MÉTHODES DU WIDGET AD-HOC ===== - + adhocWidgetLimit = 5; adhocWidgetOffset = 0; - + /** * Rendu du widget Console Ad-Hoc sur le dashboard */ @@ -9304,24 +9303,24 @@ class DashboardManager { const failedEl = document.getElementById('adhoc-widget-failed'); const totalEl = document.getElementById('adhoc-widget-total'); const countEl = document.getElementById('adhoc-widget-count'); - + if (!historyContainer) return; - + // Calculer les stats const total = Array.isArray(this.adhocWidgetLogs) ? this.adhocWidgetLogs.length : 0; const success = (this.adhocWidgetLogs || []).filter(l => (l.status || '').toLowerCase() === 'completed').length; const failed = (this.adhocWidgetLogs || []).filter(l => (l.status || '').toLowerCase() === 'failed').length; - + // Mettre à jour les stats if (successEl) successEl.textContent = success; if (failedEl) failedEl.textContent = failed; if (totalEl) totalEl.textContent = total; if (countEl) countEl.textContent = this.adhocWidgetTotalCount > 0 ? `${this.adhocWidgetTotalCount} exécution${this.adhocWidgetTotalCount > 1 ? 's' : ''}` : ''; - + // Afficher les dernières exécutions const displayedLimit = this.adhocWidgetLimit + this.adhocWidgetOffset; const displayedLogs = (this.adhocWidgetLogs || []).slice(0, displayedLimit); - + if (displayedLogs.length === 0) { historyContainer.innerHTML = `
@@ -9333,7 +9332,7 @@ class DashboardManager { if (loadMoreBtn) loadMoreBtn.classList.add('hidden'); return; } - + historyContainer.innerHTML = displayedLogs.map(log => { const status = (log.status || '').toLowerCase(); const isSuccess = status === 'completed'; @@ -9341,18 +9340,18 @@ class DashboardManager { const statusBg = isSuccess ? 'bg-green-900/30 border-green-700/50' : 'bg-red-900/30 border-red-700/50'; const statusIcon = isSuccess ? 'fa-check-circle' : 'fa-times-circle'; const statusText = isSuccess ? 'Succès' : 'Échec'; - + // Formater la date const date = log.created_at ? new Date(log.created_at) : new Date(); const timeAgo = this.formatTimeAgo(date); - + // Extraire le nom de la commande (première partie avant |) const taskName = (log.task_name || '').trim(); const cmdName = taskName ? taskName.replace(/^ad-?hoc\s*:\s*/i, '').split(' ')[0] : 'Ad-hoc'; - + // Trouver la catégorie const catColor = '#60a5fa'; - + return `
@@ -9388,7 +9387,7 @@ class DashboardManager {
`; }).join(''); - + // Afficher/masquer le bouton "Charger plus" if (loadMoreBtn) { const moreToDisplayLocally = (this.adhocWidgetLogs || []).length > displayedLogs.length; @@ -9422,18 +9421,18 @@ class DashboardManager { } let favorites = Array.from(fm.favoritesById.values()); - + const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : ''; if (searchTerm) { favorites = favorites.filter(f => { const dc = f.docker_container; if (!dc) return false; return (dc.name || '').toLowerCase().includes(searchTerm) || - (dc.host_name || '').toLowerCase().includes(searchTerm) || - (dc.image || '').toLowerCase().includes(searchTerm); + (dc.host_name || '').toLowerCase().includes(searchTerm) || + (dc.image || '').toLowerCase().includes(searchTerm); }); } - + if (clearBtn) { clearBtn.classList.toggle('hidden', !searchTerm); } @@ -9541,29 +9540,29 @@ class DashboardManager {
${items.map(f => { - const dc = f.docker_container; - if (!dc) return ''; - const hostStatus = (dc.host_docker_status || '').toLowerCase(); - const hostOffline = hostStatus && hostStatus !== 'online'; - const dotColor = stateColor(dc.state); - const isRunning = String(dc.state || '').toLowerCase() === 'running'; - const favId = f.id; - const disabledClass = hostOffline ? 'opacity-50 cursor-not-allowed' : ''; - const btnDisabled = hostOffline ? 'disabled' : ''; - const healthBadge = dc.health && dc.health !== 'none' - ? `${this.escapeHtml(dc.health)}` - : ''; + const dc = f.docker_container; + if (!dc) return ''; + const hostStatus = (dc.host_docker_status || '').toLowerCase(); + const hostOffline = hostStatus && hostStatus !== 'online'; + const dotColor = stateColor(dc.state); + const isRunning = String(dc.state || '').toLowerCase() === 'running'; + const favId = f.id; + const disabledClass = hostOffline ? 'opacity-50 cursor-not-allowed' : ''; + const btnDisabled = hostOffline ? 'disabled' : ''; + const healthBadge = dc.health && dc.health !== 'none' + ? `${this.escapeHtml(dc.health)}` + : ''; - const custom = window.containerCustomizationsManager?.get(dc.host_id, dc.container_id); - const iconKey = custom?.icon_key || ''; - const iconColor = custom?.icon_color || '#9ca3af'; - const bgColor = custom?.bg_color || ''; - const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : ''; - const iconHtml = iconKey - ? `` - : ''; + const custom = window.containerCustomizationsManager?.get(dc.host_id, dc.container_id); + const iconKey = custom?.icon_key || ''; + const iconColor = custom?.icon_color || '#9ca3af'; + const bgColor = custom?.bg_color || ''; + const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : ''; + const iconHtml = iconKey + ? `` + : ''; - return ` + return `
@@ -9610,7 +9609,7 @@ class DashboardManager {
`; - }).join('')} + }).join('')}
`; @@ -9926,7 +9925,7 @@ class DashboardManager { this.showNotification(`Erreur: ${e.message}`, 'error'); } } - + /** * Expand all favorite groups */ @@ -10016,7 +10015,7 @@ class DashboardManager { this.showNotification('Erreur chargement historique Ad-Hoc', 'error'); } } - + /** * Formater une date en "il y a X minutes/heures/jours" */ @@ -10027,14 +10026,14 @@ class DashboardManager { const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); - + if (diffSec < 60) return 'À l\'instant'; if (diffMin < 60) return `Il y a ${diffMin}min`; if (diffHour < 24) return `Il y a ${diffHour}h`; if (diffDay < 7) return `Il y a ${diffDay}j`; return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }); } - + /** * Rejouer une commande ad-hoc depuis l'historique */ @@ -10044,16 +10043,16 @@ class DashboardManager { this.showNotification('Commande non trouvée', 'error'); return; } - + // Ouvrir la console avec la commande pré-remplie this.showAdHocConsole(); - + // Attendre que le modal soit rendu puis remplir les champs setTimeout(() => { this.loadHistoryCommand(cmd.command, cmd.target, cmd.module || 'shell', cmd.become || false); }, 100); } - + /** * Afficher les détails d'une exécution ad-hoc */ @@ -10063,16 +10062,16 @@ class DashboardManager { this.showNotification('Commande non trouvée', 'error'); return; } - + const isSuccess = cmd.return_code === 0; const statusColor = isSuccess ? 'text-green-400' : 'text-red-400'; const statusBg = isSuccess ? 'bg-green-900/30' : 'bg-red-900/30'; const statusText = isSuccess ? 'SUCCESS' : 'FAILED'; - + // Parser les résultats par hôte si disponibles let hostsResults = []; let okCount = 0, changedCount = 0, failedCount = 0; - + if (cmd.stdout) { // Essayer de parser les résultats Ansible const lines = cmd.stdout.split('\n'); @@ -10091,24 +10090,24 @@ class DashboardManager { } }); } - + // Si pas de résultats parsés, utiliser les infos de base if (hostsResults.length === 0 && cmd.hosts_count) { okCount = isSuccess ? cmd.hosts_count : 0; failedCount = isSuccess ? 0 : cmd.hosts_count; } - + const totalHosts = okCount + changedCount + failedCount || cmd.hosts_count || 1; const successRate = totalHosts > 0 ? Math.round(((okCount + changedCount) / totalHosts) * 100) : 0; - + // Formater la date const date = cmd.executed_at ? new Date(cmd.executed_at) : new Date(); const dateStr = date.toLocaleDateString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit' }); - + // Trouver la catégorie const category = this.adhocCategories.find(c => c.name === cmd.category); const catColor = category?.color || '#7c3aed'; - + this.showModal(`Log: Ad-hoc: ${cmd.command?.split(' ')[0] || 'commande'}`, `
@@ -10186,12 +10185,12 @@ class DashboardManager {
${hostsResults.map(hr => { - const hrColor = hr.status === 'SUCCESS' ? 'border-green-700/50 bg-green-900/20' : - hr.status === 'CHANGED' ? 'border-yellow-700/50 bg-yellow-900/20' : - 'border-red-700/50 bg-red-900/20'; - const hrTextColor = hr.status === 'SUCCESS' ? 'text-green-400' : - hr.status === 'CHANGED' ? 'text-yellow-400' : 'text-red-400'; - return ` + const hrColor = hr.status === 'SUCCESS' ? 'border-green-700/50 bg-green-900/20' : + hr.status === 'CHANGED' ? 'border-yellow-700/50 bg-yellow-900/20' : + 'border-red-700/50 bg-red-900/20'; + const hrTextColor = hr.status === 'SUCCESS' ? 'text-green-400' : + hr.status === 'CHANGED' ? 'text-yellow-400' : 'text-red-400'; + return `
${this.escapeHtml(hr.host)} @@ -10202,7 +10201,7 @@ class DashboardManager {
${this.escapeHtml(hr.line.substring(0, 60))}...
`; - }).join('')} + }).join('')}
` : ''} @@ -10242,7 +10241,7 @@ class DashboardManager {
`); } - + /** * Filtrer les hôtes dans le détail d'exécution */ @@ -10257,7 +10256,7 @@ class DashboardManager { btn.classList.add('text-gray-400'); } }); - + // Filtrer les résultats document.querySelectorAll('.adhoc-host-result').forEach(el => { const status = el.dataset.status; @@ -10268,45 +10267,45 @@ class DashboardManager { } }); } - + // ===== MÉTHODES DU PLANIFICATEUR (SCHEDULES) ===== - + renderSchedules() { const listContainer = document.getElementById('schedules-list'); const emptyState = document.getElementById('schedules-empty'); - + if (!listContainer) return; - + // Filtrer les schedules let filteredSchedules = [...this.schedules]; - + if (this.currentScheduleFilter === 'active') { filteredSchedules = filteredSchedules.filter(s => s.enabled); } else if (this.currentScheduleFilter === 'paused') { filteredSchedules = filteredSchedules.filter(s => !s.enabled); } - + if (this.scheduleSearchQuery) { const query = this.scheduleSearchQuery.toLowerCase(); - filteredSchedules = filteredSchedules.filter(s => + filteredSchedules = filteredSchedules.filter(s => s.name.toLowerCase().includes(query) || s.playbook.toLowerCase().includes(query) || s.target.toLowerCase().includes(query) ); } - + // Mettre à jour les stats this.updateSchedulesStats(); - + // Afficher l'état vide ou la liste if (this.schedules.length === 0) { listContainer.innerHTML = ''; emptyState?.classList.remove('hidden'); return; } - + emptyState?.classList.add('hidden'); - + if (filteredSchedules.length === 0) { listContainer.innerHTML = `
@@ -10316,35 +10315,35 @@ class DashboardManager { `; return; } - + listContainer.innerHTML = filteredSchedules.map(schedule => this.renderScheduleCard(schedule)).join(''); - + // Mettre à jour les prochaines exécutions this.renderUpcomingExecutions(); } - + renderScheduleCard(schedule) { const statusClass = schedule.enabled ? 'active' : 'paused'; const statusChipClass = schedule.enabled ? 'active' : 'paused'; const statusText = schedule.enabled ? 'Actif' : 'En pause'; - + // Formater la prochaine exécution let nextRunText = '--'; if (schedule.next_run_at) { const nextRun = new Date(schedule.next_run_at); nextRunText = this.formatRelativeTime(nextRun); } - + // Formater la dernière exécution let lastRunHtml = ''; if (schedule.last_run_at) { - const lastStatusIcon = schedule.last_status === 'success' ? '✅' : - schedule.last_status === 'failed' ? '❌' : - schedule.last_status === 'running' ? '🔄' : ''; + const lastStatusIcon = schedule.last_status === 'success' ? '✅' : + schedule.last_status === 'failed' ? '❌' : + schedule.last_status === 'running' ? '🔄' : ''; const lastRunDate = new Date(schedule.last_run_at); lastRunHtml = `| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)}`; } - + // Formater la récurrence let recurrenceText = 'Exécution unique'; if (schedule.schedule_type === 'recurring' && schedule.recurrence) { @@ -10352,7 +10351,7 @@ class DashboardManager { if (rec.type === 'daily') { recurrenceText = `Tous les jours à ${rec.time}`; } else if (rec.type === 'weekly') { - const days = (rec.days || []).map(d => ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'][d-1]).join(', '); + const days = (rec.days || []).map(d => ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'][d - 1]).join(', '); recurrenceText = `Chaque ${days} à ${rec.time}`; } else if (rec.type === 'monthly') { recurrenceText = `Le ${rec.day_of_month || 1} de chaque mois à ${rec.time}`; @@ -10360,12 +10359,12 @@ class DashboardManager { recurrenceText = `Cron: ${rec.cron_expression}`; } } - + // Tags - const tagsHtml = (schedule.tags || []).map(tag => + const tagsHtml = (schedule.tags || []).map(tag => `${tag}` ).join(''); - + return `
@@ -10418,11 +10417,11 @@ class DashboardManager {
`; } - + updateSchedulesStats() { const activeCount = this.schedules.filter(s => s.enabled).length; const pausedCount = this.schedules.filter(s => !s.enabled).length; - + const activeCountEl = document.getElementById('schedules-active-count'); if (activeCountEl) activeCountEl.textContent = activeCount; @@ -10431,14 +10430,14 @@ class DashboardManager { const failuresEl = document.getElementById('schedules-failures-24h'); if (failuresEl) failuresEl.textContent = this.schedulesStats.failures_24h || 0; - + // Dashboard widget const dashboardActiveEl = document.getElementById('dashboard-schedules-active'); if (dashboardActiveEl) dashboardActiveEl.textContent = activeCount; const dashboardFailuresEl = document.getElementById('dashboard-schedules-failures'); if (dashboardFailuresEl) dashboardFailuresEl.textContent = this.schedulesStats.failures_24h || 0; - + // Prochaine exécution const activeSchedules = this.schedules.filter(s => s.enabled && s.next_run_at); if (activeSchedules.length > 0) { @@ -10446,7 +10445,7 @@ class DashboardManager { const nextRun = new Date(activeSchedules[0].next_run_at); const now = new Date(); const diffMs = nextRun - now; - + const nextRunEl = document.getElementById('schedules-next-run'); const dashboardNextEl = document.getElementById('dashboard-schedules-next'); @@ -10471,25 +10470,25 @@ class DashboardManager { if (nextRunEl) nextRunEl.textContent = '--:--'; if (dashboardNextEl) dashboardNextEl.textContent = '--'; } - + // Update dashboard upcoming schedules this.updateDashboardUpcomingSchedules(); } - + updateDashboardUpcomingSchedules() { const container = document.getElementById('dashboard-upcoming-schedules'); if (!container) return; - + const upcoming = this.schedules .filter(s => s.enabled && s.next_run_at) .sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at)) .slice(0, 3); - + if (upcoming.length === 0) { container.innerHTML = '

Aucun schedule actif

'; return; } - + container.innerHTML = upcoming.map(s => { const nextRun = new Date(s.next_run_at); return ` @@ -10503,21 +10502,21 @@ class DashboardManager { `; }).join(''); } - + renderUpcomingExecutions() { const container = document.getElementById('schedules-upcoming'); if (!container) return; - + const activeSchedules = this.schedules .filter(s => s.enabled && s.next_run_at) .sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at)) .slice(0, 5); - + if (activeSchedules.length === 0) { container.innerHTML = '

Aucune exécution planifiée

'; return; } - + container.innerHTML = activeSchedules.map(s => { const nextRun = new Date(s.next_run_at); return ` @@ -10537,17 +10536,17 @@ class DashboardManager { `; }).join(''); } - + formatRelativeTime(date) { const now = new Date(); const diffMs = date - now; const absDiffMs = Math.abs(diffMs); const isPast = diffMs < 0; - + const mins = Math.floor(absDiffMs / (1000 * 60)); const hours = Math.floor(absDiffMs / (1000 * 60 * 60)); const days = Math.floor(absDiffMs / (1000 * 60 * 60 * 24)); - + if (days > 0) { return isPast ? `il y a ${days}j` : `dans ${days}j`; } else if (hours > 0) { @@ -10558,10 +10557,10 @@ class DashboardManager { return isPast ? 'à l\'instant' : 'imminent'; } } - + filterSchedules(filter) { this.currentScheduleFilter = filter; - + // Mettre à jour les boutons document.querySelectorAll('.schedule-filter-btn').forEach(btn => { btn.classList.remove('active', 'bg-purple-600', 'text-white'); @@ -10571,19 +10570,19 @@ class DashboardManager { btn.classList.remove('bg-gray-700', 'text-gray-300'); } }); - + this.renderSchedules(); } - + searchSchedules(query) { this.scheduleSearchQuery = query; this.renderSchedules(); } - + toggleScheduleView(view) { const listView = document.getElementById('schedules-list-view'); const calendarView = document.getElementById('schedules-calendar-view'); - + if (view === 'calendar') { listView?.classList.add('hidden'); calendarView?.classList.remove('hidden'); @@ -10593,25 +10592,25 @@ class DashboardManager { calendarView?.classList.add('hidden'); } } - + async refreshSchedules() { try { const [schedulesData, statsData] = await Promise.all([ this.apiCall('/api/schedules'), this.apiCall('/api/schedules/stats') ]); - + this.schedules = schedulesData.schedules || []; this.schedulesStats = statsData.stats || {}; this.schedulesUpcoming = statsData.upcoming || []; - + this.renderSchedules(); this.showNotification('Schedules rafraîchis', 'success'); } catch (error) { this.showNotification('Erreur lors du rafraîchissement', 'error'); } } - + async refreshSchedulesStats() { try { const statsData = await this.apiCall('/api/schedules/stats'); @@ -10622,12 +10621,12 @@ class DashboardManager { console.error('Erreur rafraîchissement stats schedules:', error); } } - + // ===== ACTIONS SCHEDULES ===== - + async runScheduleNow(scheduleId) { if (!confirm('Exécuter ce schedule immédiatement ?')) return; - + try { this.showLoading(); await this.apiCall(`/api/schedules/${scheduleId}/run`, { method: 'POST' }); @@ -10638,7 +10637,7 @@ class DashboardManager { this.hideLoading(); } } - + async pauseSchedule(scheduleId) { try { const result = await this.apiCall(`/api/schedules/${scheduleId}/pause`, { method: 'POST' }); @@ -10650,7 +10649,7 @@ class DashboardManager { this.showNotification('Erreur lors de la mise en pause', 'error'); } } - + async resumeSchedule(scheduleId) { try { const result = await this.apiCall(`/api/schedules/${scheduleId}/resume`, { method: 'POST' }); @@ -10662,13 +10661,13 @@ class DashboardManager { this.showNotification('Erreur lors de la reprise', 'error'); } } - + async deleteSchedule(scheduleId) { const schedule = this.schedules.find(s => s.id === scheduleId); if (!schedule) return; - + if (!confirm(`Supprimer le schedule "${schedule.name}" ?`)) return; - + try { await this.apiCall(`/api/schedules/${scheduleId}`, { method: 'DELETE' }); this.schedules = this.schedules.filter(s => s.id !== scheduleId); @@ -10678,13 +10677,13 @@ class DashboardManager { this.showNotification('Erreur lors de la suppression', 'error'); } } - + // ===== MODAL CRÉATION/ÉDITION SCHEDULE ===== - + async showCreateScheduleModal(prefilledPlaybook = null) { this.editingScheduleId = null; this.scheduleModalStep = 1; - + // S'assurer que les playbooks sont chargés if (!this.playbooks || this.playbooks.length === 0) { try { @@ -10694,18 +10693,18 @@ class DashboardManager { console.error('Erreur chargement playbooks:', error); } } - + const content = this.getScheduleModalContent(null, prefilledPlaybook); this.showModal('Nouveau Schedule', content, 'schedule-modal'); } - + async showEditScheduleModal(scheduleId) { const schedule = this.schedules.find(s => s.id === scheduleId); if (!schedule) return; - + this.editingScheduleId = scheduleId; this.scheduleModalStep = 1; - + // S'assurer que les playbooks sont chargés if (!this.playbooks || this.playbooks.length === 0) { try { @@ -10715,34 +10714,34 @@ class DashboardManager { console.error('Erreur chargement playbooks:', error); } } - + const content = this.getScheduleModalContent(schedule); this.showModal(`Modifier: ${schedule.name}`, content, 'schedule-modal'); } - + getScheduleModalContent(schedule = null, prefilledPlaybook = null) { const isEdit = !!schedule; const s = schedule || {}; - + // Options de playbooks - const playbookOptions = this.playbooks.map(p => + const playbookOptions = this.playbooks.map(p => `` ).join(''); - + // Options de groupes - const groupOptions = this.ansibleGroups.map(g => + const groupOptions = this.ansibleGroups.map(g => `` ).join(''); - + // Options d'hôtes - const hostOptions = this.ansibleHosts.map(h => + const hostOptions = this.ansibleHosts.map(h => `` ).join(''); - + // Récurrence const rec = s.recurrence || {}; const daysChecked = (rec.days || [1]); - + return `
1
@@ -10905,8 +10904,8 @@ class DashboardManager {
${['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day, i) => ` `).join('')} @@ -11018,19 +11017,19 @@ class DashboardManager {
`; } - + async scheduleModalNextStep() { if (this.scheduleModalStep < 4) { this.scheduleModalStep++; this.updateScheduleModalStep(); - + // Si on arrive à l'étape 4 (Notifications), vérifier si NTFY est activé if (this.scheduleModalStep === 4) { await this.checkNtfyStatus(); } } } - + async checkNtfyStatus() { try { const config = await this.apiCall('/api/notifications/config'); @@ -11042,14 +11041,14 @@ class DashboardManager { console.error('Erreur vérification statut NTFY:', error); } } - + scheduleModalPrevStep() { if (this.scheduleModalStep > 1) { this.scheduleModalStep--; this.updateScheduleModalStep(); } } - + updateScheduleModalStep() { // Mettre à jour les indicateurs document.querySelectorAll('.schedule-step-dot').forEach(dot => { @@ -11061,11 +11060,11 @@ class DashboardManager { dot.classList.add('active'); } }); - + document.querySelectorAll('.schedule-step-connector').forEach((conn, i) => { conn.classList.toggle('active', i < this.scheduleModalStep - 1); }); - + // Afficher l'étape actuelle document.querySelectorAll('.schedule-modal-step').forEach(step => { step.classList.remove('active'); @@ -11074,33 +11073,33 @@ class DashboardManager { } }); } - + toggleScheduleTargetType(type) { document.getElementById('schedule-group-select')?.classList.toggle('hidden', type === 'host'); document.getElementById('schedule-host-select')?.classList.toggle('hidden', type === 'group'); } - + toggleScheduleType(type) { document.getElementById('schedule-recurring-options')?.classList.toggle('hidden', type === 'once'); document.getElementById('schedule-once-options')?.classList.toggle('hidden', type === 'recurring'); } - + updateRecurrenceOptions() { const type = document.getElementById('schedule-recurrence-type')?.value; - + document.getElementById('recurrence-time')?.classList.toggle('hidden', type === 'custom'); document.getElementById('recurrence-weekly-days')?.classList.toggle('hidden', type !== 'weekly'); document.getElementById('recurrence-monthly-day')?.classList.toggle('hidden', type !== 'monthly'); document.getElementById('recurrence-cron')?.classList.toggle('hidden', type !== 'custom'); } - + async validateCronExpression(expression) { const container = document.getElementById('cron-validation'); if (!container || !expression.trim()) { if (container) container.innerHTML = ''; return; } - + try { const result = await this.apiCall(`/api/schedules/validate-cron?expression=${encodeURIComponent(expression)}`); if (result.valid) { @@ -11115,7 +11114,7 @@ class DashboardManager { container.innerHTML = `
Erreur de validation
`; } } - + async saveSchedule() { const name = document.getElementById('schedule-name')?.value.trim(); const description = document.getElementById('schedule-description')?.value.trim(); @@ -11127,31 +11126,31 @@ class DashboardManager { const scheduleType = document.querySelector('input[name="schedule-type"]:checked')?.value || 'recurring'; const enabled = document.getElementById('schedule-enabled')?.checked ?? true; const notificationType = document.querySelector('input[name="schedule-notification-type"]:checked')?.value || 'all'; - + // Validation if (!name || name.length < 3) { this.showNotification('Le nom doit faire au moins 3 caractères', 'error'); return; } - + if (!playbook) { this.showNotification('Veuillez sélectionner un playbook', 'error'); return; } - + // Construire les tags const tags = Array.from(document.querySelectorAll('.schedule-tag-checkbox:checked')).map(cb => cb.value); - + // Construire la récurrence let recurrence = null; let startAt = null; - + if (scheduleType === 'recurring') { const recType = document.getElementById('schedule-recurrence-type')?.value || 'daily'; const time = document.getElementById('schedule-time')?.value || '02:00'; - + recurrence = { type: recType, time }; - + if (recType === 'weekly') { recurrence.days = Array.from(document.querySelectorAll('.schedule-day-checkbox:checked')).map(cb => parseInt(cb.value)); if (recurrence.days.length === 0) recurrence.days = [1]; @@ -11170,7 +11169,7 @@ class DashboardManager { startAt = new Date(startAtValue).toISOString(); } } - + const payload = { name, description: description || null, @@ -11185,16 +11184,16 @@ class DashboardManager { tags, notification_type: notificationType }; - + try { this.showLoading(); - + if (this.editingScheduleId) { await this.apiCall(`/api/schedules/${this.editingScheduleId}`, { method: 'PUT', body: JSON.stringify(payload) }); } else { await this.apiCall('/api/schedules', { method: 'POST', body: JSON.stringify(payload) }); } - + this.closeModal(); await this.refreshSchedules(); } catch (error) { @@ -11203,15 +11202,15 @@ class DashboardManager { this.hideLoading(); } } - + async showScheduleHistory(scheduleId) { const schedule = this.schedules.find(s => s.id === scheduleId); if (!schedule) return; - + try { const result = await this.apiCall(`/api/schedules/${scheduleId}/runs?limit=50`); const runs = result.runs || []; - + let content; if (runs.length === 0) { content = ` @@ -11228,15 +11227,15 @@ class DashboardManager {
${runs.map(run => { - const startedAt = new Date(run.started_at); - const statusClass = run.status === 'success' ? 'success' : - run.status === 'failed' ? 'failed' : - run.status === 'running' ? 'running' : 'scheduled'; - const statusIcon = run.status === 'success' ? 'check-circle' : - run.status === 'failed' ? 'times-circle' : - run.status === 'running' ? 'spinner fa-spin' : 'clock'; - - return ` + const startedAt = new Date(run.started_at); + const statusClass = run.status === 'success' ? 'success' : + run.status === 'failed' ? 'failed' : + run.status === 'running' ? 'running' : 'scheduled'; + const statusIcon = run.status === 'success' ? 'check-circle' : + run.status === 'failed' ? 'times-circle' : + run.status === 'running' ? 'spinner fa-spin' : 'clock'; + + return `
@@ -11253,45 +11252,45 @@ class DashboardManager {
`; - }).join('')} + }).join('')}
`; } - + this.showModal(`Historique: ${schedule.name}`, content); } catch (error) { this.showNotification('Erreur lors du chargement de l\'historique', 'error'); } } - + // ===== CALENDRIER DES SCHEDULES ===== - + renderScheduleCalendar() { const grid = document.getElementById('schedule-calendar-grid'); const titleEl = document.getElementById('schedule-calendar-title'); if (!grid || !titleEl) return; - + const year = this.scheduleCalendarMonth.getFullYear(); const month = this.scheduleCalendarMonth.getMonth(); - + // Titre - const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', - 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; + const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', + 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; titleEl.textContent = `${monthNames[month]} ${year}`; - + // Premier jour du mois const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); - + // Ajuster pour commencer par Lundi (0 = Dimanche dans JS) let startDay = firstDay.getDay() - 1; if (startDay < 0) startDay = 6; - + // Générer les jours const days = []; const today = new Date(); today.setHours(0, 0, 0, 0); - + // Jours du mois précédent const prevMonth = new Date(year, month, 0); for (let i = startDay - 1; i >= 0; i--) { @@ -11300,7 +11299,7 @@ class DashboardManager { otherMonth: true }); } - + // Jours du mois actuel for (let d = 1; d <= lastDay.getDate(); d++) { const date = new Date(year, month, d); @@ -11310,7 +11309,7 @@ class DashboardManager { isToday: date.getTime() === today.getTime() }); } - + // Jours du mois suivant const remainingDays = 42 - days.length; for (let d = 1; d <= remainingDays; d++) { @@ -11319,21 +11318,21 @@ class DashboardManager { otherMonth: true }); } - + // Générer le HTML grid.innerHTML = days.map(day => { const dateStr = day.date.toISOString().split('T')[0]; const classes = ['schedule-calendar-day']; if (day.otherMonth) classes.push('other-month'); if (day.isToday) classes.push('today'); - + // Événements pour ce jour (simplifiés - prochaines exécutions) const events = this.schedulesUpcoming.filter(s => { if (!s.next_run_at) return false; const runDate = new Date(s.next_run_at).toISOString().split('T')[0]; return runDate === dateStr; }); - + return `
@@ -11349,17 +11348,17 @@ class DashboardManager { `; }).join(''); } - + prevCalendarMonth() { this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() - 1); this.renderScheduleCalendar(); } - + nextCalendarMonth() { this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() + 1); this.renderScheduleCalendar(); } - + // ===== FIN DES MÉTHODES PLANIFICATEUR ===== closeModal() { @@ -11375,15 +11374,15 @@ class DashboardManager { } }); } - + showLoading() { document.getElementById('loading-overlay').classList.remove('hidden'); } - + hideLoading() { document.getElementById('loading-overlay').classList.add('hidden'); } - + showNotification(message, type = 'info') { // Persister en alerte (fire-and-forget) try { @@ -11416,31 +11415,29 @@ class DashboardManager { message: msgStr, source: 'ui' }) - }).catch(() => {}); + }).catch(() => { }); } } catch (e) { // ignore } const notification = document.createElement('div'); - notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${ - type === 'success' ? 'bg-green-600' : - type === 'warning' ? 'bg-yellow-600' : - type === 'error' ? 'bg-red-600' : 'bg-blue-600' - } text-white`; + notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${type === 'success' ? 'bg-green-600' : + type === 'warning' ? 'bg-yellow-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' + } text-white`; notification.innerHTML = `
- + }"> ${message}
`; - + document.body.appendChild(notification); - + // Animate in anime({ targets: notification, @@ -11449,7 +11446,7 @@ class DashboardManager { duration: 300, easing: 'easeOutExpo' }); - + // Remove after 3 seconds setTimeout(() => { anime({ @@ -11582,7 +11579,7 @@ class DashboardManager { // ===================================================== // Terminal SSH - Web Terminal Feature // ===================================================== - + async checkTerminalFeatureStatus(force = false) { // Limit check frequency unless forced const now = Date.now(); @@ -11611,13 +11608,13 @@ class DashboardManager { } return { available: false }; } - + async openTerminal(hostId, hostName, hostIp) { // Anti-double-click: reuse pending promise if (this.terminalOpeningPromise) { return this.terminalOpeningPromise; } - + // Check if terminal feature is available if (!this.terminalFeatureAvailable) { const status = await this.checkTerminalFeatureStatus(); @@ -11630,10 +11627,10 @@ class DashboardManager { this.terminalHeartbeatIntervalMs = status.heartbeat_interval_seconds * 1000; } } - + // Show loading state this.showTerminalDrawer(hostName, hostIp, true); - + // Create promise to prevent double-click this.terminalOpeningPromise = (async () => { try { @@ -11641,27 +11638,27 @@ class DashboardManager { method: 'POST', body: JSON.stringify({ mode: 'embedded' }) }); - + // Check if this is a session limit error (429 with rich response) if (response.error === 'SESSION_LIMIT') { this.closeTerminalDrawer(); this.showSessionLimitModal(response, hostId, hostName, hostIp); return; } - + this.terminalSession = response; - + // Update drawer with terminal iframe this.updateTerminalDrawer(response); - + // Start heartbeat this.startTerminalHeartbeat(); - + // Log if session was reused if (response.reused) { console.log('Terminal session reused:', response.session_id); } - + } catch (e) { console.error('Failed to open terminal:', e); // Check if error response contains session limit info @@ -11676,10 +11673,10 @@ class DashboardManager { this.terminalOpeningPromise = null; } })(); - + return this.terminalOpeningPromise; } - + async openTerminalPopout(hostId, hostName, hostIp) { // Prevent multiple popouts if (this.terminalPopoutOpening) { @@ -11697,35 +11694,37 @@ class DashboardManager { this.showNotification('Erreur terminal: impossible de vérifier la disponibilité', 'error'); return; } - + this.showNotification('Création de la session terminal...', 'info'); - + try { const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, { method: 'POST', body: JSON.stringify({ mode: 'popout' }) }); - + // Open in popup window const popupUrl = session.url; const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no'; const popup = window.open(popupUrl, `terminal_${session.session_id}`, popupFeatures); - + if (!popup || popup.closed) { // Popup blocked - open in new tab instead window.open(popupUrl, '_blank'); this.showNotification('Terminal ouvert dans un nouvel onglet (popup bloqué)', 'warning'); } - + } catch (e) { console.error('Failed to open terminal popout:', e); this.showNotification(`Erreur terminal: ${e.message}`, 'error'); + } finally { + this.terminalPopoutOpening = false; } } - + showTerminalDrawer(hostName, hostIp, loading = false) { this.terminalDrawerOpen = true; - + // Create drawer if it doesn't exist let drawer = document.getElementById('terminalDrawer'); if (!drawer) { @@ -11734,14 +11733,14 @@ class DashboardManager { drawer.className = 'terminal-drawer'; document.body.appendChild(drawer); } - + const loadingContent = loading ? `
Connexion à ${this.escapeHtml(hostName)}...
` : ''; - + drawer.innerHTML = `
@@ -11818,12 +11817,12 @@ class DashboardManager {
`; - + // Animate in requestAnimationFrame(() => { drawer.classList.add('open'); }); - + // Add keyboard handlers (Escape to close, Ctrl+R for history) this._terminalEscHandler = (e) => { if (e.key === 'Escape' && this.terminalDrawerOpen) { @@ -11843,18 +11842,18 @@ class DashboardManager { }; document.addEventListener('keydown', this._terminalEscHandler); } - + updateTerminalDrawer(session) { const body = document.getElementById('terminalDrawerBody'); if (!body) return; - + const statusBadge = document.querySelector('.terminal-status-badge'); if (statusBadge) { statusBadge.textContent = 'Connecté'; statusBadge.classList.remove('connecting'); statusBadge.classList.add('online'); } - + // Create iframe for terminal // Use embed mode so the connect page can hide its own header/pwa hint // and let the drawer UI be the single source of controls. @@ -11878,46 +11877,46 @@ class DashboardManager { onload="dashboard.onTerminalIframeLoad()" > `; - + // Start countdown timer this.startTerminalTimer(session.ttl_seconds); } - + onTerminalIframeLoad() { const iframe = document.getElementById('terminalIframe'); if (iframe) { iframe.focus(); } } - + startTerminalTimer(seconds) { const timerEl = document.getElementById('terminalTimer'); if (!timerEl) return; - + let remaining = seconds; - + const updateTimer = () => { if (remaining <= 0) { timerEl.textContent = 'Session expirée'; timerEl.classList.add('expired'); return; } - + const mins = Math.floor(remaining / 60); const secs = remaining % 60; timerEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`; - + if (remaining < 60) { timerEl.classList.add('warning'); } - + remaining--; }; - + updateTimer(); this._terminalTimerInterval = setInterval(updateTimer, 1000); } - + async closeTerminalDrawer(options = {}) { const { closeSession = true } = options; const drawer = document.getElementById('terminalDrawer'); @@ -11927,22 +11926,22 @@ class DashboardManager { drawer.remove(); }, 300); } - + // Clean up timer if (this._terminalTimerInterval) { clearInterval(this._terminalTimerInterval); this._terminalTimerInterval = null; } - + // Stop heartbeat this.stopTerminalHeartbeat(); - + // Remove escape handler if (this._terminalEscHandler) { document.removeEventListener('keydown', this._terminalEscHandler); this._terminalEscHandler = null; } - + // Close session on server (best effort) if (closeSession && this.terminalSession) { try { @@ -11954,23 +11953,23 @@ class DashboardManager { } this.terminalSession = null; } - + this.terminalDrawerOpen = false; } - + // ===== TERMINAL CLEANUP HANDLERS ===== - + setupTerminalCleanupHandlers() { // Handle page unload (browser close, navigation away) window.addEventListener('beforeunload', () => { this.sendTerminalCloseBeacon(); }); - + // Handle page hide (mobile tab switch, etc.) window.addEventListener('pagehide', () => { this.sendTerminalCloseBeacon(); }); - + // Handle visibility change (tab hidden) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { @@ -11982,13 +11981,13 @@ class DashboardManager { } }); } - + sendTerminalCloseBeacon() { if (!this.terminalSession) return; - + const sessionId = this.terminalSession.session_id; const url = `${this.apiBase}/api/terminal/sessions/${sessionId}/close-beacon`; - + // Use sendBeacon for reliable delivery during page unload if (navigator.sendBeacon) { const blob = new Blob([JSON.stringify({ reason: 'client_close' })], { type: 'application/json' }); @@ -12006,24 +12005,24 @@ class DashboardManager { console.warn('Failed to send terminal close beacon:', e); } } - + // Clear session reference this.terminalSession = null; } - + // ===== TERMINAL HEARTBEAT ===== - + startTerminalHeartbeat() { this.stopTerminalHeartbeat(); // Clear any existing - + if (!this.terminalSession) return; - + this.terminalHeartbeatInterval = setInterval(async () => { if (!this.terminalSession || !this.terminalDrawerOpen) { this.stopTerminalHeartbeat(); return; } - + try { await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat?token=${encodeURIComponent(this.terminalSession.token)}`, { method: 'POST' @@ -12037,10 +12036,10 @@ class DashboardManager { } } }, this.terminalHeartbeatIntervalMs); - + console.log('Terminal heartbeat started'); } - + stopTerminalHeartbeat() { if (this.terminalHeartbeatInterval) { clearInterval(this.terminalHeartbeatInterval); @@ -12048,14 +12047,14 @@ class DashboardManager { console.log('Terminal heartbeat stopped'); } } - + // ===== TERMINAL SESSION LIMIT MODAL ===== - + showSessionLimitModal(limitError, targetHostId, targetHostName, targetHostIp) { // Remove existing modal if any const existingModal = document.getElementById('sessionLimitModal'); if (existingModal) existingModal.remove(); - + const sessionsHtml = limitError.active_sessions.map(s => `
@@ -12068,7 +12067,7 @@ class DashboardManager {
`).join(''); - + const canReuseHtml = limitError.can_reuse ? `

Une session existe déjà pour cet hôte. Vous pouvez la réutiliser.

@@ -12077,7 +12076,7 @@ class DashboardManager {
` : ''; - + const modal = document.createElement('div'); modal.id = 'sessionLimitModal'; modal.className = 'modal-overlay'; @@ -12105,11 +12104,11 @@ class DashboardManager {
`; - + document.body.appendChild(modal); requestAnimationFrame(() => modal.classList.add('show')); } - + closeSessionLimitModal() { const modal = document.getElementById('sessionLimitModal'); if (modal) { @@ -12117,11 +12116,11 @@ class DashboardManager { setTimeout(() => modal.remove(), 300); } } - + async closeSessionFromModal(sessionId) { try { await this.apiCall(`/api/terminal/sessions/${sessionId}`, { method: 'DELETE' }); - + // Remove item from modal const item = document.querySelector(`[data-session-id="${sessionId}"]`); if (item) { @@ -12129,13 +12128,13 @@ class DashboardManager { item.querySelector('button').disabled = true; item.querySelector('button').innerHTML = ' Fermée'; } - + this.showNotification('Session fermée', 'success'); } catch (e) { this.showNotification(`Erreur: ${e.message}`, 'error'); } } - + async closeOldestSessionFromModal(targetHostId, targetHostName, targetHostIp) { try { // Get current sessions @@ -12146,7 +12145,7 @@ class DashboardManager { await this.apiCall(`/api/terminal/sessions/${oldest.session_id}`, { method: 'DELETE' }); this.showNotification(`Session fermée: ${oldest.host_name}`, 'success'); } - + // Close modal and retry this.closeSessionLimitModal(); await this.openTerminal(targetHostId, targetHostName, targetHostIp); @@ -12154,19 +12153,19 @@ class DashboardManager { this.showNotification(`Erreur: ${e.message}`, 'error'); } } - + async reuseSessionFromModal(sessionId, targetHostId, targetHostName, targetHostIp) { this.closeSessionLimitModal(); // Just retry - the server will return the reusable session await this.openTerminal(targetHostId, targetHostName, targetHostIp); } - + formatDuration(seconds) { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } - + openCurrentTerminalPopout() { if (!this.terminalSession) { this.showNotification('Aucune session terminal active', 'warning'); @@ -12199,13 +12198,13 @@ class DashboardManager { this.closeTerminalDrawer({ closeSession: false }); } } - + copySSHCommand() { if (!this.terminalSession) { this.showNotification('Aucune session terminal active', 'warning'); return; } - + const cmd = `ssh automation@${this.terminalSession.host.ip}`; this.copyTextToClipboard(cmd) .then(() => { @@ -12215,22 +12214,22 @@ class DashboardManager { this.showNotification('Impossible de copier dans le presse-papier', 'error'); }); } - + async reconnectTerminal() { if (!this.terminalSession) { this.showNotification('Aucune session terminal active', 'warning'); return; } - + const { host } = this.terminalSession; - + // Close current session await this.closeTerminalDrawer(); - + // Reopen await this.openTerminal(host.id, host.name, host.ip); } - + // ===== TERMINAL COMMAND HISTORY ===== async logTerminalCommand(command) { @@ -12250,7 +12249,7 @@ class DashboardManager { // Best-effort: do not disrupt UX if logging fails } } - + async toggleTerminalHistory() { if (this.terminalHistoryPanelOpen) { this.closeTerminalHistoryPanel(); @@ -12258,25 +12257,25 @@ class DashboardManager { this.openTerminalHistoryPanel(); } } - + async openTerminalHistoryPanel() { const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('terminalHistoryBtn'); - + if (!panel) return; - + this.terminalHistoryPanelOpen = true; this.terminalHistorySelectedIndex = -1; - + panel.style.display = 'flex'; panel.classList.add('open'); btn?.classList.add('active'); - + // Load history if not loaded if (this.terminalCommandHistory.length === 0) { await this.loadTerminalCommandHistory(); } - + // Focus search input const searchInput = document.getElementById('terminalHistorySearch'); if (searchInput) { @@ -12284,48 +12283,48 @@ class DashboardManager { searchInput.select(); } } - + closeTerminalHistoryPanel() { const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('terminalHistoryBtn'); - + if (!panel) return; - + this.terminalHistoryPanelOpen = false; this.terminalHistorySelectedIndex = -1; - + panel.classList.remove('open'); setTimeout(() => { panel.style.display = 'none'; }, 200); btn?.classList.remove('active'); - + // Return focus to terminal const iframe = document.getElementById('terminalIframe'); if (iframe) { iframe.focus(); } } - + async loadTerminalCommandHistory(query = '') { if (!this.terminalSession) return; - + const listEl = document.getElementById('terminalHistoryList'); if (!listEl) return; - + this.terminalHistoryLoading = true; listEl.innerHTML = '
Chargement...
'; - + try { const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false; const hostId = this.terminalSession.host.id; const timeFilter = this.terminalHistoryTimeFilter; - + // Build query params const params = new URLSearchParams(); params.set('limit', '100'); if (query) params.set('query', query); - + // Add time filter if (timeFilter !== 'all') { const now = new Date(); @@ -12345,7 +12344,7 @@ class DashboardManager { params.set('since', since.toISOString()); } } - + let endpoint; if (allHosts) { endpoint = `/api/terminal/command-history?${params.toString()}`; @@ -12353,10 +12352,10 @@ class DashboardManager { // Use shell-history to fetch real commands from the remote host via SSH endpoint = `/api/terminal/${hostId}/shell-history?${params.toString()}`; } - + const response = await this.apiCall(endpoint); let commands = response.commands || []; - + // Client-side time filtering if API doesn't support it if (timeFilter !== 'all') { const now = new Date(); @@ -12379,12 +12378,12 @@ class DashboardManager { }); } } - + this.terminalCommandHistory = commands; this.terminalHistorySelectedIndex = -1; - + this.renderTerminalHistory(); - + } catch (e) { console.error('Failed to load terminal history:', e); listEl.innerHTML = '
Erreur de chargement
'; @@ -12392,15 +12391,15 @@ class DashboardManager { this.terminalHistoryLoading = false; } } - + renderTerminalHistory(highlightQuery = '') { const listEl = document.getElementById('terminalHistoryList'); if (!listEl) return; - + const query = highlightQuery || this.terminalHistorySearchQuery || ''; - + if (this.terminalCommandHistory.length === 0) { - const emptyMessage = query + const emptyMessage = query ? `Aucun résultat pour "${this.escapeHtml(query)}"` : 'Aucune commande dans l\'historique'; listEl.innerHTML = ` @@ -12412,21 +12411,21 @@ class DashboardManager { `; return; } - + const items = this.terminalCommandHistory.map((cmd, index) => { const command = cmd.command || ''; const timeAgo = this.formatRelativeTime(cmd.last_used || cmd.created_at); const execCount = cmd.execution_count || 1; const hostName = cmd.host_name || ''; const isSelected = index === this.terminalHistorySelectedIndex; - + // Highlight search query in command let displayCommand = this.escapeHtml(command.length > 80 ? command.substring(0, 80) + '...' : command); if (query) { const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi'); displayCommand = displayCommand.replace(regex, '$1'); } - + return `
`; }).join(''); - + listEl.innerHTML = items; - + // Scroll selected item into view if (this.terminalHistorySelectedIndex >= 0) { const selectedEl = listEl.querySelector('.terminal-history-item.selected'); @@ -12463,15 +12462,15 @@ class DashboardManager { } } } - + escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } - + handleHistorySearchKeydown(event) { const key = event.key; const historyLength = this.terminalCommandHistory.length; - + switch (key) { case 'ArrowDown': event.preventDefault(); @@ -12483,7 +12482,7 @@ class DashboardManager { this.renderTerminalHistory(); } break; - + case 'ArrowUp': event.preventDefault(); if (historyLength > 0) { @@ -12494,7 +12493,7 @@ class DashboardManager { this.renderTerminalHistory(); } break; - + case 'Enter': event.preventDefault(); if (this.terminalHistorySelectedIndex >= 0) { @@ -12503,12 +12502,12 @@ class DashboardManager { this.selectAndInsertHistoryCommand(0); } break; - + case 'Escape': event.preventDefault(); this.closeTerminalHistoryPanel(); break; - + case 'Tab': // Tab to cycle through results event.preventDefault(); @@ -12521,21 +12520,21 @@ class DashboardManager { break; } } - + searchTerminalHistory(query) { this.terminalHistorySearchQuery = query; this.terminalHistorySelectedIndex = -1; - + // Debounce search if (this._historySearchTimeout) { clearTimeout(this._historySearchTimeout); } - + this._historySearchTimeout = setTimeout(() => { this.loadTerminalCommandHistory(query); }, 250); } - + clearTerminalHistorySearch() { const input = document.getElementById('terminalHistorySearch'); if (input) { @@ -12546,34 +12545,34 @@ class DashboardManager { this.terminalHistorySelectedIndex = -1; this.loadTerminalCommandHistory(''); } - + setHistoryTimeFilter(value) { this.terminalHistoryTimeFilter = value; this.terminalHistorySelectedIndex = -1; this.loadTerminalCommandHistory(this.terminalHistorySearchQuery); } - + toggleHistoryScope() { this.terminalHistorySelectedIndex = -1; this.loadTerminalCommandHistory(this.terminalHistorySearchQuery); } - + selectAndInsertHistoryCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; - + const command = cmd.command || ''; - + // Copy to clipboard and show notification this.copyTextToClipboard(command).then(() => { this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success'); // Best-effort log this.logTerminalCommand(command); - + // Close history panel and focus terminal this.closeTerminalHistoryPanel(); - + // Focus the iframe const iframe = document.getElementById('terminalIframe'); if (iframe && iframe.contentWindow) { @@ -12584,13 +12583,13 @@ class DashboardManager { this.showNotification('Commande: ' + command, 'info'); }); } - + executeHistoryCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; - + const command = cmd.command || ''; - + // Copy command + newline to execute it this.copyTextToClipboard(command + '\n').then(() => { this.showNotification('Commande copiée avec Enter - Collez pour exécuter', 'success'); @@ -12599,7 +12598,7 @@ class DashboardManager { this.logTerminalCommand(command); this.closeTerminalHistoryPanel(); - + const iframe = document.getElementById('terminalIframe'); if (iframe && iframe.contentWindow) { iframe.focus(); @@ -12609,13 +12608,13 @@ class DashboardManager { this.showNotification('Commande: ' + command, 'info'); }); } - + copyTerminalCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; - + const command = cmd.command || ''; - + this.copyTextToClipboard(command).then(() => { this.showNotification('Commande copiée', 'success'); @@ -12625,10 +12624,10 @@ class DashboardManager { this.showNotification('Impossible de copier', 'error'); }); } - + formatRelativeTime(dateStr) { if (!dateStr) return ''; - + const date = new Date(dateStr); const now = new Date(); const diffMs = now - date; @@ -12636,12 +12635,12 @@ class DashboardManager { const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); - + if (diffSec < 60) return 'À l\'instant'; if (diffMin < 60) return `Il y a ${diffMin}min`; if (diffHour < 24) return `Il y a ${diffHour}h`; if (diffDay < 7) return `Il y a ${diffDay}j`; - + return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); } } @@ -12704,7 +12703,7 @@ function closeModal() { dashboard.closeModal(); } -window.showCreateScheduleModal = function(prefilledPlaybook = null) { +window.showCreateScheduleModal = function (prefilledPlaybook = null) { if (!window.dashboard) { return; } diff --git a/app/requirements.txt b/app/requirements.txt index 9c7a9d4..6cccf4d 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -19,8 +19,11 @@ aiosqlite>=0.19.0 pytest>=7.0.0 pytest-asyncio>=0.21.0 # Authentication -python-jose[cryptography]>=3.3.0 -passlib[bcrypt]>=1.7.4 +PyJWT>=2.8.0 +bcrypt>=4.0.0 reportlab>=4.0.0 pillow>=10.0.0 -asyncssh>=2.14.0 \ No newline at end of file +asyncssh>=2.14.0 +slowapi>=0.1.9 +cachetools>=5.3.0 +jinja2>=3.1.0 \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index bbf932a..7c091c4 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -5,8 +5,10 @@ Routes API pour l'authentification JWT. from datetime import datetime, timezone from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm +from slowapi import Limiter +from slowapi.util import get_remote_address from sqlalchemy.ext.asyncio import AsyncSession from app.core.dependencies import get_db, get_current_user, get_current_user_optional @@ -17,6 +19,7 @@ from app.schemas.auth import ( from app.services import auth_service router = APIRouter() +limiter = Limiter(key_func=get_remote_address) @router.get("/status") @@ -55,7 +58,9 @@ async def auth_status( @router.post("/setup") +@limiter.limit("3/minute") async def setup_admin( + request: Request, user_data: UserCreate, db_session: AsyncSession = Depends(get_db) ): @@ -93,7 +98,9 @@ async def setup_admin( @router.post("/login", response_model=Token) +@limiter.limit("5/minute") async def login_form( + request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db_session: AsyncSession = Depends(get_db) ): @@ -129,7 +136,9 @@ async def login_form( @router.post("/login/json", response_model=Token) +@limiter.limit("5/minute") async def login_json( + request: Request, credentials: LoginRequest, db_session: AsyncSession = Depends(get_db) ): diff --git a/app/routes/terminal.py b/app/routes/terminal.py index 473ee68..23adfdf 100644 --- a/app/routes/terminal.py +++ b/app/routes/terminal.py @@ -22,10 +22,13 @@ from app.services.shell_history_service import shell_history_service, ShellHisto from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.templating import Jinja2Templates from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.dependencies import get_db, get_current_user, require_debug_mode + +_templates = Jinja2Templates(directory=str(settings.base_dir / "templates")) from app.crud.host import HostRepository from app.crud.bootstrap_status import BootstrapStatusRepository from app.crud.terminal_session import TerminalSessionRepository @@ -941,66 +944,29 @@ async def get_terminal_connect_page( session = await session_repo.get_active_by_id(session_id) if not session: - safe_title = "Session Expirée" - return HTMLResponse( - content=f""" - - - - {safe_title} - - - -
-

Session Expirée ou Invalide

-

Cette session terminal n'existe pas ou a expiré.

-

Retour au Dashboard

-
- - - """, - status_code=404 + return _templates.TemplateResponse( + "terminal/error.html", + { + "request": request, + "title": "Session Expirée", + "heading": "Session Expirée ou Invalide", + "messages": ["Cette session terminal n'existe pas ou a expiré."], + "show_back_link": True, + }, + status_code=404, ) if not terminal_service.verify_token(token, session.token_hash): - safe_title = html.escape(session.host_name or "Terminal") - return HTMLResponse( - content=f""" - - - - {safe_title} - - - -
-

Accès Refusé

-

Token de session invalide.

-
- - - """, - status_code=403 + return _templates.TemplateResponse( + "terminal/error.html", + { + "request": request, + "title": html.escape(session.host_name or "Terminal"), + "heading": "Accès Refusé", + "messages": ["Token de session invalide."], + "show_back_link": False, + }, + status_code=403, ) alive = True @@ -1028,420 +994,33 @@ async def get_terminal_connect_page( alive = False if not alive: - safe_title = html.escape(session.host_name or "Terminal") - return HTMLResponse( - content=f""" - - - - {safe_title} - - - -
- - - """, + return _templates.TemplateResponse( + "terminal/error.html", + { + "request": request, + "title": html.escape(session.host_name or "Terminal"), + "heading": "Terminal indisponible", + "messages": [ + "Le service terminal (ttyd) ne répond pas pour cette session.", + "Essayez de reconnecter ou de recréer une session depuis le dashboard.", + ], + "show_back_link": True, + }, status_code=503, ) now = datetime.now(timezone.utc) expires_at = _as_utc_aware(session.expires_at) remaining_seconds = _compute_remaining_seconds(expires_at, now=now) - ttyd_port = session.ttyd_port - embed_mode = request.query_params.get("embed") in {"1", "true", "yes"} - safe_host_name_html = html.escape(session.host_name or "") - safe_host_ip_html = html.escape(session.host_ip or "") - safe_title = html.escape(session.host_name or "Terminal") js_session_id = json.dumps(session_id) js_token = json.dumps(token) js_host_id = json.dumps(session.host_id) js_host_name = json.dumps(session.host_name) js_host_ip = json.dumps(session.host_ip) - history_script_block = """ - // ===== HISTORY FUNCTIONS ===== - let historyPanelOpen = false; - let historySelectedIndex = -1; - let historySearchQuery = ''; - let historyTimeFilter = 'all'; - - async function toggleHistory() { - const panel = document.getElementById('terminalHistoryPanel'); - const btn = document.getElementById('btnHistory'); - - if (historyPanelOpen) { - closeHistoryPanel(); - } else { - panel.style.display = 'flex'; - panel.classList.add('open'); - btn.classList.add('active'); - historyPanelOpen = true; - historySelectedIndex = -1; - - const searchInput = document.getElementById('terminalHistorySearch'); - if (searchInput) { - searchInput.focus(); - searchInput.select(); - } - - if (historyData.length === 0) { - loadHistory(); - } - } - } - - function closeHistoryPanel() { - const panel = document.getElementById('terminalHistoryPanel'); - const btn = document.getElementById('btnHistory'); - - panel.classList.remove('open'); - btn.classList.remove('active'); - historyPanelOpen = false; - historySelectedIndex = -1; - - setTimeout(() => { - try { - panel.style.display = 'none'; - } catch (e) { - // ignore - } - }, 200); - - document.getElementById('terminalFrame').focus(); - } - - async function loadHistory() { - const list = document.getElementById('terminalHistoryList'); - list.innerHTML = '
Chargement...
'; - - const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false; - const query = historySearchQuery; - - let endpoint; - if (allHosts) { - endpoint = '/api/terminal/command-history?limit=100'; - } else { - endpoint = `/api/terminal/${HOST_ID}/shell-history?limit=100`; - } - if (query) { - endpoint += (endpoint.includes('?') ? '&' : '?') + 'query=' + encodeURIComponent(query); - } - - try { - const res = await fetch(endpoint, { - headers: { 'Authorization': 'Bearer ' + TOKEN } - }); - const data = await res.json(); - let commands = data.commands || []; - - // Client-side time filtering - if (historyTimeFilter !== 'all') { - const now = new Date(); - let cutoff; - switch (historyTimeFilter) { - case 'today': - cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - break; - case 'week': - cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - break; - case 'month': - cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - break; - } - if (cutoff) { - commands = commands.filter(cmd => { - const cmdDate = new Date(cmd.last_used || cmd.created_at); - return cmdDate >= cutoff; - }); - } - } - - historyData = commands; - historySelectedIndex = -1; - renderHistory(); - } catch (e) { - list.innerHTML = '
Erreur de chargement
'; - } - } - - function escapeHtml(text) { - if (!text) return ''; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\"/g, """) - .replace(/'/g, "'"); - } - - function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&'); - } - - function formatRelativeTime(dateStr) { - if (!dateStr) return ''; - const date = new Date(dateStr); - const now = new Date(); - const diffMs = now - date; - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHour / 24); - - if (diffSec < 60) return "À l'instant"; - if (diffMin < 60) return `Il y a ${diffMin}min`; - if (diffHour < 24) return `Il y a ${diffHour}h`; - if (diffDay < 7) return `Il y a ${diffDay}j`; - - return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }); - } - - function renderHistory() { - const list = document.getElementById('terminalHistoryList'); - const query = historySearchQuery || ''; - - if (historyData.length === 0) { - const emptyMessage = query - ? `Aucun résultat pour "${escapeHtml(query)}"` - : 'Aucune commande dans l historique'; - list.innerHTML = ` -
- - ${emptyMessage} -
- `; - return; - } - - const items = historyData.map((cmd, index) => { - const command = cmd.command || ''; - const timeAgo = formatRelativeTime(cmd.last_used || cmd.created_at); - const execCount = cmd.execution_count || 1; - const isSelected = index === historySelectedIndex; - - let displayCommand = escapeHtml(command.length > 60 ? command.substring(0, 60) + '...' : command); - if (query) { - const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); - displayCommand = displayCommand.replace(regex, '$1'); - } - - return ` -
-
- ${displayCommand} -
-
- ${timeAgo} - ${execCount > 1 ? `×${execCount}` : ''} -
-
- - -
-
- `; - }).join(''); - - list.innerHTML = items; - - if (historySelectedIndex >= 0) { - const selectedEl = list.querySelector('.terminal-history-item.selected'); - if (selectedEl) { - selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - } - } - - function handleHistoryKeydown(event) { - const key = event.key; - const historyLength = historyData.length; - - switch (key) { - case 'ArrowDown': - event.preventDefault(); - if (historyLength > 0) { - historySelectedIndex = Math.min(historySelectedIndex + 1, historyLength - 1); - renderHistory(); - } - break; - case 'ArrowUp': - event.preventDefault(); - if (historyLength > 0) { - historySelectedIndex = Math.max(historySelectedIndex - 1, 0); - renderHistory(); - } - break; - case 'Enter': - event.preventDefault(); - if (historySelectedIndex >= 0) { - selectHistoryCommand(historySelectedIndex); - } else if (historyLength > 0) { - selectHistoryCommand(0); - } - break; - case 'Escape': - event.preventDefault(); - closeHistoryPanel(); - break; - } - } - - let searchTimeout = null; - function searchHistory(query) { - historySearchQuery = query; - historySelectedIndex = -1; - if (searchTimeout) clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => loadHistory(), 250); - } - - function clearHistorySearch() { - const input = document.getElementById('terminalHistorySearch'); - if (input) { input.value = ''; input.focus(); } - historySearchQuery = ''; - historySelectedIndex = -1; - loadHistory(); - } - - function setHistoryTimeFilter(value) { - historyTimeFilter = value; - historySelectedIndex = -1; - loadHistory(); - } - - function toggleHistoryScope() { - historySelectedIndex = -1; - loadHistory(); - } - - function showNotification(message) { - const notification = document.createElement('div'); - notification.style.cssText = ` - position: fixed; bottom: 20px; right: 20px; - background: rgba(124, 58, 237, 0.9); color: white; - padding: 0.75rem 1rem; border-radius: 0.5rem; - font-size: 0.875rem; z-index: 9999; - `; - notification.textContent = message; - document.body.appendChild(notification); - setTimeout(() => { - notification.style.opacity = '0'; - notification.style.transition = 'opacity 0.3s'; - setTimeout(() => notification.remove(), 300); - }, 2500); - } - - async function logCommand(command) { - try { - const cmd = String(command ?? '').trim(); - if (!cmd) return; - - await fetch('/api/terminal/sessions/' + encodeURIComponent(SESSION_ID) + '/command?token=' + encodeURIComponent(TOKEN), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: cmd }) - }); - } catch (e) { - // Best-effort - } - } - - function selectHistoryCommand(index) { - const cmd = historyData[index]; - if (!cmd) return; - copyTextToClipboard(cmd.command || '') - .then(() => { - showNotification('Commande copiée - Collez avec Ctrl+Shift+V'); - closeHistoryPanel(); - }) - .catch(() => showNotification('Commande: ' + (cmd.command || ''))); - } - -function executeHistoryCommand(index) { - if (!Array.isArray(historyData)) { - console.warn('historyData n’est pas un tableau'); - return; - } - - if (typeof index !== 'number' || index < 0 || index >= historyData.length) { - console.warn('Index invalide:', index); - return; - } - - const cmd = historyData[index]; - - if (!cmd || typeof cmd.command !== 'string' || !cmd.command.trim()) { - showNotification('Commande invalide ou vide'); - return; - } - - const commandText = cmd.command.trim() + '\\n'; - - copyTextToClipboard(commandText) - .then(() => { - showNotification('Commande copiée — collez pour exécuter'); - logCommand(cmd.command); - closeHistoryPanel(); - }) - .catch((err) => { - console.error('Erreur copie presse-papiers:', err); - showNotification('Impossible de copier la commande'); - }); -} - - - function copyHistoryCommand(index) { - const cmd = historyData[index]; - if (!cmd) return; - copyTextToClipboard(cmd.command || '') - .then(() => { - showNotification('Commande copiée'); - logCommand(cmd.command || ''); - }) - .catch(() => showNotification('Impossible de copier')); - } - - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - if (historyPanelOpen) { - closeHistoryPanel(); - } - } - if ((e.ctrlKey || e.metaKey) && e.key === 'r') { - e.preventDefault(); - toggleHistory(); - } - }); - """ - debug_mode_enabled = bool(settings.debug_mode) debug_panel_html = ( """ @@ -1457,6 +1036,7 @@ function executeHistoryCommand(index) { else "" ) + # JavaScript blocks are built server-side so SESSION_ID/TOKEN can be JSON-encoded debug_js = ( "" if not debug_mode_enabled else " let debugVisible = false;\n\n" @@ -1465,44 +1045,82 @@ function executeHistoryCommand(index) { " const el = document.getElementById('terminalDebug');\n" " if (el) el.style.display = debugVisible ? 'block' : 'none';\n" " }\n\n" - " async function updateDebug(reason) {\n" - " const body = document.getElementById('terminalDebugBody');\n" - " if (!body) return;\n\n" - " try {\n" - " const probeUrl = '/api/terminal/sessions/' + encodeURIComponent(SESSION_ID) + '/probe?token=' + encodeURIComponent(TOKEN);\n" - " const res = await fetch(probeUrl);\n" - " if (!res.ok) {\n" - " const errText = await res.text();\n" - " body.textContent = 'reason: ' + (reason || '') + '\\nprobe_error: ' + res.status + ' ' + (errText.substring(0, 100) || 'no response');\n" - " toggleDebug(true);\n" - " return;\n" - " }\n" - " const data = await res.json();\n" - " body.textContent = [\n" - " 'reason: ' + (reason || ''),\n" - " 'page: ' + window.location.href,\n" - " 'computed_ttyd_url: ' + (ttydUrl || '(non définie)'),\n" - " 'probe.ttyd_port: ' + (data.ttyd_port ?? 'n/a'),\n" - " 'probe.process_alive: ' + String(data.process_alive),\n" - " 'probe.port_reachable: ' + String(data.port_reachable),\n" - " 'probe.ttyd_interface: ' + (data.ttyd_interface || '(unset)'),\n" - " 'probe.dashboard_host: ' + (data.dashboard_host || '(n/a)'),\n" - " 'probe.dashboard_scheme: ' + (data.dashboard_scheme || '(n/a)')\n" - " ].join('\\n');\n" - " toggleDebug(true);\n" - " } catch (e) {\n" - " body.textContent = 'reason: ' + (reason || '') + '\\nerror: ' + (e && e.message ? e.message : String(e));\n" - " toggleDebug(true);\n" - " }\n" - " }\n\n" - " window.addEventListener('keydown', (e) => {\n" - " if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) {\n" - " e.preventDefault();\n" - " updateDebug('manual');\n" - " }\n" - " });\n\n" + " window.addEventListener('keydown', (e) => { if ((e.ctrlKey||e.metaKey)&&e.shiftKey&&(e.key==='D'||e.key==='d')) { e.preventDefault(); toggleDebug(); } });\n\n" ) + history_js = """ + let historyPanelOpen = false, historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all'; + async function toggleHistory() { + const panel = document.getElementById('terminalHistoryPanel'); + const btn = document.getElementById('btnHistory'); + if (historyPanelOpen) { closeHistoryPanel(); } + else { + panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active'); + historyPanelOpen = true; historySelectedIndex = -1; + const si = document.getElementById('terminalHistorySearch'); + if (si) { si.focus(); si.select(); } + if (historyData.length === 0) loadHistory(); + } + } + function closeHistoryPanel() { + const panel = document.getElementById('terminalHistoryPanel'); + const btn = document.getElementById('btnHistory'); + panel.classList.remove('open'); btn.classList.remove('active'); + historyPanelOpen = false; historySelectedIndex = -1; + setTimeout(() => { try { panel.style.display = 'none'; } catch(e){} }, 200); + document.getElementById('terminalFrame').focus(); + } + async function loadHistory() { + const list = document.getElementById('terminalHistoryList'); + list.innerHTML = '
Chargement...
'; + const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false; + const query = historySearchQuery; + let ep = allHosts ? '/api/terminal/command-history?limit=100' : `/api/terminal/${HOST_ID}/shell-history?limit=100`; + if (query) ep += (ep.includes('?') ? '&' : '?') + 'query=' + encodeURIComponent(query); + try { + const res = await fetch(ep, { headers: { 'Authorization': 'Bearer ' + TOKEN } }); + const data = await res.json(); + let cmds = data.commands || []; + if (historyTimeFilter !== 'all') { + const now = new Date(); + let cutoff; + if (historyTimeFilter === 'today') cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + else if (historyTimeFilter === 'week') cutoff = new Date(now.getTime() - 7*86400000); + else if (historyTimeFilter === 'month') cutoff = new Date(now.getTime() - 30*86400000); + if (cutoff) cmds = cmds.filter(c => new Date(c.last_used||c.created_at) >= cutoff); + } + historyData = cmds; historySelectedIndex = -1; renderHistory(); + } catch(e) { list.innerHTML = '
Erreur
'; } + } + function escH(t){return (t||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} + function escRE(s){return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');} + function relTime(d){if(!d)return'';const dt=new Date(d),now=new Date(),ds=Math.floor((now-dt)/1000);if(ds<60)return'À l instant';const dm=Math.floor(ds/60),dh=Math.floor(dm/60),dd=Math.floor(dh/24);if(dm<60)return`Il y a ${dm}min`;if(dh<24)return`Il y a ${dh}h`;if(dd<7)return`Il y a ${dd}j`;return dt.toLocaleDateString('fr-FR',{day:'numeric',month:'short'});} + function renderHistory(){ + const list=document.getElementById('terminalHistoryList'),q=historySearchQuery||''; + if(!historyData.length){list.innerHTML=`
${q?`Aucun résultat pour "${escH(q)}"`:'Aucune commande'}
`;return;} + list.innerHTML=historyData.map((cmd,i)=>{ + const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex; + let dc=escH(c.length>60?c.substring(0,60)+'...':c); + if(q)dc=dc.replace(new RegExp(`(${escRE(q)})`,'gi'),'$1'); + return `
${dc}
${ta}${ec>1?`×${ec}`:''}
`; + }).join(''); + if(historySelectedIndex>=0){const s=list.querySelector('.selected');if(s)s.scrollIntoView({block:'nearest',behavior:'smooth'});} + } + function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)selectH(historySelectedIndex);else if(l>0)selectH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}} + let _st=null; + function searchHistory(q){historySearchQuery=q;historySelectedIndex=-1;if(_st)clearTimeout(_st);_st=setTimeout(()=>loadHistory(),250);} + function clearHistorySearch(){const i=document.getElementById('terminalHistorySearch');if(i){i.value='';i.focus();}historySearchQuery='';historySelectedIndex=-1;loadHistory();} + function setHistoryTimeFilter(v){historyTimeFilter=v;historySelectedIndex=-1;loadHistory();} + function toggleHistoryScope(){historySelectedIndex=-1;loadHistory();} + function selectH(i){historySelectedIndex=i;renderHistory();} + function execH(i){selectH(i);closeHistoryPanel();} + function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});} + document.addEventListener('keydown',(e)=>{ + if(e.key==='Escape'&&historyPanelOpen)closeHistoryPanel(); + if((e.ctrlKey||e.metaKey)&&e.key==='r'){e.preventDefault();toggleHistory();} + }); + """ + script_block = ( "" + " }, 8000);\n" + " })();\n\n" + + history_js + + "\n startHeartbeat();\n" ) - html_content = f""" - - - - - - {safe_title} - - - - -
- 💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez chrome --app=URL - -
- -
-
- {safe_host_name_html} - {safe_host_ip_html} - Connecté -
-
- - {remaining_seconds // 60}:{remaining_seconds % 60:02d} -
-
- - - - - -
-
- -
-
-
-
-
Connexion au terminal...
-
session={session_id[:8]}… · ttyd_port={ttyd_port} · Debug: Ctrl+Shift+D
-
-
- - {debug_panel_html} - - -
- - {script_block} - - - """ - - return HTMLResponse(content=html_content, headers={"Cache-Control": "no-store"}) + return _templates.TemplateResponse( + "terminal/connect.html", + { + "request": request, + "safe_title": html.escape(session.host_name or "Terminal"), + "safe_host_name": html.escape(session.host_name or ""), + "safe_host_ip": html.escape(session.host_ip or ""), + "session_id_short": session_id[:8], + "ttyd_port": ttyd_port, + "timer_display": f"{remaining_seconds // 60}:{remaining_seconds % 60:02d}", + "embed_mode": embed_mode, + "debug_panel_html": debug_panel_html, + "script_block": script_block, + }, + headers={"Cache-Control": "no-store"}, + ) @router.get("/popout/{session_id}") diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 8e309e2..2108218 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -7,10 +7,33 @@ from typing import Optional from pydantic import BaseModel, EmailStr, Field, field_validator +def _validate_password_strength(password: str) -> str: + """Shared password strength validator. + + Requirements: + - Minimum 8 characters + - At least 1 uppercase letter + - At least 1 lowercase letter + - At least 1 digit + - At least 1 special character + """ + if len(password) < 8: + raise ValueError("Le mot de passe doit contenir au moins 8 caractères") + if not any(c.isupper() for c in password): + raise ValueError("Le mot de passe doit contenir au moins une majuscule") + if not any(c.islower() for c in password): + raise ValueError("Le mot de passe doit contenir au moins une minuscule") + if not any(c.isdigit() for c in password): + raise ValueError("Le mot de passe doit contenir au moins un chiffre") + if not any(c in "!@#$%^&*()_+-=[]{}|;:',.<>?/~`" for c in password): + raise ValueError("Le mot de passe doit contenir au moins un caractère spécial") + return password + + class LoginRequest(BaseModel): """Request schema for user login.""" username: str = Field(..., min_length=3, max_length=50, description="Username") - password: str = Field(..., min_length=6, description="Password") + password: str = Field(..., min_length=1, description="Password") class Token(BaseModel): @@ -39,15 +62,12 @@ class UserBase(BaseModel): class UserCreate(UserBase): """Schema for creating a new user.""" - password: str = Field(..., min_length=6, max_length=128, description="Password (min 6 chars)") + password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars, requires uppercase, lowercase, digit, special char)") @field_validator('password') @classmethod def password_strength(cls, v: str) -> str: - """Validate password has minimum complexity.""" - if len(v) < 6: - raise ValueError('Password must be at least 6 characters') - return v + return _validate_password_strength(v) class UserUpdate(BaseModel): @@ -61,14 +81,12 @@ class UserUpdate(BaseModel): class PasswordChange(BaseModel): """Schema for changing password.""" current_password: str = Field(..., description="Current password") - new_password: str = Field(..., min_length=6, max_length=128, description="New password") + new_password: str = Field(..., min_length=8, max_length=128, description="New password") @field_validator('new_password') @classmethod def password_strength(cls, v: str) -> str: - if len(v) < 6: - raise ValueError('Password must be at least 6 characters') - return v + return _validate_password_strength(v) class UserOut(BaseModel): @@ -90,16 +108,14 @@ class UserOut(BaseModel): class UserSetup(BaseModel): """Schema for initial admin setup (first user creation).""" username: str = Field(..., min_length=3, max_length=50) - password: str = Field(..., min_length=6, max_length=128) + password: str = Field(..., min_length=8, max_length=128) email: Optional[EmailStr] = None display_name: Optional[str] = None @field_validator('password') @classmethod def password_strength(cls, v: str) -> str: - if len(v) < 6: - raise ValueError('Password must be at least 6 characters') - return v + return _validate_password_strength(v) class AuthStatus(BaseModel): diff --git a/app/services/ansible_service.py b/app/services/ansible_service.py index 68225cc..b97fa20 100644 --- a/app/services/ansible_service.py +++ b/app/services/ansible_service.py @@ -12,6 +12,7 @@ from time import perf_counter from typing import Any, Dict, List, Optional import yaml +from cachetools import TTLCache from app.core.config import settings from app.schemas.host_api import AnsibleInventoryHost @@ -28,27 +29,23 @@ class AnsibleService: self.ssh_key_path = ssh_key_path or settings.ssh_key_path self.ssh_user = ssh_user or settings.ssh_user - # Cache - self._inventory_cache: Optional[Dict] = None - self._inventory_cache_time: float = 0 - self._playbooks_cache: Optional[List[PlaybookInfo]] = None - self._playbooks_cache_time: float = 0 - self._cache_ttl = settings.inventory_cache_ttl + # Cache with automatic TTL eviction (replaces hand-rolled cache) + cache_ttl = settings.inventory_cache_ttl + self._inventory_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl) + self._playbooks_cache: TTLCache = TTLCache(maxsize=16, ttl=cache_ttl) def invalidate_cache(self): """Invalide les caches.""" - self._inventory_cache = None - self._playbooks_cache = None + self._inventory_cache.clear() + self._playbooks_cache.clear() # ===== PLAYBOOKS ===== def get_playbooks(self) -> List[Dict[str, Any]]: """Récupère la liste des playbooks disponibles.""" - import time - current_time = time.time() - - if self._playbooks_cache and (current_time - self._playbooks_cache_time) < self._cache_ttl: - return self._playbooks_cache + cache_key = "all" + if cache_key in self._playbooks_cache: + return self._playbooks_cache[cache_key] playbooks = [] @@ -78,8 +75,7 @@ class AnsibleService: if pb: playbooks.append(pb) - self._playbooks_cache = playbooks - self._playbooks_cache_time = current_time + self._playbooks_cache[cache_key] = playbooks return playbooks def _parse_playbook_file(self, file_path: Path, category: str, subcategory: str) -> Optional[Dict[str, Any]]: @@ -179,11 +175,9 @@ class AnsibleService: def load_inventory(self) -> Dict: """Charge l'inventaire Ansible depuis le fichier YAML.""" - import time - current_time = time.time() - - if self._inventory_cache and (current_time - self._inventory_cache_time) < self._cache_ttl: - return self._inventory_cache + cache_key = "inventory" + if cache_key in self._inventory_cache: + return self._inventory_cache[cache_key] if not self.inventory_path.exists(): return {} @@ -191,8 +185,7 @@ class AnsibleService: try: with open(self.inventory_path, 'r', encoding='utf-8') as f: inventory = yaml.safe_load(f) or {} - self._inventory_cache = inventory - self._inventory_cache_time = current_time + self._inventory_cache[cache_key] = inventory return inventory except Exception: return {} @@ -204,8 +197,8 @@ class AnsibleService: with open(self.inventory_path, 'w', encoding='utf-8') as f: yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True) - # Invalider le cache - self._inventory_cache = None + # Invalider le cache inventaire + self._inventory_cache.clear() def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]: """Récupère les hôtes depuis l'inventaire Ansible.""" diff --git a/app/services/auth_service.py b/app/services/auth_service.py index c25ac93..56d186e 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -11,15 +11,16 @@ from datetime import datetime, timedelta, timezone from typing import Optional import bcrypt -from jose import JWTError, jwt +import jwt +from app.core.config import settings from app.models.user import User from app.schemas.auth import TokenData -# Configuration from environment variables -SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "homelab-secret-key-change-in-production") -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "1440")) # 24 hours default +# Configuration from centralized settings (single source of truth) +SECRET_KEY = settings.jwt_secret_key +ALGORITHM = settings.jwt_algorithm +ACCESS_TOKEN_EXPIRE_MINUTES = settings.jwt_expire_minutes class AuthService: @@ -83,7 +84,7 @@ class AuthService: return None return TokenData(username=username, user_id=user_id, role=role) - except JWTError: + except (jwt.PyJWTError, jwt.ExpiredSignatureError, jwt.DecodeError): return None @staticmethod diff --git a/app/templates/terminal/connect.html b/app/templates/terminal/connect.html new file mode 100644 index 0000000..3e6abb5 --- /dev/null +++ b/app/templates/terminal/connect.html @@ -0,0 +1,325 @@ + + + + + + {{ safe_title }} + + + + +
+ 💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez chrome --app=URL + +
+ +
+
+ {{ safe_host_name }} + {{ safe_host_ip }} + Connecté +
+
+ + {{ timer_display }} +
+
+ + + + + +
+
+ +
+
+
+
+
Connexion au terminal...
+
+ session={{ session_id_short }}… · ttyd_port={{ ttyd_port }} · Debug: Ctrl+Shift+D +
+
+
+ + {% if debug_panel_html %} + {{ debug_panel_html | safe }} + {% endif %} + + +
+ + {{ script_block | safe }} + + diff --git a/app/templates/terminal/error.html b/app/templates/terminal/error.html new file mode 100644 index 0000000..15bf8b1 --- /dev/null +++ b/app/templates/terminal/error.html @@ -0,0 +1,48 @@ + + + + + + {{ title }} + + + +
+ + diff --git a/documentation/AUDIT_STRATEGIQUE_COMPLET.md b/documentation/AUDIT_STRATEGIQUE_COMPLET.md new file mode 100644 index 0000000..f7e0caa --- /dev/null +++ b/documentation/AUDIT_STRATEGIQUE_COMPLET.md @@ -0,0 +1,899 @@ +# 🔍 Rapport d'Audit Technique Stratégique — Homelab Automation Dashboard v2.0 + +> **Date de l'audit :** 20 février 2026 +> **Auditeur :** Architecte Logiciel Full-Stack Senior / Spécialiste DevOps / Expert UI/UX +> **Version analysée :** 2.0.0 +> **Stack :** FastAPI · SQLAlchemy Async · SQLite (aiosqlite) · APScheduler · Ansible · WebSocket · HTML/JS/Tailwind/Anime.js + +--- + +## Table des Matières + +1. [Synthèse Exécutive](#1-synthèse-exécutive) +2. [Audit d'Architecture et de Sécurité 🛡️](#2-audit-darchitecture-et-de-sécurité-) +3. [Corrections et Optimisations Code/Performances ⚙️](#3-corrections-et-optimisations-codeperformances-) +4. [Améliorations UI/UX 🎨](#4-améliorations-uiux-) +5. [Idées d'Évolution "Next Level" 🚀](#5-idées-dévolution-next-level-) +6. [Feuille de Route Actionnable 🗺️](#6-feuille-de-route-actionnable-) + +--- + +## 1. Synthèse Exécutive + +### Vue d'ensemble du Projet + +Le Homelab Automation Dashboard est une application **self-hosted** impressionnante qui centralise la gestion d'infrastructure via Ansible, avec planification de tâches, monitoring Docker via SSH, un terminal SSH intégré via ttyd, et des notifications push via ntfy. L'application a atteint un niveau de maturité fonctionnel **solide** avec une architecture bien structurée (Factory pattern, Repository pattern, séparation services/routes/schemas/models). + +### Points Forts ✅ + +| Domaine | Appréciation | +|---------|-------------| +| **Architecture modulaire** | Excellente séparation : `routes/`, `services/`, `models/`, `schemas/`, `crud/`, `core/` | +| **Migrations Alembic** | 19 migrations bien structurées avec convention de nommage | +| **Exécution Ansible async** | `asyncio.create_subprocess_exec` — ne bloque **pas** l'event loop ✅ | +| **Service de notification** | Design robuste, never-throw, async, avec templates et filtrage par niveau | +| **Gestion des sessions terminal** | Architecture GC + heartbeat + session reuse bien pensée | +| **Docker monitoring** | Collection SSH + semaphore concurrency limiter + upsert/stale cleanup | +| **Startup checks** | Service de vérification des prérequis complet et bien reporté | +| **Exceptions typées** | Hiérarchie d'exceptions métier bien conçue (HomelabException) | + +### Points d'Attention ⚠️ + +| Domaine | Sévérité | Résumé | +|---------|----------|--------| +| **Secrets en `.env` committé** | 🔴 Critique | `.env` contient `API_KEY`, `JWT_SECRET_KEY` en clair dans le repo | +| **`app_optimized.py` monolithique** | 🟠 Haute | 6585 lignes — fichier "God Object" qui duplique toute l'architecture | +| **WebSocket sans authentification** | 🟠 Haute | `/ws` n'exige aucun token/clé API | +| **Bootstrap : mot de passe root en transit** | 🟠 Haute | `root_password` transmis en clair dans la requête HTTP | +| **`threading.Lock` dans WebSocket** | 🟡 Moyenne | Devrait être `asyncio.Lock` pour la cohérence async | +| **CORS `allow_origins=["*"]`** | 🟡 Moyenne | Présent dans `app_optimized.py` et en production potentielle | +| **Coverage à 45%** | 🟡 Moyenne | Seuil minimal, certaines zones critiques non couvertes | + +--- + +## 2. Audit d'Architecture et de Sécurité 🛡️ + +### 2.1 Authentification & Gestion des Secrets + +#### JWT — Implémentation Solide avec Réserves + +L'implémentation JWT utilise `python-jose` avec `bcrypt` pour le hashing des mots de passe. C'est un bon choix. + +**✅ Points positifs :** +- Hashing bcrypt avec sel aléatoire (`bcrypt.gensalt()`) +- Token avec `exp` et `iat` claims +- Double mode d'authentification (API Key + JWT Bearer) +- Setup initial protégé (uniquement si 0 utilisateurs) +- Changement de mot de passe avec vérification de l'ancien + +**🔴 Problèmes critiques :** + +```python +# auth_service.py, ligne 20 +SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "homelab-secret-key-change-in-production") +``` + +| Problème | Impact | Recommandation | +|----------|--------|----------------| +| **Clé secrète par défaut faible** | Un attaquant connaissant le code source peut forger des JWT valides | Générer une clé de 256 bits minimum au premier démarrage, stocker dans un fichier protégé | +| **Pas de rotation des clés** | Compromission permanente si la clé est exposée | Implémenter JWK (JSON Web Key) avec rotation périodique | +| **Pas de refresh token** | L'utilisateur doit se reconnecter après expiration | Ajouter un système refresh/access token | +| **Pas de révocation de token** | Impossible de déconnecter un utilisateur compromis | Maintenir une blacklist en cache (Redis ou in-memory) | + +#### `.env` Commité dans le Repository + +``` +# .env (commité dans git !) +API_KEY=dev-key-1234567890 +JWT_SECRET_KEY=dev-key-67890 +``` + +**🔴 CRITIQUE :** Le fichier `.env` est **commité** et contient des secrets en clair. Même si ce sont des valeurs de développement, cela crée un risque car : +1. Les développeurs pourraient déployer avec ces valeurs +2. L'historique Git conserve les secrets même après suppression + +**Recommandations :** +1. Ajouter `.env` au `.gitignore` immédiatement +2. Conserver uniquement un `.env.example` avec des valeurs placeholder +3. Utiliser `python-dotenv` avec validation obligatoire des secrets au démarrage +4. Pour la production, envisager HashiCorp Vault ou `docker secrets` + +#### Gestion des Clés SSH + +**✅ Points positifs :** +- Recherche intelligente multi-emplacement (`find_ssh_private_key`) +- Vérification des permissions sur Linux/Mac +- Support de multiples types de clés (RSA, Ed25519, ECDSA) + +**⚠️ Points d'attention :** + +```python +# ssh_utils.py, ligne 103 & terminal_service.py, ligne 363 +"-o", "StrictHostKeyChecking=no", +"-o", "UserKnownHostsFile=/dev/null", +``` + +Cela désactive la vérification TOFU (Trust On First Use), ce qui est acceptable pour un homelab mais devrait être configurable. + +```python +# docker_service.py, ligne 88 +known_hosts=None, # Accept any host key (homelab environment) +``` + +**Recommandation :** Ajouter un paramètre `STRICT_HOST_KEY_CHECKING` configurable avec une valeur par défaut `no` pour le homelab, `yes` pour la production. + +#### Bootstrap — Mot de Passe Root en Transit + +```python +# routes/bootstrap.py, ligne 98 +result = bootstrap_host( + host=request.host, + root_password=request.root_password, # ⚠️ En clair dans la requête + automation_user=request.automation_user +) +``` + +Le mot de passe root est transmis dans le corps de la requête HTTP. En l'absence de HTTPS, il circule en clair. + +**Recommandations :** +1. **Exiger HTTPS** pour les endpoints sensibles (bootstrap, auth) +2. Ajouter un avertissement dans les logs si la requête arrive en HTTP +3. Envisager un chiffrement côté client avec une clé éphémère (Diffie-Hellman) +4. Limiter le rate-limiting sur `/api/bootstrap` (anti brute-force) + +### 2.2 Robustesse de l'API FastAPI + +#### Architecture Dual — Le Problème `app_optimized.py` + +L'application maintient **deux architectures parallèles** : + +| Fichier | Lignes | Rôle | +|---------|--------|------| +| `app/factory.py` + routes modulaires | ~10k+ | Architecture propre, modulaire | +| `app/app_optimized.py` | **6585** | Monolithe qui duplique tout | + +**🟠 Problème majeur :** `app_optimized.py` est un "God File" de 6585 lignes contenant : +- Modèles Pydantic (dupliqués de `schemas/`) +- Services (dupliqués de `services/`) +- Routes (dupliquées de `routes/`) +- Gestion de base de données en mémoire **et** SQLite synchrone +- Un scheduler complet + +**Recommandation :** Supprimer `app_optimized.py` et ne conserver que l'architecture modulaire. Ce fichier semble être l'ancienne version monolithique conservée "au cas où" mais il crée de la confusion et un risque de régression. + +#### Gestion de la Base de Données SQLite Async + +**✅ Excellente configuration :** + +```python +# models/database.py +engine = create_async_engine(DATABASE_URL, pool_pre_ping=True, future=True) + +# Pragmas SQLite bien configurés +cursor.execute("PRAGMA foreign_keys=ON") +cursor.execute("PRAGMA journal_mode=WAL") # Avec fallback sur DELETE +``` + +**✅ Session management correct :** +```python +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() +``` + +**⚠️ Points d'attention :** + +1. **Pas de connection pooling avancé** : SQLite single-writer peut devenir un bottleneck sous charge élevée. Envisager `pool_size` et `max_overflow` quand/si migration vers PostgreSQL. + +2. **`expire_on_commit=False`** : C'est le bon choix pour l'async, mais peut causer des données stale si les sessions sont longues. Actuellement OK car les sessions sont scoped aux requêtes. + +3. **Migration Alembic dans `init_db()`** : L'exécution de `alembic upgrade head` à chaque démarrage est robuste mais peut être lente si beaucoup de migrations. Ajouter un check "already at head" avant d'exécuter. + +### 2.3 Communication WebSocket + +#### WebSocket Manager — Simple mais Fonctionnel + +```python +# websocket_service.py +class WebSocketManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + self.lock = Lock() # ⚠️ threading.Lock au lieu de asyncio.Lock +``` + +**🟡 Problème : `threading.Lock` en contexte async** + +L'utilisation de `threading.Lock` dans la méthode `broadcast` (qui est `async`) peut causer un deadlock si jamais le lock est contenu quand l'event loop essaie d'envoyer un message WebSocket qui nécessite un `await`. + +```python +# Actuel (problématique) +async def broadcast(self, message: dict): + with self.lock: # ⚠️ Bloque le thread de l'event loop + for connection in self.active_connections: + await connection.send_json(message) # ← await sous un Lock synchrone +``` + +**La correction devrait être :** +```python +async def broadcast(self, message: dict): + async with self._lock: # asyncio.Lock + disconnected = [] + for connection in self.active_connections: + try: + await connection.send_json(message) + except Exception: + disconnected.append(connection) + for conn in disconnected: + self.active_connections.remove(conn) +``` + +**🔴 WebSocket sans authentification :** + +```python +# routes/websocket.py, ligne 22-32 +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await ws_manager.connect(websocket) # ⚠️ Aucune vérification d'identité +``` + +N'importe qui peut se connecter au WebSocket et recevoir toutes les notifications système (tâches, bootstrap, statuts). C'est un **vecteur de fuite d'information**. + +**Recommandation :** Vérifier le JWT via query parameter lors du handshake WebSocket : + +```python +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + token = websocket.query_params.get("token") + if not token or not decode_token(token): + await websocket.close(code=4001, reason="Authentication required") + return + await ws_manager.connect(websocket) +``` + +#### Terminal WebSocket Proxy + +Le proxy terminal (`/terminal/ws/{session_id}`) a une **bonne implémentation de sécurité** : +- ✅ Token vérifié via hash SHA-256 +- ✅ `secrets.compare_digest` pour la comparaison (timing-safe) +- ✅ Session validée en base de données + +**⚠️ Problème mineur dans le proxy :** + +```python +# websocket.py, ligne 104 +data = await reader.readexactly(1024) # ⚠️ Attend exactement 1024 octets +``` + +`readexactly` attend exactement N bytes ou lève `IncompleteReadError`. Cela peut causer des latences si le terminal envoie moins de 1024 bytes. Utiliser `reader.read(4096)` à la place : + +```python +data = await reader.read(4096) +if not data: + break +``` + +### 2.4 Planificateur de Tâches (APScheduler) + +**✅ Configuration solide :** +- `coalesce=True` : évite les exécutions multiples si le scheduler rattrape son retard +- `max_instances=1` : empêche les exécutions parallèles du même job +- `misfire_grace_time` configurable +- Persistance en base de données avec rechargement au démarrage + +**⚠️ Risque de "Schedule Orphan" :** + +Si l'application crash pendant l'exécution d'un schedule, le `ScheduleRun` restera en statut `running` indéfiniment. Ajouter une vérification au démarrage pour marquer les runs "orphelins" comme `failed`. + +**⚠️ Jobs Docker hardcodés :** + +```python +# factory.py, lignes 182-198 +scheduler_service.scheduler.add_job( + docker_service.collect_all_hosts, + trigger="interval", + seconds=60, # Toutes les minutes + id="docker_collect", +) +``` + +Les intervalles sont hardcodés. Rendre configurable via variables d'environnement (`DOCKER_COLLECT_INTERVAL`, `DOCKER_ALERTS_INTERVAL`). + +--- + +## 3. Corrections et Optimisations Code/Performances ⚙️ + +### 3.1 Anti-Patterns Identifiés + +#### AP-1 : Fichier Monolithique `app_optimized.py` + +| Métrique | Valeur | +|----------|--------| +| Lignes | 6585 | +| Classes | ~30+ | +| Fonctions | ~200+ | +| Impact | Maintenance, lisibilité, testabilité | + +**Action :** Supprimer ce fichier et s'appuyer uniquement sur l'architecture modulaire existante (qui est déjà complète et fonctionnelle). + +#### AP-2 : Import Circulaire Protégé par Try/Except + +```python +# notification_service.py, lignes 32-47 +try: + from schemas.notification import (...) +except ModuleNotFoundError: + from app.schemas.notification import (...) +``` + +Ce pattern indique une incertitude sur le `sys.path`. C'est un symptôme de `app_optimized.py` qui fonctionne avec un chemin différent. + +**Action :** Après suppression de `app_optimized.py`, utiliser uniquement les imports absolus `from app.schemas...`. + +#### AP-3 : Double Système de Persistance (HybridDB) + +Le service `HybridDB` (`services/hybrid_db.py`) maintient des données **en mémoire** (listes Python) en parallèle avec SQLite. Cela crée une incohérence potentielle. + +```python +# routes/bootstrap.py, ligne 140 +db.logs.insert(0, log_entry) # ← Mémoire +``` + +vs. + +```python +# Ailleurs dans le code +repo = LogRepository(session) +await repo.create(...) # ← SQLite +``` + +**Action :** Migrer entièrement vers le pattern Repository + SQLite. Le `HybridDB` est un vestige de l'ancienne architecture. + +#### AP-4 : Fonctions Synchrones Bloquantes + +```python +# ssh_utils.py, lignes 128-133 +result = subprocess.run( # ⚠️ BLOQUE l'event loop + ssh_cmd, + capture_output=True, + text=True, + timeout=timeout + 10 +) +``` + +La fonction `bootstrap_host()` utilise `subprocess.run` synchrone et est appelée depuis une route `async` : + +```python +# routes/bootstrap.py, ligne 98 +result = bootstrap_host(...) # ← Appel synchrone dans une route async +``` + +Cela **bloque l'event loop** pendant toute la durée du bootstrap (jusqu'à 120 secondes). + +**Correction :** +```python +# Utiliser asyncio.to_thread pour les opérations bloquantes +result = await asyncio.to_thread( + bootstrap_host, + host=request.host, + root_password=request.root_password, + automation_user=request.automation_user +) +``` + +Ou mieux, réécrire `bootstrap_host` avec `asyncio.create_subprocess_exec` comme `ansible_service.execute_playbook` le fait déjà correctement. + +#### AP-5 : `asyncio.create_task` Sans Référence + +```python +# routes/bootstrap.py, ligne 152 +asyncio.create_task(notification_service.notify_bootstrap_success(host_name)) +``` + +Les tâches créées avec `asyncio.create_task` sans référence peuvent être garbage-collectées avant leur fin si rien ne les retient. De plus, les exceptions non attrapées dans ces tâches seront silencieusement perdues. + +**Correction :** +```python +# Stocker une référence et ajouter un callback d'erreur +background_tasks = set() + +task = asyncio.create_task(notification_service.notify_bootstrap_success(host_name)) +background_tasks.add(task) +task.add_done_callback(background_tasks.discard) +``` + +Ou utiliser `BackgroundTasks` de FastAPI : +```python +from fastapi import BackgroundTasks + +@router.post("", response_model=CommandResult) +async def bootstrap_ansible_host( + request: BootstrapRequest, + background_tasks: BackgroundTasks, + api_key_valid: bool = Depends(verify_api_key) +): + # ... + background_tasks.add_task(notification_service.notify_bootstrap_success, host_name) +``` + +### 3.2 Goulots d'Étranglement Identifiés + +#### GE-1 : Scan Système de Fichiers pour les Logs de Tâches + +```python +# app_optimized.py, lignes 519-565 +def _build_index(self, force: bool = False): + for year_dir in self.base_dir.iterdir(): + for month_dir in year_dir.iterdir(): + for day_dir in month_dir.iterdir(): + for md_file in day_dir.glob("*.md"): + # Parse chaque fichier +``` + +Ce scan récursif du système de fichiers à chaque appel (avec un cache de 60 secondes) sera lent si des centaines/milliers de fichiers de logs s'accumulent. + +**Optimisation :** Le service `TaskLogService` dans `services/task_log_service.py` devrait indexer les logs en base de données plutôt que scanner le filesystem. Créer une table `task_logs_index` : + +```sql +CREATE TABLE task_logs_index ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + path TEXT NOT NULL, + task_name TEXT, + target TEXT, + status TEXT, + date TEXT, + source_type TEXT, + created_at TIMESTAMP, + metadata_json TEXT +); +``` + +#### GE-2 : Docker Collection Séquentielle par Hôte + +La collecte Docker utilise un semaphore de 5, ce qui est bien, mais chaque hôte fait 4 commandes SSH séquentielles (`docker version`, `docker ps`, `docker images`, `docker volume ls`). + +**Optimisation :** Combiner les commandes en une seule session SSH : + +```python +combined_cmd = """ +echo '---VERSION---' +docker version --format '{{.Server.Version}}' +echo '---CONTAINERS---' +docker ps -a --format '{{json .}}' --no-trunc +echo '---IMAGES---' +docker images --format '{{json .}}' +echo '---VOLUMES---' +docker volume ls --format '{{json .}}' +""" +stdout, stderr, code = await self._ssh_exec(conn, combined_cmd, timeout=30) +# Parser les sections +``` + +Cela réduit le nombre de round-trips SSH de 4 à 1 par hôte. + +#### GE-3 : `index.html` Monolithique (247 KB) + +Le fichier `index.html` fait **247 KB** et `main.js` fait **617 KB**. Ces fichiers ne sont ni minifiés ni compressés. + +**Optimisations :** +1. Activer la compression gzip/brotli via middleware FastAPI +2. Ajouter des headers de cache (`Cache-Control`, `ETag`) +3. Diviser `main.js` en modules ES (chargement paresseux des sections) + +### 3.3 Améliorations des Pratiques de Code + +#### Python — Bonnes Pratiques + +| # | Recommandation | Fichier(s) | Priorité | +|---|---------------|------------|----------| +| 1 | Utiliser `Annotated` de typing pour les dépendances FastAPI | Toutes les routes | Basse | +| 2 | Remplacer `@app.on_event("startup")` par les `lifespan` events (FastAPI 0.93+) | `factory.py` | Moyenne | +| 3 | Ajouter des type hints manquants dans `HybridDB` | `services/hybrid_db.py` | Basse | +| 4 | Utiliser `logging` au lieu de `print()` dans `factory.py` | `factory.py` (15+ prints) | Moyenne | +| 5 | Remplacer les f-strings dans les loggers par `%s` formatage | Partout | Basse | +| 6 | Ajouter `__slots__` aux dataclasses de configuration | `core/config.py` | Basse | +| 7 | Utiliser `enum.StrEnum` pour les statuts (`"running"`, `"failed"`, etc.) | `schemas/`, `models/` | Moyenne | + +#### Python — Sécurité du Code + +```python +# routes/bootstrap.py, ligne 92-94 — Import à l'intérieur de la fonction +import logging +import traceback +logger = logging.getLogger("bootstrap_endpoint") +``` + +**Recommandation :** Déplacer les imports et la création du logger au niveau du module (en haut du fichier). + +#### JavaScript — Bonnes Pratiques + +| # | Recommandation | Fichier | Priorité | +|---|---------------|---------|----------| +| 1 | Migrer de `var` vers `const/let` | `main.js` | Moyenne | +| 2 | Utiliser ES Modules (`import/export`) au lieu du scope global | `main.js` et `*.js` | Haute | +| 3 | Ajouter un linter (ESLint) avec config stricte | Projet | Moyenne | +| 4 | Implémenter le debouncing sur les appels API fréquents | `main.js` | Moyenne | +| 5 | Ajouter une couche d'abstraction API (fetch wrapper) | `main.js` | Haute | + +### 3.4 Correctifs Prioritaires + +#### FIX-1 : `require_admin` ne vérifie pas le bon champ + +```python +# core/dependencies.py, lignes 161-162 +payload = user.get("payload", {}) # ⚠️ Le champ "payload" n'existe pas ! +role = payload.get("role", "viewer") +``` + +Le dictionnaire user retourné par `get_current_user_optional` contient `role` directement, pas dans un sous-dictionnaire `payload`. La vérification admin ne fonctionnera **jamais** pour les utilisateurs JWT. + +**Correction :** +```python +async def require_admin(user: dict = Depends(get_current_user)) -> dict: + if user.get("type") == "api_key": + return user + role = user.get("role", "viewer") # Directement dans le dict + if role != "admin": + raise HTTPException(status_code=403, detail="Droits administrateur requis") + return user +``` + +#### FIX-2 : Fuite mémoire potentielle dans WebSocket + +Le `WebSocketManager` ne nettoie les connexions mortes que lors d'un `broadcast`. Si aucun broadcast n'est envoyé pendant longtemps, les connexions mortes s'accumulent. + +**Correction :** Ajouter un heartbeat périodique : +```python +async def _heartbeat_loop(self): + while True: + await asyncio.sleep(30) + await self.broadcast({"type": "ping"}) +``` + +#### FIX-3 : Terminal proxy — `readexactly` vs `read` + +```python +# routes/websocket.py, ligne 104 +data = await reader.readexactly(1024) # Bloque jusqu'à avoir exactement 1024 bytes +``` + +**Correction :** `data = await reader.read(4096)` + +--- + +## 4. Améliorations UI/UX 🎨 + +### 4.1 Ergonomie du Tableau de Bord + +#### Dashboard — Structure Recommandée + +Le dashboard actuel charge un fichier HTML monolithique de 247 KB. Voici les améliorations ergonomiques recommandées : + +| Zone | Amélioration | Impact | +|------|-------------|--------| +| **Navigation** | Sidebar rétractable avec icônes et badges de notification | Haute | +| **Page d'accueil** | Ajouter des "sparklines" (micro-graphiques) pour les métriques | Moyenne | +| **Filtres** | Filtrage en temps réel avec chips/tags visuels | Haute | +| **Breadcrumbs** | Ajouter une navigation hiérarchique | Moyenne | +| **Raccourcis clavier** | `Ctrl+K` pour recherche rapide, `Ctrl+T` pour nouveau terminal | Moyenne | +| **Dark/Light mode** | Toggle avec persistance en `localStorage` | Moyenne | + +#### Gestion des Erreurs — Feedback Visuel + +Actuellement, les erreurs sont probablement affichées dans des `alert()` ou des toasts basiques. Voici un système plus professionnel : + +**Système de Toast Notifications à 4 Niveaux :** + +``` +┌─────────────────────────────────────────┐ +│ ✅ SUCCESS │ +│ "Playbook vm-upgrade.yml exécuté" │ +│ 3 hôtes mis à jour en 45s │ +│ ░░░░░░░░░░░░░░░░░░░░░░ auto-dismiss 5s │ +└─────────────────────────────────────────┘ + +┌─────────────────────────────────────────┐ +│ ❌ ERROR │ +│ "Bootstrap échoué pour srv-backup" │ +│ Connection timeout après 30s │ +│ [Voir les logs] [Réessayer] │ +│ ► Reste affiché jusqu'au dismiss │ +└─────────────────────────────────────────┘ +``` + +**Implémentation recommandée :** +- Position : coin supérieur droit, empilées +- Auto-dismiss : 5s pour success/info, sticky pour errors +- Actions contextuelles intégrées ("Voir les logs", "Réessayer") +- Animation entrée/sortie via Anime.js + +### 4.2 Intégration Anime.js — Suggestions + +| Animation | Déclencheur | Effet | +|-----------|------------|-------| +| **Task Progress** | Pendant l'exécution | Barre de progression animée avec shimmer | +| **Host Status Change** | WebSocket event | Pulse animation sur le badge de statut | +| **Docker Container Start/Stop** | Action utilisateur | Icon spin + color morph | +| **Page Transition** | Navigation SPA | Fade-in avec translate-Y léger (16px) | +| **Card Hover** | Mouse enter | Scale(1.02) + shadow elevation subtile | +| **Data Loading** | Fetch en cours | Skeleton screens avec wave animation | +| **Error Shake** | Validation échouée | Shake horizontal (6px, 3 cycles, 400ms) | + +### 4.3 Composants UI Recommandés + +#### Terminal SSH — Améliorations + +Le terminal SSH intégré est une fonctionnalité killer. Améliorations suggérées : + +1. **Split panes** : Permettre de diviser le terminal en panneaux horizontaux/verticaux +2. **Quick commands** : Palette de commandes pré-définies (sidebar ou dropdown) +3. **Session history** : Recall des commandes précédentes avec recherche fuzzy +4. **Tab management** : Onglets pour multiples sessions, avec indicateur d'activité +5. **Copy-paste amélioré** : Double-clic pour sélection de mot, click-and-drag pour sélection + +#### Playbook Editor — Améliorations + +1. **Syntax highlighting YAML** : Déjà intégré via CodeMirror ✅ +2. **Live linting** : Intégrer ansible-lint en temps réel (via WebSocket) +3. **Variables autocomplete** : Complétion automatique des variables de groupe +4. **Diff view** : Voir les changements avant sauvegarde +5. **Template snippets** : Bibliothèque de snippets de tâches courantes + +### 4.4 Accessibilité (a11y) + +| Aspect | État Actuel | Recommandation | +|--------|------------|----------------| +| **Contraste** | À vérifier | WCAG AA minimum (ratio 4.5:1) | +| **Navigation clavier** | Probablement partielle | `tabindex` sur tous les éléments interactifs | +| **Screen reader** | Non implémenté | Ajouter `aria-label`, `aria-live` pour les mises à jour dynamiques | +| **Focus visible** | Par défaut navigateur | Personnaliser le style de focus (`outline`) | + +--- + +## 5. Idées d'Évolution "Next Level" 🚀 + +### 5.1 Gestion des Secrets (Vault) + +**Objectif :** Centraliser et sécuriser tous les secrets (mots de passe, clés API, tokens). + +**Architecture proposée :** + +``` +┌─────────────────────────────────────────────┐ +│ Homelab Secrets Manager │ +├─────────────────────────────────────────────┤ +│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │ +│ │ Ansible │ │ Docker │ │ Services │ │ +│ │ Vault │ │ Secrets │ │ Credentials│ │ +│ └─────────┘ └──────────┘ └────────────┘ │ +├─────────────────────────────────────────────┤ +│ Backend: AES-256-GCM encryption at rest │ +│ Master Key: derived via PBKDF2 from │ +│ user password + hardware fingerprint │ +└─────────────────────────────────────────────┘ +``` + +**Implémentation par phases :** +1. **Phase 1 :** Chiffrement des secrets en base (clés SSH, tokens ntfy) +2. **Phase 2 :** Interface UI pour gérer les secrets (CRUD + audit log) +3. **Phase 3 :** Intégration avec `ansible-vault` pour les playbooks + +### 5.2 Intégration Proxmox + +**Objectif :** Gérer les VMs et containers LXC directement depuis le dashboard. + +**Fonctionnalités proposées :** + +| Fonctionnalité | Complexité | Valeur | +|---------------|-----------|--------| +| Lister VMs/CTs avec statut | Faible | Haute | +| Start/Stop/Restart VM | Moyenne | Haute | +| Créer VM from template | Haute | Moyenne | +| Monitoring CPU/RAM/IO | Moyenne | Haute | +| Snapshots management | Moyenne | Moyenne | +| Console VNC/SPICE intégrée | Haute | Haute | + +**Approche technique :** +- Utiliser l'API REST de Proxmox VE (`/api2/json/`) +- Authentification via token PVE (pas de mot de passe stocké) +- WebSocket pour le streaming des métriques en temps réel + +### 5.3 Monitoring Avancé + +#### Métriques Système Enrichies + +``` +┌──────────────────────────────────────────┐ +│ CPU History (24h) │ +│ ██████████░░░░░░░░░ 52% avg │ +│ ▁▂▃▅▆▇█▇▅▃▂▁▁▂▃▅▇█▇▅▃▂▁ │ +├──────────────────────────────────────────┤ +│ Memory ████████████████░░ 78% (12/16GB)│ +│ Swap █░░░░░░░░░░░░░░░░ 3% (0.5/16GB)│ +│ Disk / ██████████░░░░░░░ 62% (120/200G)│ +│ Network ↑ 12 Mbit/s ↓ 45 Mbit/s │ +└──────────────────────────────────────────┘ +``` + +**Stack recommandé :** +- **Collection :** Via Ansible ad-hoc (déjà capable) ou node_exporter +- **Stockage :** SQLite pour 7 jours, puis agrégation/cleanup automatique +- **Visualisation :** Graphiques temps réel en JavaScript (Chart.js ou D3.js) +- **Alerting :** Intégration avec ntfy (déjà en place) + +#### Alerting Intelligent + +Passer d'alertes simples (seuil dépassé) à un système plus sophistiqué : + +1. **Rate of change :** Alerter si le CPU augmente de >30% en 5 minutes +2. **Anomaly detection :** Baseline automatique + détection de déviation +3. **Correlation :** "Disk full → Service crash" → alerte unifiée +4. **Escalation :** ntfy (info) → email (warning) → SMS/appel (critique) + +### 5.4 Intégration Docker Compose Avancée + +**Fonctionnalités proposées :** + +1. **Visualisation des stacks** : Graphe de dépendances entre containers +2. **Logs en streaming** : `docker logs -f` via WebSocket +3. **Compose file editor** : Édition avec validation en temps réel +4. **One-click deploy** : Upload et déploiement de stacks docker-compose +5. **Auto-update** : Vérification et mise à jour automatique des images (comme Watchtower) + +### 5.5 CI/CD Self-Hosted + +**Pipeline proposée pour le dashboard lui-même :** + +``` +┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ +│ Push │ → │ Lint & │ → │ Build │ → │ Deploy │ +│ Git │ │ Test │ │ Docker │ │ Auto │ +└─────────┘ └──────────┘ └─────────┘ └──────────┘ + │ │ │ │ + └──── Gitea/Forgejo ──── ── GitHub Actions ────┘ +``` + +**Pour les services managés du homelab :** +- Interface de déploiement "GitOps-like" +- Rollback automatique si health check échoue +- Historique de déploiement avec diff + +### 5.6 Multi-Tenant & Multi-User + +| Fonctionnalité | Description | +|---------------|-------------| +| **Rôles granulaires** | Viewer, Operator, Admin avec permissions par section | +| **Audit trail** | Log de toutes les actions utilisateur avec IP/timestamp | +| **Workspaces** | Séparer les environnements (prod, staging, lab) | +| **API tokens** | Tokens scoped avec date d'expiration | +| **2FA** | Authentification à deux facteurs (TOTP) via un app authenticator | + +--- + +## 6. Feuille de Route Actionnable 🗺️ + +### Phase 1 : Gains Rapides (1-2 semaines) 🏃 + +*Objectif : Corriger les failles critiques et les quick wins sans refactoring majeur.* + +| # | Action | Effort | Impact | Fichier(s) | +|---|--------|--------|--------|-----------| +| 1.1 | ⚠️ **Ajouter `.env` au `.gitignore`** et créer `.env.example` | 5 min | 🔴 Critique | `.gitignore`, `.env.example` | +| 1.2 | 🔒 **Authentifier le WebSocket `/ws`** avec token JWT | 2h | 🔴 Haute | `routes/websocket.py` | +| 1.3 | 🐛 **Corriger `require_admin`** (champ `role` mal lu) | 15 min | 🔴 Haute | `core/dependencies.py` | +| 1.4 | 🔄 **Remplacer `threading.Lock` par `asyncio.Lock`** dans WebSocketManager | 30 min | 🟡 Moyenne | `services/websocket_service.py` | +| 1.5 | 🐛 **Corriger `readexactly` → `read`** dans le proxy terminal | 5 min | 🟡 Moyenne | `routes/websocket.py` | +| 1.6 | ⚡ **Wrapper `bootstrap_host` avec `asyncio.to_thread`** | 30 min | 🟠 Haute | `routes/bootstrap.py` | +| 1.7 | 📝 **Remplacer `print()` par `logging`** dans factory.py | 1h | 🟢 Basse | `factory.py` | +| 1.8 | 🔑 **Générer un JWT_SECRET_KEY aléatoire** au premier démarrage si non défini | 1h | 🔴 Haute | `services/auth_service.py` | + +### Phase 2 : Objectifs à Moyen Terme (1-2 mois) 🎯 + +*Objectif : Consolider l'architecture et améliorer l'expérience utilisateur.* + +| # | Action | Effort | Impact | +|---|--------|--------|--------| +| 2.1 | 🗑️ **Supprimer `app_optimized.py`** et le `HybridDB` | 2-3j | Architecture | +| 2.2 | 🔐 **Implémenter refresh tokens** + blacklist de tokens | 2j | Sécurité | +| 2.3 | 📊 **Indexer les logs de tâches en base** (remplacer le scan filesystem) | 2j | Performance | +| 2.4 | ⚡ **Optimiser la collecte Docker** (commandes SSH combinées) | 1j | Performance | +| 2.5 | 🎨 **Implémenter le système de toast notifications** | 2j | UI/UX | +| 2.6 | 🖥️ **Ajouter le split-pane et les onglets** au terminal | 3j | UI/UX | +| 2.7 | 🔔 **Heartbeat WebSocket** avec reconnexion automatique côté JS | 1j | Fiabilité | +| 2.8 | 📦 **Migrer vers FastAPI `lifespan`** (remplacer `on_event`) | 1j | Architecture | +| 2.9 | 🧪 **Augmenter la couverture de tests à 65%** (focus : auth, scheduler, WebSocket) | 3-5j | Qualité | +| 2.10 | 🗜️ **Activer la compression gzip** et les headers de cache statique | 2h | Performance | +| 2.11 | 📱 **Améliorer le responsive** (breakpoints tablette + mobile) | 2j | UI/UX | +| 2.12 | ⚙️ **Rendre configurables** les intervalles Docker et les limites de pagination | 1j | Ops | + +### Phase 3 : Vision à Long Terme (3-6 mois) 🔭 + +*Objectif : Transformer le dashboard en plateforme homelab de référence.* + +| # | Action | Effort | Impact | +|---|--------|--------|--------| +| 3.1 | 🔐 **Secrets Manager** — Chiffrement des secrets en base + UI | 1-2 sem | Sécurité | +| 3.2 | 🖥️ **Intégration Proxmox** — API + Dashboard VMs | 2-3 sem | Feature | +| 3.3 | 📊 **Monitoring avancé** — Graphiques historiques + anomaly detection | 2-3 sem | Feature | +| 3.4 | 🐳 **Docker Compose Manager** — Stacks + logs streaming + deploy | 2-3 sem | Feature | +| 3.5 | 👥 **Multi-user avec RBAC** — Rôles granulaires + audit trail | 2 sem | Sécurité | +| 3.6 | 🔑 **2FA (TOTP)** — Authentification à deux facteurs | 1 sem | Sécurité | +| 3.7 | 🚀 **CI/CD Pipeline** — Auto-deploy du dashboard + rollback | 2 sem | DevOps | +| 3.8 | 🔄 **Migration PostgreSQL** (optionnel) — Pour scalabilité multi-instance | 1-2 sem | Architecture | +| 3.9 | 📱 **PWA** — Notifications push natives, mode offline partiel | 1-2 sem | UI/UX | +| 3.10 | 🔌 **Plugin System** — Architecture extensible pour intégrations tierces | 3-4 sem | Architecture | + +### Matrice de Prioritisation + +``` + IMPACT ÉLEVÉ + ↑ + ┌────────┼────────┐ + │ 1.1 │ 2.1 │ + │ 1.2 │ 2.2 │ + │ 1.3 │ 2.3 │ + │ 1.6 │ 3.1 │ + │ 1.8 │ 3.5 │ + ├────────┼────────┤ + │ 1.4 │ 2.6 │ + │ 1.5 │ 2.9 │ + │ 1.7 │ 3.2 │ + │ 2.10 │ 3.4 │ + │ 2.7 │ 3.10 │ + └────────┼────────┘ + EFFORT FAIBLE EFFORT ÉLEVÉ + ↓ + IMPACT FAIBLE +``` + +--- + +## Annexe A : Métriques du Projet + +| Métrique | Valeur | +|----------|--------| +| Fichiers Python | 144 | +| Fichiers JS | 8 (app) | +| Fichier HTML | 1 (monolithique) | +| Migrations Alembic | 19 | +| Routes API | 22 routers | +| Modèles SQLAlchemy | 20 | +| Schemas Pydantic | 20 | +| Services | 16 | +| CRUD Repositories | 14+ | +| Tests backend | ~45% coverage | +| Tests frontend | Présents (vitest) | +| Taille `app_optimized.py` | 6585 lignes ⚠️ | +| Taille `index.html` | 247 KB ⚠️ | +| Taille `main.js` | 617 KB ⚠️ | + +## Annexe B : Sécurité — Checklist de Déploiement Production + +``` +[ ] .env absent du repository Git +[ ] JWT_SECRET_KEY est une chaîne aléatoire de 64+ caractères +[ ] API_KEY est unique et complexe +[ ] HTTPS activé (reverse proxy nginx/traefik) +[ ] CORS restreint aux origines autorisées +[ ] WebSocket authentifié par JWT +[ ] Rate limiting activé sur /api/auth et /api/bootstrap +[ ] Logs de sécurité activés (tentatives de connexion, accès refusés) +[ ] Rotation des logs configurée +[ ] Backups de la base de données automatisés +[ ] SSH key permissions = 600 +[ ] StrictHostKeyChecking configurable (pas hardcodé à "no") +[ ] Docker socket non exposé directement +[ ] ttyd bind sur localhost uniquement (si reverse proxy) +``` + +--- + +> **📌 Note finale :** Ce projet est remarquablement bien structuré pour un projet homelab. L'architecture modulaire avec Factory Pattern, Repository Pattern, et la séparation claire des responsabilités témoignent d'une excellente compréhension des patterns d'architecture logicielle. Les correctifs et améliorations proposés dans ce rapport visent à renforcer une base déjà solide pour atteindre un niveau Enterprise-grade tout en conservant la flexibilité et l'agilité qui font le charme d'un projet homelab. + +*Rapport généré le 20 février 2026 — Homelab Automation Dashboard v2.0.0* diff --git a/documentation/refactoring_status_report.md b/documentation/refactoring_status_report.md new file mode 100644 index 0000000..76647b5 --- /dev/null +++ b/documentation/refactoring_status_report.md @@ -0,0 +1,80 @@ +# Rapport d'Avancement de la Refonte — Homelab Automation API v2 + +**Date :** 3 Mars 2026 +**Statut :** En cours (11 actions complétées) + +--- + +## 1. RÉSUMÉ EXÉCUTIF + +Ce rapport documente les progrès réalisés dans la refonte de l'application **homelab-automation-api-v2**. L'objectif principal est de corriger des vulnérabilités de sécurité critiques (OWASP), d'améliorer l'architecture modulaire et d'optimiser les performances via des mécanismes de mise en cache. + +À ce jour, **toutes les actions prioritaires P0 (Critiques)** ont été traitées, ainsi que la majorité des actions **P1 (Majeures)** et **P2 (Mineures)**. Les fondations de sécurité sont désormais robustes. + +--- + +## 2. ACTIONS COMPLÉTÉES (TERMINÉES) + +### 🔴 Priorité P0 — Critique (100% complété) + +| Action | Description | Impact | +|:---|:---|:---| +| **Correction Bug RBAC** | Correction de la fonction `require_admin` dans `dependencies.py`. Le rôle est désormais correctement récupéré depuis le JWT. | Sécurisation totale des accès administrateur. | +| **Gestion des Secrets** | Création de `.env.example` et suppression de toutes les valeurs secrètes par défaut dans `config.py` et `auth_service.py`. | Élimine les risques de fuite de credentials par défaut. | +| **Isolation .env** | Les variables sensibles sont désormais strictement gérées hors du code source (via variables d'environnement). | Conformité avec les bonnes pratiques DevOps. | + +### 🟠 Priorité P1 — Majeur (75% complété) + +| Action | Description | Impact | +|:---|:---|:---| +| **Rate Limiting** | Implémentation de `slowapi` sur les endpoints d'authentification (`/login`, `/setup`). | Protection contre les attaques par force brute. | +| **Sécurisation CORS** | Restriction des origines autorisées via la variable `CORS_ORIGINS` (suppression du wildcard `*`). | Prévention des attaques CSRF. | +| **Unification Configuration** | `auth_service.py` utilise désormais le singleton `settings` comme source unique de vérité. | Cohérence et maintenabilité de la configuration. | + +### 🟡 Priorité P2 — Mineur (75% complété) + +| Action | Description | Impact | +|:---|:---|:---| +| **Nettoyage Dead Code** | Suppression du fichier monolithique obsolète `app_optimized.py` (255 KB). | Allègement du codebase et clarté technique. | +| **Migration PyJWT** | Passage de `python-jose` (déprécié) vers `PyJWT` (maintenu). | Sécurité et pérennité des dépendances. | +| **Validation Mots de Passe** | Renforcement des critères (min 8 chars, majuscule, minuscule, chiffre, caractère spécial). | Amélioration de la robustesse des comptes utilisateurs. | + +### 🔵 Priorité P3 — Améliorations (50% complété) + +| Action | Description | Impact | +|:---|:---|:---| +| **Mise en cache (TTLCache)** | Intégration de `cachetools.TTLCache` dans `AnsibleService` pour l'inventaire et les playbooks. | Réduction des accès disque/CPU lors des lectures d'inventaire. | + +--- + +## 3. ACTIONS RESTANTES (À FAIRE) + +### 🟠 Priorité P1 — Majeur (Restant) +- **Extraction HTML Terminal** : Extraire les 1 200+ lignes d'HTML inline du fichier `terminal.py` vers des templates Jinja2 (`app/templates/terminal/`). + - *Raison du report : Tâche massive de refactorisation mécanique nécessitant une session dédiée.* + +### 🟡 Priorité P2 — Mineur (Restant) +- **Restructuration Frontend** : Déplacer les fichiers `main.js`, `index.html` et autres assets dans un dossier `app/static/` dédié. + - *Nécessite la mise à jour des routes statiques et des références de fichiers.* + +### 🔵 Priorité P3 — Améliorations (Restant) +- **Injection de Dépendances (DI)** : Migrer les singletons globaux vers l'injection de dépendances native de FastAPI pour améliorer la testabilité. +- **Intégrations OpenClaw (IA)** : + 1. **Agent d'Auto-Remédiation** : Réaction automatique aux alertes métriques. + 2. **Assistant Playbook** : Aide à la rédaction de YAML Ansible via IA. + 3. **Monitoring Prédictif** : Analyse des tendances pour prédire les pannes. + +### ⚪ Priorité P4 — Scalabilité & Architecture (Futur) +- **Migration Base de Données** : Finaliser le passage de SQLite vers MySQL ou PostgreSQL pour la production. +- **Ansible Asynchrone** : Implémenter une file d'attente (arq, Celery ou TaskIQ) pour les exécutions Ansible afin de ne pas bloquer l'API. + +--- + +## 4. PROCHAINES ÉTAPES RECOMMANDÉES + +1. **Extraction Terminal HTML** : C'est la prochaine priorité haute pour "nettoyer" le code backend le plus lourd. +2. **Setup Environnement de Test** : Utiliser les nouveaux validateurs de mot de passe et le rate-limiting pour vérifier le comportement en conditions réelles. +3. **PoC Auto-Remédiation** : Commencer l'intégration OpenClaw avec un cas d'usage simple (ex: nettoyage automatique d'un disque saturé). + +--- +*Rapport généré par Antigravity — Architecte Logiciel Senior.*