Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc3fd02eb7 | ||
|
|
3a5acaa72a | ||
|
|
8e66b6d741 | ||
|
|
acdf267e48 | ||
|
|
28e971c0ff | ||
|
|
a2e8a204e8 | ||
|
|
50bb41899d | ||
|
|
cb8ac32b74 |
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
verify:
|
||||
# The NAS Docker runner must never execute code from external forks.
|
||||
# The self-hosted Docker runner must never execute code from external forks.
|
||||
if: github.server_url != 'https://github.com' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -15,6 +15,7 @@ jobs:
|
||||
DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }}
|
||||
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
|
||||
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
|
||||
CI_PYTHON_BASE: ${{ vars.CI_PYTHON_BASE }}
|
||||
ALPINE_MIRROR: ${{ vars.ALPINE_MIRROR }}
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
@@ -41,4 +42,4 @@ jobs:
|
||||
fi
|
||||
|
||||
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
|
||||
# the shared NAS runner performs isolated verification only.
|
||||
# the shared Server runner performs isolated verification only.
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ WORKDIR /app
|
||||
ARG BUILD_COMMIT=development
|
||||
ARG BUILD_TIME=unknown
|
||||
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.21" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.24" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
USER 10333:10333
|
||||
EXPOSE 18787
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
+5
-7
@@ -1,9 +1,7 @@
|
||||
# pigway-frame-flow v0.1.21
|
||||
# pigway-frame-flow v0.1.24
|
||||
|
||||
基础映像改为固定摘要的官方 Python 3.14 Alpine 3.24,并应用系统安全更新,移除 Debian 基础系统遗留告警。保留原字体路径、中文字体、时区和第三方许可。
|
||||
- 历史记录标明云端图片 ID 或本地图片 ID,同时显示图片名称和错误原因,避免编号遮住失败提示。
|
||||
- 详情优先展示可用的本地预览;云端图片清理后仍可关联原上传记录。保留图片名称快照,本地文件清理后不再提供失效预览。
|
||||
- 不重发设备指令,不改变图片清理策略。
|
||||
|
||||
发布脚本统一从完整 Dockerfile 构建,避免复用旧运行映像继续携带漏洞;版本标签读取项目元数据。移除旧 Debian 离线字体缓存和运行映像覆盖构建方式,同步构建参数与文档。
|
||||
|
||||
585 项测试、Ruff、格式和 mypy 通过。AMD64 容器启动/重启、只读接口及中文实际字形和 PNG 渲染验证通过。Trivy 0.76.0 扫描系统包和全部 25 项 Python 运行依赖,各严重级别均未检出已知漏洞;结论限于 2026-10-18 漏洞库快照。
|
||||
|
||||
生产升级须通过现有挂载身份校验和部署流程完成,发布源码不等同于生产升级。
|
||||
验证:603 项测试、Ruff、mypy 及中英文桌面/平板/手机历史列表、错误提示和预览检查通过。
|
||||
|
||||
+1
-1
@@ -16,6 +16,6 @@ person's deployment or write to real frames without explicit authorization.
|
||||
|
||||
## 当前容器映像检查
|
||||
|
||||
v0.1.21 使用官方 Python Alpine 3.24 基础映像并固定摘要,构建时应用 APK 安全更新。2026-10-18 使用 Trivy 0.76.0 扫描构建的 Linux AMD64 映像,涵盖操作系统和 Python 包、全部严重级别,未检出已知漏洞;未配置漏洞忽略规则。该结果是当日漏洞库快照,不是未来无漏洞的保证,也不代表旧运行容器已升级。
|
||||
v0.1.23 使用官方 Python Alpine 3.24 基础映像并固定摘要,构建时应用 APK 安全更新。2026-10-18 使用 Trivy 0.76.0 扫描与当前生产运行 ID 一致的 Linux AMD64 映像,涵盖操作系统和 Python 包、全部严重级别,未检出已知漏洞;未配置漏洞忽略规则。该结果是当日漏洞库快照,不是未来无漏洞的保证,也不代表旧运行容器已升级。
|
||||
|
||||
基础映像更新后须重新构建并复扫。默认非 root、只读根目录、清空 capabilities 和 no-new-privileges 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
|
||||
|
||||
+24
-23
@@ -2,16 +2,16 @@ c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
|
||||
d20031a8e861a0f3e19b7750ddb53bc84c480c469c44a70ecdf8261ec05431c0 .env.example
|
||||
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
|
||||
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
|
||||
d3ad09bdc5dc60c41a774e1cad43f3e3f8ae5c851303ae6ec2aac030392a2939 .github/workflows/verify.yml
|
||||
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
|
||||
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
||||
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||
660c76fccccae8643633d052d0d6733ecf278f2a3016de29e6850b312a365413 Dockerfile
|
||||
0acd6eb62044b52828dae0dbd6f34ae91fb64ea1b6fa9f67062c6125a7364e40 Dockerfile
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
||||
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
|
||||
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
|
||||
304bec983ac8242107b1ec4154522f451a5d33b6a77ed26669926cb3e161f863 RELEASE_NOTES.md
|
||||
6a0c85c8d52a2d4a3b779dce306a93c4efc4dfe3ab3b23e4a6a3bb7c55079910 SECURITY.md
|
||||
4eb58ccd6f4b073d6f6e2ed2ca1ce430813064831b1143d63c6ab8b0c453638f RELEASE_NOTES.md
|
||||
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
||||
dff739157875c8a70235c4994df868afc6824e0d3861b2716ac28014bb38144f deploy/build-cn.env
|
||||
@@ -21,35 +21,35 @@ fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker-
|
||||
4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json
|
||||
f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml
|
||||
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
|
||||
5f727c9588504a18009ea892f3204fce9520b03754a56dbc9f59e3505ce8a1b2 docs/BUILDING.md
|
||||
93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md
|
||||
d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md
|
||||
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
|
||||
0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md
|
||||
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
|
||||
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
|
||||
4b00d08eb5a2425b205af13b87f9be04dd6870a734b42463a3e075658039dac9 docs/PUBLICATION.md
|
||||
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
|
||||
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
|
||||
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||
b191be41aec1dab92ff0d2956d32ec6a325b49786ce9051e5299fe20e2b6b62d docs/openapi.json
|
||||
1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json
|
||||
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
||||
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
||||
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
||||
06e2e5156383120271ec989b89236bd1baa2d7ba3d6e51b3018de2b23bd39d91 pigway_frame_flow/api/app.py
|
||||
53aa03c15f847a1d770f2132d117f793602ecf766b6ab92973bcdf2169946eae pigway_frame_flow/api/app.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py
|
||||
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
||||
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
||||
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
||||
558d2e2111e3dea1917eaa4f81d7c2dac3b47af3eda1cdc9426af4bf9bc570dd pigway_frame_flow/display/recovery.py
|
||||
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||
1ee9ee8745dc497dadcc20e5e238c78f6c228f7f8a4ed310ced32f9be55b512e pigway_frame_flow/display/service.py
|
||||
5f941b8b402efd6d1adab66df1dff29b1a3effda5ab23776daa300d55d5ca924 pigway_frame_flow/display/service.py
|
||||
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||
@@ -82,7 +82,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
|
||||
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
|
||||
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
|
||||
0a7a96f84c3c1248eadf80ae412df36c71d308771f9f53512f3fc3c9bc6d5976 pigway_frame_flow/models.py
|
||||
365cb15a6b449bd0e7048666ae9485570956bafca4a5ba2324b6248f4cb08c95 pigway_frame_flow/models.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
|
||||
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
||||
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
||||
@@ -116,18 +116,18 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
|
||||
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
|
||||
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
|
||||
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
|
||||
c43faa235848bec6370d7a3d8a7b3475cbd8eb4a6e494518dfaf160ca8f77bfa pigway_frame_flow/web/app.js
|
||||
3acad7c209e3483481c3b966b27a0609f2d12c2383955274dfd226a1fe5f41bf pigway_frame_flow/web/app.js
|
||||
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
|
||||
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
|
||||
d4518595bc39adfd88d908f98c8c14c02ffbc67a4289003e792b2ceade2838dd pigway_frame_flow/web/index.html
|
||||
cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
|
||||
a43bde8abb2ac9a9e0f41f83219c43c20804d76f0984407308847238554b1f3d pigway_frame_flow/web/i18n.js
|
||||
d2eae1d2116dc426da00b709154516ca633ee0cd41abd23a5d8f165b1f1673ef pigway_frame_flow/web/index.html
|
||||
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
||||
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||
da96be4eeb3b57f6805ca766919e3899968179f1e7e6a5cb25dbabfd13bc1569 pyproject.toml
|
||||
7cc44dc5400afa2b12e99ff07252c26c27ec9f8ad87e779d1ab16caf77e29e50 pyproject.toml
|
||||
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh
|
||||
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
|
||||
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
|
||||
8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py
|
||||
b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py
|
||||
@@ -138,7 +138,7 @@ cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrat
|
||||
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
|
||||
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
|
||||
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
|
||||
792dafe1ffb2be6a50aba549e642aa95357469ac1ec94a04bdc93a0f419d5f63 scripts/server-release.py
|
||||
5a0f8560d44352f2c5595875455ab1b2397d4eb8158a8160b5587a89a934f712 scripts/server-release.py
|
||||
1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py
|
||||
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
|
||||
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
|
||||
@@ -149,7 +149,7 @@ f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gal
|
||||
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
|
||||
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
|
||||
02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
|
||||
cb0ccb04e087e2117f2a2e7ebe7f1a6b116e39e2bfc197ea996675ef628c98ae scripts/ui-history-localization.cjs
|
||||
29c9b2ef02e11aa3320d63daec28185bf6e1e781d33d3ba934125c79c81ba33a scripts/ui-history-localization.cjs
|
||||
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
|
||||
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
|
||||
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
|
||||
@@ -170,7 +170,7 @@ e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify
|
||||
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
||||
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
|
||||
c44080785bb2cca54bb17ac9e25f2e91a36839688bc70e40faecaea42d75e9f8 tests/schema-contract.json
|
||||
c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json
|
||||
14a7c56c40ea4b520585c20341b3ec70fa3ba52518144a10ba3494ceceb30758 tests/test_accounts.py
|
||||
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||
94c01e6d8bdbfffbff72edb91823eaf39a325ad8782aba1d8e2804402e256aec tests/test_api.py
|
||||
@@ -186,6 +186,7 @@ fc7a3b37e43ae6716c3afeeeddc0631c932175d527bd8adfea6e5e0acb166311 tests/test_con
|
||||
f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_extensions.py
|
||||
f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py
|
||||
2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py
|
||||
5dc4027bcdad63c4a3e02c7735e6b8445c69edb43f2a3c0cd8902ef6aa1c6342 tests/test_history_images.py
|
||||
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
|
||||
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
|
||||
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
|
||||
@@ -207,10 +208,10 @@ f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_pho
|
||||
ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py
|
||||
fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py
|
||||
f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py
|
||||
0ed189d3469097aa7538325d418425e0c7a3ccc8a285bcf4c2ac10658af4b0df tests/test_recovery.py
|
||||
072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py
|
||||
915992a2ef9aeb8ae5c688d5dd50c6acd37ba726eb5aff1a0df991ffe374d94e tests/test_recovery.py
|
||||
e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_retention.py
|
||||
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
||||
5f25f608f13f96b245d6963c02a98add679c6e7d70d398ebb5c3c49b38fbeda8 tests/test_server_release.py
|
||||
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
|
||||
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
|
||||
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
||||
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||
|
||||
+20
-1
@@ -7,6 +7,10 @@ locked Python dependencies, and `font-noto-cjk` for Chinese rendering. APK
|
||||
repository signatures and offline wheel SHA256 checks remain enabled.
|
||||
Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
|
||||
Both build the same Dockerfile with BuildKit and `docker compose build --pull`.
|
||||
For a CI host that cannot reach Docker Hub, the optional `CI_PYTHON_BASE`
|
||||
repository variable passes a registry mirror reference through the build script.
|
||||
Keep the same official Python image digest; an unset variable preserves the
|
||||
Dockerfile default. This setting does not change the shared Docker daemon.
|
||||
|
||||
`ALPINE_MIRROR` accepts a mirror host (without scheme or path), defaulting to
|
||||
`dl-cdn.alpinelinux.org`. For mainland-China builds:
|
||||
@@ -29,6 +33,17 @@ resources and are not runtime mounts or dependencies.
|
||||
|
||||
### CI with a host Docker socket
|
||||
|
||||
The managed CI runner runs on Ubuntu Server; the NAS no longer executes jobs.
|
||||
It retains `ubuntu-latest` compatibility and fixed runner/job image digests,
|
||||
accepts one job at a time, and limits each task container to 4 CPUs and 8 GiB.
|
||||
The runner's private state resides on the guarded business disk. Its systemd
|
||||
service checks the mount identity before starting. Registration credentials are
|
||||
supplied through a temporary file and removed after registration; the daemon
|
||||
container does not retain the reusable registration token in its environment.
|
||||
Keep the current runner and job images even when no task is executing: they are
|
||||
active CI dependencies. Only trusted repository events may reach this runner's
|
||||
Docker socket; CI does not automatically deploy production or write to frames.
|
||||
|
||||
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
|
||||
The script derives a temporary Compose document from production settings, with
|
||||
an absolute client-side build context, a UUID project name, a unique CI image
|
||||
@@ -40,7 +55,7 @@ owned `/data` and `/config` directories; the service remains UID/GID 10333.
|
||||
|
||||
Build contexts are sent by the Docker client, so the job workspace can remain
|
||||
inside the job container. Runtime storage is managed by the daemon; no matching
|
||||
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
|
||||
Docker host `/workspace` directory is needed. HTTP readiness and the unmodified
|
||||
nine-endpoint smoke script run inside the service with `compose exec -T`.
|
||||
Restart is followed by another readiness check and the same smoke test.
|
||||
|
||||
@@ -81,3 +96,7 @@ If dependency installation fails, the always() cleanup step still executes.
|
||||
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
|
||||
as no Docker verification resources have been created at that point.
|
||||
|
||||
|
||||
### Verified prebuilt production image
|
||||
|
||||
When the production host cannot reach the registry, build the complete Dockerfile in a trusted build environment and transfer it with `docker image save` / `docker image load`. Supply the immutable `sha256:` image ID privately as `FRAME_FLOW_PREBUILT_IMAGE_ID` to `scripts/server-release.py`. The expected version/commit tag must already exist. The script checks the ID, version and revision labels, then verifies runtime source and license hashes in an isolated container without production mounts before switching the service. Existing storage checks, backups, post-deployment verification and rollback remain in effect.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Runtime keys, passwords, sessions, private photos, databases and local `.env` fi
|
||||
must remain outside Git. Examples use documentation-only addresses and identities.
|
||||
Personal deployment reports and screenshots are excluded from public history.
|
||||
|
||||
The Gitea NAS Docker runner accepts trusted repository events only. GitHub mirrors
|
||||
The Gitea Server Docker runner accepts trusted repository events only. GitHub mirrors
|
||||
use the separate community workflow on GitHub-hosted disposable runners, with
|
||||
read-only repository permissions and no deployment configuration. External fork PRs
|
||||
are not executed on this runner; maintainers must review them before copying changes
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "PIGWay Frame Flow API",
|
||||
"version": "0.1.21"
|
||||
"version": "0.1.23"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -15227,7 +15227,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.21"
|
||||
"default": "0.1.23"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -17490,7 +17490,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.21"
|
||||
"default": "0.1.23"
|
||||
},
|
||||
"api_version": {
|
||||
"type": "string",
|
||||
|
||||
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
|
||||
app = FastAPI(
|
||||
title="PIGWay Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.21",
|
||||
version="0.1.24",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.21",
|
||||
version="0.1.24",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
@@ -71,3 +73,92 @@ class RecoveryMonitor:
|
||||
if row:
|
||||
row["requires_manual_review"] = row["state"] == "manual_review"
|
||||
return row
|
||||
|
||||
def observe_device(self, op, *, device=None, error=None):
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (op["id"],))
|
||||
result = json.loads(row["result"] or "{}")
|
||||
observation = {"checked_at": now_iso()}
|
||||
if device is not None:
|
||||
observation.update(
|
||||
image_id=device.current_content.id,
|
||||
synced=device.current_content.synced,
|
||||
online=device.status == "online",
|
||||
heartbeat=device.last_seen_at,
|
||||
)
|
||||
else:
|
||||
observation["read_error"] = {
|
||||
"code": error.code,
|
||||
**{
|
||||
k: v
|
||||
for k, v in error.details.items()
|
||||
if k
|
||||
in ("transport_error", "http_status", "attempts", "retry_exhausted")
|
||||
},
|
||||
}
|
||||
samples = result.get("sync_observations", [])
|
||||
if samples:
|
||||
old = samples[-1]
|
||||
changed = {k: v for k, v in old.items() if k != "checked_at"} != {
|
||||
k: v for k, v in observation.items() if k != "checked_at"
|
||||
}
|
||||
if (
|
||||
not changed
|
||||
and (
|
||||
datetime.now(UTC) - datetime.fromisoformat(old["checked_at"])
|
||||
).total_seconds()
|
||||
< 60
|
||||
):
|
||||
return observation
|
||||
samples.append(observation)
|
||||
result["sync_observations"] = (
|
||||
[samples[0], *samples[-11:]] if len(samples) > 12 else samples
|
||||
)
|
||||
# Evidence must not reset updated_at: that is the sync timeout's clock.
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?", (encode(result), op["id"])
|
||||
)
|
||||
return observation
|
||||
|
||||
def close_superseded_displays(self):
|
||||
for op in self.db.all(
|
||||
"SELECT * FROM operations WHERE action='display' AND write_started=1 "
|
||||
"AND status IN ('SYNCING','UNKNOWN')"
|
||||
):
|
||||
result = json.loads(op["result"] or "{}")
|
||||
if not result.get("image_id") or json.loads(op["payload"]).get(
|
||||
"options", {}
|
||||
).get("restore_previous"):
|
||||
continue
|
||||
newer = self.db.one(
|
||||
"SELECT id,result FROM operations WHERE device_id=? AND action='display' "
|
||||
"AND status='COMPLETED' AND write_started=1 AND created_at>? "
|
||||
"AND COALESCE(json_extract(payload,'$.options.restore_previous'),0)=0 "
|
||||
"AND json_extract(result,'$.image_id') IS NOT NULL "
|
||||
"ORDER BY created_at DESC LIMIT 1",
|
||||
(op["device_id"], op["created_at"]),
|
||||
)
|
||||
if (
|
||||
not newer
|
||||
or json.loads(newer["result"])["image_id"] == result["image_id"]
|
||||
):
|
||||
continue
|
||||
# A later confirmed permanent display ends this attempt, but cannot
|
||||
# prove whether the earlier image was ever shown. Never replay it.
|
||||
self.service.update(
|
||||
op["id"],
|
||||
"FAILED",
|
||||
error={
|
||||
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
|
||||
"message": "Superseded by a later confirmed display; earlier display unconfirmed",
|
||||
"request_id": op["request_id"],
|
||||
"details": {
|
||||
"superseded_by_operation_id": newer["id"],
|
||||
"previous_error": json.loads(op["error"] or "null"),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.db.history(op, "DISPLAY_NOT_CONFIRMED")
|
||||
self.db.event(
|
||||
"operation.failed",
|
||||
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
|
||||
)
|
||||
|
||||
@@ -152,6 +152,31 @@ class DisplayService:
|
||||
)
|
||||
return id
|
||||
|
||||
def operation_image(self, device_id, result):
|
||||
result = dict(result or {})
|
||||
asset_id = result.get("rendered_asset_id") or result.get("asset_id")
|
||||
if not asset_id and result.get("image_id") is not None:
|
||||
# Cloud cleanup removes image_sources; the original upload receipt survives.
|
||||
row = self.db.one(
|
||||
"SELECT result FROM operations WHERE device_id=? "
|
||||
"AND json_extract(result,'$.image_id')=? "
|
||||
"AND json_extract(result,'$.rendered_asset_id') IS NOT NULL "
|
||||
"ORDER BY created_at DESC LIMIT 1",
|
||||
(device_id, result["image_id"]),
|
||||
)
|
||||
if row:
|
||||
original = json.loads(row["result"])
|
||||
asset_id = original["rendered_asset_id"]
|
||||
result.setdefault("image_name", original.get("image_name"))
|
||||
if asset_id:
|
||||
result.setdefault("asset_id", asset_id)
|
||||
asset = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||
if asset:
|
||||
result["image_name"] = result.get("image_name") or asset["name"]
|
||||
if self.assets.path(asset_id).is_file():
|
||||
result["preview_url"] = f"/api/v1/assets/{asset_id}"
|
||||
return result or None
|
||||
|
||||
def operation(self, id):
|
||||
op = self.db.one("SELECT * FROM operations WHERE id=?", (id,))
|
||||
if not op:
|
||||
@@ -162,7 +187,9 @@ class DisplayService:
|
||||
device_id=op["device_id"],
|
||||
created_at=op["created_at"],
|
||||
updated_at=op["updated_at"],
|
||||
result=json.loads(op["result"]) if op["result"] else None,
|
||||
result=self.operation_image(
|
||||
op["device_id"], json.loads(op["result"]) if op["result"] else None
|
||||
),
|
||||
error=json.loads(op["error"]) if op["error"] else None,
|
||||
request_id=op["request_id"],
|
||||
client_id=op["client_id"],
|
||||
@@ -172,9 +199,12 @@ class DisplayService:
|
||||
|
||||
def update(self, id, status, result=None, error=None):
|
||||
if result:
|
||||
op = self.db.one("SELECT device_id FROM operations WHERE id=?", (id,))
|
||||
result = self.operation_image(op["device_id"], result)
|
||||
result.pop("preview_url", None)
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||
previous = json.loads(row["result"]) if row and row["result"] else {}
|
||||
for key in ("photo_attempts", "upload_intent"):
|
||||
for key in ("photo_attempts", "upload_intent", "sync_observations"):
|
||||
if key in previous:
|
||||
result = {**result, key: previous[key]}
|
||||
self.db.execute(
|
||||
@@ -518,6 +548,25 @@ class DisplayService:
|
||||
) and self.blocked(op["device_id"]):
|
||||
continue
|
||||
payload = json.loads(op["payload"])
|
||||
if (
|
||||
op["action"] in ("display", "delete", "restore")
|
||||
and op["client_id"]
|
||||
in ("scheduler", "scheduler-retry", "cloud-library", "retention")
|
||||
and self.db.one(
|
||||
"SELECT id FROM operations WHERE device_id=? AND action='display' "
|
||||
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
|
||||
(
|
||||
op["device_id"],
|
||||
(
|
||||
datetime.now(UTC)
|
||||
- timedelta(seconds=self.settings.sync_timeout)
|
||||
).isoformat(),
|
||||
),
|
||||
)
|
||||
):
|
||||
# Both upload and deletion update the cloud frame counter.
|
||||
# Automatic writes must not invalidate a pending display ack.
|
||||
continue
|
||||
if op["action"] == "display":
|
||||
priority = payload["options"]["priority"]
|
||||
top = self.top(op["device_id"])
|
||||
@@ -823,6 +872,7 @@ class DisplayService:
|
||||
try:
|
||||
driver = self.registry.get(op["device_id"])
|
||||
device = driver.device(refresh=True)
|
||||
self.recovery.observe_device(op, device=device)
|
||||
if intent:
|
||||
expected = self.upload_reconciliation.check(op, driver, device)
|
||||
if (
|
||||
@@ -850,7 +900,8 @@ class DisplayService:
|
||||
if device.current_content.id == expected
|
||||
else "target_mismatch",
|
||||
)
|
||||
except FlowError:
|
||||
except FlowError as exc:
|
||||
self.recovery.observe_device(op, error=exc)
|
||||
self.recovery.observe(op, "read_unavailable")
|
||||
except Exception:
|
||||
# Bad vendor metadata for one operation must not stop the worker.
|
||||
@@ -858,6 +909,7 @@ class DisplayService:
|
||||
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
||||
try:
|
||||
device = self.registry.get(op["device_id"]).device()
|
||||
observation = self.recovery.observe_device(op, device=device)
|
||||
target = json.loads(op["result"])["image_id"]
|
||||
if (
|
||||
device.current_content.id == target
|
||||
@@ -878,11 +930,13 @@ class DisplayService:
|
||||
"code": E.WRITE_RESULT_UNKNOWN,
|
||||
"message": "Frame refresh was not confirmed",
|
||||
"request_id": op["request_id"],
|
||||
"details": {},
|
||||
"details": {"last_device_observation": observation},
|
||||
},
|
||||
)
|
||||
except FlowError:
|
||||
pass
|
||||
except FlowError as exc:
|
||||
self.recovery.observe_device(op, error=exc)
|
||||
|
||||
self.recovery.close_superseded_displays()
|
||||
|
||||
def expire(self):
|
||||
if not self.settings.mock and not self.settings.write_enabled:
|
||||
|
||||
@@ -168,6 +168,8 @@ class ErrorResponse(BaseModel):
|
||||
|
||||
|
||||
class OperationResult(BaseModel):
|
||||
image_name: str | None = None
|
||||
preview_url: str | None = None
|
||||
cover_id: int | None = None
|
||||
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
|
||||
image_id: int | None = None
|
||||
@@ -366,7 +368,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.21"
|
||||
version: str = "0.1.24"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -397,7 +399,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "PIGWay Frame Flow"
|
||||
version: str = "0.1.21"
|
||||
version: str = "0.1.24"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -38,6 +38,14 @@ window.addEventListener('resize',revealCurrentNavigation);
|
||||
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
|
||||
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
|
||||
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
|
||||
function operationResultHTML(o){
|
||||
const r=o.result||{},parts=[];
|
||||
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
|
||||
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
|
||||
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
|
||||
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
|
||||
return parts.join('')||'—';
|
||||
}
|
||||
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
|
||||
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
|
||||
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
|
||||
@@ -143,7 +151,7 @@ const views={
|
||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||
};
|
||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
||||
|
||||
@@ -45,7 +45,7 @@ async function weatherSettings(){
|
||||
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
|
||||
}
|
||||
async function enhanceHistory(){
|
||||
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p><p>${esc(operationErrorLabel(current.error)||current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
|
||||
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
|
||||
async function enhanceAutomation(){
|
||||
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.21</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.21"></script><script src="/ui/features.js?v=0.1.21"></script><script src="/ui/extensions.js?v=0.1.21"></script><script src="/ui/app.js?v=0.1.21"></script></body></html>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.24</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.24"></script><script src="/ui/features.js?v=0.1.24"></script><script src="/ui/extensions.js?v=0.1.24"></script><script src="/ui/app.js?v=0.1.24"></script></body></html>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.21"
|
||||
version = "0.1.24"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.14,<3.15"
|
||||
|
||||
@@ -6,5 +6,9 @@ cd "$(dirname "$0")/.."
|
||||
DOCKER_BIN=${DOCKER_BIN:-docker}
|
||||
"$DOCKER_BIN" info >/dev/null
|
||||
"$DOCKER_BIN" compose config --quiet
|
||||
"$DOCKER_BIN" compose build --pull
|
||||
set -- --pull
|
||||
if [ -n "${CI_PYTHON_BASE:-}" ]; then
|
||||
set -- "$@" --build-arg "PYTHON_BASE=$CI_PYTHON_BASE"
|
||||
fi
|
||||
"$DOCKER_BIN" compose build "$@"
|
||||
"$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
|
||||
|
||||
+68
-17
@@ -127,6 +127,48 @@ def main():
|
||||
release(lock)
|
||||
|
||||
|
||||
def verify_prebuilt_image(image, commit, version, hashes):
|
||||
expected = os.getenv("FRAME_FLOW_PREBUILT_IMAGE_ID", "")
|
||||
if not expected:
|
||||
return False
|
||||
assert re.fullmatch(r"sha256:[0-9a-f]{64}", expected), "Require immutable image ID"
|
||||
metadata = json.loads(run("docker", "image", "inspect", image))[0]
|
||||
assert metadata["Id"] == expected, "Prebuilt image ID mismatch"
|
||||
labels = metadata["Config"].get("Labels") or {}
|
||||
assert labels.get("org.opencontainers.image.revision") == commit
|
||||
assert labels.get("org.opencontainers.image.version") == version
|
||||
project = {
|
||||
n: h
|
||||
for n, h in hashes.items()
|
||||
if n.startswith(("pigway_frame_flow/", "vendor/"))
|
||||
or n in {"requirements.lock", "LICENSE", "NOTICE.md"}
|
||||
}
|
||||
# Validate the artifact before it can access production config or data.
|
||||
code = "import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert h and all(hashlib.sha256((pathlib.Path('/app')/n).read_bytes()).hexdigest()==v for n,v in h.items())"
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"-i",
|
||||
"--network=none",
|
||||
"--read-only",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges",
|
||||
"--user=10333:10333",
|
||||
"--entrypoint=python",
|
||||
expected,
|
||||
"-c",
|
||||
code,
|
||||
],
|
||||
input=json.dumps(project),
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=60,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def release(lock):
|
||||
ctx = Path(__file__).resolve().parent.parent
|
||||
assert ctx.parent == ROOT / "migration-inbox"
|
||||
@@ -154,28 +196,37 @@ def release(lock):
|
||||
version = tomllib.loads((ctx / "pyproject.toml").read_text())["project"]["version"]
|
||||
assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version)
|
||||
image = "pigway-frame-flow:" + version + "-" + commit[:12]
|
||||
# A source-only overlay would retain vulnerable packages from the old image.
|
||||
run(
|
||||
"docker",
|
||||
"build",
|
||||
"--pull",
|
||||
"-f",
|
||||
str(ctx / "Dockerfile"),
|
||||
"--build-arg",
|
||||
"BUILD_COMMIT=" + commit,
|
||||
"--build-arg",
|
||||
"BUILD_TIME=" + stamp,
|
||||
"--build-arg",
|
||||
"ALPINE_MIRROR=" + os.getenv("ALPINE_MIRROR", "dl-cdn.alpinelinux.org"),
|
||||
"-t",
|
||||
image,
|
||||
str(ctx),
|
||||
)
|
||||
if not verify_prebuilt_image(image, commit, version, hashes):
|
||||
# A source-only overlay would retain vulnerable packages from the old image.
|
||||
run(
|
||||
"docker",
|
||||
"build",
|
||||
"--pull",
|
||||
"-f",
|
||||
str(ctx / "Dockerfile"),
|
||||
"--build-arg",
|
||||
"BUILD_COMMIT=" + commit,
|
||||
"--build-arg",
|
||||
"BUILD_TIME=" + stamp,
|
||||
"--build-arg",
|
||||
"ALPINE_MIRROR=" + os.getenv("ALPINE_MIRROR", "dl-cdn.alpinelinux.org"),
|
||||
"-t",
|
||||
image,
|
||||
str(ctx),
|
||||
)
|
||||
compose = json.loads((APP / "compose.json").read_text())
|
||||
service = compose["services"][CONTAINER]
|
||||
service["image"] = image
|
||||
service["environment"]["BUILD_COMMIT"] = commit
|
||||
service["environment"]["BUILD_TIME"] = stamp
|
||||
labels = service.get("labels") or {}
|
||||
if isinstance(labels, list):
|
||||
labels = {item.partition("=")[0]: item.partition("=")[2] for item in labels}
|
||||
service["labels"] = {
|
||||
**labels,
|
||||
"org.opencontainers.image.version": version,
|
||||
"org.opencontainers.image.revision": commit,
|
||||
}
|
||||
changed = False
|
||||
try:
|
||||
changed = True
|
||||
|
||||
@@ -4,6 +4,7 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
|
||||
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
|
||||
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
|
||||
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
|
||||
if(u.pathname==='/api/v1/assets/fixture')return r.fulfill({body:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jBz8AAAAASUVORK5CYII=','base64'),contentType:'image/png'});
|
||||
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
|
||||
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
|
||||
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
|
||||
@@ -12,6 +13,6 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
|
||||
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
|
||||
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
|
||||
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
|
||||
await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}});
|
||||
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
|
||||
await p.evaluate(async()=>{const op={operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',result:{image_id:999,image_name:'example <photo>.jpg',preview_url:'/api/v1/assets/fixture'},error:{code:'WRITE_DISABLED'},request_id:'fixture'};api=async(path,options)=>options?.binary?(await fetch(path)).blob():path.endsWith('/fixture')?op:[op];state.devices=[{id:'living-room',name:'living-room'}];document.body.classList.remove('login-mode');shell();await views.history(state.epoch);await enhanceHistory();});
|
||||
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes('example <photo>.jpg'));assert(text.includes('999'));assert(text.includes(lang==='zh'?'云端图片 ID':'Cloud image ID'));assert(await p.locator('td div.bad').count()===1);assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));await p.locator('.history-actions button').click();await p.locator('#operation-preview').waitFor();assert(await p.locator('#operation-preview').evaluate(img=>img.complete&&img.naturalWidth>0));assert(await p.locator('#modal-content').innerText().then(x=>x.includes('example <photo>.jpg')));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
|
||||
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -690,6 +690,28 @@
|
||||
},
|
||||
"OperationResult": {
|
||||
"properties": {
|
||||
"image_name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Image Name"
|
||||
},
|
||||
"preview_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Preview Url"
|
||||
},
|
||||
"cover_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from pigway_frame_flow.models import DisplayRequest
|
||||
|
||||
|
||||
def test_history_image_survives_cloud_cleanup(client, service):
|
||||
data = BytesIO()
|
||||
Image.new("RGB", (40, 60), "blue").save(data, "JPEG")
|
||||
asset = service.assets.put(data.getvalue(), "example.jpg", "images")
|
||||
request = DisplayRequest(content={"type": "image", "asset_id": asset["asset_id"]})
|
||||
op = service.submit(
|
||||
"display", request.model_dump(mode="json"), "living-room", "admin", "fixture"
|
||||
)
|
||||
service.process_one()
|
||||
service.sync()
|
||||
result = service.operation(op)["result"]
|
||||
image_id = result["image_id"]
|
||||
# Simulate an older receipt without the new name snapshot, then cloud cleanup.
|
||||
service.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(
|
||||
'{"image_id":%d,"rendered_asset_id":"%s"}' % (image_id, asset["asset_id"]),
|
||||
op,
|
||||
),
|
||||
)
|
||||
service.db.execute("DELETE FROM image_sources WHERE device_id=?", ("living-room",))
|
||||
deletion = service.submit(
|
||||
"delete",
|
||||
{"image_id": image_id, "confirm_current": True},
|
||||
"living-room",
|
||||
"admin",
|
||||
"delete-fixture",
|
||||
)
|
||||
service.update(deletion, "COMPLETED", {"image_id": image_id})
|
||||
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
|
||||
assert receipt["image_name"] == "example.jpg"
|
||||
assert client.get(receipt["preview_url"]).status_code == 200
|
||||
service.assets.path(asset["asset_id"]).unlink()
|
||||
service.db.execute("DELETE FROM assets WHERE id=?", (asset["asset_id"],))
|
||||
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
|
||||
assert receipt["image_name"] == "example.jpg"
|
||||
assert receipt["preview_url"] is None
|
||||
|
||||
|
||||
def test_history_missing_local_image_does_not_break_list(client, service):
|
||||
op = service.submit("delete", {"image_id": 999}, "living-room", "admin", "fixture")
|
||||
service.update(
|
||||
op,
|
||||
"FAILED",
|
||||
{"image_id": 999},
|
||||
{
|
||||
"code": "DEVICE_OFFLINE",
|
||||
"message": "Device offline",
|
||||
"request_id": "fixture",
|
||||
},
|
||||
)
|
||||
receipt = client.get("/api/v1/operations/" + op).json()
|
||||
assert receipt["result"]["image_id"] == 999
|
||||
assert receipt["result"]["preview_url"] is None
|
||||
assert receipt["error"]["code"] == "DEVICE_OFFLINE"
|
||||
+185
-1
@@ -1,5 +1,6 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from test_display import submit
|
||||
|
||||
from pigway_frame_flow.display.service import DisplayService
|
||||
@@ -60,7 +61,9 @@ def test_persistent_block_escalates_once_survives_restart_and_preserves_evidence
|
||||
== 1
|
||||
)
|
||||
assert service.operation(operation)["recovery"]["checks"] == 3
|
||||
assert service.operation(operation)["result"] == original["result"]
|
||||
current_result = service.operation(operation)["result"]
|
||||
assert current_result.pop("sync_observations")
|
||||
assert current_result == original["result"]
|
||||
service.reconcile(operation, acknowledge=True)
|
||||
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
||||
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
||||
@@ -107,3 +110,184 @@ def test_malformed_reconciliation_does_not_abort_other_background_work(
|
||||
assert op["recovery"]["reason"] == "reconciliation_error"
|
||||
assert "private" not in str(op["recovery"])
|
||||
assert service.blocked("living-room")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["SYNCING", "UNKNOWN"])
|
||||
def test_later_confirmed_display_closes_earlier_syncing_attempt_without_replay(
|
||||
service, status
|
||||
):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
assert service.operation(first)["status"] == "SYNCING"
|
||||
second = submit(service)
|
||||
service.process_one()
|
||||
driver = service.registry.get("living-room")
|
||||
uploads = driver.client.uploads
|
||||
previous_error = {"code": "WRITE_RESULT_UNKNOWN", "message": "Refresh unconfirmed"}
|
||||
if status == "UNKNOWN":
|
||||
service.update(first, status, error=previous_error)
|
||||
service.sync()
|
||||
assert service.operation(second)["status"] == "COMPLETED"
|
||||
old = service.operation(first)
|
||||
assert old["status"] == "FAILED"
|
||||
assert old["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
|
||||
assert old["error"]["details"]["superseded_by_operation_id"] == second
|
||||
if status == "UNKNOWN":
|
||||
assert old["error"]["details"]["previous_error"] == previous_error
|
||||
assert driver.client.uploads == uploads
|
||||
assert not service.blocked("living-room")
|
||||
service.sync()
|
||||
assert (
|
||||
len(
|
||||
service.db.all(
|
||||
"SELECT * FROM history WHERE operation_id=? AND result='DISPLAY_NOT_CONFIRMED'",
|
||||
(first,),
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_unconfirmed_later_display_does_not_close_old_attempt(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
second = submit(service)
|
||||
service.process_one()
|
||||
service.recovery.close_superseded_displays()
|
||||
assert service.operation(first)["status"] == "SYNCING"
|
||||
assert service.operation(second)["status"] == "SYNCING"
|
||||
|
||||
|
||||
def test_confirmed_temporary_override_does_not_close_earlier_display(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
second = submit(service, 60, True)
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.operation(second)["status"] == "COMPLETED"
|
||||
assert service.operation(first)["status"] == "SYNCING"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_id", ["scheduler", "scheduler-retry"])
|
||||
def test_automatic_upload_waits_for_prior_sync_confirmation(service, client_id):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||
"payload"
|
||||
]
|
||||
import json
|
||||
|
||||
second = service.submit(
|
||||
"display", json.loads(payload), "living-room", client_id, "fixture"
|
||||
)
|
||||
uploads = service.registry.get("living-room").client.uploads
|
||||
service.process_one()
|
||||
assert service.operation(second)["status"] == "QUEUED"
|
||||
assert service.registry.get("living-room").client.uploads == uploads
|
||||
service.sync()
|
||||
assert service.operation(first)["status"] == "COMPLETED"
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.operation(second)["status"] == "COMPLETED"
|
||||
assert service.registry.get("living-room").client.uploads == uploads + 1
|
||||
|
||||
|
||||
def test_device_evidence_is_bounded_and_does_not_extend_sync_timeout(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||
before = service.operation(first)["updated_at"]
|
||||
device = service.registry.get("living-room").device()
|
||||
for i in range(20):
|
||||
sample = device.model_copy(
|
||||
update={
|
||||
"current_content": device.current_content.model_copy(
|
||||
update={"id": 1000 + i}
|
||||
)
|
||||
}
|
||||
)
|
||||
service.recovery.observe_device(op, device=sample)
|
||||
result = service.operation(first)
|
||||
samples = result["result"]["sync_observations"]
|
||||
assert len(samples) == 12
|
||||
assert samples[0]["image_id"] == 1000 and samples[-1]["image_id"] == 1019
|
||||
assert result["updated_at"] == before
|
||||
service.update(first, "UNKNOWN", {"image_id": 7})
|
||||
assert service.operation(first)["result"]["sync_observations"] == samples
|
||||
|
||||
|
||||
def test_device_read_failure_evidence_never_copies_raw_error_details(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||
service.recovery.observe_device(
|
||||
op,
|
||||
error=FlowError(
|
||||
ErrorCode.CLOUD_UNAVAILABLE,
|
||||
"unavailable",
|
||||
details={
|
||||
"transport_error": "read_timeout",
|
||||
"url": "private-url-must-not-be-stored",
|
||||
},
|
||||
),
|
||||
)
|
||||
sample = service.operation(first)["result"]["sync_observations"][-1]
|
||||
assert sample["read_error"]["transport_error"] == "read_timeout"
|
||||
assert "private-url" not in str(sample)
|
||||
|
||||
|
||||
def test_sync_timeout_includes_observed_device_state(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
service.registry.get("living-room").client.synced = False
|
||||
service.registry.get("living-room").invalidate()
|
||||
service.settings.sync_timeout = 0
|
||||
service.sync()
|
||||
op = service.operation(first)
|
||||
assert op["status"] == "UNKNOWN"
|
||||
assert op["error"]["details"]["last_device_observation"]["synced"] is False
|
||||
|
||||
|
||||
def test_automatic_wait_does_not_extend_past_sync_timeout(service):
|
||||
import json
|
||||
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||
"payload"
|
||||
]
|
||||
service.db.execute(
|
||||
"UPDATE operations SET updated_at=? WHERE id=?",
|
||||
(
|
||||
(
|
||||
datetime.now(UTC) - timedelta(seconds=service.settings.sync_timeout + 1)
|
||||
).isoformat(),
|
||||
first,
|
||||
),
|
||||
)
|
||||
second = service.submit(
|
||||
"display", json.loads(payload), "living-room", "scheduler", "fixture"
|
||||
)
|
||||
service.process_one()
|
||||
assert service.operation(second)["status"] == "SYNCING"
|
||||
|
||||
|
||||
def test_automatic_cleanup_waits_for_display_confirmation(service, monkeypatch):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
monkeypatch.setattr(service.cloud_library, "eligible", lambda *args: True)
|
||||
cleanup = service.submit(
|
||||
"delete",
|
||||
{"image_id": 1, "cloud_cleanup": True},
|
||||
"living-room",
|
||||
"cloud-library",
|
||||
"fixture-cleanup",
|
||||
)
|
||||
service.process_one()
|
||||
assert service.operation(cleanup)["status"] == "QUEUED"
|
||||
assert 1 in [x.image_id for x in service.registry.get("living-room").images()]
|
||||
service.sync()
|
||||
service.process_one()
|
||||
assert service.operation(first)["status"] == "COMPLETED"
|
||||
assert service.operation(cleanup)["status"] == "COMPLETED"
|
||||
assert 1 not in [x.image_id for x in service.registry.get("living-room").images()]
|
||||
|
||||
@@ -37,6 +37,8 @@ def due(service, image_id):
|
||||
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
||||
((datetime.now(UTC) - timedelta(days=1)).isoformat(), image_id),
|
||||
)
|
||||
# Complete the upload before simulating expiry days later and switching away.
|
||||
service.sync()
|
||||
# The test driver switches to a different image, not a real device.
|
||||
driver = service.registry.get("living-room")
|
||||
driver.display(1)
|
||||
|
||||
@@ -92,3 +92,53 @@ def test_release_rejects_wrong_storage_uuid(release_tool, monkeypatch):
|
||||
monkeypatch.setattr(release_tool, "run", lambda *args: "different-storage-uuid")
|
||||
with pytest.raises(AssertionError):
|
||||
release_tool.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", [None, "id", "commit", "version", "content"])
|
||||
def test_prebuilt_image_is_bound_to_source_before_deployment(
|
||||
release_tool, monkeypatch, invalid
|
||||
):
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
digest = "sha256:" + "a" * 64
|
||||
monkeypatch.setenv("FRAME_FLOW_PREBUILT_IMAGE_ID", digest)
|
||||
metadata = {
|
||||
"Id": digest,
|
||||
"Config": {
|
||||
"Labels": {
|
||||
"org.opencontainers.image.revision": "fixture-commit",
|
||||
"org.opencontainers.image.version": "0.1.22",
|
||||
}
|
||||
},
|
||||
}
|
||||
if invalid == "id":
|
||||
metadata["Id"] = "sha256:" + "b" * 64
|
||||
if invalid == "commit":
|
||||
metadata["Config"]["Labels"]["org.opencontainers.image.revision"] = "wrong"
|
||||
if invalid == "version":
|
||||
metadata["Config"]["Labels"]["org.opencontainers.image.version"] = "wrong"
|
||||
monkeypatch.setattr(release_tool, "run", lambda *args: json.dumps([metadata]))
|
||||
calls = []
|
||||
|
||||
def isolated(command, **kwargs):
|
||||
calls.append(command)
|
||||
assert "--network=none" in command and "--read-only" in command
|
||||
assert "--cap-drop=ALL" in command and "--user=10333:10333" in command
|
||||
assert "-v" not in command and "--mount" not in command
|
||||
assert json.loads(kwargs["input"]) == {"LICENSE": "fixture-hash"}
|
||||
if invalid == "content":
|
||||
raise subprocess.CalledProcessError(1, command)
|
||||
|
||||
monkeypatch.setattr(release_tool.subprocess, "run", isolated)
|
||||
if invalid:
|
||||
with pytest.raises((AssertionError, subprocess.CalledProcessError)):
|
||||
release_tool.verify_prebuilt_image(
|
||||
"fixture-image", "fixture-commit", "0.1.22", {"LICENSE": "fixture-hash"}
|
||||
)
|
||||
else:
|
||||
assert release_tool.verify_prebuilt_image(
|
||||
"fixture-image", "fixture-commit", "0.1.22", {"LICENSE": "fixture-hash"}
|
||||
)
|
||||
if invalid in ("id", "commit", "version"):
|
||||
assert not calls
|
||||
|
||||
Reference in New Issue
Block a user