From 2ff38a4b7098e618d256940ba2bf826dbd9b0a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 09:34:24 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugins):=20=E2=9C=A8=20renewable=20up?= =?UTF-8?q?date-check=20lease,=20fenced=20writes=20and=20update=20alerts?= =?UTF-8?q?=20across=20AdminCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renew the update-check lease about once a minute from a non-overlapping heartbeat; validity uses the database clock. Lease loss, renewal errors or an unconfirmed deadline abort the check through an AbortSignal that reaches source fetching; cancellation is never stored as a publisher failure. - Fence every checker write with withUpdateCheckLease: a short transaction that takes the lease's advisory lock and row lock and re-checks ownership before writing. Acquire, renew and release share the same locking model. - Add GET /admin/plugins/updates/summary (plugins.can_view), read from stored results only, and one shared TanStack query for the AdminCP. - Show the available update count next to System → Plugins, a security banner in the AdminCP shell and a "System updates" dashboard widget. - Document alerts, freshness labels and the lease/fencing model. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F5u4nFU9nJ6kuXBV7e3VBj --- .../docs/dev/updates/admin-update-alerts.webp | Bin 0 -> 88950 bytes apps/web/content/docs/dev/updates/admin.mdx | 8 +- apps/web/content/docs/dev/updates/alerts.mdx | 80 +++ apps/web/content/docs/dev/updates/index.mdx | 4 +- apps/web/content/docs/dev/updates/lease.mdx | 61 +++ apps/web/content/docs/dev/updates/meta.json | 5 +- apps/web/src/locales/@vitnode/core/pl.json | 42 +- packages/vitnode/src/api/lib/updates/check.ts | 463 +++++++++++------- .../src/api/lib/updates/fetch-source.ts | 20 +- .../vitnode/src/api/lib/updates/heartbeat.ts | 106 ++++ packages/vitnode/src/api/lib/updates/lock.ts | 266 ++++++---- .../vitnode/src/api/lib/updates/status.ts | 101 +++- .../admin/plugins/plugins.admin.module.ts | 2 + .../plugins/routes/check-updates.route.ts | 12 +- .../plugins/routes/updates-summary.route.ts | 47 ++ .../admin/plugins/routes/updates.schema.ts | 21 + .../modules/cron/cron/check-updates.cron.ts | 10 + packages/vitnode/src/locales/en.json | 40 ++ .../src/tanstack/admin/dashboard/widgets.tsx | 12 +- packages/vitnode/src/tanstack/admin/shell.tsx | 8 +- .../layouts/sidebar/nav/item-content.tsx | 39 +- .../admin/layouts/sidebar/nav/nav-badges.tsx | 74 +++ .../admin/layouts/sidebar/nav/nav-model.tsx | 5 +- .../layouts/sidebar/nav/update-nav-badges.tsx | 41 ++ .../admin/layouts/update-security-banner.tsx | 48 ++ .../system-updates/system-updates-widget.tsx | 237 +++++++++ .../system/plugins/check-updates-button.tsx | 9 + .../system/plugins/plugin-updates-query.ts | 2 +- .../system/plugins/update-summary-query.ts | 70 +++ 29 files changed, 1540 insertions(+), 293 deletions(-) create mode 100644 apps/web/content/docs/dev/updates/admin-update-alerts.webp create mode 100644 apps/web/content/docs/dev/updates/alerts.mdx create mode 100644 apps/web/content/docs/dev/updates/lease.mdx create mode 100644 packages/vitnode/src/api/lib/updates/heartbeat.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts create mode 100644 packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/update-security-banner.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts diff --git a/apps/web/content/docs/dev/updates/admin-update-alerts.webp b/apps/web/content/docs/dev/updates/admin-update-alerts.webp new file mode 100644 index 0000000000000000000000000000000000000000..9072bc965af85f9d4cc82e0c48844dfc0b065ae1 GIT binary patch literal 88950 zcmcG$WmKI>6E%vvy99TK0KtL>cMBo71}C@&cXto&?h@Py?(Po3-OjmWW-^nEtoQrY zy*EEtSx-NwPj^>W)!w@b6eUDOE!9CmR7Hg3-^z0m>plPf6c;KDlokXo2ZE0UC-@C1 zNhV4%y#_HjL{0rC2GQYL&BX`Gt(WcJ_;z16G z0n|T2AGe(V7aZ^8&iOZ6h5=uI1waab)E4-i)uqQ(dLpn5Xy;gFEw9ikz@t*;Cf%8uw9y?bcz+5134*UkVoSv&a z4E*vb=hN!r!EO4k_9U3t^S6RA`6hkagA&m0k>H^Uw13)tLIxsV@ITK4pa5I|fR99X zCl4Om&kqi7Do5bwp{SN5GS`=)*9KwV2Jq*jIjYI6 zjKo*|?;a04v72mww_2ePTsV22q;!J_M24}447^%TtvW{~chIW4)3UfgC*zWw~uLjlL(pI7%|`zy2Ld!i1X z?+|&<@m#mqZu(75BF87H3-!-;EM2lRMy!HZ%ujrYj#)4@hHK-&IjcJdA<|zLimXd( zSww}EQG_dw5u-q?fzs9+<_A81w}1_9`!9?6kMDgxcBH9ZV(5xbB>}YE5jOgfTyY#}?=b+cFmxyim!QkFVPgR(>IrhnhZzaQ84W#7vzYHI!r%HRMEkb$U525BI~7@Q{y z9&paO1?;{+IXJyH>L*rLp-D#O&o;zikV;N3NW!olv|hhvCC3P1T~ZG{jZ*S1lLJqC zO!3ISHE@D?x>PeZwsJYIoCRwI<^@_aUOi&r2+#y+M^e{wBI3v3 zHvQa>e;aMvX_?7dJjJeYlA!PnN>xiF>Pv`QWtD*W$@_vA2jC4VfnR=hiSL&D*Ap-} zUn48xbG#5$cxsX@6&dTg;#_Sx^ms{U9_4=DX{p`{B8#Yh*DRWm1B&o|uHBnb5p{t| z4|rZoxiaeU>>9FOOK_x??8g9CQ$I{+Gm6x+@}xZW95vw=Ik15DL@q)zuYlv!%+fm{ zndmHDv^zADvF`SPQc@KjDGHml0^|$eMjWx6mF!pCXl%@RP~qx72XCDG;MmNo(`*H1KcpYC-iI&nj|LO`>i<7H?EB1ro+k1WA5@mV zqczRT$DE{3iqdT~l-jW|OkX7uw#f&lY_-44WRN-y$izZuQo}UKquOPV?hA-aI0@|G zFa7PAXGkt6wj>zi|LtQ9DDhvMeR7o~oS*+rG6VGR8VL<-5-rdZvfHz!JBt|9nwWuw zIuJxd99~+I6a@SKYLHmGvrvYQ`X_JthMbAqDBi6+Uc{NOYl(sq)EFNH>1{zmsPEm& z6^n091ZnZOzdZQxDwS^hf{Ug1xX^HvlB``XoF9A3^}uFREIc&4lCDEX7-wM=nr99H z^GU>pWsWL(gg2~NVra;WR-+F}bE~i5 zVvK7$RIxNvEP@NR0%pQJjTSa+xcAT&-=&dU?adTCT~zSR=MyvAAMA*WuXi#|75$k+ z;3ek!qzzYzDcytP_QbiTL@(F|pq0X^$fmq0=vGG!d66(AsVo=HP@ceDS-|51VZ+D? zkFS)@-$b4DSa3`ptB%o}_>+y>DYpa?2Xq$O`=zd_0O?)V61gf$oebIzEoJDsp@l%M zOjGvo779^wUg9avaaw{`xw(>ouR|-!Q4CasZe z$PALM%mPat+(eb&niqq;wEYDel|BB1Y)IyXeqQP8e~%8&ZWM6sqo;TCu1*Hj zNJxD=2wPUh_1>_f-e z2?fZ!|1DAftD}CO$A>L|sy~Cni2VUv5kxec<*$TV`5FfIrS%$*py*zgS7G#ZyK@94gq6AROAp|sWHW44i|IP{IWE2N z>bBpI{(qPP_UjZsA{yO=exqMTB@np!R-OKp6}Tlt%$!`#O6F_oM89rrx=Xf>hMR4c z*Map*Cb>!e78h=4M##Dz zwGco&NljjBuKwx0{Qyi=tB;Ya&8AiVl{YUe=UoI_d;lBMO#Hb?3ZjxTI{cd~QMr3C zp+1C8>HW}v??7?CN9*=GsQb^m^527m&_;2>t}~e!lCkhJwW%Miy4Oj6Lz?{$sOd4o zpFPoEe~3fKdcAEI0tu{`RN_@Qr#^Q3nk@$U5ra9j$jl4k==0C$?ms>6dzwK~%I8~I z2&}k`{eEQ(M9&Ig8O~IVZ1A|@)LyNIj1S|QRAH|8S4as6#6onp6g%W_^_D-qj+Eg` zNb&2w!Q8JsCV}k2EY6rOD-f}ViaG3+!%17Sq~bwhdp&#@&ubdb`c^I}MLsarCwaz@ zN|Y|@(pL5A2W2`${R2?>%+E@JRQjmN|p057mra^r2{GIFB1-bYnf)Txq<455 z#;~>k8DC2yzb@!iHQ@b4w4K2CpnT;P$H)hymCsvV>u6LhKVwehlDK+(UMN%C6wo4x zc%@C;>4tk00=98I4LpK>0q#fylyw;U_%t>jD0baqd6beM3qyfi)F( z_!M|nn|0p9hsoWS%uGmUlm=%1s%|nGUGr#*)~m~4Q& z#|)xg==7PeC53$b@#_1tqXNU8zxc^Z6B4HttDb-pE?o_hRZzo!8MxrA+I$G^wnZe>QxSsSrmiD9AWYb^`P^oR!(Z}gui5hN3@xdk zhvjyA{=}Yl&6*Zebu2golVJ(1oF73o_asuFUZGdfHd*#$7PQ-sWe@|(jypa`)r`^C z250Q+u;oeAU|blYTXztpGwE`Y7f=0?+WyDox*L}KCHXfkNOl~?BRtKhWXH&eLEvpC z5w6KFe&EKOi+xd{e_--0^VMyeV{1$^q$FvSxT_&yH-Gko?WGNf8@)fJl9P$@>r*2C zjj!;J+>eSnA#nLfQ`2-Nb*PMFR%qaYqhBHBYl>hKZcL=!@r|92PslN??Mn$^e5y{< zg{@fWb&U7s*)c8<=!2&1`-0^m`X6cF-?zl*D|eOIjF(xqb$2p&vze<@TtHkF#+-}H zG9)a0skE2rC&rknq4XQ7=Os--(B~u>Q+#!r?TLxixDNwas**Sf0*MxZ9cN_XwEwo` zQ3qs=!te;7h^@9 z(@K)*LN)O^n?pr5$cv`=aTX7O=*y%gD^EAqPN{37g=T9t_vS7SXCixigsjs}F>i>|ns6TND z?*2>G-^mq>3(77|!QNr}f^sV6oS=8o$G3kb{0vC>cW3y_3`^m%J2x=eY`A{cVE#K@ zAKiXRhf^o3_-mOzaywW?aOAqUR5RP=;I@#Dhx^wJnIYI2g{{H0X?(+d0-mXn;^7yl4xu-+VTRdO%0 zty#FqG9qcC^q!nSn*ISk1-G$!`a3kf{%@+;e)4ZS`$gSozwI5$#6r6;pl))L|L5Dk zQQ|)`^>XAt$KgqTnQa+Bml(hgPzs2F=tRo$!{T6t6UEwWC;<>O7oiF_4$T7uSGD;3 z%TE<@v55t{_@aY*y4x+TL(fpWicLRh;iUCrJ9``r6%dRFLdOxUSsKD0bbTEEnuA%X z?=s-g%^QJW8pxS@l~SuxB%TvapP>4m!Sf0SX1tgCSARox5OQlo4I@Fbp#szSxTLPG zb_Cs@*uThFW^J+xcB0l%N}Ru|>Ag3U7BmQo|6y$T$`LriIYuxdZg?ZXAwu(Pg=2!*z*pK@juB#`q9DXq zJOd00B~I`IwSrpRLn7|Ji{_jSJACEn8n}=Hn5>_A3NVuBBIIfK6sflw``txVYyta_ za!DCH;i71_8KdHtJ;jxteu`HV5=H&?XH;cv&8|IAZ_)Oj8pQR8iQ2Q(OA)vY4fKU( zKm8{uyY~O5_Hl&+5fu2eA1+OSiX(0=lUucH`Pn}=BVsAHFWga`T&2GpCQJTEhKlc! z%W;>2I`kR=xh+hvDW5F>L#^80yo&-YYTRn`AVHQ+w+)n9)$SH2N_-6+FKqG1&^t)` zVO4WcBJhAz-f@J2o>%ux++}*q3}ewqzwySXQKcy5@0Xe1kLZ^)_7kr4k{A7({hs5c zCd?sq*k9c9`+bYtagOzF$m85%K04S4c=77vCj&=7436ll@%p`FqhAyRiv{kebb&dY4ewOppICU44g9>ZILRryZoLr;9Y3wkqp40*$;qPX<@-Z>R#_}<({gfSp< zEwDB0H1;EEVd+>+A)qmOXAzo4c*N~s1TId>SK`}Z4q7>tLL%iMpkESV)?wl(F6D&5`?oWOKpfXg zWO+q!rclZk(Ylpiw)cD?07pYOVmHz8A}c zM`3wTw?>(ktR9=s+4^QCjcUV08%Cu3vM6V!kle^Q7JCu9M30kjK-Z~9$yc+sYCyAObN<1coWp%v+duimlj#DQ{2;K6bzuqy1$=Q+0I*Z&tiPMivwz&^`!(2sA zX~U8e63{jpCCf)BK$@j1^M&3Sj@x}D5A3YT5LL;+b=f7^H%0Q)t}d>t+k60c<$3r@ z9k1F$sq8CD5~R{lfyzF{YbrXU;43^Pn(A!%kF!FVSP&3I4eM@$d7zp+FeR&oV{fo6 z6zG@7iQx#!5e3PD6GnFS@7>fhHvUo*{)jXO%&0%h!$)kng_!Eilr?*l2}4mB_>-mm zkDd+Q3jP(g)f)EYp$D5eDg9)|jV@HF^_xMhe*6iJdIhk8nyvP&sOoEinnff>jLOdM zq1-7HUtEgKRx*A?hil&TN!6|QvVwvIJ$w9WK+JQ0_IbS9za(TEYFSlPRoTF+jF#1; z&PXTaD{naW9f<)*s~Lq+=a~@dc(W%sN#hy=a9JJGTE1 zW%x(FaIN}hb|NuQ_*sH=ZR@f9SsMJQYVuRYeuhE+6q?|?Y1^&ur@c2_lccPgK=sr|Xl9m{$3KD- zwz@?KukN(=;)pQ2o_ZvVbw5|m#L>>$O-x+t0~o|k{vyspV8)jxO^F&l*azhI63E`L z{x(8y((B7*isf2|6`)skMA&4%D1;D{q)ob4cJ*3Vb)ub?O0OFR_Omk3IaRxTUS=MG z5LvvtvU7ATs%?oBsuZrtBPb>zHG~VJK*i6o-#lHV+B}m`D_r@Zkv}aB5^9q z;Kl@i?;tV#K#cU!2!$4pW{UR>*2>e_)~96%ah00Yw4Et;+8{z2uGe$UV%>Db_d5tm zkIiN0!g!~b-s+n>aloLsb|v< z`-wf9uAyg&C5A0ua`e5LAT%*0?C4a+!}}+DHN)1!kX_SwDNm2K8s)M5_mqXRZ?0$vQ#WN;&l~ zY|G=+v(X#a*JL-=HSKHX0IEf(2m9k9diT^eLj@kx5-fXAf+B9m7FP_WKS}XldJ8wD>c8}3N33YPZFGS}1 z1~L5>gZ?bpD7>zrJHx8}hNQoMP@)*qI`o{O=T3B=LwT}^AzL~o4H zgK7nN+6uBlUozEVEYugP z)Zm~>^f?tl%Xy}ju4;SFzHd_XExAYM>Yk7f+ObX~AbHY<)5i#p$i$tbZ=48(zJ2sg zHO>4b^IM<2{Q3vtLM5)lh{$YyAiE#rIJLb{#z;(e0TP8oc)yHjT_Jt--5|%rpe$+mHnwE@=QI;X8Z;Q=eO)FI_aniYaN+r z{5v9QkxJ$Cxa2+;8aDwNS8sD#lj*KR`~hd*P`DVN69M=Aq})C`7#eOTxT79#hq9cx zKF=At<6*x9eOD-J9Hkri2k%*5{a8h}ilsnZuc?$fu3!t0q&k4OVZx{d< zzPNjnE0#|MmbupAT7-EyXovz@ogwiy_=U2^MFpS;|H79a(M$d)ge@+j3M3@|cge2( zC9#hDpQ?(xC?p}W&_C+adq2x3-Eg5@={^vL)}YZwm-U+}38{KEL<^c2OH7NAYUvKF z_2Z)E9Eo?w6tT&CB2I-N zs7lp&w`VTD%CgJ`=1WGds84Xg%z+VMd(h-ke zip$>-Bq6j1{%KKtU8$r;`g7aE_m_9NB&1iemK~7X{FeZa;@k`%@72Fu$9>@#zc$e4;&nzY^l+SGOzgOHX7BOVcz?F`Mo3Q zCy@QTE8=;XZyNbZn~zwg2pZ`F?(X<<8g0q2^!zC8++bS;Sow>>sxpd^z&7 zy-iilTm3AlbBv&`kSNBSegGLhfR42N1;TU)g;6kDk)FKcW#zVbiCC$i zaeRT{lzjx8xE5lV?3_6(6NjbLg*1K3yCS;kZo7vj0xl6LiVi}A*Ar<<9d12=f>8z1 z1@i{hWwn~2@(>N>ytv0=5Iz$(jG#DN{A=ro>EG65ZV%z`oE~SNHaAuE%ZxB4(?R+G zT*#kB=U5IXFbd3v-ti%MOnWYp(?_A*s~9TLcWG@HBfP}P!RT5E&w4%f6{K_VbAE#R zCHlqFjDiz}{9qTKzy8{$(p3f~*i$OO!y_%V%t%Sr3Q#VL5woJGoGQKqs_Pb$$`d5Z zU`lNO(iu)0Cddwd{ov@$t;t9CBWSgf!+5gj$PWxe@{-<7!>@J|6@`7Oz9~H{u)8oJ zy9rW~Y-UhV#&R&S+%B*#szW)t99L!XsYm!wKk&wi^?fYKB3axM$m*T_`aMo-!fgH{ z@b2UaZ9BduAnd74Y#24Plz*Y%#p^w&y&~`M9W;FRpYIGKBH);j&$WD)o;HNmj+4>} ztI3vpFjV_pg>pFuZ0K1>tteK*!7JZF>tYJJ+oaEq>NGO%)&p}nza>tdwJ5<;i`qil zUU7A=6N2hza`3+o|(c8efvV+Bge5w#qGaeS&0 zms}t^L_p;Hcrs4q*=dHn!$uOg&DV2km*u%#EYzh`B2C^Um@tsYP!~ui`>MKNgOEP* zuF~9|h!AmkVjD0utEhruR!+%FgD;rfoVtOhH28i|vNVdJ-(dQB8ZtA@+KWkRHkalq zpT2WUmQVdPbEk2evD#F`thdooNt+Hq4X>!qw|o1IPbMK!h2^mjk!D(7-U~_hA6~G* zz+7wr8LL6ON%DK@SP0{VM!rtF7&JDpxGs5Cd?(i-nSY68Ke`fAe`;EJCLkfDbv-qI zZ;qm>CioG?{`lA_&%Zc)0ol){Jmq7lru(Nx{6iD?5gqD?o@saZK(MyDdn`y zd$dM;h9uKx100)K;7v5@T(vsi7c84o?=B==fjXL5@PK;;_EnT_*YT{D$D_Y~Hs0}2t4`q37~9<7+< z9w_g;8DsvzH+OeiZ9sQMddH=ygmr~+p92L%} zGeq5G2*mzG-MIQr{2HlX*4cTJQJ!t0KyS26(ihEtB-B3|S)XGtqwn8u33#im;*V~d z-@pfB-6GcbAcJ!luZYaQHJbj>2KOHkT!l)@(nlD}*SQU2@lSn;@MN`XCfRi2U!um} zOuK%-b`|e(3H1Zn{IzmoWmJ`2;>4hU5#9TV5pmXF62B3IO{nTEcrIkjol@ix6ct25hC6p)$q*a(f7}XOi3P3LZWd^~^lO#$H-_sj3MxQ$ zEdWP>CX{&TrG9K((vY`m%|Yo4{%5snr;E99;~OrmH;+Wgjk&kN)SFcwUy1j5pA?M` zwe<>=;{*wEW?bt8sSil4Iy%WqmH27c?8|d43V){;f8v_ozV8dkUkyvojga9BOr@7@ z{=c^RK6n4+w+hSs+v86+p`$-w_Xg4h3Gi2q>M}cC8tK`{IIS#IM2|KKa z@bi22PeFl7IPb!0K5R0Ra%S@ykXx1TAGokk>9IBb2rI5_VT?m2CV$=ef3g4XgY1Uj z7=YwIA;X`a7?$*Fo#&C7{0OD6@zNcz?ycsUs9pI1Vk!7X(PY>^seLB=2WS^n!~# zuBKoX-)GXk-Oy?~>u&Mb3~~YXpnm?a-To`*0N{A953^ zWxx9B!9Orp&b@vS(_9W=A$k0&RL~c%@QjA7LE*B^#^W|^?kY~sc%#CM!{OnYOY~bQ z+Z6KVw`wMXw$1dHo|8rI;)1HApcQuO+&#@qhc<28A@cSiPz-`eD5ntbD8>+}hl=rc z=jI?Karvzo>H4(>U3mF)7H~c#>$cWD>R!0qI9n(AlGV%ZpH(FRP~5J{ea_x2}uF&W-yL-%x7DAl9qV!_~|P? z%6blB)NX)^MM$*Sf?I^hhca!GH-a;{WklXX0upff>YLNfFP3qWz?|NbsjSpa1btrW zf^64+uscqDyg9(>Qr~sKw#71onUW2NZ~W=9HBQgBAO zO=1PWOfL;aa}2T$K81AEza+%OFPH0U&^B^jEf!J%4!ENJ_ZKvGup5)bzGgdBQ!jgW_yxIG+som6n*(uB;A;bvOc(FB+7*SM0u}&}W{w^@7XMxMnCp zA?sE1(lVwrAP4o<7{?waR7pYU3|5vPnLB>NR?z^F!KW#2yyHxRVHr~sBik`K>W-&< z{xPDPfK}o%)k$B7AU0>cjnPFXic&E80IAm=?y7Bg9X9+DOo^#*2=0uze-tP4oGPV) z-Q4a#$*t05_{R~{h}P?Z7|GGv&tH@Z@|cxDIcA*FnHRUaUN1$TXBIHp(1dg2lbnZ5*N*A+P+z|@i+t`?;)hd(GAsiYyHqubrN zc563Ka0MpJG-goOp`dJu-ezB*02;V{=MI7-6Nwy#s}AQ5EMfCG}XJj24-iGbS&pVMaOKHmdM^iOBX}& zWoHO!(jmx-)%9fWm+OvCw4Rtb+^DeD0w8Wk#(h34g8Ply?@hX z!F#|;PdRJiPjyZBNq?}sKVd9668Z!YGEVL+3A&bwXz!$VjPCH+xf|V&RY8-ig|D_K zOI?5Yp&7Q+A`aZ}%jY(!h&h5NudxmkaQTR7NUB~y7NiWGn`n4(0v$>dU#m`78qd1Y z!*v;w9}gqqS@rv^QFAeW5F+Jl)YQvT{Hr0S1ye7yftM!|Aq)}hsarYy3uM0K~%FHcTXV4EzOYbT^V-rnWErB{tG{)7}nIlp&psR z@w*W2H}CswKKNx3zj+r|uUXF4F{5w4e0lrCbAm;sN0{bUoOhXbpAV!rMsiQY{MIB_ ztCqmsJP5?ZFEo+ss1I&2KX0jWIK4XEBL#^!{zLS0ygRH8V%GNkd@|nQ}?B;Wm$cv{) zt?czww+tL*%RM8V4@j;JBTb3gt*x{1xnElS-1ea(kcXwz>$Li?(i8qO+U8(k(B6v37t2=rJ?NY4d>l9C9K2aoUR507v=v^uF#aK2mz)JaJjQd7kQ55?v`jV0~b#7h*_-pMk>N(s*gUu9HgY- zgzIsjaT~EJ1srUDxdT(@2%qNcl0HDslA0`>oh8e6_MLbCC-$YqYt7q5>(hi$95K%q zaUNaGQ%1V&7A-zIY$M@JuLyhnvIl~;qNfx;gN4n`ehEh+~J zv`@0^ZgeC=hh!~66y(m&%jTJ3z(8fXyr;mDQjP;(jE>=+?Hx9J)@y&b+@X$Cz3}!C zMw-_2peRHgdT(jmidK6ox;od-NH92GYPQ-l4IvN+rp2)k5%5CBd1ttn^4QPT-CF*W z99O{G$A%?Nv3H(^*lv;SsYt4e_0`C%>&Fa@bh^!* zxWiTd^*H86T@Rwd1pm-dR~q3vWv;VPa0^11Ew+_i)GJB^<~E+K6%!|ndRUGRP0`Y; z4NTdxL$3&ci*?%k(=vrA*o`ymT?6_L8>X`tRI|~WYk+KNck|9 zvv(VnvfAvW$vilBR?YA?R&wSlYZ1tS$WY z*@z%FO9}%-2bb{62Tz4;1+bJ>?{XEu9Nn})m9-D%v9~W2K81>=yESi4(iV|+O(kE zaCV_n(+MwNUgPp7SxJS78p4ka8Dh&TxAS+U1d`-mRF=}1-Fo3%>nh(tU1QLTqZlI< zU`G_M%{xdb-4Bv+0Slt=ono099dxdZA~fF>jm<8I5+~!)<)=DxJJsk;-%?c*MQy#Mykj@4~(J2?M)h7em)c#%B;2O%c zHI%&xOW3C}->=}opH-Bz4SWJs=@`D?q^km7B%QCfOokpTjA?(#sH$5+3ktwaeHf(o z6NoY;OF0j~FLmELErEPG>=VB`8jK9Gqf2p0<4)1^@o=rN^9UMYF1B;FLFx@SUiqF=6VOA zxp`oxhR;^F8*=N*_AqBgHWKc)oQuzm{hl(@{4JaqwVm-+4H@5Vd%;x&i%&cF1O z_2@J}(V*8YDPVPM7Pe&V-er}O&CN4qtT#FiZg$Xg5J$jeCS#IS^`br+z?jzq9eqfQ zBalT$FTt(#SQLf{GxF9GqxC+%L&{wkrtVxSN)`NezX$D?C`sP-(RVsgF-{C5?#a!3 zQ{Ck;yV7!uH|A|6`>AUy{q=a0`%Mf0n*#wj zqk`}B30viAz8p-k0nJ805oJHG;h1vz{;aQ-purojnAi<8=<&A4HO=-n}r$en$tQ_HWj}fK$*C-`ppGQuRh>uCXP)B08x(i zE_@MLD#MP8zoNHg3M>awM_kSJ!+PJsJEkiu#b||MFVgJ&z?pU*-Y6){*Vz?b50U3hz*TRL zwdWX~vXOHlTVO(Kf3%TMHue442jkuC=DZMEFycggsgnkyS87oKMv)ag z8mAIh(y6tamvDnhE@JwWolra6kUiDhgubpY!<*I9D4QP+1O%1LY}_r}r#y8)LvgQP zHzs~rEG+?V4v#!OoC3bs0v9+a)2bi1Djo}S|8~ZLvya0P2XFpqm>%zBd`t1$m(BO_ zCIoOcC7fRC_x4EIVo}a^HXgk^V~CvWcAeWV={v-(S*Zn=!??v%Z@8t5f{^9>S0)+> zynRXUlF|Z&Fqy=fPtrjdas@7?`d!Yvdv6KlNS27g0Ma;N_|5Xpo~hb)aikbNIt8qz z#9pO&9wZX)ryO*HVs$>VGtm&Lid66nKt{e`K8WpE%#g~b&n4>9A}8`;MO-PG_#9fi zZcV99HUM*3J*5mkp`y2DRzBW*I~CFh2}fdk7bpsEMpq1Lg$81$4hx;zS8&H<^Ff>5 zK+drC{&|9mZuzsYez*qJZ)4xui!b<7Pfq)I0Mtt0G)a524*{FCHt^{&Z^~K6-8}b7 z-mDzG_)gWilixk?Axpg?byn;4lSr$4kL>T@JArW^%5u-mv6I9*2` zeQXVtzJR{*TQ0(SJtu?^q)@HAkVr`_wKgo`~F_QSCAB=>Ea#%jqit zxj{RWaR;Iz<2A@T-6WO94I>7|yVsqc_Ej?Ds$E3P^>0HCUj zDhiwsf$9>=pF~MC#dg{26}zfnCgZ8N$c+lbn&#Ipc3II@&X9}AW?9p!JCnBi&b)}$ zY}2F&sH4=%ecXEL!ohh_^5!+OWUx z(Eu-ywY1X8xvr|fOIT77tV1Ry8Exm*QrKqC6H$orZpMlC`8RKwfXpQ8kfkVGG2R%4 z8q(M2XmfE3$@U99?|L|An`|>Ytx?Yf#Y=N9(jq$`Mj`mOGH>udRwF>_Au@NEz($@v z2SX63*r~!cqRnf`m5r{3JguC|=5$0r3w}`7RIxLGp9Dv^-W-g1fUSd_cWmSOo&qut zh>X|0PAhGi4OB6Be^>1DpAQhr#8qNA=q3f}xC?b7kP*_Ecd6e~o4^KPm1;_aa!wAt zbsziwRUiZUDEn-Z#!kajeqgUjY$5bum;@Thd+}6NW}5h9L3H{t+2E2PWx(>hlmrUX zcgVKZApO5`F&MnT26bSb@zbyoTt}Aso1i+GlOaq->iOIJ&p#$SVRl#DlazyeR_|0j z3$k^ig7G_kA4Dkh(K@cs&3L*YPc z?8mys$TDJSZ8jjsVE|Y z=4auyT#6@;z#g0>+o$&YBCo8sP0Z4jH7}FLwXC%=uWP~9-x3qAmQfhL?sX;kztbl0rO;SM!MB11NU&E7HF9BV=J@nJq)+RrI;dX@_sbBAkh51D zxRQ6>E(Gapyi8+p`jk=lf+0>fWDW#e%enuF3yD(^d#7|Z5je8izIz0&4oz+39nvd( z?zx*bVJ~&~)MG+BU)fZKFYS;#RX3%2ZUu78ptLZ)n?)of%D1BPPA+G|*s+^{ntO?t z`;=85>Fr9H5%1(ir>3_;j@i670Hp|`J}4ljU)xLE+i-7C=Bq77uH7e+M0qoKWzGrk zCpv1K?D`bZw5Ni?VF%0pr|9iA2bh2lg2Kk{^d?JrhpP*y$?UmOXx~Q=XbS2a zU&53+LUFIV?eb!239JR@YK`=z2fo^OemL-pHQVXn4cYcWAI~%$9FGinyml0@7{Lamh6 zLvQEB)u={(bD^&re(sdIIAsL02h9F4Zn_t`{*pH#FP-abyu#4R`L&e@? z$?oZnl$;sad@73HL7_dy+*=)MYwdohLKmrBkffWi%*h2wMI9QPdKt?@!8vvLwzTF_ z+;+{e^Ykm!68Zu}^X9t@QJ>^-MBZgQ4dNid3hyhpxR>D@h7`B1?t*uk%2*9kY?9eu zHBg725kWXuYmBu3^wvsb0x78Bko2(VO&cH`3+)?PnAfwyqBJ-vre3qcvhUQll}Ht{ z5w*(>H2CO@IwAB*dnL)_s340`d|`NXZPJ=}*cZFt#nEldT6P9=Ms0C8M7gV<6pcE* zkU%xaE_z(W%aLJZ;Q;@Us?m(9U41_rHhZgvuxZqoRfhXr)Zhwpr$`1L9%SjxfVCnAf@!#MpXGcDU)&e1 zt(s5B`67u8r#_TgyGucgm^JRheJlRP)^8D}QvAw=XbWL9T`0=LkSRWu@)y1YT z(%RQ-B5_ZI*c68a5Ae*@II`n7oKgwMoK?BpaGQCU?nO*qn>#vJKfwI>Hry^FH0qAfU@tSGhsb7Li(tV)h;fPHeBg zWF>MDM_jWjSJS#MXj{a?6@Wx?t_s%4I!K|eK@8s?z_zqzxg!^A3A^o88)5;It(1;| zqNLXahgki_)4y{US3&k})^E~E`u5_UKmAoou2SZFVODAMv{U>hH1GMHN&Sk(vbXvh$GB?~x1^V>h zMsOlEA5IA$!?17ikT01DIe6Y5xYSHBZSM0y#kL7rv>ZnRII$`Ml!hcRdzva8np8Ey zSH;m!%7)YvnONLv9FuMIDkXcx<0P4YC+qccqI_>I0P%j7`F?J>gf;j035$G8mkIbBS0Hfoan&R7geYhIprg<>I$aInw@;ZAdJySiplW_$ap+xMpTjlL?0Anj?W zf6$u^eHd%ael0t2U+vA(W)5lBK{ZiOW0^Z1EX&Xh$+zL;19wt;l-TzwXOxbxc8ImQ z(2au9(nobKFVTmTLOp(!oRYlrxUo%PDU_ATZcvHI)~|*FLGUs}^|Kn}41Q=o#fo`6 zIvz+Gtp^9$p3TdQSZ-4cDVVr)_k&_}0{WYhAgO^2ZRkMs7_RMt)CRjMb2IPio*nbr z!|q9jjEv36dre6)D_|8|CwI(HC}Fbm%E+Er{e7IYSr3%ZV6kJx9UUPrVKGF{N$gi| zPV@6Bxq-~s5|~-tugU!2mlR1~tMS@BTbPbsi;J_gl&V6u_UiAI#|0$ttUE#^ z-bk2#`6UC&chRqnqUT(bcicHsq5dt)qG(lh^m{!>RU#DtcnKMB+;F`D) z{iWt?$vo=!#fF;mrF94ks=!UxxEyjbv5O`3V+_;g(rTZ8)-CRG` zB>jHRES1naP2- zFI8iF-fc5hF;bfsN`Lrui}n4?LT4^q<|1JC#JT9l1fur>f5qg*v))SuTAa&8L>yr^ z)mrmfDpF$i&S9&>Q^ruW%$Jb#(|f{FxV<^Uol)ojMoL_wdUzqX>1D_O<<8(jp;S|2 zS2j!tp#cK$Q&I)@#qJ2y*5NybRB`QGm-)5Q#AWMu|K>WD3eNIp%~_`j@aWW*95cVF z@Lv($`U;8eaq_$N|2)32H>{?YO;-=d(ViA~tb`!=Z!S9_{f^#WXnqN;{Na03QJ4=6 zXB5KI`Q{uWjGwBPwRQ+wPeJ45blpnc^PWt_Gg(?=LpBX1T|;7yf+W>B%G) ztYj>}3xrGbig?BYu{5m*UQU>V^Ef}Wtm$o017G*x(YzKM37l;%z_aRO?Q@~vS)O7LKNqGYXR~{@ZgMX?oT@g z)+5k_Ld0o3WoC9TvjU_b_dzGpQ;M+474xCA!|d(894k207$VE+jIJ{;FmNyiA?7Ha zQ@XVx$>PA6^jj%tVg>&xYB4vE&P=8#&MDL7H0#a*hieCYE6&1v{>t7WEWkV@a4Ahi z*v$PrtcYoh$8QrM2AEml1_ygcXWX_d2U(ths&AKI8PNKk zSj`8=F6^;)!P(X>D>ocCO<^=I$W?S~?I zm=^Kg>QMF=-Ikx)6QTBbsu20}IcyQAnV{aC89dt*MRtSoM>7XJY(>Efm)h>|u&mSb z%1$OtPk&p7dDIhBb)TdtPnX^9OOAal)j}m)wD4vsG(LufjA*~T4fo>(=Q$rO*gl-M zJ!;az4IotVQH~4dsP^=;I`yc*+O=Q4HM>!8y~8IOhoc4b{L!p`*&D^` zGhdIB2b{t33#q|BNe4hoQhLGfPEgeX%_D3AjKRW010S%`ty6FyVTp7u%T41Pr@g6c zUkB?=2WqHz7`4N3{x-46z3;>6$&INV;e(Pt(YT-0550A_I{AU-Z0@8$ncP=fjiZ&w9j5KPKbhG$dPJAiE8A>3z{n3(a)I{+q z67#D!_VSW<6P5aq+qsYrd(pvQ6!X+7=sx{-%)t#}i}xLxV#gA)fYbP2!C1~y{T6k@ zO$L;PkSAV&B$rv5`kPH~E%$*$(rQucjHsqt9*$efn^l*g&63pGU1ASUVMnp)wt32i zXCwxU!k5nYc(beb4i}ui9EZyDypMJhqRwYIhUwdlK}8fCG4B>;SfnIoI9`9rOOGW6 z)%Zho-)$SE?Ojs^mA$fn4lh>VMR%OuTN+Qq1MaY7Xw@h-M+g^X9g?|4ds3uyS&@;s z@%+I`tz)`g`2q!Q1?wS23jbbo>^C@sYK?N(8Ln~rK~hT9G3&=G?a?n^nGqO6QJgQp z=CJD{qEQAg%9ow$adsv;#d5ML^&Ck9hOXl(cpYE&Og+meRWv}UtxR-Zwm5X6+I;1A8e0%KguRQ zmH^`#1?JhmB!Uv{9h&`=@d)werWxx~UA;TsA<~iTYTBE*%>vK#iF(8iSrbW_ljwI* zm6;LNK_(ES^BmFQ$d2M1#BYCFsU4t^-eh0jnktceYBm0i_L@baN7{9NaRMytD9i&= znmlmIHje-J(}VaKkdof@w6*~WsOioVAfmuSEQ>hz0494lzQ8JXqxdKfI4SEnqtE71 zLL)_O9ukN+>&sv=cPV{22D52nGFRhX{H0)$4g~hvseFhRte2<_@-nKVdIBx%e`QDW zmhBQ}clq(q@UYnROoFV|?~L<_?{n`TXimz@a@9yeLpo|a=@`yzss}=TKj-gGt{xzA z#Uy8S3-@e%N%&m)72Jd0z>L2qt)j^j=+E|&@dCgc1A*{IcWle{#6V)AQkG>E%s#2An&J7;hcJYk(%b0 zH|Bu!I3oh)r=1pXEt2Y4XpfT{`EEPE6@|4RxB-SQkUJgM3rUkPysZ^4#JhNd&vSU) zf3fmcB_3=&t1J_9Iz{OujbqW6d&xK*Lp`Aib<&~TFCsvI-Q zu~*HvE>i97HZodq?htE0fP!N?+aY3s(Ki7Tb4}fbUrGEGP@Hl&F)N)8Zz9-Zp167R zJH=_O-tw;gUoBZIg6V|4K$N@*U4G|*8wRCv37lxG1*zlg$6^EynGOX0o=vqLgt=!j zUTYT92UPH30@`fFmx2w9z_+gAF9^_3S$Gp8q@kBDZbykaa~oc_B;oQk`#)ah|CC+B zY`cpIjQjm1k<4iSpvc6CJa<-E3WXyC*_|+}+d#-;HjzyNKGU0q!=)aJYUodo+5tbZ zbCOo_jt_SA&Y5pka67>QU#v#JFFfLT)-6~G$Ju&x@K(dneITqbFRuN5{y5W!J6w7| z(yY%}Ym#U^NIjXNQOi$rDmv3yBES{#ePhm_XF@@7J?Ri0Vm$7W&)h)#na%#ZMG-${ zK79H1?rL#_H7s3w6V2<#z-ps}#%|>s3x};r9TZ1JOM_K!1$gJwzdn^dPmWfZY$?~+ zr(>V%S+3$OeCQj}7<_;OM#MQK9KRLW-f;VTX7VWWVe9sNmHy7NBt7zQSRm`9B zL=eXOauCgU$OS>D4yznm|0uCyC;f5!HlQfdX(l4C1XPkX{vf*y`` z_05(f5hCq-h)gha*HAxs8y8{o#H=la*|3hhW;Rq;TO3aW4RVVLcWIJua-cPpflh3s zs>F=OfDty9v3!0uEV6%oorj9BHFDA@$e##@8}|^_O-UMv&#d;(wCnoad0|q zzi?g-Dg)=t7A#4oNY)^tX|i@o``3x}yKXJSvYmGZs9i5my!9%sU0q3L)OXCcG&O8>Ej%o62WQ-M5Aay0NcZF3cst9x=I6$LP4&*`b5Ht?l3H0nBo|o-PZ3tPqKeQ zK*vX=YR~>6K&`*Ks7%Q$a4%>i>Db)jX_K**N7f z`$+NOSWX_pTFwx-LXbK@Hkpi%_9f8yG5;ZJ?#TiMTQcODnuSN;IxmL?+pu~>Zt)k^ z#4ZduCvcBMV|J?l%K6y7=JPQe4oZGTw<@wcHL?3Zzs;>V zbZ@GTjB11oiTr%3+@> zmaY2Ex+3_p7d!T35B=n8Lv|gX??EQlBhJMrfg##j2(^9%<*P3HuN(k4LiNH18bS%8 zQ6kKn?U*<^kER@c?I&oG9Rl3J!$UY~3b0Mq<}vTTfH!)^1%3(t!V&CN{#3Ury`kp! zo&zwRa+>H>JciBKi%=br%8bZyzLbXUi**1jAZNDlXTHOJIy1d+Kk|~xt+#Yq`uj;O zH3-iPLNtnJKD0kDWA_VM(j7|2$D6kZ%V~J-H-VH=puN)`J)hTxMgP$|IhkByN`Kmy ztjxy9)ZP7-$gTvjB1#O^cz(18qM4LSl3IEaIsORHCHS83cLW?4J4$@XI9Z{lSI7Fm zdYyoxu~13od8gVl6=^o}7WGdsh{!8Jo)y>$A{ITx&SB(Cv%QWr5vX~I7FqRVc zu7jZf_rWRiyKG>Jh;mVWKb?w!c?DSv7Qfx>#I83H3Ka^U*x#*Y1YmO$L5a7`q0lx% zG7Ym+lgx!#6K@TD*=#T${t%O^l<9sjhi{Ps>4p})+&jwWRNSg$yWA`3;NQaJgXDRgGayxRy=a=-z=cD*u0kMi?kM~>vu1CV^sL~>>g!MpaodyI?NayJB?&w#PAv^ym>`8 zy2rQ{kQ0xojZWLPOSQS8S)qDPz^vDEH%j)F<(OkIpYG=l!%pDLtj%v!_54hdRhi2Fbxk#zNzqG6bL@Gu%OHrtMw#_$CEjH9FdT8iL^l|=^GT;E*z9{x(^kl2G7eBIkL zaOK8U`$l2Eb!IL@wSsc`5x0fX%nL zBK*o`y~&VBnj2f^H#aDziMJ})VvTb6;kxz$+DC@moiu4O><}@UKZ@U{P%f9p=L`h* z$E+t&>*Y*v5OHjZ*9jGz%h@=d;8F~?k(#cNB=>J3V=q&pf%v8w~h5#mxXIDY0)Ttcs{Y* z)h^}7$#~?@Frb0BAdTjw*n=@(aD)d}ZH20&8_>trZe!cqcr>FQd?WvOuLe)XE-LXY z;|XOWRq7bB!QK%=v;*PNg<1UzQ=aE^!)8TB*`{DqKA#3$`B?VVWtC_pNXWO1&v6{l zh}aF|;#}Hh_#^5syVmnsu8s%eE|iW0SnO8sP=f;FD2sGb=7GY??s`E$9&L$V43&r~ z-w|tInY`KhbUI%K(hSp#d-O#V#d6F7iU7ivxY?&RCLX31`f#I>G)D5qVs!TCF(DgT zd|fL#`|&PPFfutC%wc=uRz?kg*C(ti+qE8}Sj5puA<)p^X`>@y`_uUI0JV|cXyVXjIN)^g^y1D`S;mchP;CVys{b z#^mt5K9)FGyzoaeGD*v}jwEEz8Kub(X2sBh zVrn`7RNG{?Kc_5QHYqz`@6AIRQ(-!ZW2*!HIH2}U5=tC4Vw@n>y3YGcku7M-eW+U@ z-1tf?^pjfi2?X};0{H7`C}JxzB{Ro3JETwsiyt8crXDOjtJc#w>cI4`q|kBpZuPLU zXd&y(2(BEnJpP2z+xco@4mgldf=3~(LzqCPHf=>-Ds*8&o9LX6S2xGrZHTNVTMr*1 z7$qPifcX7+vdG`N&JGLckpawjCM6sHTjT`JqrzN|LeIK`=&{&QzwQKRt)(0Pp-UaWeW z(9+b|OxYx^pFembb6$lfCv0DrvJa54X;8N0@@9?l4Hn@E&=?K!$&y=$;D>=P?HM&- zWw+r_Pc|xfeeh27lsvbJ^fz1F*O z(jZ~C2$PNSmA4~)TT!80$+l>NGYtQEr{1@+M$;9B;ol?3AQnfuM4U#m2-djKK~+N- z@!1o6v3C|S$Cn>6lgiOA0+BqKof&wQez<)#(;6Ek;3LN);Z|BSGheSq&P50Q{ zoJc*?AAb90^@RWDo&NV=1qw@s>7td5a%xIYu(Y8TY6?rZgFKJ8&5m>a>(TlMEEb`e zvv6%yK{5D)hSJ`y@z6N2=9idPN>x(%ZL*d^!1aRb7!g|5b9y-s_Rmmpjj6_n8#_!h zkyk=S&HRSSnY%aSj`VhF`>O)ZdZcJ|k;jT{w^$s{}bR_U9ohcV(Jq zmQh7^h&W>Qt;t-tLQ*OJegOQC-N=&LqSB*7G}1t$0Z2DGm5VLd%&QMoUh`Vn)Be0= zQaJw|lrl^(>$<%MmJOmR8+MHMZT6+Uq%W}##y=ygoFv-DeFG>&$fc4?p<&#R?uU+g z*d!v5Uor70z#o)6iiA5VM53%RAxwc&ytDMZuLKSGAVQxD!@;LZFej1<PxsixJfQu$)RN`ONu1#cwQC-T}DQzy|z!s^qHF_jlI)2Vl3 zD}}fu*u+SH06sTS<6m%DYx(ZI?xkVQ@!WAkcj)qSrMc?*3l&R3f}l}AJRLqX&uiUb*lG;{7>K&R2?@C5D{A9Cv2Lfh3!Qx`oLa9?Mx{ zD~3El`^xa2mqs`bP=&r2k6*%^jPSLfNtXKJ$S4>S66;e~3TG64+8uI~CIvUpDsOlx zySqIXIIxPwuxMT0hYudozA3lQ$UIX=)m%Nne56(dvv3E?a31$SBV~9<27jQ;3EgpN zlA=r34rc>*a`;W$H+Zdf+cUR|1d>u{X@IDu7qd6^Wifwje92V7sHc-xHmd(_r<=$q z`EpdGCHdj}xJ$Rr)|+!J2G-Usw+i7p94tKGu-BM5obCOa{4wg$Yo4e^75!Kzp^5b; z)`DjeF0K-Vc<&XPhu{Vxt)&ct6U?Say=4)G8i8C`NaS+kCOM1awya00Re3$t?a1fs z+FN@yXPS#ME9{_f!Lto>VR}3U_{`{++BQ)fKtmAC-8uzdAGXf?BX`QSYlX2@BtlGN zTOj3kpuVrhG@Bv|{p!&DPFAZE5<%xqrlQ-&Jfq?K{=d}`kPT#bJ1eV;vy$L7O+jHn z+o)OxR*lN?keu05^kC6>`U&1hQl*V1t%^GvU>r^NH(cQ()(%huiLiY z1?feEmkgd0tYNQN#bIg|JK!E69%C+0CY5<`B@EAimUkU;meIv`T72_lY#kJ>6=)xZ zP!qG8?r1VbM=?V}939C&-jdY{dpkZ?TWR>v{Hc?Cq@%e9-fA03LUO2P#P+CIsw1JV z2R2Lj?3r8My;hZZHO!8Tiu?!bCm zSdMTYnsFLli(clK4bk;UNE+Z={_di49bUiZ;CP_R`~mRCsJ6XvASY=T1acIF*g4lt z>8su_hghqwRYB@@za-NM%3x;!;F=$z`=Ol@3*+%`(P2F7n*N~%!!|VgfKTOfpgCN_ z9o@($dggbzT-%%;F+T0BBn5AyKI5F`rx*S#r2%&gm-P@zNAQGKC*ZPc*3@H!QZKJp zNm64cv>`%EXcNwjgbR~zBl>MvQH4)R8bHvh%4jz*WGjb%HiJ^ACnS4vI!8*J&tRkL zROo?GQ3HFlgSY7z?$*k@G2$&?x9hS$Fqnq3;H{t*3rA$gc^M=b^^kuu?3g>vI_blk zr90{2<&us#@`7Ib?D9MpL>ieb%8}2)} z9P*%@@p(%Os?6ui4i(cC32CAIer=Fc8T`+dHm9&^M*L=3?nU(i! zv}l8VRjITsDZ)}qb7?vyI!G!h?f64a`0AxSQ|xGNWJYpJ7$B@)*OO>+VUcB1v3p!X zFT@iGMDhw^B+2vAHeBpTM%}*`f8UPw-MVsBhXb3)A2NcbF`w+%O%_?BZA;)VXFZ@3 z&IW6%$z=iBxjL|i#0-*iBD!;a2dU6n4tGFAM@2`rNXfQ(V=|uR>FtQBELeZt9MSy4 zpoWfC>U?Z!*xZlW3rG`zh+zr{2rS>uvq`r;<^wgt%UMA;Iajh_LNVPrw06<9xrl%` z*V&!9T$IYB8mb{wHMl1`Vk*pu5jA7k)|nxZ3I>C8bV)*?)EMM6zX0TG%P;m(gQh`s3`CaS>hdeHFt0}3pE+=QIvT&nUie% zV1hUA))2~kGRV~fEK(D)zfWfFDj}I*idnW3^M*)%PCct63dMU3bX4T*~1XH8#AHfi4-S@TYB);CjkjSI@+mdKao#CRA+G zD)`%<2dfG#!FoQ?Xs03Q`@sig(vs#Tgu1(>@x2c57|=L7ZRgh~x8KHwf(o@{AmnHj zhOY(%TZeuR_!`}jWJMefYJGC@I;^u^k=JCITATqYvVB9c9EScrQ%dd2R0pcF26ffs zL8(c-=zd`8yBkStmxU!wo;fvQaymxN-UtiLQwXczK>Y1v{fJNh^dRb|iQ5whXW~3x zp&AZMEafw{b&5sb;L-fCwGwa1hxg`&k9~qMarRw7d;d02yT9oDZQ4ertBQlL93-?2 zgK7KzT%bX)mX(cO@9l$G%gV-X^!3B6rLWgiKx#U622y0Ya(saLS{ThmfD9YNZVq!Ia`=ltP*~-={J?lo)R)Jzap?ko3-C*B~cef6OTmO`*WX@|ZS!-R|4w1GfboU?>RHZ9^XQH5fO z#dEWuxdd3rg~lO`!Xsx?s|N4<8m8mSizQGaiCZ~T%Yphq5?o~oRn^i(aI=c1K@^##>bI+bC# z86IUhzNh;dAR2VXT+>CE>0r|aj_2*eG1vjX#r!yHp>DNtZI%^!1MJX_g=Bcy0S9&( z`$o*ygp45o>$j!Fvr1H9qHGS+a*5*KoA6I4O62Q3CiJ-{B@-vKmf`v#)o!8RJ!(%g zm;LpE+v5ue-+|jcU#1y3f7SXKSR$$dAVBV2k(yP=&3PNr+<x5={2)}?w(HC^`mx`Cu zWj5JtXRM6k9xnK}L7G$#UOOV~d9kWSC^BqIVX5E$Fsaa33agcHVuy?6)^+P z9f~o{(7g|8mAi2DV{TK!6af9&bUMw~dB4YwF#}k)oZaQ`Q;?56hM(FplK=z^H8J{3 zG7sLbTA#S$82ID(+!@MgjNjvy$kD=_lKHj@wi0A>gN?A^%GtoxjGP{rsL~DzfC`>*M2PkCA zU_7Q;9OrAxBpJf&iR8BUfgHQ}{>h&g-lmkwrv_`&!=fhOSV+BRH{gIaSQzY+Ti*k$ z*k~Kdc>ZK!@9&{As|}lysr{lLE9;XfNNWa*i2H}H|80C&rGexDQaX8oE)_s`iXG<^ zq(i0nD>)r_U^T0cIB_`W$&&WEl74^}`dV^@@X>4P%jrSFlkFhq0yBkCLv#dt{Dg`Q zX85(uLT9Vek^!}OOl)HO+%v~(2JbGpffq&wuyxmaQ=*|jU3+k~5%s~NSq)I9tI}4V zcM$ybS63O~>Lw?@AiNbdG{L?HxWc1oL@5D9VwFY$QgrY`mOGiq)A(aMah}qT@lp6Jg;>2_h1P|XG)@AxX^b~)?K!4|e4WgoY z&d_T!rj^Y%`7TzLz}2G!CN~V6zJ+>84cX_Ae;TNI8ky!Gjv*Yh7#jJz0;u4#xui-B zv5-Nqw_=NQsTx#mpG;3Nif^k%2cU#~Xjvr<>hH&jQ}do%-LXtdP{0wjv=S@6;7ox1 z$^r1m^N_WpT-65U_$jpo5%qH`h0l0VPi%6myGdbJ5{ z%!Hk2V?r4IsE=Zywl%jOt>suJ+HyWC=ZORoG|wfz?u#&FJ6(UU3Tz z^r6<%V>w)F!gegT8%>#&R)r1U!zj@}Vhs?CzF1?&{aKWwM#66kk$jg!wM58#9gu^L z`%sEm!%vvQ)fZL_;Xnf~7}9u3%+cflttOO=biNjY_$Fl5$V~PuaVyLM8b0o^8?e2( z;7xto;QxcC)8A`Go}thD^2W#s zL|j-Km%DyW?Bh7dDI>VUA}NlF$zEYl)XCcdO3S4awqAfH!&2(2n0pzvS7M1)cq+3OCo{frl82Nmp~E_!N?3dAgFx&NImxO6u*p^ryZacr zphLN}ptSknT!nj0*7mzXGxCxV-Pz}bB#!4e@0#7KYF6avo^aHEC#mKRooi>QKibHT zPMewXhXi6(70&kfjZkN|$$lYjR0M!U#10T`G+#N7Z0yCrxscrG-k+Nwo_^GizLFLV zyN0LGv7z$)Sptl|3oPm*QxBwwZ$mY=xp&S9*>GZ1lEFq;bZj100P9a%j6frRgVH;u z-eKJE_#_3Dzxfctx0G~$B!q>V5e(MZIZi9d!C;LJvG){wMH2z-dq@-XwzE#NJJ94N`Bo@#js?~W^6f)^TCpoii;wAM-)e|`|C1asV7N%WC&FMwVf zL=hYC8#cpPDe=qH!8zSwURbqfG*$Kpu*=|djNa|~5X3)#F}MiTH zsw<_xn;~1$HlSW8){nSyk-KdD*f#@hO|>oKpKO$iAw!k<6=4&lP-R)*0|6ji$&j#& z888-9n%rS-fV_IgG3$GwD!K2!_fx1>iis~R+%W0dqS^)Z>7j5X0#!I3#vtTtN?>X? zxebTdWCQf{3;M19#W)J{&i*{xuKVC^it(^bPOz+5DIA$2W8QU>}dIO5akRQ6)&+2RNu9% zm>MCwA9QqY0Y+bNII>hXUDO^qEiRAh)X+sA?khIwG{hNZ=aS#&kBBbElbqOMtu5^) zQq(xf9WKHywwvfOBb1&~4aVGwo6DrKY(&-%^KGoogB6KY0d!YzAA{`6ICY3Jbf*H)qL{B{y7 zvi@lgkdyoOm@aA3&k}QP!;~0s3BlSSmc4M9FA=p0R-@)$n;nJWIt^_a`qvN5I6fhD z*2!$Ha&$ZdZUR+6I?X{iM1SlhwpPm~ZpX98OvP3=8tW*atP7iT4im9WW+zPBaLpe5 zC7g@HBiACCe!x}p#f*9)qT>>LBG+@?i9#(S%H^jZzyiru`l6Xz(yIV4Z0=_T8?Ro? zNvi?bef3npWCCi7(cshd{ur~%nLGXvrUP}U+0zE$@mQ5ocs;>|SZU%GxP*MkJfX;;QqkU_eMb?e@YTAHw`lC z_Ktq(a80{Bj;2)28nBw@l0z&a#Q9K}vwi?&D{%Qe6Ah3~TONnghc5|iWC5IRn8*jp zvUsE)0wmYKy#`!_Ph;w8ruO;t59>?!wLRoa)_RT;J0@1Ei9>*N5X$Di=1O81<&S^E(QwmOM14^CmMRo{35C&x15>8hWx-b9-DXl4A)6M9G$vZ}gqJ?x zqC@?;51OO;bmbgN)yapZTE7x(R>tBLx7Fu3 zKUQS^P7(s?P}sn5QkFc&fvJ?1B;>Ik727Fj8MXO<3WQ!J&%TBgNseZ$#n|HeH5aIh z*X4@g_9!MDEp^IZHxZ{IP+#TL*STFviZ3dz^B9Rk@c3I6hI?oZ0NMi`XOII!#5}z~ z8K;Ax&mS|UmfCw={j_p@%DHTw`Y1{Bj|JzqEp9KRn3-Qq_937HOzYyeZkoXk{IwcUloxn6(dmp;bMgxU8$l zO&TDs8U=xfrHl)^*8wc2=KAVU17nK6cbOry_FlyPXj*hozdqV>(pm;vnBGDW941>2 z*^*eckE&~oZ!_K=fI-7@i1b5v>NeT@{3CTrcfk`!9nds72YQ?AS3Wzt@R*QJ=Kbb7 z)i&MglV`ZeES2(%I|DV`7ho)SAuUS5FBbyh%~3qgMNX!#KwIXOjU6poP%N0(RASw4 zL8@J)zZdu-rzKS}{h6R2Ky}_zlCI$;(LGQ>*6(bsE$EhU23;b5>5k(lX3^2Y{nTj4 z!l!$L9P1n`;Qtdi5-XjL+D;9sboHjXtFJ`DcfIqEB|;O@e^SMCrSe?JQe0_P2S}e8 z)_QAR&xEb9x#)S_iu|ir;B??wVGGUWKft+@C|EtgwF&BBE3%z4Xn@uC+G3?pPsX*_ z+PwhaTiohwIjK7>UfR?9*@&+p@qzk_^fa$hl7nWd==fzttw+C@%>Trs2}_jmQOOv;%O?Tkgl=SUha=(jFJI ziZgn(2zIc}ahG4&`7lQ$$5BjmXK9%amRarktFdkZc8N19Y%=G&Ym>WR%S>Q!2eXRUY3oA2qv z0|ZLE?7nF4u1Hw<=5>6KPX+(6z-ckd*jn2^EmFdQ>cAv_2!ev1axZvkpPBbjR)h91 z1m+By|DFP%kBO8McejAthXV$qAOa#h$D>3__dyZJwe-2veWRJW-D?%Om2jcu-OeZ( zYQuXUBqYuQ0Dvk7u^!fITO>HbmS&!d1kr7&ZVx!x1Lw}C!0fV&ryOcck9VbN)4=+hR(m`Gf<)w#d_0n?R-~|KYeWNEj{l_ZSN8m2Ix^9dA`8|7a>h ze9HDy^`ly0*gh##G{1c^_GAk@Jw3HOLfLZgC~A9Y2Qol^6{Bxj>8fi)C+|Dgq$`mVG&47WE3ZR?*(sD2+igKbZ#A$t#yz zjdDj<8|qlz>uis2W^d$?Kuf0Fnfb4oh`)<&s~QOb8B{^O(R1wZzko~XQ4J9XE|D8` z6`;HBVPIs_S|o#~`OU3drW>h1Ky$AW=l9B*|FS~5VV_+UZmL~^(c!V3uf<}D4Ht3? zuu$)e3*sh^_kJ4Kb>&x~SyK0kuqbDti*Msl*fvTQ!t(ai)hSDp&cZ1|JmpOlicd%x z+*WPoDa+-JLz7LFxP2GAGvEF*UC^7xAzqdLyU{vp1pX4}z*|uN*$3l!dtYE9S5&1( zdhY}ll&3wz3bG7NeVDEy);m4o6`Di1gDPGz1f@r{$epm2es{|5Hs+bQwd4e69y!g1 z)z;-@6K-Do(xQAaPCRKNaTh!Sy}y~T5G;adp1K-v>#`r0YQHX9ONL`a??a#sm{~4! zG0w|rH#ICWSo=}KGFZ=NU^~$Z=ei|52v!5K;2o0v&Ya@NEqwN4iuw9Jkk9+)Vng(n zSQ-NN1N8Ru()GPXOYP0a)`;CMFZ}ns|5rjW68zMZhbtjMV2eW&mG8^nd|5jJ)l)!8 z2iq%Hvz47J5)k+g%#?<>!OC<2x!E55U`rZrFa;tNPshyIiE8$v<}2~d^V+k!F4!-> zfACBR%?q?6!Q=Nbq|-RKm$_x&7j@Mvzk#b)uL_Kdcf&U9}co2hKx{ zNrhx_Ofo;@huX^sGd@OtZt5`gzI(9a|_g=ORFou?o4WexrlWnx5T`n(~JJPNa>&8P0 zg&iE}03w@N?zVTUg50MMq~CG`u)Z4^Y*vZ|+o#NSBBIIlr&9 zxIIiFHw&9W5oihMNA%TvI~EW$vlO}I{fz;yS(3~1q~FABbY?Mpb+L5PH`)yg(pZRq z^T>G0{Xtr}{i7l_n1*_oDeg_LUby!i7d_ks3bC$sz~jdY1eEiJ4l&pI_!Jb_J8)@* zOF8;rJt5fa0WBf~VTQ@ok9>-8bWPNZ+9t1RXmpx%h0)HUUm|sE8jwIt*u7RXJF!B6 zX^y9D);31>y93-OA{aBnqn}zVCUwr^`CQg|wp#s(do>iv*VP^IiC@dA9J+G957bpX z+74K=f{H#AsO(tzocQ7piFM%npTp2RYsIB9cb>&K_JZd`!mFg3E?cO04w7SVCLmK-E=E914=``9U%?c+0C%- zb*(GyciB3)NgM!mucpCp7LrDktLGkn^{Qj;AB1KWrsG@qZc4L=Q;%IFDto$ z{J>8XQtLWXrYxhsOj%;afl?ytk+fngFp>NZjdPRFJ(LY>n+j|hhKDU-bBY-g&QAJ z^vuXka7t^At8P-!a1176i-?)9dXkoVu6y_Xhs^r} zLp;q?(8*GHpu9@U>-HG>NaYpUY(jK|hAfRMK;Y*=n%zJ78nG(RMdU&mMA?_cQ|IwE zoU5y^5ksA+!!{+eJI>WHN3DmLIH-7T)iA>~hN#HkXtjw$%f8+~EjT=JH+xF(m44Lk z&eheN7O7M){R!MtgeF-0OI^wdu1wsk(eUAmL@Y_v6CHdv=Z+a>XWIX+(2@r}O=rt1 z*}t<97`X=p^hK-OUDKZf{EiKBjr}d(V~K22lHcgVNqzBh097%XhsC7UXjb!a;j~Kf zQw`mleYV|rycel7`g?h-hWJ89b!QOBV4GR~$`#QfaENEIfbp4f@N(X%0|3 zuL(CS^)+uK?HN~#bhk)`aV?J2LzfJW+TYoZ!Q?I}$&AFH9Dz3DC)j6M+!FM}0y8Cv zUBFzoF38rZE7fr7jYj5e@T-^#n#r<=B44#-LHE9V0c9mzmu#)_>KBA~wXEExC}gR~ zM29`OMihQBhSnl}W^W;`uHDIjRr!JTM~dYQ1aG_y2@ry!pOKbYPKtmv2kiZCF`XIN zIQ|3Mv5lJK_8o~ogKwWjceI9!y9k>!o;}g~=m>`{d98SwMp4^CJ-?WGs%sXsS zc!|IEDnWuWd3gslE8yTNp%J&N*_5@DICjt4tndJ2jMBxHZV?7^+#Fj%UoTu)%$Mg0pX zcs!MS<8}tb4Maw}4Ut?BI<2}0~#he^Fj}WGrSAI`75!&?P>ZhNmhaOBOcmQ7I(p~WMC^^8$iq5#7EoIGq!3r4_)&dc>w0*ZvE?;KEq>$tLR2hu4f!_r7i2s35R`vkms{ zq8}0zm?qU6U`C(>59ks*FJ6C5A7>*T-9HVO*1vEcLuLr#bp~&yEhIEIZ{uKgxeeW| z)-TFHIAMSmJvmKtd5_hg@yOkp(LGU?#|JAoBGrX#m3AF?HxpA6LkCQ-Fy+2c@7m%7 z>wH6HpFHgEHdDq>dz`y<@cnxx8X7e6F_%{0pMKS7*dckU@eg&uf&vY&e8wFR5Zpk| zBB{f#nOtVsKZppSrMaK<7+GgzE^M`iqjG&rQF88zF2VnRhINX7fxiRiAsd|I6oQr^ zyPi5?*n!N0do@{9Y0%kR9Fw>X(}{*B1*82~X;9uDIvjY6Nhzcyu$wrj)F825hrCt_k8ld$~D*{p;E8Wv<9 zx{Ko3S_tpaa7Q&%m3_ICOS!3$@X|n^6e&W40C==aL%R478?!^|tHPyOH7nrQQ=NW> z!liRZ0-#PZqYg@+W3f8Q$JtP~=3Yli^Z%fhy-jeS<#=2YuDH&|DoaASrhJ45A{j&8 z0y0~a5PbQh>Iu%E-(~($)DC|{I_m_)IW;_Bt1YqqM!DW4)D1yx?V?HoVJ2~+g*C(_ zs)uO1QJfp(p!K10c(M7`#+v7c!($)|vRNDH+>n!ZWXEf_BL{zx{Hu}qcP#xoX{`$H zH~b+`H^TnH>J&6fg*}XK3^$_!%e_D>rFs2quaC&uVl6o^wbTe{EW;XJmANPg5 z9phR-G1Y~YX3F$>bD-?suARlLQ-E0GjZLW0u03i!capz8B(~nCgsS(=X&6YRDHAs2 z7oo8WbXjrJQpOggvZs zdeRevZ{bFj2(i%X1K7%QfghR3PGPeq!8B-rk8^DvZZX)1rpwJ<)RKKR5*89VC;d0U z!P9{q92IQbqpoN})$=5i{O;qn5ex~jScxX2t^kO}XsD-yIzD1~F$jOLxgmfav1i@E zspHxRPGZXZED)D)rTj7>Mjl)zPK92Y@K{_vuMRhxq!pGCJeO@?6yCXy+3dG10OZl$ ziDQ=&ylf44K;!H%0I*F`pVTH+kWd7UAJxNq;|Wt&VaTEgNiM9OKhPnQtmx?_Xe$`P zN9OY=s6O@ovA%`@Cjx@&)E8x8TVvlT!habyiIk_7iKsqST#`QrW9I9ZNyH`pX!S{O zZScw!>mU{&Rn+ioyKC%34Q84q8snEKjC?4tsJUsB}(S z&=y+jXRMFsxD*e>@pu^GzzaK zOs|&$mGFS&d6KBT5f&nLKU*~%FiappCjx@v0g>kGZM~HimgOg@PW!H{iFg$^{_TuA z{(KN|zp@QiwzV0X&pwE~gKTobdji{pf?npSBt11*THf7@5NFTuAZBS9!HLh)Rj+8U zN2olyRXWNM^3yLKaZYZ>-$mQEt62J|JRVNjkQ^foP69NUm!V|J*}+UaN>&Y&nY_q& zQw%DF-QQ_6$x!K+q~GSI(@N{+mLQ&M+J&Fyfl2_BT|^h=ksis=X`cDCn+UI4%A%kc ztUIOk)Mt)sY%sOWi0~tT;d1^h0qzAjqaN~~DBTl>EQ6_t__(#gq&(Qr$(VLLfOaz* z@4Ew-|`aULf9t(abV&Qp*h55Fx=V6=d4pq5%j`7t5SBX zKx-ZAf|Nsi_oRcsx&i2LyFYZ_b((ax?}Z&`l=c2o*znX1GobV$(;9gDhSqY(Ew$Dp z5D9?aVVWSKT%Kx(csJEauLK`hFQsKvURA0aiv1vDm*N~ma;Y*=*rrqamHju0X~-0s z=R?{yL6SLjuNT2TWB3h(9=&e;5-6X@7&}kp=~iLBKwrYdP%T&nCiEVHKY6+k?3r9* zGBkWP!4hY$yb3H*HM=W&PLvKr47uqFTFmtUmd#i!T9|wKV-EgBJAu z*{lIS@-nUUCmrsV7{v%lysC-=5ua7o5@z~qj61SoI~FH098yb3CSbLmOr$Zmcvu}r zFEb?u@mL{{GpO?URsiHUuAfXYN!d_}2yZ)=LltZVN==nC(rDz~927*=E)0V!iZhU*cV( zJK70r9J0?iwGWXuK6Ibs3Gz$BY}5~8p_+27BhG%SAI^|QnrSnEK9?JY;v zCgP!wPol4gdCKSFG;sSZO_AFZn@lubA!U6g#EH@MwAUWJ;G9qFmBpYkwS?5{w-#HU+oE<@T}F}vB^_iXnn=C2YFu3=oM zhmzHM(bzAbz$>tMij)IjRWi|N8q6Zhh(e-JV`COKN`=g%hQ8ng7sJ1A2(kMrfr#^` zvoVl=iUcOa$&rQn^HkR-!E6gH)XnNnm?sf2r^;0A@VBcVqRr!q- zf~x^4<5UnN;GypbncDp%F=_J@TxfDwgB*{)hXWtk-Sv3+tN+{cmk5Yg2F2U^UV0W8}Xd{kUC0x zkb%FWV__eRGoiJA5Z_rA%?2bx*}?T+?8aCA#6@$NM*LqAsT^7kmpu=o*j2ssXXxS+fJd2%M&%b{*95yj{4AEthmcC14ny9s1-G0MG!zM>%%j ziJt^o(tyl;j;Iy;MMS=VjZIGcHcg@4OkglofVFp&82Ev9N>KD(-!~Ja#En%4l)L>x z`rnoZSS3Oz`!exfqX{yP_w0!9^{#G4Ud5zutXF`-c_C8&S!<HvdjGY+$#z$FImC<@0Bc$x%_scm*QP$E&q5B-C-t3hG;%8w*;M-8AjpS%BtU zr``aX5!{v?rpj#?ViUi=B1hWJI}<2JkMBTLJ5^0piu(bWw4`G)h_BGiTSswMN=p43 zT4lb5tXh1x0VJ}1ZrA5zPmwabzn~Tj);_=Uu%I&kekmpn+pWUlP&epyHk^nSmLw=j zd!#ExR0x%vw?P(6HS&~Y{7#_H<3fY zZi{XA>59YQKd% z(K=t2eS^%r|K;cSOXa9G&;XsW{Tj(qcQaZavSJJqmCCS zEOBav+s^mOG@Y;p&Zf#2DRI}+;55UE7$A3KJP3{c@i8i0c?PJ|~at^?`s z26BR&BZF#aT^0jI4J?T+SWo{8vV{*VpW?osWI^90=##XVgJoAk;J!9L%VFi%Ya#Tu z!(bEt@%jKbsRJSxV8n~Any#=6k|SsKw^+)DV(jd*Qz5{zAEhA1OR{NSYs4!4h0&?~ zmylVJ?XYM88S?;&h4x?&-D&(*YAIb#;o|0?erKg#*ngE6`(YoBP6T%?f_N(ikO%-7 zJZ2rIw*GlsCoWU^+RlM*fTrmI|w*b(G z4_oe@=_6QIaOX$E8<+sLwRS?|c}w;^W#n3C&4gl9ALNx}3s6VDkc6DRRzpFzIKz_4 zWCZH~NLIvj$we8?g7fr%ZIBA9X*^yT#h1jd*RWFq_lV8zYDnR!Sg(>Q3l&3o096KP5X_EBAm)$# zoSyC|BMC}jsTG%y(b=|}z#d5?96CDh)93RwRqNm+9jr+35ybAjvP&#s2?X4_*NOwi zw(9K=Hn> z$DO(fIN@2?U9vG@P};i0s`3yx@|X~N{@6B0??^=4E$N12;!wD9cmofmUpI*x%9crQ zKo`r>k3Lk(;n$!W^iYcbZFEPlG1e${_$1+cBWhn$hHq;tK{=(7Fb6CMQO(Ev$z>FP z41@)rKC*deae^^sDp*81J8!ptpuS&x@N5@5i$GpnMyZV3!6aoK2KB0Az;F?h&OPL7 zC?6^cO~IYTDwg1ik|mElU5$cd?>K0|#o};t-xex&4tF^yKYtv* z)A8}&BY=bbc7gp@GgQLnX{s^B(N?vj%26p-jbeC?df7M^k{r_rht88A^Y8AtMn3&W zzVQOCMX4roZ{bt&!24tI1lnKmDU+bLF;@-p&g(tD&cHX0v&83B4##Nu?cRv_yR*(=XTTlV~}d^%SX$ zLQ?Nj^t;e#DYg&LU$xR2CSjEQjW!xBL!G4x=5Zhsv|$5qpZA8?1W>Wq3|4v{@ikco zzYt>gsX_|Or}w&3-u7AScD1jM=Dc(3MCRizfcEKQ5#s}-q5x%)=uNX6gWue+*{bBh z4#T?3TIECjg%RL4v_2-ak<6dX6TxS{kd~CW{-}AwNSplud0XI@5}YR?+Z=;1xDC-Q?p~G8yDwC`iSJ40qOQ(0 z&fid!2@KVzkiuMK`EM~4-Ua}j&z)F%m&(m(u<5%c>REU1{VYqwA$1Qxor!sGA&S^N zwkmYhUl+Ba`d9r;gaqM0q7uie$l(GW%n@{ESsZh}frzG0gL|CMQQ;Dp&t`-+0iGV_ zJB`+L$n@d?hcHhafmk$z3Ud3`+uv?}ofXWNPC6eP%4y6c!=7Qwic&Y)b6FRG@=kx6 zFUgnlR#k$siN?1$EjNPSB)(jy!Fsn4O`xtg0&axa>gu zKjD$QD3h>=t(MK2@V5yenr&S^RH53fXp6c~OmFiT8eCnLkP&5Oqih07M1H6Yu@Pw}K!=$v(DD~SE1Rbkw`V@tt6$hr24 zV4)pMicRzUQC^^vG9Scc%QhunKfmb=of-k2zq2vre3r2%l^9lvDf}q+-Lwxz=sKVI zXH%KC!FGluRMq3_EONCe1CN<$=7?IWLd1Sd_LQG^Q`fi&sDfkbSN6D`!%jtM{Q5%( z@UzYdU9K9XV+Jc$z_O!8pM+fWP=I$h zyKa|debVp4%lnm5H1hVdEi)zHX}4JSF2Kc^aal=p259>Q~Q*^I--e1cx5 z@Pw;id>?91xnBU0*iUX0R0X;7aEK}+_GTFE#M8Qo>AS<@x7Nao;_0(hO*J6)DUKUP z$Hg7@8#-|eYcmbZ4)=mA4l6(*2Y2HTPmkG+G;1t4RCdP_ivcpk!9cL^e)LgGjo)iS z&QB&lPhlj{$?W+DkCwxiyP`fUref8#yGGu=-JBmy7w4O!G-?NCvwsKpR^#X|k zR@_dO$PO7)4FZOgYY*cW^Ab`3rt1a5y!l^o*Az;Iww@Ph=AA?}bke2LF!ncrcfdkL z5(~iu1ewwu&Zxm9&OQysI%?H*X$?i~m@$A)j57hw6g1F{_3Akff2*_C3fFG9ohUhN zw<<7w{Wv&gDs2hA@_o5X1-ixVJ?V85#+Y17#>bB`LsKn@Qc{;t@XOp~lr0{+fl+^L z+F_z!I-xP&#z7d;yxTn6C<(yYb}K7GWv@%`sRX<&@xCHiZIFCTrt)OjH@4J!@|${= zhF$y%RhfT@d5#(HK2Bb?$G^NEIOKm(1Qj&@xTMZx2L;I#_X;A@(4UsSD+7UBN&GYh z7StVH4?I~1^ofyg<#(#Gih@wBQGQ?9NE;@vaDJdI+OE-ES*#~#tCT&QsD#OsmSaUy z?mKvcAa?~r*Wh!Tg7Z2i_=&N?k}XI)p>lS&_lVq#1`F{#>`$g;Bd>_l9tQ6emo*^2 z><$?%vi~qc&rnHJLVw^AVrE!I?3;gXw#nWI#6!QarJr&bP{x$p{t?dHu#&ihd*<3!E|uc>g^_2Rmug5B&GcgcOAa(C z<9pNgBC!T)4AzoC&6x0@Aq9nB9bEZysLPQQ9lbv)Pte!1kCtmjGW-U1 z8ZHdD=G#k}cFjLSu!Y4dBJdCFNUU{)8Q!=?@2MY#+d= z=Sk9-{TDmN^jb0&M{Lf|-qj$~w{7-;=e94Nry+xXkDO@raHcgXZe|xMjHNt^-dd{% zMs-aR3V+Ky*bD8yc27v@Y@B=piQFS-|)A5^C{VIETdMn*{x^`r}=ro7v(_nhb?V>WwHw+oxg z9_%aVLLwu(tA^cL}lFb^!C?_ZFdYVdWZd zuRbGbCpEIQlecQ;#tQx}!V9J6TrZ}7RZ82k}%R`R%;NpH!}7UsdL0*HaEfmL`+C>yI%!1nbI zWp{kytcfT{1yCnW@>EG+`&uvKJp$z;Yys274E|qYztK5mEV!Z9$9NqzhR2}&?aXa|-eznAALnvDYgg`-Venw-QZ0aHSH7bg{L|rB1iQ2_7 zNkf_s5)^qzL=I^6lWxXJk()L)&}9vj%UKMYkmdrX-ex}4+l23G1vWnqXnmLc3-^9~lbC+O-^xmsRp(*pVw$3di- zM=xFs&Jh{;J*$kd#GQ`S z{)^si)0Y}h9pI}1lJw;`LSl!H&aXOIx@4-s@@fq*J>#Q|b?e1#s{MGY{fhJj!N&U{ zH6K7#Z<#g&&+*&P(k@I;If0G zl^7>0-8a;f#gzhcM&3BNAm3_7`;%m=!MlkrS%)(jx{YrSPdzb9D#9vHk-AZkAR3X^ z)Z5e5-XnJu41yX#i+dUp6^jYYK{CBxdP*e~uJ7&UwMqoh)G~~NbbYO^n+P<%mF!9w zmJgoE9Ih&>EZZm8Kgt4g&|QDmk2&`5#bR|>4b=b(#$1ngZ;n9Z)eqy zxY5IZc?lTDBkZ;;K`TFf|EiK00D&OuL4?JEaOM*Mtbaqg2!Q>b4g0MDYpox4Qt%1i z7U7D`QhW{`(#5np3>dq4SEPB)dZV@)EVd*(eK2~a8%Bzqvpz4ZWIvA`?hGjvCAF~| z+tPhjO~t`-G%qM!xg}tsE!NaN9m;ywLPu`@!Cr)xzBCSZ{_me0+m_%QRdO3gMC4N# zB=@h-Feq6q6&#C`pu(M$z!yX~&97&&a@rI&3osCpm=wQu$Wno<00nBro(t2a{Cjy? zY8wm66x{yDjXq5?n9>GMI@N%3bWk2(K6!FBPH`lEh^RldyU9QOj@J}Z>)%+1W7B5A z7fbA#kZ0_{cg^!_+EUMI=@wmm9&+)T)j*zIvT^Xs^$V_TFu))p(Q<{dS;qo)za)om zKBeStJKgUk<$;V95u_BLfW;;FUecfpGi1QFWTAQzMa1rwRm%koBf++zZHPQQlmxs- zLmQ2e%WN;B>fvlnEdXsOfkv@KQ%j3hPHiSoANAcoR6SQau5W zRwg7D;6}xhoxE#3ST2iuw;)A~T8B;n3O*=Z+>3c z13KnPJ&>y83$t`)_g#_5{A>^m99-awbh9GST3|hpT6(9aOb*;q`p+ViSMS-AMTC8V zim$TpN&et;v!d(6NWT%nhHe)M5oL^9`@#vqRDpcKO4Vn{?7voVryj`80fN>*)pFC-kcX`nz?(@%{x&_jr-D)e9I9*& zJUs37)XO1=IUkvx9z#UNEWegmXE6^rz=dHyrPm)8HO4!(MhjxBm#=kZN8uPY-2Ck} z(y0`X-NR(T$ip>yrX4gyo9u}=N~09J4N|~k${@;LhLF;AQ3;hGv=-RtmuHN47{G{) zyb~M<7=S=k$C0Ufpfh8W0czVu?CP7+b@9D8BE|Y`xVw4IgFoxXCR=x@q?T@?}d!b@ZVoIiCmVKyD|5 z4X03=Yk87FgI35uiP&gr zvw{~2X3w_}jfODH6?3~3Qq@)>8A3qZF;_R-mo+ezrbgBC3?1$G*6Z$zOzzmaJe7{9 zpb_dL`2UiRZUL~qv~&l=In;kOBFisPI%V9#8FCtoT?Wg$+6wLXA;Cl)_e(8Q2E0Iye-F`nD#&2mS>yc#4T^r3 z5LNRtCnum0JAy=s?||y!)~N(2#I&^Z>twzjPOyv9lLi&I! zv$Y#js(gr68U{(1`<+$aZ^<%vebAp)n{G&I7))=Y{(y-VIwQMY3C)hSh(zl@%u9av z9N7hXRx_$;38S*6kN+YZTFx$uVyQYdTUqh;k;wq+HMYkOy)9~Iu%@QHhIKrq;Kntj zfOl#APWlF4umGTE7{#Vy;eEvp1+?Yxi1!@MwyZlls7yVeeIGEF-%;$$al66aevf>} z2n0Ph+cBf=Cj5w2_q_VYB*M}7W16|R36E?iH?cVk&i4`VCFCPqy-x@lrpzO7W%6NS zvjhN;Zi7RK_J=S?M58^eNENszR$e;8N9eK)?o?mW+eGIK;XRL|^a4NjSnUvBtRyuR zb>fz7a|P5z%td>p_TR`sZ+uX;)m|Mo)0K_qsJmT1Tt^+$1As6Bk}J=d$a0z97G!^h z&XAv}H{Tg8>=WNmoYxrd+k(sg6*--F_`;$41gc|N2;`07s=j~_6r6%Qy&0EIF8YXo zy5;F{?8$rtirpV_3|3x~z*!3cd6!d zuSk4o#m1llURS#nM>}>b-lNrpA6E_WA%LG|yq!g@O^dCIpn`nh3x?OC)Uo)YMv4NSn)4V79xMZF_zE%^X=rNyVSQ_4V&vlDPOu>Fy9icpgX2Sp_t z$^N+mK_QU@+lrKMJ2SO{b;wR;N}N&coW=&&9=Xv=FsZc>F)}2}tx5v9`ah)+Ii=c5 zAwmI7Ls4SPc)AR^%z-Pm!*JHbool_(JGGakC#HIAL{6f?nj*BT0C(Y?E`)JF#P_cX z<%?2JUzLy&_w#c^HrCZJW46iqvGQjGbVn0L=(YZO0;?CmL3F+2qlhHT^R|a>g9b1# zW-bwn4kIwD{aO`EH1M=_pxrE0bHd&Xd z>3Qri2hl;foxC<)rr|}?T7}upAw}ci;J1uh*^j9XCp+TBI5wd@pFoa|G!lMe;`h?JrMn!Pzl!!tzv7cA))}fV4lZ8M@+NGN?F`^ zM7tyEOF{5Gqu>H`pId}2t^}E${&;{^M(kZF zhQP~XDs2dxqU?VYRDtlJ8Ep(z0QCFlzgum_=6(tQl&e8e5wL~TG?u9N7&>@!4SZ!) z9LSdQmiIgam$};N*A&cPwoZ295HPKI8u@9`ms;4YDu#9foV#VxYbL(_tp*Omd{P?c z*5ysHMvA*q>w>3uc!K*fS-D(DJOB8E!6Spzg3>7(WnT;Bjn%`B=pB&BUO`pn-tHcH zCI0ou5I{dy5AODaBX1xW;A#kjR2qf`qI{JI*=pWBKVYgVk5Z&u{<7W4=&)29#4V*0r2^&p=!Gk8EpbH(4W?4i=O207zF?W%mKYGq|kAOo^- znbDzY`{k?3JNYJuH+R~S!BJyPY}JhW6ub~tDu zsMi_G^iQQQy3^Z*&9hk@Kzc0jyES%TOJH1$QpOG&38LlB?vi$KTVzh=LHMMl5SyBE zxO@`YV*r0td--A6gYnHQ2-K=24<;_s>aZy<_Z9vV_rf^4Y7-)YZUs(sbnKYmyF_z~ zZq8)>^E}OXZw5S0QkU@giZZA~* z(nOq$OieKW|Jvx71Q{rfq0pnyOOb_HMoeK&$vGh$QG%K8$9Fc9M1&q`I1BWt>#u5I zF7KDQd4gP!(nnC*PeF?A))Z|M;-@jz^fTg<2NXeX?B1C$Pt7sT7@}g~G6&rV_Wb&H z!=^S=Gn(HFXLn2VifP40KTVRjD092g>{{GxQu+f|LP40m58m&wj^vG_lujkSVPu3` zw<1#o!i>4^^>3@WtS<+jxH70|yFhN*G9hzbHYIVQla)Ns|DE;>5ZwX8GB zk)CkCIZ}f1Y+KmnxToN=EROFCYW$sEEDtM62CnR%4+2=`H!FjZOMR8PuWtDw|L_M* ztxH^i`R3dmVSAV(Bj1MFJ?oE?(qwN`1cr1=u+5*h(hf7L(M@?RWPh}S*-r*PbM5PQ zxH(jiNB}YamrX$=tAa7VFh&g2a0OLTO_#Mu^%tlZ_^Zb`s^+a_UoeZ{T#4{KN&O+2 zttmTIvdiLS;}H9XbWpH;s5>rXFJlo3l-EoE0qzUU5;TBNeh-$jVal9`O+*qG2HbF4 z7U;x
)l@Y{BqR_V;Eoep%udrisO+YIdIHkm`tIFgNEsI9@p06nOAFY;eF+oM1A z1%VohHH0ok2lPfCZ)&hwPWO^JH&(ZogQH4^xP?(Hm6Sl}WpgkhGu$k2!>w}{8y2w} zPmBjv+ZOF`QmG4&w(^|rz(xugF13jZ^wA1jqV^`~46&W~|14vc2&*Y>0MK+WQ6YuS z4rdn1y+*p}^tttpl>#$WfcmF+hDJe?=#{yehLbbx70Ixx{jFyZ@*879+nMx4i`YY+ zZZC;`Hi*O}69^PA!_>b&$r?65$5H8;a5vr5`R4)ND~dUL#cSSah#{ec0%N2(H3naZ zNN)g?LYprilYXq2j37hzwLa?T!6)czq40ro`)sidLzoN8`Gpu*v@53iAyHLr97+Z0 zPuunUszrhnSS2r)sKo1VZ+BHX7esPU%gT#yV!TM8SVT$)xbjUE$OPO+NzLSC$m<3B z;ghT$O4`Fw9N~w%NuSP1&c%g7?$_h40Hw3kicSo@=R(db=JyYuJNo2(e);n1Xee@y z3nbpssst<;9Dy;BZ#t7*)>~zX!$L4nSXYuHjv*zk;xQxBEz1hr=-*)PV;Msbzo|Od za2)M4qIb7bNOjg@LQ-)Za$?C^r)(9o+F+7DVvh+ue2#ZcR7_PG=z0 zD4bCcL>6t%LSPv4tKHbmI%F7aLR!&$G9Rfo-dpRjn2`SY__hL71W#gl)nUmfxKqMK z4`N#)a0XYh;$+z&JLP_T*}M{&cdQ$9xa2*PnaZLLF=$+$EWP_TS{+i$*K)>B<@qM| zGW-hyrHI2JPstJ8iG>V<2s0T%9FPXCJG9!)ly{8Whisv~i{if9s1_T!T&h z0fSG-NG{?1Uoq|_fy@2geomd4u^27rWi7$35{RU2zZPvi8Roiw^}Fn=iG8@29$owZ z=zQ3#mKT>^>jiKFEo%GMDWtNicE-Sy+2+)(9e=^} zJY37L5K#_^5VX`RDk6j?i6Z!PF1j+R>Qo9220mF>g2T^HZ6=L%AnUnUVb z$BV++PI*=ZAY}K|3M)+~LVY~_+Vov_e=9(DJqCk76odZ!=eqvN|w05Refk4A~W3x@(s|d(zzlRrQ zp3c8ScuWILL$ln=S~y=3|M|XQ^Qa@|qFZOXI-Prk^;U(DRC7Ikn4VESRcBmP8||7T zs=!v}5a*-tH;B^P2+Vmkr{)T$fxPwI!4Kr!y zOSaaSU9CdHluzz&uzpD9^67d;C*w##u+-}cZjfmmw9@XsIjsEXkFhf-yAvkMjACVB z7$HUQxltC9-NFGY8F*RjUPY7a%wJ3_*Jje{!0am9H0A$`3aX z6OK;iWHC+;MgFM|XQ_MJvRiiV{MCs_a{P4uz$TFl1TGmhlzCEF`Jrf;LgjR z^OF~j35Nz@6@CYyb!G6|Aw13;ulgEXV3R=*_y-x`_KCR&%?73+&w?QlL!IcBxQ*&z z4H&3q7|MI0%2}XbVL>?((&(=!M_jrmD6;9_$p?f$Ic~D__}{x}XtD`3%8*}%3+Mx_ zFKskQ+i1KP7@$X~U2c8Z^`hvX<+T{bq4A10eX1E~w}p+Lfag0NrHJP~mxHp?8U!^@ zge*IOkG@kYvEVt|9wl~&X033rfgxkTK&%$MY_?#t8M@^Ot-3C^}2Q}DUz^hsbt4xM7uQ4X6Odl8YFEP@nd*QEpt4}qO@F|jCS5opIS->erv_M=)DW7WK`A(%hWlfA>Cw4e!Lr) z`>LlY%oT#L%M(M2f~$2Z{Z2_AnALT9Fkx!cICYZhOmitj$tMMmZr=ho2Oc=0kyci6 z*~3+me8u4HBggzNA)DgmHx0#m5S09$fpeY2{laP&MHo8cx^otgHQ6U> zDOm7K#jYWE?nkuuYDL_FAu$@bhZgS0#`6LLjM0gqTZw;qa1~wNXQs_CxHRfg+&PJq z89tEutpP>@I|%LVkn(@p58^!vmvnGyY-1yd++mg-DAtSD(3|n7QQ+odKLjcvVWSJ0 zrUEO)J2w-CcJ|8a&7`0MJHUq?paVNE1uuxr+oVX=pr1LT%GP%27KYauJ*Wvc02tlD zv>T)660%kufcQ%{9r#InZKUF7@tf%1hzCfgVI{)vhWpX|m2PI-xrBXa0$z0%M8QT|D`1YhlupMWydZjI%YNFmHO~)=deCU zq7&g?CAaaWlC?E+kraxh*$!79E65eMV1R5lG^|h#lO&Bav-GEkg%|9AdtEgoj&Z;? zk>bj5w@D3txqkIGdvom;@S~(Sb8~_8B!rpHIs6)9-+ek9F^~SfgzQjG%fFiD>Q#%Y zm9N9^8U6&c2jT?xK!`}It^0H*WaKxJdLqVM$cT`zi>^g~p9;7pEhd)=m1MOMA!v7# zK#)zo!APuZ#818tH4nlg6{c`ia>VG>l#~RbONqQm{NtnB52I8`C9G4cfIpkD>0vu9 z6ZnO3*Lpbs7c8oV|Eznt^&=~&r1YFm1qpZ}FA%mF1iLq9!N|+%hRbBw7c*jZ=9Ch$ zQqlUgoknwT6wU~Z;df2Eo^bik|2)PetR87X6;K}O&LW{ei39V(+BCx?gMP9|_Pz>> ztX2Q%gfm>uJ++f5_2OWtEj(cH7O5i5q3l4UzO`$F;W)gvO|Nyg-17xfs4kvHEO?iz zsbVbkv?c9jfB5oN5mYbFIHf1a=*AQNg2(Gi(syTl-q190&r;oI7I=Fb>H~6$P(lg& zk$EHS!hv|t#7^Na4AR}nEW6-dk~v4|cvp!qm0L!IUOqQ)Fy6>M-suBxk0^40&fvpm!p~$JITwLu zH}MFw&TAHmK#forcO1&MoS9$n*f-A`V6U0-l@||lzo+F;++1g(oC2(Dxat22NA~rM zW$EvUT0d}o96ZOehu(h|Lm}yFt;8vm`qV+@URgK@Tzq{*4M0|+Hc?d0%^iIUhqkfS zHCDTNaZk^Flld<#gH|s(TyzB1K2XP#r=6YGP&a=>`>I&nGna21<@82!uxw#l={H_C zC!?f`&i5}r_7b;a$J0oVvKh_ML*!`QIJ={;6sA}|s_79ZjF!p3@Z$N$P&q?erzK9k z6C#UP^J;_tZ8{fhS!nJ50Q4shp)?pllN_OL*;De2tPp%c{-&_FZ}tVKd-?P+#9p#y z1JQ0WV$R<;TGU}pSRPAR`q?ODv=o8)KOrQ@IF`8g7L-e@C}Yo7|8~(i9d|;lVt?9Y7t^yA}b&9#ez7G z+)&FTMh28oqnM(pCtbukUSDz{N1X%wUvBV2yQtO5s~vl_+pZid7!O8ZoF$`sBg!yM zEW{>yjxHK;h#5O}2ft>f5b-_UoY<&2M&#eWvt0(5)5Dh((g(06cZ|GhrokVOmsRfe zST8vd=0oic9CFtDZapmuFSGy@1a#;k^fz;&kygG%3MI#q9T4vZ`0Ray6%lH6IyCg; z+W@+RuT<70k9H(W}xy@b@_2JAy=rGwaF+-_|QybtURf!B`Eno~?6lF3Fg9jy{(*}2LjCSGnH;z;LI`I?EAy;j_5J9#02+~hH z_cs>R8e4&-npTFC#jd#^QpG!g(H?Vu321C(NWbnbv|0wp8vrMb0D&w@)mG5KPPkC; zQWq;ewRJiWmG6XDy!CMAb{`fYvkdtfE=gXS_m9Pcu>w70@{(1kNC`m>00vQ3`4R9m3nvYG=jlRo%a7zb;ah5_Exp4DphrVAiu}nL1b^ zuM^{~I=2&LBkq}Lf(!C-++FRaMAu1n&I3VmHspD>G7jSau-Ej&{=n;_mZq3$r@#Z) z04wWKi3|_{mA|rw-Mdt=<9H@mX7)qCI68+>RM_!uC4eH__cd(}9RfJDcS4>NYjfDZ z4dUE9l(3k@=>m{ontcLBONik_BeIK!qt>8VY=@(pnQxXPS7bxl>=fd>&(Jkj^@TkE zVVT}Dqdps$j7$udVkJgV_Av=f<*AiH$D8t(|L6+h>lOP%tiwx>b=6}58W_zk(cEa} zXOc-dtQ;Gf*RlX3;94p1W>LarSX})sr?ON4ixU=wU_w@=E3YvT`BnCKBSFM#Xbp(? z{mif=Nm5m&xPaRpPm2L$TplcNAN>O!x}aFCc+kC%Rq>Pyu(e4!vdW&ieONDLf-eSO z1!Y>DJ%A`&NQwjwP9YHo=&*~`V}yWeqO^z0_CwiwAFq_ghoBr~HzZC-UC6lw?`p)( z^~x|N5qxQToxg9|TG&BUPBEW+U73Sq1!f-&007;}ymT&&G=zSuMARXjqf%(UbW*sa zR1Eqjs#_T+og}75CPj7zIe49!O!P`=fSZrheNUW-pJDt!V^#kX^b_z~zystV{Aky= z#xMBgT~G?1Q%OWtyy*8yblL!k5?eLO{D+X`!+Fxs9V~d1ST2AXdy#)Gk@-IeF)Rr^ ze7I-;&<4=o-wIt^z$>U1EIG2xfX(Em9Nir)z&LRa(|S)C(@-KIU+7eq?#iyIQ*7fc zKNIjZ2C=$1Q% z($ugzvz#XL$zSWR=obZNOyYsHdMHsK?{ZmZ0Ci`2tGg+bKtKQk3xEIs00000ji3Mk z00009kY>drSh;Ys24n?A4B;Qd#t8_{N$m~jcp(j#exKWI!qvZD(au8`HM~4>`F**& zw$LVbw&zE`d}b`_aTUcC%`piaoYy4~7iYo8uko!TGGRIUr5p;K42IZmKwX99l)vB% zGAi9n(l#EDDhlY;+<-?*$!=DNq~KX%*SQ4(%(PnAx%K)`5F>YEJPHOKA6QYQ<$Wf1 zf!c=weGb|5bjVvQ!^J2W=vd+ z*&9NPySWxTmivJ6bjsfq+f*<%jm;#=&S6oVu67M40;L^^k+k8yK}x6MB+9F53;7Mqs7f7gs#5Pn8uzT>M2>g1@L}~@ zwdz%xC%4hBsg>H;=``3eK8|k-*c=?!# ziuVGdGK1}HvM#d{J1;yCX@R6!bA0NFl|VQ%a{CD;J%Jql#3n-7>loamjc!UyGYMorU-Qp_A+}b5a}}Ja~%mE=pWgGQP0`Y z9ELS0->^ttYLl!Hv{KC0Yt5$-YK=4wtL3g|OZ&K(8!gN4;xHqvL-p@a^4r$!KW4oK zU`&r!hH{Q8RRlLO;EtPQujl^ucl5htdd0~4r1sko(4aMs7L(4B!~O)9y?vYkj+zK6 zU8}BD1^7O1u6w2;C9#kqlcJ;#7#C*9Sir1B4q-RRMHm@{a5)CA)k(>0kcYi`vo1dv zUfmUh`EQ_i^P7^B{UzKs8KQr-rp**ZfP0ps2NT?4erFKiI4gL|NJwav3RU%MBv&xsgxtm}JK$=j<@vIrzq_9mH%feQQxj zgH}E}0y~y~EA~C4wlv+chSrsc3(3orW6pW*ot*=ojRRXX-tSsRH>-A02THB&TwPxU z^ONw)S4R_L44y2Phi4Ldyq+}7Gxy3UNvdY!T%^0wVu^-Ixxhc(-ZG_iZb)I z+`MbBPW;x6D+wi1A^%}$1h<-+!`$I5WR4q)gHnR!K1X2>)u%-rhLIrDu#g?ujiXCn zT+jB#puY=_?@FOy0F^t!9^w{M`p{H+urpJds_p56%ULC`yiVQrP4K9nLX7AOC&y#VfBy5HGAnx$y`8GNEnEv@YYdQMw7|zbrqjy3KU#UW zIJ(`>8fftWfQ)v&sY89kRsnBqS`p%N)z<}0tuXE|=;K2E#5n4SS6nL$Ksx_PvF>^d zlAW`>i&kb~aJO#34}6;QT%t2iw$6L-fNjcRbV1A20NBDQ*@QwXTm?tSDqL&UFy+W; zjRWI{mUMR$K&Q7(weW=+S&y*Lipnu%bsEJ3M3Re~pvA7YETfAC10pZVrKP(1EPYOUQyK+EK{y z#3$w%W1w-8rOx-=fT&KBnfBl*=|N7eL?wt(6JEZ6iTGoStyt_D!kdGHmG0wC>8?0L z!?+g@JW1Edgm#<5>7?!iht?zu2i@egn|xaqJLGJ|NyMA(0)lC+8x)^qZNU1AB`$pp-u4>4D}s7y_zla4-2=HNdo*Zy z_~w=6Lccb*TG+K0>_Aw!j~TcS{9UK#if@ei{{cZX){Tl!vbNxTMUt03hVRMRuTD6g zS^0y%(7rF?itwg3b5wBPgE>TbaPpW|AgV8E0ZZcI)}UB(@|@A+P(K@#*%Mz!{6Sopp2C z%L;pNj(aPYufktu^)r;_NA`m<2QB8DYJ)BB*Pwhj9l02CX7>}6jiQd=9mDW=p9B9` z>7%s9c7R5oNTv9TTKK(^MJSf|AVc82k@S8Q)zM$&PjJxToOX>ZT^|s>F62Uh%0Qri zY0e7%AxnqOQdh{B4+LEnR)sJ@I?Uz3h}y8{8^?$v&We>kMJshpV=-vO5%?dgxpj`5 z)e68a08>*7Xu*Z<`fprgnx5|M9f&&T6A=(`%P@*cXbhB~%1H>`!$E=NY{Pn}w;K}2 z*h}&?H24nfmC`G!0h^cnyhLV}&t%Q@GvRvJ9!|v6hS+mPJhYtJ`$9R?t1~6}=+WqR zSH5E5gwB*)Dt8qf5Md_ig+de82Y1)Mj+#ZSk|%2qM+4=|fartU{~>S_e#qG9#o=(W zssKgGAnp5pdzV7Pqt~YL^w1RW%u4UfP z;0ZsK_s~K5$TDwyvL1c%p62ci|1CAQN?jS$pZOp?v1D1$^GN@&e}3pNDASQT0>Lo{ zUwj}AGLvD4x+Qw2DXH5l_0LW1p8Inqb4npD%`+&$ zelx=4pSfbHQ*`Kc{nk3W)xPO|4XK8r{g~b=T{$3{&Zzla%@CQPsbtg26jsZ7i@IoO z5fldcmbVsm{OOS{!H2Gu%GANQ1tpDzXtr8c^fLsIXFi@9_+IY# z=w#}lw_wRDP4WTc7)^1EP?Ekf%lB~r$|2PEdAUf1Ulj$1ZP<-aX02^@xM!xv?AiX< zKsJP*KMRj~`_R4xo!Te12XGXv&*+P`TRfa*S8xN^r$e)?8A%^77m*M=(Zn*IO_J`E9Ah`G|keKEf|czR7N&JbcL&v(Kw!E>HDJ*Lnve z2I53vvR5G))Kl;P+pNo|hU8_HHnQIQhbKN>`icO{$&@u9pwf^`d~#=Y$0(5G%*}5& z7kV2*ux#DbG@h(VO#6!hQ(~>(7Xl_j^)3d`4)=%;BpY-?GWOScDjh0T%aT}xO1JXD z_Pb4oE4qgQ#`VwI5CShfKk-D6sr4|F)E&&By>c$*6TZbUmcB9C92Xkx(T$`YxreVO z^3vRJ-tl&d*}TgeHMY?KJ4Wd9k-OvfDy_$Sr0TzoeCjGfzbs3)`RT>B7k-r-e~VjbqJt*Kf=V0HJ7 zRVL!JUzU0dnJwwhvO)6%ha2rV`AjE;&hR=~^}524*{Swj_9*05tFgvnH>e^qYLN1o3*P}41}E!HZr zJU}R7$h^@_VeK)xckPTag<(HrG z3un(`TYf;vMx8TM0h%u7if;2F`&}<{Y9-O;ZR^m6D5v*Rbl$Si7;*Fn zL-WyP#%2g!`8RQRi%j-nsz8M9eQ5pQJ%40d9xY`nYx0A?>~VC8*C*)>;@cU)Sbd4 zD1aP(&$hE_iKzcTN*!TS?}jbf*oHhk-ty^-V!h>Co^XKIjiFdi$SrrRT_Q^aEu2Bi zgQicx#fChg6D~4L_P4r03aDC$&)b=63bo`oStY|}{hU@jmYn(~8{;la9sxMiX794n z*5j8x2C77f1}L{~dCQB-wdz&-C~Envs?DN=G7Gv#q~Ph6w*H1$x=arJ8@OP9maccy zo3u=?e|?)CPwICsUoPi@$MR09K!%wXCTyr*Sb8J?7+KZ5UEX;2U3bO!L}*h7gi4Ai zvgaY|C29n$*Fx0~A$P7$%OA3<0z>Y;VaH5qQfhk4ZMu?8(9Nj%FLOLC9Gd^-T==Cv zo4_GDmagYrA5;Qibp2L~2?GWy+OliM5~@Ya%YWiIa!};F(;@zG=4bKbZ)=|>25TJ7 zDcOVy#t{yR?$7XaMFv@O^H!Y0Q41}zIfY~030^|**{Mg_w1+JKIlF$Nx*Bi`NRib9 zx0|(LEr0^xAGju@utoVOFl`p989(D~ZAf#qme^T1AAWT3a+;y!i5>tIg!~@V{nTvH z2cVoyM{a0j>sYTv&}YG>Y+THD9HnNE!KZ1rb@J?x(1s)t*v&e$$Y*)hIS8O1(48YG~kD zjI2ixeg1Q7{XPw{=Y@21D~tb7aS>6)Jas?L1`|3~x^XC@>0buFuJ#L0`GbW$KAPxL z&+1nx^L!$6`=CxyH`7&ly8+1gC|+Iz^yOo^gSM#%m9rX8&ZSOEn=*24=!4T=t+@1b zw9LcmJd{u+Xn`yuXV6h9f-~r&c#$vZSL14>0QvU&$@t2W zm)Q9MBP#;CV(~xgK_5FbFEBDgdJic$6^pYE&Ne5Vvt7=3Xs_sXtk3L#C6T#4l?rqpulz#$EV{#BGn79iy z6tz&rD(tFXCJ{3t`8S35yTn)Cxcd)=Ef6O9KOa8sXQ`bzP;)73E;(yFAzADzYhZT2 zw^HEL`PIGJxpbl!*#;IpdTz>)S+pNgU~Vv0PV5kk1X10pw%9HlPHh%cm8inHq9o!VNT@s&UY62g81H>WA!O**eAYZk~V;WJh zN~Cq)G4*Z|pPE7mo)4ZzH9bX6bb_-#%RVaS@yLzi60CB1d-{y>&>-f9y~I{_Z9jdD zrK#s#Q;lE!e#eOJi+lYvE?BWHr=(7J$%beM+cwbQalqUcJTsW}93q!EqGb9t?4?C7 zL+1U9>u^gxza@Pwm6OlB87Rn|0c&Nroqki&SEWE=>jW{qeJqJcI4WhUMM3modENtK z3NO)M%N^1W)Rc=6-r$>8XgmPx9`>%GVH3K&TS9E5)NtzBl2vuk_gqV8Lo{0Er1Ge4 zz;w0gYXSXF=x1mOQP95Sp(Ge!TA@&BE<2!*InVg8D^UyYf4&P3%p_Y$I)2pm+wf{L z_!P%kEf^12SLq_!)V?xCpCW&w|Hwy#`gq_(g{lj*%YBk9l>;(Nm$s}dRLP)Vz}q!o zB^NlqS;~h2PH%y0UAkR*s?Cj4+=!c%m=NfwVH}oV|%ue>|=)s zBNGmi-bkRf7*X!CK}fP|S+2%;kvc+*TT8-CpO(N`CihrBs^c+)jODyz=F3?@97JY( zo4%Y$cz3jvi5Q3U&cSg|qoiOwZ2pV+tiWb06$d zFVRBJ`7)#_*G_Pouzvz12p2Fa>gwvjiyiVYSF{)pKT&`u3oOtr_Zz@QW?Bi*A&EWZ zVdBvo&T%%qIT$&wmPn7fM75|liQc1+7Zp#5<+Y+MXsp0WRUrxB?vX8O4dQpEU=`d= zay%f>6YZFulh8b7N;);QaZSe1|M^CpjTc^qNfO4y=|74kqa~P$wG@{5c$84NdsjbK zh|^_hGzIQ=E*bdSahJE3sCx-M6&A?ykYv%Nc*~sSG!*Jnv7k^i6S?|S16Zo+M^Q)4 z1BJl2rG$1(z}S7Di(tS5@_!dDz=CiO2=xwY(^%gyaE=_P)WPOWRD7I6HzsxeO?a_* ze@C7V#P+@xtu?DtBI2t@_u03kO z6yw>wZvV{?%oJVv(bdq(78P%QTTKirw3b~@KNyEFgkM@X+dY5n-@*|5AdE5}17!EX z!*_h_@Ghn4dzEN2O1^2Tco*yy7J6)6*<2MeoUajR+K$PcIAWtU_#Xk2V?v=B*!*B3 zMBQYd{VVj`0h5lkrg72w*F)%(+Iwgf7!;KLJ!GH-$(*4|`X1uJ7Qz;vxl|Cw0}lxo zHiXgj!FFS^fl0SzeR+&Xvk)X6dj6{gfVX>N#T2>p7=&M@H5c!X-iG!_M@`B{FLEYm}wI-O@S=NOx_UzsnIvif!W{?Rx35C z`^1TVO1~RbC>4iLTAqQ9ZMMLyP$s4fCiMB|Ju&`;B1+_g2_?GT=_=|MFKUnzl&!Qu3cPSsE53L}rInIayt|Y{ zAI|UyrufcbGz*p*8N@Zl=XcyAzd#P*QXK_%*>z}bxoVPzd&@Jg3gE&3h3M3L@G`ID zcT#B!jWdyStoE(N~y3i&qZ0&IKQmc!2+g@)l`0!q6suFccJuWQ3q#S=MbV z-){Cz;c}hQJpGWbfhfSt0CszCB=LF5D~`5LJjx_;jyanChynO_kLhEv@(iPe{k&XT z>NM#0KcAU8-Kxhj?nX{b6wpa(f*$wBm0&4~9FibRx@zSCOocB}6%SGy4VK|%pyv3gScV0+&`<3O(Wc9cT3K-~;bX&95& z29IFM81=UXs>qR9iM2tw+q<#Sp(+^-DoYLeU)~i|Q@sC+_57OpA%RtlC+al2aH#{+w}{_oVG_YBv{vt7QN^+XG6SR-^b`M|EVmX zb-Z@2EXXli40*;m$q#7p7JV(rZ6X?u4P{;C0X$d&aen8zmX>?3s)cqpM6fbe;0a0IetG7`+@ z8%}fo^re*cYHHE&rIwi7F(YkA6&xsN>3Qo2gZW__$qa7Vi!tZRMK`am_{olfRg9u6 z)A@ijq!T4M_qyBJBUlxbBGO>2&P&N*C@6lW3soP&$8SfT1AQ_DeCkRrH-{ZWiu&_> zVs)UC58xYAu*lJ^taAV}^wf{&O9A-$d|I|2Wln;aG9}=OP%pUcN%pW@u|-CtD%iZl z9)UNI=scMd91lRWvsM{iz8vc)*t!jfkme-G*rK}8>dv(1gtEnn+rvA@kDr~`Cc>R2QkB?;LC!+XDH4}l^FgpQ;<#o0000001q`?e3eOXZ69A)K1*jrQ3xl;l`^9L zZ?4v(U)YqxB29T8TXH`+x^H0?P?^LlsMh^hORElex5nYBWEo^;wvpt{{TQQV(EJBq zV>D7z9K1T1%Rhn}n2*N0a=7Bx0651#F+F_cxDDI{Rx~!>|L_K7zjj7h7pc@m>Ey*k zpLR?+&G%9F+RjZ~dlPwUtka?}#Di~#xDh_QDIz`O!q~Bk)j0OWS&2BZv;ejgfn<%O z%F@wna<*g&`mem>QQOIh6KJ6?Be+U(Vc5OcD>QcHTD4li(8yGuvkgF?7M9M=k-0N5 zomNk3pf9597@yU@TP<^bye+1tlsy{@p}$f+ovh4tD^A2;`lr?oe})@;h_$- z5G}~eYwxA~o0jW6&tIX8#;I-PoWtChPDH($MXs0=KuZN%p-8)(qhiCEceD7g(12uv zYKmxAt#rGceM!N{uWJ6Trzm)+-YZ(G(a!GaQCdOE=*F|!FG!YVm{Th|P|R@{LfQnm zlZyy<*bFR7PH?u320)pW<7d$=UH)kf=a(-Ec6u`P3MS74-8Cd^<71`GwdIFCh>z7L z`dlhIGeuL%ygf8gV!Tg{z+(Pn??%W{00CHW=(c1L$)g3whs=OJ-~;XeKHvlH06yRY z?f2S;b+PAJtqz(nTzG&Sy+T6-Brrll1SBv*Lj)tzB7@Hw)trooyn^Gz0&-1%gYE!6 z-~;XeKHvlH06vz;x4;V60Mxn~YW^?)M#>uY9In$UU;qirJV};xQ<@+E4xfMr1_D6z zulr~9vxhL(IKtYS*?IC0Mv?f>HrlQ|g<#}U6KM+?yadg3(Aofid`l$;ohmlGk%k`c z??^NDx9&+U?Y?I5tGsLAY;_IPw>d&yzg8SvPWS+mj#WQCz7K0?6v!k-v<_t57v&rX zLs$rWG?eV=#164JF)iL0DSG8-emQuh>#HOT;>CSmLCKfhj&2v;tPY{-B;>|gf$v5R#Lr6aZ za<|O#dZF~)%6Q01X zPp6myC--?S6Wq%--(!#9*!BPTWKWpq8vzlq)LT;cFq8$wbtL1?8FNG2N*dfU%-zc9 zB`C;%R+4^Zovv`p8-MrT4b~g!rS3r*@TyslnrP~8rUt+4b&QNIZ#NStz1M=A>494x zYhmt^1$KAzbdhJ`(aEnzl!Qhgo*YfI)^pB*#CHbPnx#$Kw+&w2z2M1XMmmAZviVS+ z16xx1$H_O$bagK;!pyeTp5F&wEvbX+w*SH2xAUaf45JrKJ?nD)A!bDiDUes-=&a%6 zB45QRDDfsy${q{;?`;GFK!`W7668UOWvy-8r3>yUw)l&41Wyx{OcPJKG&|3F0(!Z* zg3~dyvbvNOOQe>w%cfF9R;{>gU!^N5Tb}^cGl^Z4C%?R`u?sLulC&!jojB8gGzVJu z!VW=7J3Wx0;>Dm8QpE4RJS4JOgtZ<6Q##)vZ2$Lb z7D`V+XG>Pl*%F|9YY(n%AH#O%>Y?YGiHA%efr~wGNM63^PUj#|@*4sbfaL47ZSQBE z=lCY76X9P@GD#hM`(Wa*q;x+s0gW2Pv0fY;STiq6Q8;3svM9b9+Tm{N@9{t*#sSM!$T9y1^?#Ze}HpzgFxqx z>3+5AD6&B*FT_9kSo!@VT8*vcR(S8N;!cWg;?|L5{VV-=`%JO3da7PIjDS=MX;P96 za>G}|?@KZ~M7Cvj)+ZeY=!jA!@8FaR;wc3kD};pSrDhC}?+W$|F=ZAf8;P$#1Zr;y z3^pA)stSQXBEz^N>J?8~(>_fY$P&w{d#L(2M<}PXhs!u7swb!@5&U)tiia&UqF<#> z)cyG&>#(3cch~O4Wbg7f(8$m8vA%Q#@PJU41yRQfe}QLb(ubYO2Fu&yK~nPb1HYrD zQL|Zz3NvUpsF|5f%fdA^9jsCd&QYHy5v7p!tVpeml~CQQMdU_D#FX=*^wa_neP57Z0-;Cht)~rhxU!FHM%K)jVggR*gelq7ei z6=c5oSOr1#Rkv=w`?Vt+}5bNmg3Q5>#Bmfn!}XrxhUZ75-JH zvM0jLATlC=(jjMS$6wq|6eC-8DO~?q5DJV!b9Z+lrGTNsn~3=0MH#dD_cC|m;Nl=N zn_;*2(v&hEk_;*CcTrK}!5ro9xg^CnXZ6JCmjeZ7xah;+e1>f^ukag;Kp8^F3fgya z6?|x(Myw-{^oJ`p>q)~S=DW)7;3ijSJcsbW0MY@=VE+40?XJ-BP@2z;Kt9h)2GMok z+`d#}C2vB}VA2G9)hYZ;wHy~W9VEktCOk?fzMr3=1Gu4&$q!C(1nQI^r-w@*@NB;^ zn>OvFI{oaW+=%S+UYQLMJ2@L_d`}%;+5gtB+j1}pJ7T%})br0p5C#o$qVh|AvJ*^& zJZ$a+A0H0jXkTUBjb@`*$z;Bvf^ZSXqqryXUzQKIdOrY8M9;pm;0N=!RCj87ZQg22 z0^L}G2xMyXWwH~4RCCoSH3qB{gZck~wrz7Ra}*4iXM<%?1Kl+yBw>4M{(oF0Eb!jI z;#Fc6^kZ(lp#Z0}WxD2M0&s(Ptm7wXwEhgYV0;xp`b}hvPXO%}L9HLw7fER#>#R+c z*O|5N_DLpxuDAz>6t3lBK8iQjLk#5#*CD0=uEwV_IV^OFx#QQ_O?s@EOk{2YBN@N; zx|~3Yg0dK+h_)z9VK|VjFkSwSJGa=&+p0*MS9z|Qr>SDb8P#t}?Cj`sv6rXum=OmU z7vZ&pI|oP`+DbO{{a#-30qK=q$T`RUADjS(AH-)^H163&vu{o~B^AZLn**4qQG^oQ zq%O=udN9A0g1}dFxyy`T0+^6=up0Xy7}iw$0?BHtlUu2xVK(zu!eCZbnT*~fTAhJgOlLAcEQ5o{(jCEHE%LzsF$N=h1K~)qcuw_K+8>eJ$?Zoz@{LNih)0?QK1##I6^~DtHOGRr z+r-Oo{hx*kA{QK|o5aFRx-eU$r&3rpvAB(DUhevT$ST>=W^&5am!%=!XaE$hM+jRx zIZB8|@wE|RiX+$@tAo_&kVU#jYQi*b1=zF_14E4{>aetq+$xkSFrQTyaghZqwHvnN zQi}DBem@A2l|UFO%zXrDDN^U$Y7ikE(&+T)*>$Y>%>^RkHOl`B1M&v|!{U4aI>3U^ zVljB13Ni~Bm>qUcrSQ=sf)8e#{ShE(pycaD-JV}W(k7UB8N~7MK4XICKJG~r8IH9b z7)e!^IVkPGI>qKKHYY!|(uO(&?Hjyzya*;3<8|0EB|+ zcYfpBrPH3%d@3Q;T|_#k)KmL%S15`pyve5%hkl`{o`8Hp6Fit18UpA3odrhSO4Fy? zz-zw}j8s^eYP8iKLuW#Cu?x zTUPu(7@=Vn+VnPB=_}6LtYN$#NSPt6{i__IVj@J9m3mZT`)@j@-yKY+W(2{!%@n7M z@=WNp0W=xA)6DQ3SgfmE$mB=fme+d_`z`XohYR~WZ6!hDX>HKQ0i!+u}RbbY~SMR9vK zO>LCFWc$vb%wH~!Cxc5s)aS#&e=)0i9g=L_q~9N9FIIcDbX7?A`I5q-;rIhvjN-C< zi!I5WD#WSHpkwFk;toB9U5A(Ocr5|&06=Syl)BNEwk{ME=U?GdvW^KWDB6IyhT~<> z9A^?8a8ep@2bkaXqqz-9Jvpll()6Jp}n0SE2KVJk7W)6Ku z?jch&>#@3!aN)vzM(!@?f>0{neVAI(5YD4Jeo0$B+JZ?df8VJ zMb(A)A8f3^<0)+hbggg;jdNTV*#>)}3Y)f&Ch4*NIb`aS|G+MYx70rzGH3CC2)JWd z%)rPrqB5Ob{o8UhbF>x_*KCQdgGHLIn^7a6Xrblnh$Tz~%i+!E9FV!Sgd} zY&B3oyKdcZoUd*?86q3<>g^RwM!>2fTt1Y`Sy)_mq5ALhj7;g#39)uVUbfIJOCeXw zORRPvnMb$XN-W-q_A)0Z6m5#V+%!|Gzd~jfVh<92EnA7&eK15~5x;P=?sA5ugKES( zjKJ^~^Cyg)(2iD+nEhy&wDQ4ATU^o-gA9ZDJ&L|3>Q6ZNlA%FW*rNb)Eu<=yWX-U> z&Ltii!%oMs?DXY<>%9jWqhXoZvb+VIs= z1o%^j0WBAgKM99m9SvjO-iZL*i=8dYzFqMJ>+A{S2p||k*0cu$y@rDEyV%3mU9oMw6gi3MybVP) z790{elY6398sw~^1chd#UR+$}1bS`YLnUHzUIgj7h;X@p059J!{DWA2dDLDzK-cEh z-f70&mz$8{o}|=0R5ebfCqr(T5J_GnpZa|O4cq3@M5!CRB!{xbM#MK^s{}i2rj~7{ zN1gQ*SDpy73dXpl zscUINb!d%jH;sJE4t@9}?yz^*e&{TT{LnW_7V=>#{_ zb7mLqxz@z~WjeQR73>ZdS2?0)p?TgN22YBAZf6GT!;yEQaASQv(1X0z`Gb|U2InK$ zRTzU46fq5GAz(y0R0r1l055Vn8sI8r6tOS9OWQj%JHp1`bE5>KZ=Nqq@Rmb(nMRnNc4&5bE#vCt%gyFyWyJG<*DA0%@%Q)o`h z*AA9~jc>q5o0vU4z{Lo<(}!Q|npMlbuQpRg=gv2}( z;az&3Cf(&Emm^eUq3v)ddX8&WEjl>hRKo&64q%t{dpU=hgZJhDh>Skmlm+d@kA1>p zS03l-UT1j4uSXUsg=^NSp`aN~%egz~fRZcyYOtWQE zcHzFCU`bX5zxyusxf1svtLPUqXkXK#y@WQC#H(^YDe9e{1^UlO-2AiU%n!@VRIABH zMs|1RKyF0#-UY)FeWUC1p;i+J>yDeKX!ZO+WWfA($R|=eLvfun#EMpGTBofPEyaNG zNGS0<&I9tA8nu=}YXzIgrFNa|TLwyI!!;1*yn-je@r(9>+p7z=aZ~+LloQ9lyN7a3e=bMZh9G@*|_3JkX&oPsCGW`Z@4XVrJ- z3|*m7e_D^UJRt>*+SSql$LE1pP}km%s9FJzgnA6CHV~M%O)d?b)TGo$V3;z*X$)bBX=l*Hkh?nnd4 z9+>A1Q?)>^PsTXRR*jOissA1sPV;C007gU0XE5cb0V2!Sz{=?o?+n$%YSi-Zwr7ZG zC55{1mTob=KZnl+LmxVK>V9ffran4Y2~QXU993s($jyE&7%Yno9{)KbFcMKB+P;lP!U>@|9(bi~qq^dB4L9wIo=FV&>qdYA zCp_wudAVVD`Dw|7n=l(3-sl13D&5EJC82Krycy4U6-Dt0VGr9?vv>ig>F#p#m+#FO zZk^YQlz{8I6fj->eFMkg%rcrmw3@CA;h513MXL4xPN;6N9W*k#cVSaWH$t{L>+yJt zbel+~SvE6uT3FV1$B|C2T`mkh9PefWi!wA?f)%4t5UZ#A} zDx4#rSq;2I8^_gv!)(Jtd*$rpV5#@ETRT=P%Fsn8JtU}kSM_SJ_wzM3p%{fUIG+p! zCfJnyknzBh-iI7e=n_DySNto^Ay~?EZS7ReY}Vh=Er1ZCV$B)8@U|g$|DO-@JBrzC zt^)T^&(f=AP|Z#`T@C-!`&iuszN3R~TlN?*vhtac+EVJHPaB0@CG!6LMeS*&q9U_S=}lkp6=pC#TO>}=xe0KBZ=t(LG zZ(XG3L+UPD-O(_7W>P4xN|+b5`O7Is7gI6J@gfQIzM))E0?UkmbPeMF+@1rNm@Z0{`Q@|ga<&mX*YhXik0c< z4!cn`vl++*K30kl3jS95dP8?)Yt$3=^L z1JaD89U1f5X2w4GF*SS*+JXN~Gk)Zs>4bR$syQn6U|s{GqN!BtbZS8@<{p3`m`)T% z{-+Xc7B-f^!%?gIPEvEg(5-T0X(CCNCJdN1uD#Z(PgYg9#rEv=(P|K9{Q3*<{ZHIU z>nImv`~t?phr~iv|Ff~Biw3mg#lpyQCzU)E@_-GGKllzJ1}MoHE6cuu?K}1fleH^< zL?U;k-Pb3?FKspRTJz%g+jl~_1b;sqdFU>KTKdXQ=EVi~4(E7MHM31nf3r*iS#{#T zoiJ);s}cp9Q+bxQxeP%WD z8f}jpJ&2_<0?fa|o~$9~{?fKphalr6C&%wZdJK}!PNP1bQP_Y?%N~%$QcQWL%5nk#h7jvX=esAp@%RkgD+D^9TA~!o~S&J=ZBIfDWyF zDrF*>;_jf!fk}^#q7tnAN#nOYx{^v2pVf5h!aQbBKo5!5!!ye7P|pOFRZCDtCe3R5 zj%a1S8tpYUt_HX z_L4bek#+7AnIfhnOfz0FNUK=E8s-@RQ!)&L?BEDSb-obL*$_t3a~-&P;TvkW&9WHBG{$nDWZ+PuCyvn<^VSAQiID8u-XN=L+a5cvdE5Fj8u zd}xu1BW#2)A$NimCgWH=)lls%ZlYt5_mRWK6jamF=V8&I^c3Y=HiHos-m)eVHrqI> z(ga~T8CpFnLRe)Io;47Q@r1cdjO^F-s$DH(rl#&PdvSx>%FBGVK5us>wyX^JrPts5 z2A+g(DG5*kdC2>9J6<~eBwsy2pdN4<;XVmz=IX0OMf%7!5m-bIb)87sF)kt^s}PE& z^il@qqX(Q_fzC09*{Em*zC-pV^M`(i(==yG2yd$RTnNTEW`=cKB!qyaI{TH+uRIui zLl~!>vi?owtH6UFs^MI8J|_j$*hbauTStlP1h0To6v`AO55jG~ z+x4&SeY|lQffme34n_;A$QMGaljCzb?F3HB8qqiDop&GgCtOAnLf;AM*HF9g1}=nb zL*qo84?HIV{JSln*F{30bLy#bDz3Z_50h2iFJv^&h?u9mv4*;1Wo@I%pX}7X^y*~z zc3*s5d?@0z#_AMa!j2{M3qyNMXa)7QQO1S)nN*6`WRYVGMy<*+z1K>6?=p)w_FAZ}YuB0VXW3o*DMlfWJn9|;!Qfah@M#D3gUD`v?o1ZMx+ z1_cG?P*7t4_niQr6=EszERMfZABU@m&C%e+%%xU0JNKIXE zx1c-pd$?t$GNm%PgNXz~c5W8PA}|cv`ZsaUj{x`voawpSx99`pBJ)IqKJ98RfYaw2b=zOhg`FjR9Li4^u;gKrfj^$4u+DHU#WDZGE{1hCy)ThX@2vJH!7Lb4OCuMQcvOW!I4rfw_ip$dU2|Z zwa~jbTqTORmGFj~W9qo*A!-hEAQH1rLKUSSn2uqbb{5C($@Pxy%5Ts)LRIt_XSfS zz7H74ZW3KB=Zq9v?6Qf?9=Xieg505w$7OUI1Jt1BLFD>*7pn%Azg?3(+Me(VLabQ* z@F_(woeccKqDC%ly+ML=M*2hWDxpC=Wt2y%iZ4&+CZ8x52POEPva2bT(IpQtAD!&H z3`fG3YjQt@WX@1@*QwH04tK-+(@LvSJIO&-MqtCUF_9NzNlu$9#Q=}dW06+;q%in_ zz=bG4GF?BH@g6SB!M2HV>=hz3h`bADOe3Qqhi8&~itmJ#1%4WROWT}^m6@h1W|g(h zkfxRLs3a(xKHWAp>P{E!&j(Yu7enO>Bmdu#@p;$lYi6gaP-Xwga|^s9l&RaY%x7CE z8BV%3(jyK~000W`0UNOn429Ew;FJOx7zR|X>UDBF>_@m;vb*vluKFLtl-XuI+r&(CML zfdwv(EzGKpD_8=+n3+Fl*H);xIl)?9txWW`WDuHOYNM7A*zF{qMvGJsF;7M5^&s_W zmm+Q?lkl~_;xSH%WI&Zkw6$syxM}X?=ONT0t%>X=fh(~R3a&@>NaMgLYH}3klW+wE zR56L)IoD+XKQ*s3r4K`1sjVb7fJZjsQYZT7NN0w8;n)lwKZvk*R$p>S0j3i(G&II( zRzuXMzH!;V8m|MPRm34WzsK%w`mS`On2zja0D>G|Cu=RXV>+zj>CS%eDlQ)8CqUB9 ziMe#@`kw<$z->cjoF@f+$Wb)BB`}#M40%x`>ZVZ3n1$h@8@JfNF~B|BA47U-TE+fs zUa)I0!{d3_0>jJN#u2|ebEF}6hyPi6+UMY-aj zU&J=B)&A={U`@^Xzq@3)NYD7B34$)^-UE}o0+soC@ z;5wNvYp@-$=1jT6b(^gE!NgO8o~Yym^9h?lH31J1wd0sbsXi~$QUyy-0Z`^ zHpND8_A=dSDbfrfk>I}ge)1AKub=G z#OGV*V1ac+0^vQpguaoSyn_xlVNlV0}|1)V|Q;y$ZBIs)ijl#@#DC zaaGs{q{rD&bGE(FBfL-1_$;0%Xn9jLrRn79-?SU;b&60I5_Y9SL^30-?;UgYbLC}S zYp;6GNugdOxR~`{-lTekJYiLne<$4t<9>k*jsr?P75Fs+m2bZUt*TABT$VLnZgGS* zff{)t3Iv%l_vlWCmOoNQerX}oKd3UpS%V*P7Bs9U8uBr}IH-ID$&Yg! zP!$WC`I=z7#y|_@3j8f@&%9a!+8&>JF@SZL$ZR44o>Ok)1e5kK3GDU+xdhs z{NTHocqU!e{@&m|*qe*?2AC%N-eY%73)m-77J5Bx>J zsVL zQc+#FzeZFf85xH_CndkQY8K0Lj|o42Qi@177&;U=0CLK0#Xqq6nRHMh3eF;=jN1*A zUC$I(1LUGZY1Yd7n#iox&&mfl1gD;a^;myp)hLBq$29Ep5>cKlUk`$aL&Qix=ghf@ z-Lqmkg$tppYI~8=m=)kS4yQ@Q5u>WwV5XC1Y8)glPF23P{({=LMC-7{p8o3uJ%JY8 zAN7#-Cnz2F(UU(QV;rYdhyZl1up(>!O~&yrZ36KP_1TBBrBde)Se3pO+aI<&rc&_vW4q1J(QR|Kq7d}YV)+lVEgAUSr`FIB*Z zmz~3`bI<>wi#Mk6T?uVp`fb)Zos@5c!Ivt?#{z;`3STi_fOHp(x9DTm%8?1;?d4Lz z+QY^v!=l)aQ@DJzdWDk;Zco#BIG&Sm&siO(4*@GT_|}6Q_B;Ju?L{%dz)89&{^BLDCOvg4=0P|QXz zM8z6PrPSDN$${oC?&Wz(pwO5|R-Sq`L<2`l%FbJRqbDv)TvUN*&4BW04^WFx2PPU- z8ZX*Mrjsc~FlCzzdqb0*F%}wBn9D0Q3|v4#mdBE=1{=4ZO9OzvF7e`^pz^4T)^O zWO{!V)A*vJe-#e7rh1mUsahJkC_f0IBxR4oW000005xE%Pxbzx9%@@o5b)*?4xxSUAmfwtzeEMRn=c1!sxLRcktZvk&g#IVv*FhYu^mf^ zS)lI^Yhak)_Ho>d82quPem79|@q=0PI>%`xam#-;00000000uj#aOOm;eZ)BSsN$hWU8FsG?H#I6FBy5)ECzE;= zSoADJO|vMPv<^Sezlh&98J@O-)?KY1J3!eR?^Lgtlgz`a0MrQEO~hW-uH!ZAT7YS< zVTA4I;3a!%O_52xmVZF>>AV}Z_Qaj@=>Y`Glz^@k7GcY#3w~{SS z-7P2a-#F}9x#?L%!M~UK@TY|X@i>0)W9Yw_>fGL=icjYWQ|}}P>V@| zvpFv%grK4Nm@QO)3mv^4ciTT{Yh#3XEJvCtWWzDm??F*rhwkY$=-SiZ3$cJB$l>lQ zc3u^7jcUIgvYhB|Bckh?C4m5!Fn|Rd9$mqW;=WtF+z~Z5nl>6jDOCUTJ983ln51{* z^?PMg7k(;^@=vTaVbd-Cx(wt9GrP)wQPD$J?`^s>zMB#el2Wl;a?9&;ye_fs(}C1V1%oSynkHI$ zvQieDqmS)Ql#9*z9|W;yfq$a86EBbL_VI*n1U&joxe?r_0Cur8s>{Cu*G#)E`=9w} zCz0H5E8F7N6_;qIiT_c43y?LVM_kV@9fRSwr#gfCC(a(GDkS5GLO?M^%bSIvC<4!o zbTDVAS1y%LMw*`zir%Z+(2WRE)npP@Xu394+Y&Os+u=u2N3WJ9f)N6)sCA`+77bjy zKk{gB0(ebc5rmbmQ7V~;Q%c$jw_I+i%S^SE6JRRT9OwV1xQ&~+8SigKVmlnAh^V%< z>U=X|cK&?$FfyxFvRYSMol6XD4qcm)F2jMwg#M4pHo>>pv7?@3CiG72D*rF1vgO>s z@EUUlQNS>bD9A}>7b|lkbX#IEteb9<)}B2I7wHoclvq4Nb9+OPMOJo(4!dm}-x(o5 zVjFZ*>kM-=UI}bgD)EU0aesFV4?3v1&(mA)Eqwg)dHQ?$R-?0QiZFW~JL)U&#}%Ic z$(M-wYkONtR7el-IX-5pj7n&W45q z#W^_fB{Q$7ddvou=7;=qC8Fn**Ot7v?D}6{61fQAwKzG?Ab1o-!=H6qgvB$!p@Y}; z=afj^+KZoJEy$J@(0AYk&O?X^Xd>^@Jp8TH=m{7I^7YCdU$q4bm#^EEE@*5*SFSso z*H*{nnLr0$DJ-~4+NFxX%JIF9zA|tYCSS9<@`M~{-0AGGX{)(M0h0h zi%gCBV*`HaDJk*_(6Y$MkpnscfRt5SzBOvNtew}B&ydPUa3{iU{a6TE?LktU*qKMg ze@Y8{NtX)GYBnvIc}bQujvSMOMS5lE?c|2xyt^c3FIT{ zHGH26l}l_zC{1BqyX#e{gxj8_UBoWGj`Q*_^iIMz=nA@Fs_qzupp*=0rJg1368hLK zQ42#1uOA$J57dx-9UYrOYfLX_hxz8D2TIie)E+&)mmBqw1*Ngku7{axf@KLleAv4c%L;PR}o3wsZg`Y1ZG-k*$%UuY}Ay6dtF%PxgUWetm; zPTs9rTwds1{o+@!Stjiky!Tskih3gSxkG!?YW(yA19#X-Be$)S_Mwwy|Kw|b|3Tox z`sM24nZ1owr~bmTP6qgSPCA(Wj>qxdZEaB|{Vj-`>G8XW{-ur;%IvPY$#{ia-H(@~ zIlfQ;kq4>SvEG9a-3@NF6vK}$SIw=|M{_-pR+(hT)iP3 zDTj&{IjjtlHm9{I@0TY+dcd3Z$*XHAbQG^A*h zz+U9DX|xOKzr%(Ol7Au-nrt44FHB#pB+TgkXK9EEXoQc+0UAP7#@l2Fl5bFSJOb%? zj6fnTr_ocbT0_f8wA+;jYZ)!q0#crWn!7U*JhkP=XVU(MhL*V{8)3X-$x2wC01<%V zaM9w}p+3@j6SQ))!~U3dP8x3-)NA1OcPp7lbHbG~vKZ?NLQxS|TfJHcX%X&L_n^#( z!$LYFJGY6|-l`CVoUf5m(^!a-_82FVB`ZfkmTBJzDE|3BQP$>~n?4%R!NemFmms~9 zTPydTU7ui3b4 zNU)wzcFW_$#3&l7)_t{hh>%*?x`A#h*9_gT+y-FMl&WFidx2?xCoH9cJ8-so3*pM- z53@kAt4soth@#%JDP1R_Y}GHGzjU=4XYXthBf|vt%;N zzuJ}qRVrJ918qgK9cGc@xCd5*NAmyCeew9790r4{atdX?3z+**C{NV<0m;r6v)Ba)-Fmq3F zKu#(LKA}d7%Ym!HH)&FYG+_@8IVHKlQW$KnT~fM^Wji*gZ{V_MZoZN?8PcjHP&L+WDUTMcj?oq<7q3qphFt=Luavg3v1uHFT8#t6GASF;u#NU6oE;yMj~3a zG@+Mg#k7%JnE}0dn$Dv?1fqv7Ea7`m<#^R%mH>d)m4>-cH(#3hSCKN}kaWNB#s|(X zJXyIcQ7B1ch-pcbd{_w!3WA?6wet(GFj9`I%Qb6 z5QA@zQL=C;!7rPWBJLZ;!Ecepu%cLpC*_JFEIhRFrIxAv_(G@&DP|j_rcnQ5XP>az z#+!c6WWA?1=(@6ywnYh5H!51wo5s2g>;240q@sP}h7GqP@wRJ*4?l;Tip-dM#X3~8 z-Q*W#S~GP3kCGC zOBC2GDOAJ2_X5)XPFYI?Y94r``rl;0IGcxbj)VufhNTW2&n$p+bN!He_}YAbh6ON~&yO#Y;-1IQc40Z(lDfNM@Vke*oI{b5mF62$BEj0Mkpi zi2HOs%F43$9i?2agy3RAu7WcR+@=9mV2bdC_)ZBU z=EUjwRVBgRoIHqfXTh)TM)D^5_}14v#RKxF!iKIZ0Zp#T<`~oA`VB9nyIhT4yyS3* z=LgAWZ@`TV`SU&##OM}OC>9Mf!Ix=MY+x^sf37FU+bIkmc@#5K4{5%ON0Pu{frjwZAO%qt?^1E^{HVA!{$!$nki4L_IER78-@pr*?M!Ah3oI*9}uM`Wum=U=*_aIZC?OPeZuAa%rgtW;CLh$Sv z_X6uGd*alh(I{8f_!O1^=I6LgR`-cA!CE@$+>|sEWx~-f-03b+I(UdD%?|fng7DN3 zm-yWR%7j^K>5ux{f1JX>WYEVv7R|#TTGC#D-i9)sQ&FS4ImZwTM*3upwfqk&*+cP6 zk}vD2sIcAsW+oofkZ_2Gm>P~pODXzNVo51RZ9hYZ4Af=)gJ_r{>?yqvQ7H#nHxe-; z*2-kSIChr+ve2_)e&(q=iJ10J#)Rma`QLZ?C*Y+RaNY6sOu|Hjh3*b~N$b%df5NTF|ftsjfG=T`RkHV0ukg4Ps# zcO#?(-OtLi`Gp{XBIL!+tyHj*i7(;9o1a)_EEzwW0M-F364limCh0z2{P`aTO-_9J zOV%T#)PvuzE3=dOZDC|fVS1QVxi;K+S^P1r%~FvxW;m2D65cSgThZYuL3Nr_?E7^% z-9(;{g+PR9NA3Gj!6jAZkSd@SNR{zPon}n>g%C&Mr~dP-zYpv|1<^kUXkg%&=t)gtpw&ODDz=M#Sfxe=N!LJi7IMtWPp9mU-cZZ@VC}mSOhgtY6uMzHh&TaMD-h|^ z-eO@XvD<*)m9O{Ka;&kSM?>8_RScJR14OGFaitA)>dONm0?j@1c4~{a^dJqW<&(_d zs^eS#kpXULS^ZRx&=#V{a#YzQnN0R+U-V)=OF{?l~ zs+J>Z41we!lIc*;<%lCUu?&LwykXzEkGny!rxJRH`yFM77iFB!mvgk_H#mS~})h?K>p}u@wAo^v4ZcTW_B)Vm>Hpgc8 zCErlP|IIMlqLh(z-b_Nc%1fFgV{5-1KKMV(k zuUMG+Iuaw($#w(BuL>OqSF>NCJ&evwx*DE>sbG8^bqI=D5<+>0hk2QL4;1DblSHLO zt5pJ)l-K!h@SFP;EK0D94mygO??danrT6)gPGHS(>opWe$@qB}f66y>csPOHJBj4O zGN8f!V=xtG;^t5zcY8i^Jy0cxR6TU5>C_ITCYKp`3YxJjIM^sXmus3PlT~%-JiL)q ztW%EeG#|!~TbDjmMfCyC8NAeT$xTEvdYS41>n4DLays?UOCAh*rA3*DVhn7^aPdp1 zPIednRHvMGCvDFohCgY=9=pjURPUA#@a3LuX)DPc`E!f6$~%bTiQ$A|k`Ix(bR0qF zbs4w9gn+0)$R7e@OPdMA@CZI4pB7&(c+s7)!2Wv-b3HB3SaMZEo;f=K08?U!k!7$N z^FG^6J(os+dlH(OpZ4a3uvRcD`GnT0dXg6RbkSQu-gMgrr!nj^4&`Wj?Zti#(v;uD z6e{9Tt1E?tz%~maps{0j@EnosGA+B;3OCtBEi{`6OZ{;oI@~am`%LR}=o~OpJHc|C zHSHAfvy;qlKnnAaUg@$y72)M__n?pf1vA@7_kQ$67P~})p21m#%Cogk!3Lzs9dtl} zHDIz^2C;$&gM@>=6w=mzJea7H`OG%dx7|oRS@t#1Y=CPZHkS|}I>Er%Zu+@^Es7k~ zwMb_6*Y+dMtzE)nray;p&kT5A4v^Pmd&qwHS_4ULfWA<0lKVm!l)FE@ox9)@pvs7Q z&MbP>JE8sj!W}G1^)*skppSmj7{@Kifc=H+Q_p#{^*5Ok4PpisTt6NN8Diih7OrP_ z-AO{pWm!y5;S&d4p_!vsZ%fmxg2#Q-b;$s4zs1(?fh(uf-jxAHK>QEFS=)fFFk1c^ zMuDdx<0cJ7wfju~L{(C}5Q&aBhM6m0f?}!)Ry~ye2rR8~hMSoT1`GwsIX*Yk?I)+O zj5@FZXcgH_?~z!_A7X3}I4nI;Wp9w)L?F33zqUgMuoQmA$OxHdW5q;h1G9{X2a}); z?`$I*97vvdKA6TWk1`S!oFxiI19@IfoY}GpIt6f~>{bQH>CcY#QVrtsJeGo8R$~@`R1_|5dc?8;d3u_bk~W z`-@lQT0^Ze?qu~*eHR~mFkOkTyTq0>9J$yIvk^otRhMQCTBdw`LX!V^+CkgR&wD%W z{Ykc*&vMKT6xBN0QfKnW`_Ty!9Bt?ip^z+nYwI+Gz z^YCa~Qh#qVH#v1%&9TB0`8}Tl``O(urr7>Kqt;*_{}~N1t(WrM_3GXZ$&cr7FLxNJ zRbs1q)lOxE&Y;A_$;=p_U8(;plx-$cll0$V7j~L5I_KX6zx@BJmJ#$brculwVx(-4 zrrh8Rrp$dx%&m)V_eYt+ViYwltAK8)un>e}rZ{9q3u{up%OcM7|LKUnI_bmiLtfJq zGXtWkRV|WGKK|aD>noBT=X^tck+?urqGnT?OrGj^CjGBo`rtfhbo4M=l+v{q30=fG)R*RYHWO@( z=Wuh~oMZnL>0^08)iK^f19}@s{YXG7>%w6b+C@JqRU8FOU#VW$x)3OaBl;kMR9j81 zz(Upn#)hl z9BkHf-pQ)0W?$H{-yCi`B<{14Wae_0Ry#ec^7n}VZMk??A+9nk5@N9UUC=6zs7Alt z=VBCxh2pWm5mhVh&8XzE7$imu8Y_VXG-OpS2!G!cSYZxZ zwPHVK0NQtiweM0tSn>Nq)r{RTCec3_)gOMxm92W0R4k7=om)7+8n3PZ9 zVXqtsA5(zclwJ!dO;>g)e;!4XT0!;t+khmlyMCS3W`U6raK~57WvfGR!rF;Nh<89e z%6Mj2NOoDbBy0!qgvQ{}{+TpPE|_bv5#Q0tmA1lWX1CN zc~sT-5lkzC)ulf8S0FD!o85`?`0ccg4#l8!iB z$X{9~!ftQpIm!3A{Onn$!o~!BIB$<3e}K}5dMzXKv60&Io)pVOWx#i~=f(uyf$?RLlYo zr{bGCv3z)hBc-SCe!jvSZ&7JqXjetHt~mg@&fd|&vSuDw=1z$#PllRuO2T(vn-Bo?Tm^&?a}JjnW!33{%T7ou-`nnCL*p=T2(jh?vApNIZ1akxm9 zLQ*rEESJXUWMQd@>~q;0?bj^ihb95liKv3ZFf~G!0!Em?0-ua#4Q-%)FGS!cH~HOqA`<4il=9L- zSEsgCcFoG&&%f{>WV+(dRM`mmXgXBhPnSTws(p-~nMwsTZbzS(*1MjC)hrb2RwlyT z_USCjEA+q=ZEq`Dvvwcda@9Ajn}{`<0$;mPvD7rGG(}VV{&dtTV-BVz!)8Vt5}Toe zNEfoP7s{S%sq}(jCIW=Oq9N@s&gHe(D-odbd8uk!`anVh-1n4zS|%BMd?fG-8N5w= zqtl0AInd{a^F+ewhm=E~4hqHT1A04Hr(%UJ$$U8`MSK^?S#Uzm;Ql8and{;5QpP4Kf()u~IBzwZ9xWDaaOqau0J%YUDwoY^_;bS2Y ziaP7by$e6?zKClu6|)r0(FmlmVqHfdY|dKOrbFQf-KCR@vH$eG zRnKvn8RpPYXghsL;mJbp^pMTt1KR4dn1PM(T@IWg)Qi}=H_tP+DQK^LYwBLOb;3q- z^wT6p3y7zS_w0IQ&{mk5$qCX5z0@8U-hSKpPjVa->l}8@aS89?DSSY{ynaKlp-hc0 z>LxBZo;6xtlb&f@a{Hv5yB?VGP4t1y3^MW*cH66|ds3>mtF(wT7f7WdqKKLRyeU_O z{!HK}PF;(oZkp=Rnim!;%u^1qA}ji0-ePwv)WGT186@W)c`6n+4rY_t-1|bDIjoHP3UYB- zgsf7jx0J00J7KE*X3O$n|HS;ssessIEC@Eg<_nSZdfuSy*EJwXwU}-#FtHYVFw+1-7%^eC zt(l@oEWcNNb`@nH&z|MaN%)K9#XE zg9Uj3krd!Ouf>wncA#0hL;o`Rs{T%C@T-5|XJ_2_XprMW9fW*o&Lju*-MY^9l@NJ)b?nWbJRLyAr@ot-EOhQ@XWcgOwHtgSHnGGeiKy@W@X!_DR zgR_PDi=(sVhD?mHdJ^caUBTxLHt)#^+}F@oJ2B(6>Dh>{OW|(u>OYJEtX{X@aU|kAs^MZ%oF5ct>WUTGz+tTMlHj;hQf{0P_~(!(%Jl)NO> zpGyMT@;FAe(ze1kZq!g+Zf`QikRiXm?Hkq1@gM?)n5DGl{n&X!uE}vPF0yCsnPuvT z&#{v4cDKP5laS+nm|$xW+B&Ly2X}$v5IKw~*w8WRaBTUJ zVK6U#Ii&F6n=oMuFR{l-wpE%>&aWZgBJ$T5-Z&Kx;VG-bTsF5?bPdjG^UcPyRVZ8Q z#P+I&8oXz>R&kKXUpf~FkM9s_-;J&zGhb-mfEwJL4**vnqB?KLa#!GMQQ+ajrDq_tL1dkWHK?|GCKhCO|G;2F*dL$E|<1zJJWW^YQJyR$H>cZr$u>T_M+d=a}9 zhqq*AaKDj-D@x}k7wJ(5GZeUwoYo8OK+&r=)!BxL*q{qP^8K~^noYg)aB5{eP>fc4 zu5%8T_BGGxwToqy9!Dpw(zBtdbP_~se0InuQadu^$B6I+Ae<9U4D0M=#tR=lKAQdj zD#sJepz6-TyHe<1E25|?o0iMezy5Zs!T)^unyngWDh6s?I41L<#!>1)qq@P;u0E6C zX-;Eu0YiDi-yP1X5eO_XDk(<8zt>o!^?RxFvwdAFgG)6MbV+(pU5?$m%{Fcw=JhCl zL57xK^CcN+)?}ga1H-K!%w~3%7C#wXp@?Sn4S;JdD=&6DtT9S1`P+Q7|Nb}k;gy-Z zOkt<(Tew6z2fuA&9#Wh#L6S%t=9unGC7?B&CsUF$Q3uOMGar_wszSX)$4mfn#Vjh~ zt9#soelm0_0)vMk2I(RQ8{SP#zqM|s0yDjRzWxL_-Ov29eD<{|*AF3=l-uyXV3x$c z>N`9|5B_s7H`1LU>G?tm$)r_cTqiYD_3}`uY5FvLfFRRSScL|q2ILA|mwW83KoB3e zf@?Ot+Wap2-h>eR;>C z2hMLO3k2=M)oXpq5N2CdwNXHgy2f6wno3ndlrN(wSt-%vZro7#l1y#oyPP^^ZP#E? zs8g*O_Vj4%z-5?C9=Mn$DZp75GP8d|5(<`@@2aWgbA@}=v=64N8j{v5{XUN~3x8Ns z)2IQO?LQdDw9na-sM~zy3G#ua7AOA5GHf!A6%0}oTQEy*T}F(MliZ%zDU%mdwu z5e9Lh@cILeeWxk~`JtvAL5&Z^A4L+dzyBt~RKiro!UDO9!A`jnj%-C(ItKM@L_okV zZY+E3#$cx$7r{>Vvla?y;{sFj1R-~@SxGCUOiu>b;D~Vf0YP#my>Hwxev>vFRpy9K zB}-rz_#DSM3A!HHm0R*IDBT*uH!#z(?d0;s$AaGwssED<`=40Q>TSKnPZ4$>hk`z5 z$*=X6T3fzfY5Ok;i`&bx!2F{^2zE7A2X9c`l?O0M{~~n85^MGql8Vpu0{}|0|NoKt z|MELO|MD}x|GN;Nkv`2iD~ECa{n&*Bnsd!pAo?p&YQ&<@z(H@G&@-i?z5ZU~i*@K% zTQ`Eb>Kr)K_8%f0is;r&UQQtSdD=;8LCh)BG!CqZm#;XhU5uuh7xEoB;~f4q4xzNz8|8S$!jVsGN5^4+iHgEnE~YAI}spJ zK^JM!-&JSOM_~bJ#V&V>ITlaCF8m5lv?r8Go{Z9E4F2m~t6@O{)LfCtfBd7M@g}VJ zYoFdI(9gzJB|nhceh1s=0EevVsYTHghY?=2(B_NwB2Hc2-;HW=_umezX9EV_$SJBL z94th{e?pt9+>zUc_*epM6%)`GLZ0T4)g85t7_iP`HL>mz0jU02)mXnD$FvHSsW=Yt zRJT0cUKxYJsS+o^v=NQ~dB!W8gtIP#o+@vzFA zgR2QSktdo7OQLELn|_AJDwQJ0x;qT>=|{gM|3BS+5Aw7>ml! zA3E><43$s7d(2CGz;vHkgdccV3^T6&N(x4Qcs-tx%a1(8Nd%qpdVaky`;g zp28*{Q~dj$Ol3p5;xJ4+LTHHnA9D9QyKk)^k&&_;u;PU4_qZGw>(thAb?>TD1w3)TaERnWgcvU9Mp%;z|u z(L1_2(x3q$@(kK+n~W_l)q-_Jau=m0?{_=3L3O0^__y;jyE=Pf8QrCk)U z@SBQC<0QvlJIBj-Oh*KMQfA?(Yw>|7K2EpZYbe`HLTaf z!tTOm3@W5kVRPK^pQrjR>s3S#LNdlQ*kzwyiSb@d63)ubx*^vc)Oil%XYZg(n`SEw zl#n6}wzyn?!D-2*XK;jW;fJ@)X}NaIa&lvcJ80hT6-vGj>Z>@C9Dly+F7q3x=Ew5h zwkr*nzn*r5)Y?*H`JJDO1G(QzWOgLP;JD})J~x||a7bw0#=_(PM@Z_zn^TsSaDu35 zIZ<<0q)u0yBddP@pM!`EBW&}D`v5bln>B)d8WTG-L9n2@`^im_)jMoP@?K(U%|rzh z&(!_sf@#C3YF50E&}3OV75Qud`EwK41#m_Mi9}oIiy+IB0iOzB^++BzLXDy%-87bO z>H$fEDfGC|cI=4gG#ff@|Fe-nE7#TxQte;!cY{N}8Ly(X*p{A0%jZ!IqdI>w>PcSZ zGFK0Wf7RBSPP)((9uyA0>I^9ocZn4z!z8b2tJNxHxiNwjQ6(d*5Wx z4lVNdPtWhM&KNqOx_r+;mt0a);*a1ziT;+S?kbbSqB2?uvinHU-se(ACCF&# zQEhIb=knB~zc<0F51Xx^BA=&*{W0*$fCA8^%}F#X6J(QED}dLx2h3mVe%H%a2d^fz zryaSCA@4EqQ6x*&`q!GZ>-v9{7EG_B3KoN$cq!%@^=%1?o>tF$kw)cbHF0vU&Lt!9 z+d*VCb@t%^Ufj$Jq#uA9uT*aT2Jb!uy`G=2ohh1SDYVt*jUr?<8;@~|l5zf}wG)giRlSK_uMtAb_~jh~Hg50q5-R}a2b!8oo1e1LDpqJTrzZDpuRs-atP0GPAw zSV>hrSByPP4>i9(&r3u_l22R?7@l_qflD@ap2LZqj9LH~Iyrkms6v&=^W(cZtMKt~ zsXIBWgoA1=uw(WNmMQtKVjpo`iHVWpgiy4HfhpZKG8*oUn!SAYg8l?iBng{%dvu6< zakE3oEbQo5jk3^s?T8^0H_%iXa4_Rxp}W6AJjLYvvXWP#?YlYzfD1#*dLF65b5!^p z6Xy>e2{j%Rj7;y&@li4w`V=ELF9_JW)2fJZ2gp4w9xOjS!f~+B3{hcjSneI6pO$y5 zfpprTm*T0!1q@)nGp@+%JQlmr=iC)Oe7Kp|0}xz=Nfjv(W~=}J002c$FZA``008T< zY92}fBz_AIj@lD0l)x{YDlVP?)-$k|r?%2GtXp`aHutsduk7o$#qfd)Z;!F<$Z?o{67Evaw&oxm z22WlW%R7=%CrHN zB&G^)!zqXHvPfAb_UV+9&;S7_%a6)<(IA#rdxZ;Z&_m@q-2Llw!2nU&;$!c5bbkBh zcyvc^pWJG6X-$3jam{FPMw4^%x?cZ58Igo`;%e8NSQ^89Tf*92O+7rpH8BU-e-UW~ zeKM}P_J_kU*Bb=%$rr4RnlUIM+EA+6e?b*NRgxf1fKWCESsPWg!yz&!x+%CFFCvS1 zF=`4%ewibpJvfim>-2h$%HGf(_d+)l2xOzPm=?Qgu{*}FFP?5|GnB%-FoH~i8CRvW z|H07tpIga04}QHJu{$WM^48ET3gs*E@1=s7iL@e~9qZ6CpNAO5-pJ9uB3kR^#CZ#@ zPQEg?yu}~*fPqCq@P=)-?vt@};d@(DKDf!+mS%NznSOLpPSf}Mrup>1SeSk2D^2+C z`st`?3nFdAeyf;w44OH{cD=c=_zW6qrvUW%%}%lY8f^Q1N&3Tl1881d;?b$cTX^8f4psh}O)pTS&vwGen1u z2_NfLcxyhVB_4CGk8o^MFbiy)a7}E$#sc+?8Z;!AEju}%7PpG6-z;;ttH&Sbba=ao+b-CfU z)I*a4g-$gi>0tS7$S>&ut;`?j7Mnn4UBVa={6>F1IcxqBSKWR@V9mR^)48q!gJWf= zWpg%BbreT?v`kMJ;>*jVu4cG1r}46oKk?MPf8(io|Ho4G|Bj{W{~b%${yLYh{B|P~R${m#zY%vWX z(OPjPuq3RZG!{c3I$r=vwkOuVC53hBK@xm3v?4P zZOkw;(oTgay|i{>2C6QFm+BFAtJj!AOl&&!;Jbe~zh=8ErP}%^XdY+%EfeDq zc(!SjqG-or&C|G9@aTzl0oep|rw`jHd4DY3*8lZe0&m7?yADv6pB%_-{9L0}3gAc( zCZL8)9IGmNArRYaELOy~D58kU9q8%yf>QI1xu%`c-~a#uBme*a002gS03BEb<7rL6 zA&OBiV!M$<88}0B!5PjMz2<4_%+uGIr$I51k&%&+k&%&+k&%&+k&%&+k&%&+k&%&+ zk&%&+k&%&+k&%m$*^>=%^QWv%F!2aL03WOkK(1^otE!^_WCWBhYn%b6F=iKTI{o8O zFucmDQ_WD8+GQlXMM9%ZsSv;b000Eqf)f5}Zi{IbBKY#}Tvlfxb|R8reV-cjDS6ST zuQ9=ScLo55a@>5|K2DN6C26TO1Q#2!ctt2|*D?)qeGA}!IT{_^@cf$xzGEWu$GN}! z3ZNG|ns)REOFHKNo*-|K(X(>ZU9}%~gJ|NdxvsNS&s`A^ss%45G2`r_XG;77GOrfc{<3z+~P-HE7~p@>7!imviwmxg!YtGT;a&lz77OX(%u@xRu{ zdYc1z#4!*(Udrk5!JDpw7R5k<*wXv`dozIE2j_U$jh@a@ODhV+2INIn7=;;$a&LzP zR#whc*MqUKunCOX0_4`QdwTr@vn+k{gZZw_#9i@bd`9kHfv|2BX`2rMR!^D6l?oOE z#TVVOi`A}Y96ebO$YE=fwU8ZlgP_kO$W0iS-~)?@>d_?3ne@n8JCzz=zz_D1-3*O0 zn^!j)C$}9b$UtMpFp4L4LbpLp<6id62)98lPk!%?L!i^h{)ZMJ8-=jwV|AUr>-Z-C z{eqSvTDV4=y?kKn;}8ZLrzq*E-YsstdrMUhRaohs7yguSbP013M+C*GHiZ6CuK$$<4-H%@|wDd}%O z-2cJg6KZ$a`zL*28%4+5N~TrchefCYV~@c$6+-0rNN1JYEq)IJ)#8+Vu(y_V0F1(@ z?97;F!Eg^GT{#Kv?hubTLra0dndB42>& + +The versions and the security update in the screenshot are illustrative demo data. + +## Who sees the alerts + +Staff with **Plugins → View installed plugins and their versions** (`plugins.can_view`) see all three. Everyone else sees none of them, and their browser never asks for the data. Running a check still needs `plugins.can_check_updates` and stays a button on the Plugins page. + +## The count next to System → Plugins + +A number next to **System → Plugins** tells you how many packages have a newer release on their channel. Core counts as one package and every plugin counts once, even when its API and web app are registered separately. It is the same number of _Update available_ rows the Plugins page shows. + +- The count only appears when it is above zero. While the data loads, or if it cannot be loaded, the menu looks exactly as before. +- Security updates do not add to the count. They have their own banner. +- When the **System** group is folded, the count moves to the **System** item. In the collapsed icon sidebar a small dot marks the icon, and the count sits next to **Plugins** in the menu that opens. + +## The security banner + +When a security update affects something you run, a red line appears at the top of every AdminCP page: + +> Security updates affect 2 components. **Review updates** + +**Review updates** opens System → Plugins, where each affected row names the security update and its fix. Each component counts once, however many security updates match it. The banner has no close button on purpose: it disappears on its own once no evaluated finding applies to what you run - after you upgrade and redeploy, or after a check finds the publisher changed the affected range. + +A publisher that cannot be reached never clears a warning. VitNode keeps the last known findings and adds _Based on the last known results_ to the banner, so an outage at the publisher never looks like a fix. + +## The System updates widget + +The **System updates** widget is on the dashboard by default for staff who can view plugins. You can remove it or add it back from **Edit layout**, like any other widget. It shows: + +| Field | Meaning | +| ---------------------------- | ------------------------------------------------------------------------------------------------ | +| VitNode core | The core version you run, and the newer core release on your channel when one is known. | +| Plugin updates | Plugins with a newer release on their channel. Core is not included here. | +| Affected by security updates | Packages, core included, that a security update affects. | + +Before anything was checked, the numbers show a dash and _Unknown_ instead of `0`. _Everything is up to date._ only appears when every installed package was checked, none has an update or a security warning, and no check failed. Otherwise the widget says how many packages could not be confirmed, for example because they declare no update source. + +### Freshness labels + +The first line of the widget says how far you can trust the numbers: + +| Label | What it means | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Every update source checked successfully 3 hours ago | The oldest stored result is that recent, so every package was checked at least that recently. | +| Checking for updates now... | A check is running. The widget, the count and the banner refresh every 15 seconds until it finishes. | +| The check 5 minutes ago failed for 1 package | A source could not be read. The numbers include the last known results for it. | +| Not checked for this deployment yet | You redeployed, upgraded core or changed channel. Results are recomputed from the stored manifests until the next check. | +| Not checked yet | No check has run. The daily check runs overnight, or press **Check for updates** on the Plugins page. | +| Update checks are turned off for this installation. | The API config sets `updates.enabled` to `false`. | + +The time is the **oldest** successful result, not the newest. One package checked a minute ago does not prove the others were. + +## When update checks are turned off + +With `updates: { enabled: false }` in the API config, nothing is checked. The navigation count and the banner stay hidden, and the widget only says that checks are turned off. Nothing stored earlier is shown as current. + +## How fresh the alerts are + +The alerts share one request per browser tab. It is refreshed when it is older than a minute and you come back to the tab, after **Check for updates** finishes, and every 15 seconds while a check is running. There is no other polling. + +## Check the result + +Press **Check for updates** on **System → Plugins**. When it finishes, the count next to **Plugins**, the banner and the widget update together without reloading the page. diff --git a/apps/web/content/docs/dev/updates/index.mdx b/apps/web/content/docs/dev/updates/index.mdx index c3bac834ac..8484475f62 100644 --- a/apps/web/content/docs/dev/updates/index.mdx +++ b/apps/web/content/docs/dev/updates/index.mdx @@ -4,7 +4,7 @@ description: How VitNode tells administrators about new versions and security up icon: RefreshCw --- -VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**. +VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**, as a count next to that menu item, as a security banner across the AdminCP and in the **System updates** dashboard widget. Each package says where its **update manifest** lives - a public JSON file on GitHub Raw or any other HTTPS host. VitNode reads those files from the server. It does not ask npm, and it does not install, download or change anything. @@ -13,6 +13,8 @@ Each package says where its **update manifest** lives - a public JSON file on Gi | You want to... | Read | | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | See updates and security warnings, run a check, choose a channel | [Check for updates in the AdminCP](/docs/dev/updates/admin) | +| Understand the update count, security banner and dashboard widget | [Update alerts in the AdminCP](/docs/dev/updates/alerts) | +| Learn how checks stay exclusive across API instances | [How update checks share one lease](/docs/dev/updates/lease) | | Make your own plugin report updates | [Publish updates for your plugin](/docs/dev/updates/plugin-authors) | | Look up every field of the JSON format | [Update manifest reference](/docs/dev/updates/manifest) | | Warn installations about a vulnerability | [Publish a security update](/docs/dev/updates/security-updates) | diff --git a/apps/web/content/docs/dev/updates/lease.mdx b/apps/web/content/docs/dev/updates/lease.mdx new file mode 100644 index 0000000000..5e4c3af111 --- /dev/null +++ b/apps/web/content/docs/dev/updates/lease.mdx @@ -0,0 +1,61 @@ +--- +title: How update checks share one lease +description: How VitNode keeps the daily and manual update checks exclusive across API instances with a renewable database lease, cancels a check that loses it, and fences every write so a stale owner cannot overwrite newer results. +icon: Lock +--- + +Every update check - the daily cron job and the **Check for updates** button - runs through one function, `runLockedUpdateCheck` in `@vitnode/core`. It takes a database lease first, so only one check runs at a time across all API instances. This page explains how that lease is kept, what happens when it is lost, and why an old owner can never write after a new one took over. + +## The lease lifecycle + +The lease is the single row `check` in `core_update_lock`. A check always goes through these steps, in order: + +1. **Acquire** the lease for 5 minutes. A manual check must also be past the two-minute cooldown, stamped in the same transaction. +2. **Start the heartbeat**, which renews the lease about once a minute. +3. **Run the check.** +4. **Stop the heartbeat** and wait for a renewal that is still on its way. +5. **Release** the lease, but only if it still belongs to this run. + +The heartbeat never overlaps itself: the next renewal is scheduled only after the previous one answered. A renewal only succeeds for the current owner while its lease has not expired, so an owner that let its lease run out cannot bring it back. + +All expiry decisions use the database clock (`clock_timestamp()` in UTC), never the API server's. Two instances with skewed clocks still agree on who holds the lease. + +## Recovery after a crash + +An instance that dies mid-check stops renewing. Its lease runs out at most five minutes after its last renewal, and the next daily run or manual check takes it over. The crashed owner cannot release, renew or write after that, even if it comes back to life. + +## Cancellation after the lease is lost + +The heartbeat aborts the check's `AbortSignal` with an `UpdateCheckLeaseLostError` when ownership cannot be confirmed: + +- a renewal answers that the lease is gone, +- a renewal fails, for example because the database is unreachable, +- or no renewal confirmed the lease before it could have expired. + +The signal reaches `fetchUpdateSource`. Requests in flight are aborted, queued sources never start, and nothing more is evaluated or stored. A cancelled request is not a publisher failure: the source keeps its last valid document and no error is recorded for it. Every started request is awaited before the check returns, and the heartbeat timers are cleared. + +`runLockedUpdateCheck` then returns `{ status: "lease_lost" }`. The cron job logs a warning, and `POST /api/@vitnode/core/admin/plugins/updates/check` answers `409` with `"status": "interrupted"`. + +## Fenced writes + +Renewing alone is not enough. A process paused by a long garbage collection or a slow network can wake up after its lease expired and another instance took over. Every write a check makes therefore goes through `withUpdateCheckLease`: + +```ts title="packages/vitnode/src/api/lib/updates/check.ts" +await withUpdateCheckLease(db, lease.owner, async tx => { + // write the source document and the results computed from it +}); +``` + +It opens a short transaction, takes `pg_advisory_xact_lock` on the lease name, locks the lease row with `SELECT ... FOR UPDATE`, and checks that the owner matches and the lease has not expired by the database clock. Only then does it run the writes, and it commits while still holding both locks. Otherwise it throws `UpdateCheckLeaseLostError` and writes nothing. + +Acquire, renew and release take the same two locks. So a new owner cannot take the lease between an old owner's ownership check and its commit: the takeover waits for that transaction, and the old owner's next write sees the new owner and is refused. This covers the source documents and HTTP validators, the results, recorded failures and the clean-up of removed plugins. + +The row lock also holds off an instance still running the previous release during a rolling deploy, since its lease statement locks the same row. + + + `withUpdateCheckLease` holds the lease lock until the callback returns. Fetch first, then write. A check writes one transaction per source - the source and its results commit together - and one more for the clean-up. + + +## The summary behind the AdminCP alerts + +`GET /api/@vitnode/core/admin/plugins/updates/summary` feeds the [navigation count, security banner and dashboard widget](/docs/dev/updates/alerts). It needs `plugins.can_view` and reads stored results only - the same `loadUpdatesOverview` the catalog uses, condensed by `summarizeUpdates`. Core and each plugin count as one component. `oldestSuccessAt` is the oldest successful result among packages with an update source, and `null` while any of them has none. The web app reads it through one shared query, `useAdminUpdateSummary`. diff --git a/apps/web/content/docs/dev/updates/meta.json b/apps/web/content/docs/dev/updates/meta.json index 122df555f3..7273704c96 100644 --- a/apps/web/content/docs/dev/updates/meta.json +++ b/apps/web/content/docs/dev/updates/meta.json @@ -5,10 +5,13 @@ "pages": [ "index", "admin", + "alerts", "---Publish updates---", "plugin-authors", "manifest", "security-updates", - "official-releases" + "official-releases", + "---How it works---", + "lease" ] } diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 5d3b512a30..af68955b77 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -220,7 +220,35 @@ "width": "Szerokość" }, "status_clean": "Brak zmian", - "status_unsaved": "Niezapisane zmiany" + "status_unsaved": "Niezapisane zmiany", + "system-updates": { + "title": "Aktualizacje systemu", + "desc": "Wersja rdzenia, dostępne aktualizacje i ostrzeżenia bezpieczeństwa z zapisanych sprawdzeń aktualizacji.", + "core": { + "label": "Rdzeń VitNode", + "unknown": "Nowsza wersja nieznana", + "none": "Brak znanej nowszej wersji", + "available": "Dostępna wersja {version}" + }, + "plugin_updates": "Aktualizacje wtyczek", + "affected": "Objęte aktualizacjami bezpieczeństwa", + "unknown": "Nieznane", + "up_to_date": "Wszystko jest aktualne.", + "unconfirmed": "Nie udało się potwierdzić aktualności {count, plural, one {# pakietu} other {# pakietów}} - szczegóły na stronie Wtyczki.", + "load_error": "Nie udało się wczytać podsumowania aktualizacji. Strona Wtyczki nadal pokazuje wszystkie wyniki.", + "view_plugins": "Zobacz wtyczki", + "freshness": { + "disabled": "Sprawdzanie aktualizacji jest wyłączone w tej instalacji.", + "running": "Trwa sprawdzanie aktualizacji...", + "not_checked": "Jeszcze nie sprawdzono. Codzienne sprawdzanie odbywa się w nocy; możesz też uruchomić je na stronie Wtyczki.", + "unavailable": "Żaden zainstalowany pakiet nie deklaruje źródła aktualizacji.", + "failed": "Ostatnie sprawdzenie nie powiodło się dla {count, plural, one {# pakietu} other {# pakietów}}. Pokazujemy ostatnie znane wyniki.", + "failed_at": "Sprawdzenie nie powiodło się dla {count, plural, one {# pakietu} other {# pakietów}}. Pokazujemy ostatnie znane wyniki.", + "stale": "Jeszcze nie sprawdzono dla tego wdrożenia - wyniki są przeliczane z zapisanych danych wydawców.", + "incomplete": "Jeszcze nie sprawdzono: {count, plural, one {# pakiet} few {# pakiety} other {# pakietów}}.", + "checked": "Wszystkie źródła aktualizacji sprawdzono pomyślnie " + } + } } }, "debug": { @@ -459,6 +487,14 @@ "title": "Zaloguj się ponownie", "desc": "Wszystkie karty panelu administracyjnego zostały zamknięte, więc dla bezpieczeństwa wylogowaliśmy Cię." } + }, + "updates": { + "nav_badge": "{count, plural, one {# dostępna aktualizacja} few {# dostępne aktualizacje} other {# dostępnych aktualizacji}}", + "security_banner": { + "title": "Aktualizacje bezpieczeństwa dotyczą {count, plural, one {# komponentu} other {# komponentów}}.", + "last_known": "Na podstawie ostatnich znanych wyników - najnowsze sprawdzenie nie mogło ich potwierdzić.", + "review": "Przejrzyj aktualizacje" + } } }, "navigation": { @@ -1045,6 +1081,10 @@ "error": { "title": "Nie udało się rozpocząć sprawdzania", "desc": "API nie odpowiedziało. Spróbuj ponownie za chwilę." + }, + "interrupted": { + "title": "Sprawdzanie zatrzymało się przed końcem", + "desc": "Ten serwer nie mógł potwierdzić, że nadal ma blokadę sprawdzania aktualizacji, więc przerwał pracę, zamiast ryzykować kolizję z innym sprawdzaniem. Zapisane wcześniej wyniki zostają - spróbuj ponownie za chwilę." } }, "status": { diff --git a/packages/vitnode/src/api/lib/updates/check.ts b/packages/vitnode/src/api/lib/updates/check.ts index 7ae474cf2d..52093c8484 100644 --- a/packages/vitnode/src/api/lib/updates/check.ts +++ b/packages/vitnode/src/api/lib/updates/check.ts @@ -16,10 +16,14 @@ import type { import { evaluateUpdate } from "./evaluate"; import { fetchUpdateSource, UpdateSourceError } from "./fetch-source"; +import { startUpdateCheckHeartbeat } from "./heartbeat"; import { acquireUpdateCheckLease, releaseUpdateCheckLease, type UpdateCheckLease, + UpdateCheckLeaseLostError, + type UpdateCheckTx, + withUpdateCheckLease, } from "./lock"; type Db = ReturnType; @@ -30,7 +34,7 @@ const MAX_ERROR_DETAIL = 300; export type FetchUpdateSource = ( url: string, - options: Pick, + options: Pick, ) => Promise; export interface UpdateCheckSummary { @@ -68,32 +72,63 @@ const pick = ( schemaVersion: manifest.schemaVersion, }); -const mapLimited = async ( +/** + * At most `limit` at a time. The caller's signal, or the first failure, stops + * the rest: nothing new starts, running work sees its signal aborted, and every + * worker has settled before this returns or throws. + */ +const mapLimited = async ( items: readonly T[], limit: number, - run: (item: T) => Promise, -): Promise => { - const results: R[] = Array.from({ length: items.length }); + signal: AbortSignal, + run: (item: T, signal: AbortSignal) => Promise, +): Promise => { + const failed = new AbortController(); + const scoped = AbortSignal.any([signal, failed.signal]); let next = 0; const worker = async () => { while (next < items.length) { - const index = next++; - results[index] = await run(items[index]); + scoped.throwIfAborted(); + const item = items[next++]; + try { + await run(item, scoped); + } catch (error) { + failed.abort(error); + throw error; + } } }; - await Promise.all( + + const settled = await Promise.allSettled( Array.from({ length: Math.min(limit, items.length) }, worker), ); - - return results; + signal.throwIfAborted(); + const failure = settled.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failure) throw failure.reason; }; +/** Where a check runs and how it learns it lost the lease. */ +export interface UpdateCheckLeaseScope { + owner: string; + /** Aborted with an `UpdateCheckLeaseLostError` once the lease can no longer be confirmed. */ + signal: AbortSignal; +} + /** * The one check behind the daily cron job and the AdminCP button. Fetches every * distinct update source once, validates it, evaluates each component against * it and stores the outcome. A source that fails keeps its last valid copy - * and every finding computed from it - and only records the failure, so one * unreachable publisher neither blocks the others nor turns into "up to date". + * + * Every write goes through the lease fence in its own short transaction - one + * per source, holding the source and the results computed from it, then one for + * the clean-up - and never spans a network request. Once the lease is lost the + * check stops: requests in flight are aborted, queued ones never start, and + * nothing more is stored. A cancelled request is not a publisher failure, so + * the stored data stays exactly as the last committed write left it. */ export const checkForUpdates = async ({ channelOverride, @@ -101,6 +136,7 @@ export const checkForUpdates = async ({ coreVersion, db, fetchSource = async (url, options) => await fetchUpdateSource(url, options), + lease, now = new Date(), }: { channelOverride: null | UpdateChannel; @@ -108,21 +144,31 @@ export const checkForUpdates = async ({ coreVersion: null | string; db: Db; fetchSource?: FetchUpdateSource; + lease: UpdateCheckLeaseScope; now?: Date; }): Promise => { + const write = async ( + signal: AbortSignal, + run: (tx: UpdateCheckTx) => Promise, + ) => { + signal.throwIfAborted(); + await withUpdateCheckLease(db, lease.owner, run); + }; + const checked = components.filter( (component): component is UpdateComponent & { sourceUrl: string } => component.sourceUrl !== null, ); - const bySource = new Map(); + const bySource = new Map(); for (const component of checked) { bySource.set(component.sourceUrl, [ ...(bySource.get(component.sourceUrl) ?? []), - component.id, + component, ]); } const urls = [...bySource.keys()]; + lease.signal.throwIfAborted(); const stored = new Map( urls.length === 0 ? [] @@ -134,183 +180,209 @@ export const checkForUpdates = async ({ ).map(row => [row.url, row]), ); - const outcomes = new Map(); - await mapLimited(urls, SOURCE_CONCURRENCY, async url => { - const previous = stored.get(url); - const ids = bySource.get(url) ?? []; - // A 304 can only stand in for a copy that is stored and still covers every - // package asked about now; otherwise ask for the full document. - const reusable = - previous?.document !== null && - previous?.document !== undefined && - ids.every(id => Object.hasOwn(previous.document?.packages ?? {}, id)) - ? previous.document - : null; - - let outcome: SourceOutcome; - let validators: null | { - etag: null | string; - lastModified: null | string; - } = null; - try { - const response = await fetchSource(url, { - validators: - reusable === null || (!previous?.etag && !previous?.lastModified) - ? null - : { - etag: previous?.etag ?? null, - lastModified: previous?.lastModified ?? null, - }, - }); - - if (response.kind === "not_modified") { - outcome = - reusable === null - ? { - document: previous?.document ?? null, - error: { code: "http_error", detail: "unexpected 304" }, - } - : { document: reusable, error: null }; - validators = { - etag: previous?.etag ?? null, - lastModified: previous?.lastModified ?? null, - }; - } else { - const parsed = parseUpdateManifest(response.body); - if (parsed.ok) { - outcome = { - document: pick(parsed.manifest, ids), - error: null, - }; - validators = { - etag: response.etag, - lastModified: response.lastModified, - }; - } else { - outcome = { - document: previous?.document ?? null, - error: { - code: - parsed.reason === "unsupported_schema" - ? "unsupported_schema" - : "invalid_manifest", - detail: parsed.error.slice(0, MAX_ERROR_DETAIL), - }, - }; - } - } - } catch (error) { - outcome = { document: previous?.document ?? null, error: errorOf(error) }; - } - - outcomes.set(url, outcome); - - if (outcome.error === null) { - const values = { - document: outcome.document, - etag: validators?.etag ?? null, - lastAttemptAt: now, - lastError: null, - lastModified: validators?.lastModified ?? null, - lastSuccessAt: now, - }; - await db - .insert(core_update_sources) - .values({ url, ...values }) - .onConflictDoUpdate({ set: values, target: core_update_sources.url }); - } else { - await db - .insert(core_update_sources) - .values({ lastAttemptAt: now, lastError: outcome.error, url }) - .onConflictDoUpdate({ - set: { lastAttemptAt: now, lastError: outcome.error }, - target: core_update_sources.url, - }); - } - }); - const summary: UpdateCheckSummary = { securityUpdates: 0, components: checked.length, - failedSources: [...outcomes.values()].filter( - outcome => outcome.error !== null, - ).length, + failedSources: 0, sources: urls.length, updates: 0, }; - for (const component of checked) { - const outcome = outcomes.get(component.sourceUrl); - if (outcome === undefined) continue; - - if (outcome.error !== null) { - // The failure is recorded; what was found before - and the versions it - // was found for - stays exactly as it was. - await db - .insert(core_update_results) - .values({ - channel: null, - componentId: component.id, - coreVersion, - hasUnknownVersion: component.hasUnknownVersion, - lastAttemptAt: now, - lastError: outcome.error, - result: null, - sourceUrl: component.sourceUrl, - versions: component.versions, - }) - .onConflictDoUpdate({ - set: { lastAttemptAt: now, lastError: outcome.error }, - target: core_update_results.componentId, + await mapLimited( + urls, + SOURCE_CONCURRENCY, + lease.signal, + async (url, signal) => { + const previous = stored.get(url); + const members = bySource.get(url) ?? []; + const ids = members.map(component => component.id); + // A 304 can only stand in for a copy that is stored and still covers every + // package asked about now; otherwise ask for the full document. + const reusable = + previous?.document !== null && + previous?.document !== undefined && + ids.every(id => Object.hasOwn(previous.document?.packages ?? {}, id)) + ? previous.document + : null; + + let outcome: SourceOutcome; + let validators: null | { + etag: null | string; + lastModified: null | string; + } = null; + try { + const response = await fetchSource(url, { + signal, + validators: + reusable === null || (!previous?.etag && !previous?.lastModified) + ? null + : { + etag: previous?.etag ?? null, + lastModified: previous?.lastModified ?? null, + }, }); - continue; - } - const result = evaluateUpdate({ - channelOverride, - coreVersion, - entry: outcome.document.packages[component.id], - installed: component, - isCore: component.kind === "core", - }); - summary.securityUpdates += result.securityUpdates.length; - if (result.status === "update_available") summary.updates += 1; - - const values = { - channel: result.channel, - coreVersion, - hasUnknownVersion: component.hasUnknownVersion, - lastAttemptAt: now, - lastError: null, - lastSuccessAt: now, - result, - sourceUrl: component.sourceUrl, - versions: component.versions, - }; - await db - .insert(core_update_results) - .values({ componentId: component.id, ...values }) - .onConflictDoUpdate({ - set: values, - target: core_update_results.componentId, + if (response.kind === "not_modified") { + outcome = + reusable === null + ? { + document: previous?.document ?? null, + error: { code: "http_error", detail: "unexpected 304" }, + } + : { document: reusable, error: null }; + validators = { + etag: previous?.etag ?? null, + lastModified: previous?.lastModified ?? null, + }; + } else { + const parsed = parseUpdateManifest(response.body); + if (parsed.ok) { + outcome = { + document: pick(parsed.manifest, ids), + error: null, + }; + validators = { + etag: response.etag, + lastModified: response.lastModified, + }; + } else { + outcome = { + document: previous?.document ?? null, + error: { + code: + parsed.reason === "unsupported_schema" + ? "unsupported_schema" + : "invalid_manifest", + detail: parsed.error.slice(0, MAX_ERROR_DETAIL), + }, + }; + } + } + } catch (error) { + // Cancelled, not failed: nothing about this source is recorded. + signal.throwIfAborted(); + outcome = { + document: previous?.document ?? null, + error: errorOf(error), + }; + } + + signal.throwIfAborted(); + const { document, error } = outcome; + const evaluated = + error === null + ? members.map(component => ({ + component, + result: evaluateUpdate({ + channelOverride, + coreVersion, + entry: document.packages[component.id], + installed: component, + isCore: component.kind === "core", + }), + })) + : []; + + // The source and every result computed from it commit together, or not at all. + await write(signal, async tx => { + if (error === null) { + const values = { + document, + etag: validators?.etag ?? null, + lastAttemptAt: now, + lastError: null, + lastModified: validators?.lastModified ?? null, + lastSuccessAt: now, + }; + await tx + .insert(core_update_sources) + .values({ url, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_update_sources.url, + }); + } else { + await tx + .insert(core_update_sources) + .values({ lastAttemptAt: now, lastError: error, url }) + .onConflictDoUpdate({ + set: { lastAttemptAt: now, lastError: error }, + target: core_update_sources.url, + }); + + // The failure is recorded; what was found before - and the versions it + // was found for - stays exactly as it was. + for (const component of members) { + await tx + .insert(core_update_results) + .values({ + channel: null, + componentId: component.id, + coreVersion, + hasUnknownVersion: component.hasUnknownVersion, + lastAttemptAt: now, + lastError: error, + result: null, + sourceUrl: component.sourceUrl, + versions: component.versions, + }) + .onConflictDoUpdate({ + set: { lastAttemptAt: now, lastError: error }, + target: core_update_results.componentId, + }); + } + } + + for (const { component, result } of evaluated) { + const values = { + channel: result.channel, + coreVersion, + hasUnknownVersion: component.hasUnknownVersion, + lastAttemptAt: now, + lastError: null, + lastSuccessAt: now, + result, + sourceUrl: component.sourceUrl, + versions: component.versions, + }; + await tx + .insert(core_update_results) + .values({ componentId: component.id, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_update_results.componentId, + }); + } }); - } + + // Counted only once stored, never for work this run could not commit. + if (error !== null) summary.failedSources += 1; + for (const { result } of evaluated) { + summary.securityUpdates += result.securityUpdates.length; + if (result.status === "update_available") summary.updates += 1; + } + }, + ); // Removed plugins, and plugins that dropped their update source, leave no // result behind; neither does a source nothing points to any more. const checkedIds = checked.map(component => component.id); - await db - .delete(core_update_results) - .where( - checkedIds.length === 0 - ? undefined - : notInArray(core_update_results.componentId, checkedIds), - ); - await db - .delete(core_update_sources) - .where( - urls.length === 0 ? undefined : notInArray(core_update_sources.url, urls), - ); + await write(lease.signal, async tx => { + await tx + .delete(core_update_results) + .where( + checkedIds.length === 0 + ? undefined + : notInArray(core_update_results.componentId, checkedIds), + ); + await tx + .delete(core_update_sources) + .where( + urls.length === 0 + ? undefined + : notInArray(core_update_sources.url, urls), + ); + }); return summary; }; @@ -321,19 +393,23 @@ export type LockedUpdateCheck = retryAfterSeconds: number; status: "busy"; } - | { status: "completed"; summary: UpdateCheckSummary }; + | { status: "completed"; summary: UpdateCheckSummary } + /** The lease could not be confirmed mid-check, so the check stopped. What it committed before stays; nothing after. */ + | { status: "lease_lost" }; -/** Takes the shared lease, runs the check and always gives the lease back. */ +/** + * The whole lease lifecycle, the same for the cron job and the button: take + * the lease, keep renewing it, check, stop the heartbeat and wait for it, then + * give the lease back - which only happens while it is still this run's. + */ export const runLockedUpdateCheck = async ({ manual, ...options -}: Parameters[0] & { +}: Omit[0], "lease"> & { manual: boolean; }): Promise => { - const lease = await acquireUpdateCheckLease(options.db, { - manual, - now: options.now, - }); + const confirmedAt = Date.now(); + const lease = await acquireUpdateCheckLease(options.db, { manual }); if (!lease.acquired) { return { reason: lease.reason, @@ -342,9 +418,28 @@ export const runLockedUpdateCheck = async ({ }; } + const heartbeat = startUpdateCheckHeartbeat({ + confirmedAt, + db: options.db, + owner: lease.owner, + }); try { - return { status: "completed", summary: await checkForUpdates(options) }; + return { + status: "completed", + summary: await checkForUpdates({ + ...options, + lease: { owner: lease.owner, signal: heartbeat.signal }, + }), + }; + } catch (error) { + if (error instanceof UpdateCheckLeaseLostError) { + return { status: "lease_lost" }; + } + throw error; } finally { - await releaseUpdateCheckLease(options.db, lease.owner); + await heartbeat.stop(); + // A failure here must not hide the check's own outcome; an unreleased lease + // simply runs out within UPDATE_CHECK_LEASE_MS. + await releaseUpdateCheckLease(options.db, lease.owner).catch(() => false); } }; diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.ts b/packages/vitnode/src/api/lib/updates/fetch-source.ts index ac0573aa01..73e6978f6a 100644 --- a/packages/vitnode/src/api/lib/updates/fetch-source.ts +++ b/packages/vitnode/src/api/lib/updates/fetch-source.ts @@ -183,7 +183,11 @@ const readCapped = async ( const transportFailure = ( error: unknown, signal: AbortSignal, -): UpdateSourceError => { + cancel: AbortSignal | undefined, +): unknown => { + // The caller gave up on the check - not something the publisher did, so the + // caller's own reason goes back instead of a failure it would record. + if (cancel?.aborted) return cancel.reason; if (error instanceof UpdateSourceError) return error; if (signal.aborted) return new UpdateSourceError("timeout"); if ( @@ -211,6 +215,8 @@ export interface FetchUpdateSourceOptions { maxBytes?: number; maxRedirects?: number; resolveHost?: ResolveUpdateHost; + /** Aborting it stops the request and rejects with the signal's own reason, never an {@link UpdateSourceError}. */ + signal?: AbortSignal; timeoutMs?: number; transport?: UpdateSourceTransport; /** Sent as `If-None-Match` / `If-Modified-Since`. Only pass them when a validated copy is stored. */ @@ -223,12 +229,16 @@ export const fetchUpdateSource = async ( maxBytes = UPDATE_SOURCE_MAX_BYTES, maxRedirects = DEFAULT_MAX_REDIRECTS, resolveHost = resolveHostAddresses, + signal: cancel, timeoutMs = DEFAULT_TIMEOUT_MS, transport = httpsUpdateSourceTransport, validators = null, }: FetchUpdateSourceOptions = {}, ): Promise => { - const signal = AbortSignal.timeout(timeoutMs); + cancel?.throwIfAborted(); + const timeout = AbortSignal.timeout(timeoutMs); + const signal = + cancel === undefined ? timeout : AbortSignal.any([timeout, cancel]); const headers: Record = { accept: "application/json", "user-agent": "VitNode update check", @@ -242,7 +252,7 @@ export const fetchUpdateSource = async ( for (let hop = 0; hop <= maxRedirects; hop++) { await assertFetchableUrl(url, resolveHost); - if (signal.aborted) throw new UpdateSourceError("timeout"); + if (signal.aborted) throw transportFailure(null, signal, cancel); let response: UpdateSourceTransportResponse; try { @@ -252,7 +262,7 @@ export const fetchUpdateSource = async ( signal, }); } catch (error) { - throw transportFailure(error, signal); + throw transportFailure(error, signal, cancel); } if (response.status === 304) { @@ -291,7 +301,7 @@ export const fetchUpdateSource = async ( bytes = await readCapped(response.body, maxBytes); } catch (error) { response.destroy(); - throw transportFailure(error, signal); + throw transportFailure(error, signal, cancel); } let body: unknown; diff --git a/packages/vitnode/src/api/lib/updates/heartbeat.ts b/packages/vitnode/src/api/lib/updates/heartbeat.ts new file mode 100644 index 0000000000..aafaf58ff8 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/heartbeat.ts @@ -0,0 +1,106 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { + renewUpdateCheckLease, + UPDATE_CHECK_LEASE_MS, + UPDATE_CHECK_RENEW_INTERVAL_MS, + UpdateCheckLeaseLostError, +} from "./lock"; + +type Db = ReturnType; + +export interface UpdateCheckHeartbeat { + /** Aborted with an {@link UpdateCheckLeaseLostError} once ownership can no longer be confirmed. */ + signal: AbortSignal; + /** Stops renewing and waits for a renewal already on its way. Never throws. */ + stop: () => Promise; +} + +/** + * Renews the lease about once a minute while a check runs. One renewal at a + * time: the next is only scheduled after the previous one answered. A renewal + * that says `lost`, fails, or does not answer before the lease could have run + * out stops the check - it never keeps going on ownership it cannot confirm. + */ +export const startUpdateCheckHeartbeat = ({ + confirmedAt, + db, + intervalMs = UPDATE_CHECK_RENEW_INTERVAL_MS, + leaseMs = UPDATE_CHECK_LEASE_MS, + owner, + renew = renewUpdateCheckLease, +}: { + /** `Date.now()` from just before the lease was taken: it runs at least `leaseMs` from then. */ + confirmedAt: number; + db: Db; + intervalMs?: number; + leaseMs?: number; + owner: string; + renew?: typeof renewUpdateCheckLease; +}): UpdateCheckHeartbeat => { + const controller = new AbortController(); + let stopped = false; + let beatTimer: ReturnType | undefined; + let deadlineTimer: ReturnType | undefined; + let pending: Promise = Promise.resolve(); + + const lose = (cause?: unknown) => { + clearTimeout(beatTimer); + clearTimeout(deadlineTimer); + if (controller.signal.aborted) return; + controller.abort( + new UpdateCheckLeaseLostError( + cause === undefined ? undefined : { cause }, + ), + ); + }; + + // Measured on this instance's own clock from when the renewal was sent, which + // is never later than when the database stamped it. + const armDeadline = (from: number) => { + clearTimeout(deadlineTimer); + deadlineTimer = setTimeout( + () => lose(), + Math.max(0, from + leaseMs - Date.now()), + ); + }; + + const beat = async () => { + const sentAt = Date.now(); + try { + const renewal = await renew(db, owner, { leaseMs }); + if (renewal.status === "lost") { + lose(); + + return; + } + } catch (error) { + lose(error); + + return; + } + if (stopped || controller.signal.aborted) return; + + armDeadline(sentAt); + schedule(); + }; + + const schedule = () => { + beatTimer = setTimeout(() => { + pending = beat(); + }, intervalMs); + }; + + armDeadline(confirmedAt); + schedule(); + + return { + signal: controller.signal, + stop: async () => { + stopped = true; + clearTimeout(beatTimer); + clearTimeout(deadlineTimer); + await pending; + }, + }; +}; diff --git a/packages/vitnode/src/api/lib/updates/lock.ts b/packages/vitnode/src/api/lib/updates/lock.ts index 83fe836965..7c4ca2bbab 100644 --- a/packages/vitnode/src/api/lib/updates/lock.ts +++ b/packages/vitnode/src/api/lib/updates/lock.ts @@ -1,34 +1,103 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { and, eq, isNull, lte, or, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { core_update_lock } from "@/database/updates"; type Db = ReturnType; +export type UpdateCheckTx = Parameters[0]>[0]; + const LOCK_NAME = "check"; -/** Longer than a check can take: every source has a 10 s timeout and they run four at a time. */ +const ADVISORY_KEY = `core_update_lock:${LOCK_NAME}`; + +/** Every source has a 10 s timeout and they run four at a time; the heartbeat keeps a longer check alive. */ export const UPDATE_CHECK_LEASE_MS = 5 * 60_000; +export const UPDATE_CHECK_RENEW_INTERVAL_MS = 60_000; + export const MANUAL_UPDATE_CHECK_COOLDOWN_MS = 2 * 60_000; +/** This run can no longer prove it holds the lease, so it must stop and write nothing more. */ +export class UpdateCheckLeaseLostError extends Error { + constructor(options?: ErrorOptions) { + super("This instance no longer holds the update check lease", options); + this.name = "UpdateCheckLeaseLostError"; + } +} + export type UpdateCheckLease = | { acquired: false; reason: "cooldown"; retryAfterSeconds: number } | { acquired: false; reason: "running"; retryAfterSeconds: number } | { acquired: true; owner: string }; +export type UpdateCheckLeaseRenewal = + | { expiresAt: Date; status: "renewed" } + | { status: "lost" }; + +/** + * The database's clock, in UTC like every stored timestamp. Every lease + * decision reads it, so API instances whose own clocks disagree still agree on + * who holds the lease and when it runs out. + */ +const databaseNow = () => + sql`timezone('utc', clock_timestamp())`.mapWith(core_update_lock.expiresAt); + const secondsUntil = (date: Date | null, now: Date): number => date === null ? 1 : Math.max(1, Math.ceil((date.getTime() - now.getTime()) / 1000)); /** - * One statement, so two instances racing for the lease cannot both win: the - * insert creates the row, and the conflict branch only takes it over when no - * one holds it or the holder's lease ran out. A manual check also has to be - * past the cooldown, and stamps it in the same statement. + * The one locking model every lease transaction follows - taking, renewing, + * giving back, and every write a check makes. The advisory lock serialises + * them, including the first insert when no row exists yet; the row lock also + * holds off a writer that does not take the advisory lock, such as an instance + * still running the previous release during a rolling deploy. Both are held + * until the transaction commits. + */ +const lockLease = async (tx: UpdateCheckTx, { create = false } = {}) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${ADVISORY_KEY}))`, + ); + if (create) { + await tx + .insert(core_update_lock) + .values({ name: LOCK_NAME }) + .onConflictDoNothing({ target: core_update_lock.name }); + } + + const [row] = await tx + .select({ + expiresAt: core_update_lock.expiresAt, + lastManualAt: core_update_lock.lastManualAt, + now: databaseNow(), + owner: core_update_lock.owner, + }) + .from(core_update_lock) + .where(eq(core_update_lock.name, LOCK_NAME)) + .for("update"); + + return row ?? null; +}; + +type LeaseRow = Awaited>; + +const isHeld = (row: LeaseRow): boolean => + row?.owner !== null && + row?.owner !== undefined && + row.expiresAt !== null && + row.expiresAt > row.now; + +const isHeldBy = (row: LeaseRow, owner: string): boolean => + isHeld(row) && row?.owner === owner; + +/** + * Two instances racing for the lease cannot both win: the lease lock orders + * them, and the second sees the first one's lease. A manual check also has to + * be past the cooldown, and stamps it in the same transaction. */ export const acquireUpdateCheckLease = async ( db: Db, @@ -36,96 +105,121 @@ export const acquireUpdateCheckLease = async ( cooldownMs = MANUAL_UPDATE_CHECK_COOLDOWN_MS, leaseMs = UPDATE_CHECK_LEASE_MS, manual, - now = new Date(), owner = randomUUID(), }: { cooldownMs?: number; leaseMs?: number; manual: boolean; - now?: Date; owner?: string; }, -): Promise => { - const expiresAt = new Date(now.getTime() + leaseMs); - const free = or( - isNull(core_update_lock.owner), - isNull(core_update_lock.expiresAt), - lte(core_update_lock.expiresAt, now), - ); - const cooledDown = or( - isNull(core_update_lock.lastManualAt), - lte(core_update_lock.lastManualAt, new Date(now.getTime() - cooldownMs)), - ); - - const [taken] = await db - .insert(core_update_lock) - .values({ - expiresAt, - lastManualAt: manual ? now : null, - name: LOCK_NAME, - owner, - }) - .onConflictDoUpdate({ - set: { - expiresAt, - lastManualAt: manual ? now : sql`${core_update_lock.lastManualAt}`, +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx, { create: true }); + if (row === null) throw new Error("The update check lease row is missing"); + const { now } = row; + + if (isHeld(row)) { + return { + acquired: false, + reason: "running", + retryAfterSeconds: secondsUntil(row.expiresAt, now), + }; + } + + const cooledDownAt = + row.lastManualAt === null + ? null + : new Date(row.lastManualAt.getTime() + cooldownMs); + if (manual && cooledDownAt !== null && cooledDownAt > now) { + return { + acquired: false, + reason: "cooldown", + retryAfterSeconds: secondsUntil(cooledDownAt, now), + }; + } + + await tx + .update(core_update_lock) + .set({ + expiresAt: new Date(now.getTime() + leaseMs), owner, - }, - setWhere: manual ? and(free, cooledDown) : free, - target: core_update_lock.name, - }) - .returning({ owner: core_update_lock.owner }); + ...(manual ? { lastManualAt: now } : {}), + }) + .where(eq(core_update_lock.name, LOCK_NAME)); - if (taken?.owner === owner) return { acquired: true, owner }; + return { acquired: true, owner }; + }); - const [row] = await db - .select() - .from(core_update_lock) - .where(eq(core_update_lock.name, LOCK_NAME)); +/** + * Pushes the lease out by another `leaseMs` from now - only for the owner, and + * only while its lease has not run out. An owner whose lease expired gets + * `lost`, even when nobody took it over yet: it cannot know what happened in + * between. + */ +export const renewUpdateCheckLease = async ( + db: Db, + owner: string, + { leaseMs = UPDATE_CHECK_LEASE_MS }: { leaseMs?: number } = {}, +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx); + if (row === null || !isHeldBy(row, owner)) return { status: "lost" }; + + const expiresAt = new Date(row.now.getTime() + leaseMs); + await tx + .update(core_update_lock) + .set({ expiresAt }) + .where( + and( + eq(core_update_lock.name, LOCK_NAME), + eq(core_update_lock.owner, owner), + ), + ); + + return { expiresAt, status: "renewed" }; + }); - if ( - row?.owner !== null && - row?.owner !== undefined && - row.expiresAt !== null && - row.expiresAt > now - ) { - return { - acquired: false, - reason: "running", - retryAfterSeconds: secondsUntil(row.expiresAt, now), - }; - } +/** + * The write fence. Runs `write` in one short transaction that first takes the + * lease lock and confirms `owner` still holds an unexpired lease, by the + * database's clock; otherwise it throws {@link UpdateCheckLeaseLostError} and + * writes nothing. Because taking the lease needs the same lock, no other + * instance can take it over between the check and the commit. Never call it + * around a network request - the lock is held until `write` returns. + */ +export const withUpdateCheckLease = async ( + db: Db, + owner: string, + write: (tx: UpdateCheckTx) => Promise, +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx); + if (!isHeldBy(row, owner)) throw new UpdateCheckLeaseLostError(); - return { - acquired: false, - reason: "cooldown", - retryAfterSeconds: secondsUntil( - row?.lastManualAt - ? new Date(row.lastManualAt.getTime() + cooldownMs) - : null, - now, - ), - }; -}; + return await write(tx); + }); /** Only the instance that holds the lease can give it back. */ export const releaseUpdateCheckLease = async ( db: Db, owner: string, -): Promise => { - const released = await db - .update(core_update_lock) - .set({ expiresAt: null, owner: null }) - .where( - and( - eq(core_update_lock.name, LOCK_NAME), - eq(core_update_lock.owner, owner), - ), - ) - .returning({ name: core_update_lock.name }); - - return released.length > 0; -}; +): Promise => + await db.transaction(async tx => { + const row = await lockLease(tx); + if (row?.owner !== owner) return false; + + await tx + .update(core_update_lock) + .set({ expiresAt: null, owner: null }) + .where( + and( + eq(core_update_lock.name, LOCK_NAME), + eq(core_update_lock.owner, owner), + ), + ); + + return true; + }); export interface UpdateCheckLockState { lastManualAt: Date | null; @@ -134,19 +228,19 @@ export interface UpdateCheckLockState { export const readUpdateCheckLock = async ( db: Db, - now: Date = new Date(), ): Promise => { const [row] = await db - .select() + .select({ + expiresAt: core_update_lock.expiresAt, + lastManualAt: core_update_lock.lastManualAt, + now: databaseNow(), + owner: core_update_lock.owner, + }) .from(core_update_lock) .where(eq(core_update_lock.name, LOCK_NAME)); return { lastManualAt: row?.lastManualAt ?? null, - running: - row?.owner !== null && - row?.owner !== undefined && - row.expiresAt !== null && - row.expiresAt > now, + running: isHeld(row ?? null), }; }; diff --git a/packages/vitnode/src/api/lib/updates/status.ts b/packages/vitnode/src/api/lib/updates/status.ts index c8b1ac1d5a..5a23cc7c5c 100644 --- a/packages/vitnode/src/api/lib/updates/status.ts +++ b/packages/vitnode/src/api/lib/updates/status.ts @@ -53,16 +53,14 @@ export const loadUpdatesOverview = async ({ coreVersion, db, enabled, - now = new Date(), }: { channelOverride: null | UpdateChannel; components: readonly UpdateComponent[]; coreVersion: null | string; db: Db; enabled: boolean; - now?: Date; }): Promise => { - const lock = await readUpdateCheckLock(db, now); + const lock = await readUpdateCheckLock(db); const unavailable = (component: UpdateComponent): ComponentUpdateState => ({ id: component.id, lastAttemptAt: null, @@ -149,3 +147,100 @@ export const loadUpdatesOverview = async ({ running: lock.running, }; }; + +/** + * The few numbers every AdminCP surface shares - the navigation count, the + * security banner, the dashboard widget - read off the same overview the + * catalog shows, so they cannot disagree with it. Core counts as one + * component and every plugin as one, whatever registrations it has. + */ +export interface UpdatesSummary { + /** Components (core and plugins) a security update affects, each counted once. */ + affectedComponents: number; + /** Components with a result - fresh, stale or kept from before a failed check. */ + checkedComponents: number; + core: { + /** The newer core release on its channel; `null` when there is none, or none is known. */ + latestVersion: null | string; + state: ComponentUpdateState["state"]; + }; + enabled: boolean; + /** Components whose last check failed. Their earlier result, if any, still counts. */ + failedComponents: number; + /** The newest check attempt of any component, failed or not. */ + lastAttemptAt: Date | null; + /** Components with an update source that no check has covered yet. */ + notCheckedComponents: number; + /** + * Every component with an update source has a result at least this recent: + * the oldest of their last successful checks. `null` while any has none. + */ + oldestSuccessAt: Date | null; + pluginUpdatesAvailable: number; + running: boolean; + /** Some result was not checked for what runs now and is recomputed from stored publisher data. */ + stale: boolean; + totalComponents: number; + /** Components not known to be up to date or outdated: no update source, not checked, or a status that cannot tell. */ + unconfirmedComponents: number; + /** Components with a newer release on their channel - core included. */ + updatesAvailable: number; +} + +const isUpdateAvailable = (component: ComponentUpdateState): boolean => + component.result?.status === "update_available"; + +export const summarizeUpdates = (overview: UpdatesOverview): UpdatesSummary => { + const { components } = overview; + const [core, ...plugins] = components; + const checkable = components.filter( + component => component.state !== "unavailable", + ); + const successes = checkable.map(component => component.lastSuccessAt); + const attempts = components.flatMap(component => + component.lastAttemptAt === null ? [] : [component.lastAttemptAt], + ); + + return { + affectedComponents: components.filter( + component => (component.result?.securityUpdates.length ?? 0) > 0, + ).length, + checkedComponents: components.filter( + component => component.state === "checked", + ).length, + core: { + latestVersion: + core !== undefined && isUpdateAvailable(core) + ? (core.result?.latest?.version ?? null) + : null, + state: core?.state ?? "unavailable", + }, + enabled: overview.enabled, + failedComponents: components.filter( + component => component.lastError !== null, + ).length, + lastAttemptAt: + attempts.length === 0 + ? null + : new Date(Math.max(...attempts.map(date => date.getTime()))), + notCheckedComponents: components.filter( + component => component.state === "not_checked", + ).length, + oldestSuccessAt: + successes.length === 0 || successes.includes(null) + ? null + : new Date( + Math.min(...successes.map(date => date?.getTime() ?? Infinity)), + ), + pluginUpdatesAvailable: plugins.filter(isUpdateAvailable).length, + running: overview.running, + stale: components.some(component => component.stale), + totalComponents: components.length, + unconfirmedComponents: components.filter( + component => + component.result?.status !== "up_to_date" && + component.result?.status !== "update_available", + ).length, + updatesAvailable: components.filter(isUpdateAvailable).length, + }; +}; diff --git a/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts index 837591c3d7..cfcf58730c 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@/api/builders"; import { checkPluginUpdatesAdminRoute } from "./routes/check-updates.route"; import { listPluginsAdminRoute } from "./routes/list.route"; +import { pluginUpdatesSummaryAdminRoute } from "./routes/updates-summary.route"; import { pluginUpdatesAdminRoute } from "./routes/updates.route"; export const pluginsAdminModule = buildModule({ @@ -9,6 +10,7 @@ export const pluginsAdminModule = buildModule({ routes: [ listPluginsAdminRoute, pluginUpdatesAdminRoute, + pluginUpdatesSummaryAdminRoute, checkPluginUpdatesAdminRoute, ], }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts index 0acc48b643..dafcfed323 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts @@ -4,7 +4,7 @@ import { buildRoute } from "@/api/builders"; import { runUpdateCheckFor } from "@/api/lib/updates/context"; const busySchema = z.object({ - status: z.enum(["cooldown", "disabled", "running"]), + status: z.enum(["cooldown", "disabled", "interrupted", "running"]), retryAfterSeconds: z.number().nullable(), }); @@ -37,7 +37,8 @@ export const checkPluginUpdatesAdminRoute = buildRoute({ 403: { description: "Access Denied" }, 409: { content: { "application/json": { schema: busySchema } }, - description: "A check is already running, or checks are turned off", + description: + "A check is already running, checks are turned off, or the check stopped because this instance lost its lease", }, 429: { content: { "application/json": { schema: busySchema } }, @@ -59,6 +60,13 @@ export const checkPluginUpdatesAdminRoute = buildRoute({ return c.json({ ...outcome.summary, status: "completed" as const }, 200); } + if (outcome.status === "lease_lost") { + return c.json( + { retryAfterSeconds: null, status: "interrupted" as const }, + 409, + ); + } + c.header("Retry-After", String(outcome.retryAfterSeconds)); return outcome.reason === "running" diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts new file mode 100644 index 0000000000..3c5eea2d89 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts @@ -0,0 +1,47 @@ +import { buildRoute } from "@/api/builders"; +import { + loadUpdatesOverview, + summarizeUpdates, +} from "@/api/lib/updates/status"; + +import { updatesSummarySchema } from "./updates.schema"; + +export const pluginUpdatesSummaryAdminRoute = buildRoute({ + adminStaffPermission: { module: "plugins", permission: "can_view" }, + route: { + method: "get", + description: + "Counts behind the AdminCP update badge, security banner and dashboard widget, from stored results only. Never contacts a publisher or starts a check.", + path: "/updates/summary", + responses: { + 200: { + content: { "application/json": { schema: updatesSummarySchema } }, + description: "Update summary", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const { channelOverride, components, coreVersion, enabled } = + c.get("core").updates; + const summary = summarizeUpdates( + await loadUpdatesOverview({ + channelOverride, + components, + coreVersion, + db: c.get("db"), + enabled, + }), + ); + + return c.json( + { + ...summary, + core: { ...summary.core, version: coreVersion }, + lastAttemptAt: summary.lastAttemptAt?.toISOString() ?? null, + oldestSuccessAt: summary.oldestSuccessAt?.toISOString() ?? null, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts index d92223c4f3..9999170909 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts @@ -69,3 +69,24 @@ export const serializeComponentUpdate = ( lastAttemptAt: iso(state.lastAttemptAt), lastSuccessAt: iso(state.lastSuccessAt), }); + +export const updatesSummarySchema = z.object({ + enabled: z.boolean(), + running: z.boolean(), + totalComponents: z.number(), + checkedComponents: z.number(), + updatesAvailable: z.number(), + pluginUpdatesAvailable: z.number(), + affectedComponents: z.number(), + unconfirmedComponents: z.number(), + notCheckedComponents: z.number(), + failedComponents: z.number(), + stale: z.boolean(), + oldestSuccessAt: z.string().nullable(), + lastAttemptAt: z.string().nullable(), + core: z.object({ + version: z.string().nullable(), + latestVersion: z.string().nullable(), + state: z.enum(["checked", "not_checked", "unavailable"]), + }), +}); diff --git a/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts index 96792fa90f..52c9f40e14 100644 --- a/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts +++ b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts @@ -13,6 +13,16 @@ export const checkUpdatesCron = buildCron({ const outcome = await runUpdateCheckFor(c, { manual: false }); + if (outcome.status === "lease_lost") { + await c + .get("log") + .warn( + "Update check stopped: this instance could not confirm it still holds the update check lease. Results stored before that are kept; the next run checks again.", + ); + + return; + } + if (outcome.status !== "completed") { // Someone pressed "Check for updates" a moment ago; that run covers this one. return; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 6d61cd5aa6..205785e3e4 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -2662,6 +2662,34 @@ "select": "Select {title}", "properties": { "title": "{title} properties" + }, + "system-updates": { + "title": "System updates", + "desc": "Core version, available updates and security warnings from the stored update checks.", + "core": { + "label": "VitNode core", + "unknown": "Newer release unknown", + "none": "No newer release known", + "available": "{version} available" + }, + "plugin_updates": "Plugin updates", + "affected": "Affected by security updates", + "unknown": "Unknown", + "up_to_date": "Everything is up to date.", + "unconfirmed": "{count, plural, one {# package} other {# packages}} could not be confirmed as up to date - the Plugins page says why.", + "load_error": "Could not load the update summary. The Plugins page still shows every result.", + "view_plugins": "View plugins", + "freshness": { + "disabled": "Update checks are turned off for this installation.", + "running": "Checking for updates now...", + "not_checked": "Not checked yet. The daily check runs overnight, or start one on the Plugins page.", + "unavailable": "No installed package declares an update source.", + "failed": "The last check failed for {count, plural, one {# package} other {# packages}}. Showing the last known results.", + "failed_at": "The check failed for {count, plural, one {# package} other {# packages}}. Showing the last known results.", + "stale": "Not checked for this deployment yet - results are recomputed from stored publisher data.", + "incomplete": "{count, plural, one {# package has} other {# packages have}} not been checked yet.", + "checked": "Every update source checked successfully " + } } } }, @@ -2721,6 +2749,14 @@ "title": "Please sign in again", "desc": "All AdminCP tabs were closed, so we signed you out to keep your account safe." } + }, + "updates": { + "nav_badge": "{count, plural, one {# update available} other {# updates available}}", + "security_banner": { + "title": "Security updates affect {count, plural, one {# component} other {# components}}.", + "last_known": "Based on the last known results - the latest check could not confirm them.", + "review": "Review updates" + } } }, "advanced": { @@ -3791,6 +3827,10 @@ "error": { "title": "The check could not be started", "desc": "The API did not answer. Try again in a moment." + }, + "interrupted": { + "title": "The check stopped before it finished", + "desc": "This server could not confirm it still holds the update check lease, so it stopped instead of risking a clash with another check. Results stored before that are kept - try again in a moment." } }, "status": { diff --git a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx index 323d7f850a..e84daf25cb 100644 --- a/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx +++ b/packages/vitnode/src/tanstack/admin/dashboard/widgets.tsx @@ -1,4 +1,4 @@ -import { NotebookPenIcon, SendIcon } from "lucide-react"; +import { NotebookPenIcon, RefreshCwIcon, SendIcon } from "lucide-react"; import type { AdminDashboardWidget, @@ -8,6 +8,7 @@ import type { import { NotesWidget } from "@/views/admin/views/core/dashboard/widgets/notes/notes-widget"; import { SendNotificationAction } from "@/views/admin/views/core/dashboard/widgets/send-notification/send-notification"; import { SendNotificationSettings } from "@/views/admin/views/core/dashboard/widgets/send-notification/send-notification-settings"; +import { SystemUpdatesWidget } from "@/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget"; import { useAdminUser } from "../permissions"; @@ -28,6 +29,15 @@ const SendNotificationBrowserWidget = ({ }; export const coreDashboardBrowserWidgets: AdminDashboardWidget[] = [ + { + id: "system-updates", + component: SystemUpdatesWidget, + icon: , + defaultSpan: 2, + defaultRows: 1, + defaultEnabled: true, + permission: { module: "plugins", permission: "can_view" }, + }, { id: "notes", component: NotesWidget, diff --git a/packages/vitnode/src/tanstack/admin/shell.tsx b/packages/vitnode/src/tanstack/admin/shell.tsx index be3028a845..c28d970d87 100644 --- a/packages/vitnode/src/tanstack/admin/shell.tsx +++ b/packages/vitnode/src/tanstack/admin/shell.tsx @@ -11,8 +11,10 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { NavSidebarAdminContent } from "@/views/admin/layouts/sidebar/nav/nav-content"; +import { AdminUpdateNavBadges } from "@/views/admin/layouts/sidebar/nav/update-nav-badges"; import { SidebarAdminContent } from "@/views/admin/layouts/sidebar/sidebar-content"; import { AdminSidebarToggle } from "@/views/admin/layouts/sidebar/sidebar-toggle"; +import { AdminUpdateSecurityBanner } from "@/views/admin/layouts/update-security-banner"; import { RouteMessages } from "../i18n/route-messages"; import { RouteGuardPending } from "../pending/guard-pending"; @@ -77,7 +79,9 @@ const AdminShellFrame = ({ return ( }> - + + + @@ -90,6 +94,8 @@ const AdminShellFrame = ({ + + {children} diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx index a2195ca518..38b7e2bd00 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx @@ -28,6 +28,12 @@ import type { AdminNavItem, AdminNavSubItem } from "./nav-model"; import { AdminLink } from "../../admin-link"; import { navItemActivity } from "./nav-active"; +import { + NavBadge, + NavBadgeDot, + sumAdminNavBadges, + useAdminNavBadges, +} from "./nav-badges"; export interface ItemNavAdminContentProps extends AdminNavItem { pathname: string; @@ -52,6 +58,7 @@ export const ItemNavAdminContent = ({ href, items, }); + const badges = useAdminNavBadges(); const [open, setOpen] = useState(hasActiveChild); @@ -66,12 +73,31 @@ export const ItemNavAdminContent = ({ if (isMobile) toggleSidebar(); }; + const isIconOnly = state === "collapsed" && !isMobile; + // A parent stands in for its sub-items' badges while they are out of sight. + const badge = items.length + ? isIconOnly || !open + ? sumAdminNavBadges( + badges, + items.map(item => item.href), + ) + : undefined + : badges[href]; + const content = ( <> - {icon ?? } + {isIconOnly && badge ? ( + + {icon ?? } + + + ) : ( + (icon ?? ) + )} {title} + {isIconOnly ? null : } ); @@ -95,7 +121,7 @@ export const ItemNavAdminContent = ({ ); } - if (state === "collapsed" && !isMobile) { + if (isIconOnly) { return ( @@ -126,6 +152,7 @@ export const ItemNavAdminContent = ({ } > {item.title} + ))} @@ -150,7 +177,12 @@ export const ItemNavAdminContent = ({ } > {content} - + {item.title} + ))} diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx new file mode 100644 index 0000000000..c18f1d7abb --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx @@ -0,0 +1,74 @@ +import { cn } from "cn"; +import React from "react"; + +/** A count next to a navigation item, with what it counts for screen readers. */ +export interface AdminNavBadge { + count: number; + label: string; +} + +export type AdminNavBadges = Readonly>>; + +const NO_BADGES: AdminNavBadges = {}; + +const AdminNavBadgesContext = React.createContext(NO_BADGES); + +/** Badges keyed by the `href` of the item they belong to. */ +export const AdminNavBadgesProvider = ({ + children, + value, +}: { + children: React.ReactNode; + value: AdminNavBadges; +}) => {children}; + +export const useAdminNavBadges = (): AdminNavBadges => + React.use(AdminNavBadgesContext); + +/** What a parent shows while its sub-items are out of sight. */ +export const sumAdminNavBadges = ( + badges: AdminNavBadges, + hrefs: readonly string[], +): AdminNavBadge | undefined => { + const found = hrefs.flatMap(href => { + const badge = badges[href]; + + return badge === undefined ? [] : [badge]; + }); + if (found.length === 0) return undefined; + + return { + count: found.reduce((total, badge) => total + badge.count, 0), + label: found.map(badge => badge.label).join(", "), + }; +}; + +export const NavBadge = ({ + badge, + className, +}: { + badge: AdminNavBadge | undefined; + className?: string; +}) => + badge === undefined ? null : ( + + {badge.count} + {badge.label} + + ); + +/** The collapsed sidebar's icon-only button has no room for a number. */ +export const NavBadgeDot = ({ badge }: { badge: AdminNavBadge }) => ( + <> + + {badge.label} + +); diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index 0eb19ac687..f153c0cebe 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -158,6 +158,9 @@ const core = ( plugin: manifest.id, }); +/** System → Plugins, which also carries the update count. */ +export const ADMIN_PLUGINS_HREF = "/admin/core/system/plugins"; + /** Core's own group, before the permission filter. */ const coreNavGroup = (): AdminNavGroupDeclaration => ({ id: "core", @@ -175,7 +178,7 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({ items: [ { title: key("admin.global.nav.system.plugins"), - href: "/admin/core/system/plugins", + href: ADMIN_PLUGINS_HREF, permission: core("plugins"), }, { diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.tsx new file mode 100644 index 0000000000..ba56cc9be8 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useTranslations } from "use-intl"; + +import { useAdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import type { AdminNavBadges } from "./nav-badges"; + +import { AdminNavBadgesProvider } from "./nav-badges"; +import { ADMIN_PLUGINS_HREF } from "./nav-model"; + +/** + * Puts the number of available updates next to System → Plugins. Nothing + * while the summary loads, fails, or has nothing to count - the item itself + * never changes. + */ +export const AdminUpdateNavBadges = ({ + children, +}: { + children: React.ReactNode; +}) => { + const t = useTranslations("admin.global.updates"); + const { data } = useAdminUpdateSummary(); + const count = data?.enabled ? data.updatesAvailable : 0; + + const badges = React.useMemo( + () => + count > 0 + ? { + [ADMIN_PLUGINS_HREF]: { + count, + label: t("nav_badge", { count }), + }, + } + : {}, + [count, t], + ); + + return ( + {children} + ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/update-security-banner.tsx b/packages/vitnode/src/views/admin/layouts/update-security-banner.tsx new file mode 100644 index 0000000000..471e8944de --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-security-banner.tsx @@ -0,0 +1,48 @@ +import { ShieldAlertIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { useAdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminLink } from "./admin-link"; +import { ADMIN_PLUGINS_HREF } from "./sidebar/nav/nav-model"; + +/** + * One line across the top of every AdminCP page while a security update + * affects something installed. Gone as soon as no evaluated finding applies - + * but never because a publisher could not be reached: a failed check keeps the + * last known findings, and says so. + */ +export const AdminUpdateSecurityBanner = () => { + const t = useTranslations("admin.global.updates.security_banner"); + const { data } = useAdminUpdateSummary(); + + if (!data?.enabled || data.affectedComponents === 0) return null; + + const lastKnown = data.failedComponents > 0 || data.stale; + + return ( +
+ +

+ + {t("title", { count: data.affectedComponents })} + {" "} + {lastKnown ? ( + {t("last_known")} + ) : null} +

+ + {t("review")} + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx new file mode 100644 index 0000000000..3cbd99beac --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx @@ -0,0 +1,237 @@ +import { cn } from "cn"; +import { + ArrowRightIcon, + CircleCheckIcon, + CircleDashedIcon, + CircleOffIcon, + ClockAlertIcon, + LoaderCircleIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { AdminLink } from "@/views/admin/layouts/admin-link"; +import { ADMIN_PLUGINS_HREF } from "@/views/admin/layouts/sidebar/nav/nav-model"; +import { + type AdminUpdateSummary, + useAdminUpdateSummary, +} from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { WidgetContentSkeleton } from "../../grid/widget-skeleton"; + +const TONE = { + destructive: "text-destructive", + muted: "text-muted-foreground", + success: "text-success", + warn: "text-warn", +} as const; + +type Tone = keyof typeof TONE; + +const StatusLine = ({ + children, + icon: Icon, + spin, + tone, +}: { + children: React.ReactNode; + icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>; + spin?: boolean; + tone: Tone; +}) => ( +

+ + {children} +

+); + +/** How fresh the numbers are - the first thing to read before trusting them. */ +const Freshness = ({ summary }: { summary: AdminUpdateSummary }) => { + const t = useTranslations("admin.dashboard.widgets.system-updates.freshness"); + const date = (value: string) => () => ; + + if (!summary.enabled) { + return ( + + {t("disabled")} + + ); + } + if (summary.running) { + return ( + + {t("running")} + + ); + } + if (summary.checkedComponents === 0) { + return ( + + {summary.notCheckedComponents > 0 ? t("not_checked") : t("unavailable")} + + ); + } + if (summary.failedComponents > 0) { + return ( + + {summary.lastAttemptAt === null + ? t("failed", { count: summary.failedComponents }) + : t.rich("failed_at", { + count: summary.failedComponents, + date: date(summary.lastAttemptAt), + })} + + ); + } + if (summary.stale) { + return ( + + {t("stale")} + + ); + } + if (summary.oldestSuccessAt === null) { + return ( + + {t("incomplete", { count: summary.notCheckedComponents })} + + ); + } + + return ( + + {t.rich("checked", { date: date(summary.oldestSuccessAt) })} + + ); +}; + +const Stat = ({ + children, + label, + tone, +}: { + children: React.ReactNode; + label: string; + tone?: Tone; +}) => ( +
+
{label}
+
+ {children} +
+
+); + +const Unknown = () => { + const t = useTranslations("admin.dashboard.widgets.system-updates"); + + return ( + <> + - + {t("unknown")} + + ); +}; + +/** + * Core's version and what the stored update checks know about core and + * plugins. Reads the summary every AdminCP surface shares; it never starts a + * check - that stays a button on the Plugins page. + */ +export const SystemUpdatesWidget = () => { + const t = useTranslations("admin.dashboard.widgets.system-updates"); + const { data, isError } = useAdminUpdateSummary(); + + if (isError) { + return ( +
+ + {t("load_error")} + + +
+ ); + } + if (data === undefined) return ; + + // Before anything was checked a count of 0 would read as "nothing to do". + const known = data.enabled && data.checkedComponents > 0; + const upToDate = + known && + data.updatesAvailable === 0 && + data.affectedComponents === 0 && + data.unconfirmedComponents === 0 && + data.failedComponents === 0 && + !data.stale; + + return ( +
+
+ +
+ +
+ + + {data.core.version ?? } + + + {!known || data.core.state !== "checked" + ? t("core.unknown") + : data.core.latestVersion === null + ? t("core.none") + : t("core.available", { version: data.core.latestVersion })} + + + + {known ? data.pluginUpdatesAvailable : } + + 0 ? "destructive" : undefined + } + > + {known ? data.affectedComponents : } + +
+ + {known && (upToDate || data.unconfirmedComponents > 0) ? ( +

+ {upToDate + ? t("up_to_date") + : t("unconfirmed", { count: data.unconfirmedComponents })} +

+ ) : null} + + +
+ ); +}; + +const ViewPlugins = () => { + const t = useTranslations("admin.dashboard.widgets.system-updates"); + + return ( + + {t("view_plugins")} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx index b0c92ab467..285bb4f2fa 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx @@ -59,6 +59,15 @@ export const CheckUpdatesButton = ({ case "disabled": toast.error(t("disabled.title"), { description: t("disabled.desc") }); + return; + case "interrupted": + await queryClient.invalidateQueries({ + queryKey: pluginUpdatesQueryKey, + }); + toast.warning(t("interrupted.title"), { + description: t("interrupted.desc"), + }); + return; case "running": await queryClient.invalidateQueries({ diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts index 62e5ff436d..1e741ef891 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts @@ -55,7 +55,7 @@ export type CheckPluginUpdatesResult = } | { retryAfterSeconds: null | number; - status: "cooldown" | "disabled" | "running"; + status: "cooldown" | "disabled" | "interrupted" | "running"; } | { status: "error" }; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts new file mode 100644 index 0000000000..5ab2355368 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts @@ -0,0 +1,70 @@ +import type { z } from "zod"; + +import { queryOptions, useQuery } from "@tanstack/react-query"; + +import type { updatesSummarySchema } from "@/api/modules/admin/plugins/routes/updates.schema"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { manifest } from "@/manifest"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; + +import { pluginUpdatesQueryKey } from "./plugin-updates-query"; + +export type AdminUpdateSummary = z.infer; + +export const fetchUpdateSummary = async (): Promise => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "admin/plugins", + path: "/updates/summary", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the update summary"); + } + + return await response.json(); +}; + +/** Under the catalog's key, so refreshing the catalog after a check refreshes this too. */ +export const updateSummaryQueryKey = [ + ...pluginUpdatesQueryKey, + "summary", +] as const; + +const RUNNING_REFETCH_MS = 15_000; + +export const updateSummaryQueryOptions = () => + queryOptions({ + queryFn: fetchUpdateSummary, + queryKey: updateSummaryQueryKey, + retry: false, + staleTime: 60_000, + // Coming back to the tab refreshes a summary older than a minute; while a + // check runs - the daily one, or someone else's - it is asked again until + // that check finishes. + refetchOnWindowFocus: true, + refetchInterval: query => + query.state.data?.running === true ? RUNNING_REFETCH_MS : false, + }); + +/** + * The one summary the navigation count, the security banner and the dashboard + * widget all read - one request however many of them are on screen. Never + * requested for staff who cannot view the plugin catalog. + */ +export const useAdminUpdateSummary = () => { + const canView = useAdminStaffPermission({ + plugin: manifest.id, + module: "plugins", + permission: "can_view", + }); + const { data, isError } = useQuery({ + ...updateSummaryQueryOptions(), + enabled: canView, + }); + + return { data: canView ? data : undefined, isError: canView && isError }; +}; From 2a7bf4e915e165a5ef52ca80d24f80fcfa210566 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 09:45:02 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(plugins):=20=E2=9C=85=20cover=20update?= =?UTF-8?q?-check=20lease=20renewal,=20write=20fencing=20and=20update=20al?= =?UTF-8?q?erts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Heartbeat: renewal past the first lease, no overlapping renewals, loss, renewal errors, the deadline for a renewal that never answers, timer cleanup. - Checker: lease loss aborts in-flight and queued sources, settles every worker, stores nothing and never records cancellation as a failure. - Write fence: a lost or expired owner cannot persist sources, results, failures or clean-up deletions. - Real-Postgres integration suite (VITNODE_TEST_POSTGRES_URL) proving a takeover waits for the old owner's fenced transaction. - Summary counts, the summary route's permission, and the nav count, security banner, dashboard widget and shared-query refresh. fix(plugins): 🐛 stop waiting for a renewal the heartbeat already gave up on, so a database call that never answers cannot hang the check's caller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F5u4nFU9nJ6kuXBV7e3VBj --- .../vitnode/src/api/lib/updates/check.test.ts | 269 ++++++++++++-- .../src/api/lib/updates/fetch-source.test.ts | 26 ++ .../src/api/lib/updates/heartbeat.test.ts | 174 +++++++++ .../vitnode/src/api/lib/updates/heartbeat.ts | 12 +- .../src/api/lib/updates/lock.postgres.test.ts | 348 ++++++++++++++++++ .../vitnode/src/api/lib/updates/lock.test.ts | 246 +++++++++---- .../src/api/lib/updates/status.test.ts | 186 +++++++++- .../routes/check-updates.route.test.ts | 46 ++- .../routes/updates-summary.route.test.ts | 171 +++++++++ .../plugins/routes/updates.route.test.ts | 7 + packages/vitnode/src/tests/memory-db.ts | 11 +- packages/vitnode/src/tests/update-summary.ts | 22 ++ .../sidebar/nav/update-nav-badges.test.tsx | 73 ++++ .../layouts/update-security-banner.test.tsx | 84 +++++ .../system-updates-widget.test.tsx | 99 +++++ .../plugins/check-updates-button.test.tsx | 61 +++ packages/vitnode/vitest.config.ts | 1 + 17 files changed, 1733 insertions(+), 103 deletions(-) create mode 100644 packages/vitnode/src/api/lib/updates/heartbeat.test.ts create mode 100644 packages/vitnode/src/api/lib/updates/lock.postgres.test.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts create mode 100644 packages/vitnode/src/tests/update-summary.ts create mode 100644 packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.test.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx diff --git a/packages/vitnode/src/api/lib/updates/check.test.ts b/packages/vitnode/src/api/lib/updates/check.test.ts index 28e2bdedce..cf4a569b78 100644 --- a/packages/vitnode/src/api/lib/updates/check.test.ts +++ b/packages/vitnode/src/api/lib/updates/check.test.ts @@ -1,10 +1,14 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { UpdateManifest } from "@/lib/updates/manifest"; -import { core_update_results, core_update_sources } from "@/database/updates"; +import { + core_update_lock, + core_update_results, + core_update_sources, +} from "@/database/updates"; import { createMemoryDb } from "@/tests/memory-db"; import type { FetchUpdateSource } from "./check"; @@ -12,7 +16,13 @@ import type { UpdateComponent } from "./components"; import { checkForUpdates, runLockedUpdateCheck } from "./check"; import { UpdateSourceError } from "./fetch-source"; -import { acquireUpdateCheckLease, readUpdateCheckLock } from "./lock"; +import { + acquireUpdateCheckLease, + readUpdateCheckLock, + releaseUpdateCheckLease, + UPDATE_CHECK_LEASE_MS, + UpdateCheckLeaseLostError, +} from "./lock"; type Db = ReturnType; @@ -96,9 +106,14 @@ const sources = ( ); }); -const setup = () => { +const OWNER = "run"; + +/** A database whose lease `OWNER` holds; `lost` stands in for its heartbeat. */ +const setup = async () => { const memory = createMemoryDb(); const db = memory.db as unknown as Db; + await acquireUpdateCheckLease(db, { manual: false, owner: OWNER }); + const lost = new AbortController(); const check = async ( components: UpdateComponent[], fetchSource: FetchUpdateSource, @@ -110,14 +125,19 @@ const setup = () => { coreVersion: "2.0.0", db, fetchSource, + lease: { owner: OWNER, signal: lost.signal }, now, }); const result = (id: string) => memory.rows(core_update_results).find(row => row.componentId === id); const source = (url: string) => memory.rows(core_update_sources).find(row => row.url === url); + const stored = () => ({ + results: memory.rows(core_update_results), + sources: memory.rows(core_update_sources), + }); - return { check, db, memory, result, source }; + return { check, db, lost, memory, result, source, stored }; }; const BLOG = component("@acme/blog", ACME); @@ -125,7 +145,7 @@ const FORUM = component("@acme/forum", ACME, ["1.1.0"]); describe("checkForUpdates", () => { it("fetches a source shared by several plugins once, and evaluates each", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); const fetchSource = sources({ [ACME]: fetched(acmeManifest()) }); const summary = await check([BLOG, FORUM], fetchSource); @@ -154,7 +174,7 @@ describe("checkForUpdates", () => { }); it("stores only the packages this install checks", async () => { - const { check, source } = setup(); + const { check, source } = await setup(); await check([BLOG], sources({ [ACME]: fetched(acmeManifest(), '"v1"') })); @@ -166,7 +186,7 @@ describe("checkForUpdates", () => { }); it("skips a component without an update source", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); const fetchSource = sources({}); await expect( @@ -177,7 +197,7 @@ describe("checkForUpdates", () => { }); it("keeps one failing source from affecting the others", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); const core = component("@vitnode/core", CORE, ["1.9.0"]); const summary = await check( @@ -221,7 +241,7 @@ describe("checkForUpdates", () => { { code: "unsupported_schema" }, ], ])("keeps the previous findings through %s", async (_, answer, error) => { - const { check, result, source } = setup(); + const { check, result, source } = await setup(); await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) }), T1); await check([BLOG], sources({ [ACME]: answer }), T2); @@ -244,7 +264,7 @@ describe("checkForUpdates", () => { }); it("answers a 304 from the stored copy, evaluated for what runs now", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); await check( [BLOG], sources({ [ACME]: fetched(acmeManifest(), '"v1"') }), @@ -255,6 +275,7 @@ describe("checkForUpdates", () => { await check([component("@acme/blog", ACME, ["1.1.0"])], fetchSource, T2); expect(fetchSource).toHaveBeenCalledWith(ACME, { + signal: expect.any(AbortSignal) as unknown, validators: { etag: '"v1"', lastModified: null }, }); expect(result("@acme/blog")).toMatchObject({ @@ -266,7 +287,7 @@ describe("checkForUpdates", () => { }); it("asks for the full document when the stored copy does not cover a new plugin", async () => { - const { check, result } = setup(); + const { check, result } = await setup(); await check( [BLOG], sources({ [ACME]: fetched(acmeManifest(), '"v1"') }), @@ -276,7 +297,10 @@ describe("checkForUpdates", () => { await check([BLOG, FORUM], fetchSource, T2); - expect(fetchSource).toHaveBeenCalledWith(ACME, { validators: null }); + expect(fetchSource).toHaveBeenCalledWith(ACME, { + signal: expect.any(AbortSignal) as unknown, + validators: null, + }); expect(result("@acme/forum")).toMatchObject({ lastError: { code: "http_error" }, result: null, @@ -284,7 +308,7 @@ describe("checkForUpdates", () => { }); it("forgets removed plugins and sources nothing points to", async () => { - const { check, memory } = setup(); + const { check, memory } = await setup(); const core = component("@vitnode/core", CORE, ["2.0.0"]); await check( [core, BLOG], @@ -306,7 +330,7 @@ describe("checkForUpdates", () => { }); it("forgets everything when nothing has an update source any more", async () => { - const { check, memory } = setup(); + const { check, memory } = await setup(); await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) }), T1); await check([component("@acme/blog", null)], sources({}), T2); @@ -316,6 +340,93 @@ describe("checkForUpdates", () => { }); }); +/** A request that only ends when the check gives up on it. */ +const untilAborted: FetchUpdateSource = async (_, { signal }) => + await new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + reject(signal.reason as Error); + }); + }); + +describe("checkForUpdates once the lease is lost", () => { + const url = (index: number) => `https://p${index}.example.com/updates.json`; + + it("aborts requests in flight, never starts queued ones, and stores nothing", async () => { + const { check, lost, stored } = await setup(); + await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) })); + const before = stored(); + const signals: AbortSignal[] = []; + let answerLate: ( + value: Awaited>, + ) => void = () => undefined; + const fetchSource = vi.fn(async (requested, options) => { + if (options.signal) signals.push(options.signal); + // One publisher ignores the cancellation and answers anyway. + if (requested === ACME) { + return await new Promise(resolve => { + answerLate = resolve; + }); + } + + return await untilAborted(requested, options); + }); + const running = check( + [ + BLOG, + ...[1, 2, 3, 4, 5].map(index => component(`@p/${index}`, url(index))), + ], + fetchSource, + T2, + ); + let settled = false; + void running.catch(() => undefined).finally(() => (settled = true)); + await vi.waitFor(() => { + expect(signals).toHaveLength(4); + }); + + lost.abort(new UpdateCheckLeaseLostError()); + await new Promise(resolve => setImmediate(resolve)); + expect(signals.every(signal => signal.aborted)).toBe(true); + // Every worker settles before the check gives up - including the late one. + expect(settled).toBe(false); + answerLate(fetched(acmeManifest(), '"v2"')); + + await expect(running).rejects.toBeInstanceOf(UpdateCheckLeaseLostError); + expect(fetchSource).toHaveBeenCalledTimes(4); + // Cancelled is not failed: the last good copy and its findings stay as they were. + expect(stored()).toEqual(before); + }); + + it.each([ + [ + "a fresh copy of a source", + [BLOG], + sources({ [ACME]: fetched(acmeManifest(["@acme/blog"]), '"v2"') }), + ], + [ + "a failed request", + [BLOG], + sources({ [ACME]: new UpdateSourceError("timeout") }), + ], + ["the clean-up", [component("@acme/blog", null)], sources({})], + ])( + "refuses %s from a run whose lease was taken over before it noticed", + async (_, components, fetchSource) => { + const { check, db, memory, stored } = await setup(); + await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) })); + const before = stored(); + await releaseUpdateCheckLease(db, OWNER); + await acquireUpdateCheckLease(db, { manual: false, owner: "other" }); + + await expect(check(components, fetchSource, T2)).rejects.toBeInstanceOf( + UpdateCheckLeaseLostError, + ); + expect(stored()).toEqual(before); + expect(memory.rows(core_update_lock)).toMatchObject([{ owner: "other" }]); + }, + ); +}); + describe("runLockedUpdateCheck", () => { const options = (db: Db, fetchSource: FetchUpdateSource) => ({ channelOverride: null, @@ -326,13 +437,19 @@ describe("runLockedUpdateCheck", () => { now: T1, }); + const unleased = () => { + const memory = createMemoryDb(); + + return { db: memory.db as unknown as Db, memory }; + }; + + afterEach(() => { + vi.useRealTimers(); + }); + it("does not check while another instance holds the lease", async () => { - const { db } = setup(); - await acquireUpdateCheckLease(db, { - manual: false, - now: T1, - owner: "other", - }); + const { db } = unleased(); + await acquireUpdateCheckLease(db, { manual: false, owner: "other" }); const fetchSource = sources({}); await expect( @@ -342,13 +459,28 @@ describe("runLockedUpdateCheck", () => { }); it("gives the lease back after the check, even when it throws", async () => { - const { db } = setup(); + const { db } = unleased(); const failingDb = new Proxy(db, { get: (target, property, receiver) => - property === "delete" - ? () => { - throw new Error("database went away"); - } + property === "transaction" + ? async (run: (tx: unknown) => Promise) => + await target.transaction( + async tx => + await run( + new Proxy(tx, { + get: (inner, name, innerReceiver) => + name === "delete" + ? () => { + throw new Error("database went away"); + } + : (Reflect.get( + inner, + name, + innerReceiver, + ) as unknown), + }), + ), + ) : (Reflect.get(target, property, receiver) as unknown), }); @@ -358,13 +490,13 @@ describe("runLockedUpdateCheck", () => { manual: false, }), ).rejects.toThrow("database went away"); - await expect(readUpdateCheckLock(db, T1)).resolves.toMatchObject({ + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ running: false, }); }); it("returns the summary of a completed check", async () => { - const { db } = setup(); + const { db } = unleased(); await expect( runLockedUpdateCheck({ @@ -376,4 +508,85 @@ describe("runLockedUpdateCheck", () => { summary: { sources: 1, updates: 1 }, }); }); + + it("keeps a check that outlasts the first lease protected by renewing it", async () => { + vi.useFakeTimers({ now: T1 }); + const { db, memory } = unleased(); + let answer: (value: Awaited>) => void = () => + undefined; + const fetchSource = vi.fn( + async () => + await new Promise(resolve => { + answer = resolve; + }), + ); + const running = runLockedUpdateCheck({ + ...options(db, fetchSource), + manual: false, + }); + + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_LEASE_MS + 60_000); + await expect( + acquireUpdateCheckLease(db, { manual: false, owner: "other" }), + ).resolves.toMatchObject({ acquired: false, reason: "running" }); + + answer(fetched(acmeManifest())); + await expect(running).resolves.toMatchObject({ status: "completed" }); + expect(memory.rows(core_update_results)).toHaveLength(1); + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ + running: false, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops when a renewal fails, keeping what it stored before", async () => { + vi.useFakeTimers({ now: T1 }); + const { db, memory } = unleased(); + let offline = false; + const flakyDb = new Proxy(db, { + get: (target, property, receiver) => + property === "transaction" && offline + ? async () => await Promise.reject(new Error("connection reset")) + : (Reflect.get(target, property, receiver) as unknown), + }); + const fetchSource = vi.fn(untilAborted); + const running = runLockedUpdateCheck({ + ...options(flakyDb, fetchSource), + manual: false, + }); + await vi.waitFor(() => { + expect(fetchSource).toHaveBeenCalled(); + }); + + offline = true; + await vi.advanceTimersByTimeAsync(60_000); + + await expect(running).resolves.toEqual({ status: "lease_lost" }); + expect(fetchSource.mock.calls[0]?.[1].signal?.aborted).toBe(true); + expect(memory.rows(core_update_results)).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops once another instance holds the lease, and leaves that lease alone", async () => { + vi.useFakeTimers({ now: T1 }); + const { db, memory } = unleased(); + const fetchSource = vi.fn(untilAborted); + const running = runLockedUpdateCheck({ + ...options(db, fetchSource), + manual: false, + }); + await vi.waitFor(() => { + expect(fetchSource).toHaveBeenCalled(); + }); + + const [{ owner }] = memory.rows(core_update_lock) as [{ owner: string }]; + await releaseUpdateCheckLease(db, owner); + await acquireUpdateCheckLease(db, { manual: false, owner: "other" }); + await vi.advanceTimersByTimeAsync(60_000); + + await expect(running).resolves.toEqual({ status: "lease_lost" }); + expect(memory.rows(core_update_lock)).toMatchObject([{ owner: "other" }]); + expect(memory.rows(core_update_results)).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.test.ts b/packages/vitnode/src/api/lib/updates/fetch-source.test.ts index d7d5758270..8ff0c96914 100644 --- a/packages/vitnode/src/api/lib/updates/fetch-source.test.ts +++ b/packages/vitnode/src/api/lib/updates/fetch-source.test.ts @@ -296,6 +296,32 @@ describe("fetchUpdateSource", () => { ).rejects.toMatchObject({ reason: "timeout" }); }); + it("gives the caller's own reason back when the caller cancels, not a publisher failure", async () => { + const cancel = new AbortController(); + const reason = new Error("lease lost"); + const transport: UpdateSourceTransport = async (_, { signal }) => + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + cancel.abort(reason); + }); + + await expect( + fetchWith(URL_OK, transport, { signal: cancel.signal }), + ).rejects.toBe(reason); + }); + + it("never contacts a publisher for a check already cancelled", async () => { + const { requests, transport } = scripted(json(MANIFEST)); + const reason = new Error("lease lost"); + + await expect( + fetchWith(URL_OK, transport, { signal: AbortSignal.abort(reason) }), + ).rejects.toBe(reason); + expect(requests).toEqual([]); + }); + it.each([ [ "a connection the connect-time lookup blocked", diff --git a/packages/vitnode/src/api/lib/updates/heartbeat.test.ts b/packages/vitnode/src/api/lib/updates/heartbeat.test.ts new file mode 100644 index 0000000000..d2e9fd1d9f --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/heartbeat.test.ts @@ -0,0 +1,174 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { UpdateCheckLeaseRenewal } from "./lock"; + +import { startUpdateCheckHeartbeat } from "./heartbeat"; +import { UpdateCheckLeaseLostError } from "./lock"; + +type Db = ReturnType; + +const INTERVAL = 60_000; +const LEASE = 5 * 60_000; +const RENEWED: UpdateCheckLeaseRenewal = { + expiresAt: new Date(0), + status: "renewed", +}; + +/** A renewal the test answers by hand. */ +const deferred = () => { + let answer: (renewal: UpdateCheckLeaseRenewal) => void = () => undefined; + const promise = new Promise(resolve => { + answer = resolve; + }); + + return { answer, promise }; +}; + +const start = ( + renew: (...args: unknown[]) => Promise, +) => + startUpdateCheckHeartbeat({ + confirmedAt: Date.now(), + db: {} as Db, + intervalMs: INTERVAL, + leaseMs: LEASE, + owner: "a", + renew, + }); + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("startUpdateCheckHeartbeat", () => { + it("keeps a check alive past the first lease while renewals succeed", async () => { + const renew = vi.fn(async () => await Promise.resolve(RENEWED)); + const heartbeat = start(renew); + + await vi.advanceTimersByTimeAsync(2 * LEASE); + + expect(renew).toHaveBeenCalledTimes((2 * LEASE) / INTERVAL); + expect(renew).toHaveBeenCalledWith({}, "a", { leaseMs: LEASE }); + expect(heartbeat.signal.aborted).toBe(false); + await heartbeat.stop(); + }); + + it("sends the next renewal only after the previous one answered", async () => { + const sentAt: number[] = []; + let inFlight = 0; + let mostInFlight = 0; + const renew = vi.fn(async () => { + sentAt.push(Date.now()); + inFlight += 1; + mostInFlight = Math.max(mostInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 30_000)); + inFlight -= 1; + + return RENEWED; + }); + const heartbeat = start(renew); + + await vi.advanceTimersByTimeAsync(270_000); + + expect(sentAt).toEqual([60_000, 150_000, 240_000]); + expect(mostInFlight).toBe(1); + await heartbeat.stop(); + }); + + it.each([ + [ + "the lease was lost", + async () => await Promise.resolve({ status: "lost" as const }), + undefined, + ], + [ + "the renewal failed", + async () => await Promise.reject(new Error("connection reset")), + new Error("connection reset"), + ], + ])("stops the check when %s", async (_, answer, cause) => { + const renew = vi.fn(answer); + const heartbeat = start(renew); + + await vi.advanceTimersByTimeAsync(INTERVAL); + + expect(heartbeat.signal.aborted).toBe(true); + expect(heartbeat.signal.reason).toBeInstanceOf(UpdateCheckLeaseLostError); + expect((heartbeat.signal.reason as Error).cause).toEqual(cause); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(2 * LEASE); + expect(renew).toHaveBeenCalledTimes(1); + await heartbeat.stop(); + }); + + it("stops the check once a renewal hangs past the last confirmed lease", async () => { + const hung = deferred(); + const renew = vi + .fn<() => Promise>() + .mockResolvedValueOnce(RENEWED) + .mockReturnValueOnce(hung.promise); + const heartbeat = start(renew); + + // Confirmed by the renewal sent at 60 s, so it holds until 60 s + LEASE. + await vi.advanceTimersByTimeAsync(INTERVAL + LEASE - 1); + expect(heartbeat.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(heartbeat.signal.reason).toBeInstanceOf(UpdateCheckLeaseLostError); + + // A late answer does not bring it back. + hung.answer(RENEWED); + await heartbeat.stop(); + expect(vi.getTimerCount()).toBe(0); + expect(heartbeat.signal.aborted).toBe(true); + expect(renew).toHaveBeenCalledTimes(2); + }); + + it("waits for a renewal on its way when stopped, then leaves no timer behind", async () => { + const inFlight = deferred(); + const renew = vi.fn(async () => await inFlight.promise); + const heartbeat = start(renew); + await vi.advanceTimersByTimeAsync(INTERVAL); + + let stopped = false; + const stopping = heartbeat.stop().then(() => { + stopped = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(stopped).toBe(false); + + inFlight.answer(RENEWED); + await stopping; + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(2 * LEASE); + expect(renew).toHaveBeenCalledTimes(1); + expect(heartbeat.signal.aborted).toBe(false); + }); + + it("stops waiting for a renewal that never answers once the deadline passes", async () => { + const renew = vi.fn( + async () => await new Promise(() => undefined), + ); + const heartbeat = start(renew); + await vi.advanceTimersByTimeAsync(INTERVAL); + + let stopped = false; + const stopping = heartbeat.stop().then(() => { + stopped = true; + }); + await vi.advanceTimersByTimeAsync(LEASE - INTERVAL - 1); + expect(stopped).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await stopping; + expect(heartbeat.signal.reason).toBeInstanceOf(UpdateCheckLeaseLostError); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/heartbeat.ts b/packages/vitnode/src/api/lib/updates/heartbeat.ts index aafaf58ff8..e60459ad35 100644 --- a/packages/vitnode/src/api/lib/updates/heartbeat.ts +++ b/packages/vitnode/src/api/lib/updates/heartbeat.ts @@ -99,8 +99,18 @@ export const startUpdateCheckHeartbeat = ({ stop: async () => { stopped = true; clearTimeout(beatTimer); + // A renewal on its way is waited for until the deadline gives up on it, + // so one that never answers cannot hang the caller. It never rejects, and + // after the release it could only come back `lost`. + if (!controller.signal.aborted) { + const aborted = new Promise(resolve => { + controller.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + await Promise.race([pending, aborted]); + } clearTimeout(deadlineTimer); - await pending; }, }; }; diff --git a/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts b/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts new file mode 100644 index 0000000000..6af83649b2 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts @@ -0,0 +1,348 @@ +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import postgres from "postgres"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import type { UpdateManifest } from "@/lib/updates/manifest"; + +import { + core_update_lock, + core_update_results, + core_update_sources, +} from "@/database/updates"; + +import type { FetchUpdateSource } from "./check"; +import type { UpdateComponent } from "./components"; + +import { checkForUpdates } from "./check"; +import { UpdateSourceError } from "./fetch-source"; +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, + renewUpdateCheckLease, + UPDATE_CHECK_LEASE_MS, + UpdateCheckLeaseLostError, + withUpdateCheckLease, +} from "./lock"; + +type Db = ReturnType; + +const URL_ENV = process.env.VITNODE_TEST_POSTGRES_URL; + +const MIGRATION = resolve( + import.meta.dirname, + "../../../../../../apps/api/migrations/20261015193600_add_core_update_checks/migration.sql", +); + +/** + * `postgres://user@host:port/db`, or a unix socket directory given as + * `postgres://user@/db?host=/tmp&port=5432`. + */ +const connectionOptions = (raw: string) => { + // An empty host is how a socket URL is usually written, and `URL` rejects it. + const url = new URL(raw.replace(/@\//, "@localhost/")); + const param = (name: string) => url.searchParams.get(name); + + return { + database: decodeURIComponent(url.pathname.slice(1)), + host: param("host") ?? decodeURIComponent(url.hostname), + password: url.password ? decodeURIComponent(url.password) : undefined, + port: Number(param("port") ?? (url.port || 5432)), + user: decodeURIComponent(url.username) || "postgres", + }; +}; + +const ACME = "https://acme.example.com/updates.json"; +const T1 = new Date("2026-10-14T03:00:00Z"); +const T2 = new Date("2026-10-15T03:00:00Z"); + +const BLOG: UpdateComponent = { + hasUnknownVersion: false, + id: "@acme/blog", + kind: "plugin", + sourceUrl: ACME, + versions: ["1.0.0"], +}; + +const manifest = (stable: string): UpdateManifest => ({ + packages: { + "@acme/blog": { + channels: { stable }, + releases: { [stable]: {} }, + securityUpdates: [], + }, + }, + schemaVersion: 1, +}); + +const answer = + (result: Awaited> | Error): FetchUpdateSource => + async () => + result instanceof Error + ? await Promise.reject(result) + : await Promise.resolve(result); + +const fetched = (stable: string, etag: string) => + answer({ body: manifest(stable), etag, kind: "fetched", lastModified: null }); + +/** Resolves once `check` holds, polling the database every few milliseconds. */ +const until = async (check: () => Promise, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs; + while (!(await check())) { + if (Date.now() > deadline) throw new Error("condition never held"); + await new Promise(next => setTimeout(next, 5)); + } +}; + +describe.skipIf(!URL_ENV)("update check lease on PostgreSQL", () => { + const schema = `vitnode_lease_${randomUUID().replaceAll("-", "")}`; + let client: postgres.Sql; + let db: Db; + + const expire = async (owner: string) => { + await db.execute( + sql`update core_update_lock set "expiresAt" = timezone('utc', clock_timestamp()) - interval '1 second' where owner = ${owner}`, + ); + }; + + const stored = async () => ({ + results: await db + .select() + .from(core_update_results) + .orderBy(core_update_results.componentId), + sources: await db + .select() + .from(core_update_sources) + .orderBy(core_update_sources.url), + }); + + const owner = async () => + ( + await db.select({ owner: core_update_lock.owner }).from(core_update_lock) + )[0]?.owner; + + const check = async ( + leaseOwner: string, + components: UpdateComponent[], + fetchSource: FetchUpdateSource, + now = T1, + ) => + await checkForUpdates({ + channelOverride: null, + components, + coreVersion: "2.0.0", + db, + fetchSource, + lease: { owner: leaseOwner, signal: new AbortController().signal }, + now, + }); + + beforeAll(async () => { + const admin = postgres({ + ...connectionOptions(URL_ENV ?? ""), + max: 1, + onnotice: () => undefined, + }); + await admin.unsafe(`create schema "${schema}"`); + await admin.end(); + + client = postgres({ + ...connectionOptions(URL_ENV ?? ""), + connection: { search_path: schema }, + max: 6, + onnotice: () => undefined, + }); + db = drizzle({ client }); + for (const statement of readFileSync(MIGRATION, "utf8").split( + "--> statement-breakpoint", + )) { + if (statement.trim()) await client.unsafe(statement); + } + }); + + afterAll(async () => { + if (!client) return; + await client.unsafe(`drop schema if exists "${schema}" cascade`); + await client.end(); + }); + + beforeEach(async () => { + await client.unsafe( + "truncate core_update_lock, core_update_results, core_update_sources", + ); + }); + + it("lets exactly one of many concurrent instances take the lease", async () => { + const owners = ["a", "b", "c", "d", "e"]; + + const results = await Promise.all( + owners.map( + async name => + await acquireUpdateCheckLease(db, { manual: false, owner: name }), + ), + ); + + const winners = results.flatMap(result => + result.acquired ? [result.owner] : [], + ); + expect(winners).toHaveLength(1); + expect(results.filter(result => !result.acquired)).toEqual( + owners.slice(1).map(() => expect.objectContaining({ reason: "running" })), + ); + await expect(owner()).resolves.toBe(winners[0]); + }); + + it("times the lease by the database clock in UTC, whatever the server's time zone", async () => { + const before = Date.now(); + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + + const [row] = await db + .select({ expiresAt: core_update_lock.expiresAt }) + .from(core_update_lock); + + expect(row?.expiresAt?.getTime()).toBeGreaterThanOrEqual( + before + UPDATE_CHECK_LEASE_MS - 1000, + ); + expect(row?.expiresAt?.getTime()).toBeLessThanOrEqual( + Date.now() + UPDATE_CHECK_LEASE_MS + 1000, + ); + }); + + it("renews only for the owner, and never once its lease ran out", async () => { + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + + await expect(renewUpdateCheckLease(db, "b")).resolves.toEqual({ + status: "lost", + }); + await expect(renewUpdateCheckLease(db, "a")).resolves.toMatchObject({ + status: "renewed", + }); + + await expire("a"); + await expect(renewUpdateCheckLease(db, "a")).resolves.toEqual({ + status: "lost", + }); + }); + + it("holds a takeover back while the old owner's write is open, then fences the old owner", async () => { + const events: string[] = []; + await acquireUpdateCheckLease(db, { + leaseMs: 50, + manual: false, + owner: "a", + }); + let finishWrite = () => {}; + const writing = new Promise(resolveWrite => { + finishWrite = resolveWrite; + }); + let insideWrite = () => {}; + const inside = new Promise(resolveInside => { + insideWrite = resolveInside; + }); + + const oldWrite = withUpdateCheckLease(db, "a", async tx => { + await tx + .insert(core_update_sources) + .values({ lastAttemptAt: T1, url: ACME }); + insideWrite(); + await writing; + }).then(() => events.push("a committed")); + let takeover: ReturnType | undefined; + // A failed assertion must not leave a's transaction open for the next test. + try { + await inside; + // Let a's lease run out by the database clock while its write is still open. + await until(async () => { + const rows = await db.execute<{ expired: boolean }>( + sql`select "expiresAt" <= timezone('utc', clock_timestamp()) as expired from core_update_lock`, + ); + + return rows.some(row => row.expired); + }); + + takeover = acquireUpdateCheckLease(db, { + manual: false, + owner: "b", + }).then(result => { + events.push("b acquired"); + + return result; + }); + await until(async () => { + const [row] = await db.execute<{ waiting: number }>( + sql`select count(*)::int as waiting from pg_locks where locktype = 'advisory' and not granted`, + ); + + return row?.waiting === 1; + }); + expect(events).toEqual([]); + } finally { + finishWrite(); + } + await oldWrite; + await expect(takeover).resolves.toEqual({ acquired: true, owner: "b" }); + expect(events).toEqual(["a committed", "b acquired"]); + + // What a committed while it held the lease stays; nothing after. + await expect( + withUpdateCheckLease(db, "a", async tx => { + await tx.delete(core_update_sources); + }), + ).rejects.toBeInstanceOf(UpdateCheckLeaseLostError); + expect((await stored()).sources).toMatchObject([{ url: ACME }]); + }); + + it.each([ + ["a fresh copy of a source", [BLOG], fetched("1.2.0", '"v2"')], + ["a failed request", [BLOG], answer(new UpdateSourceError("timeout"))], + ["the clean-up", [{ ...BLOG, sourceUrl: null }], answer(new Error("no"))], + ])( + "refuses %s from an owner whose lease was taken over", + async (_, components, fetchSource) => { + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + await check("a", [BLOG], fetched("1.1.0", '"v1"')); + const before = await stored(); + await expire("a"); + await acquireUpdateCheckLease(db, { manual: false, owner: "b" }); + + await expect( + check("a", components, fetchSource, T2), + ).rejects.toBeInstanceOf(UpdateCheckLeaseLostError); + await expect(stored()).resolves.toEqual(before); + }, + ); + + it("lets the owner that holds the lease write and clean up as usual", async () => { + await acquireUpdateCheckLease(db, { manual: false, owner: "b" }); + await check("b", [BLOG], fetched("1.1.0", '"v1"')); + await check("b", [BLOG], fetched("1.2.0", '"v2"'), T2); + + expect(await stored()).toMatchObject({ + results: [ + { + componentId: "@acme/blog", + lastSuccessAt: T2, + result: { latest: { version: "1.2.0" }, status: "update_available" }, + }, + ], + sources: [{ etag: '"v2"', lastSuccessAt: T2, url: ACME }], + }); + + await check("b", [{ ...BLOG, sourceUrl: null }], answer(new Error("no"))); + await expect(stored()).resolves.toEqual({ results: [], sources: [] }); + }); + + it("never frees a replacement owner's lease", async () => { + await acquireUpdateCheckLease(db, { manual: false, owner: "a" }); + await expire("a"); + await acquireUpdateCheckLease(db, { manual: false, owner: "b" }); + + await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(false); + await expect(owner()).resolves.toBe("b"); + await expect(releaseUpdateCheckLease(db, "b")).resolves.toBe(true); + await expect(owner()).resolves.toBeNull(); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/lock.test.ts b/packages/vitnode/src/api/lib/updates/lock.test.ts index 6cc03d8cd8..9ce8331dfe 100644 --- a/packages/vitnode/src/api/lib/updates/lock.test.ts +++ b/packages/vitnode/src/api/lib/updates/lock.test.ts @@ -1,8 +1,8 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { core_update_lock } from "@/database/updates"; +import { core_update_lock, core_update_sources } from "@/database/updates"; import { createMemoryDb } from "@/tests/memory-db"; import { @@ -10,7 +10,10 @@ import { MANUAL_UPDATE_CHECK_COOLDOWN_MS, readUpdateCheckLock, releaseUpdateCheckLease, + renewUpdateCheckLease, UPDATE_CHECK_LEASE_MS, + UpdateCheckLeaseLostError, + withUpdateCheckLease, } from "./lock"; type Db = ReturnType; @@ -18,20 +21,33 @@ type Db = ReturnType; const T0 = new Date("2026-10-15T12:00:00Z"); const at = (ms: number) => new Date(T0.getTime() + ms); +/** The memory database reads `clock_timestamp()` off `Date`, so this moves its clock. */ +const clockAt = (ms: number) => { + vi.setSystemTime(at(ms)); +}; + const memoryDb = () => { const memory = createMemoryDb(); return { ...memory, db: memory.db as unknown as Db }; }; +const take = async (db: Db, owner: string, manual = false) => + await acquireUpdateCheckLease(db, { manual, owner }); + +beforeEach(() => { + vi.useFakeTimers({ now: T0, toFake: ["Date"] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + describe("acquireUpdateCheckLease", () => { it("lets only one of two racing checks take the lease", async () => { const { db } = memoryDb(); - const results = await Promise.all([ - acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }), - acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "b" }), - ]); + const results = await Promise.all([take(db, "a"), take(db, "b")]); expect(results.filter(result => result.acquired)).toHaveLength(1); expect(results.find(result => !result.acquired)).toEqual({ @@ -43,78 +59,68 @@ describe("acquireUpdateCheckLease", () => { it("takes over a lease whose holder died and let it expire", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); - - await expect( - acquireUpdateCheckLease(db, { - manual: false, - now: at(UPDATE_CHECK_LEASE_MS), - owner: "b", - }), - ).resolves.toEqual({ acquired: true, owner: "b" }); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS - 1); + await expect(take(db, "b")).resolves.toMatchObject({ acquired: false }); + clockAt(UPDATE_CHECK_LEASE_MS); + await expect(take(db, "b")).resolves.toEqual({ + acquired: true, + owner: "b", + }); }); it("is free again once the holder gives it back", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + await take(db, "a"); await releaseUpdateCheckLease(db, "a"); - await expect( - acquireUpdateCheckLease(db, { manual: false, now: at(1000), owner: "b" }), - ).resolves.toEqual({ acquired: true, owner: "b" }); + clockAt(1000); + await expect(take(db, "b")).resolves.toEqual({ + acquired: true, + owner: "b", + }); }); it("keeps manual checks apart by the cooldown, but not the daily one", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); await releaseUpdateCheckLease(db, "a"); - await expect( - acquireUpdateCheckLease(db, { - manual: true, - now: at(30_000), - owner: "b", - }), - ).resolves.toEqual({ + clockAt(30_000); + await expect(take(db, "b", true)).resolves.toEqual({ acquired: false, reason: "cooldown", retryAfterSeconds: MANUAL_UPDATE_CHECK_COOLDOWN_MS / 1000 - 30, }); - await expect( - acquireUpdateCheckLease(db, { - manual: false, - now: at(30_000), - owner: "c", - }), - ).resolves.toEqual({ acquired: true, owner: "c" }); + await expect(take(db, "c")).resolves.toEqual({ + acquired: true, + owner: "c", + }); }); it("allows a manual check again once the cooldown is over", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); await releaseUpdateCheckLease(db, "a"); - await expect( - acquireUpdateCheckLease(db, { - manual: true, - now: at(MANUAL_UPDATE_CHECK_COOLDOWN_MS), - owner: "b", - }), - ).resolves.toEqual({ acquired: true, owner: "b" }); + clockAt(MANUAL_UPDATE_CHECK_COOLDOWN_MS); + await expect(take(db, "b", true)).resolves.toEqual({ + acquired: true, + owner: "b", + }); }); it("does not let the daily check reset the manual cooldown", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); await releaseUpdateCheckLease(db, "a"); - await acquireUpdateCheckLease(db, { - manual: false, - now: at(10_000), - owner: "b", - }); + clockAt(10_000); + await take(db, "b"); await releaseUpdateCheckLease(db, "b"); - await expect(readUpdateCheckLock(db, at(20_000))).resolves.toEqual({ + clockAt(20_000); + await expect(readUpdateCheckLock(db)).resolves.toEqual({ lastManualAt: T0, running: false, }); @@ -122,39 +128,155 @@ describe("acquireUpdateCheckLease", () => { it("reports a running check to a manual request still in cooldown", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await take(db, "a", true); + + clockAt(1000); + await expect(take(db, "b", true)).resolves.toMatchObject({ + acquired: false, + reason: "running", + }); + }); +}); + +describe("renewUpdateCheckLease", () => { + it("pushes the holder's lease out from the database's now", async () => { + const { db } = memoryDb(); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS - 1000); + await expect(renewUpdateCheckLease(db, "a")).resolves.toEqual({ + expiresAt: at(2 * UPDATE_CHECK_LEASE_MS - 1000), + status: "renewed", + }); + + // Past the first lease's end, still held thanks to the renewal. + clockAt(UPDATE_CHECK_LEASE_MS + 1000); + await expect(take(db, "b")).resolves.toMatchObject({ + acquired: false, + reason: "running", + }); + }); - await expect( - acquireUpdateCheckLease(db, { manual: true, now: at(1000), owner: "b" }), - ).resolves.toMatchObject({ acquired: false, reason: "running" }); + it("never renews someone else's lease", async () => { + const { db, rows } = memoryDb(); + await take(db, "a"); + + await expect(renewUpdateCheckLease(db, "b")).resolves.toEqual({ + status: "lost", + }); + expect(rows(core_update_lock)).toMatchObject([ + { expiresAt: at(UPDATE_CHECK_LEASE_MS), owner: "a" }, + ]); + }); + + it("refuses an owner whose lease ran out, even before anyone took it over", async () => { + const { db, rows } = memoryDb(); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS); + await expect(renewUpdateCheckLease(db, "a")).resolves.toEqual({ + status: "lost", + }); + expect(rows(core_update_lock)).toMatchObject([ + { expiresAt: at(UPDATE_CHECK_LEASE_MS), owner: "a" }, + ]); + }); +}); + +describe("withUpdateCheckLease", () => { + const writeSource = async (db: Db, owner: string) => + await withUpdateCheckLease(db, owner, async tx => { + await tx.insert(core_update_sources).values({ + lastAttemptAt: T0, + url: "https://acme.example.com/updates.json", + }); + + return "written"; + }); + + it("runs the write for the holder", async () => { + const { db, rows } = memoryDb(); + await take(db, "a"); + + await expect(writeSource(db, "a")).resolves.toBe("written"); + expect(rows(core_update_sources)).toHaveLength(1); + }); + + it.each([ + ["nobody holds the lease", async () => {}], + [ + "another instance holds it", + async (db: Db) => { + await take(db, "b"); + }, + ], + [ + "its own lease ran out", + async (db: Db) => { + await take(db, "a"); + clockAt(UPDATE_CHECK_LEASE_MS); + }, + ], + [ + "another instance took it over after expiry", + async (db: Db) => { + await take(db, "a"); + clockAt(UPDATE_CHECK_LEASE_MS); + await take(db, "b"); + }, + ], + ])("writes nothing when %s", async (_, prepare) => { + const { db, rows } = memoryDb(); + await prepare(db); + const write = vi.fn(); + + await expect(withUpdateCheckLease(db, "a", write)).rejects.toBeInstanceOf( + UpdateCheckLeaseLostError, + ); + expect(write).not.toHaveBeenCalled(); + expect(rows(core_update_sources)).toEqual([]); }); }); describe("releaseUpdateCheckLease", () => { it("only lets the holder give the lease back", async () => { const { db } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + await take(db, "a"); + clockAt(1000); await expect(releaseUpdateCheckLease(db, "b")).resolves.toBe(false); - await expect(readUpdateCheckLock(db, at(1000))).resolves.toMatchObject({ + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ running: true, }); await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(true); - await expect(readUpdateCheckLock(db, at(1000))).resolves.toMatchObject({ + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ running: false, }); }); it("cannot give back a lease another instance took over after expiry", async () => { const { db, rows } = memoryDb(); - await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); - await acquireUpdateCheckLease(db, { - manual: false, - now: at(UPDATE_CHECK_LEASE_MS + 1), - owner: "b", - }); + await take(db, "a"); + clockAt(UPDATE_CHECK_LEASE_MS + 1); + await take(db, "b"); await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(false); expect(rows(core_update_lock)).toMatchObject([{ owner: "b" }]); }); }); + +describe("readUpdateCheckLock", () => { + it("stops reporting a check as running once its lease ran out", async () => { + const { db } = memoryDb(); + await take(db, "a"); + + clockAt(UPDATE_CHECK_LEASE_MS - 1); + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ + running: true, + }); + clockAt(UPDATE_CHECK_LEASE_MS); + await expect(readUpdateCheckLock(db)).resolves.toMatchObject({ + running: false, + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/status.test.ts b/packages/vitnode/src/api/lib/updates/status.test.ts index c99c25dccf..b99fefe741 100644 --- a/packages/vitnode/src/api/lib/updates/status.test.ts +++ b/packages/vitnode/src/api/lib/updates/status.test.ts @@ -1,17 +1,19 @@ import type { drizzle } from "drizzle-orm/postgres-js"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { UpdateChannel, UpdateManifest } from "@/lib/updates/manifest"; import { createMemoryDb } from "@/tests/memory-db"; import type { UpdateComponent } from "./components"; +import type { UpdateEvaluation } from "./evaluate"; +import type { ComponentUpdateState, UpdatesOverview } from "./status"; import { checkForUpdates } from "./check"; import { UpdateSourceError } from "./fetch-source"; -import { acquireUpdateCheckLease } from "./lock"; -import { loadUpdatesOverview } from "./status"; +import { acquireUpdateCheckLease, releaseUpdateCheckLease } from "./lock"; +import { loadUpdatesOverview, summarizeUpdates } from "./status"; type Db = ReturnType; @@ -46,14 +48,29 @@ const blog = (overrides: Partial = {}): UpdateComponent => ({ ...overrides, }); +const OWNER = "check"; + +/** Runs one check the way the locked run does: under the lease, then gives it back. */ +const leased = async ( + db: Db, + options: Omit[0], "db" | "lease">, +) => { + await acquireUpdateCheckLease(db, { manual: false, owner: OWNER }); + await checkForUpdates({ + ...options, + db, + lease: { owner: OWNER, signal: new AbortController().signal }, + }); + await releaseUpdateCheckLease(db, OWNER); +}; + /** A database holding one successful check of `blog()` on core 2.0.0. */ const checked = async () => { const db = createMemoryDb().db as unknown as Db; - await checkForUpdates({ + await leased(db, { channelOverride: null, components: [blog()], coreVersion: "2.0.0", - db, fetchSource: async () => await Promise.resolve({ body: MANIFEST, @@ -87,10 +104,13 @@ const overview = async ( coreVersion, db, enabled, - now: T2, }); describe("loadUpdatesOverview", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("shows the stored result while nothing changed", async () => { const db = await checked(); @@ -163,11 +183,10 @@ describe("loadUpdatesOverview", () => { it("keeps the previous result next to a failed check", async () => { const db = await checked(); - await checkForUpdates({ + await leased(db, { channelOverride: null, components: [blog()], coreVersion: "2.0.0", - db, fetchSource: async () => await Promise.reject(new UpdateSourceError("timeout")), now: T2, @@ -214,7 +233,8 @@ describe("loadUpdatesOverview", () => { it("reports a running check and the last manual one", async () => { const db = await checked(); - await acquireUpdateCheckLease(db, { manual: true, now: T2, owner: "a" }); + vi.useFakeTimers({ now: T2, toFake: ["Date"] }); + await acquireUpdateCheckLease(db, { manual: true, owner: "a" }); await expect(overview(db)).resolves.toMatchObject({ lastManualAt: T2, @@ -222,3 +242,151 @@ describe("loadUpdatesOverview", () => { }); }); }); + +describe("summarizeUpdates", () => { + const advisory = (id: string) => ({ + affected: "<9.0.0", + fixed: "9.0.0", + id, + url: `https://example.com/advisories/${id}`, + versions: ["1.0.0"], + }); + + const evaluation = ( + status: UpdateEvaluation["status"], + securityUpdates: UpdateEvaluation["securityUpdates"] = [], + latest: null | string = status === "update_available" ? "9.0.0" : null, + ): UpdateEvaluation => ({ + alternative: null, + channel: "stable", + compatibility: null, + latest: + latest === null + ? null + : { publishedAt: null, requiresCore: null, url: null, version: latest }, + securityUpdates, + status, + }); + + const state = ( + id: string, + overrides: Partial = {}, + ): ComponentUpdateState => ({ + id, + lastAttemptAt: T1, + lastError: null, + lastSuccessAt: T1, + result: evaluation("up_to_date"), + sourceUrl: ACME, + stale: false, + state: "checked", + ...overrides, + }); + + const summarize = ( + components: ComponentUpdateState[], + overrides: Partial = {}, + ) => + summarizeUpdates({ + channelOverride: null, + components, + enabled: true, + lastManualAt: null, + running: false, + ...overrides, + }); + + it("counts each component once, whatever it has to report", () => { + const summary = summarize([ + state("@vitnode/core", { + result: evaluation("update_available", [advisory("CORE-1")], "2.1.0"), + }), + state("@acme/blog", { + result: evaluation("update_available", [ + advisory("ACME-1"), + advisory("ACME-2"), + ]), + }), + state("@acme/forum", { result: evaluation("update_available") }), + state("@acme/wiki"), + ]); + + expect(summary).toMatchObject({ + affectedComponents: 2, + checkedComponents: 4, + core: { latestVersion: "2.1.0", state: "checked" }, + pluginUpdatesAvailable: 2, + totalComponents: 4, + unconfirmedComponents: 0, + updatesAvailable: 3, + }); + }); + + it("tells a stale, failed or never checked component from a confirmed one", () => { + const summary = summarize([ + state("@vitnode/core", { lastAttemptAt: T2 }), + state("@acme/blog", { + lastAttemptAt: T2, + lastError: { code: "timeout", detail: null }, + result: evaluation("update_available", [advisory("ACME-1")]), + }), + state("@acme/forum", { stale: true }), + state("@acme/wiki", { + lastAttemptAt: null, + lastSuccessAt: null, + result: null, + state: "not_checked", + }), + state("@acme/local", { + lastAttemptAt: null, + lastSuccessAt: null, + result: null, + sourceUrl: null, + state: "unavailable", + }), + state("@acme/odd", { result: evaluation("version_unknown") }), + ]); + + expect(summary).toMatchObject({ + // A failed check keeps its earlier finding. + affectedComponents: 1, + checkedComponents: 4, + core: { latestVersion: null }, + failedComponents: 1, + lastAttemptAt: T2, + notCheckedComponents: 1, + oldestSuccessAt: null, + stale: true, + unconfirmedComponents: 3, + }); + }); + + it("dates the summary by the oldest success once every checkable component has one", () => { + const summary = summarize([ + state("@vitnode/core", { lastSuccessAt: T2 }), + state("@acme/blog", { lastSuccessAt: T1 }), + state("@acme/local", { + lastSuccessAt: null, + result: null, + state: "unavailable", + }), + ]); + + expect(summary.oldestSuccessAt).toEqual(T1); + }); + + it("reports checks turned off and a check in progress as they are", () => { + expect( + summarize( + [state("@vitnode/core", { result: null, state: "unavailable" })], + { enabled: false, running: true }, + ), + ).toMatchObject({ + checkedComponents: 0, + core: { latestVersion: null, state: "unavailable" }, + enabled: false, + running: true, + updatesAvailable: 0, + }); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts index 12f95b6e95..7b774722d0 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts @@ -10,6 +10,7 @@ import { acquireUpdateCheckLease, releaseUpdateCheckLease, } from "@/api/lib/updates/lock"; +import { core_update_lock } from "@/database/updates"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { grantStaffPermissions } from "@/tests/staff-permissions"; @@ -34,11 +35,16 @@ const checkAs = async ( { enabled = true, prepare, - }: { enabled?: boolean; prepare?: (db: Db) => Promise } = {}, + wrap = db => db, + }: { + enabled?: boolean; + prepare?: (db: Db) => Promise; + wrap?: (db: Db) => Db; + } = {}, ) => { const cache = createTestCache(); await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); - const db = createMemoryDb().db as unknown as Db; + const db = wrap(createMemoryDb().db as unknown as Db); await prepare?.(db); const app = new OpenAPIHono(); @@ -124,6 +130,42 @@ describe("POST /admin/plugins/updates/check", () => { expect(Number(response.headers.get("Retry-After"))).toBeGreaterThan(0); }); + it("answers 409 when the check lost its lease midway", async () => { + // Another instance takes the lease over right before the check's first write. + const takenOverMidCheck = (db: Db): Db => { + let transactions = 0; + + return new Proxy(db, { + get: (target, property, receiver) => + property === "transaction" + ? async (...args: Parameters) => { + transactions += 1; + if (transactions === 2) { + const [lease] = await target + .select({ owner: core_update_lock.owner }) + .from(core_update_lock); + await releaseUpdateCheckLease(target, lease?.owner ?? ""); + await acquireUpdateCheckLease(target, { + manual: false, + owner: "other", + }); + } + + return await target.transaction(...args); + } + : (Reflect.get(target, property, receiver) as unknown), + }); + }; + + const response = await checkAs(CAN_CHECK, { wrap: takenOverMidCheck }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + retryAfterSeconds: null, + status: "interrupted", + }); + }); + it("answers 409 while update checks are turned off", async () => { const response = await checkAs(CAN_CHECK, { enabled: false }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts new file mode 100644 index 0000000000..b52e387fb4 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts @@ -0,0 +1,171 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { UpdateComponent } from "@/api/lib/updates/components"; + +import { checkForUpdates } from "@/api/lib/updates/check"; +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, +} from "@/api/lib/updates/lock"; +import { core_update_lock } from "@/database/updates"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { pluginUpdatesSummaryAdminRoute } from "./updates-summary.route"; + +type Db = ReturnType; + +const ADMIN = { id: 1, roleId: 1 }; +const CORE_SOURCE = "https://vitnode.example.com/updates.json"; +const ACME_SOURCE = "https://acme.example.com/updates.json"; +const CHECKED_AT = new Date("2026-10-15T03:00:00Z"); + +const advisory = (id: string) => ({ + affected: "<1.1.0", + fixed: "1.1.0", + id, + url: `https://acme.example.com/advisories/${id}`, +}); + +const COMPONENTS: UpdateComponent[] = [ + { + hasUnknownVersion: false, + id: "@vitnode/core", + kind: "core", + sourceUrl: CORE_SOURCE, + versions: ["2.0.0"], + }, + // Its API and its web app both report a version: still one plugin. + { + hasUnknownVersion: false, + id: "@acme/blog", + kind: "plugin", + sourceUrl: ACME_SOURCE, + versions: ["1.0.1", "1.0.0"], + }, + { + hasUnknownVersion: false, + id: "@acme/local", + kind: "plugin", + sourceUrl: null, + versions: ["1.0.0"], + }, +]; + +const MANIFESTS: Record = { + [ACME_SOURCE]: { + packages: { + "@acme/blog": { + channels: { stable: "1.1.0" }, + releases: { "1.1.0": {} }, + securityUpdates: [advisory("ACME-1"), advisory("ACME-2")], + }, + }, + schemaVersion: 1, + }, + [CORE_SOURCE]: { + packages: { + "@vitnode/core": { + channels: { stable: "2.1.0" }, + releases: { "2.1.0": {} }, + }, + }, + schemaVersion: 1, + }, +}; + +const requestAs = async (permissions: PermissionsStaffArgs[]) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); + const memory = createMemoryDb(); + const db = memory.db as unknown as Db; + await acquireUpdateCheckLease(db, { manual: false, owner: "cron" }); + await checkForUpdates({ + channelOverride: null, + components: COMPONENTS, + coreVersion: "2.0.0", + db, + fetchSource: async url => + await Promise.resolve({ + body: MANIFESTS[url], + etag: null, + kind: "fetched" as const, + lastModified: null, + }), + lease: { owner: "cron", signal: new AbortController().signal }, + now: CHECKED_AT, + }); + await releaseUpdateCheckLease(db, "cron"); + const lockBefore = memory.rows(core_update_lock); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: ADMIN } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", db); + c.set("core", { + updates: { + channelOverride: null, + components: COMPONENTS, + coreVersion: "2.0.0", + enabled: true, + }, + } as unknown as Context["var"]["core"]); + await next(); + }); + app.openapi( + pluginUpdatesSummaryAdminRoute.route, + pluginUpdatesSummaryAdminRoute.handler, + ); + + return { + lockBefore, + memory, + response: await app.request("/updates/summary"), + }; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("GET /admin/plugins/updates/summary", () => { + it("summarises the stored results without starting a check", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const { lockBefore, memory, response } = await requestAs([ + { module: "plugins", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + affectedComponents: 1, + checkedComponents: 2, + core: { latestVersion: "2.1.0", state: "checked", version: "2.0.0" }, + enabled: true, + oldestSuccessAt: CHECKED_AT.toISOString(), + pluginUpdatesAvailable: 1, + running: false, + totalComponents: 3, + unconfirmedComponents: 1, + updatesAvailable: 2, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(memory.rows(core_update_lock)).toEqual(lockBefore); + }); + + it("refuses an administrator without the plugins permission", async () => { + const { response } = await requestAs([ + { module: "system", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(403); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts index 536cf7354f..8cdd75cf63 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts @@ -8,6 +8,10 @@ import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import type { UpdateComponent } from "@/api/lib/updates/components"; import { checkForUpdates } from "@/api/lib/updates/check"; +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, +} from "@/api/lib/updates/lock"; import { createTestCache } from "@/tests/cache"; import { createMemoryDb } from "@/tests/memory-db"; import { grantStaffPermissions } from "@/tests/staff-permissions"; @@ -41,6 +45,7 @@ const requestAs = async (permissions: PermissionsStaffArgs[]) => { const cache = createTestCache(); await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); const db = createMemoryDb().db as unknown as Db; + await acquireUpdateCheckLease(db, { manual: false, owner: "cron" }); await checkForUpdates({ channelOverride: null, components: COMPONENTS, @@ -61,8 +66,10 @@ const requestAs = async (permissions: PermissionsStaffArgs[]) => { kind: "fetched" as const, lastModified: null, }), + lease: { owner: "cron", signal: new AbortController().signal }, now: CHECKED_AT, }); + await releaseUpdateCheckLease(db, "cron"); const app = new OpenAPIHono(); app.use("*", async (c, next) => { diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index 6293d49035..d7ca994827 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -527,7 +527,16 @@ const call = ( } if (node.args === "*") return unsupported(`${node.name}(*)`); const args = node.args.map(argument => evaluate(argument, scope, group)); - if (node.name === "now" && args.length === 0) return new Date(); + if ( + (node.name === "now" || node.name === "clock_timestamp") && + args.length === 0 + ) { + return new Date(); + } + // Dates here are already absolute instants, so shifting them to UTC changes nothing. + if (node.name === "timezone" && args.length === 2) { + return args[1] instanceof Date ? args[1] : unsupported("timezone()"); + } if (node.name === "lower") { return isMissing(args[0]) ? null : textOf(args[0]).toLowerCase(); } diff --git a/packages/vitnode/src/tests/update-summary.ts b/packages/vitnode/src/tests/update-summary.ts new file mode 100644 index 0000000000..e2cc2eecf1 --- /dev/null +++ b/packages/vitnode/src/tests/update-summary.ts @@ -0,0 +1,22 @@ +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +/** A summary of an install whose every component was checked and is up to date. */ +export const updateSummary = ( + overrides: Partial = {}, +): AdminUpdateSummary => ({ + affectedComponents: 0, + checkedComponents: 3, + core: { latestVersion: null, state: "checked", version: "2.0.0" }, + enabled: true, + failedComponents: 0, + lastAttemptAt: "2026-10-15T03:00:00.000Z", + notCheckedComponents: 0, + oldestSuccessAt: "2026-10-15T03:00:00.000Z", + pluginUpdatesAvailable: 0, + running: false, + stale: false, + totalComponents: 3, + unconfirmedComponents: 0, + updatesAvailable: 0, + ...overrides, +}); diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.test.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.test.tsx new file mode 100644 index 0000000000..043bbd8ed1 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/update-nav-badges.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StaffPermissionSet } from "@/api/lib/permission-staff"; +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { updateSummary as summary } from "@/tests/update-summary"; +import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { NavBadge, useAdminNavBadges } from "./nav-badges"; +import { ADMIN_PLUGINS_HREF } from "./nav-model"; +import { AdminUpdateNavBadges } from "./update-nav-badges"; + +const LABEL = "admin.global.updates.nav_badge"; + +const PluginsItemBadge = () => ( + +); + +const renderBadges = ( + data: AdminUpdateSummary, + permissions: StaffPermissionSet = { permissions: [], root: true }, +) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, data); + + return render( + + + + + + + , + ); +}; + +describe("AdminUpdateNavBadges", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("counts the available updates next to Plugins", () => { + renderBadges(summary({ updatesAvailable: 3 })); + + expect(screen.getByText("3")).toBeTruthy(); + expect(screen.getByText(LABEL)).toBeTruthy(); + }); + + it.each([ + ["nothing to update", summary(), undefined], + [ + "checks turned off", + summary({ enabled: false, updatesAvailable: 3 }), + undefined, + ], + [ + "staff who cannot view plugins", + summary({ updatesAvailable: 3 }), + { permissions: [], root: false }, + ], + ])("shows no badge for %s", (_, data, permissions) => { + renderBadges(data, permissions); + + expect(screen.queryByText(LABEL)).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx b/packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx new file mode 100644 index 0000000000..6a0bdd9ed5 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx @@ -0,0 +1,84 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { updateSummary as summary } from "@/tests/update-summary"; +import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { ADMIN_PLUGINS_HREF } from "./sidebar/nav/nav-model"; +import { AdminUpdateSecurityBanner } from "./update-security-banner"; + +const T = "admin.global.updates.security_banner"; + +const renderBanner = async (data: AdminUpdateSummary) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, data); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/admin"] }), + routeTree: createRootRoute({ + component: () => ( + <> +

page

+ + + ), + }), + }); + + render( + + + + + , + ); + await screen.findByText("page"); +}; + +describe("AdminUpdateSecurityBanner", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("warns about a security update and links to the plugins", async () => { + await renderBanner(summary({ affectedComponents: 2 })); + + expect( + screen.getByRole("region", { name: `${T}.title` }).textContent, + ).not.toContain(`${T}.last_known`); + expect( + screen.getByRole("link", { name: `${T}.review` }).getAttribute("href"), + ).toBe(ADMIN_PLUGINS_HREF); + }); + + it("says the finding is the last known one when a check failed since", async () => { + await renderBanner(summary({ affectedComponents: 1, failedComponents: 1 })); + + expect(screen.getByText(`${T}.last_known`)).toBeTruthy(); + }); + + it.each([ + ["nothing is affected", summary()], + [ + "checks are turned off", + summary({ affectedComponents: 1, enabled: false }), + ], + ])("stays hidden when %s", async (_, data) => { + await renderBanner(data); + + expect(screen.queryByRole("region")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx new file mode 100644 index 0000000000..2323c8ccbe --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx @@ -0,0 +1,99 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { updateSummary as summary } from "@/tests/update-summary"; +import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { SystemUpdatesWidget } from "./system-updates-widget"; + +const T = "admin.dashboard.widgets.system-updates"; + +const renderWidget = async (data: AdminUpdateSummary) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, data); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/admin"] }), + routeTree: createRootRoute({ component: SystemUpdatesWidget }), + }); + + render( + + + + + + + , + ); + await screen.findByText(`${T}.view_plugins`); +}; + +const NOT_CHECKED = summary({ + checkedComponents: 0, + core: { latestVersion: null, state: "not_checked", version: "2.0.0" }, + lastAttemptAt: null, + notCheckedComponents: 3, + oldestSuccessAt: null, + unconfirmedComponents: 3, +}); + +describe("SystemUpdatesWidget", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ["nothing was checked yet", NOT_CHECKED, `${T}.freshness.not_checked`], + [ + "checks are turned off", + summary({ enabled: false }), + `${T}.freshness.disabled`, + ], + ])( + "shows the counts as unknown, never 0, while %s", + async (_, data, status) => { + await renderWidget(data); + + expect(screen.getByText(status)).toBeTruthy(); + expect(screen.getAllByText(`${T}.unknown`)).toHaveLength(2); + expect(screen.queryByText("0")).toBeNull(); + expect(screen.queryByText(`${T}.up_to_date`)).toBeNull(); + }, + ); + + it("calls everything up to date only once every component is confirmed", async () => { + await renderWidget(summary()); + + expect(screen.getByText(`${T}.freshness.checked`)).toBeTruthy(); + expect(screen.getByText(`${T}.up_to_date`)).toBeTruthy(); + }); + + it.each([ + ["a result is stale", { stale: true }], + ["a check failed", { failedComponents: 1 }], + ["a component cannot be confirmed", { unconfirmedComponents: 1 }], + ["a security update applies", { affectedComponents: 1 }], + ])( + "does not call it up to date when %s", + async (_, overrides: Partial) => { + await renderWidget(summary(overrides)); + + expect(screen.queryByText(`${T}.up_to_date`)).toBeNull(); + }, + ); +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx new file mode 100644 index 0000000000..73ec46ee1a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx @@ -0,0 +1,61 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { CheckPluginUpdatesResult } from "./plugin-updates-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { updateSummary } from "@/tests/update-summary"; + +import { CheckUpdatesButton } from "./check-updates-button"; +import { + updateSummaryQueryKey, + useAdminUpdateSummary, +} from "./update-summary-query"; + +/** Stands in for the navigation badge, the banner and the widget: any reader of the summary. */ +const SummaryReader = () => { + useAdminUpdateSummary(); + + return null; +}; + +describe("CheckUpdatesButton", () => { + it.each([ + { failedSources: 0, securityUpdates: 0, status: "completed", updates: 2 }, + { retryAfterSeconds: null, status: "interrupted" }, + ])( + "asks for the shared update summary again after a $status check", + async result => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, updateSummary()); + const refetched: unknown[] = []; + queryClient.getQueryCache().subscribe(event => { + if (event.type === "updated" && event.action.type === "fetch") { + refetched.push(event.query.queryKey); + } + }); + + render( + + + + await Promise.resolve(result)} + /> + + , + ); + expect(refetched).toEqual([]); + + const button = screen.getByRole("button", { + name: "admin.system.plugins.updates.check.button", + }); + fireEvent.submit(button.closest("form") as HTMLFormElement); + + await waitFor(() => { + expect(refetched).toContainEqual(updateSummaryQueryKey); + }); + }, + ); +}); diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index 078911bad0..b2739bb89e 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -38,6 +38,7 @@ const INTEGRATION = [ "scripts/cli/commands/start.test.ts", "scripts/cli/dev/package-watch.test.ts", "scripts/watch-plugin-manifest.test.ts", + "src/api/lib/updates/lock.postgres.test.ts", ]; export default defineConfig({ From 69054e77dec295634e954c50a7953b956a9bffb1 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 17 Oct 2026 13:07:26 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(security):=20=F0=9F=94=92=20replace=20?= =?UTF-8?q?`AdminUpdateSecurityBanner`=20with=20`AdminUpdateSecurityPill`?= =?UTF-8?q?=20for=20improved=20security=20update=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/dev/updates/admin-update-alerts.webp | Bin 88950 -> 84844 bytes apps/web/content/docs/dev/updates/admin.mdx | 2 +- apps/web/content/docs/dev/updates/alerts.mdx | 74 +++-- apps/web/content/docs/dev/updates/index.mdx | 4 +- apps/web/content/docs/dev/updates/lease.mdx | 2 +- apps/web/src/locales/@vitnode/core/pl.json | 64 ++-- .../vitnode/src/api/lib/installed-plugins.ts | 6 +- .../vitnode/src/api/lib/updates/check.test.ts | 5 - packages/vitnode/src/api/lib/updates/check.ts | 32 +- .../src/api/lib/updates/fetch-source.ts | 18 +- .../src/api/lib/updates/heartbeat.test.ts | 22 +- .../vitnode/src/api/lib/updates/heartbeat.ts | 26 +- .../src/api/lib/updates/lock.postgres.test.ts | 9 - .../vitnode/src/api/lib/updates/lock.test.ts | 3 - packages/vitnode/src/api/lib/updates/lock.ts | 59 +--- .../src/api/lib/updates/status.test.ts | 20 +- .../vitnode/src/api/lib/updates/status.ts | 37 +-- .../routes/check-updates.route.test.ts | 1 - .../routes/updates-summary.route.test.ts | 21 +- .../plugins/routes/updates-summary.route.ts | 8 +- .../admin/plugins/routes/updates.schema.ts | 5 +- packages/vitnode/src/locales/en.json | 62 ++-- packages/vitnode/src/tanstack/admin/shell.tsx | 5 +- packages/vitnode/src/tests/memory-db.ts | 1 - packages/vitnode/src/tests/plugin-updates.ts | 75 +++++ packages/vitnode/src/tests/update-summary.ts | 4 +- .../layouts/sidebar/nav/item-content.tsx | 1 - .../admin/layouts/sidebar/nav/nav-badges.tsx | 20 +- .../admin/layouts/sidebar/nav/nav-model.tsx | 1 - .../layouts/sidebar/nav/update-nav-badges.tsx | 5 - .../admin/layouts/update-entries.test.ts | 61 ++++ .../src/views/admin/layouts/update-entries.ts | 64 ++++ .../layouts/update-security-banner.test.tsx | 84 ------ .../admin/layouts/update-security-banner.tsx | 48 --- .../layouts/update-security-pill.test.tsx | 114 +++++++ .../admin/layouts/update-security-pill.tsx | 228 ++++++++++++++ .../system-updates-widget.test.tsx | 65 ++-- .../system-updates/system-updates-widget.tsx | 278 ++++++++---------- .../system-updates/update-status.test.ts | 35 +++ .../widgets/system-updates/update-status.ts | 47 +++ .../plugins/check-updates-button.test.tsx | 1 - .../views/core/system/plugins/plugin-list.tsx | 90 +----- .../views/core/system/plugins/plugin-rows.ts | 28 +- .../views/core/system/plugins/plugin-tone.ts | 69 ++++- .../plugins/plugin-update-status.test.tsx | 8 - .../system/plugins/plugin-update-status.tsx | 25 +- .../core/system/plugins/plugin-version.tsx | 4 +- .../core/system/plugins/plugins-status.tsx | 63 +--- .../core/system/plugins/unstable-channel.ts | 4 +- .../system/plugins/update-summary-query.ts | 9 - 50 files changed, 1071 insertions(+), 846 deletions(-) create mode 100644 packages/vitnode/src/tests/plugin-updates.ts create mode 100644 packages/vitnode/src/views/admin/layouts/update-entries.test.ts create mode 100644 packages/vitnode/src/views/admin/layouts/update-entries.ts delete mode 100644 packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx delete mode 100644 packages/vitnode/src/views/admin/layouts/update-security-banner.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/update-security-pill.test.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/update-security-pill.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts create mode 100644 packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts diff --git a/apps/web/content/docs/dev/updates/admin-update-alerts.webp b/apps/web/content/docs/dev/updates/admin-update-alerts.webp index 9072bc965af85f9d4cc82e0c48844dfc0b065ae1..24edc75eb6c9900481f36b02c002f6cec79243bc 100644 GIT binary patch literal 84844 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 H1oZy^IZQO` literal 88950 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 @@ -27,49 +27,67 @@ Staff with **Plugins → View installed plugins and their versions** (`plugins.c A number next to **System → Plugins** tells you how many packages have a newer release on their channel. Core counts as one package and every plugin counts once, even when its API and web app are registered separately. It is the same number of _Update available_ rows the Plugins page shows. - The count only appears when it is above zero. While the data loads, or if it cannot be loaded, the menu looks exactly as before. -- Security updates do not add to the count. They have their own banner. +- Security updates do not add to the count. They have their own alert in the header. - When the **System** group is folded, the count moves to the **System** item. In the collapsed icon sidebar a small dot marks the icon, and the count sits next to **Plugins** in the menu that opens. -## The security banner +## The security alert in the header -When a security update affects something you run, a red line appears at the top of every AdminCP page: +When a security update affects something you run, a red pill appears in the AdminCP header, next to search, on every page: -> Security updates affect 2 components. **Review updates** +> **2 security updates** -**Review updates** opens System → Plugins, where each affected row names the security update and its fix. Each component counts once, however many security updates match it. The banner has no close button on purpose: it disappears on its own once no evaluated finding applies to what you run - after you upgrade and redeploy, or after a check finds the publisher changed the affected range. +On a phone it shrinks to the shield and the number. Each component counts once, however many security updates match it. The pill has no close button on purpose: it disappears on its own once no evaluated finding applies to what you run - after you upgrade and redeploy, or after a check finds the publisher changed the affected range. It sits in the header, so it never pushes the page down. -A publisher that cannot be reached never clears a warning. VitNode keeps the last known findings and adds _Based on the last known results_ to the banner, so an outage at the publisher never looks like a fix. +Click it to see what is waiting, in two groups: + +- **Security fixes** - every package a security update affects, with the version that fixes it. +- **Other updates** - packages with a newer release and no security finding. + +Each group lists up to four packages and then says how many more there are. A row, or **Open Plugins** at the bottom, takes you to System → Plugins, where each affected row names the security update and its fix. The list itself is only fetched when you open the pill, so the header stays as light as before. + +A publisher that cannot be reached never clears a warning. VitNode keeps the last known findings and adds _Based on the last known results_ under the list, so an outage at the publisher never looks like a fix. ## The System updates widget -The **System updates** widget is on the dashboard by default for staff who can view plugins. You can remove it or add it back from **Edit layout**, like any other widget. It shows: +The **System updates** widget is on the dashboard by default for staff who can view plugins. You can remove it or add it back from **Edit layout**, like any other widget. It reads like a small ledger, one row per fact: + +| Row | Meaning | +| -------------- | --------------------------------------------------------------------------------------------------------- | +| Status | The one-line verdict - see below. | +| VitNode | The core version you run and, after the arrow, the newer core release on your channel when one is known. | +| Plugin updates | Plugins with a newer release on their channel. Core is not included here. | +| Security | How many packages, core included, a security update affects - or _None_. | +| Last checked | How fresh the numbers are. | -| Field | Meaning | -| ---------------------------- | ------------------------------------------------------------------------------------------------ | -| VitNode core | The core version you run, and the newer core release on your channel when one is known. | -| Plugin updates | Plugins with a newer release on their channel. Core is not included here. | -| Affected by security updates | Packages, core included, that a security update affects. | +Before anything was checked, the numbers show a dash and _Unknown_ instead of `0`. -Before anything was checked, the numbers show a dash and _Unknown_ instead of `0`. _Everything is up to date._ only appears when every installed package was checked, none has an update or a security warning, and no check failed. Otherwise the widget says how many packages could not be confirmed, for example because they declare no update source. +### Status -### Freshness labels +| Status | What it means | +| ------------------------ | ------------------------------------------------------------------------------------------------- | +| Security fix needed | At least one package you run is affected by a security update. | +| Updates available | A newer release exists for core or a plugin, and nothing is affected by a security update. | +| Up to date | Every installed package was checked and none has an update or a security warning. | +| 2 packages not confirmed | Nothing is outdated, but some packages could not be confirmed, for example they declare no update source. The Plugins page says why. | +| Checking... | A check is running. The widget, the count and the pill refresh every 15 seconds until it finishes. | +| Not checked yet | No check has run. The daily check runs overnight, or press **Check for updates** on the Plugins page. | +| No update sources | No installed package declares where its updates are published. | +| Checks turned off | The API config sets `updates.enabled` to `false`. | -The first line of the widget says how far you can trust the numbers: +### Last checked -| Label | What it means | -| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| Every update source checked successfully 3 hours ago | The oldest stored result is that recent, so every package was checked at least that recently. | -| Checking for updates now... | A check is running. The widget, the count and the banner refresh every 15 seconds until it finishes. | -| The check 5 minutes ago failed for 1 package | A source could not be read. The numbers include the last known results for it. | -| Not checked for this deployment yet | You redeployed, upgraded core or changed channel. Results are recomputed from the stored manifests until the next check. | -| Not checked yet | No check has run. The daily check runs overnight, or press **Check for updates** on the Plugins page. | -| Update checks are turned off for this installation. | The API config sets `updates.enabled` to `false`. | +| Value | What it means | +| -------------- | ----------------------------------------------------------------------------------------------------------------------- | +| 3 hours ago | The oldest stored result is that recent, so every package was checked at least that recently. | +| Failed 5 minutes ago | The latest check could not read a source. The numbers include the last known results for it. | +| Out of date | You redeployed, upgraded core or changed channel. Results are recomputed from the stored manifests until the next check. | +| Never | Nothing has been checked yet. | The time is the **oldest** successful result, not the newest. One package checked a minute ago does not prove the others were. ## When update checks are turned off -With `updates: { enabled: false }` in the API config, nothing is checked. The navigation count and the banner stay hidden, and the widget only says that checks are turned off. Nothing stored earlier is shown as current. +With `updates: { enabled: false }` in the API config, nothing is checked. The navigation count and the security pill stay hidden, and the widget only says that checks are turned off. Nothing stored earlier is shown as current. ## How fresh the alerts are @@ -77,4 +95,4 @@ The alerts share one request per browser tab. It is refreshed when it is older t ## Check the result -Press **Check for updates** on **System → Plugins**. When it finishes, the count next to **Plugins**, the banner and the widget update together without reloading the page. +Press **Check for updates** on **System → Plugins**. When it finishes, the count next to **Plugins**, the security pill and the widget update together without reloading the page. diff --git a/apps/web/content/docs/dev/updates/index.mdx b/apps/web/content/docs/dev/updates/index.mdx index 8484475f62..2cb875ffaa 100644 --- a/apps/web/content/docs/dev/updates/index.mdx +++ b/apps/web/content/docs/dev/updates/index.mdx @@ -4,7 +4,7 @@ description: How VitNode tells administrators about new versions and security up icon: RefreshCw --- -VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**, as a count next to that menu item, as a security banner across the AdminCP and in the **System updates** dashboard widget. +VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**, as a count next to that menu item, as a security alert in the AdminCP header and in the **System updates** dashboard widget. Each package says where its **update manifest** lives - a public JSON file on GitHub Raw or any other HTTPS host. VitNode reads those files from the server. It does not ask npm, and it does not install, download or change anything. @@ -13,7 +13,7 @@ Each package says where its **update manifest** lives - a public JSON file on Gi | You want to... | Read | | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | See updates and security warnings, run a check, choose a channel | [Check for updates in the AdminCP](/docs/dev/updates/admin) | -| Understand the update count, security banner and dashboard widget | [Update alerts in the AdminCP](/docs/dev/updates/alerts) | +| Understand the update count, security alert and dashboard widget | [Update alerts in the AdminCP](/docs/dev/updates/alerts) | | Learn how checks stay exclusive across API instances | [How update checks share one lease](/docs/dev/updates/lease) | | Make your own plugin report updates | [Publish updates for your plugin](/docs/dev/updates/plugin-authors) | | Look up every field of the JSON format | [Update manifest reference](/docs/dev/updates/manifest) | diff --git a/apps/web/content/docs/dev/updates/lease.mdx b/apps/web/content/docs/dev/updates/lease.mdx index 5e4c3af111..13a41e7ac0 100644 --- a/apps/web/content/docs/dev/updates/lease.mdx +++ b/apps/web/content/docs/dev/updates/lease.mdx @@ -58,4 +58,4 @@ The row lock also holds off an instance still running the previous release durin ## The summary behind the AdminCP alerts -`GET /api/@vitnode/core/admin/plugins/updates/summary` feeds the [navigation count, security banner and dashboard widget](/docs/dev/updates/alerts). It needs `plugins.can_view` and reads stored results only - the same `loadUpdatesOverview` the catalog uses, condensed by `summarizeUpdates`. Core and each plugin count as one component. `oldestSuccessAt` is the oldest successful result among packages with an update source, and `null` while any of them has none. The web app reads it through one shared query, `useAdminUpdateSummary`. +`GET /api/@vitnode/core/admin/plugins/updates/summary` feeds the [navigation count, security alert and dashboard widget](/docs/dev/updates/alerts). It needs `plugins.can_view` and reads stored results only - the same `loadUpdatesOverview` the catalog uses, condensed by `summarizeUpdates`. Core and each plugin count as one component. `oldestSuccessAt` is the oldest successful result among packages with an update source, and `null` while any of them has none. The web app reads it through one shared query, `useAdminUpdateSummary`. diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index af68955b77..52103b7d13 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -224,30 +224,35 @@ "system-updates": { "title": "Aktualizacje systemu", "desc": "Wersja rdzenia, dostępne aktualizacje i ostrzeżenia bezpieczeństwa z zapisanych sprawdzeń aktualizacji.", - "core": { - "label": "Rdzeń VitNode", - "unknown": "Nowsza wersja nieznana", - "none": "Brak znanej nowszej wersji", - "available": "Dostępna wersja {version}" - }, - "plugin_updates": "Aktualizacje wtyczek", - "affected": "Objęte aktualizacjami bezpieczeństwa", + "rows": { + "status": "Stan", + "core": "VitNode", + "plugin_updates": "Aktualizacje wtyczek", + "security": "Bezpieczeństwo", + "last_checked": "Ostatnie sprawdzenie" + }, + "status": { + "security": "Wymagana poprawka bezpieczeństwa", + "updates": "Dostępne aktualizacje", + "up_to_date": "Wszystko aktualne", + "unconfirmed": "{count, plural, one {# pakiet niepotwierdzony} few {# pakiety niepotwierdzone} other {# pakietów niepotwierdzonych}}", + "running": "Trwa sprawdzanie...", + "not_checked": "Jeszcze nie sprawdzono", + "unavailable": "Brak źródeł aktualizacji", + "disabled": "Sprawdzanie wyłączone" + }, + "core_update": "dostępna aktualizacja do {version}", + "security_affected": "{count, plural, one {# pakiet} few {# pakiety} other {# pakietów}}", + "security_none": "Brak", + "last_checked": { + "running": "W toku", + "failed": "Niepowodzenie ", + "stale": "Nieaktualne", + "never": "Nigdy" + }, "unknown": "Nieznane", - "up_to_date": "Wszystko jest aktualne.", - "unconfirmed": "Nie udało się potwierdzić aktualności {count, plural, one {# pakietu} other {# pakietów}} - szczegóły na stronie Wtyczki.", "load_error": "Nie udało się wczytać podsumowania aktualizacji. Strona Wtyczki nadal pokazuje wszystkie wyniki.", - "view_plugins": "Zobacz wtyczki", - "freshness": { - "disabled": "Sprawdzanie aktualizacji jest wyłączone w tej instalacji.", - "running": "Trwa sprawdzanie aktualizacji...", - "not_checked": "Jeszcze nie sprawdzono. Codzienne sprawdzanie odbywa się w nocy; możesz też uruchomić je na stronie Wtyczki.", - "unavailable": "Żaden zainstalowany pakiet nie deklaruje źródła aktualizacji.", - "failed": "Ostatnie sprawdzenie nie powiodło się dla {count, plural, one {# pakietu} other {# pakietów}}. Pokazujemy ostatnie znane wyniki.", - "failed_at": "Sprawdzenie nie powiodło się dla {count, plural, one {# pakietu} other {# pakietów}}. Pokazujemy ostatnie znane wyniki.", - "stale": "Jeszcze nie sprawdzono dla tego wdrożenia - wyniki są przeliczane z zapisanych danych wydawców.", - "incomplete": "Jeszcze nie sprawdzono: {count, plural, one {# pakiet} few {# pakiety} other {# pakietów}}.", - "checked": "Wszystkie źródła aktualizacji sprawdzono pomyślnie " - } + "view_plugins": "Zobacz wtyczki" } } }, @@ -490,10 +495,18 @@ }, "updates": { "nav_badge": "{count, plural, one {# dostępna aktualizacja} few {# dostępne aktualizacje} other {# dostępnych aktualizacji}}", - "security_banner": { - "title": "Aktualizacje bezpieczeństwa dotyczą {count, plural, one {# komponentu} other {# komponentów}}.", + "security_pill": { + "label": "{count, plural, one {# aktualizacja bezpieczeństwa} few {# aktualizacje bezpieczeństwa} other {# aktualizacji bezpieczeństwa}}", + "title": "{count, plural, one {# aktualizacja} few {# aktualizacje} other {# aktualizacji}}", + "checked": "Sprawdzono ", + "security": "Poprawki bezpieczeństwa", + "other": "Pozostałe aktualizacje", + "more": "i {count} więcej", + "entry_label": "{title}, aktualizacja do {version}", + "core": "VitNode", "last_known": "Na podstawie ostatnich znanych wyników - najnowsze sprawdzenie nie mogło ich potwierdzić.", - "review": "Przejrzyj aktualizacje" + "load_error": "Nie udało się wczytać listy aktualizacji. Strona Wtyczki nadal pokazuje wszystkie wyniki.", + "open_plugins": "Otwórz Wtyczki" } } }, @@ -1108,7 +1121,6 @@ "stale": "Jeszcze nie sprawdzono dla tego wdrożenia - wyniki pochodzą z danych wydawcy pobranych .", "failed": "Ostatnie sprawdzanie nie powiodło się: {reason}", "failed_with_previous": "Ostatnie sprawdzanie nie powiodło się: {reason} Widoczny jest ostatni udany wynik z .", - "checked_at": "Sprawdzono ", "error": { "blocked_address": "źródło aktualizacji wskazuje na adres prywatny lub lokalny.", "http_error": "źródło aktualizacji odpowiedziało kodem HTTP {detail}.", diff --git a/packages/vitnode/src/api/lib/installed-plugins.ts b/packages/vitnode/src/api/lib/installed-plugins.ts index 50cd09bb5a..1b6f5e3ef4 100644 --- a/packages/vitnode/src/api/lib/installed-plugins.ts +++ b/packages/vitnode/src/api/lib/installed-plugins.ts @@ -1,5 +1,6 @@ import type { FrontendPluginInventory, + PluginAuthor, PluginManifest, } from "@/lib/plugin-manifest"; @@ -10,10 +11,7 @@ export interface InstalledPluginRegistration { version: null | string; } -export interface InstalledPluginAuthor { - name: string; - url: null | string; -} +export type InstalledPluginAuthor = Pick; export interface InstalledPlugin { author: InstalledPluginAuthor | null; diff --git a/packages/vitnode/src/api/lib/updates/check.test.ts b/packages/vitnode/src/api/lib/updates/check.test.ts index cf4a569b78..ad4c67b58f 100644 --- a/packages/vitnode/src/api/lib/updates/check.test.ts +++ b/packages/vitnode/src/api/lib/updates/check.test.ts @@ -108,7 +108,6 @@ const sources = ( const OWNER = "run"; -/** A database whose lease `OWNER` holds; `lost` stands in for its heartbeat. */ const setup = async () => { const memory = createMemoryDb(); const db = memory.db as unknown as Db; @@ -340,7 +339,6 @@ describe("checkForUpdates", () => { }); }); -/** A request that only ends when the check gives up on it. */ const untilAborted: FetchUpdateSource = async (_, { signal }) => await new Promise((_resolve, reject) => { signal?.addEventListener("abort", () => { @@ -361,7 +359,6 @@ describe("checkForUpdates once the lease is lost", () => { ) => void = () => undefined; const fetchSource = vi.fn(async (requested, options) => { if (options.signal) signals.push(options.signal); - // One publisher ignores the cancellation and answers anyway. if (requested === ACME) { return await new Promise(resolve => { answerLate = resolve; @@ -387,13 +384,11 @@ describe("checkForUpdates once the lease is lost", () => { lost.abort(new UpdateCheckLeaseLostError()); await new Promise(resolve => setImmediate(resolve)); expect(signals.every(signal => signal.aborted)).toBe(true); - // Every worker settles before the check gives up - including the late one. expect(settled).toBe(false); answerLate(fetched(acmeManifest(), '"v2"')); await expect(running).rejects.toBeInstanceOf(UpdateCheckLeaseLostError); expect(fetchSource).toHaveBeenCalledTimes(4); - // Cancelled is not failed: the last good copy and its findings stay as they were. expect(stored()).toEqual(before); }); diff --git a/packages/vitnode/src/api/lib/updates/check.ts b/packages/vitnode/src/api/lib/updates/check.ts index 52093c8484..1677942e53 100644 --- a/packages/vitnode/src/api/lib/updates/check.ts +++ b/packages/vitnode/src/api/lib/updates/check.ts @@ -72,11 +72,6 @@ const pick = ( schemaVersion: manifest.schemaVersion, }); -/** - * At most `limit` at a time. The caller's signal, or the first failure, stops - * the rest: nothing new starts, running work sees its signal aborted, and every - * worker has settled before this returns or throws. - */ const mapLimited = async ( items: readonly T[], limit: number, @@ -109,10 +104,8 @@ const mapLimited = async ( if (failure) throw failure.reason; }; -/** Where a check runs and how it learns it lost the lease. */ -export interface UpdateCheckLeaseScope { +interface UpdateCheckLeaseScope { owner: string; - /** Aborted with an `UpdateCheckLeaseLostError` once the lease can no longer be confirmed. */ signal: AbortSignal; } @@ -122,13 +115,6 @@ export interface UpdateCheckLeaseScope { * it and stores the outcome. A source that fails keeps its last valid copy - * and every finding computed from it - and only records the failure, so one * unreachable publisher neither blocks the others nor turns into "up to date". - * - * Every write goes through the lease fence in its own short transaction - one - * per source, holding the source and the results computed from it, then one for - * the clean-up - and never spans a network request. Once the lease is lost the - * check stops: requests in flight are aborted, queued ones never start, and - * nothing more is stored. A cancelled request is not a publisher failure, so - * the stored data stays exactly as the last committed write left it. */ export const checkForUpdates = async ({ channelOverride, @@ -196,8 +182,6 @@ export const checkForUpdates = async ({ const previous = stored.get(url); const members = bySource.get(url) ?? []; const ids = members.map(component => component.id); - // A 304 can only stand in for a copy that is stored and still covers every - // package asked about now; otherwise ask for the full document. const reusable = previous?.document !== null && previous?.document !== undefined && @@ -259,7 +243,6 @@ export const checkForUpdates = async ({ } } } catch (error) { - // Cancelled, not failed: nothing about this source is recorded. signal.throwIfAborted(); outcome = { document: previous?.document ?? null, @@ -267,7 +250,6 @@ export const checkForUpdates = async ({ }; } - signal.throwIfAborted(); const { document, error } = outcome; const evaluated = error === null @@ -283,7 +265,6 @@ export const checkForUpdates = async ({ })) : []; - // The source and every result computed from it commit together, or not at all. await write(signal, async tx => { if (error === null) { const values = { @@ -310,8 +291,6 @@ export const checkForUpdates = async ({ target: core_update_sources.url, }); - // The failure is recorded; what was found before - and the versions it - // was found for - stays exactly as it was. for (const component of members) { await tx .insert(core_update_results) @@ -355,7 +334,6 @@ export const checkForUpdates = async ({ } }); - // Counted only once stored, never for work this run could not commit. if (error !== null) summary.failedSources += 1; for (const { result } of evaluated) { summary.securityUpdates += result.securityUpdates.length; @@ -394,14 +372,8 @@ export type LockedUpdateCheck = status: "busy"; } | { status: "completed"; summary: UpdateCheckSummary } - /** The lease could not be confirmed mid-check, so the check stopped. What it committed before stays; nothing after. */ | { status: "lease_lost" }; -/** - * The whole lease lifecycle, the same for the cron job and the button: take - * the lease, keep renewing it, check, stop the heartbeat and wait for it, then - * give the lease back - which only happens while it is still this run's. - */ export const runLockedUpdateCheck = async ({ manual, ...options @@ -438,8 +410,6 @@ export const runLockedUpdateCheck = async ({ throw error; } finally { await heartbeat.stop(); - // A failure here must not hide the check's own outcome; an unreleased lease - // simply runs out within UPDATE_CHECK_LEASE_MS. await releaseUpdateCheckLease(options.db, lease.owner).catch(() => false); } }; diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.ts b/packages/vitnode/src/api/lib/updates/fetch-source.ts index 73e6978f6a..5465995fdf 100644 --- a/packages/vitnode/src/api/lib/updates/fetch-source.ts +++ b/packages/vitnode/src/api/lib/updates/fetch-source.ts @@ -183,11 +183,7 @@ const readCapped = async ( const transportFailure = ( error: unknown, signal: AbortSignal, - cancel: AbortSignal | undefined, -): unknown => { - // The caller gave up on the check - not something the publisher did, so the - // caller's own reason goes back instead of a failure it would record. - if (cancel?.aborted) return cancel.reason; +): UpdateSourceError => { if (error instanceof UpdateSourceError) return error; if (signal.aborted) return new UpdateSourceError("timeout"); if ( @@ -215,7 +211,6 @@ export interface FetchUpdateSourceOptions { maxBytes?: number; maxRedirects?: number; resolveHost?: ResolveUpdateHost; - /** Aborting it stops the request and rejects with the signal's own reason, never an {@link UpdateSourceError}. */ signal?: AbortSignal; timeoutMs?: number; transport?: UpdateSourceTransport; @@ -252,7 +247,10 @@ export const fetchUpdateSource = async ( for (let hop = 0; hop <= maxRedirects; hop++) { await assertFetchableUrl(url, resolveHost); - if (signal.aborted) throw transportFailure(null, signal, cancel); + if (signal.aborted) { + cancel?.throwIfAborted(); + throw new UpdateSourceError("timeout"); + } let response: UpdateSourceTransportResponse; try { @@ -262,7 +260,8 @@ export const fetchUpdateSource = async ( signal, }); } catch (error) { - throw transportFailure(error, signal, cancel); + cancel?.throwIfAborted(); + throw transportFailure(error, signal); } if (response.status === 304) { @@ -301,7 +300,8 @@ export const fetchUpdateSource = async ( bytes = await readCapped(response.body, maxBytes); } catch (error) { response.destroy(); - throw transportFailure(error, signal, cancel); + cancel?.throwIfAborted(); + throw transportFailure(error, signal); } let body: unknown; diff --git a/packages/vitnode/src/api/lib/updates/heartbeat.test.ts b/packages/vitnode/src/api/lib/updates/heartbeat.test.ts index d2e9fd1d9f..85ff9c851f 100644 --- a/packages/vitnode/src/api/lib/updates/heartbeat.test.ts +++ b/packages/vitnode/src/api/lib/updates/heartbeat.test.ts @@ -5,18 +5,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateCheckLeaseRenewal } from "./lock"; import { startUpdateCheckHeartbeat } from "./heartbeat"; -import { UpdateCheckLeaseLostError } from "./lock"; +import { + UPDATE_CHECK_LEASE_MS, + UPDATE_CHECK_RENEW_INTERVAL_MS, + UpdateCheckLeaseLostError, +} from "./lock"; type Db = ReturnType; -const INTERVAL = 60_000; -const LEASE = 5 * 60_000; -const RENEWED: UpdateCheckLeaseRenewal = { - expiresAt: new Date(0), - status: "renewed", -}; +const INTERVAL = UPDATE_CHECK_RENEW_INTERVAL_MS; +const LEASE = UPDATE_CHECK_LEASE_MS; +const RENEWED: UpdateCheckLeaseRenewal = { status: "renewed" }; -/** A renewal the test answers by hand. */ const deferred = () => { let answer: (renewal: UpdateCheckLeaseRenewal) => void = () => undefined; const promise = new Promise(resolve => { @@ -32,8 +32,6 @@ const start = ( startUpdateCheckHeartbeat({ confirmedAt: Date.now(), db: {} as Db, - intervalMs: INTERVAL, - leaseMs: LEASE, owner: "a", renew, }); @@ -54,7 +52,7 @@ describe("startUpdateCheckHeartbeat", () => { await vi.advanceTimersByTimeAsync(2 * LEASE); expect(renew).toHaveBeenCalledTimes((2 * LEASE) / INTERVAL); - expect(renew).toHaveBeenCalledWith({}, "a", { leaseMs: LEASE }); + expect(renew).toHaveBeenCalledWith({}, "a"); expect(heartbeat.signal.aborted).toBe(false); await heartbeat.stop(); }); @@ -116,13 +114,11 @@ describe("startUpdateCheckHeartbeat", () => { .mockReturnValueOnce(hung.promise); const heartbeat = start(renew); - // Confirmed by the renewal sent at 60 s, so it holds until 60 s + LEASE. await vi.advanceTimersByTimeAsync(INTERVAL + LEASE - 1); expect(heartbeat.signal.aborted).toBe(false); await vi.advanceTimersByTimeAsync(1); expect(heartbeat.signal.reason).toBeInstanceOf(UpdateCheckLeaseLostError); - // A late answer does not bring it back. hung.answer(RENEWED); await heartbeat.stop(); expect(vi.getTimerCount()).toBe(0); diff --git a/packages/vitnode/src/api/lib/updates/heartbeat.ts b/packages/vitnode/src/api/lib/updates/heartbeat.ts index e60459ad35..0352b63420 100644 --- a/packages/vitnode/src/api/lib/updates/heartbeat.ts +++ b/packages/vitnode/src/api/lib/updates/heartbeat.ts @@ -9,32 +9,19 @@ import { type Db = ReturnType; -export interface UpdateCheckHeartbeat { - /** Aborted with an {@link UpdateCheckLeaseLostError} once ownership can no longer be confirmed. */ +interface UpdateCheckHeartbeat { signal: AbortSignal; - /** Stops renewing and waits for a renewal already on its way. Never throws. */ stop: () => Promise; } -/** - * Renews the lease about once a minute while a check runs. One renewal at a - * time: the next is only scheduled after the previous one answered. A renewal - * that says `lost`, fails, or does not answer before the lease could have run - * out stops the check - it never keeps going on ownership it cannot confirm. - */ export const startUpdateCheckHeartbeat = ({ confirmedAt, db, - intervalMs = UPDATE_CHECK_RENEW_INTERVAL_MS, - leaseMs = UPDATE_CHECK_LEASE_MS, owner, renew = renewUpdateCheckLease, }: { - /** `Date.now()` from just before the lease was taken: it runs at least `leaseMs` from then. */ confirmedAt: number; db: Db; - intervalMs?: number; - leaseMs?: number; owner: string; renew?: typeof renewUpdateCheckLease; }): UpdateCheckHeartbeat => { @@ -55,20 +42,18 @@ export const startUpdateCheckHeartbeat = ({ ); }; - // Measured on this instance's own clock from when the renewal was sent, which - // is never later than when the database stamped it. const armDeadline = (from: number) => { clearTimeout(deadlineTimer); deadlineTimer = setTimeout( () => lose(), - Math.max(0, from + leaseMs - Date.now()), + Math.max(0, from + UPDATE_CHECK_LEASE_MS - Date.now()), ); }; const beat = async () => { const sentAt = Date.now(); try { - const renewal = await renew(db, owner, { leaseMs }); + const renewal = await renew(db, owner); if (renewal.status === "lost") { lose(); @@ -88,7 +73,7 @@ export const startUpdateCheckHeartbeat = ({ const schedule = () => { beatTimer = setTimeout(() => { pending = beat(); - }, intervalMs); + }, UPDATE_CHECK_RENEW_INTERVAL_MS); }; armDeadline(confirmedAt); @@ -99,9 +84,6 @@ export const startUpdateCheckHeartbeat = ({ stop: async () => { stopped = true; clearTimeout(beatTimer); - // A renewal on its way is waited for until the deadline gives up on it, - // so one that never answers cannot hang the caller. It never rejects, and - // after the release it could only come back `lost`. if (!controller.signal.aborted) { const aborted = new Promise(resolve => { controller.signal.addEventListener("abort", () => resolve(), { diff --git a/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts b/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts index 6af83649b2..2932d939fe 100644 --- a/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts +++ b/packages/vitnode/src/api/lib/updates/lock.postgres.test.ts @@ -37,12 +37,7 @@ const MIGRATION = resolve( "../../../../../../apps/api/migrations/20261015193600_add_core_update_checks/migration.sql", ); -/** - * `postgres://user@host:port/db`, or a unix socket directory given as - * `postgres://user@/db?host=/tmp&port=5432`. - */ const connectionOptions = (raw: string) => { - // An empty host is how a socket URL is usually written, and `URL` rejects it. const url = new URL(raw.replace(/@\//, "@localhost/")); const param = (name: string) => url.searchParams.get(name); @@ -88,7 +83,6 @@ const answer = const fetched = (stable: string, etag: string) => answer({ body: manifest(stable), etag, kind: "fetched", lastModified: null }); -/** Resolves once `check` holds, polling the database every few milliseconds. */ const until = async (check: () => Promise, timeoutMs = 2000) => { const deadline = Date.now() + timeoutMs; while (!(await check())) { @@ -251,10 +245,8 @@ describe.skipIf(!URL_ENV)("update check lease on PostgreSQL", () => { await writing; }).then(() => events.push("a committed")); let takeover: ReturnType | undefined; - // A failed assertion must not leave a's transaction open for the next test. try { await inside; - // Let a's lease run out by the database clock while its write is still open. await until(async () => { const rows = await db.execute<{ expired: boolean }>( sql`select "expiresAt" <= timezone('utc', clock_timestamp()) as expired from core_update_lock`, @@ -286,7 +278,6 @@ describe.skipIf(!URL_ENV)("update check lease on PostgreSQL", () => { await expect(takeover).resolves.toEqual({ acquired: true, owner: "b" }); expect(events).toEqual(["a committed", "b acquired"]); - // What a committed while it held the lease stays; nothing after. await expect( withUpdateCheckLease(db, "a", async tx => { await tx.delete(core_update_sources); diff --git a/packages/vitnode/src/api/lib/updates/lock.test.ts b/packages/vitnode/src/api/lib/updates/lock.test.ts index 9ce8331dfe..271bf75acd 100644 --- a/packages/vitnode/src/api/lib/updates/lock.test.ts +++ b/packages/vitnode/src/api/lib/updates/lock.test.ts @@ -21,7 +21,6 @@ type Db = ReturnType; const T0 = new Date("2026-10-15T12:00:00Z"); const at = (ms: number) => new Date(T0.getTime() + ms); -/** The memory database reads `clock_timestamp()` off `Date`, so this moves its clock. */ const clockAt = (ms: number) => { vi.setSystemTime(at(ms)); }; @@ -145,11 +144,9 @@ describe("renewUpdateCheckLease", () => { clockAt(UPDATE_CHECK_LEASE_MS - 1000); await expect(renewUpdateCheckLease(db, "a")).resolves.toEqual({ - expiresAt: at(2 * UPDATE_CHECK_LEASE_MS - 1000), status: "renewed", }); - // Past the first lease's end, still held thanks to the renewal. clockAt(UPDATE_CHECK_LEASE_MS + 1000); await expect(take(db, "b")).resolves.toMatchObject({ acquired: false, diff --git a/packages/vitnode/src/api/lib/updates/lock.ts b/packages/vitnode/src/api/lib/updates/lock.ts index 7c4ca2bbab..6ca68ba315 100644 --- a/packages/vitnode/src/api/lib/updates/lock.ts +++ b/packages/vitnode/src/api/lib/updates/lock.ts @@ -13,14 +13,12 @@ const LOCK_NAME = "check"; const ADVISORY_KEY = `core_update_lock:${LOCK_NAME}`; -/** Every source has a 10 s timeout and they run four at a time; the heartbeat keeps a longer check alive. */ export const UPDATE_CHECK_LEASE_MS = 5 * 60_000; export const UPDATE_CHECK_RENEW_INTERVAL_MS = 60_000; export const MANUAL_UPDATE_CHECK_COOLDOWN_MS = 2 * 60_000; -/** This run can no longer prove it holds the lease, so it must stop and write nothing more. */ export class UpdateCheckLeaseLostError extends Error { constructor(options?: ErrorOptions) { super("This instance no longer holds the update check lease", options); @@ -33,31 +31,16 @@ export type UpdateCheckLease = | { acquired: false; reason: "running"; retryAfterSeconds: number } | { acquired: true; owner: string }; -export type UpdateCheckLeaseRenewal = - | { expiresAt: Date; status: "renewed" } - | { status: "lost" }; +export interface UpdateCheckLeaseRenewal { + status: "lost" | "renewed"; +} -/** - * The database's clock, in UTC like every stored timestamp. Every lease - * decision reads it, so API instances whose own clocks disagree still agree on - * who holds the lease and when it runs out. - */ const databaseNow = () => sql`timezone('utc', clock_timestamp())`.mapWith(core_update_lock.expiresAt); -const secondsUntil = (date: Date | null, now: Date): number => - date === null - ? 1 - : Math.max(1, Math.ceil((date.getTime() - now.getTime()) / 1000)); +const secondsUntil = (date: Date, now: Date): number => + Math.max(1, Math.ceil((date.getTime() - now.getTime()) / 1000)); -/** - * The one locking model every lease transaction follows - taking, renewing, - * giving back, and every write a check makes. The advisory lock serialises - * them, including the first insert when no row exists yet; the row lock also - * holds off a writer that does not take the advisory lock, such as an instance - * still running the previous release during a rolling deploy. Both are held - * until the transaction commits. - */ const lockLease = async (tx: UpdateCheckTx, { create = false } = {}) => { await tx.execute( sql`select pg_advisory_xact_lock(hashtext(${ADVISORY_KEY}))`, @@ -85,14 +68,16 @@ const lockLease = async (tx: UpdateCheckTx, { create = false } = {}) => { type LeaseRow = Awaited>; -const isHeld = (row: LeaseRow): boolean => - row?.owner !== null && - row?.owner !== undefined && +type HeldLeaseRow = NonNullable & { expiresAt: Date; owner: string }; + +const isHeld = (row: LeaseRow): row is HeldLeaseRow => + row !== null && + row.owner !== null && row.expiresAt !== null && row.expiresAt > row.now; const isHeldBy = (row: LeaseRow, owner: string): boolean => - isHeld(row) && row?.owner === owner; + isHeld(row) && row.owner === owner; /** * Two instances racing for the lease cannot both win: the lease lock orders @@ -150,25 +135,17 @@ export const acquireUpdateCheckLease = async ( return { acquired: true, owner }; }); -/** - * Pushes the lease out by another `leaseMs` from now - only for the owner, and - * only while its lease has not run out. An owner whose lease expired gets - * `lost`, even when nobody took it over yet: it cannot know what happened in - * between. - */ export const renewUpdateCheckLease = async ( db: Db, owner: string, - { leaseMs = UPDATE_CHECK_LEASE_MS }: { leaseMs?: number } = {}, ): Promise => await db.transaction(async tx => { const row = await lockLease(tx); - if (row === null || !isHeldBy(row, owner)) return { status: "lost" }; + if (!isHeld(row) || row.owner !== owner) return { status: "lost" }; - const expiresAt = new Date(row.now.getTime() + leaseMs); await tx .update(core_update_lock) - .set({ expiresAt }) + .set({ expiresAt: new Date(row.now.getTime() + UPDATE_CHECK_LEASE_MS) }) .where( and( eq(core_update_lock.name, LOCK_NAME), @@ -176,17 +153,9 @@ export const renewUpdateCheckLease = async ( ), ); - return { expiresAt, status: "renewed" }; + return { status: "renewed" }; }); -/** - * The write fence. Runs `write` in one short transaction that first takes the - * lease lock and confirms `owner` still holds an unexpired lease, by the - * database's clock; otherwise it throws {@link UpdateCheckLeaseLostError} and - * writes nothing. Because taking the lease needs the same lock, no other - * instance can take it over between the check and the commit. Never call it - * around a network request - the lock is held until `write` returns. - */ export const withUpdateCheckLease = async ( db: Db, owner: string, diff --git a/packages/vitnode/src/api/lib/updates/status.test.ts b/packages/vitnode/src/api/lib/updates/status.test.ts index b99fefe741..2b5fdc05fc 100644 --- a/packages/vitnode/src/api/lib/updates/status.test.ts +++ b/packages/vitnode/src/api/lib/updates/status.test.ts @@ -50,7 +50,6 @@ const blog = (overrides: Partial = {}): UpdateComponent => ({ const OWNER = "check"; -/** Runs one check the way the locked run does: under the lease, then gives it back. */ const leased = async ( db: Db, options: Omit[0], "db" | "lease">, @@ -314,9 +313,8 @@ describe("summarizeUpdates", () => { expect(summary).toMatchObject({ affectedComponents: 2, checkedComponents: 4, - core: { latestVersion: "2.1.0", state: "checked" }, + core: { latestVersion: "2.1.0" }, pluginUpdatesAvailable: 2, - totalComponents: 4, unconfirmedComponents: 0, updatesAvailable: 3, }); @@ -348,7 +346,6 @@ describe("summarizeUpdates", () => { ]); expect(summary).toMatchObject({ - // A failed check keeps its earlier finding. affectedComponents: 1, checkedComponents: 4, core: { latestVersion: null }, @@ -374,19 +371,4 @@ describe("summarizeUpdates", () => { expect(summary.oldestSuccessAt).toEqual(T1); }); - - it("reports checks turned off and a check in progress as they are", () => { - expect( - summarize( - [state("@vitnode/core", { result: null, state: "unavailable" })], - { enabled: false, running: true }, - ), - ).toMatchObject({ - checkedComponents: 0, - core: { latestVersion: null, state: "unavailable" }, - enabled: false, - running: true, - updatesAvailable: 0, - }); - }); }); diff --git a/packages/vitnode/src/api/lib/updates/status.ts b/packages/vitnode/src/api/lib/updates/status.ts index 5a23cc7c5c..d5cf37e4a0 100644 --- a/packages/vitnode/src/api/lib/updates/status.ts +++ b/packages/vitnode/src/api/lib/updates/status.ts @@ -148,42 +148,19 @@ export const loadUpdatesOverview = async ({ }; }; -/** - * The few numbers every AdminCP surface shares - the navigation count, the - * security banner, the dashboard widget - read off the same overview the - * catalog shows, so they cannot disagree with it. Core counts as one - * component and every plugin as one, whatever registrations it has. - */ export interface UpdatesSummary { - /** Components (core and plugins) a security update affects, each counted once. */ affectedComponents: number; - /** Components with a result - fresh, stale or kept from before a failed check. */ checkedComponents: number; - core: { - /** The newer core release on its channel; `null` when there is none, or none is known. */ - latestVersion: null | string; - state: ComponentUpdateState["state"]; - }; + core: { latestVersion: null | string }; enabled: boolean; - /** Components whose last check failed. Their earlier result, if any, still counts. */ failedComponents: number; - /** The newest check attempt of any component, failed or not. */ lastAttemptAt: Date | null; - /** Components with an update source that no check has covered yet. */ notCheckedComponents: number; - /** - * Every component with an update source has a result at least this recent: - * the oldest of their last successful checks. `null` while any has none. - */ oldestSuccessAt: Date | null; pluginUpdatesAvailable: number; running: boolean; - /** Some result was not checked for what runs now and is recomputed from stored publisher data. */ stale: boolean; - totalComponents: number; - /** Components not known to be up to date or outdated: no update source, not checked, or a status that cannot tell. */ unconfirmedComponents: number; - /** Components with a newer release on their channel - core included. */ updatesAvailable: number; } @@ -196,7 +173,9 @@ export const summarizeUpdates = (overview: UpdatesOverview): UpdatesSummary => { const checkable = components.filter( component => component.state !== "unavailable", ); - const successes = checkable.map(component => component.lastSuccessAt); + const successes = checkable.flatMap(component => + component.lastSuccessAt === null ? [] : [component.lastSuccessAt], + ); const attempts = components.flatMap(component => component.lastAttemptAt === null ? [] : [component.lastAttemptAt], ); @@ -213,7 +192,6 @@ export const summarizeUpdates = (overview: UpdatesOverview): UpdatesSummary => { core !== undefined && isUpdateAvailable(core) ? (core.result?.latest?.version ?? null) : null, - state: core?.state ?? "unavailable", }, enabled: overview.enabled, failedComponents: components.filter( @@ -227,15 +205,12 @@ export const summarizeUpdates = (overview: UpdatesOverview): UpdatesSummary => { component => component.state === "not_checked", ).length, oldestSuccessAt: - successes.length === 0 || successes.includes(null) + successes.length === 0 || successes.length !== checkable.length ? null - : new Date( - Math.min(...successes.map(date => date?.getTime() ?? Infinity)), - ), + : new Date(Math.min(...successes.map(date => date.getTime()))), pluginUpdatesAvailable: plugins.filter(isUpdateAvailable).length, running: overview.running, stale: components.some(component => component.stale), - totalComponents: components.length, unconfirmedComponents: components.filter( component => component.result?.status !== "up_to_date" && diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts index 7b774722d0..2d15752c5d 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts @@ -131,7 +131,6 @@ describe("POST /admin/plugins/updates/check", () => { }); it("answers 409 when the check lost its lease midway", async () => { - // Another instance takes the lease over right before the check's first write. const takenOverMidCheck = (db: Db): Db => { let transactions = 0; diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts index b52e387fb4..6eb8db063e 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.test.ts @@ -2,7 +2,7 @@ import type { drizzle } from "drizzle-orm/postgres-js"; import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; import type { UpdateComponent } from "@/api/lib/updates/components"; @@ -41,7 +41,6 @@ const COMPONENTS: UpdateComponent[] = [ sourceUrl: CORE_SOURCE, versions: ["2.0.0"], }, - // Its API and its web app both report a version: still one plugin. { hasUnknownVersion: false, id: "@acme/blog", @@ -131,33 +130,17 @@ const requestAs = async (permissions: PermissionsStaffArgs[]) => { }; }; -afterEach(() => { - vi.unstubAllGlobals(); -}); - describe("GET /admin/plugins/updates/summary", () => { it("summarises the stored results without starting a check", async () => { - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const { lockBefore, memory, response } = await requestAs([ { module: "plugins", permission: "can_view", plugin: "@vitnode/core" }, ]); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ - affectedComponents: 1, - checkedComponents: 2, - core: { latestVersion: "2.1.0", state: "checked", version: "2.0.0" }, - enabled: true, + core: { latestVersion: "2.1.0", version: "2.0.0" }, oldestSuccessAt: CHECKED_AT.toISOString(), - pluginUpdatesAvailable: 1, - running: false, - totalComponents: 3, - unconfirmedComponents: 1, - updatesAvailable: 2, }); - expect(fetchSpy).not.toHaveBeenCalled(); expect(memory.rows(core_update_lock)).toEqual(lockBefore); }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts index 3c5eea2d89..8af2b6a3c6 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates-summary.route.ts @@ -4,14 +4,14 @@ import { summarizeUpdates, } from "@/api/lib/updates/status"; -import { updatesSummarySchema } from "./updates.schema"; +import { iso, updatesSummarySchema } from "./updates.schema"; export const pluginUpdatesSummaryAdminRoute = buildRoute({ adminStaffPermission: { module: "plugins", permission: "can_view" }, route: { method: "get", description: - "Counts behind the AdminCP update badge, security banner and dashboard widget, from stored results only. Never contacts a publisher or starts a check.", + "Counts behind the AdminCP update badge, security pill and dashboard widget, from stored results only. Never contacts a publisher or starts a check.", path: "/updates/summary", responses: { 200: { @@ -38,8 +38,8 @@ export const pluginUpdatesSummaryAdminRoute = buildRoute({ { ...summary, core: { ...summary.core, version: coreVersion }, - lastAttemptAt: summary.lastAttemptAt?.toISOString() ?? null, - oldestSuccessAt: summary.oldestSuccessAt?.toISOString() ?? null, + lastAttemptAt: iso(summary.lastAttemptAt), + oldestSuccessAt: iso(summary.oldestSuccessAt), }, 200, ); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts index 9999170909..4bd83277e9 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts @@ -60,7 +60,8 @@ export const updatesOverviewSchema = z.object({ plugins: z.array(componentUpdateSchema), }); -const iso = (date: Date | null): null | string => date?.toISOString() ?? null; +export const iso = (date: Date | null): null | string => + date?.toISOString() ?? null; export const serializeComponentUpdate = ( state: ComponentUpdateState, @@ -73,7 +74,6 @@ export const serializeComponentUpdate = ( export const updatesSummarySchema = z.object({ enabled: z.boolean(), running: z.boolean(), - totalComponents: z.number(), checkedComponents: z.number(), updatesAvailable: z.number(), pluginUpdatesAvailable: z.number(), @@ -87,6 +87,5 @@ export const updatesSummarySchema = z.object({ core: z.object({ version: z.string().nullable(), latestVersion: z.string().nullable(), - state: z.enum(["checked", "not_checked", "unavailable"]), }), }); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 205785e3e4..d308a1890c 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -2666,30 +2666,35 @@ "system-updates": { "title": "System updates", "desc": "Core version, available updates and security warnings from the stored update checks.", - "core": { - "label": "VitNode core", - "unknown": "Newer release unknown", - "none": "No newer release known", - "available": "{version} available" + "rows": { + "status": "Status", + "core": "VitNode", + "plugin_updates": "Plugin updates", + "security": "Security", + "last_checked": "Last checked" + }, + "status": { + "security": "Security fix needed", + "updates": "Updates available", + "up_to_date": "Up to date", + "unconfirmed": "{count, plural, one {# package not confirmed} other {# packages not confirmed}}", + "running": "Checking...", + "not_checked": "Not checked yet", + "unavailable": "No update sources", + "disabled": "Checks turned off" + }, + "core_update": "update to {version} available", + "security_affected": "{count, plural, one {# package} other {# packages}}", + "security_none": "None", + "last_checked": { + "running": "In progress", + "failed": "Failed ", + "stale": "Out of date", + "never": "Never" }, - "plugin_updates": "Plugin updates", - "affected": "Affected by security updates", "unknown": "Unknown", - "up_to_date": "Everything is up to date.", - "unconfirmed": "{count, plural, one {# package} other {# packages}} could not be confirmed as up to date - the Plugins page says why.", "load_error": "Could not load the update summary. The Plugins page still shows every result.", - "view_plugins": "View plugins", - "freshness": { - "disabled": "Update checks are turned off for this installation.", - "running": "Checking for updates now...", - "not_checked": "Not checked yet. The daily check runs overnight, or start one on the Plugins page.", - "unavailable": "No installed package declares an update source.", - "failed": "The last check failed for {count, plural, one {# package} other {# packages}}. Showing the last known results.", - "failed_at": "The check failed for {count, plural, one {# package} other {# packages}}. Showing the last known results.", - "stale": "Not checked for this deployment yet - results are recomputed from stored publisher data.", - "incomplete": "{count, plural, one {# package has} other {# packages have}} not been checked yet.", - "checked": "Every update source checked successfully " - } + "view_plugins": "View plugins" } } }, @@ -2752,10 +2757,18 @@ }, "updates": { "nav_badge": "{count, plural, one {# update available} other {# updates available}}", - "security_banner": { - "title": "Security updates affect {count, plural, one {# component} other {# components}}.", + "security_pill": { + "label": "{count, plural, one {# security update} other {# security updates}}", + "title": "{count, plural, one {# update} other {# updates}}", + "checked": "Checked ", + "security": "Security fixes", + "other": "Other updates", + "more": "and {count} more", + "entry_label": "{title}, update to {version}", + "core": "VitNode", "last_known": "Based on the last known results - the latest check could not confirm them.", - "review": "Review updates" + "load_error": "Could not load the update list. The Plugins page still shows every result.", + "open_plugins": "Open Plugins" } } }, @@ -3854,7 +3867,6 @@ "stale": "Not checked for this deployment yet - showing results from publisher data fetched .", "failed": "Last check failed: {reason}", "failed_with_previous": "Last check failed: {reason} Showing the last successful result, from .", - "checked_at": "Checked ", "error": { "blocked_address": "the update source points to a private or local address.", "http_error": "the update source answered HTTP {detail}.", diff --git a/packages/vitnode/src/tanstack/admin/shell.tsx b/packages/vitnode/src/tanstack/admin/shell.tsx index c28d970d87..d0012b84a3 100644 --- a/packages/vitnode/src/tanstack/admin/shell.tsx +++ b/packages/vitnode/src/tanstack/admin/shell.tsx @@ -14,7 +14,7 @@ import { NavSidebarAdminContent } from "@/views/admin/layouts/sidebar/nav/nav-co import { AdminUpdateNavBadges } from "@/views/admin/layouts/sidebar/nav/update-nav-badges"; import { SidebarAdminContent } from "@/views/admin/layouts/sidebar/sidebar-content"; import { AdminSidebarToggle } from "@/views/admin/layouts/sidebar/sidebar-toggle"; -import { AdminUpdateSecurityBanner } from "@/views/admin/layouts/update-security-banner"; +import { AdminUpdateSecurityPill } from "@/views/admin/layouts/update-security-pill"; import { RouteMessages } from "../i18n/route-messages"; import { RouteGuardPending } from "../pending/guard-pending"; @@ -90,12 +90,11 @@ const AdminShellFrame = ({
{breadcrumb}
+
- - {children} diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index d7ca994827..88a93141ce 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -533,7 +533,6 @@ const call = ( ) { return new Date(); } - // Dates here are already absolute instants, so shifting them to UTC changes nothing. if (node.name === "timezone" && args.length === 2) { return args[1] instanceof Date ? args[1] : unsupported("timezone()"); } diff --git a/packages/vitnode/src/tests/plugin-updates.ts b/packages/vitnode/src/tests/plugin-updates.ts new file mode 100644 index 0000000000..0a176ee492 --- /dev/null +++ b/packages/vitnode/src/tests/plugin-updates.ts @@ -0,0 +1,75 @@ +import type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import type { AdminInstalledPlugin } from "@/views/admin/views/core/system/plugins/plugins-query"; + +type UpdateResult = NonNullable; + +export const securityFinding = ( + fixed: null | string, +): UpdateResult["securityUpdates"][number] => ({ + affected: "<9.9.9", + fixed, + id: `ACME-${fixed ?? "none"}`, + url: "https://acme.example.com/advisories/1", + versions: ["1.0.0"], +}); + +export const componentUpdate = ( + id: string, + result: null | Partial = {}, +): AdminComponentUpdate => ({ + id, + lastAttemptAt: "2026-10-15T03:00:00.000Z", + lastError: null, + lastSuccessAt: "2026-10-15T03:00:00.000Z", + result: + result === null + ? null + : { + alternative: null, + channel: "stable", + compatibility: "compatible", + latest: { + publishedAt: null, + requiresCore: null, + url: null, + version: "1.1.0", + }, + securityUpdates: [], + status: "update_available", + ...result, + }, + sourceUrl: "https://acme.example.com/updates.json", + stale: false, + state: "checked", +}); + +export const pluginUpdates = ( + core: AdminComponentUpdate, + plugins: AdminComponentUpdate[] = [], +): AdminPluginUpdates => ({ + channelOverride: null, + core, + enabled: true, + lastManualAt: null, + manualCooldownSeconds: 0, + plugins, + running: false, +}); + +export const installedPlugin = ( + id: string, + title: string, +): AdminInstalledPlugin => ({ + author: null, + description: null, + icon: null, + id, + license: null, + registrations: { api: null, frontend: null }, + title, + version: "1.0.0", + versionMismatch: false, +}); diff --git a/packages/vitnode/src/tests/update-summary.ts b/packages/vitnode/src/tests/update-summary.ts index e2cc2eecf1..79d14391d1 100644 --- a/packages/vitnode/src/tests/update-summary.ts +++ b/packages/vitnode/src/tests/update-summary.ts @@ -1,12 +1,11 @@ import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; -/** A summary of an install whose every component was checked and is up to date. */ export const updateSummary = ( overrides: Partial = {}, ): AdminUpdateSummary => ({ affectedComponents: 0, checkedComponents: 3, - core: { latestVersion: null, state: "checked", version: "2.0.0" }, + core: { latestVersion: null, version: "2.0.0" }, enabled: true, failedComponents: 0, lastAttemptAt: "2026-10-15T03:00:00.000Z", @@ -15,7 +14,6 @@ export const updateSummary = ( pluginUpdatesAvailable: 0, running: false, stale: false, - totalComponents: 3, unconfirmedComponents: 0, updatesAvailable: 0, ...overrides, diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx index 38b7e2bd00..661f9c1630 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.tsx @@ -74,7 +74,6 @@ export const ItemNavAdminContent = ({ }; const isIconOnly = state === "collapsed" && !isMobile; - // A parent stands in for its sub-items' badges while they are out of sight. const badge = items.length ? isIconOnly || !open ? sumAdminNavBadges( diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx index c18f1d7abb..c890a30e49 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-badges.tsx @@ -1,7 +1,5 @@ -import { cn } from "cn"; import React from "react"; -/** A count next to a navigation item, with what it counts for screen readers. */ export interface AdminNavBadge { count: number; label: string; @@ -13,7 +11,6 @@ const NO_BADGES: AdminNavBadges = {}; const AdminNavBadgesContext = React.createContext(NO_BADGES); -/** Badges keyed by the `href` of the item they belong to. */ export const AdminNavBadgesProvider = ({ children, value, @@ -25,7 +22,6 @@ export const AdminNavBadgesProvider = ({ export const useAdminNavBadges = (): AdminNavBadges => React.use(AdminNavBadgesContext); -/** What a parent shows while its sub-items are out of sight. */ export const sumAdminNavBadges = ( badges: AdminNavBadges, hrefs: readonly string[], @@ -43,26 +39,14 @@ export const sumAdminNavBadges = ( }; }; -export const NavBadge = ({ - badge, - className, -}: { - badge: AdminNavBadge | undefined; - className?: string; -}) => +export const NavBadge = ({ badge }: { badge: AdminNavBadge | undefined }) => badge === undefined ? null : ( - + {badge.count} {badge.label} ); -/** The collapsed sidebar's icon-only button has no room for a number. */ export const NavBadgeDot = ({ badge }: { badge: AdminNavBadge }) => ( <> { + it("splits core and plugins into security and other updates", () => { + const groups = updateGroupsOf({ + coreTitle: "VitNode", + plugins: [installedPlugin("@acme/blog", "Blog")], + updates: pluginUpdates(componentUpdate("core"), [ + componentUpdate("@acme/blog", { + securityUpdates: [securityFinding(null), securityFinding("1.0.1")], + }), + componentUpdate("@acme/shop", { + latest: null, + securityUpdates: [securityFinding(null)], + status: "up_to_date", + }), + componentUpdate("@acme/up-to-date", { status: "up_to_date" }), + componentUpdate("@acme/unchecked", null), + ]), + }); + + expect(groups).toEqual({ + other: [ + { id: "core", security: false, title: "VitNode", version: "1.1.0" }, + ], + security: [ + { id: "@acme/blog", security: true, title: "Blog", version: "1.1.0" }, + { + id: "@acme/shop", + security: true, + title: "@acme/shop", + version: null, + }, + ], + }); + }); + + it("falls back to the first fixed version when no newer release is available", () => { + const groups = updateGroupsOf({ + coreTitle: "VitNode", + plugins: undefined, + updates: pluginUpdates( + componentUpdate("core", { + securityUpdates: [securityFinding(null), securityFinding("2.0.1")], + status: "up_to_date", + }), + ), + }); + + expect(groups.security[0]?.version).toBe("2.0.1"); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/update-entries.ts b/packages/vitnode/src/views/admin/layouts/update-entries.ts new file mode 100644 index 0000000000..65b8419005 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-entries.ts @@ -0,0 +1,64 @@ +import type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import type { AdminInstalledPlugins } from "@/views/admin/views/core/system/plugins/plugins-query"; + +export interface UpdateEntry { + id: string; + security: boolean; + title: string; + version: null | string; +} + +interface UpdateGroups { + other: UpdateEntry[]; + security: UpdateEntry[]; +} + +const entryOf = ( + update: AdminComponentUpdate, + title: string, +): null | UpdateEntry => { + const result = update.result; + if (result === null) return null; + + const security = result.securityUpdates.length > 0; + const available = result.status === "update_available"; + if (!security && !available) return null; + + return { + id: update.id, + security, + title, + version: + (available ? result.latest?.version : null) ?? + result.securityUpdates.find(finding => finding.fixed !== null)?.fixed ?? + null, + }; +}; + +export const updateGroupsOf = ({ + coreTitle, + plugins, + updates, +}: { + coreTitle: string; + plugins: AdminInstalledPlugins["plugins"] | undefined; + updates: AdminPluginUpdates; +}): UpdateGroups => { + const titleById = new Map( + (plugins ?? []).map(plugin => [plugin.id, plugin.title]), + ); + const entries = [ + entryOf(updates.core, coreTitle), + ...updates.plugins.map(update => + entryOf(update, titleById.get(update.id) ?? update.id), + ), + ].filter(entry => entry !== null); + + return { + other: entries.filter(entry => !entry.security), + security: entries.filter(entry => entry.security), + }; +}; diff --git a/packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx b/packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx deleted file mode 100644 index 6a0bdd9ed5..0000000000 --- a/packages/vitnode/src/views/admin/layouts/update-security-banner.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - createMemoryHistory, - createRootRoute, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; - -import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; -import { updateSummary as summary } from "@/tests/update-summary"; -import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; - -import { ADMIN_PLUGINS_HREF } from "./sidebar/nav/nav-model"; -import { AdminUpdateSecurityBanner } from "./update-security-banner"; - -const T = "admin.global.updates.security_banner"; - -const renderBanner = async (data: AdminUpdateSummary) => { - const queryClient = new QueryClient(); - queryClient.setQueryData(updateSummaryQueryKey, data); - const router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/admin"] }), - routeTree: createRootRoute({ - component: () => ( - <> -

page

- - - ), - }), - }); - - render( - - - - - , - ); - await screen.findByText("page"); -}; - -describe("AdminUpdateSecurityBanner", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("warns about a security update and links to the plugins", async () => { - await renderBanner(summary({ affectedComponents: 2 })); - - expect( - screen.getByRole("region", { name: `${T}.title` }).textContent, - ).not.toContain(`${T}.last_known`); - expect( - screen.getByRole("link", { name: `${T}.review` }).getAttribute("href"), - ).toBe(ADMIN_PLUGINS_HREF); - }); - - it("says the finding is the last known one when a check failed since", async () => { - await renderBanner(summary({ affectedComponents: 1, failedComponents: 1 })); - - expect(screen.getByText(`${T}.last_known`)).toBeTruthy(); - }); - - it.each([ - ["nothing is affected", summary()], - [ - "checks are turned off", - summary({ affectedComponents: 1, enabled: false }), - ], - ])("stays hidden when %s", async (_, data) => { - await renderBanner(data); - - expect(screen.queryByRole("region")).toBeNull(); - }); -}); diff --git a/packages/vitnode/src/views/admin/layouts/update-security-banner.tsx b/packages/vitnode/src/views/admin/layouts/update-security-banner.tsx deleted file mode 100644 index 471e8944de..0000000000 --- a/packages/vitnode/src/views/admin/layouts/update-security-banner.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ShieldAlertIcon } from "lucide-react"; -import { useTranslations } from "use-intl"; - -import { useAdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; - -import { AdminLink } from "./admin-link"; -import { ADMIN_PLUGINS_HREF } from "./sidebar/nav/nav-model"; - -/** - * One line across the top of every AdminCP page while a security update - * affects something installed. Gone as soon as no evaluated finding applies - - * but never because a publisher could not be reached: a failed check keeps the - * last known findings, and says so. - */ -export const AdminUpdateSecurityBanner = () => { - const t = useTranslations("admin.global.updates.security_banner"); - const { data } = useAdminUpdateSummary(); - - if (!data?.enabled || data.affectedComponents === 0) return null; - - const lastKnown = data.failedComponents > 0 || data.stale; - - return ( -
- -

- - {t("title", { count: data.affectedComponents })} - {" "} - {lastKnown ? ( - {t("last_known")} - ) : null} -

- - {t("review")} - -
- ); -}; diff --git a/packages/vitnode/src/views/admin/layouts/update-security-pill.test.tsx b/packages/vitnode/src/views/admin/layouts/update-security-pill.test.tsx new file mode 100644 index 0000000000..760ee71b9d --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-security-pill.test.tsx @@ -0,0 +1,114 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { + componentUpdate, + installedPlugin, + pluginUpdates, + securityFinding, +} from "@/tests/plugin-updates"; +import { updateSummary as summary } from "@/tests/update-summary"; +import { pluginUpdatesQueryKey } from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import { installedPluginsQueryKey } from "@/views/admin/views/core/system/plugins/plugins-query"; +import { updateSummaryQueryKey } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import { AdminUpdateSecurityPill } from "./update-security-pill"; + +const T = "admin.global.updates.security_pill"; + +const SECURE_PLUGINS = ["a", "b", "c", "d", "e"].map(id => + componentUpdate(`@acme/${id}`, { + securityUpdates: [securityFinding("2.0.0")], + }), +); + +const renderPill = async (data: AdminUpdateSummary) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(updateSummaryQueryKey, data); + queryClient.setQueryData( + pluginUpdatesQueryKey, + pluginUpdates(componentUpdate("core"), SECURE_PLUGINS), + ); + queryClient.setQueryData(installedPluginsQueryKey, { + core: { author: null, license: null, version: "2.0.0" }, + frontendInventory: true, + plugins: [installedPlugin("@acme/a", "Alpha")], + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/admin"] }), + routeTree: createRootRoute({ + component: () => ( + <> +

page

+ + + ), + }), + }); + + render( + + + + + + + , + ); + await screen.findByText("page"); +}; + +describe("AdminUpdateSecurityPill", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ["nothing is affected", summary()], + [ + "checks are turned off", + summary({ affectedComponents: 1, enabled: false }), + ], + ])("stays hidden when %s", async (_, data) => { + await renderPill(data); + + expect(screen.queryByRole("button", { name: `${T}.label` })).toBeNull(); + }); + + it("lists the security and other updates once opened", async () => { + await renderPill(summary({ affectedComponents: 5 })); + + fireEvent.click(screen.getByRole("button", { name: `${T}.label` })); + + const security = await screen.findByRole("region", { + name: `${T}.security 5`, + }); + expect(within(security).getByText("Alpha")).toBeTruthy(); + expect(within(security).getByText("@acme/d")).toBeTruthy(); + expect(within(security).queryByText("@acme/e")).toBeNull(); + expect(within(security).getByText(`${T}.more`)).toBeTruthy(); + expect( + within(screen.getByRole("region", { name: `${T}.other 1` })).getByText( + `${T}.core`, + ), + ).toBeTruthy(); + expect( + screen.getByRole("link", { name: `${T}.open_plugins` }), + ).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/views/admin/layouts/update-security-pill.tsx b/packages/vitnode/src/views/admin/layouts/update-security-pill.tsx new file mode 100644 index 0000000000..623db9d454 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/update-security-pill.tsx @@ -0,0 +1,228 @@ +import type { LucideIcon } from "lucide-react"; + +import { useQuery } from "@tanstack/react-query"; +import { cn } from "cn"; +import { DownloadIcon, ShieldAlertIcon, TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; +import { + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { pluginUpdatesQueryOptions } from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +import { installedPluginsQueryOptions } from "@/views/admin/views/core/system/plugins/plugins-query"; +import { + type AdminUpdateSummary, + useAdminUpdateSummary, +} from "@/views/admin/views/core/system/plugins/update-summary-query"; + +import type { UpdateEntry } from "./update-entries"; + +import { AdminLink } from "./admin-link"; +import { ADMIN_PLUGINS_HREF } from "./sidebar/nav/nav-model"; +import { updateGroupsOf } from "./update-entries"; + +const GROUP_LIMIT = 4; + +const UpdateGroup = ({ + entries, + icon: Icon, + id, + onNavigate, + title, + tone, +}: { + entries: UpdateEntry[]; + icon: LucideIcon; + id: string; + onNavigate: () => void; + title: string; + tone: "destructive" | "muted"; +}) => { + const t = useTranslations("admin.global.updates.security_pill"); + + if (entries.length === 0) return null; + + const shown = entries.slice(0, GROUP_LIMIT); + const hidden = entries.length - shown.length; + + return ( +
+

+ + {title} + {entries.length} +

+
    + {shown.map(entry => ( +
  • + + {entry.title} + {entry.version === null ? null : ( + + {entry.version} + + )} + +
  • + ))} +
+ {hidden > 0 ? ( +

+ {t("more", { count: hidden })} +

+ ) : null} +
+ ); +}; + +const UpdateGroupsSkeleton = () => ( +
+ + + + +
+); + +const SecurityPillContent = ({ + onNavigate, + summary, +}: { + onNavigate: () => void; + summary: AdminUpdateSummary; +}) => { + const t = useTranslations("admin.global.updates.security_pill"); + const updates = useQuery(pluginUpdatesQueryOptions()); + const installed = useQuery(installedPluginsQueryOptions()); + const lastKnown = summary.failedComponents > 0 || summary.stale; + const { lastAttemptAt } = summary; + + const groups = React.useMemo( + () => + updates.data === undefined + ? null + : updateGroupsOf({ + coreTitle: t("core"), + plugins: installed.data?.plugins, + updates: updates.data, + }), + [installed.data, t, updates.data], + ); + const total = + groups === null ? null : groups.security.length + groups.other.length; + + return ( +
+
+ + {total === null + ? t("label", { count: summary.affectedComponents }) + : t("title", { count: total })} + + {lastAttemptAt === null ? null : ( +

+ {t.rich("checked", { + date: () => , + })} +

+ )} +
+ + {updates.isError ? ( +

+ {t("load_error")} +

+ ) : groups === null ? ( + + ) : ( +
+ + +
+ )} + + {lastKnown ? ( +

+ + {t("last_known")} +

+ ) : null} + + + {t("open_plugins")} + +
+ ); +}; + +export const AdminUpdateSecurityPill = () => { + const t = useTranslations("admin.global.updates.security_pill"); + const { data } = useAdminUpdateSummary(); + const [open, setOpen] = React.useState(false); + + if (!data?.enabled || data.affectedComponents === 0) return null; + + const label = t("label", { count: data.affectedComponents }); + + return ( + + + + + {data.affectedComponents} + + + {label} + + + + setOpen(false)} summary={data} /> + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx index 2323c8ccbe..e5ae132112 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx @@ -19,9 +19,9 @@ import { SystemUpdatesWidget } from "./system-updates-widget"; const T = "admin.dashboard.widgets.system-updates"; -const renderWidget = async (data: AdminUpdateSummary) => { +const renderWidget = async (data?: AdminUpdateSummary) => { const queryClient = new QueryClient(); - queryClient.setQueryData(updateSummaryQueryKey, data); + if (data !== undefined) queryClient.setQueryData(updateSummaryQueryKey, data); const router = createRouter({ history: createMemoryHistory({ initialEntries: ["/admin"] }), routeTree: createRootRoute({ component: SystemUpdatesWidget }), @@ -39,15 +39,6 @@ const renderWidget = async (data: AdminUpdateSummary) => { await screen.findByText(`${T}.view_plugins`); }; -const NOT_CHECKED = summary({ - checkedComponents: 0, - core: { latestVersion: null, state: "not_checked", version: "2.0.0" }, - lastAttemptAt: null, - notCheckedComponents: 3, - oldestSuccessAt: null, - unconfirmedComponents: 3, -}); - describe("SystemUpdatesWidget", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); @@ -58,42 +49,46 @@ describe("SystemUpdatesWidget", () => { }); it.each([ - ["nothing was checked yet", NOT_CHECKED, `${T}.freshness.not_checked`], [ - "checks are turned off", - summary({ enabled: false }), - `${T}.freshness.disabled`, + "nothing was checked yet", + summary({ + checkedComponents: 0, + lastAttemptAt: null, + notCheckedComponents: 3, + oldestSuccessAt: null, + }), + 2, ], + ["checks are turned off", summary({ enabled: false }), 3], ])( "shows the counts as unknown, never 0, while %s", - async (_, data, status) => { + async (_, data, unknown) => { await renderWidget(data); - expect(screen.getByText(status)).toBeTruthy(); - expect(screen.getAllByText(`${T}.unknown`)).toHaveLength(2); + expect(screen.getAllByText(`${T}.unknown`)).toHaveLength(unknown); expect(screen.queryByText("0")).toBeNull(); - expect(screen.queryByText(`${T}.up_to_date`)).toBeNull(); + expect(screen.queryByText(`${T}.security_none`)).toBeNull(); }, ); - it("calls everything up to date only once every component is confirmed", async () => { - await renderWidget(summary()); + it("shows the checked counts and the newer core version", async () => { + await renderWidget( + summary({ + affectedComponents: 1, + core: { latestVersion: "2.1.0", version: "2.0.0" }, + pluginUpdatesAvailable: 2, + }), + ); - expect(screen.getByText(`${T}.freshness.checked`)).toBeTruthy(); - expect(screen.getByText(`${T}.up_to_date`)).toBeTruthy(); + expect(screen.getByText("2")).toBeTruthy(); + expect(screen.getByText(`${T}.core_update`)).toBeTruthy(); + expect(screen.getByText(`${T}.security_affected`)).toBeTruthy(); + expect(screen.queryByText(`${T}.unknown`)).toBeNull(); }); - it.each([ - ["a result is stale", { stale: true }], - ["a check failed", { failedComponents: 1 }], - ["a component cannot be confirmed", { unconfirmedComponents: 1 }], - ["a security update applies", { affectedComponents: 1 }], - ])( - "does not call it up to date when %s", - async (_, overrides: Partial) => { - await renderWidget(summary(overrides)); + it("points to the plugins page when the summary cannot load", async () => { + await renderWidget(); - expect(screen.queryByText(`${T}.up_to_date`)).toBeNull(); - }, - ); + expect(await screen.findByText(`${T}.load_error`)).toBeTruthy(); + }); }); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx index 3cbd99beac..38e1702952 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx @@ -1,11 +1,7 @@ import { cn } from "cn"; import { ArrowRightIcon, - CircleCheckIcon, - CircleDashedIcon, - CircleOffIcon, - ClockAlertIcon, - LoaderCircleIcon, + ShieldAlertIcon, TriangleAlertIcon, } from "lucide-react"; import { useTranslations } from "use-intl"; @@ -18,118 +14,42 @@ import { useAdminUpdateSummary, } from "@/views/admin/views/core/system/plugins/update-summary-query"; -import { WidgetContentSkeleton } from "../../grid/widget-skeleton"; - -const TONE = { - destructive: "text-destructive", - muted: "text-muted-foreground", - success: "text-success", - warn: "text-warn", -} as const; - -type Tone = keyof typeof TONE; - -const StatusLine = ({ - children, - icon: Icon, - spin, - tone, -}: { - children: React.ReactNode; - icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>; - spin?: boolean; - tone: Tone; -}) => ( -

- - {children} -

-); - -/** How fresh the numbers are - the first thing to read before trusting them. */ -const Freshness = ({ summary }: { summary: AdminUpdateSummary }) => { - const t = useTranslations("admin.dashboard.widgets.system-updates.freshness"); - const date = (value: string) => () => ; +import type { UpdateStatus } from "./update-status"; - if (!summary.enabled) { - return ( - - {t("disabled")} - - ); - } - if (summary.running) { - return ( - - {t("running")} - - ); - } - if (summary.checkedComponents === 0) { - return ( - - {summary.notCheckedComponents > 0 ? t("not_checked") : t("unavailable")} - - ); - } - if (summary.failedComponents > 0) { - return ( - - {summary.lastAttemptAt === null - ? t("failed", { count: summary.failedComponents }) - : t.rich("failed_at", { - count: summary.failedComponents, - date: date(summary.lastAttemptAt), - })} - - ); - } - if (summary.stale) { - return ( - - {t("stale")} - - ); - } - if (summary.oldestSuccessAt === null) { - return ( - - {t("incomplete", { count: summary.notCheckedComponents })} - - ); - } +import { WidgetContentSkeleton } from "../../grid/widget-skeleton"; +import { + compactVersion, + isKnownUpdateStatus, + updateStatusOf, +} from "./update-status"; + +const STATUS_TONE: Record = { + disabled: "text-muted-foreground", + not_checked: "text-muted-foreground", + running: "text-muted-foreground", + security: "text-destructive", + unavailable: "text-muted-foreground", + unconfirmed: "text-muted-foreground", + up_to_date: "text-success", + updates: "text-primary", +}; - return ( - - {t.rich("checked", { date: date(summary.oldestSuccessAt) })} - - ); +const STATUS_TEXT_TONE: Partial> = { + security: STATUS_TONE.security, + up_to_date: STATUS_TONE.up_to_date, + updates: STATUS_TONE.updates, }; -const Stat = ({ +const Row = ({ children, label, - tone, }: { children: React.ReactNode; label: string; - tone?: Tone; }) => ( -
-
{label}
-
+
+
{label}
+
{children}
@@ -146,11 +66,35 @@ const Unknown = () => { ); }; -/** - * Core's version and what the stored update checks know about core and - * plugins. Reads the summary every AdminCP surface shares; it never starts a - * check - that stays a button on the Plugins page. - */ +const Warning = ({ children }: { children: React.ReactNode }) => ( + + + {children} + +); + +const LastChecked = ({ summary }: { summary: AdminUpdateSummary }) => { + const t = useTranslations( + "admin.dashboard.widgets.system-updates.last_checked", + ); + const date = (value: string) => () => ; + + if (!summary.enabled) return ; + if (summary.running) return t("running"); + if (summary.failedComponents > 0 && summary.lastAttemptAt !== null) { + return ( + + {t.rich("failed", { date: date(summary.lastAttemptAt) })} + + ); + } + if (summary.stale) return {t("stale")}; + + const checkedAt = summary.oldestSuccessAt ?? summary.lastAttemptAt; + + return checkedAt === null ? t("never") : ; +}; + export const SystemUpdatesWidget = () => { const t = useTranslations("admin.dashboard.widgets.system-updates"); const { data, isError } = useAdminUpdateSummary(); @@ -158,66 +102,76 @@ export const SystemUpdatesWidget = () => { if (isError) { return (
- - {t("load_error")} - +

+ + {t("load_error")} +

); } if (data === undefined) return ; - // Before anything was checked a count of 0 would read as "nothing to do". - const known = data.enabled && data.checkedComponents > 0; - const upToDate = - known && - data.updatesAvailable === 0 && - data.affectedComponents === 0 && - data.unconfirmedComponents === 0 && - data.failedComponents === 0 && - !data.stale; + const status = updateStatusOf(data); + const known = isKnownUpdateStatus(status); return ( -
-
- -
- -
- - - {data.core.version ?? } - - - {!known || data.core.state !== "checked" - ? t("core.unknown") - : data.core.latestVersion === null - ? t("core.none") - : t("core.available", { version: data.core.latestVersion })} +
+
+ + + + {t(`status.${status}`, { count: data.unconfirmedComponents })} - - + + + {data.core.version === null ? ( + + ) : ( + {data.core.version} + )} + {known && data.core.latestVersion !== null ? ( + + + → {compactVersion(data.core.version, data.core.latestVersion)} + + + {t("core_update", { version: data.core.latestVersion })} + + + ) : null} + + {known ? data.pluginUpdatesAvailable : } - - 0 ? "destructive" : undefined - } - > - {known ? data.affectedComponents : } - + + + {!known ? ( + + ) : data.affectedComponents > 0 ? ( + + + {t("security_affected", { count: data.affectedComponents })} + + ) : ( + t("security_none") + )} + + + +
- - {known && (upToDate || data.unconfirmedComponents > 0) ? ( -

- {upToDate - ? t("up_to_date") - : t("unconfirmed", { count: data.unconfirmedComponents })} -

- ) : null} - - +
+ +
); }; @@ -227,7 +181,7 @@ const ViewPlugins = () => { return ( {t("view_plugins")} diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts new file mode 100644 index 0000000000..b52680e226 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { updateSummary as summary } from "@/tests/update-summary"; + +import { compactVersion, updateStatusOf } from "./update-status"; + +describe("updateStatusOf", () => { + it.each([ + ["disabled", summary({ enabled: false, running: true })], + [ + "running", + summary({ checkedComponents: 0, notCheckedComponents: 2, running: true }), + ], + ["not_checked", summary({ checkedComponents: 0, notCheckedComponents: 2 })], + ["unavailable", summary({ checkedComponents: 0 })], + ["security", summary({ affectedComponents: 1, updatesAvailable: 2 })], + ["updates", summary({ unconfirmedComponents: 1, updatesAvailable: 2 })], + ["unconfirmed", summary({ unconfirmedComponents: 1 })], + ["up_to_date", summary()], + ])("returns %s", (status, data) => { + expect(updateStatusOf(data)).toBe(status); + }); +}); + +describe("compactVersion", () => { + it.each([ + ["2.0.0-canary.4", "2.0.0-canary.7", "canary.7"], + ["1.9.0-canary.4", "2.0.0-canary.7", "2.0.0-canary.7"], + ["2.0.0", "2.1.0", "2.1.0"], + ["2.0.0", "2.0.0-canary.7", "2.0.0-canary.7"], + [null, "2.0.0-canary.7", "2.0.0-canary.7"], + ])("shortens %s -> %s to %s", (current, latest, expected) => { + expect(compactVersion(current, latest)).toBe(expected); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts new file mode 100644 index 0000000000..59425e7dca --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts @@ -0,0 +1,47 @@ +import { parse } from "semver"; + +import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins/update-summary-query"; + +export type UpdateStatus = + | "disabled" + | "not_checked" + | "running" + | "security" + | "unavailable" + | "unconfirmed" + | "up_to_date" + | "updates"; + +export const updateStatusOf = (summary: AdminUpdateSummary): UpdateStatus => { + if (!summary.enabled) return "disabled"; + if (summary.running) return "running"; + if (summary.checkedComponents === 0) { + return summary.notCheckedComponents > 0 ? "not_checked" : "unavailable"; + } + if (summary.affectedComponents > 0) return "security"; + if (summary.updatesAvailable > 0) return "updates"; + if (summary.unconfirmedComponents > 0) return "unconfirmed"; + + return "up_to_date"; +}; + +export const isKnownUpdateStatus = (status: UpdateStatus): boolean => + status === "security" || + status === "updates" || + status === "unconfirmed" || + status === "up_to_date"; + +export const compactVersion = (current: null | string, latest: string) => { + const next = parse(latest); + const installed = current === null ? null : parse(current); + + return next !== null && + installed !== null && + next.prerelease.length > 0 && + installed.prerelease.length > 0 && + next.major === installed.major && + next.minor === installed.minor && + next.patch === installed.patch + ? next.prerelease.join(".") + : latest; +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx index 73ec46ee1a..c0cb3e185f 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.test.tsx @@ -13,7 +13,6 @@ import { useAdminUpdateSummary, } from "./update-summary-query"; -/** Stands in for the navigation badge, the banner and the widget: any reader of the summary. */ const SummaryReader = () => { useAdminUpdateSummary(); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx index 5b68d241d5..d89edd7464 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-list.tsx @@ -1,15 +1,5 @@ import { cn } from "cn"; -import { - ArrowUpCircleIcon, - ChevronDownIcon, - CircleCheckIcon, - CircleDashedIcon, - CircleHelpIcon, - CircleOffIcon, - PackageIcon, - ShieldAlertIcon, - TriangleAlertIcon, -} from "lucide-react"; +import { ChevronDownIcon, PackageIcon, TriangleAlertIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import { DateFormat } from "@/components/date-format"; @@ -27,11 +17,11 @@ import { FrameTitle, } from "@/components/ui/frame"; +import type { PluginRow } from "./plugin-rows"; import type { PluginTone } from "./plugin-tone"; -import type { AdminComponentUpdate } from "./plugin-updates-query"; -import type { AdminInstalledPlugin, AdminPluginAuthor } from "./plugins-query"; import { PluginIcon } from "./plugin-icon"; +import { PLUGIN_TONE_STYLE } from "./plugin-tone"; import { ExternalLink, PluginUpdateStatus, @@ -41,59 +31,9 @@ import { } from "./plugin-update-status"; import { PluginVersion } from "./plugin-version"; -export interface PluginRow { - author: AdminPluginAuthor | null; - description: null | string; - icon: null | string; - id: string; - kind: "core" | "plugin"; - license: null | string; - registrations: AdminInstalledPlugin["registrations"] | null; - title: string; - tone: null | PluginTone; - update: AdminComponentUpdate | undefined; - version: null | string; - versionMismatch: boolean; -} - -const TONE_BADGE = { - security: { - icon: ShieldAlertIcon, - className: "bg-destructive/10 text-destructive dark:bg-destructive/20", - }, - error: { - icon: TriangleAlertIcon, - className: "bg-warn/10 text-warn dark:bg-warn/20", - }, - update: { - icon: ArrowUpCircleIcon, - className: "bg-primary/10 text-primary dark:bg-primary/20", - }, - unknown: { - icon: CircleHelpIcon, - className: "bg-muted text-muted-foreground", - }, - pending: { - icon: CircleDashedIcon, - className: "bg-muted text-muted-foreground", - }, - ok: { - icon: CircleCheckIcon, - className: "bg-success/10 text-success dark:bg-success/20", - }, - off: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, - disabled: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, -} as const; - const StatusBadge = ({ tone }: { tone: PluginTone }) => { const t = useTranslations("admin.system.plugins.badge"); - const { className, icon: Icon } = TONE_BADGE[tone]; + const { className, icon: Icon } = PLUGIN_TONE_STYLE[tone]; return ( @@ -221,36 +161,34 @@ const Panel = ({ }) => { const t = useTranslations("admin.system.plugins"); const { api, frontend } = row.registrations ?? { api: null, frontend: null }; - const showMismatch = - row.versionMismatch && !!api?.version && !!frontend?.version; + const mismatch = + row.versionMismatch && api?.version && frontend?.version + ? { api: api.version, frontend: frontend.version } + : null; const showSecurity = (row.update?.result?.securityUpdates.length ?? 0) > 0; const showStatus = row.update !== undefined && (!enabled || row.update.state !== "unavailable"); - const hasStatus = showMismatch || showSecurity || showStatus; + const hasStatus = mismatch !== null || showSecurity || showStatus; return (
{hasStatus ? (
- {showMismatch ? ( + {mismatch === null ? null : (

- {t("mismatch", { - api: api?.version ?? "", - frontend: frontend?.version ?? "", - })} + {t("mismatch", mismatch)}

- ) : null} + )} {showStatus ? ( ) : null} @@ -341,7 +279,9 @@ export const PluginList = ({ {t("title")} - {t("desc", { count: rows.length - 1 })} + {t("desc", { + count: rows.filter(row => row.kind === "plugin").length, + })} }> diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts index f0f77de7db..95652f79fd 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-rows.ts @@ -1,9 +1,31 @@ -import type { PluginRow } from "./plugin-list"; -import type { AdminPluginUpdates } from "./plugin-updates-query"; -import type { AdminInstalledPlugins } from "./plugins-query"; +import type { PluginTone } from "./plugin-tone"; +import type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "./plugin-updates-query"; +import type { + AdminInstalledPlugin, + AdminInstalledPlugins, + AdminPluginAuthor, +} from "./plugins-query"; import { pluginToneOf } from "./plugin-tone"; +export interface PluginRow { + author: AdminPluginAuthor | null; + description: null | string; + icon: null | string; + id: string; + kind: "core" | "plugin"; + license: null | string; + registrations: AdminInstalledPlugin["registrations"] | null; + title: string; + tone: null | PluginTone; + update: AdminComponentUpdate | undefined; + version: null | string; + versionMismatch: boolean; +} + export const pluginRowsOf = ({ core, data, diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts index bd9857a47e..311f6aa123 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-tone.ts @@ -1,17 +1,64 @@ +import type { LucideIcon } from "lucide-react"; + +import { + ArrowUpCircleIcon, + CircleCheckIcon, + CircleDashedIcon, + CircleHelpIcon, + CircleOffIcon, + ShieldAlertIcon, + TriangleAlertIcon, +} from "lucide-react"; + import type { AdminComponentUpdate } from "./plugin-updates-query"; -export const PLUGIN_TONES = [ - "security", - "error", - "update", - "unknown", - "pending", - "ok", - "off", - "disabled", -] as const; +export type PluginTone = + | "disabled" + | "error" + | "off" + | "ok" + | "pending" + | "security" + | "unknown" + | "update"; -export type PluginTone = (typeof PLUGIN_TONES)[number]; +export const PLUGIN_TONE_STYLE: Record< + PluginTone, + { className: string; icon: LucideIcon } +> = { + security: { + icon: ShieldAlertIcon, + className: "bg-destructive/10 text-destructive dark:bg-destructive/20", + }, + error: { + icon: TriangleAlertIcon, + className: "bg-warn/10 text-warn dark:bg-warn/20", + }, + update: { + icon: ArrowUpCircleIcon, + className: "bg-primary/10 text-primary dark:bg-primary/20", + }, + unknown: { + icon: CircleHelpIcon, + className: "bg-muted text-muted-foreground", + }, + pending: { + icon: CircleDashedIcon, + className: "bg-muted text-muted-foreground", + }, + ok: { + icon: CircleCheckIcon, + className: "bg-success/10 text-success dark:bg-success/20", + }, + off: { + icon: CircleOffIcon, + className: "bg-muted text-muted-foreground", + }, + disabled: { + icon: CircleOffIcon, + className: "bg-muted text-muted-foreground", + }, +}; export const pluginToneOf = ( update: AdminComponentUpdate | undefined, diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx index fcd569c18f..2d7506b628 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx @@ -146,7 +146,6 @@ describe("PluginUpdateStatus", () => { expect(screen.getByText(`${T}.status.update_available`)).toBeTruthy(); expect(screen.getByText(`${T}.failed_with_previous`)).toBeTruthy(); - expect(screen.queryByText(`${T}.checked_at`)).toBeNull(); }); it("reports a check that failed before anything was found", () => { @@ -167,13 +166,6 @@ describe("PluginUpdateStatus", () => { renderStatus(update({ stale: true })); expect(screen.getByText(`${T}.stale`)).toBeTruthy(); - expect(screen.queryByText(`${T}.checked_at`)).toBeNull(); - }); - - it("says when the last check is current", () => { - renderStatus(update()); - - expect(screen.getByText(`${T}.checked_at`)).toBeTruthy(); }); it.each([ diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx index fe48bdb158..591d3abf06 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx @@ -201,7 +201,7 @@ const ResultLines = ({ ); case "update_available": { - const latest = result.latest; + const { alternative, latest } = result; if (latest === null) return null; return ( @@ -246,22 +246,22 @@ const ResultLines = ({ {t("updates.compatibility.unknown")} ) : null} - {result.alternative === null ? null : ( + {alternative === null ? null : (

{t.rich("updates.compatibility.alternative", { version: () => ( ), })} - {safeHttpsHref(result.alternative.url) === null ? null : ( + {safeHttpsHref(alternative.url) === null ? null : ( <> · - + {t("updates.release_notes")} @@ -288,13 +288,10 @@ const ResultLines = ({ export const PluginUpdateStatus = ({ coreVersion, enabled, - showCheckedAt = true, update, }: { coreVersion: string; enabled: boolean; - /** Off where the same date is already shown nearby. */ - showCheckedAt?: boolean; update: AdminComponentUpdate | undefined; }) => { const t = useTranslations("admin.system.plugins"); @@ -345,18 +342,6 @@ export const PluginUpdateStatus = ({ : t("updates.failed", { reason })} )} - - {showCheckedAt && - update.state === "checked" && - !update.stale && - lastError === null && - update.lastSuccessAt !== null ? ( -

- {t.rich("updates.checked_at", { - date: () => , - })} -

- ) : null}
); }; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx index 73d1645673..0f240226c1 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-version.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { unstableChannelLabel, unstableChannelOf } from "./unstable-channel"; -export const UnstableChannelTag = ({ version }: { version: null | string }) => { +const UnstableChannelTag = ({ version }: { version: string }) => { const t = useTranslations("admin.system.plugins.unstable"); const channel = unstableChannelOf(version); if (channel === null) return null; @@ -15,7 +15,7 @@ export const UnstableChannelTag = ({ version }: { version: null | string }) => { return ( diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx index 0cc3a5b7a1..362980286b 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-status.tsx @@ -1,64 +1,20 @@ import { cn } from "cn"; -import { - ArrowUpCircleIcon, - CircleCheckIcon, - CircleDashedIcon, - CircleOffIcon, - ShieldAlertIcon, - TriangleAlertIcon, -} from "lucide-react"; import { useFormatter, useTranslations } from "use-intl"; import { DateFormat } from "@/components/date-format"; -import type { PluginRow } from "./plugin-list"; +import type { PluginRow } from "./plugin-rows"; +import type { PluginTone } from "./plugin-tone"; import type { AdminPluginUpdates, CheckPluginUpdates, } from "./plugin-updates-query"; import { CheckUpdatesButton } from "./check-updates-button"; +import { PLUGIN_TONE_STYLE } from "./plugin-tone"; import { ExternalLink } from "./plugin-update-status"; -type StatusKind = - | "disabled" - | "error" - | "ok" - | "pending" - | "security" - | "unavailable" - | "update"; - -const KIND_ICON = { - disabled: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, - error: { - icon: TriangleAlertIcon, - className: "bg-warn/10 text-warn dark:bg-warn/20", - }, - ok: { - icon: CircleCheckIcon, - className: "bg-success/10 text-success dark:bg-success/20", - }, - pending: { - icon: CircleDashedIcon, - className: "bg-muted text-muted-foreground", - }, - security: { - icon: ShieldAlertIcon, - className: "bg-destructive/10 text-destructive dark:bg-destructive/20", - }, - unavailable: { - icon: CircleOffIcon, - className: "bg-muted text-muted-foreground", - }, - update: { - icon: ArrowUpCircleIcon, - className: "bg-primary/10 text-primary dark:bg-primary/20", - }, -} as const; +type StatusKind = "unavailable" | Exclude; const statusKindOf = ( rows: PluginRow[], @@ -141,9 +97,11 @@ const StatusBody = ({ return (

{format.list( - available.map( - row => `${row.title} ${row.update?.result?.latest?.version ?? ""}`, - ), + available.map(row => { + const latest = row.update?.result?.latest; + + return latest ? `${row.title} ${latest.version}` : row.title; + }), )}

); @@ -183,7 +141,8 @@ export const PluginsStatus = ({ }) => { const t = useTranslations("admin.system.plugins.status"); const kind = statusKindOf(rows, updates); - const { className, icon: Icon } = KIND_ICON[kind]; + const { className, icon: Icon } = + PLUGIN_TONE_STYLE[kind === "unavailable" ? "off" : kind]; const securityCount = rows.reduce( (sum, row) => sum + (row.update?.result?.securityUpdates.length ?? 0), 0, diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts index bd3b3eaf3f..cacb7014d0 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/unstable-channel.ts @@ -5,9 +5,7 @@ const LABELS: Record = { rc: "RC", }; -export const unstableChannelOf = (version: null | string): null | string => { - if (version === null) return null; - +export const unstableChannelOf = (version: string): null | string => { const [tag] = prerelease(version) ?? []; if (tag === undefined) return null; if (typeof tag === "number") return "prerelease"; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts index 5ab2355368..9772492f14 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/update-summary-query.ts @@ -28,7 +28,6 @@ export const fetchUpdateSummary = async (): Promise => { return await response.json(); }; -/** Under the catalog's key, so refreshing the catalog after a check refreshes this too. */ export const updateSummaryQueryKey = [ ...pluginUpdatesQueryKey, "summary", @@ -42,19 +41,11 @@ export const updateSummaryQueryOptions = () => queryKey: updateSummaryQueryKey, retry: false, staleTime: 60_000, - // Coming back to the tab refreshes a summary older than a minute; while a - // check runs - the daily one, or someone else's - it is asked again until - // that check finishes. refetchOnWindowFocus: true, refetchInterval: query => query.state.data?.running === true ? RUNNING_REFETCH_MS : false, }); -/** - * The one summary the navigation count, the security banner and the dashboard - * widget all read - one request however many of them are on screen. Never - * requested for staff who cannot view the plugin catalog. - */ export const useAdminUpdateSummary = () => { const canView = useAdminStaffPermission({ plugin: manifest.id,