2 Commits
Author SHA1 Message Date
NuklearRabbit 32ed4fcb5e perf: streamline repository and deployment awareness
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-12 15:26:53 +02:00
NuklearRabbit 38e221cbd1 fix: harden repository refresh and server pull
ForgeFlow quality gate / quality (push) Canceled after 0s
2026-08-12 15:05:57 +02:00
30 changed files with 570 additions and 93 deletions
+2 -2
View File
@@ -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. 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.10** · [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)
![ForgeFlow release-overzicht](docs/screenshots/overview.png) ![ForgeFlow release-overzicht](docs/screenshots/overview.png)
@@ -39,7 +39,7 @@ npm install
npm run demo npm run demo
``` ```
Open daarna `http://127.0.0.1:4173`. Open daarna `http://127.0.0.1:41737`.
## De dagelijkse workflow ## De dagelijkse workflow
+30 -26
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.10.10 source manifest ForgeFlow 0.10.12 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest excludes itself, dependencies and generated release artifacts.)
cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml cedceb71eb846d99c7c4019031833c1c7f93b84a1c6073aec7d2435dc744ca3d 703 .gitea/workflows/quality.yml
@@ -35,6 +35,8 @@ f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md 979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md 0eb44bda2209a5979a6ac693ac4cd4d235c0015031e54b9e895990f37bf60054 1433 docs/RELEASE_NOTES_0.10.1.md
5d3240169765e3fb1d3cd391d09547101227e76dd4670ee46be8ca3a21553a03 894 docs/RELEASE_NOTES_0.10.10.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 8d713471a437a8a55b00d7e1dd95290680862107bc4e586cf27d727f6274e46c 577 docs/RELEASE_NOTES_0.10.2.md
0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md 0942fb2c4a4f972296423b5232687f7389e2c6417a9d48a3244beef9dec907b9 1164 docs/RELEASE_NOTES_0.10.3.md
8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md 8f4a0fe6dc250ae210cc2fc1c57c46091822ae6c2a58caa76e0091f255f9f30d 775 docs/RELEASE_NOTES_0.10.4.md
@@ -102,16 +104,16 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
970c2afb09f2b9d1e97a5a92c0bd824c054b72298c5a5d234396d5e34de065ae 13870 main.cjs d7cae22a646c8f06f339db799f2b9921ba1799cdc44e5d05578c04aaace103ab 14357 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
9c2567281d405b608b5381d79360288601a2a4211bdacf5a8cae990caa952f98 179808 package-lock.json 198a8a51705fcea2ddf716558a49e06df5a8181c5476709c474d183b64890ca0 179808 package-lock.json
da986418af193d1334e4ab9e09a5dab41ebe32aace73127a145be834ef20bbd9 5704 package.json 84fa9b490e0845b1788d4f58741d733f0afd5946db5487dfba03a6e3018890cc 5782 package.json
2a597a5704c576783b8a72407fbc377fa7506b36a4596ea7f7bce126e394f837 1326 playwright.config.mjs 1237df9ddcbb5ac7dc4316f18c34ff4a7030e3e0d56216ade6dd07369e5e2a04 1353 playwright.config.mjs
69318fdf054be7aa2fe86ead9847da9da65745d8d5de548c8346f3ba0afc4892 12175 preload.cjs 16d6c37007c6c14dbf541060535c594308758477b867fc2a190c2946d74022b9 12196 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
0438b8de9bb3409543e4f3bc121bd3ccc094a5a879655db11aef1403c323f96e 10314 README.md 42e2f375c2fa9bea138428b85eb2bb93fe8bf6e4a5fc0cd728f2c6713a019df7 10315 README.md
8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json 8f8dc5ff0c2967563457e4027d795f6f515253e0a459ecca5a9f5bcf0b658870 14650 reports/architecture-audit.json
5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md 5e923f0096895f588fe66b187779cdb7cb5af6644010c6dcc4d55876930ae376 1220 reports/architecture-audit.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
@@ -123,11 +125,11 @@ d0745072321aca2c80f44460974a7926715a9f429164aaf7660dced40b52c736 4790
0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs 0244d42896b8c44f734d0bb6cdcb29b5981342be2f070ce89f8d9eaf3e4d49e6 1793 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 558ff442988f1396c174c7161ff5bd3ef0b2f43cfc31459ec7c3967faa146bc3 1694 scripts/serve-demo.mjs
c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1 c76507857292c5713e1c699cf02e24b80265da39af2cecd148034bdb874adbb6 5246 scripts/test-authenticode-chain.ps1
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs e6127e1e62f39c70ddb1abf72f4d7e7b8e3f19ff1f219e1a3660353c2e0cdfac 2411 scripts/verify-release-signatures.mjs
2a80bfc448aed0a50597947afc75ae42c6830d5bcfd5c5f712957c85268b23d3 20660 scripts/verify.mjs 1ad4023136361431eeda4df740ec1be5d00d63be0d7a3533250da68c3971480d 21407 scripts/verify.mjs
0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
@@ -135,7 +137,7 @@ dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs 86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs
7cbfe51973d6607203cb197652ed7f296a3f6b6b644df876957117866a47d802 2159 src/main/deployment-identity.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 c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
@@ -144,26 +146,27 @@ e28fc1ca2fd4c0116148f5005d793feddf04c36ef711d2d348560394d209a613 7253
3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs 3cc53e24e023aa0d8bf36c35ce9672ca98e6c74066512c8b59ab42274e838c22 21307 src/main/gitea-service.cjs
2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs 2ad3b2e647377f687ad987fe248a142ad399ecac98e4b49965aa7efc6093e5fa 6914 src/main/inventory-classifier.cjs
dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs dafdb09133d2b6ec2161a3f0b09354551e54fc606c8107976fca37405643be91 3404 src/main/inventory-review-service.cjs
00989577aed509a7ddfdf9f4df09a196a393a85b5ea59b21089002215e69f065 25949 src/main/ipc.cjs f263ab067edea8dfeb962958d045ed4ab20b49cbeb965936f4ffa8c1dd0b7d59 25905 src/main/ipc.cjs
0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs 0eb1cfdcd3a37a0ec9502bf753966f87230c03580335798bef9265add42ee6fa 12530 src/main/ipc/deployment-handlers.cjs
dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 src/main/ipc/operations-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 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
720c4a0c554f46386d87c3ab6607d1fbcae66e50b69483c7dbba169d5128c851 680 src/main/process-error-policy.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs
fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs e48ac82e3a618459170969a6370c8a2d667594cd0b1915ccece38ba3f7eda6df 10303 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
d85780a19cac9ed5c89721a25916c35235e76012159363ff61924dba4215d171 25801 src/main/unraid-access-methods.cjs 5394d16c1ceceef700a4b2a47cda088713e24c2e42a4bf4f86b1fa6006e796a2 25877 src/main/unraid-access-methods.cjs
2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs 5ba5a90ad9b7a0d05dd64fa64968cbdd7271bec4df6971f9685a9dd411e4fad4 9536 src/main/unraid-deploy-key-host.cjs
803a079499f7b8148495209dea43b505f6eb9bb587de19e82054e47183186c6e 30461 src/main/unraid-deployment-methods.cjs 6d9910dace52625f88e066a8485af2663c3735ff15e9ce9031441ce742710a21 30793 src/main/unraid-deployment-methods.cjs
b8a6ef72cfd5c5405e3f1a48848b5ef1575ca82ebbe8cad816dff0dc7335c8ec 17184 src/main/unraid-deployment-service.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 2c0cf07921ca7ee5a9085ced44498c2e6798e5cc1e8a5ecf704c3cecabe39a25 27607 src/main/unraid-preflight-methods.cjs
d45220176aed72d692f9ae5534f9d40bcc359a2d08e025e74a3b3b505b8b9ed4 16559 src/main/unraid-runtime-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 a387ac9735624ef89d964ea59e035dae256b9915ba2cee22ae5cbc2894f5808a 22000 src/main/update-service.cjs
b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js b5c304531bec358d059189a27cd9db8fa20cefb7f817e5eb0287001f7353f6a7 985 src/renderer/actions/command.js
d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 src/renderer/actions/deployment-operation.js
@@ -172,7 +175,7 @@ d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706
4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js 4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js
cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js
a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js a980f2e86d8286ea605a7259b9e9adcf3fda4f657b8d54a5d2d8765a7bbbec13 19065 src/renderer/actions/shell.js
edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333 src/renderer/app.js d399b9b277350225ba98634f595032eae4412a07071faea69185ba5f1c0f2330 26597 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
@@ -181,11 +184,11 @@ edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333
eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js
a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html
06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js 06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js
5a2c0616a8a0ba23c182a8928abdf40a69612ce34c4cba47b1fd5f21fe5843d4 28617 src/renderer/mock-deployment-bridge.js 870024aff376826a92c9cf7452689cc1ecc5d9034f055bea56734f3f7fcea5e5 28703 src/renderer/mock-deployment-bridge.js
041daf59931aff37075d9ee238bdf9c397c4dd799580540f30216276f95cfd7a 20033 src/renderer/mock-repository-bridge.js 81b0155dd51c7bc8871fd29bd7fdb28857cf11a86ed6d6953238c04c2774800c 20033 src/renderer/mock-repository-bridge.js
94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js 94fa265c2fe9ca8d644f0ce9b620b6f85d9b25dca5802c4e9195b66dcbe80120 6522 src/renderer/operations.js
1fc4c28859f7283aa2b771a50f1fd26addc8862b97a09250f806930ab4483f7a 79874 src/renderer/styles.css 21b8f3228bddd6da06613857763282d1f88f4eeab3d063f3a63395d7b567f2e4 80004 src/renderer/styles.css
4fbd3d7af3279a02c1da508758f6c7d9ca8de3d82f869be8439030ec81e8d941 101429 src/renderer/views.js 8bc794e191aa22a13cbbc89c9b3882e0c3070e1365a7c02cfe381a62d81877d9 101549 src/renderer/views.js
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -205,7 +208,7 @@ ac17f8bbe9e388b80abef7792c8b184a1fd482c93f13d23a478e433961020f75 17214
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.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 fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
@@ -219,18 +222,19 @@ fcc9a063882840dd89d74c2785284c8f2f6a9e5acec482b6d89ed8de62efdb85 9635
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs 1b6c920e18a248f78acaed6187197c88ec8d911b62d5e2a9f8ad57b91ae80499 11827 tests/preflight.test.mjs
7f2751ea2621f76b5427f442e931344d13e97faa7b6ef3151949bbd6a03097cf 1205 tests/process-error-policy.test.mjs
0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs 0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs
d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs d4980aa7d2d364793f38771011383348be472d5447b6ee88a4d9e5f3a3b3ffa9 9911 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs 62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs
ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs 32056b36a7e78701e8b9253ea78646ab4ecd88c5788841a1459177417ecb2e08 10441 tests/repository-service.test.mjs
d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs
bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs
12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs 12cb3b240bdd0922566323c0014838ca067ad10d9d4009943165ae2c4e93bc6f 11786 tests/server-inventory-branches.test.mjs
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs 0d1bc4d623ce299337736c577ec61c8ffd6974ebe20335b72838d10eae35ecb1 7993 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
7b8e9118c91503c499e3194223f98ab3f689d6071e7f2316be2b700db592e06f 53633 tests/unraid-deployment.test.mjs db0a347cdab1da11a04d41981695a65d7244367c46f0cdc868326e8d00d73cbf 56694 tests/unraid-deployment.test.mjs
bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 tests/update-service.test.mjs bf95071f8d8bec6dcf2c889f1f9d83be1b56178dc0d2be84da555848b6319cbc 25650 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
+10
View File
@@ -0,0 +1,10 @@
# ForgeFlow 0.10.11
## Resilient repository refresh and consistent server pull
- Temporary Gitea list failures now use the in-session **last-known-good** repository inventory while local and server state continue to refresh. The UI clearly reports that remote data is stale.
- A **closed output pipe** from a detached parent process is no longer treated as a fatal desktop-app exception.
- Server pull, deploy-key verification, deployment, rollback and metadata now consistently prefer the verified **linked checkout origin** over a stale URL detected earlier on the server.
- Repository-scoped **read-only deploy key** checks remain fail-closed; a changed SSH host still requires explicit trust and access reconfiguration.
- The local **browser test server** now has a dedicated port and identity endpoint, preventing another localhost application from being mistaken for ForgeFlow.
- All 42 responsive browser flows pass across dark/light, compact/wide and reduced-motion configurations.
+12
View File
@@ -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.
+16 -1
View File
@@ -33,6 +33,10 @@ const {
ExternalToolsService, ExternalToolsService,
} = require("./src/main/external-tools-service.cjs"); } = require("./src/main/external-tools-service.cjs");
const { registerIpc } = require("./src/main/ipc.cjs"); const { registerIpc } = require("./src/main/ipc.cjs");
const {
installOutputPipeGuards,
isBrokenPipeError,
} = require("./src/main/process-error-policy.cjs");
let mainWindow; let mainWindow;
let repositoryMonitor; let repositoryMonitor;
@@ -41,6 +45,14 @@ let diagnostics;
let configStore; let configStore;
let tray; let tray;
let quitCleanupStarted = false; let quitCleanupStarted = false;
const reportBrokenOutputPipe = (error) => {
const report = diagnostics?.warning("process.output-pipe.closed", {
code: error?.code || null,
message: error?.message || "The parent output pipe was closed.",
});
report?.catch(() => {});
};
installOutputPipeGuards({ onBrokenPipe: reportBrokenOutputPipe });
const ownsSingleInstanceLock = app.requestSingleInstanceLock(); const ownsSingleInstanceLock = app.requestSingleInstanceLock();
if (!ownsSingleInstanceLock) app.quit(); if (!ownsSingleInstanceLock) app.quit();
@@ -219,6 +231,10 @@ app
await audit.initialize(); await audit.initialize();
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
if (isBrokenPipeError(error)) {
reportBrokenOutputPipe(error);
return;
}
diagnostics diagnostics
?.error("process.uncaught-exception", error) ?.error("process.uncaught-exception", error)
.finally(() => app.exit(1)); .finally(() => app.exit(1));
@@ -424,7 +440,6 @@ app
}); });
}) })
.catch(async (error) => { .catch(async (error) => {
console.error("[startup]", error);
await diagnostics?.error("app.startup.failed", error); await diagnostics?.error("app.startup.failed", error);
await diagnostics?.flush(); await diagnostics?.flush();
app.exit(1); app.exit(1);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.10", "version": "0.10.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.10", "version": "0.10.12",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.10.10", "version": "0.10.12",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -115,6 +115,8 @@
"docs/RELEASE_NOTES_0.10.8.md", "docs/RELEASE_NOTES_0.10.8.md",
"docs/RELEASE_NOTES_0.10.9.md", "docs/RELEASE_NOTES_0.10.9.md",
"docs/RELEASE_NOTES_0.10.10.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/CURRENT_STATE.md",
"docs/MUTATION_MODEL.md", "docs/MUTATION_MODEL.md",
"docs/RELEASING.md", "docs/RELEASING.md",
+2 -2
View File
@@ -18,14 +18,14 @@ export default defineConfig({
workers: process.env.CI ? 2 : 3, workers: process.env.CI ? 2 : 3,
reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]], reporter: [["line"], ["html", { outputFolder: "artifacts/browser-report", open: "never" }]],
use: { use: {
baseURL: "http://127.0.0.1:4173", baseURL: "http://127.0.0.1:41737",
screenshot: "only-on-failure", screenshot: "only-on-failure",
trace: "retain-on-failure", trace: "retain-on-failure",
video: "retain-on-failure", video: "retain-on-failure",
}, },
webServer: { webServer: {
command: "node scripts/serve-demo.mjs", command: "node scripts/serve-demo.mjs",
url: "http://127.0.0.1:4173", url: "http://127.0.0.1:41737/__forgeflow_test_ready__",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 30_000, timeout: 30_000,
}, },
+1 -1
View File
@@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld(
serverId, serverId,
remoteFolder, remoteFolder,
}), }),
refreshRepositories: () => invoke('repositories:refresh'), refreshRepositories: (options = {}) => invoke('repositories:refresh', options),
discoverRepositories: (roots) => invoke('repositories:discover', { roots }), discoverRepositories: (roots) => invoke('repositories:discover', { roots }),
favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }), favoriteRepository: (fullName, favorite) => invoke('repository:favorite', { fullName, favorite }),
linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }), linkRepository: (fullName, localPath) => invoke('repository:link', { fullName, localPath }),
+6 -1
View File
@@ -4,12 +4,17 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
const port = Number(process.env.PORT || 4173); const port = Number(process.env.PORT || 41737);
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' }; const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => { const server = http.createServer(async (request, response) => {
try { try {
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname); const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
if (pathname === '/__forgeflow_test_ready__') {
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
response.end('forgeflow-demo-ready');
return;
}
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, ''); const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
const target = path.resolve(root, relative); const target = path.resolve(root, relative);
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' }); if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
+12 -2
View File
@@ -90,6 +90,8 @@ const required = [
"docs/RELEASE_NOTES_0.10.8.md", "docs/RELEASE_NOTES_0.10.8.md",
"docs/RELEASE_NOTES_0.10.9.md", "docs/RELEASE_NOTES_0.10.9.md",
"docs/RELEASE_NOTES_0.10.10.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/UPDATING.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md", "docs/DEPLOYMENT_SETUP.md",
@@ -128,9 +130,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if (packageJson.version !== "0.10.10") if (packageJson.version !== "0.10.12")
throw new Error( throw new Error(
`Expected package version 0.10.10, got ${packageJson.version}.`, `Expected package version 0.10.12, got ${packageJson.version}.`,
); );
const sourceManifest = await readFile( const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"), path.join(root, "SOURCE_MANIFEST.txt"),
@@ -484,6 +486,14 @@ const release01010 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.10.
for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) { for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) {
if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`); if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`);
} }
const release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.md"), "utf8");
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"); const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) { for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
+8 -1
View File
@@ -328,8 +328,15 @@ class DeploymentService {
async refreshActiveOperations() { async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status)); const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
const queue = active.slice(0, 20);
const results = []; 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; return results;
} }
-1
View File
@@ -72,7 +72,6 @@ function register(channel, handler) {
stack: error?.stack, stack: error?.stack,
}, },
}); });
console.error(`[${channel}]`, error);
return { ok: false, error: toErrorPayload(error) }; return { ok: false, error: toErrorPayload(error) };
} }
}); });
+2 -2
View File
@@ -6,8 +6,8 @@ function registerRepositoryIpc({
resolveRepository, cloneRepositoryInto, cloneDirectoryName, resolveRepository, cloneRepositoryInto, cloneDirectoryName,
matchRemoteToRepository, shell, dialog, matchRemoteToRepository, shell, dialog,
}) { }) {
register("repositories:refresh", async () => { register("repositories:refresh", async ({ force = false }) => {
const result = await repositories.refresh(); const result = await repositories.refresh({ force: force === true });
monitor?.setPaths(repositories.getWatchPaths()); monitor?.setPaths(repositories.getWatchPaths());
return result; return result;
}); });
+26
View File
@@ -0,0 +1,26 @@
'use strict';
function isBrokenPipeError(error) {
return error?.code === 'EPIPE';
}
function installOutputPipeGuards({
stdout = process.stdout,
stderr = process.stderr,
onBrokenPipe = () => {}
} = {}) {
const guardedStreams = [stdout, stderr].filter(Boolean);
const handlers = guardedStreams.map((stream) => {
const handler = (error) => {
if (!isBrokenPipeError(error)) throw error;
onBrokenPipe(error);
};
stream.on('error', handler);
return { stream, handler };
});
return () => {
for (const { stream, handler } of handlers) stream.off('error', handler);
};
}
module.exports = { installOutputPipeGuards, isBrokenPipeError };
+72 -7
View File
@@ -29,6 +29,12 @@ class RepositoryService {
this.gitea = giteaService; this.gitea = giteaService;
this.diagnostics = diagnostics; this.diagnostics = diagnostics;
this.lastKnownLocalPaths = []; this.lastKnownLocalPaths = [];
this.lastKnownRemoteRepositories = [];
this.lastSuccessfulRemoteRefreshAt = null;
this.lastRemoteRefreshAtMs = 0;
this.lastDiscoveredPaths = [];
this.lastDiscoveryAtMs = 0;
this.refreshPromise = null;
} }
async discoverInRoot(root, maxDepth = 4) { async discoverInRoot(root, maxDepth = 4) {
@@ -80,13 +86,65 @@ class RepositoryService {
return [...this.lastKnownLocalPaths]; return [...this.lastKnownLocalPaths];
} }
async refresh() { async getRemoteRepositories({ force = false } = {}) {
const started = Date.now(); if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken() this.lastKnownRemoteRepositories = [];
? await this.gitea.listRepositories() this.lastSuccessfulRemoteRefreshAt = null;
: []; return { repositories: [], stale: false, error: null };
}
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots); 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;
await this.diagnostics?.warning('repositories.remote-refresh.degraded', {
message: error.message,
cachedCount: this.lastKnownRemoteRepositories.length,
lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt
});
return {
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
stale: true,
error: error.message
};
}
}
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({ force });
const remoteRepositories = remoteResult.repositories;
const discoveredPaths = await this.getDiscoveredPaths({ force });
const mappedPaths = Object.values(this.store.data.repositoryMappings || {}); const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])]; const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
const localDescriptors = await this.getLocalDescriptors(localPaths); const localDescriptors = await this.getLocalDescriptors(localPaths);
@@ -106,7 +164,12 @@ class RepositoryService {
...profile, ...profile,
state: this.store.getDeploymentState(profile.id) state: this.store.getDeploymentState(profile.id)
})); }));
repositories.push(this.decorate(remote, local, profiles)); repositories.push({
...this.decorate(remote, local, profiles),
remoteStale: remoteResult.stale,
remoteRefreshError: remoteResult.error,
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
});
} }
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) { for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
@@ -145,6 +208,8 @@ class RepositoryService {
await this.diagnostics?.debug('repositories.refresh.completed', { await this.diagnostics?.debug('repositories.refresh.completed', {
durationMs: Date.now() - started, durationMs: Date.now() - started,
remoteCount: remoteRepositories.length, remoteCount: remoteRepositories.length,
remoteStale: remoteResult.stale,
remoteCached: remoteResult.cached === true,
discoveredCount: discoveredPaths.length, discoveredCount: discoveredPaths.length,
linkedCount: sorted.filter((item) => item.localPath).length, linkedCount: sorted.filter((item) => item.localPath).length,
attentionCount: sorted.filter((item) => item.attention).length, attentionCount: sorted.filter((item) => item.attention).length,
+6 -1
View File
@@ -3,7 +3,12 @@
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection }) { function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection }) {
class UnraidAccessMethods { class UnraidAccessMethods {
serverGitRemote(repository, profile) { serverGitRemote(repository, profile) {
const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl] const candidates = [
repository.localStatus?.remoteUrl,
repository.sshUrl,
repository.preferredCloneUrl,
profile.cloneUrl,
]
.map((value) => String(value || "").trim()) .map((value) => String(value || "").trim())
.filter(Boolean); .filter(Boolean);
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate)); const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
+1 -1
View File
@@ -20,7 +20,7 @@ class UnraidDeployKeyHost {
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") }; return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
} }
remote(repository, profile) { remote(repository, profile) {
const value = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item)); const value = [repository.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" }); if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
return value; return value;
} }
+8 -2
View File
@@ -418,9 +418,12 @@ function createUnraidDeploymentMethods({
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
const iconReference = await this.prepareIcon(profile, repository, server); const iconReference = await this.prepareIcon(profile, repository, server);
const deploymentRepositoryUrl = mode === "server-git"
? this.serverGitRemote(repository, profile)
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
const metadata = this.metadataCompose(profile, repository, iconReference, { const metadata = this.metadataCompose(profile, repository, iconReference, {
sha: targetSha, sha: targetSha,
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, repositoryUrl: deploymentRepositoryUrl,
}); });
const previousState = this.store.getDeploymentState?.(profileId) || null; const previousState = this.store.getDeploymentState?.(profileId) || null;
@@ -518,9 +521,12 @@ function createUnraidDeploymentMethods({
}); });
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
const iconReference = await this.prepareIcon(profile, repository, server); const iconReference = await this.prepareIcon(profile, repository, server);
const rollbackRepositoryUrl = rollbackMode === "server-git"
? this.serverGitRemote(repository, profile)
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
const metadata = this.metadataCompose(profile, repository, iconReference, { const metadata = this.metadataCompose(profile, repository, iconReference, {
sha: target, sha: target,
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName, repositoryUrl: rollbackRepositoryUrl,
}); });
try { try {
const mode = rollbackMode; const mode = rollbackMode;
+97 -30
View File
@@ -34,18 +34,23 @@ function createUnraidInventoryMethods({
fi fi
if [ -n "$ids" ]; then if [ -n "$ids" ]; then
disappeared=0 disappeared=0
while IFS= read -r container_id; do mapfile -t container_ids <<< "$ids"
[ -n "$container_id" ] || continue # Docker accepts multiple IDs and returns one JSON array. This avoids one
# Use Docker's own JSON document instead of a Go template. Accessing an # daemon round-trip per container on larger Unraid installations.
# absent .State.Health map key makes the formatted Docker inspect fail for if inspect=$(docker inspect "\${container_ids[@]}" 2>/dev/null); then
# every container without a healthcheck, which previously made those printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
# containers look as if they disappeared during the scan. else
if inspect=$(docker inspect "$container_id" 2>/dev/null); then # A container can disappear between docker ps and inspect. Fall back to
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')" # individual reads so the remaining inventory stays complete.
else for container_id in "\${container_ids[@]}"; do
disappeared=$((disappeared + 1)) [ -n "$container_id" ] || continue
fi if inspect=$(docker inspect "$container_id" 2>/dev/null); then
done <<< "$ids" 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 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')" 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 fi
@@ -99,26 +104,35 @@ function createUnraidInventoryMethods({
( (
set -- -f "$primary" set -- -f "$primary"
files_text=$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 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 [ -f "$extra" ] || continue
has_override=true
set -- "$@" -f "$extra" set -- "$@" -f "$extra"
files_text="$files_text files_text="$files_text
$extra" $extra"
done 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) 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") [ -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 if [ "$compose_ok" != true ]; then
compose_error='Docker Compose is unavailable; file metadata was still detected.' compose_error='Docker Compose is unavailable; file metadata was still detected.'
elif [ "$compose_v2" = true ]; then elif [ "$compose_v2" = true ]; then
if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then
valid=true 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 compose_error=$services; services=''; fi
else else
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
valid=true 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 compose_error=$services; services=''; fi
fi fi
if [ -z "$services" ]; then if [ -z "$services" ]; then
@@ -130,15 +144,6 @@ function createUnraidInventoryMethods({
} }
' "$primary" 2>/dev/null || true) ' "$primary" 2>/dev/null || true)
fi 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 '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' "$dir" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$files_text" | 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 candidateSha = String(workload.metadata?.liveRevision || "");
const previousState = this.store.getDeploymentState?.(profile.id) || {}; const previousState = this.store.getDeploymentState?.(profile.id) || {};
const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null; 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 profileRemote = inventoryRemoteIdentity(profile.cloneUrl);
const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository); const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository);
const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote); 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 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, { return this.store.saveDeploymentState(profile.id, {
liveSha, liveSha,
healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null, healthy: effectiveHealthy,
runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health, 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, containerRunning: workload.runtime.running,
dockerHealth: primary.health || null, dockerHealth: primary.health || null,
containerName: primary.name || profile.containerName, containerName: primary.name || profile.containerName,
@@ -332,8 +347,8 @@ function createUnraidInventoryMethods({
composeProject: workload.compose?.project || null, composeProject: workload.compose?.project || null,
observedAt: workload.observedAt, observedAt: workload.observedAt,
evidence: liveSha ? "container-provenance-label" : "runtime-only", evidence: liveSha ? "container-provenance-label" : "runtime-only",
giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null, giteaSha: verifiedGiteaSha,
matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha, matchesGitea,
previousSha: previousState.previousSha || null, previousSha: previousState.previousSha || null,
}); });
} }
@@ -408,6 +423,7 @@ function createUnraidInventoryMethods({
} }
async scanServerInventory(serverId, repositories, { autoLink = false } = {}) { async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
const started = Date.now();
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories); const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
let adopted = 0; let adopted = 0;
const adoptedLinks = []; const adoptedLinks = [];
@@ -442,6 +458,7 @@ function createUnraidInventoryMethods({
adopted, adopted,
adoptedLinks, adoptedLinks,
readOnly: !autoLink, readOnly: !autoLink,
durationMs: Date.now() - started,
}); });
return response; return response;
} }
@@ -609,7 +626,57 @@ function createUnraidInventoryMethods({
} }
async discoverServerWorkloads(serverId, repositories) { 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 = "" }) { async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
+20 -6
View File
@@ -59,18 +59,23 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
return ""; 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 const dockerHealthy = fields.dockerHealth
? fields.dockerHealth === "healthy" ? fields.dockerHealth === "healthy"
: null; : null;
const effectiveHealthy = health.configured ? health.healthy : dockerHealthy; const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy;
const runtimeVerification = health.configured const runtimeVerification = !containerRunning
? "stopped"
: health.configured
? "desktop-healthcheck" ? "desktop-healthcheck"
: dockerHealthy === true : dockerHealthy === true
? "docker-healthcheck" ? "docker-healthcheck"
: dockerHealthy === false : dockerHealthy === false
? "docker-unhealthy" ? "docker-unhealthy"
: fields.containerRunning === "true" : containerRunning
? "running-unverified" ? "running-unverified"
: "stopped"; : "stopped";
return this.store.saveDeploymentState(profile.id, { return this.store.saveDeploymentState(profile.id, {
@@ -85,7 +90,7 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
healthStatus: health.status, healthStatus: health.status,
healthLatencyMs: health.latencyMs, healthLatencyMs: health.latencyMs,
containerName, containerName,
containerRunning: fields.containerRunning === "true", containerRunning,
dockerHealth: fields.dockerHealth || null, dockerHealth: fields.dockerHealth || null,
dockerMan: { dockerMan: {
webUi: decode(fields.webUiLabel), webUi: decode(fields.webUiLabel),
@@ -273,7 +278,16 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
item.status, 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; return UnraidStateMethods.prototype;
+59 -3
View File
@@ -176,7 +176,12 @@ const ui = {
inputRenderTimer: null, inputRenderTimer: null,
isMock: false, isMock: false,
refreshError: null, refreshError: null,
refreshWarning: null,
autoRefreshPending: false, autoRefreshPending: false,
repositoryRefreshPromise: null,
repositoryRefreshRequest: null,
deploymentTruthPromise: null,
deploymentTruthRequest: null,
paletteQuery: "", paletteQuery: "",
updateStatus: null, updateStatus: null,
updateChecking: false, updateChecking: false,
@@ -423,11 +428,39 @@ function scheduleAutoRefresh() {
} }
async function refreshRepositories(withLoader = true, silent = false) { 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…"); if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…");
try { try {
const selectedId = ui.selectedRepoId; const selectedId = ui.selectedRepoId;
ui.repositories = await window.forgeflow.refreshRepositories(); ui.repositories = await window.forgeflow.refreshRepositories({ force: withLoader });
ui.refreshError = null; ui.refreshError = null;
const staleRepository = ui.repositories.find(
(repository) => repository.remoteStale,
);
ui.refreshWarning = staleRepository
? `Gitea could not be reached. Showing repository data last refreshed ${formatDate(staleRepository.remoteLastRefreshedAt)} while local and server state continue to refresh.`
: null;
if (selectedId && !selectedRepository()) ui.selectedRepoId = null; if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
const repository = selectedRepository(); const repository = selectedRepository();
if ( if (
@@ -459,6 +492,7 @@ async function refreshRepositories(withLoader = true, silent = false) {
selectRepository(ui.repositories[0].id, false); selectRepository(ui.repositories[0].id, false);
} catch (error) { } catch (error) {
ui.refreshError = error.message; ui.refreshError = error.message;
ui.refreshWarning = null;
if (!silent) showToast("Refresh failed", error.message, "error"); if (!silent) showToast("Refresh failed", error.message, "error");
} finally { } finally {
if (withLoader) setLoading(false); if (withLoader) setLoading(false);
@@ -480,6 +514,25 @@ async function refreshActiveOperations(showErrors = true) {
} }
async function refreshDeploymentTruth(showErrors = false) { 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 = []; let discovery = [];
try { try {
discovery = (await window.forgeflow.discoverServerDeployments?.()) || []; discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
@@ -508,8 +561,11 @@ async function refreshDeploymentTruth(showErrors = false) {
); );
if (!targets.length) return { checked: 0, failed: 0, discovery }; 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 failures = [];
const queue = [...targets]; const queue = [...pendingTargets];
const workers = Array.from( const workers = Array.from(
{ length: Math.min(3, queue.length) }, { length: Math.min(3, queue.length) },
async () => { async () => {
@@ -539,7 +595,7 @@ async function refreshDeploymentTruth(showErrors = false) {
"error", "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) { function selectRepository(id, shouldRender = true) {
+2
View File
@@ -241,6 +241,8 @@ function createMockDeploymentBridge(context) {
detected: 3, detected: 3,
adopted: 0, adopted: 0,
verified: 1, verified: 1,
refreshedProfiles: 1,
refreshedProfileIds: ["profile-portfolio"],
linked: 2, linked: 2,
unmatched: 0, unmatched: 0,
needsReview: 2, needsReview: 2,
+1 -1
View File
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
await wait(80); await wait(80);
snapshot(); snapshot();
return { return {
appVersion: "0.10.10-demo", appVersion: "0.10.12-demo",
platform: "win32", platform: "win32",
state: clone(state), state: clone(state),
git: { available: true, version: "git version 2.47.3" }, git: { available: true, version: "git version 2.47.3" },
+4
View File
@@ -357,6 +357,8 @@ select:focus-visible {
padding: 0 6px 10px; padding: 0 6px 10px;
} }
.repo-row { .repo-row {
content-visibility: auto;
contain-intrinsic-size: auto 48px;
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto; grid-template-columns: 18px minmax(0, 1fr) auto;
@@ -2706,6 +2708,8 @@ kbd {
background: linear-gradient(180deg, var(--primary), var(--success)); background: linear-gradient(180deg, var(--primary), var(--success));
} }
.server-inventory-panel .tool-row { .server-inventory-panel .tool-row {
content-visibility: auto;
contain-intrinsic-size: auto 76px;
transition: background 150ms ease, transform 150ms ease; transition: background 150ms ease, transform 150ms ease;
} }
.server-inventory-panel .tool-row:hover { .server-inventory-panel .tool-row:hover {
+1
View File
@@ -157,6 +157,7 @@ function renderOverview() {
return `<div class="page"> return `<div class="page">
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div> <div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""} ${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
${ui.refreshWarning ? `<div class="notice warning">${icon("warning")} ${escapeHtml(ui.refreshWarning)}</div>` : ""}
<div class="summary-grid"> <div class="summary-grid">
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")} ${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")} ${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
+19
View File
@@ -172,3 +172,22 @@ test('rollback refuses a stale target that is no longer the server-reported prev
assert.equal(dispatched, false); 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);
});
+26
View File
@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import policyModule from '../src/main/process-error-policy.cjs';
const { installOutputPipeGuards, isBrokenPipeError } = policyModule;
test('broken output pipes are recognized without treating unrelated failures as EPIPE', () => {
assert.equal(isBrokenPipeError(Object.assign(new Error('closed'), { code: 'EPIPE' })), true);
assert.equal(isBrokenPipeError(Object.assign(new Error('denied'), { code: 'EACCES' })), false);
assert.equal(isBrokenPipeError(null), false);
});
test('output pipe guard absorbs EPIPE and can be cleanly removed', () => {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
const observed = [];
const remove = installOutputPipeGuards({ stdout, stderr, onBrokenPipe: (error) => observed.push(error.code) });
stdout.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
stderr.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
assert.deepEqual(observed, ['EPIPE', 'EPIPE']);
remove();
assert.equal(stdout.listenerCount('error'), 0);
assert.equal(stderr.listenerCount('error'), 0);
});
+61
View File
@@ -143,6 +143,67 @@ test('refresh remains local-only without configured Gitea credentials', async ()
assert.deepEqual(await instance.refresh(), []); assert.deepEqual(await instance.refresh(), []);
}); });
test('refresh uses last-known Gitea repositories after a transient remote failure', async () => {
const warnings = [];
let remoteAvailable = true;
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 () => {
if (!remoteAvailable) throw new Error('Gitea timed out');
return [remote];
}
}, { debug: async () => {}, warning: async (...args) => warnings.push(args) });
instance.discoverAll = async () => [];
const fresh = await instance.refresh();
remoteAvailable = false;
const degraded = await instance.refresh({ force: true });
assert.equal(fresh[0].remoteStale, false);
assert.equal(degraded[0].fullName, remote.full_name);
assert.equal(degraded[0].remoteStale, true);
assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out');
assert.ok(degraded[0].remoteLastRefreshedAt);
assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded');
});
test('initial Gitea failure remains visible when no safe cache exists', async () => {
const store = {
data: { gitea: { baseUrl: 'https://gitea.example' } },
getToken: () => 'token'
};
const instance = new RepositoryService(store, {}, {
listRepositories: async () => { throw new Error('Gitea unavailable'); }
});
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', () => { test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
const instance = service(); const instance = service();
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []); const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
+61
View File
@@ -116,6 +116,20 @@ test("server pull provisions a pinned repository-scoped key and records access m
assert.equal(result.remoteSha, "a".repeat(40)); assert.equal(result.remoteSha, "a".repeat(40));
}); });
test("server pull prefers the linked checkout origin over stale detected SSH endpoints", () => {
const service = new UnraidDeploymentService({});
const repository = {
fullName: "Jens/Portfolio",
localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" },
sshUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
preferredCloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git",
};
const profile = { cloneUrl: "ssh://git@192.168.10.150:222/Jens/Portfolio.git" };
assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git");
assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 });
});
test("write-access inspection parses the remote permission report through the access module", async () => { test("write-access inspection parses the remote permission report through the access module", async () => {
const encoded = (value) => Buffer.from(value).toString("base64"); const encoded = (value) => Buffer.from(value).toString("base64");
const profile = { id: "profile", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", composeFiles: ["compose.yml"] }; const profile = { id: "profile", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", composeFiles: ["compose.yml"] };
@@ -303,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].containerName, "Portfolio");
assert.equal(profiles[0].adoptedFromServer, true); assert.equal(profiles[0].adoptedFromServer, true);
assert.equal(states.get(profiles[0].id).matchesGitea, true); assert.equal(states.get(profiles[0].id).matchesGitea, true);
assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]);
}); });
@@ -371,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); 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", () => { test("server inventory groups multi-service Compose projects and preserves their identity", () => {
const baseContainer = { const baseContainer = {
image: "example/app:latest", image: "example/app:latest",
@@ -463,6 +490,40 @@ test("manual workload linking does not claim Gitea parity for unrelated provenan
assert.equal(savedState.giteaSha, null); 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", () => { test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n"; const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
assert.equal(dockerIgnoreHasPath(rules, ".git"), true); assert.equal(dockerIgnoreHasPath(rules, ".git"), true);