From 73b09d2802a45a0b9becd77cb8148b29ea49e804 Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 11:40:21 +0100 Subject: [PATCH] feat: voortgang copy/duplicate/move in headerbar --- project_docs/UI_FEEDACK.md | 252 +++++++++++++ .../__pycache__/dependencies.cpython-313.pyc | Bin 6929 -> 6929 bytes .../app/__pycache__/main.cpython-313.pyc | Bin 3042 -> 3615 bytes .../history_repository.cpython-313.pyc | Bin 7835 -> 8997 bytes .../task_repository.cpython-313.pyc | Bin 19698 -> 21710 bytes webui/backend/app/db/history_repository.py | 21 ++ webui/backend/app/db/task_repository.py | 39 ++ webui/backend/app/main.py | 10 + .../history_service.cpython-313.pyc | Bin 1030 -> 1031 bytes .../task_recovery_service.cpython-313.pyc | Bin 0 -> 772 bytes .../__pycache__/task_service.cpython-313.pyc | Bin 2246 -> 2247 bytes .../app/services/task_recovery_service.py | 14 + webui/backend/data/tasks.db | Bin 237568 -> 307200 bytes .../test_api_history_golden.cpython-313.pyc | Bin 26390 -> 26390 bytes .../test_api_tasks_golden.cpython-313.pyc | Bin 14717 -> 14691 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 62256 -> 79612 bytes .../tests/golden/test_ui_smoke_golden.py | 334 ++++++++++++++++++ ...test_task_recovery_service.cpython-313.pyc | Bin 0 -> 4882 bytes .../test_task_repository.cpython-313.pyc | Bin 6756 -> 9242 bytes .../tests/unit/test_task_recovery_service.py | 93 +++++ .../tests/unit/test_task_repository.py | 58 +++ webui/html/app.js | 180 +++++++++- webui/html/base.css | 86 +++++ webui/html/index.html | 19 + 24 files changed, 1104 insertions(+), 2 deletions(-) create mode 100644 project_docs/UI_FEEDACK.md create mode 100644 webui/backend/app/services/__pycache__/task_recovery_service.cpython-313.pyc create mode 100644 webui/backend/app/services/task_recovery_service.py create mode 100644 webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc create mode 100644 webui/backend/tests/unit/test_task_recovery_service.py diff --git a/project_docs/UI_FEEDACK.md b/project_docs/UI_FEEDACK.md new file mode 100644 index 0000000..1700e1b --- /dev/null +++ b/project_docs/UI_FEEDACK.md @@ -0,0 +1,252 @@ +1 analyse + +De repo heeft al een bruikbaar taskmodel voor copy, move, download en duplicate, maar de main WebUI gebruikt dat model voor copy/move nog nauwelijks. In de hoofd-UI ziet de gebruiker na start nu vooral een korte statusregel of summary; live voortgang staat feitelijk alleen in `F1 > Settings > Logs`. Daardoor ontbreekt directe, persistente feedback in de hoofd-UI en is er geen zichtbare rem op dubbel starten. + +Belangrijkste conclusie: + +- Copy en move hebben al echte backend-tasks met progressvelden. +- De bron van truth voor lopende copy/move-taken is al `/api/tasks`. +- Er bestaat nu geen cancel/abort voor copy of move. +- Een eerlijke abortknop voor copy/move kan dus nu niet frontend-only worden toegevoegd. +- De kleinste veilige stap is een compacte live task-indicator in de bestaande header/toolbar-zone, gevoed door de bestaande task-feed. + +2 bestaande functionaliteit + +A. Taskmodel / backend + +- `copy` en `move` gebruiken hetzelfde taskmechanisme via [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py), [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py), [copy_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/copy_task_service.py) en [move_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/move_task_service.py). +- Taskstatussen die al bestaan in [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py): + - `queued` + - `running` + - `completed` + - `failed` + - daarnaast voor download ook `requested`, `preparing`, `ready`, `cancelled` +- Progressinformatie bestaat al: + - files: `done_bytes`, `total_bytes`, `current_item` + - batch/directory: `done_items`, `total_items`, `current_item` +- Copy: + - file copy gebruikt byte-progress callback + - directory copy is grof: `0/1` naar `1/1` + - batch copy gebruikt item-progress +- Move: + - same-root file move heeft praktisch geen tussentijdse progress, alleen start/einde + - cross-root file move gebruikt copy-progress en delete na afloop + - directory move is grof `0/1` naar `1/1` + - batch move gebruikt item-progress +- Er is al read-API voor tasks: + - `GET /api/tasks` + - `GET /api/tasks/{task_id}` +- Er is geen cancel-API voor copy/move. +- De enige echte cancel in de repo zit nu bij archive-downloads in [archive_download_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/archive_download_task_service.py) en `POST /api/files/download/archive/{task_id}/cancel`. +- Copy/move workers in [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py) hebben geen cooperative cancel checks. +- Copy/move history bestaat al via [history_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/history_repository.py): `queued`, `completed`, `failed`. + +B. Bestaande frontend feedback + +- In de hoofd-UI starten copy en move vanuit [app.js](/workspace/webmanager-mvp/webui/html/app.js): + - `startCopySelected()` + - `executeMoveSelection()` +- Huidige feedback voor copy/move: + - `setStatus(...)` onderin/headerstatus + - `showActionSummary(...)` + - `openFeedbackModal(...)` via `actions-error` +- Die feedback is niet persistent als live taskweergave. +- Er is nu geen compacte taskindicator in de hoofd-UI. +- `state.selectedTaskId` en `refreshTasksSnapshot()` bestaan al in [app.js](/workspace/webmanager-mvp/webui/html/app.js), maar worden voor copy/move alleen gebruikt om een snapshotcount op te halen; er is geen zichtbare hoofd-UI-component die dit toont. +- Buiten download is er geen modal of popover voor actieve taken in de hoofd-UI. + +C. Logs / history / settings + +- `F1 > Settings > Logs` toont al twee side-by-side secties: + - `Tasks` + - `History` +- Deze UI gebruikt al de bestaande feeds: + - `/api/tasks` + - `/api/history` +- Polling bestaat al in [app.js](/workspace/webmanager-mvp/webui/html/app.js): + - `loadTasksForSettings()` + - `loadHistoryForSettings()` + - `loadLogsAndTasksForSettings()` + - `scheduleSettingsLogsPolling()` +- De UI rendert taskdetails al compact via `formatTaskLine(task)`: + - status + - source/destination + - `done_items/total_items` + - `current_item` +- Dat betekent dat de repo al een bruikbare frontend formatteringslaag heeft die ook buiten Settings herbruikbaar is. + +D. Abort/cancel haalbaarheid + +- Copy/move kunnen nu technisch niet veilig worden afgebroken via bestaande code. +- Er is geen taskstatus-overgang of API-contract voor copy/move-cancel. +- Er is geen cooperative worker-check in copy/move loops. +- Er is geen rollback. +- Eerlijke cancelsemantiek voor copy/move zou dus moeten zijn: + - stop resterende verwerking zo snel mogelijk op een checkpunt + - reeds verwerkte bestanden blijven zoals ze zijn + - geen rollback +- Maar die semantiek is nog niet geïmplementeerd. +- Conclusie: een abortknop voor copy/move is nu buiten scope zonder backendwerk. + +3 scope + +Minimale veilige volgende stap, op basis van wat al bestaat: + +- frontend-only hoofd-UI verbetering +- geen layoutwijziging van de dual-pane browse-UI +- geen nieuw vast paneel +- wel een compacte task/status chip in bestaande headerbar of function-bar zone +- alleen zichtbaar als er actieve taken zijn (`queued`, `running`, en eventueel download `requested/preparing`) +- klik opent een kleine popover/dropdown met actieve taken +- popover hergebruikt bestaande taskdata en formattering uit `/api/tasks` +- popover bevat link/actie naar `F1 > Settings > Logs` +- geen abortknop voor copy/move in deze fase + +Waarom dit binnen scope past: + +- gebruikt bestaande task-feed +- gebruikt bestaande taaksemantiek +- verandert de hoofd-layout niet +- geeft persistente feedback zonder modal-first patroon +- is compatibel met de OneDrive-achtige richting: compacte indicator, detail op aanvraag + +4 impact + +Positief: + +- gebruiker ziet direct in de hoofd-UI dat copy/move loopt +- feedback blijft zichtbaar zolang taak actief is +- minder kans op dubbel starten +- geen extra structureel paneel +- F1 Logs blijft intact als detailbron + +Beperkingen: + +- zonder backendwerk is er nog geen eerlijke cancel voor copy/move +- progress blijft zo nauwkeurig als bestaande taskdata toelaat +- same-root move en directory move blijven qua progress relatief grof + +5 risico + +Laag tot middel als alleen de voorgestelde frontendstap wordt gebouwd. + +Belangrijkste risico’s: + +- polling in de hoofd-UI kan onrustig worden als hij niet net zo stabiel wordt gebouwd als de bestaande Settings-polling +- een te opvallende indicator kan visueel concurreren met de bestaande headerstatus +- als een abortknop zonder backendsteun zou worden toegevoegd, zou dat misleidend zijn; dat moet expliciet niet gebeuren + +Expliciet risico buiten scope: + +- copy/move-cancel vereist backend-aanpassing aan taskmodel, runner en waarschijnlijk history + +6 testplan + +Voor de minimale frontendstap: + +- gerichte UI smoke/golden checks voor: + - indicator aanwezig in header/toolbar markup + - indicator alleen bedoeld voor actieve taken + - popover/dropdown markup aanwezig + - link naar bestaande logs-entrypoint aanwezig +- gerichte JS-checks voor: + - actieve taken worden uit `/api/tasks` gefilterd + - `queued`/`running` tonen indicator + - `completed`/`failed` verdwijnen uit de actieve indicator + - polling start/stop logisch zonder extra layoutreset +- geen backend golden updates nodig zolang `/api/tasks` contract ongewijzigd blijft + +Niet nu testen: + +- abort voor copy/move, want die functionaliteit bestaat nog niet + +7 acceptatiecriteria + +Voor de voorgestelde minimale stap: + +- Een gestart copy- of move-proces is zichtbaar in de hoofd-UI zonder navigatie naar `F1 > Settings / Logs`. +- De oplossing verandert de dual-panel layout niet structureel. +- De feedback blijft zichtbaar zolang de taak actief is. +- De oplossing gebruikt bestaande taskdata als bron van truth. +- Er wordt geen fake progress getoond. +- Er wordt geen fake cancelknop getoond voor copy/move. +- Bestaande task/log/history-functionaliteit blijft intact. +- API-contract blijft ongewijzigd. + +Voor abort/cancel: + +- Niet acceptabel in deze fase zonder backendsteun. +- Eerst aparte backendfase nodig. + +8 codex-uitvoering / voorstel + +Huidige stap: + +- Alleen analyse uitgevoerd. +- Geen functionele implementatie gedaan. + +Waarom: + +- `CHANGE_POLICY.md` zegt dat frontend flow aanpassen eerst een voorstel nodig heeft. +- De opdracht vroeg expliciet om eerst grondige repo-inspectie en pas daarna een minimaal voorstel. +- Cancel/abort voor copy/move is niet eerlijk implementeerbaar zonder backendwerk. + +Minimaal wijzigingsvoorstel dat ik hierna zou uitvoeren als vervolgstap: + +1. Frontend-only compacte task chip +- plaats in `#title-zone-actions` of direct naast `#status` +- toont bijvoorbeeld: + - `1 task running` + - `3 active tasks` + +2. Kleine popover/dropdown +- opent op klik op de chip +- toont alleen actieve taken uit `/api/tasks` +- hergebruikt bestaande `formatTaskLine(task)` of een kleine variant daarop +- toont eerlijke status: + - `queued` + - `running` + - eventueel later download `requested/preparing` + +3. Polling hergebruik +- hergebruik bestaande `/api/tasks` +- implementeer lichte polling alleen als er actieve taken zijn of als de popover open is +- gebruik stabiele rerender-aanpak zoals in Settings > Logs + +4. Doorgang naar detail +- knop of link `View in Logs` +- opent bestaande `F1 > Settings > Logs` + +5. Expliciet nog niet doen +- geen cancelknop voor copy/move +- geen extra paneel +- geen fake progressbar + +Vervolgvoorstel voor latere backendfase als abort gewenst is: + +- copy/move taskstatus uitbreiden met `cancelled` +- cancel-endpoint voor copy/move +- cooperative checks in `TaskRunner` tussen items/chunks +- eerlijke semantiek: + - stop resterende verwerking + - reeds verwerkte bestanden blijven bestaan + - geen rollback + +9 gewijzigde bestanden + +- [project_docs/UI_FEEDACK.md](/workspace/webmanager-mvp/project_docs/UI_FEEDACK.md) + +10 uitgevoerde tests + +Wel gedaan: + +- code-inspectie van backend taskmodel, runners, services, routes en frontend task/log UI + +Niet gedaan: + +- geen functionele tests +- geen implementatiechecks + +Reden: + +- deze stap is bewust alleen analyse + voorstel, geen implementatie diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index 855c7129f383580e0b618f44dd67634f96dd4e25..121f2a624839b296a4335995ad46555b5c3a4ae4 100644 GIT binary patch delta 20 acmbPeHqngxGcPX}0}y;}*|w3JO&S0`O$ECE delta 20 acmbPeHqngxGcPX}0}$-$*t(IMO&S0^(FKJ7 diff --git a/webui/backend/app/__pycache__/main.cpython-313.pyc b/webui/backend/app/__pycache__/main.cpython-313.pyc index bbbfe75098a29044f2c9b9640f17c09b668ca32e..cf50032f5ae332caf4276d6da17a669922bf80d6 100644 GIT binary patch delta 1620 zcmah|&1)M+6rcT&63Mn4#Yrq#vMqmmlSp+c$qt z{*XV0VAL;AgtgZN?L`r)8Xys^Ydl43Mgc2INxwO3u%b^{=xw5?qt2v@C&yx+o z!UtE%JkhlcslDF46MQ6PuH0>}dXbJqa0ypmm%sG7{85ol$MPe{%Io>vd%=&=N^@14i?NDf*``x>Ys_b18ShxZGakzbT;2EUwTIJp-Vg4oJ5nf**7u~;xmSdgM8)vw%2yKkeI6=_Nv)DiWlz%NTbj(r2b$vNMfc7A z_B90(SO-Q0;B_Ovl9s5;)1R{Y4QuUw**su&Wv%SGYxS~a7u*MY>3R&LC)VXg^F;EYmM}K3vTq+|zD6 z{x|xee30tMRlq|PD$GUY7>*0`LTN*h`9yC{XS84pYxB#!5s$&4WiqeO`HQA=$Z&8w zZb$@n%({IR_Cv9G5;bD3Z=ts>PFoYM;E0Dk4@z=&tL{`9HTD*i@Bm`oyuX5M y{LR>(Wak;V_KcJse-Pi1roPF3k^R;>l2R>Mm7*<$$mtitdXWfz9=V|kjsF8BzDgDV delta 792 zcmbO)^GKZUGcPX}0}$-$*qXVNVrL}z^JY+JMlNHD4`3Y$J1~mQxH2wiJ{sUD0BxY3O70~z&X#50b(Rze);xWAy!vpt> zRIr#7Llz4xVu}oa9!kd45iAbZAssA%qQfX%Qd8z7NQ)-pE$+m;y!?{HlF4c;VIqvT z1e5dg(lXOai&Eor^3&5Z^U^0jXGvk?o9x3XKe>tRhKeLmTag%u5C;(wAXAubF~;9w zPOV5TW&&~*6ebI>$%qSNB<7{$q{b%}6vU?%73CKd$pIy`C(mZ@;#|p4qzvSIW|(Zq zA;PK(WLi%4;!sv&E0PAXi`YPf42a+a61UijQcFsU@`~7j98IPowaE)OL>P@XZ{>(& znry@^%E&#rk~?$qM{Z#wkPWw((^E@|KxTsNC{h8jWI+-fHo5sJr8%i~MgBl8C?OZ` Zn{38o!F-p&aC13N0ORB*yyh$*^#F|Ek3Rqa diff --git a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc index 581c374855fd7bc218494a5cccfcc92532187e5b..d4322a0ce7bbf93b6976fab78a8271dba56055bf 100644 GIT binary patch delta 1370 zcmaJ=UuauZ82`?_H-GL;Zkjaxljcuzp|CD(SDmA4k*!+<=O!4jX91<1Attx=cImzK z+;o~KCdwZAplCUi4hG`OzzrFW!k+fv9(?O0V5*fD7}ncKz;NlG~VdP zzBnvP%L4J@0X&HP?hX+jj$xT&;>QXO;NYY%3{U>>5LUa9GLGYr>#HmTa8DejcnwTQ za6!ch9OkxNF2uMbj&!(i*RcoplJJt4No~I?q!<(xM6IyBSJa8*K!mB&LN*Do+R|ru!?E^5`!(D() zj|MNv00CxOLet_R$_Q8Ae9Dw%B`KW*4mi`3@y zRm(Q2)*Pu#61`-ZC8MGjbhAnf-7YK^*{(c!tYVdl6?=AaH~u0^XY+UZ8JciMo4ICu z5xEk*W<0VbMt;~6LG@2*R{5xLCZ`>&_huArpAc3yWF*2Tpe-$1hDnYxNTxaMBg34Q zc^S2WQMUJ2F$Cfq3 zVda$AKkRrRJ$~tR2hc>s@kxrDwl7e{A z!bgU<$enx_@!{@pnO(%Mv%`)5i`rhISJ#M1*67JZI=(M-FQ7?liBzGu7_(_qey6igIRdTNLq|a~%w=Neo`PTIpy0P?|L^{psdVr_b z-qUiHsk0B-b>%^s2o;krqY}*}pF{I>Bbh`+dOi8;lyejkhgzxU?+2ou&=1k1<6+wI zLx0SXIZ_~&aJbD0f)e^&W$57;{VY{LEA;PFmgPD*977+|*M<*A-V#t5{RXZgBka_F E0F^6B*8l(j delta 527 zcmZ4LHrtl(GcPX}0}$-$*qX^Fwvn%rQRE$vJCz}Zp@=bjC$ zKY68s9b>`d7YZ_rdXrfd-!XmBo2;P}&lo?sL&;I90A#K*h)@6$w>WHa^HWN5QtgTw UCO=j(6pUeFOkw=Q0H(mo0OGrK5dZ)H diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc index 11d23ee05d2430a37889fad66a775ee4a8d31809..e179f081cf9e2c79b0c4e05294f461d0eaa76e65 100644 GIT binary patch delta 3056 zcmbtWZERat89wLw`rE#?<2ZKg#J>5i+r`ay(~>4l9XF|Pk}fA^NKIuz;&cwFot*2? zG{798P3nq(Wk(qspebxcP^6-^m+jArMhb#5R^*Zbk$)iI4^*m<*9Jx155_syc9O2E zG{iabdtN{9=XuY2?!EV4gTKE7g$HJ{0noAV{M+%r^(_l7xVqeMY5<#%9vP64PE%o; zQGN=_Y6)1d8Ck~!WNV=2E>I0ni5nn$JICAsfGwvvf9dkg>t%i(vk=&*po|EV$j`%h zSUpCySHKv+5CO~~h*d~LoEuE@nD+uNKviT-Rn~#35zoMBh}EbDsTBncszn-w)1o@0 zRX80p%1=~J=6JV&8j!AFs`moY%)4C3kMsq|P}HWO6#X))Zzu?jMIqYDvS>m3kg*^# zF%f#IsE}V#BQh1_=DeI{P(eOYX>9>yE=Yt&CF%W0C`c@*30WAAt*9AU70!lQkWJxA z=x%9&snkw)Ygf2Z)QUPS;3Hr}OV)z!P7Wm05%q{ZuezHeRfHD+;#*Zau8Oy9#K??? z6Pk#|4nC)qxcxxl>|j6(K=Le7tErz8+#q#o1R_Z2655DvPr>&jy_ZZI0XB;cMNkWr z+|%riMRcE#v;GNbY9so5nn?Hg<_Nc+kLaF;2Q)jLpJ1mi+C_9bx`-I7Ear%=mS!l@ zUqU-YmiBtm&rsyB$_J*P-!QbuJtc0R0^1fliB@(B^^~chTV4SXT6R{B1bxzc8LX&9 zP%Ou3ctAA;FL7DeGjs{il?yawVN_yHL=iXAC9Cf`9ZSR(W^s46i*-nPr^N#}HjzwB z#Ajl(kqR9Wi?YU&0$nr7iSZe!JFsoOLQh;Op)T)&C6E#O{tyWM*!Dv^T^>GQ4f1u1 z4K6-o5I;6hC>#ooit)*UMA(rZ8yOxXa}`c<#jQz8sqxgjBpx3U_bit9A9I8IX^8)~ zQkYODc!iNyDA`@7z4$c-f0vRs*ha0CQ8wy7a&6&C)#aLN3lH)4D7g6M%Z_exvbK#} zY-p@z>0;LLi$jm4+(&ml8X`@drDWCNOy@7`q_RW526s`TEI*x$C$f&A;i2egXk_sC zP;WSjMtesiD1`7~s)4&18KJ~)!21}#eR**;{hKw-&WumQo=wh7#;}BsGL2D2f=s9@ z#FVnORGK|6b}LKl)wXjO`EGGPxh1lG);^!Ad%ndgSVhSa$eDoC^PMxxXUa+Pym5o_ z=a$di7W@7f2(F8L>(0LOCh~5lBc0<^j_RBS)a+mN<^XK6<+Qx5bkhKA^$*P2QejH~ z7Uzr4eq%ak0cP6=RuE`eceeawbamp@zVGWVjh-LKI9vW=_iQ-Jx12x_Z<~EN7pSWH zk^Tqz+x{b07uNkp*2N$#HIUTT-ZyGgbVQU|ao$CZv*Llms z`whU=x@D)x9hyxZ{7|Z;W&e3&uuiqMR|txlwN3-&OKzBY+4@US&1jt=*e0JnbO*wp z$>FYf2(QaG4!1#gjcj%Afmeyx(*)P#r+eOkJZooS?rny*m0A?_{4^U7skG?L5xCg)BL1r-*}FOp3E zOU5@Si@(OmH8M4D)T^{g6Z_BJ6u7QPSZ>mQ{Of@mTp%X^Pvb^q=mUMN+C{I6!yWs1jE9|4~I2Lvp kHYiLV5J1A@xp19k!_}Vw4)SKW+cvC)C*XacESP2YU%#ouF#rGn delta 1381 zcmb7?UuaWT9LMkP<~F8DlQd1+q&IDvHg;`n?NV##BGS=1i>o$v`$Z;L38jq^?QZlW zc~F!%*zgYs)`N_WS@z(c`7(ybTB|-O9b=4bAo|d^v6l({0Tl$FJm=)v`WG~Zob$b( z-}m>szx%uQuM6;K3S5t!PP@W?SC7nO#y_2O`QgXO*6p8RIokPhn5zOy%PCJ+r<+uu zd&G&AtvuDQG%M)Q6|9P>qLiW#*G@I!o&57Un}z~3_i}tn8BqwJLR17|!Fp76WtT>@ zpb}KDflg|^SP(VAb^$5FMl6$YD~8Z2)iP|tWm4HhPHAB?{l*gThS6qbmYaMz{nz64 zwP3l)?JqL;-DHj_E5Bq!L$RrBoj56!YS&ia%bJR9^mgUa6|_W2}~{2BTObRV{X5tyI1dKl*ef zRvNTI>PB>2g?t2ENoOPrA3Twas3GTE+Rxh}cqq*H*ruSrXnS7S)yBnm3 z%l?5zw$(bLL72H5pKFk0TittA*w6m(bf{2dPrWBqILeOI{-VNZ7Oz{c!mq3t;Kxli z-@u)BSWoD(3jebQ%~1=Kv&N6UQ=x;MZA;i7&YnbjG%#34>=r=rV$VH*Z`u7%4gnmc z#+rWiu#fZO?B==xz$q4AzZzhg8Ut-`ntneJg&)}O1J?j9(8C+L;2fLVa0aaB#i#c( z9UF|nH8wTa0C1kpZfu8n`rpPgU6)04hHnd6B5R~08tD@x{sQlJjAzF3p#vcqRmlv! zvMC<9BEq`ZqzWvmdZ8|fP1Gc_R3FmycYA1JsOc*iu3Ieewz8#S+!9d}iHPl`9^dJn zqrVMBx@9Z=#oF(47m>H+!}I1ucdt*6NXn-sI+&$DB|3+s6jOiGJX@mu{CHNC+dGlT zr@K4XWep=emd|7jvx+d86|d61&3@lfMH9z`rIGI0e8PH=OEOIz!~OiNCx(62AP2d6 zwtM)7sy?yNZMdV_y None: + if not entry_ids: + return + finished_at = self._now_iso() + placeholders = ", ".join("?" for _ in entry_ids) + with self._connection() as conn: + conn.execute( + f""" + UPDATE history + SET status = ?, error_code = ?, error_message = ?, finished_at = ? + WHERE id IN ({placeholders}) + """, + ("failed", error_code, error_message, finished_at, *entry_ids), + ) + def _ensure_schema(self) -> None: db_path = Path(self._db_path) if db_path.parent and str(db_path.parent) not in {"", "."}: diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index 27a8b6e..ae98cd9 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -8,6 +8,7 @@ from pathlib import Path VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"} VALID_OPERATIONS = {"copy", "move", "download", "duplicate"} +NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing") TASK_MIGRATION_COLUMNS: dict[str, str] = { "operation": "TEXT NOT NULL DEFAULT 'copy'", "status": "TEXT NOT NULL DEFAULT 'queued'", @@ -394,6 +395,44 @@ class TaskRepository: with self._connection() as conn: conn.execute("DELETE FROM task_artifacts WHERE task_id = ?", (task_id,)) + def reconcile_incomplete_tasks( + self, + *, + error_code: str = "task_interrupted", + error_message: str = "Task was interrupted before completion", + ) -> list[str]: + finished_at = self._now_iso() + placeholders = ", ".join("?" for _ in NON_TERMINAL_STATUSES) + with self._connection() as conn: + rows = conn.execute( + f""" + SELECT id + FROM tasks + WHERE status IN ({placeholders}) + """, + NON_TERMINAL_STATUSES, + ).fetchall() + task_ids = [row["id"] for row in rows] + if not task_ids: + return [] + task_placeholders = ", ".join("?" for _ in task_ids) + conn.execute( + f""" + UPDATE tasks + SET status = ?, finished_at = ?, error_code = ?, error_message = ?, current_item = NULL + WHERE id IN ({task_placeholders}) + """, + ("failed", finished_at, error_code, error_message, *task_ids), + ) + conn.execute( + f""" + DELETE FROM task_artifacts + WHERE task_id IN ({task_placeholders}) + """, + task_ids, + ) + return task_ids + def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None: rows = conn.execute("PRAGMA table_info(tasks)").fetchall() existing_columns = {row["name"] for row in rows} diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py index 182bb10..b28e714 100644 --- a/webui/backend/app/main.py +++ b/webui/backend/app/main.py @@ -17,7 +17,9 @@ from backend.app.api.routes_move import router as move_router from backend.app.api.routes_search import router as search_router from backend.app.api.routes_settings import router as settings_router from backend.app.api.routes_tasks import router as tasks_router +from backend.app.dependencies import get_history_repository, get_task_repository from backend.app.logging import configure_logging +from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks configure_logging() @@ -40,6 +42,14 @@ app.include_router(history_router, prefix="/api") app.include_router(tasks_router, prefix="/api") +@app.on_event("startup") +async def reconcile_incomplete_tasks_on_startup() -> None: + reconcile_persisted_incomplete_tasks( + task_repository=get_task_repository(), + history_repository=get_history_repository(), + ) + + @app.exception_handler(AppError) async def handle_app_error(_: Request, exc: AppError) -> JSONResponse: return JSONResponse( diff --git a/webui/backend/app/services/__pycache__/history_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/history_service.cpython-313.pyc index f47342b21a10d7165f888150043bb447b29a2544..e3f130421a8378e0fe2b8e6ec9e733cd2574ffb5 100644 GIT binary patch delta 32 mcmZqUXy@Sm%*)Hg00bXewr%A0V`S4UPfaS#+#JWaf(ZbTkqLkR delta 31 lcmZqYXyf4i%*)Hg00h!|H*VziV`SCQPb?_d9M8Cd2>^M_2r>Ww diff --git a/webui/backend/app/services/__pycache__/task_recovery_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/task_recovery_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8be4cb1cbbc4b588b41e4b21cd6040bd4b652f8 GIT binary patch literal 772 zcmZWnF>ljQ5WZ(8w%d}LmLgRdih&MzAQ_?#5m44Bk&2WM7KALveg)Iiw(dEt!jyr( z&>z9Z#D8F{Kx$;E2njK;S&A5uxEC8zrDwf&-<{9jefOScmC7oR{dxZ>_{;!)$l#wT zIbgGW0%y>J9_v7xA+sb_I!aqbb&l0esa?jh1v^)sp4KZ>;cW@E*JWG(CCO}-uDM|t zC2kT#p`dW16^JClahJ!D2qdE9`g2ztEizsqL^6PyFE)EY;0*fECE3fiS0qBEuRJKQ zy8+N=UHScz^_9JX!-BrDODYd+C9NX&qRI2t1;-O!6Fz>D%u!Gu?q5@j6>6c=67nb}kQs3E&V|`LH=AF~N0Zh!-JBZL3!`yv zG(NVz8c(KcwBmM=wuuWUVCb)3TyrMcsv zoGQIr3{#_FYW|>h{Ty<(ou#^^1I(MtAwB@EOAR+9yYcuq@<&6yk6RSV3ltA&%T&hL kCEWW7PcNaFX*%1Q-oBrymcwCVCGcPX}0}#A!*|w2;HY1x>d1_K==H}Im((C}I0ty@e delta 31 lcmX>ucubJ{GcPX}0}$K_*|3p&HY2O1equqv<~5Ac>;RP|2`B&n diff --git a/webui/backend/app/services/task_recovery_service.py b/webui/backend/app/services/task_recovery_service.py new file mode 100644 index 0000000..15275aa --- /dev/null +++ b/webui/backend/app/services/task_recovery_service.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from backend.app.db.history_repository import HistoryRepository +from backend.app.db.task_repository import TaskRepository + + +def reconcile_persisted_incomplete_tasks( + task_repository: TaskRepository, + history_repository: HistoryRepository, +) -> list[str]: + task_ids = task_repository.reconcile_incomplete_tasks() + if task_ids: + history_repository.reconcile_entries_failed(task_ids) + return task_ids diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index bebca038f0c85aafb69d0cb2d157eba77d88b7ce..7ae249aec0f1bf36c74b3fd594bae3b8dbe7b7f1 100644 GIT binary patch delta 61027 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

PBb`i| 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 delta 3988 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 delta 22 ccmbPsj&a&KM()qNyj%=GP|~?|BR6+C09CXG%>V!Z diff --git a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc index f5998071f16f983d9c07ee8e95183bc6bed487ff..b067121d329f04f097dc3490804f8a344e32005c 100644 GIT binary patch delta 26 gcmexc^tg!oGcPX}0}x2IZrjLxi-pm66Kkyr0DhVXaR2}S delta 53 zcmaD{^tXunGcPX}0}#}7Zr#Xzi$&U3zdXMvySN}RIaR+rH7PeSFEKr}NH@2vK!5Wy HmRb`45pWac diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 274fe8ee18df04a14b1fe84ee0d570e8f3223d4b..d6bcffca0f18cfb00849dfda3d538952f4aa05f3 100644 GIT binary patch delta 13616 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 a#jn_EuiA_6+j-Z{v%4p47W*8E<$nRJ3m+K( delta 1701 zcmZ9Me@s(X6vy9rKiUVhw4J3CC=6{8E220kieVehU&J|G_N+4kLj+0-l*%iJQ0Ckp z+jP;PyPJ>1jk(Mq*&mC`RV&U6-Q2Rjv`SUvug#dnZ82hOvMt$k_uc}@c**;m@A>i0 zJ+J4b|2z;+|0uaV@f7U&s2&ctEnw1?sQztZvuD@eNpb!6@GB982-OJN~C`0A|8hv(AqjVBeE zTQ}0WmkHcys+DRwtSVE~Q|zQ@px8yxNYO+vY3Nql>vuPF`RjclRjni;w?(zkv`-7z zAP1_V@7SOj^uy$<4EycTN0~4T(eW%;$7_8Hz@+u&5!}fp(B%+}7J?}{l?!)y?LG-c zr}Ci=v;$5s9$NtU(Sr-%k*FOkgkEh=E?AN+YH6Pz2?~oww#DVL3E6O4wqKX+gLxyi zxLh*p$Fi1;_~Noht8;*TUD;`(pC`!y<3VH07L0|uPT*?-H&SS()ju^{oiv`1R zxob{=_9zGJy5xvVXUrM5kiD8VR!ENbImpR9`2X^Ui?}6ssJ+6SDjxUm)Fd6 zk2)8FB_*wcZYcS2XfKvRhJ$UH?RG8Qp)kEeT2`g)E`<;H65&`GgiDXo&=Cr%+@e}F zqgixP+#Kp?cJs-S4{1r7He3c(X4+S6qj;a<@G%ehItDa{2ONvtB=Rno;6 None: + functions = "\n\n".join( + [ + self._extract_js_function(app_js, "headerTaskElements"), + self._extract_js_function(app_js, "formatTaskStatusLabel"), + self._extract_js_function(app_js, "inferDownloadTaskContext"), + self._extract_js_function(app_js, "formatTaskLine"), + self._extract_js_function(app_js, "isActiveTask"), + self._extract_js_function(app_js, "activeTasksFromItems"), + self._extract_js_function(app_js, "activeTaskChipLabel"), + self._extract_js_function(app_js, "headerTaskRenderKey"), + self._extract_js_function(app_js, "shouldPollHeaderTasks"), + self._extract_js_function(app_js, "stopHeaderTaskPolling"), + self._extract_js_function(app_js, "scheduleHeaderTaskPolling"), + self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"), + self._extract_js_function(app_js, "renderHeaderTaskPopover"), + self._extract_js_function(app_js, "renderHeaderTaskChip"), + self._extract_js_function(app_js, "updateHeaderTaskState"), + ] + ) + script = textwrap.dedent( + f""" + const assert = (condition, message) => {{ + if (!condition) {{ + throw new Error(message); + }} + }}; + + function createClassList(initialHidden = false) {{ + const names = new Set(initialHidden ? ["hidden"] : []); + return {{ + add(name) {{ names.add(name); }}, + remove(name) {{ names.delete(name); }}, + toggle(name, force) {{ + if (force === undefined) {{ + if (names.has(name)) {{ + names.delete(name); + }} else {{ + names.add(name); + }} + return; + }} + if (force) {{ + names.add(name); + }} else {{ + names.delete(name); + }} + }}, + contains(name) {{ return names.has(name); }}, + }}; + }} + + function createElement(initialHidden = false) {{ + return {{ + classList: createClassList(initialHidden), + textContent: "", + innerHTML: "", + children: [], + scrollTop: 0, + attributes: {{}}, + append(...nodes) {{ + this.children.push(...nodes); + }}, + setAttribute(name, value) {{ + this.attributes[name] = value; + }}, + }}; + }} + + const elements = {{ + "header-task-chip-container": createElement(true), + "header-task-chip-btn": createElement(false), + "header-task-chip-label": createElement(false), + "header-task-popover": createElement(true), + "header-task-popover-list": createElement(false), + "header-task-logs-btn": createElement(false), + }}; + + const document = {{ + getElementById(id) {{ + return elements[id] || null; + }}, + createElement() {{ + return createElement(false); + }}, + }}; + + const window = {{ + setTimeout(fn, delay) {{ + return 1; + }}, + clearTimeout(id) {{}}, + }}; + + function formatModified(value) {{ + return value || "now"; + }} + + let headerTaskState = {{ + activeItems: [], + popoverOpen: false, + pollTimer: null, + lastRenderKey: "", + }}; + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); + + {functions} + + const mixedTasks = [ + {{ id: "a", operation: "copy", status: "queued", source: "/src/a", destination: "/dst/a" }}, + {{ id: "b", operation: "move", status: "running", source: "/src/b", destination: "/dst/b", done_items: 1, total_items: 3, current_item: "b.mkv" }}, + {{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.zip" }}, + {{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }}, + {{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }}, + {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }}, + {{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }}, + {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, + {{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }}, + {{ id: "h", operation: "download", status: "cancelled", source: "/src/h", destination: "folder.zip" }}, + ]; + + const activeTasks = activeTasksFromItems(mixedTasks); + assert(activeTasks.length === 3, "Only copy, move and duplicate tasks in queued or running should count as active"); + assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active"); + assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should not be counted because it is not task-based in the current UI flow"); + assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task count"); + + updateHeaderTaskState(mixedTasks); + assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); + assert(elements["header-task-chip-label"].textContent === "3 active tasks", "Chip label should render active task count"); + assert(shouldPollHeaderTasks(), "Active tasks should enable header polling"); + + setHeaderTaskPopoverOpen(true); + assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); + assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open"); + assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state"); + assert(elements["header-task-popover-list"].children.length === 3, "Popover should render only active file-action tasks"); + + updateHeaderTaskState([ + {{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, + {{ id: "z2", operation: "move", status: "failed", source: "/src/z2", destination: "/dst/z2" }}, + {{ id: "z3", operation: "download", status: "ready", source: "/src/z3", destination: "folder.zip" }}, + ]); + assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when no active tasks remain"); + assert(!headerTaskState.popoverOpen, "Popover should close when no active tasks remain"); + assert(elements["header-task-popover"].classList.contains("hidden"), "Popover should be hidden when no active tasks remain"); + assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "false", "Chip button should reset expanded state when hidden"); + """ + ) + result = subprocess.run( + ["node", "-e", script], + cwd="/workspace/webmanager-mvp", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + + def _run_task_snapshot_sync_behavior_check(self, app_js: str) -> None: + functions = "\n\n".join( + [ + self._extract_js_function(app_js, "headerTaskElements"), + self._extract_js_function(app_js, "formatTaskStatusLabel"), + self._extract_js_function(app_js, "inferDownloadTaskContext"), + self._extract_js_function(app_js, "formatTaskLine"), + self._extract_js_function(app_js, "isActiveTask"), + self._extract_js_function(app_js, "activeTasksFromItems"), + self._extract_js_function(app_js, "activeTaskChipLabel"), + self._extract_js_function(app_js, "headerTaskRenderKey"), + self._extract_js_function(app_js, "shouldPollHeaderTasks"), + self._extract_js_function(app_js, "stopHeaderTaskPolling"), + self._extract_js_function(app_js, "scheduleHeaderTaskPolling"), + self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"), + self._extract_js_function(app_js, "renderHeaderTaskPopover"), + self._extract_js_function(app_js, "renderHeaderTaskChip"), + self._extract_js_function(app_js, "updateHeaderTaskState"), + self._extract_js_function(app_js, "applyTaskSnapshot"), + ] + ) + script = textwrap.dedent( + f""" + const assert = (condition, message) => {{ + if (!condition) {{ + throw new Error(message); + }} + }}; + + function createClassList(initialHidden = false) {{ + const names = new Set(initialHidden ? ["hidden"] : []); + return {{ + add(name) {{ names.add(name); }}, + remove(name) {{ names.delete(name); }}, + toggle(name, force) {{ + if (force === undefined) {{ + if (names.has(name)) {{ + names.delete(name); + }} else {{ + names.add(name); + }} + return; + }} + if (force) {{ + names.add(name); + }} else {{ + names.delete(name); + }} + }}, + contains(name) {{ return names.has(name); }}, + }}; + }} + + function createElement(initialHidden = false) {{ + return {{ + classList: createClassList(initialHidden), + textContent: "", + innerHTML: "", + children: [], + scrollTop: 0, + attributes: {{}}, + append(...nodes) {{ + this.children.push(...nodes); + }}, + setAttribute(name, value) {{ + this.attributes[name] = value; + }}, + }}; + }} + + const elements = {{ + "header-task-chip-container": createElement(true), + "header-task-chip-btn": createElement(false), + "header-task-chip-label": createElement(false), + "header-task-popover": createElement(true), + "header-task-popover-list": createElement(false), + "header-task-logs-btn": createElement(false), + }}; + + const document = {{ + getElementById(id) {{ + return elements[id] || null; + }}, + createElement() {{ + return createElement(false); + }}, + }}; + + const window = {{ + setTimeout(fn, delay) {{ + return 1; + }}, + clearTimeout(id) {{}}, + }}; + + function formatModified(value) {{ + return value || "now"; + }} + + let state = {{ lastTaskCount: 0 }}; + let headerTaskState = {{ + activeItems: [], + popoverOpen: false, + pollTimer: null, + lastRenderKey: "", + }}; + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); + + {functions} + + applyTaskSnapshot([ + {{ id: "copy-1", operation: "copy", status: "running", source: "/src", destination: "/dst" }}, + ]); + assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible"); + assert(elements["header-task-chip-label"].textContent === "1 active task", "Chip should show one active task"); + assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state"); + + applyTaskSnapshot([ + {{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }}, + ]); + assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when latest task snapshot has no active tasks"); + assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed"); + assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length"); + """ + ) + result = subprocess.run( + ["node", "-e", script], + cwd="/workspace/webmanager-mvp", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def test_ui_mount_and_index_contains_expected_panels(self) -> None: mount = self._ui_mount() self.assertIsInstance(mount.app, StaticFiles) @@ -256,6 +550,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('/ui/assets/img/logo.svg', body) self.assertIn('id="title-zone-actions"', body) self.assertIn('id="status"', body) + self.assertIn('id="header-task-chip-container"', body) + self.assertIn('id="header-task-chip-btn"', body) + self.assertIn('id="header-task-popover"', body) + self.assertIn('id="header-task-popover-list"', body) + self.assertIn('id="header-task-logs-btn"', body) self.assertIn('id="theme-toggle"', body) self.assertIn('id="theme-toggle-icon"', body) self.assertIn('id="left-pane"', body) @@ -438,6 +737,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("#title-brand {", base_css) self.assertIn("#title-logo {", base_css) self.assertIn("height: 32px;", base_css) + self.assertIn(".header-task-chip-container {", base_css) + self.assertIn(".header-task-chip {", base_css) + self.assertIn(".header-task-popover {", base_css) + self.assertIn(".header-task-popover-list {", base_css) self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css) self.assertIn(".settings-activity-grid {", base_css) self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css) @@ -476,6 +779,32 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function formatTaskStatusLabel(task)', app_js) self.assertIn('function inferDownloadTaskContext(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js) + self.assertIn('let headerTaskState = {', app_js) + self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js) + self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js) + self.assertIn("Delete stays out of this set because it still runs as a direct request flow", app_js) + self.assertIn('function headerTaskElements()', app_js) + self.assertIn('function isActiveTask(task)', app_js) + self.assertIn('function activeTasksFromItems(items)', app_js) + self.assertIn('function activeTaskChipLabel(count)', app_js) + self.assertIn('function shouldPollHeaderTasks()', app_js) + self.assertIn('function scheduleHeaderTaskPolling()', app_js) + self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js) + self.assertIn('function renderHeaderTaskPopover(items)', app_js) + self.assertIn('function renderHeaderTaskChip(items)', app_js) + self.assertIn('function updateHeaderTaskState(taskItems)', app_js) + self.assertIn('function applyTaskSnapshot(taskItems)', app_js) + self.assertIn('return `${count} active task${count === 1 ? "" : "s"}`;', app_js) + self.assertIn('ACTIVE_TASK_OPERATIONS.has(task.operation)', app_js) + self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js) + self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js) + self.assertIn('const headerTasks = headerTaskElements();', app_js) + self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js) + self.assertIn('headerTasks.logsButton.onclick = () => {', app_js) + self.assertIn('setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen);', app_js) + self.assertIn('setHeaderTaskPopoverOpen(false);', app_js) + self.assertIn('updateHeaderTaskState(items);', app_js) + self.assertIn('renderTaskItems(applyTaskSnapshot(data.items));', app_js) self.assertIn('function renderTaskItems(items)', app_js) self.assertIn('async function loadTasksForSettings()', app_js) self.assertIn('async function loadLogsAndTasksForSettings()', app_js) @@ -529,6 +858,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js) self.assertIn('"/api/files/download/archive-prepare"', app_js) self.assertIn('"/api/files/duplicate"', app_js) + self.assertIn('"/api/files/delete"', app_js) self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js) @@ -553,6 +883,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function startContextMenuDownload()', app_js) self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startDuplicateSelected()', app_js) + self.assertIn('async function deleteSelected()', app_js) self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) @@ -637,6 +968,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('Delete selected items and folder contents?', app_js) self.assertIn('async function loadSettings()', app_js) self.assertIn('await loadSettings();', app_js) + self.assertIn('await refreshTasksSnapshot();', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js) self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js) @@ -676,6 +1008,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function ensureFolderUploadPicker()', app_js) self.assertIn('function openFolderPicker()', app_js) self.assertIn('function uploadModalElements()', app_js) + self._run_header_task_chip_behavior_check(app_js) + self._run_task_snapshot_sync_behavior_check(app_js) self.assertIn('function setUploadModalVisible(', app_js) self.assertIn('function updateUploadModalDisplay(', app_js) diff --git a/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa89add1afa5f38d991661ef83127f758882e917 GIT binary patch literal 4882 zcmb^#ZEO?S@vXhK*Nz8+()N;q02X z>!YMn)T?y8AXTEMLJd--QmInIucLq3`*}b6Gg}AQ+Jc01>JQXEAR<-jug<*n#!heq zr}oKtJ8#~+c{6Y3&CJWoNF+p{jeYSK^&Dm|Trs<|^s=8)O!kGWD zY%V-Q)-c#}K{ZUB-pnZ_-B1w%Wcy{=xV8;*h$?wq%d162Dk;=}HA+EJwYffP4%Jh<+-}2hu@1 z?#PqZKU3&UQK6rydF8TVn9riXJ6|^(+Z=5HnsF})bDe31#4cBtXA1#9+W_M<#f=X?#VN#0v+Po$& zDt4eSE0w@!D4vWRM2->#f1#+89bRxeh@l>Udl^b`&OoE2>=QSX*|Iu3E9b8%T4C4( zn;FApO*I+Fyatvzc9jgru$j_L+h-`|RLSn$#qJq4-2qz|B6L3|j}xhCV)XBc(Mn=$ zh5uFLalGeuZ{O$t6!}A>ni{cEBb9xl4?nf`ovXy(hxzd1jKB3bPso(q%#Wpc4 zM#Oe8Dt3r_#8@WKkYL+2bvJAJwwBP1)EWo#cs4uQ6en`SoJ+N(>%NI z_K2Mrsl={r{98%k5xW`X-Z!9(Gs?s^%B^72kaEf+CXv#Wa#tI+%9uUkzSEqzAJ=Q% zU9|Ixy-bqrr`Jk5?;D?kx8*rBl-h!Ftt`^!>Z4KAt_3#oyJX7TDug z(}L|$3wBV4{K|5*?KdE4mks#pWtvy)wgQAtRdXgod8){mgk{qX0U@o!;e44=MKdK8 zg1zm(URKIVA?>#Xz|otMTA)~G&?DHrgL0jQ|U1=|Pv0HVtP z^&$I1esJ=Iw4|1nfGlrh5ZbBJNJ~ve5&IxO596l031YX+shVmmFy)w}wtr5Bv{kS} z3Z*)g@_IqBJ5XGxK}^7vOQ60Tab_14!vLEs4Py9A-H?q`6H#hbnbWC~s#h*h6HzRt zo}t4+<>M==i>gU6*fSy1L5iu@j;k7^C)3GTz(B%O1}8$ha5n51Lt6E7W#EDz zTrbN-TbNgzrFPgsUo<9lO|ip8NS#tWf2MsD<2fXT1zDR1b$C?J4%DaZ5Ho~RX`!?! zY);C!gwn7?`nnQLT9oNEL(+={Nz*mZf-Ztb6&!T}`*vBh@r$V?YBrGAZ8o&SUdjH6VFj|Xt z-d?`7T#XJ|(ZT!6mFS5T95Qb~9z17%&6&t9=vQ{kn@cMczyB<6J^+79kb|uhY6TjbeuXn9| zLpa%tOIXRHYZo?zv1a6cw%-RE!iTl)&M@(BtQ7(l$HG9->y9pt}-uA^At;J=a4b@vw9uscH2)CvrILc$KVamQbCgN zeI!YCNRk%yLK*U-WQQdQgMNJ`DoJxHHO!)_DVi=x6#s=ld$Bu@9ja}2OA_XKH4oX` zr0Q%L3@1qs2$P3}c#7AJby(0D$mEw~)5H7DZiYEuYSW+bo%;7rxDMYm=PW{c6mU^i zHAzaxQN+12hz}xCe5JrE@1{YqSh59tk)f~H0et2a)!F*%4@b7&NqrQbTgVX~kf3IO zDu!MJzlJqfa2WQ%`ZF&hmrC&9%rw6XoqsJj55~S7ba-3XA=8`wagLkjRu`&$!&cw$Cj9dXhR?5h2p?Wqs(6z%KK$jSUtId)N6)>4?_t9I E7xuDPBLDyZ literal 0 HcmV?d00001 diff --git a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc index 433237f4a7ef5315603d5ceaee9bef78b4651f43..0bb0d5234aa03999658fa86abf5578a0ba1adf17 100644 GIT binary patch delta 2162 zcmaJ>%TF6e7~fs|*j>Ny1HTH`1_Cv~W_cJ0X$VO~8`_FSS(>D2Qg>q(;$pAStZ5n! zbq+lcwN;vqqL4~e%%KvwB_*oz544x|zzQO)?InkvE21D(%BkP1A*LnCX!X-?zM1c_ zGrw>C>i^-S^O?iJQ!tjl`XxINe&GC$E`0&hThs*#(ISNykw$f*Q)I%_XC`CG|riMi?kOyd|-kKQ4W z1c=umFX7_^gw-pqE~a6a@WTY&B5;Jj2!7;FoF=}yGcTdpbVgD#axN!N8L@O;QdJoh zC(*oY#MNC@!?-$8C{7d<`JSnkNVjG#OUexR3af*jBecy1aqB3Ges+q%5q^jlEuvL4 ziENU^S#u1JI(=?VWJF%HiFVNeXW~j)VdWTpSntF4c`sZ7{+*9iDTgEOn3opmZaT$4 zU>H&AL+Uh#AMoA)Bi5V2BD!C?<}ud1``0)|^cianxX0FN^%I_=ao!ziV8lRmMl$Wq zRjS*`#nC|P9&HG!9I-JL~XhN1*w01Liep?4r5^d)+PN#W42 zAPfsV=i@ADB|F*(93aq6po74h06K_6LZzZSm6nRSJu{DxtQ6DPqMToF8o4ut{9I0k z4LbLNqE6y}5MW={6eK>s&pJS?aqFvUDOHO>lO!yuQnyS?S&)p*%Lo-vI#ZaE^?FiI zI;#}HHa|xya#UCIvZ{i<7uqLbL-dNIM)z!rUXrH^NRI9lpDidTP12BDP?4MPQWwb? zMslX0WFRjj+5OivNvhV`K)2WAs>tXpiLxWuk4UJC1U>~&O#o3C$Ol9}RFu_X`V|=> zkRnZ*MNKOOB@I8A&ni+by>kff<1Krz^a|hCx+SW2P@6}f2!ZYrdiKkQVA%nSIO-qW~OR22Sn8uu_z@`tB^@X8tGq zqnX1SyvUu4i5Ak*-B_@-aCIV2CZY|`x?}j~dOz)gm!zeX+6fh=)cdV&dnit zPC}%?N5I`^l7)uuVt%fBs*q8W3)#6OtQWIV4jm;MMhO_rECGWSH$*%}tZx&L3A}i? z(V1y7KS|&m!2g6f@H$}>vq8xmUC|~Y}Nd>>+F~{*z zqT-KM0*Nh-;#!_kW)P8++AkuP_r}%hS2tZT%@r$mth6mB)?LD)sdjOJmHy=;Vyv1ri|%U&Wy*yZ~O}_mQF?h delta 520 zcmbQ`@x+AhGcPX}0}wQJZq2M0+{ib9F)IbgoyriyP{bI_P{b6>Si~I66U-#Vki}BO z0%S3RS*$=73z)?QWU+!->_8S9n8g8Pv4dHhKo$p>#RX(>f?3?bTtz(T+?u?bWte(c zc&oUIOY(~n(^C!gld3jvWt+*wsI}Rja|@Grm57mnk(sW6v96&(h@pX%v4NF=L6qiX zDIRl1`^iB(j$%4Mw`#H!2?MEH?8!x`i6yBiMS7c8@*HE5aRdq$IfDqWDrT^%B3F=r z-sWz89VSr=5Ze+&Sb+#@5MeXOUr;;h7&g6O}O{VX1lea19 zFh)#%tkkKR43xdalAD;BSCj!#C=Mb-K!O}Lx%nxjIjMF(@p@Mnxe`RR^?Ye)A diff --git a/webui/backend/tests/unit/test_task_recovery_service.py b/webui/backend/tests/unit/test_task_recovery_service.py new file mode 100644 index 0000000..d2e497a --- /dev/null +++ b/webui/backend/tests/unit/test_task_recovery_service.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.db.history_repository import HistoryRepository +from backend.app.db.task_repository import TaskRepository +from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks + + +class TaskRecoveryServiceTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.db_path = str(Path(self.temp_dir.name) / "tasks.db") + self.task_repo = TaskRepository(self.db_path) + self.history_repo = HistoryRepository(self.db_path) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_reconcile_persisted_incomplete_tasks_marks_old_non_terminal_tasks_failed(self) -> None: + self.task_repo.insert_task_for_testing( + { + "id": "task-running", + "operation": "copy", + "status": "running", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "created_at": "2026-03-10T10:00:00Z", + "started_at": "2026-03-10T10:00:01Z", + "current_item": "storage1/a.txt", + } + ) + self.history_repo.create_entry( + entry_id="task-running", + operation="copy", + status="queued", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + ) + self.task_repo.insert_task_for_testing( + { + "id": "task-ready", + "operation": "download", + "status": "ready", + "source": "single_directory_zip", + "destination": "docs.zip", + "created_at": "2026-03-10T10:02:00Z", + "finished_at": "2026-03-10T10:03:00Z", + } + ) + + changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo) + + self.assertEqual(changed, ["task-running"]) + task = self.task_repo.get_task("task-running") + self.assertEqual(task["status"], "failed") + self.assertEqual(task["error_code"], "task_interrupted") + self.assertEqual(task["error_message"], "Task was interrupted before completion") + self.assertIsNone(task["current_item"]) + history = self.history_repo.list_history(limit=5)[0] + self.assertEqual(history["id"], "task-running") + self.assertEqual(history["status"], "failed") + self.assertEqual(history["error_code"], "task_interrupted") + ready_task = self.task_repo.get_task("task-ready") + self.assertEqual(ready_task["status"], "ready") + + def test_reconcile_persisted_incomplete_tasks_is_noop_when_all_tasks_terminal(self) -> None: + self.task_repo.insert_task_for_testing( + { + "id": "task-completed", + "operation": "move", + "status": "completed", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "created_at": "2026-03-10T10:00:00Z", + "finished_at": "2026-03-10T10:00:02Z", + } + ) + + changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo) + + self.assertEqual(changed, []) + self.assertEqual(self.task_repo.get_task("task-completed")["status"], "completed") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/unit/test_task_repository.py b/webui/backend/tests/unit/test_task_repository.py index ddd6a15..dae4de8 100644 --- a/webui/backend/tests/unit/test_task_repository.py +++ b/webui/backend/tests/unit/test_task_repository.py @@ -107,6 +107,64 @@ class TaskRepositoryTest(unittest.TestCase): self.assertEqual(task["status"], "cancelled") self.assertIsNotNone(task["finished_at"]) + def test_reconcile_incomplete_tasks_marks_non_terminal_failed(self) -> None: + self.repo.insert_task_for_testing( + { + "id": "task-running", + "operation": "copy", + "status": "running", + "source": "storage1/a", + "destination": "storage2/a", + "created_at": "2026-03-10T09:00:00Z", + "started_at": "2026-03-10T09:00:01Z", + "current_item": "storage1/a", + } + ) + self.repo.insert_task_for_testing( + { + "id": "task-completed", + "operation": "copy", + "status": "completed", + "source": "storage1/b", + "destination": "storage2/b", + "created_at": "2026-03-10T09:05:00Z", + "finished_at": "2026-03-10T09:06:00Z", + } + ) + + changed = self.repo.reconcile_incomplete_tasks() + + running = self.repo.get_task("task-running") + completed = self.repo.get_task("task-completed") + self.assertEqual(changed, ["task-running"]) + self.assertEqual(running["status"], "failed") + self.assertEqual(running["error_code"], "task_interrupted") + self.assertEqual(running["error_message"], "Task was interrupted before completion") + self.assertIsNone(running["current_item"]) + self.assertIsNotNone(running["finished_at"]) + self.assertEqual(completed["status"], "completed") + + def test_reconcile_incomplete_tasks_removes_stale_artifact(self) -> None: + created = self.repo.create_task( + operation="download", + source="storage1/docs", + destination="docs.zip", + status="preparing", + ) + self.repo.upsert_artifact( + task_id=created["id"], + file_path="/tmp/docs.zip.partial", + file_name="docs.zip", + expires_at="2026-03-10T10:30:00Z", + ) + + changed = self.repo.reconcile_incomplete_tasks() + + task = self.repo.get_task(created["id"]) + self.assertEqual(changed, [created["id"]]) + self.assertEqual(task["status"], "failed") + self.assertIsNone(self.repo.get_artifact(created["id"])) + def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None: legacy_db_path = Path(self.temp_dir.name) / "legacy.db" conn = sqlite3.connect(legacy_db_path) diff --git a/webui/html/app.js b/webui/html/app.js index 0055348..30abb2a 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -114,6 +114,16 @@ let settingsState = { selectedColorMode: "dark", zipDownloadLimits: null, }; +let headerTaskState = { + activeItems: [], + popoverOpen: false, + pollTimer: null, + lastRenderKey: "", +}; +// The header chip reflects only user-visible file actions that currently use the shared task system. +// Delete stays out of this set because it still runs as a direct request flow, not as a backend task. +const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); +const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); const VALID_THEME_FAMILIES = [ "default", "macos-soft", @@ -189,6 +199,17 @@ function setStatus(msg) { document.getElementById("status").textContent = msg; } +function headerTaskElements() { + return { + container: document.getElementById("header-task-chip-container"), + chipButton: document.getElementById("header-task-chip-btn"), + chipLabel: document.getElementById("header-task-chip-label"), + popover: document.getElementById("header-task-popover"), + popoverList: document.getElementById("header-task-popover-list"), + logsButton: document.getElementById("header-task-logs-btn"), + }; +} + function setError(id, msg) { if (id === "actions-error") { document.getElementById(id).textContent = ""; @@ -1266,7 +1287,7 @@ async function uploadFileRequest(targetPath, file, overwrite = false) { async function refreshTasksSnapshot() { try { const data = await apiRequest("GET", "/api/tasks"); - state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount; + applyTaskSnapshot(data.items); } catch (_) { // Task list panel is not visible in current UI; silently keep flow stable. } @@ -3865,6 +3886,138 @@ function formatTaskLine(task) { }; } +function isActiveTask(task) { + return Boolean(task) && ACTIVE_TASK_OPERATIONS.has(task.operation) && ACTIVE_TASK_STATUSES.has(task.status); +} + +function activeTasksFromItems(items) { + return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : []; +} + +function activeTaskChipLabel(count) { + return `${count} active task${count === 1 ? "" : "s"}`; +} + +function headerTaskRenderKey(items) { + return JSON.stringify( + Array.isArray(items) + ? items.map((task) => ({ + id: task.id || "", + operation: task.operation || "", + status: task.status || "", + source: task.source || "", + destination: task.destination || "", + done_items: task.done_items, + total_items: task.total_items, + current_item: task.current_item || "", + })) + : [] + ); +} + +function shouldPollHeaderTasks() { + return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0; +} + +function stopHeaderTaskPolling() { + if (headerTaskState.pollTimer) { + window.clearTimeout(headerTaskState.pollTimer); + headerTaskState.pollTimer = null; + } +} + +function scheduleHeaderTaskPolling() { + stopHeaderTaskPolling(); + if (!shouldPollHeaderTasks()) { + return; + } + headerTaskState.pollTimer = window.setTimeout(async () => { + await refreshTasksSnapshot(); + scheduleHeaderTaskPolling(); + }, 1500); +} + +function setHeaderTaskPopoverOpen(nextOpen) { + const elements = headerTaskElements(); + const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0; + headerTaskState.popoverOpen = open; + if (elements.chipButton) { + elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false"); + } + if (elements.popover) { + elements.popover.classList.toggle("hidden", !open); + } + scheduleHeaderTaskPolling(); +} + +function renderHeaderTaskPopover(items) { + const elements = headerTaskElements(); + if (!elements.popoverList) { + return; + } + const renderKey = headerTaskRenderKey(items); + if (headerTaskState.lastRenderKey === renderKey) { + return; + } + const scrollTop = elements.popoverList.scrollTop; + elements.popoverList.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + const empty = document.createElement("div"); + empty.className = "header-task-item-empty"; + empty.textContent = "No active tasks right now."; + elements.popoverList.append(empty); + headerTaskState.lastRenderKey = renderKey; + return; + } + for (const task of items) { + const line = formatTaskLine(task); + const row = document.createElement("div"); + row.className = "header-task-item"; + const title = document.createElement("div"); + title.className = "header-task-item-title"; + title.textContent = line.title; + const path = document.createElement("div"); + path.className = "header-task-item-path"; + path.textContent = line.path; + const meta = document.createElement("div"); + meta.className = "header-task-item-meta"; + meta.textContent = line.meta; + row.append(title, path, meta); + elements.popoverList.append(row); + } + headerTaskState.lastRenderKey = renderKey; + elements.popoverList.scrollTop = scrollTop; +} + +function renderHeaderTaskChip(items) { + const elements = headerTaskElements(); + if (!elements.container || !elements.chipLabel) { + return; + } + const hasActiveTasks = Array.isArray(items) && items.length > 0; + elements.container.classList.toggle("hidden", !hasActiveTasks); + elements.chipLabel.textContent = activeTaskChipLabel(items.length); + if (!hasActiveTasks) { + headerTaskState.lastRenderKey = ""; + setHeaderTaskPopoverOpen(false); + return; + } + renderHeaderTaskPopover(items); +} + +function updateHeaderTaskState(taskItems) { + headerTaskState.activeItems = activeTasksFromItems(taskItems); + renderHeaderTaskChip(headerTaskState.activeItems); + scheduleHeaderTaskPolling(); +} + +function applyTaskSnapshot(taskItems) { + const items = Array.isArray(taskItems) ? taskItems : []; + state.lastTaskCount = items.length; + updateHeaderTaskState(items); + return items; +} + function renderHistoryItems(items) { const elements = settingsElements(); const renderKey = JSON.stringify(Array.isArray(items) ? items : []); @@ -3957,7 +4110,7 @@ async function loadHistoryForSettings() { async function loadTasksForSettings() { const data = await apiRequest("GET", "/api/tasks"); - renderTaskItems(data.items || []); + renderTaskItems(applyTaskSnapshot(data.items)); settingsState.tasksLoaded = true; } @@ -4517,6 +4670,11 @@ function handleKeyboardShortcuts(event) { if (!shouldHandleShortcut(event.target)) { return; } + if (event.key === "Escape" && headerTaskState.popoverOpen) { + event.preventDefault(); + setHeaderTaskPopoverOpen(false); + return; + } const isInfoShortcut = event.key === "Enter" && !event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); if (isInfoShortcut) { @@ -4627,6 +4785,19 @@ function setupEvents() { setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("theme-toggle").onclick = toggleTheme; + const headerTasks = headerTaskElements(); + if (headerTasks.chipButton) { + headerTasks.chipButton.onclick = (event) => { + event.stopPropagation(); + setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen); + }; + } + if (headerTasks.logsButton) { + headerTasks.logsButton.onclick = () => { + setHeaderTaskPopoverOpen(false); + openSettings("logs"); + }; + } document.getElementById("upload-btn").onclick = openUploadPicker; document.getElementById("upload-menu-toggle").onclick = (event) => { event.stopPropagation(); @@ -4715,6 +4886,10 @@ function setupEvents() { } else { closeUploadMenu(); } + const headerTaskContainer = headerTaskElements().container; + if (headerTaskContainer && !headerTaskContainer.contains(event.target)) { + setHeaderTaskPopoverOpen(false); + } const contextMenu = contextMenuElements().menu; if (contextMenu && !contextMenu.contains(event.target)) { closeContextMenu(); @@ -4914,6 +5089,7 @@ async function init() { paneState("right").currentPath = "/Volumes"; await loadBrowsePane("right"); } + await refreshTasksSnapshot(); } init(); diff --git a/webui/html/base.css b/webui/html/base.css index 006d144..c6613fe 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -71,6 +71,92 @@ body { min-width: 0; } +.header-task-chip-container { + position: relative; + flex: 0 0 auto; +} + +.header-task-chip { + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-primary); + border-radius: 999px; + padding: 5px 10px; + font: inherit; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 1px 2px rgba(8, 14, 22, 0.08); +} + +.header-task-chip:hover, +.header-task-chip[aria-expanded="true"] { + border-color: var(--color-accent); +} + +.header-task-popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(360px, calc(100vw - 24px)); + padding: 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-elevated); + box-shadow: var(--shadow-elevated); + z-index: 30; +} + +.header-task-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.header-task-link { + border: 0; + background: none; + color: var(--color-accent); + padding: 0; + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.header-task-popover-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 260px; + overflow-y: auto; +} + +.header-task-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + padding: 8px 9px; +} + +.header-task-item-title { + font-size: 12px; + font-weight: 700; +} + +.header-task-item-path, +.header-task-item-meta, +.header-task-item-empty { + margin-top: 4px; + font-size: 12px; + color: var(--color-text-muted); + word-break: break-word; +} + h1, h2, h3 { margin: 0; } diff --git a/webui/html/index.html b/webui/html/index.html index 1678328..7e9e2b3 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -25,6 +25,25 @@

+