Compare commits

..
15 Commits
Author SHA1 Message Date
渡口浪人 ec14ca2f1d release: v0.1.16 with compact layouts and corrected settings columns [skip ci] 2026-10-18 14:15:46 +08:00
渡口浪人 f0a42caf45 fix: collapse empty connection result placeholders 2026-10-18 14:11:19 +08:00
渡口浪人 7579ee13d1 fix: stack settings information below account controls 2026-10-18 14:09:12 +08:00
渡口浪人 c153822479 fix: compact connection and collage controls across responsive pages 2026-10-18 14:05:02 +08:00
渡口浪人 7cb940cf36 fix: compact related settings controls into shared rows 2026-10-18 13:57:16 +08:00
渡口浪人 54a8d37f5e fix: rebalance history columns and simplify status text 2026-10-18 13:54:03 +08:00
渡口浪人 b101bb2f2d fix: simplify automation status and center row actions 2026-10-18 13:52:30 +08:00
渡口浪人 f1ce7e2cb3 fix: align gallery search and filters in one desktop row 2026-10-18 13:49:30 +08:00
渡口浪人 10534fe8b0 fix: widen shared workspace for three gallery columns 2026-10-18 13:45:24 +08:00
渡口浪人 32e8d4066e fix: size gallery cards to fit a single action row 2026-10-18 13:40:57 +08:00
渡口浪人 83837a4511 fix: widen gallery cards and compact action rows 2026-10-18 13:36:37 +08:00
渡口浪人 2b5f36019e docs: align copyright brand capitalization 2026-10-18 13:35:31 +08:00
渡口浪人 f7b6c78a10 fix: standardize brand capitalization as PIGWay 2026-10-18 13:35:10 +08:00
渡口浪人 ba91dde2e7 fix: preview gallery images on click with square thumbnails 2026-10-18 13:31:07 +08:00
渡口浪人 7c929cb955 feat: recover failed scheduled uploads with bounded persistent retries [skip ci] 2026-10-18 08:42:05 +08:00
32 changed files with 628 additions and 67 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Pigway
Copyright (c) 2026 PIGWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -1,6 +1,6 @@
# Notices
Pigway Frame Flow is an independent project. KoKonna is a supported device
PIGWay Frame Flow is an independent project. KoKonna is a supported device
integration and is not affiliated with this project unless otherwise stated.
KoKonna names and marks belong to their respective owners.
+2 -2
View File
@@ -1,8 +1,8 @@
# Pigway Frame Flow
# PIGWay Frame Flow
[简体中文](README.zh-CN.md) | English
Pigway Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
### Installation
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow
# PIGWay Frame Flow
简体中文 | [English](README.md)
+1 -1
View File
@@ -29,4 +29,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0
ARG BUILD_COMMIT
ARG BUILD_TIME
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.14" org.opencontainers.image.revision=$BUILD_COMMIT
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.16" org.opencontainers.image.revision=$BUILD_COMMIT
+1 -1
View File
@@ -9,7 +9,7 @@
duplicates, name/date/dimensions anomalies. No download-all action.
3. Stop on discrepancy. Missing model fields must be reported as unavailable.
4. Once explicitly authorized, locally render **one** 1200×1600 test image containing
PIGWAY FRAME FLOW TEST and Shanghai timestamp, no private content.
PIGWay Frame Flow TEST and Shanghai timestamp, no private content.
5. Record an operation ID and previous image; one upload only, no write retries.
6. Poll device read-only no more often than 60 seconds, respecting the 20/min budget.
Confirm imageId and synced. If sent but response lost, mark UNKNOWN, block writes,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"openapi": "3.1.0",
"info": {
"title": "Pigway Frame Flow API",
"title": "PIGWay Frame Flow API",
"version": "0.1.9"
},
"paths": {
@@ -17409,7 +17409,7 @@
"product": {
"type": "string",
"title": "Product",
"default": "Pigway Frame Flow"
"default": "PIGWay Frame Flow"
},
"version": {
"type": "string",
+9
View File
@@ -0,0 +1,9 @@
# v0.1.15
自动照片、拼版、模板及数据模板任务在上传失败后,持久保存同一张暂存图片,并在下一次计划之前安排最多 5 次额外重试。间隔按 1、2、4、8、16 权重递增,随剩余窗口缩放,计划时间集中在窗口前半段,为上传与设备同步留出时间。重试不移动原定计划,也不复用旧操作 ID;每次独立记录操作及事件。成功后取消剩余重试,重启后沿用已有计数与截止时间;停用任务不再追加重试,删除任务取消其重试计划。
若额外重试均失败,下一次计划仍先上传同一张图;若这一次仍失败,只删除该任务未入库、未星标的生成缓存,再按照片源生成并上传新图。NAS 原照片、用户上传文件和成功图库图片不由此流程删除。新图仍失败时,进入它自己的下一轮恢复窗口。
上传结果未知不再阻塞后续独立上传。每次重试前先只读核对设备及云图库,精确确认已成功的上传不再重传;需继续尝试时关闭旧尝试并保留错误和核对审计。删除、恢复及自动清理仍保留各自的保护条件。重试窗口到期后不再启动额外上传,待同步尝试经核对结束后交给原计划执行。
任务生成图片先保存在缓存,确认任务成功后才进入本地图库;失败不会产生看似成功的本地图库记录。额外重试仍归属原任务的云端保留规则,任务列表显示最近尝试结果。
+12
View File
@@ -0,0 +1,12 @@
# v0.1.16
统一 PIGWay 品牌写法,优化图库、自动化、历史记录、设置、连接中心和拼图页面布局。
- 图库直接点击图片查看,移除重复的查看按钮;缩略图底框为正方形,卡片宽度适配单行操作按钮。
- 全站共用更宽的内容区域,宽屏图库可一行显示三张卡片;搜索、排序、来源和任务筛选合并排列。
- 自动化与历史记录状态改为仅保留颜色的文字,优化列宽与操作对齐。
- 设置与鉴权信息保持左右并排,鉴权卡片内部信息改为单列;相关设置控件紧凑排列。
- 连接中心和自动拼图短字段合并排列,按钮按内容宽度显示,空结果提示不再占据额外空间。
- 窄屏自动换行,保留图片预览、长文本和表格所需空间。
本轮界面检查覆盖 11 个主页面、本地图库及弹窗,中英文、明暗主题和四种屏幕宽度共 289 项布局检查通过;隔离环境拼图流程 8 项检查通过。生产部署后的界面复查使用隔离数据,没有执行真实画框写入。
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.5
# PIGWay Frame Flow v0.1.5
## 简体中文
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.6
# PIGWay Frame Flow v0.1.6
## 简体中文
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.7
# PIGWay Frame Flow v0.1.7
## 简体中文
+1 -1
View File
@@ -1,4 +1,4 @@
# Pigway Frame Flow v0.1.8
# PIGWay Frame Flow v0.1.8
增加 NAS 本地图库反选、放大总览图片,并统一图片查看器与界面表达。
+5 -4
View File
@@ -124,9 +124,9 @@ def create_app(settings=None, start_worker=True):
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
}
app = FastAPI(
title="Pigway Frame Flow API",
title="PIGWay Frame Flow API",
docs_url=None,
version="0.1.14",
version="0.1.16",
lifespan=lifespan,
responses=error_responses,
)
@@ -944,6 +944,7 @@ def create_app(settings=None, start_worker=True):
)
def delete_job(job_id: str):
db.execute("DELETE FROM jobs WHERE id=?", (job_id,))
db.execute("DELETE FROM app_config WHERE key=?", ("upload-retry:" + job_id,))
return Response(status_code=204)
@app.get(
@@ -1032,7 +1033,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True,
),
],
version="0.1.14",
version="0.1.16",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
@@ -1261,7 +1262,7 @@ def create_app(settings=None, start_worker=True):
def api_docs():
response = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Pigway Frame Flow · API Docs",
title="PIGWay Frame Flow · API Docs",
swagger_js_url="/ui/swagger-ui-bundle.js",
swagger_css_url="/ui/swagger-ui.css",
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
+14 -3
View File
@@ -13,18 +13,29 @@ class CloudLibrary:
def register(self, device_id, image_id, asset_id, operation_id):
operation = self.db.one(
"SELECT payload FROM operations WHERE id=?", (operation_id,)
"SELECT payload,client_id FROM operations WHERE id=?", (operation_id,)
)
payload = json.loads(operation["payload"])
content = payload.get("content", {})
job = self.db.one(
"SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,)
)
if not job and operation["client_id"] == "scheduler-retry":
job = self.db.one(
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
(asset_id,),
)
job_config = (
self.db.one("SELECT payload FROM jobs WHERE id=?", (job["job_id"],))
if job
else None
)
source = json.loads(job_config["payload"])["source"] if job_config else content
job_id = (
job["job_id"]
if job
and content.get("type") in ("photo_source", "auto_collage")
and content.get("item_id") is None
and source.get("type") in ("photo_source", "auto_collage")
and source.get("item_id") is None
else None
)
self.db.execute(
+59 -8
View File
@@ -115,7 +115,7 @@ class DisplayService:
)
return existing["operation_id"]
if (
action not in ("render", "render-ai", "local-upload")
action not in ("display", "render", "render-ai", "local-upload")
and tx.execute(
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
(device_id,),
@@ -187,6 +187,13 @@ class DisplayService:
id,
),
)
if status == "COMPLETED":
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
asset_id = json.loads(row["result"] or "{}").get("rendered_asset_id")
if asset_id and self.db.one(
"SELECT id FROM assets WHERE id=?", (asset_id,)
):
self.assets.promote(asset_id)
if status in ("COMPLETED", "FAILED"):
self.recovery.resolve(id)
@@ -226,12 +233,17 @@ class DisplayService:
(device_id, image_id, image_id, device_id, device_id),
)
def content_asset(self, content, device_id=None, operation_id=None):
def content_asset(
self, content, device_id=None, operation_id=None, *, staging=False
):
if isinstance(content, AutoCollageContent):
from pigway_frame_flow.extensions.auto_collage import prepare
return self.content_asset(
prepare(self, content, device_id), device_id, operation_id
prepare(self, content, device_id),
device_id,
operation_id,
staging=staging,
)
if isinstance(content, ProviderContent):
values = {**content.data, **self.providers.get(content.provider_id)}
@@ -244,11 +256,14 @@ class DisplayService:
),
device_id,
operation_id,
staging=staging,
)
if isinstance(content, TemplateContent):
values = self.renderer.resolve(content.template, content.data)
data = self.renderer.render(content.template, values, content.profile)
asset = self.assets.put(data, content.template + ".jpg", "generated")
asset = self.assets.put(
data, content.template + ".jpg", "cache" if staging else "generated"
)
source = {
"type": "template",
"template": content.template,
@@ -377,7 +392,7 @@ class DisplayService:
"UPDATE operations SET result=? WHERE id=?",
(encode({"photo_attempts": attempts}), operation_id),
)
return self.assets.put(rendered, name)
return self.assets.put(rendered, name, "cache" if staging else "images")
raise FlowError(
E.INVALID_REQUEST, "No decodable photo found within 5 candidates"
)
@@ -390,7 +405,9 @@ class DisplayService:
else:
raise FlowError(E.INVALID_REQUEST, "Content requires renderable image data")
return self.assets.put(
convert(data, content.fit, content.profile), content.name
convert(data, content.fit, content.profile),
content.name,
"cache" if staging else "images",
)
def current_content(self, device_id):
@@ -446,10 +463,38 @@ class DisplayService:
yield from rows
cursor_time, cursor_id = rows[-1]["created_at"], rows[-1]["id"]
def upload_retry_expired(self, op):
if op["client_id"] != "scheduler-retry" or not op["request_id"].startswith(
"upload-retry-"
):
return False
root = op["request_id"].split("-")[-2]
for row in self.db.all(
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'"
):
plan = json.loads(row["payload"])
if plan["root"] == root:
return now_iso() >= plan["deadline"]
return True
def process_one(self):
with self.lock:
for op in self.queued_operations():
if self.upload_retry_expired(op):
self.update(
op["id"],
"FAILED",
error={
"code": E.CONFLICT,
"message": "Upload retry window closed",
"request_id": op["request_id"],
"details": {"reason": "RETRY_WINDOW_CLOSED"},
},
)
self.db.history(op, "RETRY_WINDOW_CLOSED")
continue
if op["action"] not in (
"display",
"render",
"render-ai",
"local-upload",
@@ -632,6 +677,13 @@ class DisplayService:
self.update(id, "SYNCING", {"image_id": target})
self.db.history(op, "ACCEPTED")
return
# A new upload is independent of a previous failed write. Read back its
# outcome before closing the old attempt; never replay that operation.
for pending in self.db.all(
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action='display'",
(device_id,),
):
self.reconcile(pending["id"], acknowledge=True)
request = DisplayRequest.model_validate(payload)
content = request.content
self.update(id, "RENDERING")
@@ -648,7 +700,7 @@ class DisplayService:
)
image_id = matches[0].image_id
else:
asset = self.content_asset(content, device_id, id)
asset = self.content_asset(content, device_id, id, staging=True)
self.assets.record_origin(asset["asset_id"], op)
image_id = None
self.db.execute(
@@ -683,7 +735,6 @@ class DisplayService:
driver.display(image_id)
result = {"image_id": image_id}
if asset:
self.assets.promote(asset["asset_id"])
result["rendered_asset_id"] = asset["asset_id"]
source = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?",
@@ -77,7 +77,6 @@ class UploadReconciliation:
result["image_id"] = expected
result["upload_confirmed"] = True
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
service.assets.promote(result["rendered_asset_id"])
source = service.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?",
(result["rendered_asset_id"],),
+1
View File
@@ -43,6 +43,7 @@ class LocalRetention:
"SELECT payload FROM jobs",
"SELECT payload FROM template_config",
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
"SELECT payload FROM app_config WHERE key LIKE 'upload-retry:%'",
]:
payloads += [row["payload"] for row in db.all(query)]
for payload in payloads:
+3 -3
View File
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.14"
version: str = "0.1.16"
class Readiness(BaseModel):
@@ -395,8 +395,8 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
version: str = "0.1.14"
product: str = "PIGWay Frame Flow"
version: str = "0.1.16"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
+11 -2
View File
@@ -1,5 +1,6 @@
import base64
import ipaddress
import json
import socket
from io import BytesIO
from urllib.parse import urlsplit
@@ -115,9 +116,17 @@ class Assets:
def promote(self, asset_id):
row = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
if row and row["kind"] == "cache":
target = self.settings.data_dir / "images" / (asset_id + ".jpg")
source = self.db.one(
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,)
)
kind = (
"generated"
if source and json.loads(source["payload"]).get("type") == "template"
else "images"
)
target = self.settings.data_dir / kind / (asset_id + ".jpg")
self.path(asset_id).replace(target)
self.db.execute("UPDATE assets SET kind='images' WHERE id=?", (asset_id,))
self.db.execute("UPDATE assets SET kind=? WHERE id=?", (kind, asset_id))
return self.info(asset_id)
def info(self, asset_id):
+11
View File
@@ -18,6 +18,7 @@ from pigway_frame_flow.models import (
PlaylistInput,
RestoreContent,
)
from pigway_frame_flow.scheduler.upload_retries import UploadRetries
from pigway_frame_flow.storage.database import encode, now_iso
@@ -60,6 +61,7 @@ class Scheduler:
self.thread = None
self.webhook_thread = None
self.tick_lock = threading.Lock()
self.upload_retries = UploadRetries(service)
def jobs(self):
rows = [
@@ -147,6 +149,7 @@ class Scheduler:
def tick(self, current=None):
current = current or datetime.now(UTC)
with self.tick_lock:
self.upload_retries.tick(current)
for row in self.db.all(
"SELECT * FROM jobs WHERE enabled=1 AND next_run<=?",
(current.isoformat(),),
@@ -183,6 +186,10 @@ class Scheduler:
),
).model_dump(mode="json")
)
if action == "display":
payload = self.upload_retries.scheduled_payload(
row["id"], payload
)
operation_id = self.service.submit(
action,
payload,
@@ -191,6 +198,10 @@ class Scheduler:
"schedule-" + uuid4().hex,
"job-" + row["id"] + "-" + slot,
)
if action == "display":
self.upload_retries.scheduled_submitted(
row["id"], operation_id
)
following = next_run(job.schedule, current)
enabled = bool(following)
updated = job.model_copy(update={"enabled": enabled}).model_dump(
@@ -0,0 +1,246 @@
"""Durable, job-local upload retries; scheduled execution remains authoritative."""
import json
from datetime import datetime, timedelta
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.storage.database import encode
class UploadRetries:
def __init__(self, service):
self.service, self.db = service, service.db
def load(self, job_id):
row = self.db.one(
"SELECT payload FROM app_config WHERE key=?", ("upload-retry:" + job_id,)
)
return json.loads(row["payload"]) if row else None
def save(self, job_id, plan):
if plan is None:
self.db.execute(
"DELETE FROM app_config WHERE key=?", ("upload-retry:" + job_id,)
)
else:
self.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("upload-retry:" + job_id, encode(plan)),
)
@staticmethod
def reuse(payload, asset_id):
return {**payload, "content": {"type": "image", "asset_id": asset_id}}
def scheduled_payload(self, job_id, payload):
plan = self.load(job_id)
if plan and plan["phase"] in ("extra", "scheduled", "scheduled_running"):
return self.reuse(payload, plan["asset_id"])
return payload
def scheduled_submitted(self, job_id, operation_id):
plan = self.load(job_id)
if plan and plan["phase"] in ("extra", "scheduled", "scheduled_running"):
plan.update(phase="scheduled_running", active=operation_id)
self.save(job_id, plan)
def close_unknown(self, operation):
if operation["status"] == "UNKNOWN":
raw = self.db.one(
"SELECT * FROM operations WHERE id=?", (operation["operation_id"],)
)
driver = self.service.registry.get(operation["device_id"])
self.service.upload_reconciliation.check(
raw, driver, driver.device(refresh=True)
)
self.service.reconcile(operation["operation_id"], acknowledge=True)
return self.service.operation(operation["operation_id"])
return operation
def discard(self, asset_id):
row = self.db.one("SELECT kind FROM assets WHERE id=?", (asset_id,))
meta = self.db.one(
"SELECT starred FROM asset_metadata WHERE asset_id=?", (asset_id,)
)
# Only disposable generated staging files are owned by this recovery.
if row and row["kind"] == "cache" and not (meta and meta["starred"]):
self.service.assets.path(asset_id).unlink(missing_ok=True)
with self.db.transaction() as tx:
for table, field in (
("assets", "id"),
("asset_sources", "asset_id"),
("asset_metadata", "asset_id"),
):
tx.execute(f"DELETE FROM {table} WHERE {field}=?", (asset_id,))
def tick(self, current):
for job in self.db.all("SELECT * FROM jobs WHERE enabled=1"):
try:
self.check(job, current)
except FlowError:
# A read failure leaves the durable plan in place for a later check.
continue
def check(self, job, current):
config = json.loads(job["payload"])
if config["source"]["type"] not in (
"photo_source",
"auto_collage",
"template",
"provider",
):
return
plan = self.load(job["id"])
if plan and plan["source"] != config["source"]:
self.save(job["id"], None)
plan = None
if not plan:
if not job["last_result"] or not job["next_run"]:
return
operation = self.service.operation(job["last_result"])
asset_id = (operation["result"] or {}).get("rendered_asset_id")
if operation["status"] not in ("FAILED", "UNKNOWN") or not asset_id:
return
deadline = datetime.fromisoformat(job["next_run"])
remaining = (deadline - current).total_seconds()
if remaining <= 0:
times = []
else:
# Five increasing delays occupy the first half of the available
# window, leaving time for upload/sync before the next schedule.
unit = remaining / 62
times = [
(current + timedelta(seconds=unit * n)).isoformat()
for n in (1, 3, 7, 15, 31)
]
plan = dict(
root=operation["operation_id"],
active=operation["operation_id"],
asset_id=asset_id,
source=config["source"],
times=times,
count=0,
deadline=deadline.isoformat(),
phase="extra",
)
self.save(job["id"], plan)
operation = self.service.operation(plan["active"])
deadline = min(
datetime.fromisoformat(plan["deadline"]),
datetime.fromisoformat(job["next_run"]),
)
if (
plan["phase"] == "extra"
and current >= deadline
and operation["status"] != "COMPLETED"
):
if operation["status"] == "SYNCING":
self.service.update(
operation["operation_id"],
"UNKNOWN",
operation["result"],
operation["error"],
)
operation = self.service.operation(operation["operation_id"])
operation = self.close_unknown(operation)
if operation["status"] == "QUEUED":
self.service.update(
operation["operation_id"],
"FAILED",
error={
"code": "CONFLICT",
"message": "Upload retry window closed",
"request_id": operation["request_id"],
"details": {"reason": "RETRY_WINDOW_CLOSED"},
},
)
if operation["status"] != "COMPLETED":
plan["phase"] = "scheduled"
self.save(job["id"], plan)
return
if operation["status"] == "COMPLETED":
self.save(job["id"], None)
# Extra retries are not job_runs and do not move the next plan time.
self.db.execute(
"UPDATE jobs SET last_result=? WHERE id=?",
(operation["operation_id"], job["id"]),
)
return
if operation["status"] not in ("FAILED", "UNKNOWN"):
return
if plan["phase"] == "replacement":
# A failed replacement gets its own bounded recovery window.
self.db.execute(
"UPDATE jobs SET last_result=? WHERE id=?",
(operation["operation_id"], job["id"]),
)
self.save(job["id"], None)
return
if plan["phase"] == "scheduled_running":
operation = self.close_unknown(operation)
if operation["status"] == "COMPLETED":
return
self.discard(plan["asset_id"])
payload = self.payload(config)
key = "replacement-" + plan["root"]
op_id = self.service.submit(
"display", payload, config["device_id"], "scheduler-retry", key, key
)
plan.update(phase="replacement", active=op_id)
self.save(job["id"], plan)
self.db.execute(
"UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"])
)
self.db.event(
"upload.regenerated",
{
"job_id": job["id"],
"operation_id": op_id,
"failed_asset_id": plan["asset_id"],
},
)
return
if current >= deadline or plan["count"] >= len(plan["times"]):
plan["phase"] = "scheduled"
self.save(job["id"], plan)
return
if current < datetime.fromisoformat(plan["times"][plan["count"]]):
return
operation = self.close_unknown(operation)
if operation["status"] == "COMPLETED":
return
number = plan["count"] + 1
key = f"upload-retry-{plan['root']}-{number}"
op_id = self.service.submit(
"display",
self.reuse(self.payload(config), plan["asset_id"]),
config["device_id"],
"scheduler-retry",
key,
key,
)
plan.update(active=op_id, count=number)
self.save(job["id"], plan)
self.db.execute("UPDATE jobs SET last_result=? WHERE id=?", (op_id, job["id"]))
self.db.event(
"upload.retry",
{
"job_id": job["id"],
"operation_id": op_id,
"attempt": number,
"deadline": deadline.isoformat(),
},
)
@staticmethod
def payload(config):
return {
"device_id": config["device_id"],
"content": config["source"],
"options": {
"duration": config["display_duration"],
"restore_previous": config["restore_previous"],
"priority": config["priority"],
"cleanup_unstarred": config.get("cleanup_unstarred", False),
},
}
+6 -6
View File
@@ -118,7 +118,7 @@ async function templateEditor(selected){
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
}
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
const views={
studio:()=>layoutStudioPage(),
connections:()=>connectionsPage(),
@@ -128,7 +128,7 @@ const views={
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:t('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];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),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();});});};
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"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('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 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];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),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):''));
@@ -143,13 +143,13 @@ const views={
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns"><section class="card"><h3>${t('settings')}</h3><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
};
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
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('playbackMode')}</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();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(()=>{});
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.
@@ -175,7 +175,7 @@ async function jobEditor(job){
views.automation=async epoch=>{
const jobs=await api('/api/v1/jobs');
if(epoch!==state.epoch)return;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${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(key=>`<th>${t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong><br><small>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</small></td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="badge">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong><br><small>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</small></td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${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(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
+3 -3
View File
@@ -33,14 +33,14 @@ function displayDialog(content){
}
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('sortOrder')}</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-invert">${t('invertSelection')}</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);const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#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();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.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 controls=document.createElement('div');controls.className='form-grid gallery-filters';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('sortOrder')}</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'){controls.classList.add('gallery-filters-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-invert">${t('invertSelection')}</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);for(const [id,key] of [['asset-origin','originFilter'],['asset-job','taskOrigin']]){const field=document.createElement('div');field.className='field';const label=document.createElement('label');label.htmlFor=id;label.textContent=t(key);field.append(label,bar.querySelector('#'+id));controls.append(field);}const help=document.createElement('p');help.className='hint';help.id='gallery-selection-help';help.textContent=t('gallerySelectionHelp')+' '+t('starHelp');grid.before(help);bar.setAttribute('aria-describedby',help.id);$('#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();});const visibleSelection=()=>[...grid.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);$('#asset-all').onclick=()=>{const boxes=visibleSelection();const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};$('#asset-invert').onclick=()=>visibleSelection().forEach(x=>x.checked=!x.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('templateSourceUnavailable'));});}});
$('#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">${t('weatherSettings')}</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;
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">${t('weatherSettings')}</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 weather-coordinates">${['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;
function result(w){$('#weather-result').innerHTML=`<p>${esc(w.current.temperature)} · ${esc(w.current.summary)}</p><p class="hint">${w.current.mock?t('weatherMock'):t('weatherAttribution')}${w.current.stale?' · '+t('stale'):''}</p><h4>${t('forecast')}</h4>${w.forecast.map(x=>`<p>${esc(x.date)} · ${esc(x.summary)} · ${x.minimum_c}–${x.maximum_c}°C · ${x.rain_probability}%</p>`).join('')}`;}
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
}
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span 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.14</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=upload-caption-v0114"></script><script src="/ui/features.js?v=upload-caption-v0114"></script><script src="/ui/extensions.js?v=upload-caption-v0114"></script><script src="/ui/app.js?v=upload-caption-v0114"></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.16</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=gallery-layout-v0116"></script><script src="/ui/features.js?v=gallery-layout-v0116"></script><script src="/ui/extensions.js?v=gallery-layout-v0116"></script><script src="/ui/app.js?v=gallery-layout-v0116"></script></body></html>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pigway-frame-flow"
version = "0.1.14"
version = "0.1.16"
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]
+1 -1
View File
@@ -151,7 +151,7 @@ def release(lock):
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
base = "pigway-frame-flow:release-base-" + commit[:12]
run("docker", "image", "tag", before["Image"], base)
image = "pigway-frame-flow:0.1.14-" + commit[:12]
image = "pigway-frame-flow:0.1.16-" + commit[:12]
try:
run(
"docker",
+5 -5
View File
@@ -9,12 +9,12 @@ 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}));
const viewports=process.env.FRAME_FLOW_VIEWPORTS?JSON.parse(process.env.FRAME_FLOW_VIEWPORTS):[1920,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]);}
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),compact:!!e.closest('.image-card .actions'),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);const gap=controls[i].compact&&controls[j].compact?5.5:7.5;if((x>1&&dy<gap)||(y>1&&dx<gap))close.push([controls[i].id,controls[j].id]);}
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;});
@@ -25,7 +25,7 @@ const assert=require('assert');const fs=require('fs');const {chromium}=require('
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();
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('#modal-cancel').click();
await page.evaluate(()=>navigate('automation'));await page.locator('#new-job').click();await inspect(`Job/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>navigate('playlists'));await page.locator('#new-playlist').click();await inspect(`Playlist/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
await page.evaluate(()=>displayDialog({type:'image',image_id:1}));await inspect(`Display options/${width}/${lang}/${theme}`);await page.locator('#modal-cancel').click();
@@ -33,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, '+(viewports.length*4)+' 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, 5 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)});
+2 -1
View File
@@ -338,7 +338,8 @@ def test_unknown_same_idempotency_key_returns_original(client, service):
fresh = client.post(
"/api/v1/display", json=payload, headers={"Idempotency-Key": "new-key"}
)
assert fresh.status_code == 409
assert fresh.status_code == 202
assert fresh.json()["operation_id"] != original.json()["operation_id"]
def test_schema_contract(client):
+12 -10
View File
@@ -71,22 +71,24 @@ def test_nested_overrides(service):
assert service.registry.get("living-room").get_current_image() == 1
def test_no_retry_unknown(service):
def test_unknown_does_not_block_new_independent_upload(service):
driver = service.registry.get("living-room")
attempts = []
def timeout(*a):
def timeout(*args):
attempts.append(args)
raise FlowError(ErrorCode.WRITE_RESULT_UNKNOWN, "Write outcome unknown", 502)
driver.upload = timeout
id = submit(service)
first = submit(service)
service.process_one()
assert service.operation(id)["status"] == "UNKNOWN"
with pytest.raises(FlowError):
submit(service)
assert service.process_one() is False
assert service.reconcile(id)["resolved"] is False
assert service.reconcile(id, True)["resolved"] is True
assert service.operation(id)["status"] == "FAILED"
assert service.operation(first)["status"] == "UNKNOWN"
second = submit(service, priority=10)
assert second != first
assert service.process_one()
assert service.operation(first)["status"] == "FAILED"
assert service.operation(second)["status"] == "UNKNOWN"
assert len(attempts) == 2
def test_restart_preserves_override(service):
+154
View File
@@ -0,0 +1,154 @@
from datetime import UTC, datetime, timedelta
from pigway_frame_flow.errors import ErrorCode, FlowError
from pigway_frame_flow.models import JobInput
from pigway_frame_flow.scheduler.engine import Scheduler
def failed_job(app, service, monkeypatch):
scheduler = app.state.scheduler
job = scheduler.save_job(
JobInput(
name="Retry",
schedule={"type": "interval", "interval_minutes": 120},
source={"type": "template", "template": "quote"},
)
)
now = datetime.now(UTC)
service.db.execute(
"UPDATE jobs SET next_run=? WHERE id=?",
((now - timedelta(seconds=1)).isoformat(), job["id"]),
)
driver = service.registry.get("living-room")
upload = driver.upload
def fail(*a):
raise FlowError(ErrorCode.UPLOAD_FAILED, "Rejected", 502)
monkeypatch.setattr(driver, "upload", fail)
scheduler.tick(now)
service.process_one()
scheduler.tick(now + timedelta(seconds=1))
return scheduler, job, now, driver, upload
def test_five_backoffs_then_one_scheduled_failure_regenerates(
app, service, monkeypatch, client
):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
asset = plan["asset_id"]
path = service.assets.path(asset)
original = path.read_bytes()
assert client.get("/api/v1/assets").json() == []
assert (
service.db.one("SELECT kind FROM assets WHERE id=?", (asset,))["kind"]
== "cache"
)
times = [datetime.fromisoformat(x) for x in plan["times"]]
gaps = [(times[0] - now).total_seconds()] + [
(b - a).total_seconds() for a, b in zip(times, times[1:], strict=False)
]
assert gaps == sorted(gaps) and len(times) == 5
assert times[-1] < datetime.fromisoformat(plan["deadline"])
for number, when in enumerate(times, 1):
scheduler.tick(when + timedelta(milliseconds=1))
attempt = policy.load(job["id"])
assert attempt["count"] == number
op = service.operation(attempt["active"])
assert (
service.db.one(
"SELECT payload FROM operations WHERE id=?", (op["operation_id"],)
)["payload"].find(asset)
>= 0
)
service.process_one()
assert path.read_bytes() == original
assert len(service.db.all("SELECT * FROM job_runs")) == 1
# The plan survives a process restart, without resetting the five-attempt cap.
scheduler = Scheduler(service)
deadline = datetime.fromisoformat(plan["deadline"])
scheduler.tick(deadline + timedelta(milliseconds=1))
planned = scheduler.upload_retries.load(job["id"])
assert planned["phase"] == "scheduled_running"
service.process_one()
scheduler.tick(deadline + timedelta(seconds=1))
replacement = scheduler.upload_retries.load(job["id"])
assert replacement["phase"] == "replacement"
assert not path.exists()
assert service.db.one("SELECT id FROM assets WHERE id=?", (asset,)) is None
monkeypatch.setattr(driver, "upload", upload)
service.process_one()
service.sync()
op = service.operation(replacement["active"])
assert op["status"] == "COMPLETED"
assert op["result"]["rendered_asset_id"] != asset
assert len(service.db.all("SELECT * FROM job_runs")) == 2
def test_retry_success_cancels_rest_and_keeps_schedule(app, service, monkeypatch):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
deadline = service.db.one("SELECT next_run FROM jobs WHERE id=?", (job["id"],))[
"next_run"
]
monkeypatch.setattr(driver, "upload", upload)
when = datetime.fromisoformat(plan["times"][0]) + timedelta(milliseconds=1)
scheduler.tick(when)
operation = policy.load(job["id"])["active"]
service.process_one()
service.sync()
scheduler.tick(when + timedelta(seconds=1))
assert policy.load(job["id"]) is None
assert service.operation(operation)["status"] == "COMPLETED"
assert (
service.db.one("SELECT next_run FROM jobs WHERE id=?", (job["id"],))["next_run"]
== deadline
)
assert (
service.db.one("SELECT kind FROM assets WHERE id=?", (plan["asset_id"],))[
"kind"
]
== "generated"
)
assert driver.client.uploads == 1
def test_expired_queued_retry_never_uploads(app, service, monkeypatch):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
scheduler.tick(datetime.fromisoformat(plan["times"][0]) + timedelta(milliseconds=1))
plan = policy.load(job["id"])
plan["deadline"] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
policy.save(job["id"], plan)
def unexpected(*a):
raise AssertionError("Expired retry must never upload")
monkeypatch.setattr(driver, "upload", unexpected)
service.process_one()
operation = service.operation(plan["active"])
assert operation["status"] == "FAILED"
assert operation["error"]["details"]["reason"] == "RETRY_WINDOW_CLOSED"
assert service.assets.path(plan["asset_id"]).exists()
def test_lost_response_is_reconciled_before_retry_without_duplicate(
app, service, monkeypatch
):
scheduler, job, now, driver, upload = failed_job(app, service, monkeypatch)
policy = scheduler.upload_retries
plan = policy.load(job["id"])
asset = service.assets.info(plan["asset_id"])
monkeypatch.setattr(driver, "upload", upload)
# The original request reached the cloud, but its response was lost.
driver.upload(service.assets.path(plan["asset_id"]), asset["name"])
service.update(plan["root"], "UNKNOWN")
scheduler.tick(datetime.fromisoformat(plan["times"][0]) + timedelta(milliseconds=1))
scheduler.tick(datetime.fromisoformat(plan["times"][0]) + timedelta(seconds=1))
assert service.operation(plan["root"])["status"] == "COMPLETED"
assert policy.load(job["id"]) is None
assert driver.client.uploads == 1