Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ed4fcb5e |
@@ -4,7 +4,7 @@
|
||||
|
||||
ForgeFlow is een Windows-desktopapp voor wie Git, Gitea en eigen Docker- of Unraid-servers gebruikt. Je ziet in één werkruimte wat lokaal gewijzigd is, wat op Gitea staat en welke exacte commit op de server draait. ForgeFlow begeleidt je daarna veilig door review, commit, push, deployment en verificatie.
|
||||
|
||||
> Huidige release: **0.10.11** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||
> Huidige release: **0.10.12** · [download de laatste Windows-release](https://gitea.itworx.tech/Jens/ForgeFlow/releases/latest)
|
||||
|
||||

|
||||
|
||||
|
||||
+19
-18
@@ -1,4 +1,4 @@
|
||||
ForgeFlow 0.10.11 source manifest
|
||||
ForgeFlow 0.10.12 source manifest
|
||||
SHA-256 BYTES PATH
|
||||
(The manifest excludes itself, dependencies and generated release artifacts.)
|
||||
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
|
||||
@@ -36,6 +36,7 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
|
||||
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
|
||||
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.md
|
||||
36edb4f096a248fb8679bd13e5766befb478cf628c6ccfb21e3eda71bbec7633 992 docs/RELEASE_NOTES_0.10.11.md
|
||||
a355d3f577c2ec85dde5dfd7b6995f4f1615e2f6bac597529f3ff102acd93c35 1292 docs/RELEASE_NOTES_0.10.12.md
|
||||
8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
|
||||
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
|
||||
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
|
||||
@@ -105,14 +106,14 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
||||
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
|
||||
d7cae22a646c8f06f339db799f2b9921ba1799cdc44e5d05578c04aaace103ab 14357 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
9957d6e9730a6d357647f829a878a265bf5aade1bcc55312df1c09a1fa12ad75 179808 package-lock.json
|
||||
728b894b767f1b4050fb0e08d19d08decb32489afe1011fa0e3dfaeed1510544 5743 package.json
|
||||
198a8a51705fcea2ddf716558a49e06df5a8181c5476709c474d183b64890ca0 179808 package-lock.json
|
||||
84fa9b490e0845b1788d4f58741d733f0afd5946db5487dfba03a6e3018890cc 5782 package.json
|
||||
1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
|
||||
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs
|
||||
16d6c37007c6c14dbf541060535c594308758477b867fc2a190c2946d74022b9 12196 preload.cjs
|
||||
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
||||
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
|
||||
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
|
||||
3351f252c6fec606e9f164ea9d20f695845a8eae64855a5562e193329862f729 10315 README.md
|
||||
42e2f375c2fa9bea138428b85eb2bb93fe8bf6e4a5fc0cd728f2c6713a019df7 10315 README.md
|
||||
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
|
||||
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
|
||||
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
|
||||
@@ -128,7 +129,7 @@ b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102
|
||||
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
|
||||
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
||||
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
|
||||
eba4a4540ec5f62884a72f1ee80ba35a2a6b7243b2a9e095f7310896aa55f58f 21034 scripts/verify.mjs
|
||||
1ad4023136361431eeda4df740ec1be5d00d63be0d7a3533250da68c3971480d 21407 scripts/verify.mjs
|
||||
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
@@ -136,7 +137,7 @@ dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412
|
||||
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
||||
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
|
||||
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.cjs
|
||||
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
|
||||
e36ae11f6f60c9520a1f7b77b034091900d49306ff8674b5293eb95d679dcc5c 24273 src/main/deployment-service.cjs
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
|
||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
||||
@@ -148,24 +149,24 @@ dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404
|
||||
f263ab067edea8dfeb962958d045ed4ab20b49cbeb965936f4ffa8c1dd0b7d59 25905 src/main/ipc.cjs
|
||||
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs
|
||||
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-handlers.cjs
|
||||
629b0f4704cda6ed6288c5a6ec9e5d73d6ae3e2692c43581b258431b65b58e51 15887 src/main/ipc/repository-handlers.cjs
|
||||
253d4f027de09e1e8fd46b40ef0da4eaf8350c0edfbf069b0a3429d712c7e8c4 15929 src/main/ipc/repository-handlers.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
|
||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
|
||||
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs
|
||||
d955b13609bab728a07faab89c4998686f6fc7e220c46fe04a3bcb4a63c5b4f5 9122 src/main/repository-service.cjs
|
||||
e48ac82e3a618459170969a6370c8a2d667594cd0b1915ccece38ba3f7eda6df 10303 src/main/repository-service.cjs
|
||||
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
|
||||
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
|
||||
5394d16c1ceceef700a4b2a47cda088713e24c2e42a4bf4f86b1fa6006e796a2 25877 src/main/unraid-access-methods.cjs
|
||||
5ba5a90ad9b7a0d05dd64fa64968cbdd7271bec4df6971f9685a9dd411e4fad4 9536 src/main/unraid-deploy-key-host.cjs
|
||||
6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
|
||||
b8a6ef72cfd5c5405e3f1a48848b5ef1575ca82ebbe8cad816dff0dc7335c8ec 17184 src/main/unraid-deployment-service.cjs
|
||||
c3439857c985a44f8298f76aca46ad71f5fa30eabb32ddca9d18fc4f65dc4a38 38809 src/main/unraid-inventory-methods.cjs
|
||||
a2fa253bb2664ad7e430d7a4cd4c4434b89e6c54c19c7b9e4f53fe08485339d3 42652 src/main/unraid-inventory-methods.cjs
|
||||
2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
|
||||
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-methods.cjs
|
||||
d4b3a07eeca687a49544a94ea574f7c6f7e0bc3aa9311b614d5244a468ca4a1b 11307 src/main/unraid-state-methods.cjs
|
||||
9f92b5716ff72e676218863ca8f9678edf5d61db8a9cef5bdfe579cc4446d9c8 11783 src/main/unraid-state-methods.cjs
|
||||
a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs
|
||||
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
|
||||
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
|
||||
@@ -174,7 +175,7 @@ d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706
|
||||
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
|
||||
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
|
||||
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
|
||||
e8521a5dfc5f8b7462589ae6d9bfb89280b348297da831be74b94fe7987fe7b5 24724 src/renderer/app.js
|
||||
d399b9b277350225ba98634f595032eae4412a07071faea69185ba5f1c0f2330 26597 src/renderer/app.js
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||
@@ -183,10 +184,10 @@ e8521a5dfc5f8b7462589ae6d9bfb89280b348297da831be74b94fe7987fe7b5 24724
|
||||
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
|
||||
a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
|
||||
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
|
||||
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js
|
||||
d8b2a9bb7e51f9e7d06e3f449d7e4c6b4f1aaa66ffb0fa5c10f4ff9d9c6c143a 20033 src/renderer/mock-repository-bridge.js
|
||||
870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
|
||||
81b0155dd51c7bc8871fd29bd7fdb28857cf11a86ed6d6953238c04c2774800c 20033 src/renderer/mock-repository-bridge.js
|
||||
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
|
||||
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css
|
||||
21b8f3228bddd6da06613857763282d1f88f4eeab3d063f3a63395d7b567f2e4 80004 src/renderer/styles.css
|
||||
8bc794e191aa22a13cbbc89c9b3882e0c3070e1365a7c02cfe381a62d81877d9 101549 src/renderer/views.js
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||
@@ -207,7 +208,7 @@ ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214
|
||||
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
|
||||
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
|
||||
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
||||
50e90cd41dae952a14903c40c0cb1fd191d7b875cfeb730f06a454e755fad7ce 9076 tests/deployment-status.test.mjs
|
||||
bf4576901e32662d832687a2761852aa1b2cffe256de5044f18c6637c189463b 9780 tests/deployment-status.test.mjs
|
||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
||||
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
|
||||
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
|
||||
@@ -226,14 +227,14 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392
|
||||
d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs
|
||||
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
|
||||
62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs
|
||||
b4132272eda39edcb40c36d2fba73a57782b13e9f7e6902985d795f7c720349f 9400 tests/repository-service.test.mjs
|
||||
32056b36a7e78701e8b9253ea78646ab4ecd88c5788841a1459177417ecb2e08 10441 tests/repository-service.test.mjs
|
||||
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
|
||||
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
|
||||
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||
c48739160c0e5a7a4ba75b3528835f4a93e39a4947b3d887147c2c0195a819b0 54354 tests/unraid-deployment.test.mjs
|
||||
db0a347cdab1da11a04d41981695a65d7244367c46f0cdc868326e8d00d73cbf 56694 tests/unraid-deployment.test.mjs
|
||||
bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 tests/update-service.test.mjs
|
||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# ForgeFlow 0.10.12
|
||||
|
||||
## Faster awareness with stricter deployment truth
|
||||
|
||||
- Repository refreshes are **coalesced** and briefly cache Gitea inventory and workspace discovery; a manual refresh remains fully forced and file changes arriving mid-refresh receive one trailing refresh.
|
||||
- Server discovery reuses its Docker and Compose evidence for existing deployment profiles instead of opening a separate SSH session for every linked workload.
|
||||
- Deployment status only claims **exact Gitea commit parity** after comparing a concrete branch SHA with the live server SHA; matching repository provenance alone is no longer sufficient.
|
||||
- Container discovery uses **batched Docker inspect** with a safe per-container fallback when a container disappears during the scan.
|
||||
- Active Gitea and SSH deployment polling uses **bounded worker pools**, improving multi-deployment latency without flooding external services.
|
||||
- A **stopped container** can no longer be marked healthy because another process answers on its previous healthcheck port.
|
||||
- Large repository and server-inventory lists use offscreen rendering containment to reduce layout and paint work.
|
||||
- Inventory diagnostics now include scan and state-refresh durations, and Gitea bulk verification fails fast after a confirmed connectivity outage.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.10.11",
|
||||
"version": "0.10.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "forgeflow",
|
||||
"version": "0.10.11",
|
||||
"version": "0.10.12",
|
||||
"dependencies": {
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "forgeflow",
|
||||
"version": "0.10.11",
|
||||
"version": "0.10.12",
|
||||
"private": true,
|
||||
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
|
||||
"main": "main.cjs",
|
||||
@@ -116,6 +116,7 @@
|
||||
"docs/RELEASE_NOTES_0.10.9.md",
|
||||
"docs/RELEASE_NOTES_0.10.10.md",
|
||||
"docs/RELEASE_NOTES_0.10.11.md",
|
||||
"docs/RELEASE_NOTES_0.10.12.md",
|
||||
"docs/CURRENT_STATE.md",
|
||||
"docs/MUTATION_MODEL.md",
|
||||
"docs/RELEASING.md",
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld(
|
||||
serverId,
|
||||
remoteFolder,
|
||||
}),
|
||||
refreshRepositories: () => invoke('repositories:refresh'),
|
||||
refreshRepositories: (options = {}) => invoke('repositories:refresh', options),
|
||||
discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
|
||||
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
|
||||
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
|
||||
|
||||
+7
-2
@@ -91,6 +91,7 @@ const required = [
|
||||
"docs/RELEASE_NOTES_0.10.9.md",
|
||||
"docs/RELEASE_NOTES_0.10.10.md",
|
||||
"docs/RELEASE_NOTES_0.10.11.md",
|
||||
"docs/RELEASE_NOTES_0.10.12.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
@@ -129,9 +130,9 @@ for (const file of required) await access(path.join(root, file));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.10.11")
|
||||
if (packageJson.version !== "0.10.12")
|
||||
throw new Error(
|
||||
`Expected package version 0.10.11, got ${packageJson.version}.`,
|
||||
`Expected package version 0.10.12, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
@@ -489,6 +490,10 @@ const release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.
|
||||
for (const phrase of ["last-known-good", "closed output pipe", "linked checkout origin", "read-only deploy key", "browser test server"]) {
|
||||
if (!release01011.includes(phrase)) throw new Error(`0.10.11 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.md"), "utf8");
|
||||
for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) {
|
||||
if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`);
|
||||
}
|
||||
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
|
||||
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
|
||||
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
|
||||
|
||||
@@ -328,8 +328,15 @@ class DeploymentService {
|
||||
|
||||
async refreshActiveOperations() {
|
||||
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
||||
const queue = active.slice(0, 20);
|
||||
const results = [];
|
||||
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const operation = queue.shift();
|
||||
results.push(await this.refreshOperation(operation.id));
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ function registerRepositoryIpc({
|
||||
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
||||
matchRemoteToRepository, shell, dialog,
|
||||
}) {
|
||||
register("repositories:refresh", async () => {
|
||||
const result = await repositories.refresh();
|
||||
register("repositories:refresh", async ({ force = false }) => {
|
||||
const result = await repositories.refresh({ force: force === true });
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -31,6 +31,10 @@ class RepositoryService {
|
||||
this.lastKnownLocalPaths = [];
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
this.lastRemoteRefreshAtMs = 0;
|
||||
this.lastDiscoveredPaths = [];
|
||||
this.lastDiscoveryAtMs = 0;
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
|
||||
async discoverInRoot(root, maxDepth = 4) {
|
||||
@@ -82,17 +86,27 @@ class RepositoryService {
|
||||
return [...this.lastKnownLocalPaths];
|
||||
}
|
||||
|
||||
async getRemoteRepositories() {
|
||||
async getRemoteRepositories({ force = false } = {}) {
|
||||
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
return { repositories: [], stale: false, error: null };
|
||||
}
|
||||
|
||||
if (!force && this.lastSuccessfulRemoteRefreshAt && Date.now() - this.lastRemoteRefreshAtMs < 15_000) {
|
||||
return {
|
||||
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
|
||||
stale: false,
|
||||
error: null,
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const repositories = await this.gitea.listRepositories();
|
||||
this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository }));
|
||||
this.lastSuccessfulRemoteRefreshAt = new Date().toISOString();
|
||||
this.lastRemoteRefreshAtMs = Date.now();
|
||||
return { repositories, stale: false, error: null };
|
||||
} catch (error) {
|
||||
if (!this.lastSuccessfulRemoteRefreshAt) throw error;
|
||||
@@ -109,12 +123,28 @@ class RepositoryService {
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
async getDiscoveredPaths({ force = false } = {}) {
|
||||
if (!force && this.lastDiscoveryAtMs && Date.now() - this.lastDiscoveryAtMs < 30_000) {
|
||||
return [...this.lastDiscoveredPaths];
|
||||
}
|
||||
const paths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
this.lastDiscoveredPaths = [...paths];
|
||||
this.lastDiscoveryAtMs = Date.now();
|
||||
return paths;
|
||||
}
|
||||
|
||||
async refresh(options = {}) {
|
||||
if (this.refreshPromise) return this.refreshPromise;
|
||||
this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; });
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async performRefresh({ force = false } = {}) {
|
||||
const started = Date.now();
|
||||
const remoteResult = await this.getRemoteRepositories();
|
||||
const remoteResult = await this.getRemoteRepositories({ force });
|
||||
const remoteRepositories = remoteResult.repositories;
|
||||
|
||||
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
const discoveredPaths = await this.getDiscoveredPaths({ force });
|
||||
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
||||
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
|
||||
const localDescriptors = await this.getLocalDescriptors(localPaths);
|
||||
@@ -179,6 +209,7 @@ class RepositoryService {
|
||||
durationMs: Date.now() - started,
|
||||
remoteCount: remoteRepositories.length,
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteCached: remoteResult.cached === true,
|
||||
discoveredCount: discoveredPaths.length,
|
||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
|
||||
@@ -34,18 +34,23 @@ function createUnraidInventoryMethods({
|
||||
fi
|
||||
if [ -n "$ids" ]; then
|
||||
disappeared=0
|
||||
while IFS= read -r container_id; do
|
||||
[ -n "$container_id" ] || continue
|
||||
# Use Docker's own JSON document instead of a Go template. Accessing an
|
||||
# absent .State.Health map key makes the formatted Docker inspect fail for
|
||||
# every container without a healthcheck, which previously made those
|
||||
# containers look as if they disappeared during the scan.
|
||||
if inspect=$(docker inspect "$container_id" 2>/dev/null); then
|
||||
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
||||
else
|
||||
disappeared=$((disappeared + 1))
|
||||
fi
|
||||
done <<< "$ids"
|
||||
mapfile -t container_ids <<< "$ids"
|
||||
# Docker accepts multiple IDs and returns one JSON array. This avoids one
|
||||
# daemon round-trip per container on larger Unraid installations.
|
||||
if inspect=$(docker inspect "\${container_ids[@]}" 2>/dev/null); then
|
||||
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
||||
else
|
||||
# A container can disappear between docker ps and inspect. Fall back to
|
||||
# individual reads so the remaining inventory stays complete.
|
||||
for container_id in "\${container_ids[@]}"; do
|
||||
[ -n "$container_id" ] || continue
|
||||
if inspect=$(docker inspect "$container_id" 2>/dev/null); then
|
||||
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
|
||||
else
|
||||
disappeared=$((disappeared + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$disappeared" -gt 0 ]; then
|
||||
printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')"
|
||||
fi
|
||||
@@ -99,26 +104,35 @@ function createUnraidInventoryMethods({
|
||||
(
|
||||
set -- -f "$primary"
|
||||
files_text=$primary
|
||||
has_override=false
|
||||
for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do
|
||||
[ -f "$extra" ] || continue
|
||||
has_override=true
|
||||
set -- "$@" -f "$extra"
|
||||
files_text="$files_text
|
||||
$extra"
|
||||
done
|
||||
project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true)
|
||||
[ -n "$project_name" ] || project_name=$(basename "$dir")
|
||||
valid=false; services=''; images=''; compose_error=''
|
||||
valid=false; services=''; compose_error=''
|
||||
images=$(awk '
|
||||
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
|
||||
in_services && /^[^[:space:]]/ { exit }
|
||||
in_services && /^[[:space:]]+image:[[:space:]]*/ {
|
||||
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line
|
||||
}
|
||||
' "$primary" 2>/dev/null || true)
|
||||
if [ "$compose_ok" != true ]; then
|
||||
compose_error='Docker Compose is unavailable; file metadata was still detected.'
|
||||
elif [ "$compose_v2" = true ]; then
|
||||
if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then
|
||||
valid=true
|
||||
images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true)
|
||||
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true); fi
|
||||
else compose_error=$services; services=''; fi
|
||||
else
|
||||
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
|
||||
valid=true
|
||||
images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true)
|
||||
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true); fi
|
||||
else compose_error=$services; services=''; fi
|
||||
fi
|
||||
if [ -z "$services" ]; then
|
||||
@@ -130,15 +144,6 @@ function createUnraidInventoryMethods({
|
||||
}
|
||||
' "$primary" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$images" ]; then
|
||||
images=$(awk '
|
||||
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
|
||||
in_services && /^[^[:space:]]/ { exit }
|
||||
in_services && /^[[:space:]]+image:[[:space:]]*/ {
|
||||
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line
|
||||
}
|
||||
' "$primary" 2>/dev/null || true)
|
||||
fi
|
||||
printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
|
||||
"$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\
|
||||
"$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\
|
||||
@@ -310,7 +315,7 @@ function createUnraidInventoryMethods({
|
||||
};
|
||||
}
|
||||
|
||||
async saveWorkloadState(profile, workload, server) {
|
||||
async saveWorkloadState(profile, workload, server, { expectedGiteaSha = null, health = null } = {}) {
|
||||
const candidateSha = String(workload.metadata?.liveRevision || "");
|
||||
const previousState = this.store.getDeploymentState?.(profile.id) || {};
|
||||
const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null;
|
||||
@@ -318,11 +323,21 @@ function createUnraidInventoryMethods({
|
||||
const profileRemote = inventoryRemoteIdentity(profile.cloneUrl);
|
||||
const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository);
|
||||
const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote);
|
||||
const verifiedGiteaSha = /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
|
||||
? String(expectedGiteaSha).toLowerCase()
|
||||
: null;
|
||||
const matchesGitea = Boolean(repositoryMatches && verifiedGiteaSha && observedLiveSha === verifiedGiteaSha);
|
||||
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
|
||||
const dockerHealthy = workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null;
|
||||
const effectiveHealthy = workload.runtime.running === false
|
||||
? false
|
||||
: health?.configured ? health.healthy : dockerHealthy;
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
liveSha,
|
||||
healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null,
|
||||
runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health,
|
||||
healthy: effectiveHealthy,
|
||||
healthStatus: health?.status ?? null,
|
||||
healthLatencyMs: health?.latencyMs ?? null,
|
||||
runtimeVerification: workload.runtime.running === false ? "stopped" : health?.configured ? "desktop-healthcheck" : workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health,
|
||||
containerRunning: workload.runtime.running,
|
||||
dockerHealth: primary.health || null,
|
||||
containerName: primary.name || profile.containerName,
|
||||
@@ -332,8 +347,8 @@ function createUnraidInventoryMethods({
|
||||
composeProject: workload.compose?.project || null,
|
||||
observedAt: workload.observedAt,
|
||||
evidence: liveSha ? "container-provenance-label" : "runtime-only",
|
||||
giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null,
|
||||
matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha,
|
||||
giteaSha: verifiedGiteaSha,
|
||||
matchesGitea,
|
||||
previousSha: previousState.previousSha || null,
|
||||
});
|
||||
}
|
||||
@@ -408,6 +423,7 @@ function createUnraidInventoryMethods({
|
||||
}
|
||||
|
||||
async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
|
||||
const started = Date.now();
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
let adopted = 0;
|
||||
const adoptedLinks = [];
|
||||
@@ -442,6 +458,7 @@ function createUnraidInventoryMethods({
|
||||
adopted,
|
||||
adoptedLinks,
|
||||
readOnly: !autoLink,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -609,7 +626,57 @@ function createUnraidInventoryMethods({
|
||||
}
|
||||
|
||||
async discoverServerWorkloads(serverId, repositories) {
|
||||
return this.scanServerInventory(serverId, repositories, { autoLink: true });
|
||||
const started = Date.now();
|
||||
const inventory = await this.scanServerInventory(serverId, repositories, { autoLink: true });
|
||||
const server = this.store.getServer(serverId);
|
||||
const queue = inventory.workloads.filter((workload) => workload.link?.profileId && workload.link?.repositoryFullName);
|
||||
const refreshedProfileIds = [];
|
||||
let giteaUnavailable = false;
|
||||
let giteaFailureReported = false;
|
||||
const workers = Array.from({ length: Math.min(5, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const workload = queue.shift();
|
||||
const repository = repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
|
||||
const profile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|
||||
|| this.store.getDeploymentProfiles?.(workload.link.repositoryFullName)?.find((item) => item.id === workload.link.profileId)
|
||||
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId);
|
||||
if (!repository || !profile) continue;
|
||||
let expectedGiteaSha = null;
|
||||
const status = repository.localStatus;
|
||||
if (status?.head && status.branch?.head === profile.branch && status.branch?.upstream && status.branch.ahead === 0 && status.branch.behind === 0) {
|
||||
expectedGiteaSha = status.head;
|
||||
} else if (!giteaUnavailable) {
|
||||
try {
|
||||
const [owner, repo] = String(repository.fullName).split("/");
|
||||
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
||||
expectedGiteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch (error) {
|
||||
if (!error?.status || Number(error.status) >= 500) {
|
||||
giteaUnavailable = true;
|
||||
if (!giteaFailureReported) {
|
||||
giteaFailureReported = true;
|
||||
await this.diagnostics?.warning("unraid.workloads.gitea-verification-degraded", {
|
||||
serverId,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const health = workload.runtime.running
|
||||
? await this.checkHealth(profile.healthcheckUrl)
|
||||
: { configured: false, healthy: false, skipped: "container-stopped" };
|
||||
await this.saveWorkloadState(profile, workload, server, { expectedGiteaSha, health });
|
||||
refreshedProfileIds.push(profile.id);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
await this.diagnostics?.debug("unraid.workloads.states-refreshed", {
|
||||
serverId,
|
||||
profiles: refreshedProfileIds.length,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
return { ...inventory, refreshedProfiles: refreshedProfileIds.length, refreshedProfileIds };
|
||||
}
|
||||
|
||||
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
|
||||
|
||||
@@ -59,18 +59,23 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const containerRunning = fields.containerRunning === "true";
|
||||
const health = containerRunning
|
||||
? await this.checkHealth(profile.healthcheckUrl)
|
||||
: { configured: false, healthy: false, skipped: "container-stopped" };
|
||||
const dockerHealthy = fields.dockerHealth
|
||||
? fields.dockerHealth === "healthy"
|
||||
: null;
|
||||
const effectiveHealthy = health.configured ? health.healthy : dockerHealthy;
|
||||
const runtimeVerification = health.configured
|
||||
const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy;
|
||||
const runtimeVerification = !containerRunning
|
||||
? "stopped"
|
||||
: health.configured
|
||||
? "desktop-healthcheck"
|
||||
: dockerHealthy === true
|
||||
? "docker-healthcheck"
|
||||
: dockerHealthy === false
|
||||
? "docker-unhealthy"
|
||||
: fields.containerRunning === "true"
|
||||
: containerRunning
|
||||
? "running-unverified"
|
||||
: "stopped";
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
@@ -85,7 +90,7 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
containerName,
|
||||
containerRunning: fields.containerRunning === "true",
|
||||
containerRunning,
|
||||
dockerHealth: fields.dockerHealth || null,
|
||||
dockerMan: {
|
||||
webUi: decode(fields.webUiLabel),
|
||||
@@ -273,7 +278,16 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
|
||||
item.status,
|
||||
),
|
||||
);
|
||||
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
|
||||
const queue = [...active];
|
||||
const results = [];
|
||||
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const operation = queue.shift();
|
||||
results.push(await this.refreshOperation(operation.id));
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
return UnraidStateMethods.prototype;
|
||||
|
||||
+51
-3
@@ -178,6 +178,10 @@ const ui = {
|
||||
refreshError: null,
|
||||
refreshWarning: null,
|
||||
autoRefreshPending: false,
|
||||
repositoryRefreshPromise: null,
|
||||
repositoryRefreshRequest: null,
|
||||
deploymentTruthPromise: null,
|
||||
deploymentTruthRequest: null,
|
||||
paletteQuery: "",
|
||||
updateStatus: null,
|
||||
updateChecking: false,
|
||||
@@ -424,10 +428,32 @@ function scheduleAutoRefresh() {
|
||||
}
|
||||
|
||||
async function refreshRepositories(withLoader = true, silent = false) {
|
||||
ui.repositoryRefreshRequest = {
|
||||
withLoader: ui.repositoryRefreshRequest?.withLoader === true || withLoader,
|
||||
silent: ui.repositoryRefreshRequest ? ui.repositoryRefreshRequest.silent && silent : silent,
|
||||
};
|
||||
if (ui.repositoryRefreshPromise) return ui.repositoryRefreshPromise;
|
||||
ui.repositoryRefreshPromise = (async () => {
|
||||
let result;
|
||||
while (ui.repositoryRefreshRequest) {
|
||||
const request = ui.repositoryRefreshRequest;
|
||||
ui.repositoryRefreshRequest = null;
|
||||
result = await performRepositoryRefresh(request.withLoader, request.silent);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
try {
|
||||
return await ui.repositoryRefreshPromise;
|
||||
} finally {
|
||||
ui.repositoryRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function performRepositoryRefresh(withLoader = true, silent = false) {
|
||||
if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…");
|
||||
try {
|
||||
const selectedId = ui.selectedRepoId;
|
||||
ui.repositories = await window.forgeflow.refreshRepositories();
|
||||
ui.repositories = await window.forgeflow.refreshRepositories({ force: withLoader });
|
||||
ui.refreshError = null;
|
||||
const staleRepository = ui.repositories.find(
|
||||
(repository) => repository.remoteStale,
|
||||
@@ -488,6 +514,25 @@ async function refreshActiveOperations(showErrors = true) {
|
||||
}
|
||||
|
||||
async function refreshDeploymentTruth(showErrors = false) {
|
||||
ui.deploymentTruthRequest = { showErrors: ui.deploymentTruthRequest?.showErrors === true || showErrors };
|
||||
if (ui.deploymentTruthPromise) return ui.deploymentTruthPromise;
|
||||
ui.deploymentTruthPromise = (async () => {
|
||||
let result;
|
||||
while (ui.deploymentTruthRequest) {
|
||||
const request = ui.deploymentTruthRequest;
|
||||
ui.deploymentTruthRequest = null;
|
||||
result = await performDeploymentTruthRefresh(request.showErrors);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
try {
|
||||
return await ui.deploymentTruthPromise;
|
||||
} finally {
|
||||
ui.deploymentTruthPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function performDeploymentTruthRefresh(showErrors = false) {
|
||||
let discovery = [];
|
||||
try {
|
||||
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
|
||||
@@ -516,8 +561,11 @@ async function refreshDeploymentTruth(showErrors = false) {
|
||||
);
|
||||
if (!targets.length) return { checked: 0, failed: 0, discovery };
|
||||
|
||||
const inventoryRefreshedProfiles = new Set(discovery.flatMap((server) => server.refreshedProfileIds || []));
|
||||
const pendingTargets = targets.filter(({ profile }) => !inventoryRefreshedProfiles.has(profile.id));
|
||||
|
||||
const failures = [];
|
||||
const queue = [...targets];
|
||||
const queue = [...pendingTargets];
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(3, queue.length) },
|
||||
async () => {
|
||||
@@ -547,7 +595,7 @@ async function refreshDeploymentTruth(showErrors = false) {
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return { checked: targets.length, failed: failures.length, discovery };
|
||||
return { checked: targets.length, reusedInventory: targets.length - pendingTargets.length, failed: failures.length, discovery };
|
||||
}
|
||||
|
||||
function selectRepository(id, shouldRender = true) {
|
||||
|
||||
@@ -241,6 +241,8 @@ function createMockDeploymentBridge(context) {
|
||||
detected: 3,
|
||||
adopted: 0,
|
||||
verified: 1,
|
||||
refreshedProfiles: 1,
|
||||
refreshedProfileIds: ["profile-portfolio"],
|
||||
linked: 2,
|
||||
unmatched: 0,
|
||||
needsReview: 2,
|
||||
|
||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.10.11-demo",
|
||||
appVersion: "0.10.12-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
|
||||
@@ -357,6 +357,8 @@ select:focus-visible {
|
||||
padding: 0 6px 10px;
|
||||
}
|
||||
.repo-row {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 48px;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
@@ -2706,6 +2708,8 @@ kbd {
|
||||
background: linear-gradient(180deg, var(--primary), var(--success));
|
||||
}
|
||||
.server-inventory-panel .tool-row {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 76px;
|
||||
transition: background 150ms ease, transform 150ms ease;
|
||||
}
|
||||
.server-inventory-panel .tool-row:hover {
|
||||
|
||||
@@ -172,3 +172,22 @@ test('rollback refuses a stale target that is no longer the server-reported prev
|
||||
assert.equal(dispatched, false);
|
||||
});
|
||||
|
||||
test('active deployment refreshes run concurrently with a bounded worker pool', async () => {
|
||||
const operations = Array.from({ length: 9 }, (_, index) => ({ id: `operation-${index}`, type: 'deployment', status: 'running' }));
|
||||
const service = new DeploymentService({ data: { operations } }, {}, {});
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
service.refreshOperation = async (id) => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
running -= 1;
|
||||
return { id };
|
||||
};
|
||||
|
||||
const refreshed = await service.refreshActiveOperations();
|
||||
assert.equal(refreshed.length, operations.length);
|
||||
assert.ok(peak > 1);
|
||||
assert.ok(peak <= 4);
|
||||
});
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ test('refresh uses last-known Gitea repositories after a transient remote failur
|
||||
|
||||
const fresh = await instance.refresh();
|
||||
remoteAvailable = false;
|
||||
const degraded = await instance.refresh();
|
||||
const degraded = await instance.refresh({ force: true });
|
||||
|
||||
assert.equal(fresh[0].remoteStale, false);
|
||||
assert.equal(degraded[0].fullName, remote.full_name);
|
||||
@@ -184,6 +184,26 @@ test('initial Gitea failure remains visible when no safe cache exists', async ()
|
||||
await assert.rejects(() => instance.refresh(), /Gitea unavailable/);
|
||||
});
|
||||
|
||||
test('refresh coalesces concurrent work and briefly reuses remote and discovery results', async () => {
|
||||
let remoteCalls = 0;
|
||||
let discoveryCalls = 0;
|
||||
const store = {
|
||||
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
|
||||
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
|
||||
};
|
||||
const instance = new RepositoryService(store, {}, { listRepositories: async () => { remoteCalls += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return [remote]; } });
|
||||
instance.discoverAll = async () => { discoveryCalls += 1; return []; };
|
||||
|
||||
const [first, second] = await Promise.all([instance.refresh(), instance.refresh()]);
|
||||
assert.deepEqual(first, second);
|
||||
await instance.refresh();
|
||||
assert.equal(remoteCalls, 1);
|
||||
assert.equal(discoveryCalls, 1);
|
||||
await instance.refresh({ force: true });
|
||||
assert.equal(remoteCalls, 2);
|
||||
assert.equal(discoveryCalls, 2);
|
||||
});
|
||||
|
||||
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
|
||||
const instance = service();
|
||||
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
|
||||
|
||||
@@ -317,6 +317,7 @@ test("low-level inventory scan is read-only and user discovery auto-links exact
|
||||
assert.equal(profiles[0].containerName, "Portfolio");
|
||||
assert.equal(profiles[0].adoptedFromServer, true);
|
||||
assert.equal(states.get(profiles[0].id).matchesGitea, true);
|
||||
assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]);
|
||||
});
|
||||
|
||||
|
||||
@@ -385,6 +386,18 @@ test("server inventory includes stopped DockerMan containers without Git and kee
|
||||
assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i);
|
||||
});
|
||||
|
||||
test("server inventory batches Docker inspect and retains a disappearing-container fallback", async () => {
|
||||
const source = await unraidSource();
|
||||
assert.match(source, /docker inspect "\\\$\{container_ids\[@\]\}"/);
|
||||
assert.match(source, /for container_id in "\\\$\{container_ids\[@\]\}"/);
|
||||
});
|
||||
|
||||
test("server inventory avoids a second Compose process for static image definitions", async () => {
|
||||
const source = await unraidSource();
|
||||
assert.match(source, /has_override=false/);
|
||||
assert.match(source, /\[ -z "\$images" \].*config --images/);
|
||||
});
|
||||
|
||||
test("server inventory groups multi-service Compose projects and preserves their identity", () => {
|
||||
const baseContainer = {
|
||||
image: "example/app:latest",
|
||||
@@ -477,6 +490,40 @@ test("manual workload linking does not claim Gitea parity for unrelated provenan
|
||||
assert.equal(savedState.giteaSha, null);
|
||||
});
|
||||
|
||||
test("inventory provenance alone never claims Gitea commit parity", async () => {
|
||||
let savedState = null;
|
||||
const service = new UnraidDeploymentService({
|
||||
store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } },
|
||||
});
|
||||
await service.saveWorkloadState(
|
||||
{ id: "profile", remoteFolder: "app", cloneUrl: "git@gitea.test:Owner/App.git" },
|
||||
{
|
||||
workloadId: "workload", observedAt: new Date().toISOString(),
|
||||
metadata: { sourceRepository: "git@gitea.test:Owner/App.git", liveRevision: "a".repeat(40) },
|
||||
runtime: { running: true, health: "healthy" }, containers: [{ name: "app", running: true, health: "healthy" }], compose: {},
|
||||
},
|
||||
{ basePath: "/mnt/user/appdata" },
|
||||
);
|
||||
assert.equal(savedState.matchesGitea, false);
|
||||
assert.equal(savedState.giteaSha, null);
|
||||
});
|
||||
|
||||
test("a stopped workload cannot become healthy through a reused healthcheck port", async () => {
|
||||
let savedState = null;
|
||||
const service = new UnraidDeploymentService({
|
||||
store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } },
|
||||
});
|
||||
await service.saveWorkloadState(
|
||||
{ id: "profile", remoteFolder: "app", healthcheckUrl: "http://server.test/health" },
|
||||
{ workloadId: "workload", metadata: {}, runtime: { running: false, health: "unverified" }, containers: [{ name: "app", running: false }], compose: {} },
|
||||
{ basePath: "/mnt/user/appdata" },
|
||||
{ health: { configured: true, healthy: true, status: 200 } },
|
||||
);
|
||||
assert.equal(savedState.containerRunning, false);
|
||||
assert.equal(savedState.healthy, false);
|
||||
assert.equal(savedState.runtimeVerification, "stopped");
|
||||
});
|
||||
|
||||
test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
|
||||
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
|
||||
assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
|
||||
|
||||
Reference in New Issue
Block a user