Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a3974d08 | ||
|
|
4771db6f2a | ||
|
|
c3bed2edce | ||
|
|
f8fe8e8b14 | ||
|
|
50903564ad | ||
|
|
69ef87e8e9 | ||
|
|
09c6773d76 | ||
|
|
ecfa17607f | ||
|
|
a83591d2c8 | ||
|
|
beb0bb0eb8 | ||
|
|
cc3fd02eb7 | ||
|
|
3a5acaa72a | ||
|
|
8e66b6d741 | ||
|
|
acdf267e48 |
@@ -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.
|
||||
|
||||
@@ -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.23" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.27" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
USER 10333:10333
|
||||
EXPOSE 18787
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
|
||||
|
||||
# PIGWay Frame Flow
|
||||
|
||||
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
|
||||
|
||||
# PIGWay Frame Flow
|
||||
|
||||
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
|
||||
@@ -26,6 +28,8 @@ docker compose up -d
|
||||
|
||||
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||
|
||||
网页仅支持一个用户,不提供公开注册。密码为 8–256 位,支持密码管理器;使用带随机盐的 scrypt(N=16384、r=8、p=5)保存,旧哈希登录成功后自动升级。连续 5 次失败后按账号和来源 IP 限制登录 5 分钟。修改密码需验证当前密码,成功后撤销全部会话;写入请求校验 CSRF Token,登录拒绝跨来源请求。公网访问应通过 HTTPS,HTTPS 下会话 Cookie 自动启用 Secure。
|
||||
|
||||
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
|
||||
|
||||
使用 Python 3.14 运行本地 Mock 演示:
|
||||
@@ -99,3 +103,5 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
|
||||
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||
|
||||
[文档目录](docs/README.md)
|
||||
|
||||
历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# pigway-frame-flow v0.1.23
|
||||
# pigway-frame-flow v0.1.27
|
||||
|
||||
- 自动调度、自动重试和后台清理在同一设备已有图片等待同步时排队,避免 /upload 激活新图或删除操作更新云端计数,干扰尚未确认的目标。等待沿用现有同步超时;保留用户手动切换与优先级行为。
|
||||
- 在操作回执中保留最多 12 条设备读回状态及受限错误字段,记录实际图片、同步、在线和心跳,供定位未确认原因;不保存原始异常、URL 或凭据。采样不延长同步超时,原始上传证据保留。
|
||||
- 沿用安全基础镜像、完整构建和固定镜像 ID 的预构建验收流程;不重发不确定的设备写入。
|
||||
历史记录增加服务端分页、筛选和删除管理。
|
||||
|
||||
验证:601 项测试、Ruff、格式和 mypy 通过。历史设备物理刷新状态无法从缺失的原始采样追溯,不能将此版本描述为已修复所有网络或设备故障。
|
||||
- 支持每页 20 / 50 / 100 条及前后翻页,显示记录总数。
|
||||
- 支持图片名称、任务 ID、请求 ID、来源搜索,以及状态、操作、设备和上海时区日期筛选。
|
||||
- 支持当前页全选、反选、单条和批量删除;删除前确认,运行中任务不可删除。
|
||||
- 删除仅移除历史展示,保留图库来源、幂等处理及任务恢复所需的内部执行凭据,不删除图片或取消任务。
|
||||
- 同步 API 文档、契约快照及权限、CSRF、分页和删除回归验证。
|
||||
|
||||
保留既有单用户安全、设备控制及品牌布局。源码包包含 LICENSE、NOTICE 和第三方许可材料。
|
||||
|
||||
@@ -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 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
|
||||
|
||||
@@ -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
|
||||
c6fc984a1ca46cbd3e06de4591e41d21e4eb592416385d4000ade8fd570aad0c Dockerfile
|
||||
219c51489601f68ae24e90e5cb6f0b3c46ef68a6b1c21f9452b041d6a76a337c Dockerfile
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
||||
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
|
||||
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
|
||||
5ec5770a6e0202d981c5719de8cb09fefbd5d0a79b537d949b91ec67c8bce72b RELEASE_NOTES.md
|
||||
6a0c85c8d52a2d4a3b779dce306a93c4efc4dfe3ab3b23e4a6a3bb7c55079910 SECURITY.md
|
||||
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
|
||||
2da8a4418059ce61f7f0abfd4181338db0c741b4196dc658989388d142b5ee64 README.zh-CN.md
|
||||
4a81645fe7ee8ee335ee74262004e2c51e804e7282ee9ef17f7a80dfcb7baa84 RELEASE_NOTES.md
|
||||
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
||||
dff739157875c8a70235c4994df868afc6824e0d3861b2716ac28014bb38144f deploy/build-cn.env
|
||||
@@ -21,35 +21,40 @@ fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker-
|
||||
4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json
|
||||
f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml
|
||||
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
|
||||
e897b531b5ec4c2411fb11c4ea38808a6305e066782fe6112953f1cf63cef78f 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
|
||||
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
||||
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
|
||||
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
|
||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
|
||||
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||
1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json
|
||||
6921da6e05c08b5f1e3d9b3127095428ce8686d92dbd711121ea8b8640837dbb 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
|
||||
f28c2277b30d583fb7702e425ca99fe229c7e7bdc45b6142fc1bfe2e989440a6 pigway_frame_flow/api/app.py
|
||||
32e43ab71a3fbcc44925bd4e265892260cef07dd4bef3e319d9c4473d477ec4e pigway_frame_flow/api/app.py
|
||||
1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py
|
||||
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 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
|
||||
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||
6b09243081d53fb6c8a780d77e064f0685267c61e05f514262c8514ee3a36fda 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 +87,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
|
||||
c3fbe6af9d22989f1dc2fa980f64cf51672d7d45c28c5c8e88ba121a9709b578 pigway_frame_flow/models.py
|
||||
ab89887e6b4320d7668a0c554741e165f82c31da90a08d4ae708487d78894a97 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
|
||||
@@ -106,7 +111,7 @@ ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_f
|
||||
d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py
|
||||
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
|
||||
36cfc19b9583809f59a2276da3ba0f023a747013b6903d3a11fbb5941931681a pigway_frame_flow/storage/database.py
|
||||
2a288fe13d0c9b626ceaf1c825d079cf1e93f5c3abadc5907fc438211f3bfa46 pigway_frame_flow/storage/database.py
|
||||
44c0c47d852ffd80cb05969965ee216479c1047daffabf9c3bdc8fe2b036db12 pigway_frame_flow/templates/calendar.json
|
||||
1b01fcda4b1cba11c3b152dfc2223d4119f1879ddd0b7eaf6a2e2731a6f6bf6d pigway_frame_flow/templates/collage.json
|
||||
7bfe623e0569beae25b44261d949679c95d42c8bd095ef6b5cca25cf1b0d4c40 pigway_frame_flow/templates/daily-dashboard.json
|
||||
@@ -116,29 +121,33 @@ 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
|
||||
26294166cdf50cd41885b5e71d1314b2e885b212e1efe7496d401d424b2beda4 pigway_frame_flow/web/app.js
|
||||
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
|
||||
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
|
||||
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
|
||||
2086605c67bb818b5cabae20ea4d9ccecb17e350c59cdb0d0f2ba1b70f7c8601 pigway_frame_flow/web/index.html
|
||||
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
||||
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
|
||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
|
||||
f58b5c088c4a4d55b332c4fd52dc8e2fe436b7bcc607870e06bf4d6d6f497850 pigway_frame_flow/web/features.js
|
||||
44641bdc3d7735625cd9a37dfa6597825030e0f8896a68ead4a2f434c43dced7 pigway_frame_flow/web/i18n.js
|
||||
51796b4db73773cea7e451ea21d3b493fcfaf340945d44a696b5d484a5222ea5 pigway_frame_flow/web/index.html
|
||||
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
|
||||
4eca31e33a3d14f1e25d61655715dd4a4789adceb4fb78247a12e041071b6810 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
|
||||
97aaf8a332dcf472b8933b0a39aa329865133171e828892d932d29694e011ea5 pyproject.toml
|
||||
24d1e0233bc9107f290543b1fccf74455697bbc9f6eafa557d559ea0ea044548 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
|
||||
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
|
||||
a11e660a0343f2631c1a3dd0aa28471db827fb1f43d1af4217f51209aa3afe32 scripts/create-account.py
|
||||
eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
|
||||
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
|
||||
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
|
||||
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
|
||||
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
|
||||
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
|
||||
68fc95bb91e5529135a669a33450120465109222a4ce9bd7c3e2842c814626f5 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
|
||||
@@ -148,8 +157,8 @@ b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/produc
|
||||
f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gallery-current.cjs
|
||||
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
|
||||
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
|
||||
02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
|
||||
cb0ccb04e087e2117f2a2e7ebe7f1a6b116e39e2bfc197ea996675ef628c98ae scripts/ui-history-localization.cjs
|
||||
8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs
|
||||
86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 scripts/ui-history-localization.cjs
|
||||
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
|
||||
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
|
||||
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
|
||||
@@ -169,11 +178,11 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro
|
||||
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
|
||||
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
||||
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
|
||||
c44080785bb2cca54bb17ac9e25f2e91a36839688bc70e40faecaea42d75e9f8 tests/schema-contract.json
|
||||
14a7c56c40ea4b520585c20341b3ec70fa3ba52518144a10ba3494ceceb30758 tests/test_accounts.py
|
||||
19621a4c9dc5f060741504044710f2314d523530ae158dcf6fa3487ce2945e47 tests/contract.json
|
||||
c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json
|
||||
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
|
||||
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||
94c01e6d8bdbfffbff72edb91823eaf39a325ad8782aba1d8e2804402e256aec tests/test_api.py
|
||||
a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py
|
||||
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
|
||||
7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py
|
||||
f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py
|
||||
@@ -186,6 +195,8 @@ 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
|
||||
1f548f3ac8023da49c3cfd1e61ed986d949229e67e5b34b5e4947852ce3b5ca4 tests/test_history_management.py
|
||||
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
|
||||
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
|
||||
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# PIGWAY 品牌资源 / Brand assets
|
||||
|
||||
来源:PIGWAY Brand Production v1.1 正式交付包。图形、颜色与比例保持原文件,不使用重绘、滤镜或替代字标。
|
||||
|
||||
- `pigway-logo.svg`:正式 Web 图形标。
|
||||
- `pigway-logo.png`:正式 256px 透明图形标,用于项目介绍页。
|
||||
- `pigway-avatar.png`:正式 512px 平台图标,用于 Tea 项目头像。
|
||||
|
||||
本项目的产品名称保持独立。品牌物料版本不跟随软件版本变化;网页小图标使用同一正式包的 Favicon 和 Apple Touch Icon。
|
||||
|
||||
## SHA-256
|
||||
|
||||
- `0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210` `pigway-logo.svg`
|
||||
- `da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7` `pigway-logo.png`
|
||||
- `f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3` `pigway-avatar.png`
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "PIGWay Frame Flow API",
|
||||
"version": "0.1.23"
|
||||
"version": "0.1.27"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -14118,6 +14118,398 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/operation-history": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Operations"
|
||||
],
|
||||
"summary": "List History",
|
||||
"operationId": "list_history_api_v1_operation_history_get",
|
||||
"security": [
|
||||
{
|
||||
"HTTPBearer": []
|
||||
},
|
||||
{
|
||||
"AccountSession": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1,
|
||||
"title": "Page"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page_size",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"default": 20,
|
||||
"title": "Page Size"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/OperationStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Status"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "action",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 80,
|
||||
"default": "",
|
||||
"title": "Action"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "device_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 120,
|
||||
"default": "",
|
||||
"title": "Device Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 200,
|
||||
"default": "",
|
||||
"title": "Q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Start"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "End"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HistoryPage"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Conflict"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Content Too Large"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Content"
|
||||
},
|
||||
"429": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Too Many Requests"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"502": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Gateway"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Operations"
|
||||
],
|
||||
"summary": "Delete History",
|
||||
"operationId": "delete_history_api_v1_operation_history_delete",
|
||||
"security": [
|
||||
{
|
||||
"HTTPBearer": []
|
||||
},
|
||||
{
|
||||
"AccountSession": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HistoryDelete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Conflict"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Content Too Large"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Content"
|
||||
},
|
||||
"429": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Too Many Requests"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"502": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Gateway"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -17733,6 +18125,55 @@
|
||||
"catalog"
|
||||
],
|
||||
"title": "WidgetState"
|
||||
},
|
||||
"HistoryPage": {
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Operation"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Items"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"title": "Total"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"title": "Page"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer",
|
||||
"title": "Page Size"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items",
|
||||
"total",
|
||||
"page",
|
||||
"page_size"
|
||||
],
|
||||
"title": "HistoryPage"
|
||||
},
|
||||
"HistoryDelete": {
|
||||
"properties": {
|
||||
"operation_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"maxItems": 200,
|
||||
"minItems": 1,
|
||||
"title": "Operation Ids"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"operation_ids"
|
||||
],
|
||||
"title": "HistoryDelete"
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from pigway_frame_flow.api.history import install_history
|
||||
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
|
||||
from pigway_frame_flow.core.auth import require, seed_client
|
||||
from pigway_frame_flow.core.registry import DeviceRegistry
|
||||
@@ -126,12 +127,13 @@ def create_app(settings=None, start_worker=True):
|
||||
app = FastAPI(
|
||||
title="PIGWay Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.23",
|
||||
version="0.1.27",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
install_extensions(app, service)
|
||||
install_accounts(app, db)
|
||||
install_history(app, service)
|
||||
app.state.service = service
|
||||
app.state.worker_enabled = start_worker
|
||||
app.state.scheduler = scheduler
|
||||
@@ -842,7 +844,7 @@ def create_app(settings=None, start_worker=True):
|
||||
return [
|
||||
service.operation(x["id"])
|
||||
for x in db.all(
|
||||
"SELECT id FROM operations WHERE (? OR client_id=?) ORDER BY created_at DESC LIMIT ?",
|
||||
"SELECT id FROM operations WHERE (? OR client_id=?) AND NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=operations.id) ORDER BY created_at DESC,rowid DESC LIMIT ?",
|
||||
("admin" in request.state.permissions, request.state.client_id, limit),
|
||||
)
|
||||
]
|
||||
@@ -1001,7 +1003,10 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("history:read"))],
|
||||
)
|
||||
def history(limit: int = Query(default=100, ge=1, le=200)):
|
||||
return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
|
||||
return db.all(
|
||||
"SELECT * FROM history WHERE NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=history.operation_id) ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings",
|
||||
@@ -1040,7 +1045,7 @@ def create_app(settings=None, start_worker=True):
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.23",
|
||||
version="0.1.27",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
@@ -1313,6 +1318,10 @@ def create_app(settings=None, start_worker=True):
|
||||
"i18n.js",
|
||||
"swagger-ui-bundle.js",
|
||||
"swagger-ui.css",
|
||||
"favicon.svg",
|
||||
"favicon.ico",
|
||||
"apple-touch-icon.png",
|
||||
"logo-symbol.svg",
|
||||
]:
|
||||
raise FlowError(E.NOT_FOUND, "Resource not found", 404)
|
||||
return FileResponse(root / filename)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Paginated history without removing operation receipts used by other services."""
|
||||
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import Depends, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import Operation, OperationStatus
|
||||
|
||||
|
||||
class HistoryPage(BaseModel):
|
||||
items: list[Operation]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class HistoryDelete(BaseModel):
|
||||
operation_ids: list[str] = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
def install_history(app, service):
|
||||
db = service.db
|
||||
|
||||
@app.get(
|
||||
"/api/v1/operation-history",
|
||||
response_model=HistoryPage,
|
||||
tags=["Operations"],
|
||||
dependencies=[Depends(require("history:read"))],
|
||||
)
|
||||
def list_history(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: OperationStatus | None = None,
|
||||
action: str = Query("", max_length=80),
|
||||
device_id: str = Query("", max_length=120),
|
||||
q: str = Query("", max_length=200),
|
||||
start: date | None = None,
|
||||
end: date | None = None,
|
||||
):
|
||||
if start and end and start > end:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Start date must not follow end date", 422
|
||||
)
|
||||
clauses = [
|
||||
"NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=o.id)"
|
||||
]
|
||||
args: list = []
|
||||
if "admin" not in request.state.permissions:
|
||||
clauses.append("o.client_id=?")
|
||||
args.append(request.state.client_id)
|
||||
for column, value in [
|
||||
("status", status),
|
||||
("action", action),
|
||||
("device_id", device_id),
|
||||
]:
|
||||
if value:
|
||||
clauses.append(f"o.{column}=?")
|
||||
args.append(value)
|
||||
if q.strip():
|
||||
clauses.append(
|
||||
"instr(lower(o.id||' '||o.request_id||' '||o.client_id||' '||coalesce(o.result,'')),lower(?))>0"
|
||||
)
|
||||
args.append(q.strip())
|
||||
for day, operator, offset in [(start, ">=", 0), (end, "<", 1)]:
|
||||
if day:
|
||||
boundary = datetime.combine(
|
||||
day, time(), ZoneInfo("Asia/Shanghai")
|
||||
) + timedelta(days=offset)
|
||||
clauses.append(f"o.created_at{operator}?")
|
||||
args.append(boundary.astimezone(UTC).isoformat())
|
||||
where = " AND ".join(clauses)
|
||||
# Keep count and rows consistent if a worker finishes or a deletion occurs.
|
||||
with db.lock:
|
||||
total = db.one(
|
||||
"SELECT count(*) AS total FROM operations o WHERE " + where, args
|
||||
)["total"]
|
||||
page = min(page, max(1, (total + page_size - 1) // page_size))
|
||||
rows = db.all(
|
||||
"SELECT o.id FROM operations o WHERE "
|
||||
+ where
|
||||
+ " ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?",
|
||||
[*args, page_size, (page - 1) * page_size],
|
||||
)
|
||||
return HistoryPage(
|
||||
items=[service.operation(row["id"]) for row in rows],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/operation-history",
|
||||
tags=["Operations"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_history(payload: HistoryDelete):
|
||||
ids = list(dict.fromkeys(payload.operation_ids))
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with db.transaction() as connection:
|
||||
rows = connection.execute(
|
||||
f"SELECT id,status FROM operations WHERE id IN ({placeholders})", ids
|
||||
).fetchall()
|
||||
if len(rows) != len(ids):
|
||||
raise FlowError(E.NOT_FOUND, "History record not found", 404)
|
||||
if any(
|
||||
row["status"] not in {"COMPLETED", "FAILED", "UNKNOWN"} for row in rows
|
||||
):
|
||||
raise FlowError(E.CONFLICT, "Running operations cannot be deleted", 409)
|
||||
before = connection.total_changes
|
||||
connection.executemany(
|
||||
"INSERT OR IGNORE INTO hidden_history(operation_id) VALUES(?)",
|
||||
[(key,) for key in ids],
|
||||
)
|
||||
deleted = connection.total_changes - before
|
||||
return {"deleted": deleted}
|
||||
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
|
||||
def password_hash(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5
|
||||
).hex()
|
||||
return f"scrypt${salt}${digest}"
|
||||
return f"scrypt-v2${salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
_, salt, expected = encoded.split("$")
|
||||
algorithm, salt, expected = encoded.split("$")
|
||||
if algorithm not in {"scrypt", "scrypt-v2"}:
|
||||
return False
|
||||
actual = hashlib.scrypt(
|
||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
||||
password.encode(),
|
||||
salt=bytes.fromhex(salt),
|
||||
n=16384,
|
||||
r=8,
|
||||
p=5 if algorithm == "scrypt-v2" else 1,
|
||||
).hex()
|
||||
return hmac.compare_digest(actual, expected)
|
||||
except (ValueError, TypeError):
|
||||
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
|
||||
)
|
||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS single_browser_account ON users((1))")
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
|
||||
)
|
||||
@@ -65,7 +72,13 @@ def seed_accounts(db, config_dir: Path):
|
||||
)
|
||||
path = config_dir / "accounts.json"
|
||||
if path.exists():
|
||||
for user in json.loads(path.read_text()).get("users", []):
|
||||
users = json.loads(path.read_text()).get("users", [])
|
||||
if len(users) > 1:
|
||||
raise ValueError("Only one browser account is supported")
|
||||
for user in users:
|
||||
existing = db.one("SELECT username FROM users LIMIT 1")
|
||||
if existing and existing["username"] != user["username"]:
|
||||
raise ValueError("A browser account already exists")
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO users VALUES(?,?,1)",
|
||||
(user["username"], user["password_hash"]),
|
||||
@@ -83,7 +96,7 @@ def session_user(request: Request):
|
||||
)
|
||||
if not row or not row["enabled"] or row["expires_at"] <= time.time():
|
||||
return None
|
||||
if request.method in {"POST", "PUT", "DELETE"}:
|
||||
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||
if not hmac.compare_digest(
|
||||
request.headers.get("X-CSRF-Token", ""), row["csrf"]
|
||||
):
|
||||
@@ -94,60 +107,88 @@ def session_user(request: Request):
|
||||
def install_accounts(app, db):
|
||||
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
|
||||
def login(payload: Login, request: Request, response: Response):
|
||||
identity = hashlib.sha256(
|
||||
(request.client.host if request.client else "unknown").encode()
|
||||
).hexdigest()
|
||||
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,))
|
||||
if attempt and attempt["blocked_until"] > time.time():
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||
with db.lock:
|
||||
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
|
||||
origin = request.headers.get("origin")
|
||||
if request.headers.get("sec-fetch-site") == "cross-site" or (
|
||||
origin and origin != str(request.base_url).rstrip("/")
|
||||
):
|
||||
raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
|
||||
identity = hashlib.sha256(
|
||||
(request.client.host if request.client else "unknown").encode()
|
||||
).hexdigest()
|
||||
# Account throttling also applies when an attacker changes source IPs.
|
||||
identities = (
|
||||
identity,
|
||||
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
|
||||
)
|
||||
row = db.one(
|
||||
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,)
|
||||
)
|
||||
# Perform a password calculation even for unknown usernames.
|
||||
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64
|
||||
valid = verify_password(
|
||||
payload.password, row["password_hash"] if row else fallback
|
||||
)
|
||||
if not row or not valid:
|
||||
failures = (
|
||||
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0
|
||||
) + 1
|
||||
attempts = [
|
||||
db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
|
||||
for key in identities
|
||||
]
|
||||
attempt = next(
|
||||
(a for a in attempts if a and a["blocked_until"] > time.time()), None
|
||||
)
|
||||
if attempt and attempt["blocked_until"] > time.time():
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||
)
|
||||
row = db.one(
|
||||
"SELECT * FROM users WHERE username=? AND enabled=1",
|
||||
(payload.username,),
|
||||
)
|
||||
# Perform a password calculation even for unknown usernames.
|
||||
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
|
||||
valid = verify_password(
|
||||
payload.password, row["password_hash"] if row else fallback
|
||||
)
|
||||
if not row or not valid:
|
||||
for key, previous in zip(identities, attempts, strict=True):
|
||||
failures = (
|
||||
previous["failures"]
|
||||
if previous and previous["blocked_until"] == 0
|
||||
else 0
|
||||
) + 1
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
||||
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
||||
)
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
for key in identities:
|
||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
|
||||
if row["password_hash"].startswith("scrypt$"):
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.password), row["username"]),
|
||||
)
|
||||
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
||||
old = request.cookies.get(COOKIE)
|
||||
if old:
|
||||
db.execute(
|
||||
"DELETE FROM sessions WHERE token_hash=?",
|
||||
(hashlib.sha256(old.encode()).hexdigest(),),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
||||
(identity, failures, time.time() + 300 if failures >= 5 else 0),
|
||||
"INSERT INTO sessions VALUES(?,?,?,?)",
|
||||
(
|
||||
hashlib.sha256(raw.encode()).hexdigest(),
|
||||
row["username"],
|
||||
csrf,
|
||||
time.time() + lifetime,
|
||||
),
|
||||
)
|
||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,))
|
||||
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
||||
old = request.cookies.get(COOKIE)
|
||||
if old:
|
||||
db.execute(
|
||||
"DELETE FROM sessions WHERE token_hash=?",
|
||||
(hashlib.sha256(old.encode()).hexdigest(),),
|
||||
response.set_cookie(
|
||||
COOKIE,
|
||||
raw,
|
||||
max_age=lifetime if payload.remember else None,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=request.url.scheme == "https",
|
||||
path="/",
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sessions VALUES(?,?,?,?)",
|
||||
(
|
||||
hashlib.sha256(raw.encode()).hexdigest(),
|
||||
row["username"],
|
||||
csrf,
|
||||
time.time() + lifetime,
|
||||
),
|
||||
)
|
||||
response.set_cookie(
|
||||
COOKIE,
|
||||
raw,
|
||||
max_age=lifetime if payload.remember else None,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=request.url.scheme == "https",
|
||||
path="/",
|
||||
)
|
||||
return AccountView(username=row["username"], csrf_token=csrf)
|
||||
return AccountView(username=row["username"], csrf_token=csrf)
|
||||
|
||||
def current(request):
|
||||
row = session_user(request)
|
||||
@@ -187,9 +228,12 @@ def install_accounts(app, db):
|
||||
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
|
||||
if not verify_password(payload.current_password, user["password_hash"]):
|
||||
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.new_password), session["username"]),
|
||||
)
|
||||
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],))
|
||||
with db.transaction() as connection:
|
||||
connection.execute(
|
||||
"UPDATE users SET password_hash=? WHERE username=?",
|
||||
(password_hash(payload.new_password), session["username"]),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM sessions WHERE username=?", (session["username"],)
|
||||
)
|
||||
response.delete_cookie(COOKIE, path="/")
|
||||
|
||||
@@ -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,6 +199,9 @@ 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", "sync_observations"):
|
||||
|
||||
@@ -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.23"
|
||||
version: str = "0.1.27"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -397,7 +399,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "PIGWay Frame Flow"
|
||||
version: str = "0.1.23"
|
||||
version: str = "0.1.27"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -45,6 +45,7 @@ class Database:
|
||||
last_run TEXT, last_result TEXT);
|
||||
CREATE TABLE IF NOT EXISTS job_runs (job_id TEXT, scheduled_at TEXT,
|
||||
operation_id TEXT, PRIMARY KEY(job_id,scheduled_at));
|
||||
CREATE TABLE IF NOT EXISTS hidden_history (operation_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, client_id TEXT,
|
||||
request_id TEXT, operation_id TEXT, device_id TEXT, action TEXT,
|
||||
result TEXT, timestamp TEXT);
|
||||
|
||||
@@ -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();}
|
||||
@@ -118,7 +126,7 @@ async function templateEditor(selected){
|
||||
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
||||
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
||||
}
|
||||
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
||||
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><div class="field"><label for="confirm-password">${t('confirmPassword')}</label><input id="confirm-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);const confirm=$('#confirm-password');const validate=()=>confirm.setCustomValidity(confirm.value!==$('#new-password').value?t('passwordMismatch'):'');confirm.oninput=validate;$('#new-password').oninput=validate;form.onsubmit=busy(async e=>{e.preventDefault();validate();if(!form.reportValidity())return;await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
||||
const views={
|
||||
studio:()=>layoutStudioPage(),
|
||||
connections:()=>connectionsPage(),
|
||||
@@ -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 data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#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};}
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -45,7 +45,8 @@ 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);});});}
|
||||
historyControls();
|
||||
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);const op=state.historyPageData?.items.find(x=>x.operation_id===operationId);if(op&&['COMPLETED','FAILED','UNKNOWN'].includes(op.status)){const remove=document.createElement('button');remove.textContent=t('delete');remove.className='danger';remove.onclick=()=>deleteHistory([operationId]);actions.append(remove);}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);});}});
|
||||
}
|
||||
@@ -166,3 +167,25 @@ async function previewSource(id,item_id=null){
|
||||
async function sourceJobForm(id){
|
||||
await jobEditor({name:t('randomPhoto'),device_id:state.device,enabled:false,schedule:{type:'interval',interval_minutes:120},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50});
|
||||
}
|
||||
|
||||
function historyQuery(){return new URLSearchParams({...state.historyFilters,page:state.historyPage||1,page_size:state.historyPageSize||20});}
|
||||
function historyControls(){
|
||||
const data=state.historyPageData;if(!data)return;
|
||||
const filters=state.historyFilters||{};
|
||||
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
|
||||
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
|
||||
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
|
||||
const wrap=$('#main .table-wrap');wrap.before(form);
|
||||
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
|
||||
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
|
||||
$('#history-end').oninput=()=>$('#history-end').setCustomValidity('');$('#history-start').oninput=()=>$('#history-end').setCustomValidity('');
|
||||
bind('history-reset',async()=>{state.historyFilters={};state.historyPage=1;await navigate();});
|
||||
const bar=document.createElement('div');bar.className='actions history-toolbar';bar.innerHTML=`<button id="history-all">${t('selectAll')}</button><button id="history-invert">${t('invertSelection')}</button><button id="history-delete" class="danger" disabled>${t('deleteSelected')}</button><span id="history-selected" class="hint" role="status"></span>`;wrap.before(bar);
|
||||
const pager=document.createElement('div');pager.className='actions history-pagination';const pages=Math.max(1,Math.ceil(data.total/data.page_size));pager.innerHTML=`<span>${t('historyTotal').replace('{count}',data.total)} · ${data.page} / ${pages}</span><button id="history-prev" ${data.page<=1?'disabled':''}>${t('previousPage')}</button><button id="history-next" ${data.page>=pages?'disabled':''}>${t('nextPage')}</button><label for="history-page-size">${t('pageSize')}</label><select id="history-page-size">${[20,50,100].map(n=>`<option value="${n}" ${n===data.page_size?'selected':''}>${n}</option>`).join('')}</select>`;wrap.after(pager);
|
||||
bind('history-prev',async()=>{state.historyPage=data.page-1;await navigate();});bind('history-next',async()=>{state.historyPage=data.page+1;await navigate();});$('#history-page-size').onchange=busy(async()=>{state.historyPageSize=Number($('#history-page-size').value);state.historyPage=1;await navigate();});
|
||||
const checkboxes=()=>[...document.querySelectorAll('.history-select:not(:disabled)')];
|
||||
const update=()=>{const selected=checkboxes().filter(x=>x.checked);$('#history-delete').disabled=!selected.length;$('#history-selected').textContent=t('historySelected').replace('{count}',selected.length);};
|
||||
for(const op of data.items){const row=[...wrap.querySelectorAll('tbody tr')].find(r=>r.dataset.operationId===op.operation_id);if(!row)continue;const box=document.createElement('input');box.type='checkbox';box.className='history-select';box.value=op.operation_id;box.setAttribute('aria-label',t('selectHistory')+' '+op.operation_id);box.disabled=!['COMPLETED','FAILED','UNKNOWN'].includes(op.status);box.onchange=update;row.cells[0].prepend(box);}
|
||||
bind('history-all',()=>{const boxes=checkboxes(),checked=boxes.length>0&&boxes.every(x=>x.checked);boxes.forEach(x=>x.checked=!checked);update();});bind('history-invert',()=>{checkboxes().forEach(x=>x.checked=!x.checked);update();});bind('history-delete',()=>deleteHistory(checkboxes().filter(x=>x.checked).map(x=>x.value)));update();
|
||||
}
|
||||
async function deleteHistory(ids){await confirmDialog(t('deleteHistoryConfirm').replace('{count}',ids.length),async()=>{await api('/api/v1/operation-history',{method:'DELETE',body:{operation_ids:ids}});await navigate();});}
|
||||
|
||||
@@ -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.23</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.23"></script><script src="/ui/features.js?v=0.1.23"></script><script src="/ui/extensions.js?v=0.1.23"></script><script src="/ui/app.js?v=0.1.23"></script></body></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="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.27</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></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.27"></script><script src="/ui/features.js?v=0.1.27"></script><script src="/ui/extensions.js?v=0.1.27"></script><script src="/ui/app.js?v=0.1.27"></script></body></html>
|
||||
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -49,7 +49,7 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
|
||||
|
||||
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
|
||||
|
||||
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:top;white-space:nowrap}.history-actions button{white-space:nowrap}
|
||||
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:180px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:190px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
|
||||
|
||||
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
|
||||
|
||||
@@ -187,7 +187,10 @@ body.login-mode header,body.login-mode footer{flex-shrink:0}
|
||||
.history-status{color:var(--accent)}
|
||||
.history-status.bad{color:var(--bad)}
|
||||
|
||||
.settings-columns{align-items:stretch}
|
||||
.settings-columns{align-items:stretch;grid-template-columns:minmax(0,2fr) minmax(240px,1fr)}
|
||||
.password-fields{grid-template-columns:repeat(3,minmax(0,1fr));max-width:780px;align-items:end}
|
||||
@media(max-width:1050px){.settings-columns{grid-template-columns:minmax(0,1fr)}}
|
||||
@media(max-width:720px){.password-fields{grid-template-columns:minmax(0,1fr)}}
|
||||
.settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0}
|
||||
#password-form>button{align-self:flex-start}
|
||||
form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end}
|
||||
@@ -224,6 +227,23 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
|
||||
.automation-table{min-width:1200px}
|
||||
.automation-table td:is(:nth-child(2),:nth-child(5),:nth-child(6)){white-space:nowrap}
|
||||
.automation-table .row-actions{flex-wrap:nowrap}
|
||||
.history-table td:is(:nth-child(3),:nth-child(4),:nth-child(5)),.history-table .history-actions{vertical-align:middle}
|
||||
.history-table td{vertical-align:middle}
|
||||
|
||||
.image-card h3.source-path{white-space:normal;overflow-wrap:anywhere}
|
||||
|
||||
.pigway-logo{display:block;width:56px;height:56px;padding:6px;flex:0 0 56px;object-fit:contain}
|
||||
.brand>span{min-width:0}.workspace-heading{display:flex;align-items:center;gap:12px}.workspace-heading>div{min-width:0}.mobile-brand-logo{display:none}
|
||||
@media(max-width:720px){.mobile-brand-logo{display:block}}
|
||||
|
||||
/* Stacked wordmark keeps the product name readable within the sidebar. */
|
||||
.sidebar .brand{font-size:26px;font-weight:800;line-height:1.15;letter-spacing:1px}
|
||||
.sidebar .brand small{margin-top:6px;font-size:12px;font-weight:700;letter-spacing:4px;line-height:1.4;white-space:nowrap}
|
||||
|
||||
form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
|
||||
#history-filters .field{min-width:0;margin:0}
|
||||
#history-filters>.actions{grid-column:1/-1}
|
||||
.history-select{margin:0 10px 0 0;vertical-align:middle}
|
||||
.history-actions button+button{margin-left:10px}
|
||||
.history-pagination select{width:auto}
|
||||
@media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.23"
|
||||
version = "0.1.27"
|
||||
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}}'
|
||||
|
||||
@@ -19,8 +19,8 @@ if len(password) < 8 or password != getpass.getpass("Confirm password: "):
|
||||
args.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = args.config_dir / "accounts.json"
|
||||
users = json.loads(path.read_text()).get("users", []) if path.exists() else []
|
||||
if any(u["username"] == args.username for u in users):
|
||||
sys.exit("Bootstrap user already exists. Change the password in Settings.")
|
||||
if users:
|
||||
sys.exit("Single-user account already exists. Change the password in Settings.")
|
||||
users.append({"username": args.username, "password_hash": password_hash(password)})
|
||||
path.write_text(json.dumps({"users": users}) + "\n")
|
||||
path.chmod(0o600)
|
||||
|
||||
@@ -219,6 +219,14 @@ def release(lock):
|
||||
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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Mock history fixtures only; verifies long identifiers and detail actions. */
|
||||
const assert=require('assert'),{chromium}=require('playwright');
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const op={operation_id:'a'.repeat(64),request_id:'b'.repeat(64),action:'render',device_id:'living-room',client_id:'user:way',created_at:'2026-10-05T00:28:05Z',status:'COMPLETED',result:{asset_id:'c'.repeat(64)}};await page.route('**/api/v1/operations',r=>r.fulfill({json:[op,op]}));await page.route('**/api/v1/operations/'+op.operation_id,r=>r.fulfill({json:op}));await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();let checks=0;for(const lang of ['zh','en'])for(const width of [1440,768,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="history"]').click();await page.locator('.history-actions button').first().waitFor();assert(await page.evaluate(()=>{const table=document.querySelector('.history-table');return table.querySelectorAll('th').length===7&&[...table.querySelectorAll('tbody tr')].every(row=>row.children.length===7&&row.lastElementChild.classList.contains('history-actions')&&row.lastElementChild.previousElementSibling.querySelector('button')===null)&&document.documentElement.scrollWidth<=innerWidth;}));await page.locator('.history-actions button').first().click();await page.locator('#modal[open]').waitFor();await page.locator('#modal-cancel').click();checks++;}await browser.close();console.log('History layout and detail dialog: '+checks+' passed');})().catch(e=>{console.error(e);process.exit(1)});
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const op={operation_id:'a'.repeat(64),request_id:'b'.repeat(64),action:'render',device_id:'living-room',client_id:'user:way',created_at:'2026-10-05T00:28:05Z',status:'COMPLETED',result:{asset_id:'c'.repeat(64)}};await page.route('**/api/v1/operation-history?*',r=>r.fulfill({json:{items:[op,{...op,operation_id:'d'.repeat(64)}],total:2,page:1,page_size:20}}));await page.route('**/api/v1/operations/'+op.operation_id,r=>r.fulfill({json:op}));await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();let checks=0;for(const lang of ['zh','en'])for(const width of [1440,768,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="history"]').click();await page.locator('.history-actions button').first().waitFor();assert(await page.evaluate(()=>{const table=document.querySelector('.history-table');return table.querySelectorAll('th').length===7&&[...table.querySelectorAll('tbody tr')].every(row=>row.children.length===7&&row.lastElementChild.classList.contains('history-actions')&&row.lastElementChild.previousElementSibling.querySelector('button')===null)&&document.documentElement.scrollWidth<=innerWidth;}));await page.locator('.history-actions button').first().click();await page.locator('#modal[open]').waitFor();await page.locator('#modal-cancel').click();checks++;}await browser.close();console.log('History layout and detail dialog: '+checks+' passed');})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -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:{items:[op],total:1,page:1,page_size:20};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').first().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)});
|
||||
|
||||
@@ -1448,5 +1448,35 @@
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/operation-history": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"delete": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -141,3 +141,104 @@ def test_browser_admin_can_read_other_clients_operation(client, app, service):
|
||||
assert operation_id in [
|
||||
op["operation_id"] for op in client.get("/api/v1/operations").json()
|
||||
]
|
||||
|
||||
|
||||
def test_password_minimum_and_https_cookie(client, app):
|
||||
response = login(client, app)
|
||||
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/auth/password",
|
||||
json={"current_password": "fixture-password", "new_password": "1234567"},
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.put(
|
||||
"/api/v1/auth/password",
|
||||
json={"current_password": "fixture-password", "new_password": "12345678"},
|
||||
).status_code
|
||||
== 204
|
||||
)
|
||||
response = client.post(
|
||||
"https://testserver/api/v1/auth/login",
|
||||
json={"username": "test-user", "password": "12345678"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Secure" in response.headers["set-cookie"]
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_login_rejects_cross_origin(client, app):
|
||||
account(app)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
headers={"Origin": "https://evil.example"},
|
||||
json={"username": "test-user", "password": "fixture-password"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_account_throttle_across_source_addresses(client, app):
|
||||
account(app)
|
||||
for _ in range(5):
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "test-user", "password": "wrong"},
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
app.state.service.db.execute(
|
||||
"DELETE FROM login_attempts WHERE identity NOT LIKE 'account:%'"
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "test-user", "password": "fixture-password"},
|
||||
).status_code
|
||||
== 429
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_hash_upgrade(client, app):
|
||||
import hmac
|
||||
|
||||
from pigway_frame_flow.core.accounts import verify_password
|
||||
|
||||
salt = "12" * 16
|
||||
digest = hashlib.scrypt(
|
||||
b"fixture-password", salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
||||
).hex()
|
||||
legacy = f"scrypt${salt}${digest}"
|
||||
app.state.service.db.execute(
|
||||
"INSERT INTO users VALUES(?,?,1)", ("test-user", legacy)
|
||||
)
|
||||
assert verify_password("fixture-password", legacy)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "test-user", "password": "fixture-password"},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
stored = app.state.service.db.one("SELECT password_hash FROM users")[
|
||||
"password_hash"
|
||||
]
|
||||
assert stored.startswith("scrypt-v2$") and not hmac.compare_digest(stored, legacy)
|
||||
assert verify_password("fixture-password", stored)
|
||||
|
||||
|
||||
def test_only_one_browser_account(app):
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
account(app)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
app.state.service.db.execute(
|
||||
"INSERT INTO users VALUES(?,?,1)",
|
||||
("other", password_hash("another-password")),
|
||||
)
|
||||
|
||||
@@ -291,6 +291,28 @@ def test_core_has_no_client_special_case():
|
||||
)
|
||||
|
||||
|
||||
def test_official_brand_assets(client):
|
||||
html = client.get("/").text
|
||||
assert "/ui/favicon.svg" in html
|
||||
assert "/ui/apple-touch-icon.png" in html
|
||||
assert 'class="mobile-brand-logo pigway-logo"' in html
|
||||
for filename, content_type in [
|
||||
("logo-symbol.svg", "image/svg+xml"),
|
||||
("favicon.svg", "image/svg+xml"),
|
||||
("favicon.ico", "image/vnd.microsoft.icon"),
|
||||
("apple-touch-icon.png", "image/png"),
|
||||
]:
|
||||
response = client.get("/ui/" + filename)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].split(";")[0] in (
|
||||
content_type,
|
||||
"image/x-icon",
|
||||
)
|
||||
assert (
|
||||
response.content == (Path("pigway_frame_flow/web") / filename).read_bytes()
|
||||
)
|
||||
|
||||
|
||||
def test_ui_languages(client):
|
||||
assert client.get("/ui/app.js").status_code == 200
|
||||
i18n = client.get("/ui/i18n.js").text
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,151 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from pigway_frame_flow.core.accounts import password_hash
|
||||
|
||||
|
||||
def make_op(service, n, status="COMPLETED", client_id="admin", action="render"):
|
||||
key = service.submit(action, {}, "living-room", client_id, f"request-{n}")
|
||||
service.update(key, status)
|
||||
service.db.execute(
|
||||
"UPDATE operations SET created_at=?,result=? WHERE id=?",
|
||||
(
|
||||
f"2026-10-{n:02d}T10:00:00+00:00",
|
||||
json.dumps({"image_name": f"photo-{n}.jpg"}),
|
||||
key,
|
||||
),
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def test_pagination_filters_and_page_clamp(client, service):
|
||||
ids = [make_op(service, n, "FAILED" if n % 2 else "COMPLETED") for n in range(1, 7)]
|
||||
page = client.get("/api/v1/operation-history?page_size=2").json()
|
||||
assert page["total"] == 6
|
||||
assert [x["operation_id"] for x in page["items"]] == ids[-1:-3:-1]
|
||||
assert (
|
||||
client.get("/api/v1/operation-history?page=2&page_size=2").json()["items"][0][
|
||||
"operation_id"
|
||||
]
|
||||
== ids[3]
|
||||
)
|
||||
assert (
|
||||
client.get("/api/v1/operation-history?page=99&page_size=2").json()["page"] == 3
|
||||
)
|
||||
assert client.get("/api/v1/operation-history?status=FAILED").json()["total"] == 3
|
||||
assert (
|
||||
client.get("/api/v1/operation-history?q=photo-3").json()["items"][0][
|
||||
"operation_id"
|
||||
]
|
||||
== ids[2]
|
||||
)
|
||||
assert (
|
||||
client.get("/api/v1/operation-history?start=2026-10-03&end=2026-10-04").json()[
|
||||
"total"
|
||||
]
|
||||
== 2
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
"/api/v1/operation-history?start=2026-10-05&end=2026-10-01"
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert client.get("/api/v1/operation-history?device_id=other").json()["total"] == 0
|
||||
assert client.get("/api/v1/operation-history?action=display").json()["total"] == 0
|
||||
|
||||
|
||||
def test_delete_preserves_receipts_and_protects_running(client, service):
|
||||
done = make_op(service, 1)
|
||||
running = make_op(service, 2, "QUEUED")
|
||||
assert (
|
||||
client.request(
|
||||
"DELETE",
|
||||
"/api/v1/operation-history",
|
||||
json={"operation_ids": [done, running]},
|
||||
).status_code
|
||||
== 409
|
||||
)
|
||||
assert client.get("/api/v1/operation-history").json()["total"] == 2
|
||||
assert (
|
||||
client.request(
|
||||
"DELETE",
|
||||
"/api/v1/operation-history",
|
||||
json={"operation_ids": [done, "missing"]},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.request(
|
||||
"DELETE", "/api/v1/operation-history", json={"operation_ids": [done, done]}
|
||||
).json()["deleted"]
|
||||
== 1
|
||||
)
|
||||
assert client.get("/api/v1/operation-history").json()["total"] == 1
|
||||
assert done not in [
|
||||
x["operation_id"] for x in client.get("/api/v1/operations").json()
|
||||
]
|
||||
assert client.get("/api/v1/operations/" + done).status_code == 200
|
||||
assert service.db.one("SELECT id FROM operations WHERE id=?", (done,))
|
||||
assert (
|
||||
client.request(
|
||||
"DELETE", "/api/v1/operation-history", json={"operation_ids": [done]}
|
||||
).json()["deleted"]
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_history_permissions(client, service):
|
||||
mine = make_op(service, 1, client_id="reader")
|
||||
make_op(service, 2)
|
||||
service.db.execute(
|
||||
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
"reader",
|
||||
hashlib.sha256(b"reader-token").hexdigest(),
|
||||
"Reader",
|
||||
json.dumps(["history:read"]),
|
||||
"",
|
||||
None,
|
||||
1,
|
||||
),
|
||||
)
|
||||
client.headers["Authorization"] = "Bearer reader-token"
|
||||
result = client.get("/api/v1/operation-history").json()
|
||||
assert result["total"] == 1 and result["items"][0]["operation_id"] == mine
|
||||
assert (
|
||||
client.request(
|
||||
"DELETE", "/api/v1/operation-history", json={"operation_ids": [mine]}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
client.headers.pop("Authorization")
|
||||
assert client.get("/api/v1/operation-history").status_code == 401
|
||||
|
||||
|
||||
def test_history_delete_requires_browser_csrf(client, service):
|
||||
op = make_op(service, 1)
|
||||
service.db.execute(
|
||||
"INSERT INTO users VALUES(?,?,1)",
|
||||
("fixture", password_hash("fixture-password")),
|
||||
)
|
||||
client.headers.pop("Authorization")
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "fixture", "password": "fixture-password"},
|
||||
).json()
|
||||
assert (
|
||||
client.request(
|
||||
"DELETE", "/api/v1/operation-history", json={"operation_ids": [op]}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.request(
|
||||
"DELETE",
|
||||
"/api/v1/operation-history",
|
||||
json={"operation_ids": [op]},
|
||||
headers={"X-CSRF-Token": login["csrf_token"]},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||