Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4543a9843 | ||
|
|
106b6f311f | ||
|
|
d3f40cfea3 | ||
|
|
a843d4de9b | ||
|
|
d2f02d8054 |
+3
-1
@@ -2,7 +2,9 @@ TZ=Asia/Shanghai
|
||||
# Default local access. For NAS LAN access set this to the NAS LAN IP (recommended)
|
||||
# or explicitly 0.0.0.0. No router, reverse proxy or firewall rules are modified.
|
||||
WEB_BIND=127.0.0.1
|
||||
WEB_PORT=18787
|
||||
WEB_PORT=6005
|
||||
# Display name of the NAS/server storing the local gallery.
|
||||
FRAME_FLOW_STORAGE_NAME=NAS
|
||||
KOKONNA_MOCK=true
|
||||
KOKONNA_WRITE_ENABLED=false
|
||||
KOKONNA_API_KEY=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Verify Frame Flow
|
||||
on:
|
||||
push:
|
||||
# Branch verification gates releases; tags do not duplicate the same build.
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -65,6 +67,7 @@ jobs:
|
||||
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }}
|
||||
PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }}
|
||||
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
|
||||
FRAME_FLOW_STORAGE_NAME: ${{ vars.FRAME_FLOW_STORAGE_NAME }}
|
||||
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
|
||||
PROD_MOCK: ${{ vars.PROD_MOCK }}
|
||||
PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }}
|
||||
|
||||
@@ -22,7 +22,7 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:18787` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||
Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||
|
||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ docker compose run --rm --no-deps -e PYTHONPATH=/app \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:18787`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||
|
||||
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)和 [Gitea 部署与回滚](docs/deployment/GITEA_CD.md)。不会自动修改防火墙、路由器或反向代理。
|
||||
|
||||
|
||||
+2
-1
@@ -17,12 +17,13 @@ services:
|
||||
init: true
|
||||
user: "10333:10333"
|
||||
ports:
|
||||
- "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-18787}:18787"
|
||||
- "${WEB_BIND:-127.0.0.1}:${WEB_PORT:-6005}:18787"
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
DATA_DIR: /data
|
||||
CONFIG_DIR: /config
|
||||
FRAME_FLOW_STORAGE_NAME: ${FRAME_FLOW_STORAGE_NAME:-NAS}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config:/config
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# KoKonna 错误、重试与阻塞处理
|
||||
|
||||
核对日期:2026-10-16。依据:[KoKonna OpenAPI 官方文档](https://kokonna.art/pages/openapi)、[Requests 异常定义](https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/)。本页区分厂商契约与 Frame Flow 的本地策略;没有文档保证的行为不作为自动重发依据。
|
||||
|
||||
## 官方接口契约
|
||||
|
||||
- 设备 API Key 使用 Bearer 鉴权;单设备每分钟上限 20 次,请求超限返回 429。
|
||||
- 上传成功返回新图片 ID 和计数,并立即激活新图;接口不提供幂等键或上传状态查询契约。
|
||||
- 删除可能改变当前图片。切换与删除都不能视为天然可重放。
|
||||
- 404 可表示方法不存在;图片下载时也可表示目标图片不存在。
|
||||
- 500 既可能是通用服务故障,也可能明确表示设备密钥无效、图片不存在或图片 ID 参数错误。实现只匹配官方明确列出的签名,其他 500 不猜测是否产生副作用。
|
||||
- 上传请求体上限 50 MB;本服务原图限制仍为 20 MiB,并保留像素上限和 base64 检查。
|
||||
- 官方文档未承诺服务处理时长、超时后的取消、事务回滚或 Retry-After 返回;不能靠等待固定时间证明写入没有执行。
|
||||
- Requests 将连接建立超时定义为可安全重试;读取超时不提供相同保证。一般连接异常也不能推定请求未发送。
|
||||
|
||||
## 已落实的本地策略
|
||||
|
||||
| 结果 | 处理 | 自动重试 | 设备阻塞 |
|
||||
| --- | --- | --- | --- |
|
||||
| 未配置密钥 | CONFIG_REQUIRED | 无 | 无 |
|
||||
| 明确的设备密钥无效 | CONFIG_REQUIRED,保存固定错误分类 | 无 | 无 |
|
||||
| 明确的图片不存在 | NOT_FOUND | 无 | 无 |
|
||||
| 明确的图片 ID 参数错误 | INVALID_REQUEST | 无 | 无 |
|
||||
| 明确的方法不存在 | UNSUPPORTED_CAPABILITY | 无 | 无 |
|
||||
| 其他 HTTP 4xx 拒绝 | 保存状态码,结束失败 | 除 429 外无 | 无 |
|
||||
| 本地请求预算耗尽 | 等待预算窗口 | 同一请求最多 3 次尝试 | 不创建 UNKNOWN |
|
||||
| HTTP 429 | RATE_LIMITED,尊重服务端冷却时间 | 最多 3 次尝试 | 耗尽后失败,不创建 UNKNOWN |
|
||||
| ConnectTimeout | 确定请求未发送 | 读写均最多 3 次尝试 | 耗尽后失败,不创建 UNKNOWN |
|
||||
| 只读查询的临时网络错误、通用 5xx、无效 JSON | CLOUD_UNAVAILABLE | 最多 3 次尝试 | 不创建 UNKNOWN |
|
||||
| TLS 异常 | 保存固定分类 | 无 | 写请求仍保守标为 UNKNOWN |
|
||||
| 写请求读取超时、一般连接异常、通用 5xx | 无法确定写入结果 | 不重发,只读核对 | UNKNOWN,保留保护 |
|
||||
| 写请求无效响应或无有效上传 ID | 无法确认成功 | 不重发,只读核对 | UNKNOWN,保留保护 |
|
||||
|
||||
“最多 3 次”包含首次请求,重试发生在同一传输请求内,不重新渲染、不重新执行整个操作。普通退避为 1、2 秒;每次实际请求都计入本地 18 次/分钟预算。429 优先使用 Retry-After 秒数或 HTTP 日期;缺失/无效时按 60 秒。单次等待不超过 60 秒,超过此值保留冷却并结束当前尝试,不提前请求。没有无限循环,也不在重启后重放不确定的写入。
|
||||
|
||||
操作错误的 details 保存 attempts、retryable、write_outcome,以及 transport_error / http_status / failure_kind 等固定分类。不会保存异常原文、完整下载 URL、带密钥的厂商消息或响应正文。是否重试由结果类别决定,不能仅依据 HTTP 500。
|
||||
|
||||
## 未知写入的核对与升级
|
||||
|
||||
上传前记录资产、哈希、字节数、名称、上传前图库和时间。响应丢失后只读查找并校验唯一候选;图片相同且设备已同步才能记为成功。已证明上传、但设备持续停留原图时,沿用两次稳定观察的失败关闭规则。
|
||||
|
||||
没有候选、候选不唯一、内容不符、设备未同步、查询失败或缺少历史证据,都有独立恢复原因。图库没有目标图片不等于厂商保证从未接受请求,因此不自动把它改为失败或重传。
|
||||
|
||||
`GET /api/v1/operations/{operation_id}` 的 `recovery` 返回:
|
||||
|
||||
- `state`:checking / manual_review / resolved。
|
||||
- `reason`:gallery_match_missing、gallery_match_ambiguous、content_mismatch、waiting_device_sync、target_mismatch、read_unavailable、reconciliation_error、missing_upload_evidence 或 manual_reconciliation_required。
|
||||
- `first_checked_at`、`last_checked_at`、`checks`、`requires_manual_review`。
|
||||
|
||||
只读核对每条操作至多每分钟一次。操作持续未知超过 sync_timeout + 300 秒(默认约 15 分钟)时,持久化一次 `operation.blocked` 事件和 `MANUAL_REVIEW_REQUIRED` 历史记录;服务重启、重复检查或原因改变均不会重复升级同一条未解决操作。既有检查继续运行,不自动取消设备保护。
|
||||
|
||||
管理员可在 Webhooks 中订阅 `operation.blocked`;未配置订阅不会自动向外发送通知。事件和 API 记录始终保留,但它们不能替代已配置的通知渠道。单个任务失败、单条未知操作的元数据异常均不应中断其他任务处理。
|
||||
|
||||
## 人工处理
|
||||
|
||||
1. 根据 operation_id 查看原始错误、恢复原因、当前设备和图库;不点击重新执行来猜测结果。
|
||||
2. 调用现有 reconcile 接口并传 `acknowledge=false` 进行核对。
|
||||
3. 无法证明成功、但管理员确认可以结束旧操作时,传 `acknowledge=true`:结束为失败,保留原始目标、上传意图和此前错误,记录观察到的图片与人工处理历史。该接口不会重发设备写入。
|
||||
4. 有目标一致且同步完成的证据才记为成功。处理后恢复原计划,错过的任务不补发。
|
||||
|
||||
仍可能因厂商无法给出明确写入结果而需要人工处理;本策略解决错误误判、可安全重试的暂时失败和长期阻塞缺乏解释的问题,不承诺网络故障时所有设备任务都能自动成功。
|
||||
+9
-3
@@ -1,7 +1,9 @@
|
||||
# Synology NAS deployment / 群晖部署
|
||||
|
||||
Target: DS923+, DSM 7.4.1 (verified on target; originally specified 7.3.1), AMD64, Container Manager.
|
||||
Directory: /volume2/docker/pigway-frame-flow. Port: 18787.
|
||||
Directory: /volume2/docker/pigway-frame-flow. Default published port: 6005.
|
||||
The container still listens on 18787; Compose maps host 6005 to container 18787.
|
||||
Existing installations must update their own WEB_PORT and recreate the service to activate the change.
|
||||
|
||||
## 1. Package and upload / 打包上传
|
||||
|
||||
@@ -37,10 +39,14 @@ chmod 600 .env
|
||||
Edit `.env` on the NAS. Set FRAME_FLOW_TOKEN to a strong random token. API key
|
||||
stays empty. KOKONNA_MOCK=true and KOKONNA_WRITE_ENABLED=false.
|
||||
Set WEB_BIND to the actual NAS LAN address (recommended) or explicitly 0.0.0.0;
|
||||
WEB_PORT=18787. The latter binds every NAS interface, so use the LAN IP when
|
||||
WEB_PORT=6005. The latter binds every NAS interface, so use the LAN IP when
|
||||
strict LAN-only binding is required. No firewall/router/proxy edits are performed.
|
||||
Copy deploy/devices.yml.example to config/devices.yml if multiple devices are desired.
|
||||
|
||||
Set `FRAME_FLOW_STORAGE_NAME` to the NAS display name. The gallery tab shows
|
||||
this name followed by “本地图库” (or “Local gallery”). For Actions deployment,
|
||||
set the repository Variable of the same name. It is a display label, not a path.
|
||||
|
||||
## 3. Validate, build, start / 验证构建启动
|
||||
|
||||
Synology Docker is /usr/local/bin/docker. The SSH user may require sudo. Do not
|
||||
@@ -61,7 +67,7 @@ sudo /usr/local/bin/docker image inspect pigway-frame-flow:latest \
|
||||
Do not print unfiltered `docker inspect`: its environment can contain keys/tokens.
|
||||
Likewise do not print `docker compose config` without --quiet on a live installation.
|
||||
Run the read-only smoke checker from a machine with Python and set FRAME_FLOW_URL
|
||||
to http://NAS_LAN_IP:18787; FRAME_FLOW_TOKEN stays in private env/file.
|
||||
to http://NAS_LAN_IP:6005; FRAME_FLOW_TOKEN stays in private env/file.
|
||||
|
||||
Health: GET /health → 200. Readiness: GET /ready → 200 when SQLite, driver config,
|
||||
auth and Scheduler are ready. Missing credentials in live mode → 503. The cloud
|
||||
|
||||
@@ -23,3 +23,22 @@ The UI labels the top card **Shared connection**, displays source IDs and paths
|
||||
on each source card, and explains each source's connection dependency. Shared
|
||||
session disconnect is available only in the shared connection card and asks for
|
||||
confirmation of its impact on all people-filtered sources.
|
||||
|
||||
|
||||
## Gallery destination labels / 图库目的地标签
|
||||
|
||||
图库标签描述可保存图片的具体目的地,照片来源连接不自动成为图库目的地。
|
||||
当前为 `KoKonna 云端图库` 与 `<FRAME_FLOW_STORAGE_NAME> 本地图库`;
|
||||
NAS 名称通过部署配置设置,空值回退为 NAS,不使用容器 ID 代替 NAS 名称。
|
||||
英文使用同一实例名称与 cloud gallery / Local gallery。
|
||||
|
||||
`GET /api/v1/settings` 的 `gallery_destinations` 提供目的地 ID、适配器、
|
||||
实例/服务商名称、存储类型、连接状态和保存能力。只有连接有效、明确支持上传且
|
||||
前端存在对应适配器的目的地才进入标签栏。能力默认关闭;全局写入保护仍单独生效,
|
||||
目的地的保存能力不等于当前用户获准写入。
|
||||
|
||||
后续网盘接入须先实现其读取/保存适配器、校验授权及能力,再注册目的地。
|
||||
命名规则为“服务商/实例名称 + 云端图库”,例如已实现保存能力后才可展示
|
||||
“百度网盘·家庭 云端图库”。多个账号/实例必须能从名称区分。
|
||||
目前 Google Drive、阿里云盘、百度网盘和 WebDAV 仅作为只读照片来源,
|
||||
不出现在可保存目的地标签栏,不显示尚未实现的上传入口。
|
||||
|
||||
@@ -41,3 +41,7 @@ minute apart record `DISPLAY_NOT_CONFIRMED` and unblock future scheduled work.
|
||||
Temporary overrides retain their restore behavior and are not automatically
|
||||
closed as failed. Legacy operations without durable upload evidence require
|
||||
manual acknowledgement; absence from the gallery alone never proves failure.
|
||||
|
||||
### 错误与有限重试
|
||||
|
||||
完整错误矩阵、重试边界、持久化恢复状态和人工处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。自动重试只允许只读请求、连接建立超时或明确限流拒绝;读取超时等未知写入不会重发。
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
# KoKonna API research / 接口调查
|
||||
|
||||
Checked 2026-10-01 against https://kokonna.art/pages/openapi.
|
||||
Rechecked 2026-10-16 against https://kokonna.art/pages/openapi.
|
||||
错误分类、重试和未知写入处理见 [KoKonna 恢复策略](KOKONNA_RECOVERY.md)。
|
||||
Base: https://api.kokonna.art/openapi. No fallback to legacy galaxyguide.cn.
|
||||
|
||||
| Official endpoint | Frame Flow capability |
|
||||
@@ -29,7 +30,7 @@ headroom at 18. Device state has a 60-second cache, gallery is loaded on demand.
|
||||
Uploads automatically activate a new image; there is no upload-without-activation
|
||||
flag. Gallery-only uploads are therefore local staging, not a fictitious cloud
|
||||
operation. Maximum upstream request size is 50 MB; this service accepts 20 MiB
|
||||
image files with bounded decoded pixels. No non-idempotent cloud write is retried.
|
||||
image files with bounded decoded pixels. Only proven-unsent connections or explicit rate-limit rejections may retry a write, within a three-attempt budget. Ambiguous writes are never replayed.
|
||||
Cloud acknowledgement is not physical refresh confirmation; /device synced and
|
||||
imageId drive the operation state. Unconfirmed or interrupted writes become UNKNOWN
|
||||
and block later device writes until read-only reconciliation/explicit acknowledgement.
|
||||
|
||||
+50
-24
@@ -1,36 +1,62 @@
|
||||
# UI layout audit
|
||||
# UI 一致性与图片加载检查
|
||||
|
||||
The Mock-only `scripts/ui-layout-audit.cjs` checks all 11 navigation pages,
|
||||
local/cloud gallery layouts and six modal states plus expanded manual collage at 1440, 768 and 390 CSS pixels,
|
||||
in Chinese/English and light/dark themes. It checks page horizontal overflow and
|
||||
at least 8 pixels between visible input/select/textarea/button rectangles, and
|
||||
44-pixel control heights on mobile.
|
||||
Closed details, background content behind dialogs and hidden fields are excluded.
|
||||
Photos authentication is intercepted with fixture credentials; no NAS login or
|
||||
frame write occurs. Populated task/history fixtures remain in disposable Mock data.
|
||||
页面使用既有真实元数据创建控件和图片区域,不使用页面骨架或虚构图片卡片。
|
||||
搜索、排序、卡片操作与写入保护在图片请求前初始化;已知图库数据可先显示,
|
||||
后台刷新元数据。首页图片独立加载,不阻塞后续页面内容。
|
||||
|
||||
This audit found and corrected missing upload preview spacing, nested task field
|
||||
spacing, small mobile action gaps and inconsistent dynamically inserted form
|
||||
spacing. NAS source forms now hide irrelevant cloud credential fields. Native
|
||||
drive forms expose their credential field and hide local-only quality controls.
|
||||
图片共用局部加载函数:旋转指示器仅居中显示于所属图片容器,网络请求与解码完成后
|
||||
立即显示图片,失败后显示中英文提示。没有固定等待或动画最短展示时长。
|
||||
图库仅请求可见区域及附近图片。浏览器禁缓存不会清除服务端缩略图缓存,
|
||||
快速图片可能在第一帧绘制前完成,动画不一定肉眼可见。
|
||||
|
||||
Synology Photos has a dedicated connection card on the Photo Sources page, with
|
||||
status, an explanation of affected automation and a direct NAS sign-in dialog.
|
||||
Folder settings and person selection remain separate. Credentials are not saved;
|
||||
the existing protected session API is used. Successful login does not run a job
|
||||
or change frame content. Passwords are cleared from the dialog before submission.
|
||||
手动拼版先创建全部真实区域与坐标/拖动交互,再并行请求图片。
|
||||
每张图片独立完成或失败,持久的区域容器保持加载前后几何位置与坐标索引稳定。
|
||||
过期布局事件不修改新的规划。
|
||||
|
||||
Run against an isolated Mock server with the existing Playwright runtime:
|
||||
移动端导航单行横向滚动,当前入口自动进入可见区域;低高度横屏侧栏可滚动。
|
||||
页头在窄屏分行,并为底部安全区域留白。可见控件保留 44 像素触控高度,
|
||||
当前导航使用 aria-current,扩展表单标签关联相应控件。
|
||||
表格与手机导航允许容器内滚动,不应导致整页水平溢出。
|
||||
|
||||
## 自动检查
|
||||
|
||||
使用隔离 Mock 服务、临时数据与测试账号,禁止对生产执行可写的 Mock 脚本。
|
||||
Photos 授权使用拦截测试响应;不需要真实 NAS 凭据。
|
||||
|
||||
```sh
|
||||
FRAME_FLOW_URL=http://127.0.0.1:18789 \
|
||||
FRAME_FLOW_URL=http://127.0.0.1:6005 \
|
||||
FRAME_FLOW_ENV_FILE=/path/to/mock.env \
|
||||
FRAME_FLOW_TEST_USERNAME=test-ui \
|
||||
FRAME_FLOW_TEST_PASSWORD=test-only-password \
|
||||
node scripts/ui-layout-audit.cjs
|
||||
```
|
||||
|
||||
Set `NODE_PATH` to the installed Playwright runtime if needed. Optional
|
||||
`FRAME_FLOW_SCREENSHOT_DIR` saves fixture-only screenshots for visual review.
|
||||
The full audit passed 229 checks with no console errors. This complements
|
||||
`ui-smoke.cjs` and `ui-integrations.cjs`; it does not replace functional tests.
|
||||
需要已安装的 Playwright;可通过 NODE_PATH 指定运行时。
|
||||
FRAME_FLOW_VIEWPORTS 接受包含 width/height 的 JSON 数组;
|
||||
FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
|
||||
|
||||
| 脚本 | 检查范围 | 本次结果 |
|
||||
| --- | --- | --- |
|
||||
| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
|
||||
| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
|
||||
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;首次无缓存时控件先就绪、输入保留、慢请求切换、列表失败、无骨架 | 18 项通过 |
|
||||
| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
|
||||
|
||||
上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
|
||||
生产浏览器交互验证使用拦截的账号、规划及素材响应,对真实服务仅 GET,
|
||||
不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
|
||||
|
||||
连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
|
||||
|
||||
|
||||
## 首次未缓存图库的初始化
|
||||
|
||||
目的地标签、搜索、排序、刷新和上传控件必须先于列表接口完成。
|
||||
仅已有列表的缓存分支先渲染还不够;无缓存时也应创建真实控件与空的列表容器。
|
||||
元数据返回前不推测图片数量,不显示真实空图库提示,不生成骨架卡片。
|
||||
列表失败保留页面控件;慢云端请求期间切换 NAS,迟到响应不得覆盖新页面。
|
||||
列表返回后保留之前输入的搜索词和排序。任务筛选在同一导航内复用请求,仍不阻塞图库。
|
||||
|
||||
启动使用 Settings API 中不调用厂商接口的 device_catalog;完整设备状态作为后台更新,
|
||||
迟到响应在退出账号后不应用。ui-gallery-bootstrap.cjs 同时阻塞设备状态与图片列表,
|
||||
验证真实控件仍先显示,列表返回后保留查询和排序,2 项通过。
|
||||
|
||||
@@ -37,6 +37,7 @@ Repository Settings → Actions configuration (private paths/addresses use Secre
|
||||
| `PROD_PHOTOS_DIR` (Secret) | Existing read-only photos source if present; otherwise empty |
|
||||
| `PROD_WEB_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
|
||||
| `PROD_WEB_PORT` | Exact existing mapped port |
|
||||
| `FRAME_FLOW_STORAGE_NAME` | Optional NAS display name for the local gallery; defaults to NAS |
|
||||
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
|
||||
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
|
||||
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
|
||||
@@ -184,7 +185,7 @@ PROD_IMAGE_REPO=pigway-frame-flow
|
||||
PROD_DATA_DIR=/volume1/docker/pigway-frame-flow/data
|
||||
PROD_CONFIG_DIR=/volume1/docker/pigway-frame-flow/config
|
||||
PROD_WEB_BIND=192.0.2.10
|
||||
PROD_WEB_PORT=18787
|
||||
PROD_WEB_PORT=6005
|
||||
PROD_STATE_VOLUME=pigway-frame-flow-cd-state
|
||||
PROD_MOCK=true
|
||||
PROD_WRITE_ENABLED=false
|
||||
@@ -263,8 +264,8 @@ as an Actions artifact. First bootstrap has no prior healthy version to roll bac
|
||||
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
|
||||
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
|
||||
exact `/volume1/docker/pigway-frame-flow/{data,config}` mounts and
|
||||
`192.0.2.10:18787` binding (not loopback).
|
||||
4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full
|
||||
`192.0.2.10:6005` binding (not loopback).
|
||||
4. Open `http://192.0.2.10:6005/api/v1/version`; its `commit` must equal the full
|
||||
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
|
||||
is `pigway-frame-flow:latest`; compare its image ID in
|
||||
Container Manager with the running container.
|
||||
|
||||
+238
-4
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Pigway Frame Flow API",
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.7"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -6397,7 +6397,15 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TemplateContent"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TemplateContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
}
|
||||
],
|
||||
"title": "Payload"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14298,6 +14306,87 @@
|
||||
],
|
||||
"title": "AssetBatch"
|
||||
},
|
||||
"AutoCollageContent": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "auto_collage",
|
||||
"title": "Type"
|
||||
},
|
||||
"source_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]{1,60}$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Source Id"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"maximum": 12.0,
|
||||
"minimum": 1.0,
|
||||
"title": "Count",
|
||||
"default": 4
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"title": "Title",
|
||||
"default": ""
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"title": "Message",
|
||||
"default": ""
|
||||
},
|
||||
"orientation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"portrait",
|
||||
"landscape"
|
||||
],
|
||||
"title": "Orientation",
|
||||
"default": "auto"
|
||||
},
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"grid",
|
||||
"hero",
|
||||
"poster"
|
||||
],
|
||||
"title": "Style",
|
||||
"default": "grid"
|
||||
},
|
||||
"use_ai": {
|
||||
"type": "boolean",
|
||||
"title": "Use Ai",
|
||||
"default": true
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"maxLength": 1500,
|
||||
"title": "Prompt",
|
||||
"default": ""
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/components/schemas/Profile",
|
||||
"default": "color"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "AutoCollageContent"
|
||||
},
|
||||
"Body_import_images_api_v1_layout_images_post": {
|
||||
"properties": {
|
||||
"files": {
|
||||
@@ -14730,6 +14819,24 @@
|
||||
],
|
||||
"title": "Device"
|
||||
},
|
||||
"DeviceIdentity": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"title": "DeviceIdentity"
|
||||
},
|
||||
"DisplayGeometry": {
|
||||
"properties": {
|
||||
"width": {
|
||||
@@ -14778,6 +14885,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ProviderContent"
|
||||
},
|
||||
@@ -14789,6 +14899,7 @@
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"auto_collage": "#/components/schemas/AutoCollageContent",
|
||||
"html": "#/components/schemas/FutureContent",
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
@@ -14852,6 +14963,7 @@
|
||||
"UNSUPPORTED_CAPABILITY",
|
||||
"RENDER_FAILED",
|
||||
"UPLOAD_FAILED",
|
||||
"DISPLAY_NOT_CONFIRMED",
|
||||
"WRITE_RESULT_UNKNOWN",
|
||||
"RATE_LIMITED",
|
||||
"INTERNAL_ERROR",
|
||||
@@ -14953,6 +15065,48 @@
|
||||
],
|
||||
"title": "FutureContent"
|
||||
},
|
||||
"GalleryDestination": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
},
|
||||
"adapter": {
|
||||
"type": "string",
|
||||
"title": "Adapter"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"storage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cloud",
|
||||
"local"
|
||||
],
|
||||
"title": "Storage"
|
||||
},
|
||||
"connected": {
|
||||
"type": "boolean",
|
||||
"title": "Connected",
|
||||
"default": false
|
||||
},
|
||||
"supports_upload": {
|
||||
"type": "boolean",
|
||||
"title": "Supports Upload",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"adapter",
|
||||
"name",
|
||||
"storage"
|
||||
],
|
||||
"title": "GalleryDestination"
|
||||
},
|
||||
"GalleryImage": {
|
||||
"properties": {
|
||||
"starred": {
|
||||
@@ -15062,7 +15216,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.0"
|
||||
"default": "0.1.7"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -15324,6 +15478,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ProviderContent"
|
||||
},
|
||||
@@ -15334,6 +15491,7 @@
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"auto_collage": "#/components/schemas/AutoCollageContent",
|
||||
"html": "#/components/schemas/FutureContent",
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
@@ -15469,6 +15627,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AutoCollageContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ProviderContent"
|
||||
},
|
||||
@@ -15479,6 +15640,7 @@
|
||||
"discriminator": {
|
||||
"propertyName": "type",
|
||||
"mapping": {
|
||||
"auto_collage": "#/components/schemas/AutoCollageContent",
|
||||
"html": "#/components/schemas/FutureContent",
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
@@ -15787,6 +15949,16 @@
|
||||
},
|
||||
"Operation": {
|
||||
"properties": {
|
||||
"recovery": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RecoveryState"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"operation_id": {
|
||||
"type": "string",
|
||||
"title": "Operation Id"
|
||||
@@ -16808,6 +16980,49 @@
|
||||
],
|
||||
"title": "Reconciliation"
|
||||
},
|
||||
"RecoveryState": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"checking",
|
||||
"manual_review",
|
||||
"resolved"
|
||||
],
|
||||
"title": "State"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"title": "Reason"
|
||||
},
|
||||
"first_checked_at": {
|
||||
"type": "string",
|
||||
"title": "First Checked At"
|
||||
},
|
||||
"last_checked_at": {
|
||||
"type": "string",
|
||||
"title": "Last Checked At"
|
||||
},
|
||||
"checks": {
|
||||
"type": "integer",
|
||||
"title": "Checks"
|
||||
},
|
||||
"requires_manual_review": {
|
||||
"type": "boolean",
|
||||
"title": "Requires Manual Review"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"state",
|
||||
"reason",
|
||||
"first_checked_at",
|
||||
"last_checked_at",
|
||||
"checks",
|
||||
"requires_manual_review"
|
||||
],
|
||||
"title": "RecoveryState"
|
||||
},
|
||||
"Region": {
|
||||
"properties": {
|
||||
"asset_id": {
|
||||
@@ -16936,6 +17151,25 @@
|
||||
},
|
||||
"SettingsView": {
|
||||
"properties": {
|
||||
"storage_name": {
|
||||
"type": "string",
|
||||
"title": "Storage Name",
|
||||
"default": "NAS"
|
||||
},
|
||||
"gallery_destinations": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GalleryDestination"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Gallery Destinations"
|
||||
},
|
||||
"device_catalog": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DeviceIdentity"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Device Catalog"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version"
|
||||
@@ -17178,7 +17412,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.0"
|
||||
"default": "0.1.7"
|
||||
},
|
||||
"api_version": {
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Pigway Frame Flow v0.1.5
|
||||
|
||||
## 简体中文
|
||||
|
||||
- 图片仅在真实图片区域内居中显示加载动画,无页面骨架、虚构图片卡片或人为展示延时;请求及解码完成后立即显示,失败时显示局部提示。
|
||||
- 图库搜索、排序、卡片操作及写入保护先于图片请求初始化,复用真实列表元数据;本地任务筛选异步填充,不阻塞其他控件。
|
||||
- 手动拼版改为并行加载,各图独立完成或失败;固定区域容器保留坐标和拖动交互,加载前后位置一致。
|
||||
- 修复手机底栏文字裁切、窄屏页头拥挤和横屏导航可达性,支持底部安全区域,补齐表单标签和当前导航无障碍状态。
|
||||
- 默认对外访问端口改为 **6005**。本地启动、Compose、测试地址及文档同步;容器内部仍监听 18787。
|
||||
|
||||
### 验证
|
||||
|
||||
本地 505 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法和差异检查通过。
|
||||
浏览器回归:布局 305 项、图片加载 45 项、图库加载顺序 14 项、手动拼版 31 项通过。
|
||||
先行 UI 部署的生产静态文件哈希、health/ready 和拦截测试数据下的浏览器交互已验证;
|
||||
不代表真实 AI 生成、画框写入、手机实机或软键盘测试。
|
||||
|
||||
### 升级说明
|
||||
|
||||
已有安装须更新私有环境配置中的 `WEB_PORT=6005`,并重建服务后生效。
|
||||
自动部署的 `PROD_WEB_PORT` 必须与现有实例的映射一致;仅修改该变量不能迁移端口。
|
||||
NAS 附件是源码部署包,需自行构建 Docker 镜像,不含密钥、运行配置、数据库或私人照片。
|
||||
|
||||
## English
|
||||
|
||||
Image loading indicators stay inside real image regions, without page skeletons or artificial delays.
|
||||
Gallery controls initialize before image requests. Manual collage images load independently in parallel,
|
||||
with stable regions and working coordinate/drag controls. Mobile navigation, safe-area spacing and form
|
||||
accessibility are improved. The default published port is now **6005**, mapped to container port 18787.
|
||||
Existing installations must update their private port configuration and recreate the service.
|
||||
NAS source package and SHA256 checksum are included; this is not a prebuilt Docker image.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Pigway Frame Flow v0.1.6
|
||||
|
||||
## 简体中文
|
||||
|
||||
- 图库使用具体目的地标签:`KoKonna 云端图库` 与 `<NAS 名称> 本地图库`,NAS 名称通过 `FRAME_FLOW_STORAGE_NAME` 配置。
|
||||
- Settings API 增加目的地元数据,标签仅展示连接已配置、明确支持保存且存在适配器的目的地;全局写入保护保持独立。
|
||||
- 后续网盘须实现读取/保存适配器并完成连接后才能加入标签栏,显示具体服务商/实例名称。当前只读照片来源不会作为保存目的地展示。
|
||||
- 长标签在手机上换行,避免整页横向溢出;中英文保持相同目的地身份。
|
||||
- Compose 和自动部署透传 NAS 展示名称;分支 CI 完整验证后发布标签,避免相同提交的标签推送重复占用构建队列。
|
||||
|
||||
### 验证
|
||||
|
||||
本地 507 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法检查通过。
|
||||
图库目的地标签与能力过滤 18 项浏览器检查通过;覆盖 320–1440 像素、中英文、明暗主题和长 NAS 名称。
|
||||
本次不增加任何网盘写入功能,不改变画框写入策略。
|
||||
|
||||
### 升级
|
||||
|
||||
Compose 设置 `FRAME_FLOW_STORAGE_NAME`;Actions 设置同名仓库 Variable。
|
||||
名称只描述实例,不作为路径或授权凭据。未设置时回退为 NAS。
|
||||
默认访问端口仍为 6005,容器内部端口仍为 18787。
|
||||
NAS 源码包与 SHA256 附件不含运行配置、密钥、数据库或私人照片,需自行构建镜像。
|
||||
|
||||
## English
|
||||
|
||||
Gallery tabs identify the concrete destination: KoKonna cloud gallery and the configured NAS name plus Local gallery.
|
||||
Destination metadata includes connection and upload capabilities; only implemented writable adapters are shown.
|
||||
Existing read-only cloud photo sources are not presented as save destinations. Long labels wrap on mobile.
|
||||
Set FRAME_FLOW_STORAGE_NAME in Compose or the Actions repository Variables. No cloud-drive write support or device write policy is changed.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Pigway Frame Flow v0.1.7
|
||||
|
||||
## 简体中文
|
||||
|
||||
修复首次打开图库时内容区空白、必须等待云端图片列表返回才出现控件的问题。
|
||||
|
||||
- 目的地标签、搜索、排序、刷新和上传按钮立即初始化,图片列表独立请求。
|
||||
- 仅在工具栏提示正在读取列表;未知数量时不生成图片卡片,不提前显示“图库为空”。实际图片仍在自己的区域内独立加载。
|
||||
- 启动使用轻量设备身份目录,完整设备状态在后台更新,避免厂商状态请求阻塞首屏。
|
||||
- 慢请求期间可以切换到 NAS 图库;过期响应不会覆盖当前页面。
|
||||
- 列表失败时保留控件并在工具栏显示错误,仍可刷新或切换目的地。
|
||||
- 保留等待期间输入的搜索词和排序选择;本地任务筛选请求在同一次导航内复用。
|
||||
|
||||
### 验证
|
||||
|
||||
本地 508 项 Python 测试、Ruff、格式检查、mypy 和 JavaScript 语法检查通过。
|
||||
新增首次未缓存列表的控件就绪、输入保留、慢云端切换和列表失败回归。
|
||||
不增加图库列表的服务端缓存,不修改厂商请求重试、照片、生成内容或设备写入策略。
|
||||
|
||||
## English
|
||||
|
||||
Fix the blank gallery on its first uncached visit. Destination tabs and gallery controls initialize before metadata requests complete.
|
||||
Slow or failed cloud listing does not prevent switching to NAS storage. Search and sort choices survive list arrival, and stale responses cannot replace the active destination.
|
||||
Only a small toolbar status indicates metadata loading; real images keep independent loading indicators.
|
||||
@@ -33,9 +33,11 @@ from pigway_frame_flow.models import (
|
||||
CurrentDisplay,
|
||||
DeleteRequest,
|
||||
Device,
|
||||
DeviceIdentity,
|
||||
DisplayRequest,
|
||||
ErrorResponse,
|
||||
Fit,
|
||||
GalleryDestination,
|
||||
GalleryImage,
|
||||
Health,
|
||||
History,
|
||||
@@ -124,7 +126,7 @@ def create_app(settings=None, start_worker=True):
|
||||
app = FastAPI(
|
||||
title="Pigway Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.2",
|
||||
version="0.1.7",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -1000,7 +1002,36 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
def settings_view():
|
||||
return SettingsView(
|
||||
version="0.1.2",
|
||||
storage_name=settings.storage_name,
|
||||
# Page navigation needs device identity, not a fresh cloud status request.
|
||||
device_catalog=[
|
||||
DeviceIdentity(
|
||||
id=id,
|
||||
name=getattr(getattr(driver, "cached", None), "name", id),
|
||||
)
|
||||
for id, driver in registry.drivers.items()
|
||||
],
|
||||
gallery_destinations=[
|
||||
GalleryDestination(
|
||||
id="cloud",
|
||||
adapter="kokonna",
|
||||
name="KoKonna",
|
||||
storage="cloud",
|
||||
supports_upload=True,
|
||||
connected=settings.mock
|
||||
or bool(settings.api_key)
|
||||
or any(registry.keys),
|
||||
),
|
||||
GalleryDestination(
|
||||
id="local",
|
||||
adapter="local-assets",
|
||||
name=settings.storage_name,
|
||||
storage="local",
|
||||
connected=True,
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.7",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class RecoveryMonitor:
|
||||
def __init__(self, service):
|
||||
self.service, self.db = service, service.db
|
||||
|
||||
def observe(self, op, reason):
|
||||
timestamp = now_iso()
|
||||
age = (
|
||||
datetime.now(UTC) - datetime.fromisoformat(op["created_at"])
|
||||
).total_seconds()
|
||||
review = age >= self.service.settings.sync_timeout + 300
|
||||
with self.db.transaction() as tx:
|
||||
previous = tx.execute(
|
||||
"SELECT * FROM operation_recovery WHERE operation_id=?", (op["id"],)
|
||||
).fetchone()
|
||||
# Once escalated, wall-clock corrections must not clear the alert.
|
||||
review = review or bool(previous and previous["state"] == "manual_review")
|
||||
state = "manual_review" if review else "checking"
|
||||
tx.execute(
|
||||
"INSERT INTO operation_recovery VALUES(?,?,?,?,?,1) ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,reason=excluded.reason,last_checked_at=excluded.last_checked_at,checks=operation_recovery.checks+1",
|
||||
(op["id"], state, reason, timestamp, timestamp),
|
||||
)
|
||||
if review and (not previous or previous["state"] != "manual_review"):
|
||||
tx.execute(
|
||||
"INSERT INTO events VALUES(?,?,?,?)",
|
||||
(
|
||||
uuid4().hex,
|
||||
"operation.blocked",
|
||||
encode(
|
||||
{
|
||||
"operation_id": op["id"],
|
||||
"device_id": op["device_id"],
|
||||
"reason": reason,
|
||||
"requires_manual_review": True,
|
||||
}
|
||||
),
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
tx.execute(
|
||||
"INSERT INTO history(client_id,request_id,operation_id,device_id,action,result,timestamp) VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
op["client_id"],
|
||||
op["request_id"],
|
||||
op["id"],
|
||||
op["device_id"],
|
||||
op["action"],
|
||||
"MANUAL_REVIEW_REQUIRED",
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
def resolve(self, operation_id):
|
||||
self.db.execute(
|
||||
"UPDATE operation_recovery SET state='resolved' WHERE operation_id=? AND state!='resolved'",
|
||||
(operation_id,),
|
||||
)
|
||||
|
||||
def info(self, operation_id):
|
||||
row = self.db.one(
|
||||
"SELECT state,reason,first_checked_at,last_checked_at,checks FROM operation_recovery WHERE operation_id=?",
|
||||
(operation_id,),
|
||||
)
|
||||
if row:
|
||||
row["requires_manual_review"] = row["state"] == "manual_review"
|
||||
return row
|
||||
@@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.display.cloud_library import CloudLibrary
|
||||
from pigway_frame_flow.display.recovery import RecoveryMonitor
|
||||
from pigway_frame_flow.display.retention import Retention
|
||||
from pigway_frame_flow.display.upload_reconciliation import UploadReconciliation
|
||||
from pigway_frame_flow.drivers.base import DriverRegistry
|
||||
@@ -50,6 +51,7 @@ class DisplayService:
|
||||
self.lock = threading.RLock()
|
||||
self.retention = Retention(self)
|
||||
self.cloud_library = CloudLibrary(self)
|
||||
self.recovery = RecoveryMonitor(self)
|
||||
self.upload_reconciliation = UploadReconciliation(self)
|
||||
self.expiration_checked: dict[str, float] = {}
|
||||
self.unknown_checked: dict[str, float] = {}
|
||||
@@ -165,6 +167,7 @@ class DisplayService:
|
||||
request_id=op["request_id"],
|
||||
client_id=op["client_id"],
|
||||
action=op["action"],
|
||||
recovery=self.recovery.info(id),
|
||||
)
|
||||
|
||||
def update(self, id, status, result=None, error=None):
|
||||
@@ -184,6 +187,8 @@ class DisplayService:
|
||||
id,
|
||||
),
|
||||
)
|
||||
if status in ("COMPLETED", "FAILED"):
|
||||
self.recovery.resolve(id)
|
||||
|
||||
def blocked(self, device_id):
|
||||
return (
|
||||
@@ -442,7 +447,7 @@ class DisplayService:
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"request_id": op["request_id"],
|
||||
"details": {},
|
||||
"details": exc.details,
|
||||
}
|
||||
self.update(
|
||||
op["id"], "UNKNOWN" if uncertain else "FAILED", error=error
|
||||
@@ -702,21 +707,34 @@ class DisplayService:
|
||||
# Late acknowledgements only resolve a known display target. Never replay
|
||||
# writes or infer success from gallery presence alone.
|
||||
for op in self.db.all(
|
||||
"SELECT * FROM operations WHERE status='UNKNOWN' AND action='display' AND write_started=1"
|
||||
"SELECT * FROM operations WHERE status='UNKNOWN' AND action!='render-ai' AND write_started=1"
|
||||
):
|
||||
expected = json.loads(op["result"] or "{}").get("image_id")
|
||||
intent = json.loads(op["result"] or "{}").get("upload_intent")
|
||||
if not expected and not intent:
|
||||
continue
|
||||
checked = time.monotonic()
|
||||
if checked - self.unknown_checked.get(op["id"], float("-inf")) < 60:
|
||||
continue
|
||||
self.unknown_checked[op["id"]] = checked
|
||||
if op["action"] != "display" or (not expected and not intent):
|
||||
self.recovery.observe(
|
||||
op,
|
||||
"missing_upload_evidence"
|
||||
if op["action"] == "display"
|
||||
else "manual_reconciliation_required",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
driver = self.registry.get(op["device_id"])
|
||||
device = driver.device(refresh=True)
|
||||
if intent:
|
||||
expected = self.upload_reconciliation.check(op, driver, device)
|
||||
if (
|
||||
self.db.one(
|
||||
"SELECT status FROM operations WHERE id=?", (op["id"],)
|
||||
)["status"]
|
||||
== "FAILED"
|
||||
):
|
||||
continue
|
||||
if (
|
||||
expected
|
||||
and device.current_content.id == expected
|
||||
@@ -727,8 +745,18 @@ class DisplayService:
|
||||
self.db.history(op, "COMPLETED")
|
||||
self.db.event("operation.completed", {"operation_id": op["id"]})
|
||||
self.unknown_checked.pop(op["id"], None)
|
||||
elif expected:
|
||||
self.recovery.observe(
|
||||
op,
|
||||
"waiting_device_sync"
|
||||
if device.current_content.id == expected
|
||||
else "target_mismatch",
|
||||
)
|
||||
except FlowError:
|
||||
pass
|
||||
self.recovery.observe(op, "read_unavailable")
|
||||
except Exception:
|
||||
# Bad vendor metadata for one operation must not stop the worker.
|
||||
self.recovery.observe(op, "reconciliation_error")
|
||||
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
||||
try:
|
||||
device = self.registry.get(op["device_id"]).device()
|
||||
@@ -861,7 +889,20 @@ class DisplayService:
|
||||
self.update(
|
||||
id,
|
||||
"COMPLETED" if confirmed else "FAILED",
|
||||
{"image_id": device.current_content.id},
|
||||
op["result"],
|
||||
error=None
|
||||
if confirmed
|
||||
else {
|
||||
"code": E.DISPLAY_NOT_CONFIRMED
|
||||
if op["action"] == "display"
|
||||
else E.WRITE_RESULT_UNKNOWN,
|
||||
"message": "Manually closed unconfirmed write; no write replayed",
|
||||
"request_id": op["request_id"],
|
||||
"details": {
|
||||
"observed_image_id": device.current_content.id,
|
||||
"previous_error": op["error"],
|
||||
},
|
||||
},
|
||||
)
|
||||
resolved = True
|
||||
if confirmed and op["action"] == "display":
|
||||
|
||||
@@ -59,6 +59,12 @@ class UploadReconciliation:
|
||||
candidates.append(image)
|
||||
# Ambiguous matches never release a write lock.
|
||||
if len(candidates) != 1:
|
||||
service.recovery.observe(
|
||||
op,
|
||||
"gallery_match_missing"
|
||||
if not candidates
|
||||
else "gallery_match_ambiguous",
|
||||
)
|
||||
return None
|
||||
expected = candidates[0].image_id
|
||||
data = driver.download(expected)
|
||||
@@ -66,6 +72,7 @@ class UploadReconciliation:
|
||||
len(data) != intent["size"]
|
||||
or hashlib.sha256(data).hexdigest() != intent["sha256"]
|
||||
):
|
||||
service.recovery.observe(op, "content_mismatch")
|
||||
return None
|
||||
result["image_id"] = expected
|
||||
result["upload_confirmed"] = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Hardened adapter over the MIT-0 guooscar 1.2.0 device/upload methods.
|
||||
|
||||
No UI, scheduler, renderer, environment lookup, or constructor cloud calls.
|
||||
Never retry a write; a timeout may have occurred after cloud acceptance.
|
||||
Retry only read-only requests or writes proven unsent/rejected; never replay ambiguous writes.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -19,28 +19,85 @@ BASE_URL = "https://api.kokonna.art/openapi"
|
||||
|
||||
|
||||
class SafeTransport:
|
||||
MAX_ATTEMPTS = 3
|
||||
MAX_RETRY_DELAY = 60
|
||||
|
||||
def __init__(self, key, session=None):
|
||||
self.key = key
|
||||
self.session = session or requests.Session()
|
||||
self.calls: deque[float] = deque()
|
||||
self.lock = threading.Lock()
|
||||
self.cooldown_until = 0.0
|
||||
|
||||
def request(self, endpoint, payload=None, write=False, binary=False):
|
||||
if not self.key:
|
||||
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
|
||||
for attempt in range(1, self.MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
return self._request_once(endpoint, payload, write, binary)
|
||||
except FlowError as exc:
|
||||
exc.details["attempts"] = attempt
|
||||
delay = max(
|
||||
2 ** (attempt - 1), exc.details.get("retry_after_seconds", 0)
|
||||
)
|
||||
if (
|
||||
not exc.details.get("retryable")
|
||||
or attempt == self.MAX_ATTEMPTS
|
||||
or delay > self.MAX_RETRY_DELAY
|
||||
):
|
||||
if exc.details.get("retryable"):
|
||||
exc.details["retry_exhausted"] = attempt == self.MAX_ATTEMPTS
|
||||
exc.details["retry_deferred"] = delay > self.MAX_RETRY_DELAY
|
||||
raise
|
||||
time.sleep(delay)
|
||||
raise AssertionError("Retry loop exhausted")
|
||||
|
||||
def _reserve(self):
|
||||
with self.lock:
|
||||
current = time.monotonic()
|
||||
while self.calls and current - self.calls[0] >= 60:
|
||||
self.calls.popleft()
|
||||
# Reserve capacity beneath the official 20/minute ceiling.
|
||||
delay = max(0.0, self.cooldown_until - current)
|
||||
if len(self.calls) >= 18:
|
||||
raise FlowError(E.RATE_LIMITED, "Device request budget exhausted", 429)
|
||||
delay = max(delay, 60 - (current - self.calls[0]))
|
||||
if delay:
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED,
|
||||
"Device request budget exhausted",
|
||||
429,
|
||||
details={
|
||||
"retryable": True,
|
||||
"retry_after_seconds": delay,
|
||||
"write_outcome": "not_sent",
|
||||
"failure_kind": "local_rate_limit",
|
||||
},
|
||||
)
|
||||
self.calls.append(current)
|
||||
url = BASE_URL + endpoint
|
||||
|
||||
@staticmethod
|
||||
def _retry_after(value):
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
seconds = float(value)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
seconds = (
|
||||
parsedate_to_datetime(value) - datetime.now(UTC)
|
||||
).total_seconds()
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return 60.0
|
||||
import math
|
||||
|
||||
return max(0.0, seconds) if math.isfinite(seconds) else 60.0
|
||||
|
||||
def _request_once(self, endpoint, payload, write, binary):
|
||||
if not self.key:
|
||||
raise FlowError(E.CONFIG_REQUIRED, "KoKonna configuration required", 503)
|
||||
self._reserve()
|
||||
try:
|
||||
response = self.session.request(
|
||||
"GET" if binary else "POST",
|
||||
url,
|
||||
BASE_URL + endpoint,
|
||||
headers={}
|
||||
if binary
|
||||
else {
|
||||
@@ -51,49 +108,140 @@ class SafeTransport:
|
||||
timeout=(5, 120 if endpoint == "/upload" else 30),
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException:
|
||||
except requests.RequestException as exc:
|
||||
# Never retain exception text or a URL: binary URLs contain the key.
|
||||
category = next(
|
||||
(
|
||||
label
|
||||
for kind, label in (
|
||||
(requests.ConnectTimeout, "connect_timeout"),
|
||||
(requests.ReadTimeout, "read_timeout"),
|
||||
(requests.exceptions.SSLError, "tls_error"),
|
||||
(requests.ConnectionError, "connection_error"),
|
||||
(requests.Timeout, "timeout"),
|
||||
)
|
||||
if isinstance(exc, kind)
|
||||
),
|
||||
"request_error",
|
||||
)
|
||||
not_sent = isinstance(exc, requests.ConnectTimeout)
|
||||
uncertain = write and not not_sent
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE,
|
||||
"Cloud write outcome is unknown"
|
||||
if write
|
||||
if uncertain
|
||||
else "Device cloud is unavailable",
|
||||
502,
|
||||
details={
|
||||
"transport_error": category,
|
||||
"retryable": (not write and category != "tls_error") or not_sent,
|
||||
"write_outcome": "unknown"
|
||||
if uncertain
|
||||
else ("not_sent" if write else "read_only"),
|
||||
},
|
||||
) from None
|
||||
if response.status_code != 200:
|
||||
code = (
|
||||
E.RATE_LIMITED
|
||||
if response.status_code == 429
|
||||
else (
|
||||
E.WRITE_RESULT_UNKNOWN
|
||||
if write and response.status_code >= 500
|
||||
else E.CLOUD_UNAVAILABLE
|
||||
)
|
||||
)
|
||||
raise FlowError(
|
||||
code,
|
||||
"Device cloud request failed",
|
||||
429 if code == E.RATE_LIMITED else 502,
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
try:
|
||||
value = response.json()
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError()
|
||||
# Cloud errors may echo credentials: never return raw messages.
|
||||
if value.get("success") is False or (
|
||||
"message" in value and "id" not in value
|
||||
value = None
|
||||
if not binary or response.status_code != 200:
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError:
|
||||
pass
|
||||
message = value.get("message") if isinstance(value, dict) else None
|
||||
status = response.status_code
|
||||
# Only exact documented business failures establish rejection. Generic
|
||||
# 5xx responses may arrive after a write; those remain UNKNOWN.
|
||||
business = None
|
||||
if status in (404, 500) and isinstance(message, str):
|
||||
if message == "image not found":
|
||||
business = (
|
||||
E.NOT_FOUND,
|
||||
"Cloud image not found",
|
||||
404,
|
||||
"image_not_found",
|
||||
)
|
||||
elif status == 500 and message == "imageId is empty":
|
||||
business = (
|
||||
E.INVALID_REQUEST,
|
||||
"Cloud image ID is invalid",
|
||||
400,
|
||||
"invalid_image_id",
|
||||
)
|
||||
elif status == 500 and message.startswith("can not find robot "):
|
||||
business = (
|
||||
E.CONFIG_REQUIRED,
|
||||
"Device key does not identify a frame",
|
||||
503,
|
||||
"device_key_invalid",
|
||||
)
|
||||
elif status == 404 and message.startswith("can not find method "):
|
||||
business = (
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Cloud method not found",
|
||||
422,
|
||||
"method_not_found",
|
||||
)
|
||||
if business:
|
||||
code, safe_message, local_status, kind = business
|
||||
raise FlowError(
|
||||
code,
|
||||
safe_message,
|
||||
local_status,
|
||||
details={
|
||||
"http_status": status,
|
||||
"failure_kind": kind,
|
||||
"retryable": False,
|
||||
"write_outcome": "rejected" if write else "read_only",
|
||||
},
|
||||
)
|
||||
if status != 200:
|
||||
limited = status == 429
|
||||
rejected = 400 <= status < 500
|
||||
uncertain = write and not rejected
|
||||
details = {
|
||||
"http_status": status,
|
||||
"retryable": limited or (not write and status >= 500),
|
||||
"write_outcome": "unknown"
|
||||
if uncertain
|
||||
else ("rejected" if write else "read_only"),
|
||||
}
|
||||
if limited:
|
||||
delay = self._retry_after(response.headers.get("Retry-After"))
|
||||
details["retry_after_seconds"] = delay
|
||||
with self.lock:
|
||||
self.cooldown_until = max(
|
||||
self.cooldown_until, time.monotonic() + delay
|
||||
)
|
||||
raise FlowError(
|
||||
E.RATE_LIMITED
|
||||
if limited
|
||||
else (E.WRITE_RESULT_UNKNOWN if uncertain else E.CLOUD_UNAVAILABLE),
|
||||
"Device cloud request failed",
|
||||
429 if limited else 502,
|
||||
details=details,
|
||||
)
|
||||
if binary:
|
||||
if len(response.content) > 20 * 1024 * 1024:
|
||||
raise FlowError(E.INVALID_REQUEST, "Cloud image exceeds size limit")
|
||||
return response.content
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("success") is False
|
||||
or ("message" in value and "id" not in value)
|
||||
):
|
||||
raise ValueError()
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
details={
|
||||
"failure_kind": "invalid_response",
|
||||
"retryable": not write,
|
||||
"write_outcome": "unknown" if write else "read_only",
|
||||
},
|
||||
)
|
||||
return value
|
||||
except ValueError:
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN if write else E.CLOUD_UNAVAILABLE,
|
||||
"Invalid device cloud response",
|
||||
502,
|
||||
) from None
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
class KokonnaClient:
|
||||
@@ -118,7 +266,7 @@ class KokonnaClient:
|
||||
def upload(self, path, name):
|
||||
self.guard()
|
||||
value = guooscar_protocol.upload(self.transport, path.read_bytes(), name)
|
||||
if not isinstance(value.get("id"), int) or value["id"] <= 0:
|
||||
if type(value.get("id")) is not int or value["id"] <= 0:
|
||||
raise FlowError(E.WRITE_RESULT_UNKNOWN, "Upload ID not confirmed", 502)
|
||||
return value["id"]
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class ErrorCode(StrEnum):
|
||||
|
||||
|
||||
class FlowError(Exception):
|
||||
def __init__(self, code: ErrorCode, message: str, status: int = 400):
|
||||
def __init__(self, code: ErrorCode, message: str, status: int = 400, details=None):
|
||||
self.code, self.message, self.status = code, message, status
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
@@ -138,6 +138,7 @@ def install_webhooks(app, service):
|
||||
allowed = {
|
||||
"operation.completed",
|
||||
"operation.failed",
|
||||
"operation.blocked",
|
||||
"display.changed",
|
||||
"device.online",
|
||||
"device.offline",
|
||||
|
||||
@@ -177,7 +177,17 @@ class OperationResult(BaseModel):
|
||||
deferred: bool = False
|
||||
|
||||
|
||||
class RecoveryState(BaseModel):
|
||||
state: Literal["checking", "manual_review", "resolved"]
|
||||
reason: str
|
||||
first_checked_at: str
|
||||
last_checked_at: str
|
||||
checks: int
|
||||
requires_manual_review: bool
|
||||
|
||||
|
||||
class Operation(BaseModel):
|
||||
recovery: RecoveryState | None = None
|
||||
operation_id: str
|
||||
status: OperationStatus
|
||||
device_id: str
|
||||
@@ -323,7 +333,24 @@ class History(BaseModel):
|
||||
timestamp: str
|
||||
|
||||
|
||||
class GalleryDestination(BaseModel):
|
||||
id: str
|
||||
adapter: str
|
||||
name: str
|
||||
storage: Literal["cloud", "local"]
|
||||
connected: bool = False
|
||||
supports_upload: bool = False
|
||||
|
||||
|
||||
class DeviceIdentity(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SettingsView(BaseModel):
|
||||
storage_name: str = "NAS"
|
||||
gallery_destinations: list[GalleryDestination] = Field(default_factory=list)
|
||||
device_catalog: list[DeviceIdentity] = Field(default_factory=list)
|
||||
version: str
|
||||
mock: bool
|
||||
write_enabled: bool
|
||||
@@ -336,7 +363,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.2"
|
||||
version: str = "0.1.7"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -366,7 +393,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "Pigway Frame Flow"
|
||||
version: str = "0.1.2"
|
||||
version: str = "0.1.7"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -27,6 +27,11 @@ class Settings:
|
||||
default_factory=lambda: secret("FRAME_FLOW_TOKEN"), repr=False
|
||||
)
|
||||
additional_secrets: list[str] = field(default_factory=list, repr=False)
|
||||
storage_name: str = field(
|
||||
default_factory=lambda: (
|
||||
os.getenv("FRAME_FLOW_STORAGE_NAME", "NAS").strip() or "NAS"
|
||||
)
|
||||
)
|
||||
device_id: str = "living-room"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
cors_origins: list[str] = field(
|
||||
|
||||
@@ -31,6 +31,7 @@ class Database:
|
||||
CREATE TABLE IF NOT EXISTS image_sources (device_id TEXT, image_id INTEGER, asset_id TEXT, operation_id TEXT, payload TEXT NOT NULL, PRIMARY KEY(device_id,image_id));
|
||||
CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, payload TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS playlists (id TEXT PRIMARY KEY, payload TEXT, position INTEGER, next_run TEXT);
|
||||
CREATE TABLE IF NOT EXISTS operation_recovery (operation_id TEXT PRIMARY KEY, state TEXT NOT NULL, reason TEXT NOT NULL, first_checked_at TEXT NOT NULL, last_checked_at TEXT NOT NULL, checks INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS operations (
|
||||
id TEXT PRIMARY KEY, status TEXT NOT NULL, action TEXT NOT NULL,
|
||||
payload TEXT NOT NULL, device_id TEXT NOT NULL, client_id TEXT NOT NULL,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
'use strict';
|
||||
const $=s=>document.querySelector(s);
|
||||
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
|
||||
function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
|
||||
async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
|
||||
function forgetLogin(){state.galleryQuery='';state.galleryOrder='newest';state.galleryJobsRequest=null;state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
|
||||
async function loadConnection(){
|
||||
const [settings,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/templates')]);
|
||||
const devices=settings.device_catalog||await api('/api/v1/devices').catch(error=>{toast(error.message);return [];});
|
||||
state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device;
|
||||
if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);});
|
||||
}
|
||||
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
|
||||
const t=k=>window.MESSAGES[state.lang][k]||k;
|
||||
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
@@ -27,15 +32,46 @@ async function api(path,options={}){
|
||||
function bind(id,fn){const element=$('#'+id);if(element)element.onclick=async event=>{try{element.disabled=true;await fn(event);}catch(e){toast(e.message);}finally{element.disabled=false;}};}
|
||||
function busy(fn){return async event=>{try{await fn(event);}catch(e){toast(e.message);}};}
|
||||
function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);document.documentElement.dataset.theme=dark?'dark':'light';}
|
||||
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':''}"><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(d.name)}</option>`).join('');}
|
||||
function clearBlobs(){for(const url of state.blobs)URL.revokeObjectURL(url);state.blobs=[];}
|
||||
async function blobImage(img,path){try{const blob=await api(path,{binary:true});if(!img.isConnected)return;const url=URL.createObjectURL(blob);state.blobs.push(url);img.src=url;}catch{img.alt=t('apiError');}}
|
||||
function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>observer.observe(img));}
|
||||
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
|
||||
window.addEventListener('resize',revealCurrentNavigation);
|
||||
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(d.name)}</option>`).join('');revealCurrentNavigation();}
|
||||
// A destination appears only when its connection and read/write adapter are available.
|
||||
// Future cloud-drive destinations must implement their adapter before registration here.
|
||||
const galleryDestinationAdapters=new Set(['kokonna','local-assets']);
|
||||
function galleryDestinations(){
|
||||
const configured=state.settings?.gallery_destinations;
|
||||
const destinations=Array.isArray(configured)?configured:[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:state.settings?.storage_name||'NAS',storage:'local',connected:true,supports_upload:true}];
|
||||
return destinations.filter(d=>d.connected&&d.supports_upload&&galleryDestinationAdapters.has(d.adapter));
|
||||
}
|
||||
function galleryDestinationName(destination){return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;}
|
||||
function imageLoading(img){
|
||||
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
||||
const box=document.createElement('div');box.className='image-loading';
|
||||
if(img.classList.contains('thumb'))box.classList.add('image-loading-thumb');
|
||||
if(img.closest('.frame-demo, .image-viewer-viewport, .studio-region'))box.classList.add('image-loading-fill');
|
||||
box.setAttribute('aria-busy','true');img.before(box);box.append(img);
|
||||
const status=document.createElement('span');status.className='image-loading-status';status.setAttribute('role','status');status.setAttribute('aria-label',t('loading'));
|
||||
const spinner=document.createElement('span');spinner.className='spinner';spinner.setAttribute('aria-hidden','true');status.append(spinner);box.append(status);return box;
|
||||
}
|
||||
async function blobImage(img,path){
|
||||
const box=imageLoading(img);
|
||||
try{
|
||||
const blob=await api(path,{binary:true});if(!img.isConnected)return;
|
||||
const url=URL.createObjectURL(blob);state.blobs.push(url);
|
||||
// Keep the placeholder until the browser has decoded the image as well as fetched it.
|
||||
img.src=url;await img.decode();if(!img.isConnected)return;box.replaceWith(img);
|
||||
}catch{
|
||||
box.setAttribute('aria-busy','false');box.classList.add('image-load-error');
|
||||
const status=box.querySelector('.image-loading-status');status.removeAttribute('aria-label');status.textContent=t('imageLoadError');
|
||||
}
|
||||
}
|
||||
function hydrateImages(){const observer=new IntersectionObserver(entries=>entries.forEach(entry=>{if(entry.isIntersecting){observer.unobserve(entry.target);blobImage(entry.target,entry.target.dataset.src);}}),{rootMargin:'100px'});document.querySelectorAll('img[data-src]').forEach(img=>{imageLoading(img);observer.observe(img);});}
|
||||
function notice(){return state.settings?.mock?`<div class="notice">${t('mock')}</div>`:'';}
|
||||
async function navigate(page=state.page){state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();clearBlobs();state.epoch++;const epoch=state.epoch;
|
||||
async function navigate(page=state.page){const previousPage=state.page;state.page=icons[page]?page:'dashboard';location.hash=state.page;shell();state.epoch++;const epoch=state.epoch;
|
||||
if(!state.token){login();return;}
|
||||
$('#main').innerHTML='<div class="spinner"></div>';
|
||||
try{await views[state.page](epoch);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,gallery:enhanceGallery,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
|
||||
if(previousPage!==state.page)$('#main').replaceChildren();
|
||||
const previousBlobs=state.blobs;state.blobs=[];
|
||||
try{await views[state.page](epoch);for(const url of previousBlobs)URL.revokeObjectURL(url);if(epoch!==state.epoch)return;const enhance={dashboard:enhanceDashboard,templates:enhanceTemplates,settings:enhanceSettings,upload:enhanceUpload,history:enhanceHistory,automation:enhanceAutomation}[state.page];if(enhance)await enhance();guardDeviceButtons();}catch(e){if(epoch!==state.epoch)return;if(!state.token){shell();login();toast(e.message);return;}$('#main').innerHTML=`<div class="card empty"><strong>${t('apiError')}</strong><p>${esc(e.message)}</p>${buttons('retry','refresh')}</div>`;bind('retry',()=>navigate());}
|
||||
}
|
||||
function login(){ $('#main').innerHTML=`<div class="card" style="max-width:560px;margin:40px auto"><span class="badge">FRAME FLOW</span><h2 style="margin-top:20px">${t('connect')}</h2><p class="hint">${t('tokenHint')}</p><form id="login-form"><div class="field"><label for="username">${t('username')}</label><input id="username" required autocomplete="username"></div><div class="field"><label for="password">${t('password')}</label><input id="password" type="password" required autocomplete="current-password"></div><label class="check-row"><input id="remember-login" type="checkbox" checked>${t('rememberLogin')}</label><button class="primary" type="submit">${t('connect')}</button></form></div>`;$('#login-form').onsubmit=busy(async e=>{e.preventDefault();try{const account=await api('/api/v1/auth/login',{method:'POST',body:{username:$('#username').value.trim(),password:$('#password').value,remember:$('#remember-login').checked}});state.token=account.username;state.csrf=account.csrf_token;await loadConnection();await navigate();}catch(error){forgetLogin();shell();login();toast(error.message);}});}
|
||||
// LAN HTTP is not a secure context; getRandomValues remains available there.
|
||||
@@ -80,8 +116,23 @@ const views={
|
||||
studio:()=>layoutStudioPage(),
|
||||
connections:()=>connectionsPage(),
|
||||
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
|
||||
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||
async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});},
|
||||
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||
async gallery(epoch){
|
||||
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
|
||||
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
|
||||
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
|
||||
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
|
||||
render(cached||[],!cached);
|
||||
try{
|
||||
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
|
||||
if(epoch!==state.epoch)return;cache.set(key,items);
|
||||
if(!cached||JSON.stringify(cached)!==JSON.stringify(items))render(items);
|
||||
}catch(error){
|
||||
if(epoch!==state.epoch)return;if(!state.token)throw error;
|
||||
const status=$('#gallery-list-status');status.hidden=false;status.setAttribute('role','alert');status.textContent=error.message;
|
||||
$('.gallery')?.setAttribute('aria-busy','false');
|
||||
}
|
||||
},
|
||||
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 automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
@@ -93,7 +144,7 @@ function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_i
|
||||
async function jobEditor(job){const j=job||{name:'',enabled:true,device_id:state.device,schedule:{type:'daily',time:'07:00'},source:{type:'template',template:'daily-dashboard',data:{}},display_duration:5400,restore_previous:true,priority:50};showModal(`<h2>${t(job?'edit':'newJob')}</h2><form id="job-form"><div class="field"><label for="job-name">${t('name')}</label><input id="job-name" required maxlength="120" value="${esc(j.name)}"></div><div class="form-grid"><div class="field"><label for="job-schedule">${t('schedule')}</label><select id="job-schedule">${['daily','weekdays','weekends','cron','date','temporary'].map(x=>`<option ${j.schedule.type===x?'selected':''} value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label id="schedule-label" for="schedule-value">${t('time')}</label><input id="schedule-value" required value="${esc(j.schedule.time||j.schedule.cron||(j.schedule.at?j.schedule.at.slice(0,16):'07:00'))}"></div></div><div class="field"><label for="job-source">${t('source')} (JSON)</label><textarea id="job-source">${esc(JSON.stringify(j.source,null,2))}</textarea></div><div class="form-grid"><div class="field"><label for="job-duration">${t('duration')}</label><input id="job-duration" type="number" min="0" max="604800" value="${j.display_duration}"></div><div class="field"><label for="job-priority">${t('priority')}</label><input id="job-priority" type="number" min="0" max="100" value="${j.priority}"></div></div><label class="check-row"><input type="checkbox" id="job-enabled" ${j.enabled?'checked':''}>${t('enabled')}</label><label class="check-row"><input type="checkbox" id="job-restore" ${j.restore_previous?'checked':''}>${t('restoreAfter')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);const source=$('#job-source');const choose=document.createElement('div');choose.className='field';choose.innerHTML=`<label for="job-template">${t('template')}</label><select id="job-template"><option value="">${t('advanced')}</option>${state.templates.map(x=>`<option value="${esc(x.id)}" ${j.source.template===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select>`;source.parentElement.before(choose);$('#job-template').onchange=()=>{const id=$('#job-template').value;if(id)source.value=JSON.stringify({type:'template',template:id,data:{}},null,2);};function update(){const type=$('#job-schedule').value;$('#schedule-label').textContent=t(type==='cron'?'cron':['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';}$('#job-schedule').onchange=()=>{update();$('#schedule-value').value='';};update();$('#job-form').onsubmit=busy(async e=>{e.preventDefault();const type=$('#job-schedule').value,value=$('#schedule-value').value;const schedule={type};if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse($('#job-source').value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?'/'+job.id:''),{method:job?'PUT':'POST',body:payload});$('#modal').close();await navigate();});await jobSourceEditor(j);}
|
||||
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('mode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||
applyTheme();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>Pigway Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||
|
||||
// Keep Cron available for exceptional schedules, but make ordinary schedules form-based.
|
||||
|
||||
@@ -2,24 +2,25 @@
|
||||
const tr=(zh,en)=>state.lang==='zh'?zh:en;
|
||||
async function layoutStudioPage(){
|
||||
const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null;
|
||||
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label>${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label>${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label>${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label>${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label>${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label>${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
|
||||
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label for="studio-title">${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label for="studio-message">${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label for="studio-style">${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label for="studio-orientation">${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label for="studio-profile">${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label for="studio-prompt">${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
|
||||
await autoCollagePanel();
|
||||
let rendered=null;
|
||||
const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;};
|
||||
$('#studio-assets').onchange=()=>{selected=new Set([...document.querySelectorAll('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();};
|
||||
$('#studio-files').onchange=busy(async()=>{const files=[...$('#studio-files').files];if(!files.length)return;const body=new FormData();files.forEach(f=>body.append('files',f));const uploaded=await api('/api/v1/layout/images',{method:'POST',body});for(const a of uploaded){const label=document.createElement('label');label.className='check-row';label.innerHTML=`<input type="checkbox" checked value="${a.asset_id}">${esc(a.name)}`;$('#studio-assets').append(label);}$('#studio-assets').onchange();toast(t('done'));});
|
||||
async function showPlan(){
|
||||
const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=plan.width+'/'+plan.height;
|
||||
$('#studio-region-editor').innerHTML=plan.regions.map((r,i)=>`<div class="field"><label>${tr('图片区域','Photo region')} ${i+1}</label><div class="region-fields">${['x','y','width','height'].map(k=>`<label>${k}<input data-region="${i}" data-key="${k}" type="number" value="${r[k]}" min="0"></label>`).join('')}</div></div>`).join('');
|
||||
const update=(el,r)=>{el.style.left=(r.x/plan.width*100)+'%';el.style.top=(r.y/plan.height*100)+'%';el.style.width=(r.width/plan.width*100)+'%';el.style.height=(r.height/plan.height*100)+'%';};
|
||||
for(const [i,r] of plan.regions.entries()){
|
||||
const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;update(img,r);box.append(img);await blobImage(img,'/api/v1/assets/'+r.asset_id);
|
||||
img.onpointerdown=e=>{e.preventDefault();img.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};img.onpointermove=ev=>{r.x=Math.max(0,Math.min(plan.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*plan.width/box.clientWidth)));r.y=Math.max(150,Math.min(plan.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*plan.height/box.clientHeight)));update(img,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};img.onpointerup=()=>{img.onpointermove=null;};};
|
||||
function showPlan(){
|
||||
const layout=plan;
|
||||
const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=layout.width+'/'+layout.height;
|
||||
$('#studio-region-editor').innerHTML=layout.regions.map((r,i)=>`<div class="field"><label>${tr('图片区域','Photo region')} ${i+1}</label><div class="region-fields">${['x','y','width','height'].map(k=>`<label>${k}<input data-region="${i}" data-key="${k}" type="number" value="${r[k]}" min="0"></label>`).join('')}</div></div>`).join('');
|
||||
const update=(el,r)=>{el.style.left=(r.x/layout.width*100)+'%';el.style.top=(r.y/layout.height*100)+'%';el.style.width=(r.width/layout.width*100)+'%';el.style.height=(r.height/layout.height*100)+'%';};
|
||||
for(const [i,r] of layout.regions.entries()){
|
||||
const region=document.createElement('div');region.className='studio-region';region.dataset.regionIndex=String(i);update(region,r);box.append(region);const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;region.append(img);void blobImage(img,'/api/v1/assets/'+r.asset_id);
|
||||
region.onpointerdown=e=>{if(plan!==layout)return;e.preventDefault();region.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};region.onpointermove=ev=>{if(plan!==layout)return;r.x=Math.max(0,Math.min(layout.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*layout.width/box.clientWidth)));r.y=Math.max(150,Math.min(layout.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*layout.height/box.clientHeight)));update(region,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};region.onpointerup=region.onpointercancel=()=>{region.onpointermove=null;};};
|
||||
}
|
||||
document.querySelectorAll('[data-region]').forEach(el=>el.oninput=()=>{const r=plan.regions[Number(el.dataset.region)];r[el.dataset.key]=Number(el.value);update(box.children[Number(el.dataset.region)],r);invalidate();});
|
||||
document.querySelectorAll('[data-region]').forEach(el=>el.oninput=()=>{if(plan!==layout)return;const r=layout.regions[Number(el.dataset.region)];r[el.dataset.key]=Number(el.value);update(box.children[Number(el.dataset.region)],r);invalidate();});
|
||||
}
|
||||
for(const id of ['studio-title','studio-message','studio-style','studio-orientation','studio-ai','studio-prompt','studio-profile'])$('#'+id).oninput=()=>{plan=null;$('#studio-render').disabled=true;invalidate();};
|
||||
bind('studio-plan',async()=>{plan=await api('/api/v1/layout/plan',{method:'POST',body:{asset_ids:[...selected],title:$('#studio-title').value,message:$('#studio-message').value,orientation:$('#studio-orientation').value,style:$('#studio-style').value,use_ai:$('#studio-ai').checked,prompt:$('#studio-prompt').value}});invalidate();await showPlan();$('#studio-render').disabled=false;});
|
||||
bind('studio-plan',async()=>{plan=await api('/api/v1/layout/plan',{method:'POST',body:{asset_ids:[...selected],title:$('#studio-title').value,message:$('#studio-message').value,orientation:$('#studio-orientation').value,style:$('#studio-style').value,use_ai:$('#studio-ai').checked,prompt:$('#studio-prompt').value}});invalidate();showPlan();$('#studio-render').disabled=false;});
|
||||
bind('studio-render',async()=>{if(!plan)return;const accepted=await api('/api/v1/render',{method:'POST',body:{type:'template',template:$('#studio-style').value==='poster'?'poster':'collage',data:{layout:plan},profile:$('#studio-profile').value}});const op=await waitOperation(accepted.operation_id);rendered=op.result;$('#studio-preview').className='';$('#studio-preview').innerHTML='<img class="preview" id="studio-result" alt="Preview">';await blobImage($('#studio-result'),rendered.url);$('#studio-display').disabled=!liveWriteAllowed();$('#studio-download').disabled=false;});
|
||||
bind('studio-display',()=>displayDialog({type:'image',asset_id:rendered.asset_id}));bind('studio-download',()=>downloadImage(rendered.url,'frame-flow-layout.jpg'));
|
||||
}
|
||||
@@ -28,34 +29,34 @@ async function connectionsPage(){
|
||||
await aiSettingsPanel();await aiImagePanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();await mqttPanel();
|
||||
}
|
||||
async function aiSettingsPanel(){
|
||||
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label>Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label>Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label>${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
|
||||
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label for="ai-endpoint">Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label for="ai-model">Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label for="ai-key">${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
|
||||
}
|
||||
async function storageSettingsPanel(){
|
||||
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label>${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label>${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label>${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
|
||||
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label for="storage-days">${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label for="storage-cache-days">${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label for="storage-size">${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
|
||||
}
|
||||
async function clientsPanel(){
|
||||
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label>Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
|
||||
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label for="client-id">Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="client-name">${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
|
||||
}
|
||||
async function webhooksPanel(){
|
||||
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label>Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label>${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
||||
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label for="hook-url">Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label for="hook-events">${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
|
||||
}
|
||||
async function externalProvidersPanel(){
|
||||
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label>ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="provider-name" required></div><div class="field"><label>${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label>${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label>${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
|
||||
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label for="provider-id">ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label for="provider-name">${t('name')}</label><input id="provider-name" required></div><div class="field"><label for="provider-kind">${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label for="provider-url">${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label for="provider-key">${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
|
||||
}
|
||||
async function officialWidgetControls(info,data){
|
||||
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label>${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
|
||||
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label for="official-widget-choice">${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
|
||||
const fields=()=>{const w=data.catalog.find(w=>w.id===Number($('#official-widget-choice').value));$('#official-widget-args').innerHTML=(w?.arg_keys||[]).map(k=>`<div class="field"><label>${esc(k)}</label><input data-widget-arg="${esc(k)}" maxlength="2000"></div>`).join('');};if(data.current)$('#official-widget-choice').value=data.current.id;fields();$('#official-widget-choice').onchange=fields;
|
||||
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
|
||||
}
|
||||
function addPhotoQualityFields(source){
|
||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label>${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label>${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label>${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label>${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label for="source-people-space">${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
|
||||
|
||||
}
|
||||
async function aiImagePanel(){
|
||||
const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 生图','AI image generation')}</h3><p class="hint">${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}</p><form id="image-ai-config"><div class="field"><label>Endpoint</label><input id="image-ai-url" type="url" required value="${esc(conf.endpoint||'')}"></div><div class="field"><label>Model</label><input id="image-ai-model" required value="${esc(conf.model||'')}"></div><div class="field"><label>Size</label><select id="image-ai-size"><option>1024x1536</option><option>1536x1024</option><option>1024x1024</option></select></div><div class="field"><label>API Key</label><input id="image-ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(info.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form><form id="image-ai-generate"><div class="field"><label>${tr('画面描述','Image prompt')}</label><textarea id="image-ai-prompt" required maxlength="4000"></textarea></div><button type="submit">${tr('生成一张(可能收费)','Generate one image (may incur charges)')}</button></form><div id="image-ai-preview"></div>`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='<img class="preview" id="image-ai-result" alt="Generated image">';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));});
|
||||
const info=await api('/api/v1/settings/ai-images');const conf=info.config||{};const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 生图','AI image generation')}</h3><p class="hint">${tr('独立配置 images/generations 接口。每次仅生成一张并保存本地,不会自动显示到画框。请求可能收费,失败不自动重试。','Configure a separate images/generations endpoint. One image is saved locally per request, never automatically displayed. Requests may be billed; failures are not retried.')}</p><form id="image-ai-config"><div class="field"><label for="image-ai-url">Endpoint</label><input id="image-ai-url" type="url" required value="${esc(conf.endpoint||'')}"></div><div class="field"><label for="image-ai-model">Model</label><input id="image-ai-model" required value="${esc(conf.model||'')}"></div><div class="field"><label for="image-ai-size">Size</label><select id="image-ai-size"><option>1024x1536</option><option>1536x1024</option><option>1024x1024</option></select></div><div class="field"><label for="image-ai-key">API Key</label><input id="image-ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(info.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form><form id="image-ai-generate"><div class="field"><label for="image-ai-prompt">${tr('画面描述','Image prompt')}</label><textarea id="image-ai-prompt" required maxlength="4000"></textarea></div><button type="submit">${tr('生成一张(可能收费)','Generate one image (may incur charges)')}</button></form><div id="image-ai-preview"></div>`;$('#main').append(card);$('#image-ai-size').value=conf.size||'1024x1536';$('#image-ai-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-images',{method:'PUT',body:{endpoint:$('#image-ai-url').value,model:$('#image-ai-model').value,size:$('#image-ai-size').value}});const key=$('#image-ai-key').value;$('#image-ai-key').value='';if(key)await api('/api/v1/settings/ai-images/key',{method:'PUT',body:{key}});toast(t('done'));});$('#image-ai-generate').onsubmit=busy(async e=>{e.preventDefault();const result=await api('/api/v1/ai/images',{method:'POST',body:{prompt:$('#image-ai-prompt').value},headers:{'Idempotency-Key':'ai-image-'+clientUUID()}});const op=await waitOperation(result.operation_id);$('#image-ai-preview').innerHTML='<img class="preview" id="image-ai-result" alt="Generated image">';await blobImage($('#image-ai-result'),op.result.url);toast(tr('已存入本地图库','Saved to local gallery'));});
|
||||
}
|
||||
async function mqttPanel(){
|
||||
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label>Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label>Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label>Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label>${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
|
||||
const status=await api('/api/v1/integrations/mqtt');const c=status.config;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>MQTT · ${t(status.connected?'connected':'disconnected')}</h3><p class="hint">${tr('默认关闭。须配置环境 Secret FRAME_FLOW_MQTT_TOKEN(有 display:write 权限的客户端 Token),以及可选 FRAME_FLOW_MQTT_PASSWORD。消息必须携带 request_id;retained 消息被拒绝。结果发送到命令主题的 /result。','Disabled by default. Configure FRAME_FLOW_MQTT_TOKEN with display:write scope and optional FRAME_FLOW_MQTT_PASSWORD as environment secrets. Commands require request_id; retained commands are rejected. Results are published to the command topic plus /result.')}</p><form id="mqtt-config"><label class="check-row"><input id="mqtt-enabled" type="checkbox" ${c.enabled?'checked':''}>${t('enabled')}</label><div class="field"><label for="mqtt-host">Host</label><input id="mqtt-host" value="${esc(c.host)}"></div><div class="field"><label for="mqtt-port">Port</label><input id="mqtt-port" type="number" min="1" max="65535" value="${c.port}"></div><div class="field"><label for="mqtt-topic">Topic</label><input id="mqtt-topic" value="${esc(c.topic)}"></div><div class="field"><label for="mqtt-username">${t('username')}</label><input id="mqtt-username" value="${esc(c.username)}"></div><label class="check-row"><input id="mqtt-tls" type="checkbox" ${c.tls?'checked':''}>TLS</label><label class="check-row"><input id="mqtt-lan" type="checkbox" ${c.allow_insecure_lan?'checked':''}>${tr('明确允许 LAN 明文连接','Explicitly allow plaintext LAN connection')}</label><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#mqtt-config').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/integrations/mqtt',{method:'PUT',body:{enabled:$('#mqtt-enabled').checked,host:$('#mqtt-host').value,port:Number($('#mqtt-port').value),topic:$('#mqtt-topic').value,username:$('#mqtt-username').value,tls:$('#mqtt-tls').checked,allow_insecure_lan:$('#mqtt-lan').checked}});toast(t('done'));});
|
||||
}
|
||||
|
||||
async function autoCollagePanel(){
|
||||
|
||||
@@ -14,7 +14,7 @@ function editTemplateSource(source){state.selectedTemplate=source.template;state
|
||||
async function enhanceDashboard(){
|
||||
const epoch=state.epoch;const frame=$('.frame-demo');const panel=document.createElement('section');panel.className='card';$('#main').append(panel);
|
||||
const current=await currentPanel(panel);if(epoch!==state.epoch)return;
|
||||
if(current.preview_url){frame.innerHTML=`<img id="current-frame-image" alt="${t('currentContent')}" style="width:100%;height:100%;object-fit:contain"><span class="frame-label">${t('currentContent')} #${current.image_id}</span>`;await blobImage($('#current-frame-image'),current.preview_url);}
|
||||
if(current.preview_url){frame.innerHTML=`<img id="current-frame-image" alt="${t('currentContent')}" style="width:100%;height:100%;object-fit:contain"><span class="frame-label">${t('currentContent')} #${current.image_id}</span>`;void blobImage($('#current-frame-image'),current.preview_url);}else{frame.innerHTML=`<div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span>`;}
|
||||
const weather=await api('/api/v1/providers/weather');if(epoch!==state.epoch)return;const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weather')}</h3><p>${esc(weather.current.location)} · ${esc(weather.current.temperature)} · ${esc(weather.current.summary)}</p><p class="hint">${weather.current.mock?t('weatherMock'):t('weatherAttribution')}${weather.current.stale?' · '+t('stale'):''}</p>`;$('#main').append(box);
|
||||
}
|
||||
async function enhanceTemplates(){
|
||||
@@ -31,12 +31,13 @@ function displayDialog(content){
|
||||
showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
|
||||
$('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');});
|
||||
}
|
||||
async function enhanceGallery(){
|
||||
function enhanceGallery(){
|
||||
const grid=$('.gallery');if(!grid)return;
|
||||
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('mode')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
||||
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';const jobs=await api('/api/v1/jobs');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;$('#asset-job').append(option);}$('#asset-job').value=state.assetJob||'';$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
|
||||
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
||||
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
|
||||
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
||||
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}});
|
||||
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
|
||||
}
|
||||
async function weatherSettings(){
|
||||
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">Provider</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><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 class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.2</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"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></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="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.7</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"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
|
||||
|
||||
@@ -104,3 +104,50 @@ form>.modal-actions{margin-top:8px}
|
||||
.auto-collage{margin-bottom:24px}.auto-collage h2{margin-bottom:12px}.auto-collage>p{margin-bottom:20px}.auto-collage #collage-preview:not(:empty){margin-top:20px}.auto-collage>.actions{margin-top:16px}.manual-collage>summary{cursor:pointer;font-weight:600}.manual-collage[open]>summary{margin-bottom:24px}
|
||||
|
||||
.source-identity{overflow-wrap:anywhere}
|
||||
|
||||
.image-loading{isolation:isolate;position:relative;display:grid;place-items:center;width:100%;min-height:160px;background:var(--soft);border-radius:12px;overflow:hidden}
|
||||
.image-loading>img{visibility:hidden;grid-area:1/1;min-width:0}
|
||||
.image-loading-thumb{min-height:0;aspect-ratio:3/4;border-radius:10px}
|
||||
.image-loading-fill{height:100%;min-height:0;border-radius:0}
|
||||
.image-loading-status{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;text-align:center;color:var(--muted);font-size:13px}
|
||||
.image-loading-status .spinner{margin:0;flex-shrink:0}
|
||||
@media(prefers-reduced-motion:reduce){.image-loading-status .spinner{animation:none}}
|
||||
|
||||
|
||||
|
||||
/* Keep the horizontally scrollable navigation labels on one line. */
|
||||
@media(max-width:720px){
|
||||
.sidebar{height:76px;padding:0 6px}
|
||||
nav{min-width:max-content;gap:2px}
|
||||
nav button{flex:0 0 auto;min-width:76px;height:70px;padding:8px;gap:4px;white-space:nowrap}
|
||||
.workspace{padding-bottom:92px}
|
||||
#toast{bottom:96px}
|
||||
}
|
||||
@media(max-width:480px){
|
||||
header{flex-wrap:wrap;gap:14px}
|
||||
header>div:first-child{flex-basis:100%}
|
||||
header .toolbar{width:100%}
|
||||
header .toolbar select{flex:1;max-width:none}
|
||||
}
|
||||
|
||||
/* Short landscape viewports must keep every navigation item reachable. */
|
||||
@media(min-width:721px) and (max-height:700px){
|
||||
.sidebar{overflow-y:auto}
|
||||
.sidebar>.brand,.sidebar>nav,.sidebar>.sidebar-bottom{flex-shrink:0}
|
||||
.sidebar-bottom{margin-top:24px}
|
||||
}
|
||||
@media(max-width:720px){
|
||||
.sidebar{height:calc(76px + env(safe-area-inset-bottom,0px));padding-bottom:env(safe-area-inset-bottom,0px)}
|
||||
.workspace{padding-bottom:calc(92px + env(safe-area-inset-bottom,0px))}
|
||||
#toast{bottom:calc(96px + env(safe-area-inset-bottom,0px))}
|
||||
}
|
||||
|
||||
/* Region geometry belongs to the editor, independent of image load state. */
|
||||
.studio-region{position:absolute;border:2px solid var(--accent);cursor:move;touch-action:none;overflow:hidden}
|
||||
.studio-canvas .studio-region img{position:static;display:block;width:100%;height:100%;object-fit:cover;border:0;pointer-events:none}
|
||||
.studio-region>.image-loading{height:100%;min-height:0;border-radius:0}
|
||||
|
||||
.gallery-tabs{max-width:100%;flex-wrap:wrap}
|
||||
.gallery-tabs button{min-width:0;white-space:normal;overflow-wrap:anywhere}
|
||||
|
||||
.gallery:empty{display:none}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.2"
|
||||
version = "0.1.7"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -10,7 +10,7 @@ from urllib.request import Request, urlopen
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", choices=["record", "check"])
|
||||
args = parser.parse_args()
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:18787").rstrip("/")
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:6005").rstrip("/")
|
||||
token = os.getenv("FRAME_FLOW_TOKEN", "")
|
||||
if not token:
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
|
||||
@@ -403,7 +403,11 @@ class Deploy:
|
||||
service["image"] = latest
|
||||
proposed = self.secret_config()
|
||||
service["environment"].update(proposed)
|
||||
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
|
||||
for key in (
|
||||
"SYNOLOGY_PHOTOS_URL",
|
||||
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
||||
"FRAME_FLOW_STORAGE_NAME",
|
||||
):
|
||||
if self.env.get(key):
|
||||
service["environment"][key] = self.env[key]
|
||||
service["environment"].update(
|
||||
|
||||
@@ -40,5 +40,8 @@ os.environ["BUILD_COMMIT"] = (
|
||||
|
||||
os.environ["BUILD_TIME"] = datetime.now(UTC).isoformat()
|
||||
uvicorn.run(
|
||||
"pigway_frame_flow.main:app", host="127.0.0.1", port=18787, access_log=False
|
||||
"pigway_frame_flow.main:app",
|
||||
host="127.0.0.1",
|
||||
port=int(os.getenv("WEB_PORT", "6005")),
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Mock vendor expiration must preserve first-party login and show reconnect. */
|
||||
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 errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:18787');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();assert(await page.evaluate(()=>state.settings.mock));await page.route('**/api/v1/devices/*/widgets',r=>r.fulfill({status:401,json:{error:{code:'OFFICIAL_AUTH_REQUIRED',message:'Official account session expired',request_id:'fixture',details:{}}}}));await page.route('**/api/v1/devices/*/official/connection',r=>r.fulfill({json:{authorized:false,username:'fixture',robot_id:null,mock:true,devices:[]}}));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.locator('#official-login-form').waitFor();assert(await page.evaluate(()=>Boolean(state.token&&state.csrf)));assert(await page.evaluate(async()=>Boolean((await api('/api/v1/auth/me')).username)));await page.route('**/api/v1/test-first-party-expired',r=>r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Expired',request_id:'fixture',details:{}}}}));await page.evaluate(async()=>{try{await api('/api/v1/test-first-party-expired');}catch{}});assert(await page.evaluate(()=>state.token===''));assert(errors.length===0);await browser.close();console.log('Audit UI: official expiry preserves login, reconnect visible, own expiry clears login; PASS');})().catch(e=>{console.error(e.message);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 errors=[];page.on('pageerror',e=>errors.push(e.message));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();assert(await page.evaluate(()=>state.settings.mock));await page.route('**/api/v1/devices/*/widgets',r=>r.fulfill({status:401,json:{error:{code:'OFFICIAL_AUTH_REQUIRED',message:'Official account session expired',request_id:'fixture',details:{}}}}));await page.route('**/api/v1/devices/*/official/connection',r=>r.fulfill({json:{authorized:false,username:'fixture',robot_id:null,mock:true,devices:[]}}));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.locator('#official-login-form').waitFor();assert(await page.evaluate(()=>Boolean(state.token&&state.csrf)));assert(await page.evaluate(async()=>Boolean((await api('/api/v1/auth/me')).username)));await page.route('**/api/v1/test-first-party-expired',r=>r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Expired',request_id:'fixture',details:{}}}}));await page.evaluate(async()=>{try{await api('/api/v1/test-first-party-expired');}catch{}});assert(await page.evaluate(()=>state.token===''));assert(errors.length===0);await browser.close();console.log('Audit UI: official expiry preserves login, reconnect visible, own expiry clears login; PASS');})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Mock-only: slow device status and cloud metadata must not block real gallery controls. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);const b=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{const p=await b.newPage({extraHTTPHeaders:{Authorization:'Bearer '+token}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));let releaseStatus,releaseList;const pending=[];let checks=0;
|
||||
await p.route('**/api/v1/devices',async r=>{pending.push('device');await new Promise(resolve=>releaseStatus=resolve);await r.fulfill({json:[{id:'living-room',name:'Fixture frame'}]});});
|
||||
await p.route('**/api/v1/devices/*/images',async r=>{pending.push('list');await new Promise(resolve=>releaseList=resolve);await r.fulfill({json:[]});});
|
||||
await p.goto(base+'/#gallery');await p.locator('#gallery-search').waitFor({timeout:5000});for(let i=0;i<100&&pending.length<2;i++)await p.waitForTimeout(20);assert.deepEqual(pending.sort(),['device','list']);assert(await p.locator('#gallery-upload').isEnabled());assert(await p.locator('#local-tab').isEnabled());assert.equal(await p.locator('.image-card,.placeholder-line,.image-pending,.page-loading').count(),0);checks++;if(process.env.FRAME_FLOW_SCREENSHOT_DIR){fs.mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/gallery-list-pending.png'});}
|
||||
await p.locator('#gallery-search').fill('kept query');await p.locator('#gallery-sort').selectOption('name');releaseStatus();releaseList();await p.waitForFunction(()=>document.querySelector('.gallery')?.getAttribute('aria-busy')==='false');assert.equal(await p.locator('#gallery-search').inputValue(),'kept query');assert.equal(await p.locator('#gallery-sort').inputValue(),'name');assert.equal(await p.locator('.card.empty').count(),1);checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Cold gallery bootstrap:',checks,'checks PASS; device/cloud requests blocked independently, real controls ready, input preserved');
|
||||
}finally{await b.close();}})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -5,7 +5,7 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Current fixture',current:true,url:'/mock-image.jpg',created_at:null},{image_id:2,name:'Other fixture',current:false,url:'/mock-image.jpg',created_at:null}]}));
|
||||
page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;});
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:18787');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();assert(await page.evaluate(()=>state.settings.mock));
|
||||
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();assert(await page.evaluate(()=>state.settings.mock));
|
||||
for(const lang of ['zh','en'])for(const width of [1440,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="gallery"]').click();await page.locator('[data-show="0"]').waitFor();assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Mock-only: real writable destinations, concrete names and responsive labels. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);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();
|
||||
await page.evaluate(()=>{state.settings.storage_name='FixtureNAS';state.settings.gallery_destinations=[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:'FixtureNAS',storage:'local',connected:true,supports_upload:true},{id:'read-only-drive',adapter:'kokonna',name:'Read-only fixture',storage:'cloud',connected:true,supports_upload:false},{id:'offline-drive',adapter:'kokonna',name:'Offline fixture',storage:'cloud',connected:false,supports_upload:true},{id:'unimplemented-drive',adapter:'unimplemented',name:'Future fixture',storage:'cloud',connected:true,supports_upload:true}];});let checks=0;
|
||||
for(const width of [320,390,768,1440])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;return navigate('gallery');},{lang,theme});
|
||||
assert.equal(await page.locator('#cloud-tab').innerText(),lang==='zh'?'KoKonna 云端图库':'KoKonna cloud gallery');assert.equal(await page.locator('#local-tab').innerText(),lang==='zh'?'FixtureNAS 本地图库':'FixtureNAS Local gallery');assert.equal(await page.locator('[data-gallery-destination]').count(),2);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
await page.setViewportSize({width:320,height:740});await page.evaluate(()=>{state.settings.gallery_destinations.find(d=>d.id==='local').name='家庭照片存储服务器名称很长的NAS';return navigate('gallery');});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
await page.locator('#local-tab').click();assert.equal(await page.evaluate(()=>state.tab),'local');await page.locator('#cloud-tab').click();assert.equal(await page.evaluate(()=>state.tab),'cloud');checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Gallery destinations:',checks,'checks PASS; concrete labels, capability/connection/adapter gates, responsive layout and tab switching');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -6,7 +6,7 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
await page.route('**/api/v1/assets/local-1/star',r=>{starred=r.request().postDataJSON().starred;return r.fulfill({json:{starred,expires_at:null}});});
|
||||
page.on('request',r=>{if(r.method()==='POST'&&r.url().endsWith('/display'))writes++;});
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:18787');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();assert(await page.evaluate(()=>state.settings.mock));
|
||||
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();assert(await page.evaluate(()=>state.settings.mock));
|
||||
for(const lang of ['zh','en'])for(const width of [1440,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="gallery"]').click();await page.locator('#local-tab').click();await page.locator('[data-show="0"]').waitFor();const star=page.locator('.image-card').first().locator('button[aria-pressed]');assert(await star.count()===1);const original=await star.getAttribute('aria-pressed');await star.click();await page.waitForFunction(expected=>document.querySelector('.image-card button[aria-pressed]')?.getAttribute('aria-pressed')===expected,original==='true'?'false':'true');assert(await page.locator('[data-show="0"]').isDisabled());assert(await page.locator('[data-show="0"]').textContent()===(lang==='zh'?'当前图片':'Current image'));assert(await page.locator('[data-show="1"]').isEnabled());await page.locator('[data-show="0"]').evaluate(b=>b.onclick());assert(!await page.locator('#modal').evaluate(d=>d.open));assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
|
||||
@@ -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('http://127.0.0.1:18787');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/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)});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Isolated Mock UI audit: delayed images stay inside their own placeholders. */
|
||||
const assert=require('assert');
|
||||
const fs=require('fs');
|
||||
const {chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);
|
||||
const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({json:[{image_id:1,name:'Loading fixture',url:'/ui-audit-image.svg',current:false}]}));
|
||||
let release;let requested;
|
||||
await page.route('**/ui-audit-image.svg*',async r=>{requested();await new Promise(resolve=>release=resolve);await r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#167253"/></svg>'});});
|
||||
await page.goto(base);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;
|
||||
async function centered(){
|
||||
const result=await page.locator('.image-loading').evaluate(box=>{
|
||||
const spinner=box.querySelector('.spinner'),img=box.querySelector('img'),a=box.getBoundingClientRect(),b=spinner.getBoundingClientRect();
|
||||
return {centered:Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1,hidden:getComputedStyle(img).visibility==='hidden',contained:box.querySelector('.image-loading-status').offsetParent===box,overflow:document.documentElement.scrollWidth>innerWidth};
|
||||
});assert.deepStrictEqual(result,{centered:true,hidden:true,contained:true,overflow:false});checks++;
|
||||
assert.equal(await page.locator('.spinner:not(.image-loading .spinner)').count(),0);checks++;
|
||||
}
|
||||
for(const width of [1440,768,390])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(theme=>document.documentElement.dataset.theme=theme,theme);
|
||||
const request=new Promise(resolve=>requested=resolve);await page.evaluate(()=>navigate('gallery'));await request;await centered();
|
||||
const before=await page.locator('.image-loading').boundingBox();release();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));
|
||||
const after=await page.locator('.thumb').boundingBox();assert(Math.abs(before.height-after.height)<1,'Thumbnail placeholder changes height');checks++;
|
||||
const originalRequest=new Promise(resolve=>requested=resolve);await page.locator('[data-original]').click();await originalRequest;await centered();release();await page.waitForFunction(()=>document.querySelector('#original-image')?.naturalWidth===300&&!document.querySelector('.image-loading'));await page.locator('#modal-cancel').click();checks++;
|
||||
await page.evaluate(async()=>{const img=document.querySelector('.thumb');await blobImage(img,'/ui-audit-missing-image');});
|
||||
assert.equal(await page.locator('.image-load-error').count(),1);assert.equal(await page.locator('.image-load-error .spinner').count(),0);assert(await page.locator('.image-load-error').innerText());checks++;
|
||||
}
|
||||
await page.evaluate(()=>navigate('connections'));
|
||||
assert.deepStrictEqual(await page.locator('#main input,#main select,#main textarea').evaluateAll(nodes=>nodes.filter(e=>e.checkVisibility()&&!e.labels.length&&!e.getAttribute('aria-label')&&!e.getAttribute('aria-labelledby')).map(e=>e.id)),[]);checks++;
|
||||
assert.equal(await page.locator('nav [aria-current="page"]').count(),1);checks++;
|
||||
const nav=await page.locator('nav button').evaluateAll(nodes=>nodes.map(e=>({height:e.getBoundingClientRect().height,nowrap:getComputedStyle(e).whiteSpace})));assert(nav.every(e=>e.height>=44&&e.nowrap==='nowrap'));checks++;
|
||||
assert.deepStrictEqual(errors,[]);console.log('Image/UI consistency audit:',checks,'passed; desktop/tablet/mobile, light/dark; no page errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -9,16 +9,20 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
||||
await page.route('**/api/v1/integrations/synology-photos',async route=>{if(route.request().method()==='POST'){assert.deepStrictEqual(route.request().postDataJSON(),{username:'fixture-nas',password:'fixture-only'});connected=true;logins++;}await route.fulfill({json:{configured:true,connected}});});
|
||||
await page.route('**/api/v1/photo-sources',async route=>{assert(route.request().method()==='GET');await route.fulfill({json:[{id:'fixture-source',name:'NAS photos / NAS 照片',kind:'local',location:'.',enabled:false,people_filter:true,orientation:'auto'}]});});
|
||||
await page.goto(base);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();
|
||||
const viewports=process.env.FRAME_FLOW_VIEWPORTS?JSON.parse(process.env.FRAME_FLOW_VIEWPORTS):[1440,768,390].map(width=>({width,height:900}));
|
||||
let checks=0;const failures=[];const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
|
||||
async function inspect(label){
|
||||
const result=await page.evaluate(()=>{
|
||||
const root=document.querySelector('dialog[open]')||document.querySelector('#main');const controls=[...root.querySelectorAll('button,select,input:not([type=checkbox]):not([type=radio]):not([type=file]),textarea')].filter(e=>e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true})&&!e.closest('[hidden]')).map(e=>({id:e.id||e.textContent.trim().slice(0,24),r:e.getBoundingClientRect()}));const close=[];
|
||||
for(let i=0;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){const a=controls[i].r,b=controls[j].r;const x=Math.min(a.right,b.right)-Math.max(a.left,b.left),y=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dx=Math.max(a.left,b.left)-Math.min(a.right,b.right),dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom);if((x>1&&dy<7.5)||(y>1&&dx<7.5))close.push([controls[i].id,controls[j].id]);}
|
||||
return {overflow:document.documentElement.scrollWidth>innerWidth,close,small:innerWidth<=720?controls.filter(e=>e.r.height<43.5).map(e=>e.id):[]};
|
||||
});checks++;if(result.overflow||result.close.length||result.small.length)failures.push({label,...result});
|
||||
const dialog=document.querySelector('dialog[open]');const rect=dialog?.getBoundingClientRect();
|
||||
const clippedDialog=!!rect&&(rect.left<0||rect.right>innerWidth||rect.top<0||rect.bottom>innerHeight);
|
||||
const clippedNav=innerWidth<=720&&[...document.querySelectorAll('nav button')].some(button=>{const r=button.getBoundingClientRect();return button.scrollHeight>button.clientHeight||r.height<44;});
|
||||
return {clippedDialog,clippedNav,overflow:document.documentElement.scrollWidth>innerWidth,close,small:innerWidth<=720?controls.filter(e=>e.r.height<43.5).map(e=>e.id):[]};
|
||||
});checks++;if(result.clippedDialog||result.clippedNav||result.overflow||result.close.length||result.small.length)failures.push({label,...result});
|
||||
}
|
||||
for(const width of [1440,768,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
||||
for(const {width,height} of viewports)for(const lang of ['zh','en'])for(const theme of ['light','dark']){
|
||||
await page.setViewportSize({width,height});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});
|
||||
for(const view of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings','studio','connections']){await page.evaluate(view=>navigate(view),view);await inspect(`${view}/${width}/${lang}/${theme}`);}
|
||||
await page.evaluate(()=>navigate('studio'));await page.locator('.manual-collage summary').click();await inspect(`Manual collage/${width}/${lang}/${theme}`);await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await inspect(`Photos login/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
await page.locator('#add-photo-source').click();assert(await page.locator('#source-credential').isHidden());assert(await page.locator('#source-oauth').isHidden());await inspect(`NAS source/${width}/${lang}/${theme}`);await page.locator('#source-kind').selectOption('google-drive');assert(await page.locator('#source-oauth').isVisible());assert(await page.locator('#source-local-quality').isHidden());await inspect(`Drive source/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
|
||||
@@ -29,6 +33,6 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
|
||||
if(out&&lang==='zh'&&theme==='dark'){await page.evaluate(()=>navigate('photo-sources'));await page.screenshot({path:out+`/photos-${width}.png`,fullPage:true});}
|
||||
}
|
||||
await page.evaluate(()=>navigate('photo-sources'));await page.locator('#photos-login').click();await page.locator('#photos-login-user').fill('fixture-nas');await page.locator('#photos-login-password').fill('fixture-only');await page.locator('#photos-login-form button[type=submit]').click();await page.locator('#photos-login').filter({hasText:'Sign in again'}).waitFor();assert(logins===1);checks++;
|
||||
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 6 modal states, 12 viewport/language/theme combinations; console errors:',errors.length);
|
||||
assert.deepStrictEqual(errors,[]);assert.deepStrictEqual(failures,[],JSON.stringify(failures));console.log('Layout audit:',checks,'passed; 11 pages plus local gallery, 6 modal states, '+(viewports.length*4)+' viewport/language/theme combinations; console errors:',errors.length);
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* All-page layout and daily-poetry checks; Mock only, no real device writes. */
|
||||
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();let unintended=0;page.on('request',r=>{if(r.method()==='POST'&&/\/(jobs|playlists|display)$/.test(r.url()))unintended++;});const base='http://127.0.0.1:18787';await page.goto(base);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();assert(await page.evaluate(()=>state.settings.mock));let checks=0;for(const lang of ['zh','en'])for(const writable of [false,true]){const clean=await page.evaluate(({lang,writable})=>{const previous={lang:state.lang,settings:state.settings};state.lang=lang;state.settings={...state.settings,mock:false,write_enabled:writable};shell();const clean=notice()===''&&!document.querySelector('#connection')&&!document.querySelector('.sidebar-bottom .live-dot');state.lang=previous.lang;state.settings=previous.settings;shell();return clean;},{lang,writable});assert(clean,'Live-device status must not appear in either language');checks++;}for(const width of [1440,390])for(const theme of ['light','dark']){await page.setViewportSize({width,height:1000});await page.evaluate(theme=>{localStorage.setItem('ff-theme',theme);document.documentElement.dataset.theme=theme;},theme);for(const name of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings']){await page.locator(`[data-page="${name}"]`).click();await page.waitForTimeout(350);if(name==='settings')await page.locator('#image-host-form').waitFor();const result=await page.evaluate(()=>{const children=[...document.querySelector('#main').children].filter(x=>x.getBoundingClientRect().height>0);return {overflow:document.documentElement.scrollWidth>innerWidth,buttonGaps:[...document.querySelectorAll('#main button')].flatMap(b=>{const next=b.nextElementSibling;if(next?.tagName!=='BUTTON')return [];const a=b.getBoundingClientRect(),c=next.getBoundingClientRect();if(!a.width||!c.width)return [];return [Math.abs(a.top-c.top)<4?c.left-a.right:c.top-a.bottom];}),gaps:children.slice(1).map((x,i)=>x.getBoundingClientRect().top-children[i].getBoundingClientRect().bottom)};});const controls=await page.evaluate(()=>{const nodes=[...document.querySelectorAll('#main input:not([type="checkbox"]):not([type="radio"]),#main select,#main textarea,#main button')].filter(x=>{const r=x.getBoundingClientRect();return r.width&&r.height&&getComputedStyle(x).visibility!=='hidden';});const issues=[];for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){const a=nodes[i].getBoundingClientRect(),b=nodes[j].getBoundingClientRect();const ox=Math.min(a.right,b.right)-Math.max(a.left,b.left),oy=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom),dx=Math.max(a.left,b.left)-Math.min(a.right,b.right);if((ox>Math.min(a.width,b.width)/2&&dy>=0&&dy<8)||(oy>Math.min(a.height,b.height)/2&&dx>=0&&dx<8))issues.push({a:nodes[i].id,b:nodes[j].id,gap:ox>0?dy:dx});}return issues;});assert(controls.length===0,`${name} ${width} ${theme} controls ${JSON.stringify(controls)}`);checks++;
|
||||
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();let unintended=0;page.on('request',r=>{if(r.method()==='POST'&&/\/(jobs|playlists|display)$/.test(r.url()))unintended++;});const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005';await page.goto(base);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();assert(await page.evaluate(()=>state.settings.mock));let checks=0;for(const lang of ['zh','en'])for(const writable of [false,true]){const clean=await page.evaluate(({lang,writable})=>{const previous={lang:state.lang,settings:state.settings};state.lang=lang;state.settings={...state.settings,mock:false,write_enabled:writable};shell();const clean=notice()===''&&!document.querySelector('#connection')&&!document.querySelector('.sidebar-bottom .live-dot');state.lang=previous.lang;state.settings=previous.settings;shell();return clean;},{lang,writable});assert(clean,'Live-device status must not appear in either language');checks++;}for(const width of [1440,390])for(const theme of ['light','dark']){await page.setViewportSize({width,height:1000});await page.evaluate(theme=>{localStorage.setItem('ff-theme',theme);document.documentElement.dataset.theme=theme;},theme);for(const name of ['dashboard','gallery','photo-sources','upload','templates','automation','playlists','history','settings']){await page.locator(`[data-page="${name}"]`).click();await page.waitForTimeout(350);if(name==='settings')await page.locator('#image-host-form').waitFor();const result=await page.evaluate(()=>{const children=[...document.querySelector('#main').children].filter(x=>x.getBoundingClientRect().height>0);return {overflow:document.documentElement.scrollWidth>innerWidth,buttonGaps:[...document.querySelectorAll('#main button')].flatMap(b=>{const next=b.nextElementSibling;if(next?.tagName!=='BUTTON')return [];const a=b.getBoundingClientRect(),c=next.getBoundingClientRect();if(!a.width||!c.width)return [];return [Math.abs(a.top-c.top)<4?c.left-a.right:c.top-a.bottom];}),gaps:children.slice(1).map((x,i)=>x.getBoundingClientRect().top-children[i].getBoundingClientRect().bottom)};});const controls=await page.evaluate(()=>{const nodes=[...document.querySelectorAll('#main input:not([type="checkbox"]):not([type="radio"]),#main select,#main textarea,#main button')].filter(x=>{const r=x.getBoundingClientRect();return r.width&&r.height&&getComputedStyle(x).visibility!=='hidden';});const issues=[];for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){const a=nodes[i].getBoundingClientRect(),b=nodes[j].getBoundingClientRect();const ox=Math.min(a.right,b.right)-Math.max(a.left,b.left),oy=Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top);const dy=Math.max(a.top,b.top)-Math.min(a.bottom,b.bottom),dx=Math.max(a.left,b.left)-Math.min(a.right,b.right);if((ox>Math.min(a.width,b.width)/2&&dy>=0&&dy<8)||(oy>Math.min(a.height,b.height)/2&&dx>=0&&dx<8))issues.push({a:nodes[i].id,b:nodes[j].id,gap:ox>0?dy:dx});}return issues;});assert(controls.length===0,`${name} ${width} ${theme} controls ${JSON.stringify(controls)}`);checks++;
|
||||
assert(result.buttonGaps.every(x=>x>=8),`${name} ${width} button gaps ${result.buttonGaps}`);assert(!result.overflow,`${name} ${width} overflow`);assert(result.gaps.every(x=>x>=16),`${name} ${width} gaps ${result.gaps}`);checks++;if(name==='templates'||name==='settings')await page.screenshot({path:`docs/screenshots/${name}-${width}-${theme}.png`,fullPage:true});}}
|
||||
await page.locator('[data-page="templates"]').click();await page.locator('[data-template="daily-poetry"]').click();await page.locator('#poem-choice').waitFor();assert(await page.locator('#poem-choice option').count()===21);checks++;await page.locator('#poem-choice').selectOption('jing-ye-si');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.waitForFunction(()=>document.querySelector('#template-image')?.naturalWidth===1200);checks++;await page.locator('#daily-poetry-job').click();assert(!await page.locator('#poetry-job-enabled').isChecked());checks++;const gap=await page.evaluate(()=>{const a=document.querySelector('#daily-poetry-job').getBoundingClientRect(),b=document.querySelector('#template-fields').getBoundingClientRect();return b.top-a.bottom;});assert(gap>=20,'Poetry action touches fields');checks++;await page.locator('#modal-cancel').click();await page.screenshot({path:'docs/screenshots/daily-poetry-controls-mobile-dark.png',fullPage:true});for(const width of [1440,390])for(const theme of ['light','dark']){await page.setViewportSize({width,height:1000});await page.evaluate(theme=>document.documentElement.dataset.theme=theme,theme);await page.locator('[data-page="templates"]').click();for(const id of ['calendar','daily-dashboard','daily-poetry','photo-text','quote','system-alert','weather']){await page.locator(`[data-template="${id}"]`).click();await page.waitForTimeout(250);await page.locator('#download-template').waitFor();if(id==='photo-text'){const photoGap=await page.evaluate(()=>document.querySelector('#template-photo-upload').getBoundingClientRect().top-document.querySelector('#field-photo_id').getBoundingClientRect().bottom);assert(photoGap>=12,`${width} ${theme} photo controls touch`);checks++;}const gap=await page.evaluate(()=>{const a=document.querySelector('#display-template').getBoundingClientRect(),b=document.querySelector('#download-template').getBoundingClientRect();return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});assert(gap>=8,`${id} ${width} preview buttons touch`);checks++;}for(const [name,button] of [['automation','new-job'],['playlists','new-playlist'],['templates','daily-poetry-job']]){await page.locator(`[data-page="${name}"]`).click();if(name==='templates'){await page.locator('[data-template="daily-poetry"]').click();await page.locator('#poem-choice').waitFor();}await page.locator('#'+button).click();const gap=await page.evaluate(()=>{const [a,b]=[...document.querySelectorAll('#modal .modal-actions button')].map(x=>x.getBoundingClientRect());return Math.abs(a.top-b.top)<4?b.left-a.right:b.top-a.bottom;});assert(gap>=8,`${name} ${width} modal buttons touch`);checks++;await page.locator('#modal-cancel').click();}}assert(unintended===0,`Cancel submitted ${unintended} requests`);checks++;await browser.close();console.log(`Layout/poetry browser: ${checks} passed (32 page/profile combinations)`);})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Local Mock only. No real official account or frame writes. */
|
||||
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 errors=[];let frameWrites=0;page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/**',route=>{const r=route.request();if(r.method()!=='GET'&&!r.url().includes('/auth/')&&!r.url().endsWith('/official/connection')){frameWrites++;return route.abort();}return route.continue();});await page.goto('http://127.0.0.1:18787');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();assert(await page.evaluate(()=>state.settings.mock));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.waitForTimeout(300);if(await page.locator('#official-disconnect').count())await page.locator('#official-disconnect').click();await page.locator('#official-username').fill('mock-official-user');await page.locator('#official-password').fill('mock-password-not-saved');await page.locator('#official-login-form button').click();await page.locator('#official-device').waitFor();await page.locator('#official-select-form button').click();await page.locator('.official-account-panel').waitFor();await page.reload();await page.locator('.official-content-info summary').click();await page.locator('#official-disconnect').waitFor();assert((await page.locator('.official-account-panel').textContent()).includes('已关联画框'));for(const width of [1440,390]){await page.setViewportSize({width,height:1000});assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth));}await page.locator('#official-disconnect').click();await page.locator('#official-login-form').waitFor();assert(await page.locator('#official-password').inputValue()==='');assert(!await page.evaluate(()=>JSON.stringify(localStorage).includes('mock-password-not-saved')));assert(errors.length===0&&frameWrites===0);await browser.close();console.log('Official connection UI: login, discover, select, reload, disconnect, 2 sizes PASS; 0 page errors, 0 frame writes');})().catch(e=>{console.error(e.message);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 errors=[];let frameWrites=0;page.on('pageerror',e=>errors.push(e.message));await page.route('**/api/**',route=>{const r=route.request();if(r.method()!=='GET'&&!r.url().includes('/auth/')&&!r.url().endsWith('/official/connection')){frameWrites++;return route.abort();}return route.continue();});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();assert(await page.evaluate(()=>state.settings.mock));await page.locator('[data-page="templates"]').click();await page.locator('.official-content-info summary').click();await page.waitForTimeout(300);if(await page.locator('#official-disconnect').count())await page.locator('#official-disconnect').click();await page.locator('#official-username').fill('mock-official-user');await page.locator('#official-password').fill('mock-password-not-saved');await page.locator('#official-login-form button').click();await page.locator('#official-device').waitFor();await page.locator('#official-select-form button').click();await page.locator('.official-account-panel').waitFor();await page.reload();await page.locator('.official-content-info summary').click();await page.locator('#official-disconnect').waitFor();assert((await page.locator('.official-account-panel').textContent()).includes('已关联画框'));for(const width of [1440,390]){await page.setViewportSize({width,height:1000});assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth));}await page.locator('#official-disconnect').click();await page.locator('#official-login-form').waitFor();assert(await page.locator('#official-password').inputValue()==='');assert(!await page.evaluate(()=>JSON.stringify(localStorage).includes('mock-password-not-saved')));assert(errors.length===0&&frameWrites===0);await browser.close();console.log('Official connection UI: login, discover, select, reload, disconnect, 2 sizes PASS; 0 page errors, 0 frame writes');})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
|
||||
@@ -7,7 +7,7 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
await page.route('**/api/v1/integrations/synology-photos',route=>route.fulfill({json:{configured:true,connected:true}}));
|
||||
await page.route('**/api/v1/integrations/synology-photos/people',route=>route.fulfill({json:[{id:1,name:'Family A',count:20,named:true},{id:2,name:'Family B',count:15,named:true},{id:3,name:'3',count:2,named:false}]}));
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:18787');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();assert(await page.evaluate(()=>state.settings.mock));
|
||||
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();assert(await page.evaluate(()=>state.settings.mock));
|
||||
for(const lang of ['zh','en'])for(const width of [1440,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="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('People fixture');await page.locator('#source-people-load').click();await page.locator('[data-person="1"]').waitFor();assert(await page.locator('[data-person]').count()===2);await page.locator('#people-unnamed').check();assert(await page.locator('[data-person]').count()===3);await page.locator('#people-search').fill('Family B');assert(await page.locator('[data-person]').count()===1);await page.locator('[data-person="2"]').check();await page.locator('#source-people-filter').check();await page.locator('#source-person-match').selectOption('all');assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
if(checks===0){await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-edit="${id}"]`).waitFor();const saved=await page.evaluate(async id=>(await api('/api/v1/photo-sources')).find(s=>s.id===id),id);assert(saved.people_filter&&saved.person_match==='all'&&saved.person_ids.join(',')==='2');await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-people-filter').isChecked());assert(await page.locator('#source-person-match').inputValue()==='all');}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
|
||||
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();await page.goto('http://127.0.0.1:18787');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();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,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="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-form').textContent().then(x=>x.includes('最新成功图')||x.includes('latest confirmed')));assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().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();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();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,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="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-form').textContent().then(x=>x.includes('最新成功图')||x.includes('latest confirmed')));assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
|
||||
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();await page.goto('http://127.0.0.1:18787');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();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,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="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().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();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();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="photo-sources"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#source-orientation').selectOption('landscape');await page.locator('#source-organized').check();await page.locator('#source-caption').check();await page.locator('#source-synology-previews').check();await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,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="photo-sources"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);assert(await page.locator('#source-orientation').inputValue()==='landscape');assert(await page.locator('#source-organized').isChecked());assert(await page.locator('#source-caption').isChecked());assert(await page.locator('#source-synology-previews').isChecked());await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);assert(await page.locator('#source-preview').evaluate(img=>img.naturalWidth===1600&&img.naturalHeight===1200));const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Mock-only: known image metadata renders real slots; no synthetic skeletons. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);
|
||||
const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
let releaseAccount;await page.route('**/api/v1/auth/me',async r=>{await new Promise(resolve=>releaseAccount=resolve);await r.continue();});
|
||||
await page.goto(base);await page.locator('#nav button').first().waitFor();assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);releaseAccount();
|
||||
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=1;
|
||||
for(const width of [1440,390]){
|
||||
await page.setViewportSize({width,height:844});await page.evaluate(()=>state.galleryCache?.clear());let releaseMetadata,metadataRequested,releaseImage,imageRequested;
|
||||
await page.route('**/api/v1/devices/*/images',async r=>{metadataRequested();await new Promise(resolve=>releaseMetadata=resolve);await r.fulfill({json:[{image_id:1,name:'Progressive fixture',url:'/progressive-fixture.svg',current:false}]});});
|
||||
await page.route('**/progressive-fixture.svg*',async r=>{assert.equal(await page.locator('#gallery-search').count(),1,'Search must precede image requests');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));imageRequested();await new Promise(resolve=>releaseImage=resolve);await r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"/>'});});
|
||||
const metadata=new Promise(resolve=>metadataRequested=resolve);const image=new Promise(resolve=>imageRequested=resolve);
|
||||
await page.evaluate(()=>{window.navigation=navigate('gallery');});await metadata;
|
||||
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.image-card').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
|
||||
assert(await page.locator('#cloud-tab').isVisible());assert(await page.locator('#local-tab').isEnabled());assert(await page.locator('#gallery-upload').isEnabled());assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#gallery-sort').isEnabled());assert(await page.locator('#gallery-list-status').isVisible());assert.equal(await page.locator('.card.empty').count(),0);await page.locator('#gallery-search').fill('Progressive');await page.locator('#gallery-sort').selectOption('name');checks++;
|
||||
releaseMetadata();await image;await page.evaluate(()=>window.navigation);
|
||||
assert.equal(await page.locator('#gallery-search').inputValue(),'Progressive');assert.equal(await page.locator('#gallery-sort').inputValue(),'name');assert(await page.locator('#gallery-upload').isEnabled());assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending').count(),0);checks++;
|
||||
assert(await page.locator('.image-loading').evaluate(box=>{const a=box.getBoundingClientRect(),b=box.querySelector('.spinner').getBoundingClientRect();return Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1;}));checks++;
|
||||
releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));checks++;
|
||||
await page.evaluate(()=>navigate('upload'));
|
||||
const cachedMetadata=new Promise(resolve=>metadataRequested=resolve);const cachedImage=new Promise(resolve=>imageRequested=resolve);
|
||||
await page.evaluate(()=>{window.navigation=navigate('gallery');});await cachedMetadata;await cachedImage;
|
||||
assert.equal(await page.locator('#gallery-search').count(),1,'Search must exist while cached metadata refresh is pending');assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'),'Search must be interactive');assert.equal(await page.locator('.image-card').count(),1);assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);checks++;
|
||||
releaseMetadata();await page.evaluate(()=>window.navigation);releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));
|
||||
|
||||
await page.unroute('**/api/v1/devices/*/images');await page.unroute('**/progressive-fixture.svg*');
|
||||
let releaseDevice,deviceRequested;await page.route('**/api/v1/devices/living-room',async r=>{deviceRequested();await new Promise(resolve=>releaseDevice=resolve);await r.continue();});
|
||||
const device=new Promise(resolve=>deviceRequested=resolve);await page.evaluate(()=>{window.navigation=navigate('dashboard');});await device;
|
||||
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
|
||||
releaseDevice();await page.evaluate(()=>window.navigation);await page.unroute('**/api/v1/devices/living-room');
|
||||
}
|
||||
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
|
||||
let releaseSlow,slowRequested;const slow=new Promise(resolve=>slowRequested=resolve);
|
||||
await page.route('**/api/v1/devices/*/images',async r=>{slowRequested();await new Promise(resolve=>releaseSlow=resolve);await r.fulfill({json:[]});});
|
||||
await page.evaluate(()=>{window.slowNavigation=navigate('gallery');});await slow;await page.locator('#local-tab').click();await page.waitForFunction(()=>state.tab==='local'&&document.querySelector('#local-tab')?.classList.contains('active'));releaseSlow();await page.evaluate(()=>window.slowNavigation);assert.equal(await page.evaluate(()=>state.tab),'local');assert(await page.locator('#gallery-search').isVisible());checks++;
|
||||
await page.unroute('**/api/v1/devices/*/images');
|
||||
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
|
||||
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({status:503,json:{error:{code:'CLOUD_UNAVAILABLE',message:'fixture unavailable'}}}));
|
||||
await page.evaluate(()=>navigate('gallery'));assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#local-tab').isEnabled());assert.equal(await page.locator('#gallery-list-status').getAttribute('role'),'alert');checks++;
|
||||
await page.unroute('**/api/v1/devices/*/images');
|
||||
let releaseJobs,jobsRequested;await page.route('**/api/v1/jobs',async r=>{jobsRequested();await new Promise(resolve=>releaseJobs=resolve);await r.fulfill({json:[]});});
|
||||
await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-fixture',name:'Local fixture',url:'/local-fixture.svg',origin:'manual',starred:false}]}));
|
||||
await page.route('**/local-fixture.svg',r=>r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"/>'}));
|
||||
const jobs=new Promise(resolve=>jobsRequested=resolve);await page.evaluate(()=>{state.tab='local';return navigate('gallery');});await jobs;
|
||||
assert.equal(await page.locator('.asset-select').count(),1);assert(await page.locator('#gallery-search').evaluate(e=>typeof e.oninput==='function'));await page.locator('#gallery-search').fill('no-match');assert(await page.locator('.image-card').evaluate(e=>e.hidden));releaseJobs();checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Known image positions without skeletons:',checks,'checks PASS; delayed bootstrap, metadata and images; no page errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -2,7 +2,7 @@
|
||||
const fs=require('fs');const path=require('path');const assert=require('assert');const {chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const uploadName='browser-upload-'+Date.now();const root=path.resolve(__dirname,'..');const env=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE||path.join(root,'.env'),'utf8');const token=env.split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice('FRAME_FLOW_TOKEN='.length);
|
||||
const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:18787';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode');
|
||||
const base=process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode');
|
||||
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000}});const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message.replaceAll(token,'[REDACTED]')));let checks=0;
|
||||
await page.addInitScript(()=>Object.defineProperty(window.crypto,'randomUUID',{value:undefined,configurable:true}));await page.goto(base);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();assert(await page.locator('h2').first().innerText()==='客厅相框');checks++;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Mock-only editor regression: parallel image requests, stable regions and live controls. */
|
||||
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
|
||||
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'studio-fixture-a',name:'A'},{asset_id:'studio-fixture-b',name:'B'}]}));
|
||||
await page.route('**/api/v1/layout/plan',r=>r.fulfill({json:{width:1200,height:1600,regions:[{asset_id:'studio-fixture-a',x:20,y:200,width:550,height:900},{asset_id:'studio-fixture-b',x:620,y:200,width:550,height:900}]}}));
|
||||
let requests,releases,failSecond=false;
|
||||
await page.route('**/api/v1/assets/studio-fixture-*',async r=>{const key=r.request().url().split('-').pop();requests.add(key);await new Promise(resolve=>releases[key]=resolve);await r.fulfill({contentType:'image/svg+xml',body:failSecond&&key==='b'?'invalid image':'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#167253"/></svg>'});});
|
||||
await page.goto(base);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 width of [1440,390])for(const theme of ['light','dark']){
|
||||
requests=new Set();releases={};await page.setViewportSize({width,height:900});await page.evaluate(theme=>{document.documentElement.dataset.theme=theme;return navigate('studio');},theme);await page.locator('.manual-collage summary').click();
|
||||
await page.locator('#studio-assets input').nth(0).check();await page.locator('#studio-assets input').nth(1).check();await page.locator('#studio-plan').click();
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.studio-region .spinner').length===2);for(let n=0;n<100&&requests.size<2;n++)await page.waitForTimeout(20);assert.equal(requests.size,2,'Both requests must start before either finishes');assert(await page.locator('#studio-render').isEnabled());checks++;
|
||||
assert(await page.locator('.studio-region').evaluateAll(regions=>regions.every(region=>{const a=region.getBoundingClientRect(),b=region.querySelector('.spinner').getBoundingClientRect();return Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1;})));checks++;
|
||||
const field=page.locator('[data-region="0"][data-key="x"]');await field.fill('40');await field.dispatchEvent('input');assert(await page.locator('.studio-region').first().evaluate(e=>Math.abs(parseFloat(e.style.left)-40/1200*100)<0.01));checks++;
|
||||
const before=await page.locator('.studio-region').nth(1).boundingBox();releases.b();await page.waitForFunction(()=>document.querySelectorAll('.studio-region')[1].querySelector('img').naturalWidth===300&&!document.querySelectorAll('.studio-region')[1].querySelector('.image-loading'));
|
||||
assert.equal(await page.locator('.studio-region').first().locator('.spinner').count(),1);assert.deepEqual(await page.locator('.studio-region').nth(1).boundingBox(),before);checks++;
|
||||
const region=page.locator('.studio-region').nth(1);await region.scrollIntoViewIfNeeded();const point=await region.boundingBox();const old=await page.locator('[data-region="1"][data-key="x"]').inputValue();await page.mouse.move(point.x+point.width/2,point.y+Math.min(point.height/2,80));await page.mouse.down();await page.mouse.move(point.x+point.width/2-10,point.y+Math.min(point.height/2,80)+5,{steps:3});await page.mouse.up();assert.notEqual(await page.locator('[data-region="1"][data-key="x"]').inputValue(),old);checks++;
|
||||
releases.a();await page.waitForFunction(()=>document.querySelectorAll('.studio-region img').length===2&&[...document.querySelectorAll('.studio-region img')].every(img=>img.naturalWidth===300)&&!document.querySelector('.studio-region .image-loading'));checks++;
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
|
||||
}
|
||||
requests=new Set();releases={};failSecond=true;await page.locator('#studio-plan').click();for(let n=0;n<100&&requests.size<2;n++)await page.waitForTimeout(20);assert.equal(requests.size,2);
|
||||
releases.b();await page.locator('.studio-region .image-load-error').waitFor();assert.equal(await page.locator('.studio-region').first().locator('.spinner').count(),1);assert(await page.locator('#studio-render').isEnabled());checks++;
|
||||
const errorField=page.locator('[data-region="1"][data-key="x"]');await errorField.fill('600');await errorField.dispatchEvent('input');assert.equal(await page.locator('.studio-region').nth(1).evaluate(e=>e.style.left),'50%');checks++;
|
||||
releases.a();await page.waitForFunction(()=>document.querySelector('.studio-region img')?.naturalWidth===300);assert.equal(await page.locator('.studio-region .spinner').count(),0);checks++;
|
||||
assert.deepEqual(errors,[]);console.log('Studio image loading:',checks,'checks PASS; parallel requests, independent completion, local spinners, coordinate editing and dragging; no page errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exit(1)});
|
||||
@@ -4,7 +4,7 @@ const assert=require('assert'),{chromium}=require('playwright');
|
||||
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));let status='AUTH_REQUIRED',checks=0;
|
||||
await page.route('**/api/v1/devices/*/widgets',route=>route.fulfill({json:{source:status==='AUTH_REQUIRED'?'snapshot':'live',configured:status!=='AUTH_REQUIRED',status,observed_at:'2026-10-02',catalog:[],current:null,current_verified:status==='NO_WIDGET',writable:false}}));
|
||||
await page.goto('http://127.0.0.1:18787');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();
|
||||
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();
|
||||
for(const lang of ['zh','en'])for(const value of ['AUTH_REQUIRED','NO_WIDGET','CURRENT_UNVERIFIED']){
|
||||
status=value;await page.evaluate(lang=>{state.lang=lang;return navigate('templates');},lang);
|
||||
await page.locator('.widget-auth-notice').waitFor({state:'attached'});
|
||||
|
||||
@@ -613,6 +613,16 @@
|
||||
},
|
||||
"Operation": {
|
||||
"properties": {
|
||||
"recovery": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RecoveryState"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"operation_id": {
|
||||
"type": "string",
|
||||
"title": "Operation Id"
|
||||
@@ -1422,5 +1432,48 @@
|
||||
"type"
|
||||
],
|
||||
"title": "AutoCollageContent"
|
||||
},
|
||||
"RecoveryState": {
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"checking",
|
||||
"manual_review",
|
||||
"resolved"
|
||||
],
|
||||
"title": "State"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"title": "Reason"
|
||||
},
|
||||
"first_checked_at": {
|
||||
"type": "string",
|
||||
"title": "First Checked At"
|
||||
},
|
||||
"last_checked_at": {
|
||||
"type": "string",
|
||||
"title": "Last Checked At"
|
||||
},
|
||||
"checks": {
|
||||
"type": "integer",
|
||||
"title": "Checks"
|
||||
},
|
||||
"requires_manual_review": {
|
||||
"type": "boolean",
|
||||
"title": "Requires Manual Review"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"state",
|
||||
"reason",
|
||||
"first_checked_at",
|
||||
"last_checked_at",
|
||||
"checks",
|
||||
"requires_manual_review"
|
||||
],
|
||||
"title": "RecoveryState"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,3 +423,35 @@ def test_scoped_client_operation_isolation(client, service):
|
||||
assert [op["operation_id"] for op in client.get("/api/v1/operations").json()] == [
|
||||
own
|
||||
]
|
||||
|
||||
|
||||
def test_settings_exposes_storage_display_name(client, service):
|
||||
service.settings.storage_name = "Family NAS"
|
||||
result = client.get("/api/v1/settings").json()
|
||||
assert result["storage_name"] == "Family NAS"
|
||||
destinations = {d["id"]: d for d in result["gallery_destinations"]}
|
||||
assert set(destinations) == {"cloud", "local"}
|
||||
assert destinations["cloud"]["name"] == "KoKonna"
|
||||
assert destinations["local"]["name"] == "Family NAS"
|
||||
assert all(d["connected"] and d["supports_upload"] for d in destinations.values())
|
||||
assert "test-frame-token" not in str(result)
|
||||
|
||||
|
||||
def test_storage_display_name_environment(monkeypatch):
|
||||
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " Family NAS ")
|
||||
assert Settings().storage_name == "Family NAS"
|
||||
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " ")
|
||||
assert Settings().storage_name == "NAS"
|
||||
|
||||
|
||||
def test_settings_device_catalog_does_not_fetch_cloud_status(
|
||||
client, service, monkeypatch
|
||||
):
|
||||
registry = service.registry
|
||||
for driver in registry.drivers.values():
|
||||
monkeypatch.setattr(
|
||||
driver, "device", lambda *a, **kw: pytest.fail("Cloud status fetched")
|
||||
)
|
||||
result = client.get("/api/v1/settings")
|
||||
assert result.status_code == 200
|
||||
assert {d["id"] for d in result.json()["device_catalog"]} == set(registry.drivers)
|
||||
|
||||
+199
-6
@@ -1,3 +1,4 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -7,11 +8,28 @@ from pigway_frame_flow.drivers.kokonna.client import KokonnaClient
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def transport_clock(monkeypatch):
|
||||
import pigway_frame_flow.drivers.kokonna.client as module
|
||||
|
||||
clock = SimpleNamespace(now=0.0, waits=[])
|
||||
|
||||
def sleep(seconds):
|
||||
clock.waits.append(seconds)
|
||||
clock.now += seconds
|
||||
|
||||
monkeypatch.setattr(
|
||||
module, "time", SimpleNamespace(monotonic=lambda: clock.now, sleep=sleep)
|
||||
)
|
||||
return clock
|
||||
|
||||
|
||||
def session(body=None, status=200):
|
||||
s = Mock()
|
||||
s.request.return_value.status_code = status
|
||||
s.request.return_value.json.return_value = body or {}
|
||||
s.request.return_value.content = b"image"
|
||||
s.request.return_value.headers = {}
|
||||
return s
|
||||
|
||||
|
||||
@@ -88,7 +106,7 @@ def test_timeout_redaction_no_retry(write):
|
||||
c.display(1) if write else c.get_device()
|
||||
assert "secret" not in str(e.value)
|
||||
assert e.value.code == ("WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE")
|
||||
assert s.request.call_count == 1
|
||||
assert s.request.call_count == (1 if write else 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 429, 500, 530])
|
||||
@@ -109,12 +127,187 @@ def test_invalid_upload_id(tmp_path):
|
||||
assert e.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
|
||||
|
||||
def test_rate_budget():
|
||||
def test_rate_budget(transport_clock):
|
||||
s = session({"nickname": "x"})
|
||||
c = KokonnaClient("secret", session=s)
|
||||
for _ in range(18):
|
||||
c.get_device()
|
||||
with pytest.raises(FlowError) as e:
|
||||
c.get_device()
|
||||
assert e.value.code == "RATE_LIMITED"
|
||||
assert s.request.call_count == 18
|
||||
c.get_device()
|
||||
assert s.request.call_count == 19
|
||||
assert transport_clock.waits == [60]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception,category",
|
||||
[
|
||||
(requests.ConnectTimeout, "connect_timeout"),
|
||||
(requests.ReadTimeout, "read_timeout"),
|
||||
(requests.exceptions.SSLError, "tls_error"),
|
||||
(requests.ConnectionError, "connection_error"),
|
||||
(requests.Timeout, "timeout"),
|
||||
(requests.RequestException, "request_error"),
|
||||
],
|
||||
)
|
||||
def test_transport_failure_records_safe_category_without_replay(exception, category):
|
||||
s = session()
|
||||
s.request.side_effect = exception("secret https://host/secret")
|
||||
c = KokonnaClient("secret", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == (
|
||||
"CLOUD_UNAVAILABLE" if category == "connect_timeout" else "WRITE_RESULT_UNKNOWN"
|
||||
)
|
||||
assert error.value.details["transport_error"] == category
|
||||
assert error.value.details["attempts"] == (
|
||||
3 if category == "connect_timeout" else 1
|
||||
)
|
||||
assert "secret" not in str(error.value.details)
|
||||
assert s.request.call_count == (3 if category == "connect_timeout" else 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("write", [False, True])
|
||||
def test_connect_timeout_retries_then_succeeds(write, transport_clock):
|
||||
s = session({"success": True})
|
||||
response = s.request.return_value
|
||||
s.request.side_effect = [requests.ConnectTimeout("private"), response]
|
||||
c = KokonnaClient("private", True, s)
|
||||
c.display(1) if write else c.get_device()
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message,code,kind",
|
||||
[
|
||||
("image not found", "NOT_FOUND", "image_not_found"),
|
||||
("imageId is empty", "INVALID_REQUEST", "invalid_image_id"),
|
||||
("can not find robot private", "CONFIG_REQUIRED", "device_key_invalid"),
|
||||
],
|
||||
)
|
||||
def test_documented_500_rejection_does_not_lock_or_retry(message, code, kind):
|
||||
s = session({"message": message}, 500)
|
||||
c = KokonnaClient("private", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == code
|
||||
assert error.value.details["write_outcome"] == "rejected"
|
||||
assert error.value.details["failure_kind"] == kind
|
||||
assert "private" not in str(error.value.details)
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 502, 503, 530])
|
||||
def test_generic_5xx_read_retries_but_write_never_replays(status):
|
||||
for write in [False, True]:
|
||||
s = session({"message": "unknown failure private"}, status)
|
||||
c = KokonnaClient("private", True, s)
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1) if write else c.get_device()
|
||||
assert s.request.call_count == (1 if write else 3)
|
||||
assert error.value.code == (
|
||||
"WRITE_RESULT_UNKNOWN" if write else "CLOUD_UNAVAILABLE"
|
||||
)
|
||||
assert "private" not in str(error.value.details)
|
||||
|
||||
|
||||
def test_rate_limit_honors_retry_after_and_closes_response(transport_clock):
|
||||
s = session({"message": "rate limited"}, 429)
|
||||
first = s.request.return_value
|
||||
first.headers = {"Retry-After": "7"}
|
||||
second = session({"success": True}).request.return_value
|
||||
s.request.side_effect = [first, second]
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [7]
|
||||
first.close.assert_called_once()
|
||||
second.close.assert_called_once()
|
||||
|
||||
|
||||
def test_long_retry_after_preserves_cooldown_without_early_request(transport_clock):
|
||||
s = session({"message": "rate limited"}, 429)
|
||||
s.request.return_value.headers = {"Retry-After": "120"}
|
||||
c = KokonnaClient("private", True, s)
|
||||
for _ in range(2):
|
||||
with pytest.raises(FlowError) as error:
|
||||
c.display(1)
|
||||
assert error.value.code == "RATE_LIMITED"
|
||||
assert s.request.call_count == 1
|
||||
assert transport_clock.waits == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", [None, "bad", "nan", "inf"])
|
||||
def test_invalid_retry_after_defaults_to_one_minute(header):
|
||||
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
|
||||
|
||||
assert SafeTransport._retry_after(header) == 60
|
||||
|
||||
|
||||
def test_retry_after_accepts_http_date():
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.utils import format_datetime
|
||||
|
||||
from pigway_frame_flow.drivers.kokonna.client import SafeTransport
|
||||
|
||||
future = format_datetime(datetime.now(UTC) + timedelta(seconds=30), usegmt=True)
|
||||
assert 28 <= SafeTransport._retry_after(future) <= 30
|
||||
|
||||
|
||||
def test_invalid_write_response_is_never_retried():
|
||||
s = session({"success": False, "message": "private"})
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert error.value.details["failure_kind"] == "invalid_response"
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure,expected",
|
||||
[
|
||||
("connect_timeout", "CLOUD_UNAVAILABLE"),
|
||||
("business_rejection", "CONFIG_REQUIRED"),
|
||||
("rate_limit", "RATE_LIMITED"),
|
||||
],
|
||||
)
|
||||
def test_proven_upload_failures_end_failed_without_device_lock(
|
||||
service, monkeypatch, failure, expected
|
||||
):
|
||||
from test_display import submit
|
||||
|
||||
s = session({"message": "can not find robot private"}, 500)
|
||||
if failure == "rate_limit":
|
||||
s.request.return_value.status_code = 429
|
||||
if failure == "connect_timeout":
|
||||
s.request.side_effect = requests.ConnectTimeout("private")
|
||||
c = KokonnaClient("private", True, s)
|
||||
monkeypatch.setattr(service.registry.get("living-room"), "upload", c.upload)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
op = service.operation(operation)
|
||||
assert op["status"] == "FAILED"
|
||||
assert op["error"]["code"] == expected
|
||||
assert not service.blocked("living-room")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, 0, -1, "12", None])
|
||||
def test_invalid_upload_id_never_becomes_confirmed(tmp_path, value):
|
||||
path = tmp_path / "image.jpg"
|
||||
path.write_bytes(b"jpeg")
|
||||
s = session({"id": value})
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).upload(path, "image.jpg")
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert s.request.call_count == 1
|
||||
|
||||
|
||||
def test_ambiguous_failure_after_safe_rejection_stops_retrying(transport_clock):
|
||||
s = session({"message": "limited"}, 429)
|
||||
first = s.request.return_value
|
||||
first.headers = {"Retry-After": "1"}
|
||||
s.request.side_effect = [first, requests.ReadTimeout("private")]
|
||||
with pytest.raises(FlowError) as error:
|
||||
KokonnaClient("private", True, s).display(1)
|
||||
assert error.value.code == "WRITE_RESULT_UNKNOWN"
|
||||
assert error.value.details["attempts"] == 2
|
||||
assert s.request.call_count == 2
|
||||
assert transport_clock.waits == [1]
|
||||
|
||||
@@ -23,7 +23,7 @@ def tool():
|
||||
"PROD_DATA_DIR": "/confirmed/data",
|
||||
"PROD_CONFIG_DIR": "/confirmed/config",
|
||||
"PROD_WEB_BIND": "192.0.2.1",
|
||||
"PROD_WEB_PORT": "18787",
|
||||
"PROD_WEB_PORT": "6005",
|
||||
"PROD_STATE_VOLUME": "production-frame-cd",
|
||||
"PROD_MOCK": "false",
|
||||
"PROD_WRITE_ENABLED": "false",
|
||||
@@ -52,9 +52,7 @@ def existing(tool):
|
||||
],
|
||||
"HostConfig": {
|
||||
"PortBindings": {
|
||||
"18787/tcp": [
|
||||
{"HostIp": tool.env["PROD_WEB_BIND"], "HostPort": "18787"}
|
||||
]
|
||||
"18787/tcp": [{"HostIp": tool.env["PROD_WEB_BIND"], "HostPort": "6005"}]
|
||||
},
|
||||
"NetworkMode": "production-frame_default",
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from test_display import submit
|
||||
|
||||
from pigway_frame_flow.display.service import DisplayService
|
||||
from pigway_frame_flow.errors import ErrorCode, FlowError
|
||||
|
||||
|
||||
def unknown_upload(service, monkeypatch):
|
||||
def fail(*args):
|
||||
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Response lost", 502)
|
||||
|
||||
monkeypatch.setattr(service.registry.get("living-room"), "upload", fail)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
return operation
|
||||
|
||||
|
||||
def test_missing_upload_is_visible_without_unsafe_unlock(client, service, monkeypatch):
|
||||
operation = unknown_upload(service, monkeypatch)
|
||||
service.sync()
|
||||
recovery = client.get("/api/v1/operations/" + operation).json()["recovery"]
|
||||
assert recovery["reason"] == "gallery_match_missing"
|
||||
assert recovery["state"] == "checking"
|
||||
assert not recovery["requires_manual_review"]
|
||||
assert service.blocked("living-room")
|
||||
assert service.registry.get("living-room").client.uploads == 0
|
||||
|
||||
|
||||
def test_persistent_block_escalates_once_survives_restart_and_preserves_evidence(
|
||||
service, monkeypatch
|
||||
):
|
||||
operation = unknown_upload(service, monkeypatch)
|
||||
original = service.operation(operation)
|
||||
service.db.execute(
|
||||
"UPDATE operations SET created_at=? WHERE id=?",
|
||||
(
|
||||
(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
|
||||
operation,
|
||||
),
|
||||
)
|
||||
service.sync()
|
||||
first = service.operation(operation)["recovery"]
|
||||
assert first["state"] == "manual_review"
|
||||
assert first["reason"] == "gallery_match_missing"
|
||||
service.unknown_checked.clear()
|
||||
service.sync()
|
||||
restarted = DisplayService(
|
||||
service.db, service.settings, service.registry, service.assets, service.renderer
|
||||
)
|
||||
restarted.sync()
|
||||
assert (
|
||||
len(service.db.all("SELECT * FROM events WHERE event_type='operation.blocked'"))
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
service.db.one(
|
||||
"SELECT COUNT(*) AS n FROM history WHERE result='MANUAL_REVIEW_REQUIRED'"
|
||||
)["n"]
|
||||
== 1
|
||||
)
|
||||
assert service.operation(operation)["recovery"]["checks"] == 3
|
||||
assert service.operation(operation)["result"] == original["result"]
|
||||
service.reconcile(operation, acknowledge=True)
|
||||
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
||||
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
||||
assert not service.blocked("living-room")
|
||||
|
||||
|
||||
def test_missing_legacy_evidence_escalates_without_cloud_write(service):
|
||||
operation = submit(service)
|
||||
service._start_write(operation)
|
||||
service.update(operation, "UNKNOWN")
|
||||
service.sync()
|
||||
assert (
|
||||
service.operation(operation)["recovery"]["reason"] == "missing_upload_evidence"
|
||||
)
|
||||
assert service.blocked("living-room")
|
||||
|
||||
|
||||
def test_delete_unknown_requires_manual_review_without_replay(service):
|
||||
operation = service.submit(
|
||||
"delete", {"image_id": 1}, "living-room", "admin", "test"
|
||||
)
|
||||
service._start_write(operation)
|
||||
service.update(operation, "UNKNOWN")
|
||||
service.sync()
|
||||
assert (
|
||||
service.operation(operation)["recovery"]["reason"]
|
||||
== "manual_reconciliation_required"
|
||||
)
|
||||
assert service.blocked("living-room")
|
||||
assert service.registry.get("living-room").images()[0].image_id == 1
|
||||
|
||||
|
||||
def test_malformed_reconciliation_does_not_abort_other_background_work(
|
||||
service, monkeypatch
|
||||
):
|
||||
operation = unknown_upload(service, monkeypatch)
|
||||
|
||||
def invalid_metadata(*args):
|
||||
raise ValueError("vendor metadata with private data")
|
||||
|
||||
monkeypatch.setattr(service.upload_reconciliation, "check", invalid_metadata)
|
||||
service.sync()
|
||||
op = service.operation(operation)
|
||||
assert op["recovery"]["reason"] == "reconciliation_error"
|
||||
assert "private" not in str(op["recovery"])
|
||||
assert service.blocked("living-room")
|
||||
@@ -111,3 +111,43 @@ def test_recovered_upload_preserves_temporary_restore(service, monkeypatch):
|
||||
assert service.operation(operation)["status"] == "COMPLETED"
|
||||
assert service.top("living-room")["id"] == operation
|
||||
assert service.state("living-room")["managed_image_id"] == 2
|
||||
|
||||
|
||||
def test_manual_close_preserves_unknown_upload_evidence(service, monkeypatch):
|
||||
operation, driver = lose_response(service, monkeypatch, change_display=False)
|
||||
before = service.operation(operation)
|
||||
assert before["result"].get("image_id") is None
|
||||
result = service.reconcile(operation, acknowledge=True)
|
||||
after = service.operation(operation)
|
||||
assert result["resolved"]
|
||||
assert after["status"] == "FAILED"
|
||||
assert after["result"] == before["result"]
|
||||
assert after["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
|
||||
assert after["error"]["details"]["previous_error"] == before["error"]
|
||||
assert after["error"]["details"]["observed_image_id"] == 1
|
||||
assert not service.blocked("living-room")
|
||||
assert driver.client.uploads == 1
|
||||
assert (
|
||||
service.db.one("SELECT * FROM history ORDER BY id DESC LIMIT 1")["result"]
|
||||
== "ACKNOWLEDGED"
|
||||
)
|
||||
|
||||
|
||||
def test_upload_transport_diagnostics_persist_on_operation(service, monkeypatch):
|
||||
driver = service.registry.get("living-room")
|
||||
|
||||
def fail(*args):
|
||||
raise FlowError(
|
||||
ErrorCode.WRITE_RESULT_UNKNOWN,
|
||||
"Cloud write outcome is unknown",
|
||||
502,
|
||||
details={"transport_error": "read_timeout"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(driver, "upload", fail)
|
||||
operation = submit(service)
|
||||
service.process_one()
|
||||
op = service.operation(operation)
|
||||
assert op["status"] == "UNKNOWN"
|
||||
assert op["error"]["details"] == {"transport_error": "read_timeout"}
|
||||
assert op["result"]["upload_intent"]
|
||||
|
||||
Reference in New Issue
Block a user