diff --git a/README.zh-CN.md b/README.zh-CN.md
index 64acbb0..eb31bd6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -103,3 +103,5 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
[文档目录](docs/README.md)
+
+历史记录支持服务端翻页、图片名称/请求 ID/任务 ID/来源搜索,以及状态、操作、设备和日期(上海时区)筛选;可选择当前页记录进行单条或批量删除,操作前需确认。删除仅从历史列表移除,内部执行凭据继续供图库来源、幂等及恢复使用,不会删除图片或取消任务;运行中的任务不能删除。
diff --git a/SHA256SUMS b/SHA256SUMS
index acd6000..4059e5a 100644
--- a/SHA256SUMS
+++ b/SHA256SUMS
@@ -9,7 +9,7 @@ bd03eb87817b811ab7701b6c63470905a0c554a8ee0de6a83c67b22732b2727e Dockerfile
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
-791881a9956148e0a3aa00e1d5650983d4e397eb5f3e67b5ccacb1fb965725ec README.zh-CN.md
+2da8a4418059ce61f7f0abfd4181338db0c741b4196dc658989388d142b5ee64 README.zh-CN.md
1083bf4b9bfa263efa4243703d3345e546db86bd1f9eb22bda581fc509aa6cfe RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
@@ -37,14 +37,15 @@ f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pig
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
-1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json
+f17ea5a60cc4f1841069695c1c17854ff11a7cc3261dd7e5a496866851015a7a docs/openapi.json
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
-46c6e2331bda76eeac108d07ef282dbce74f482cc679ad9cf64d9cd2e29aaef3 pigway_frame_flow/api/app.py
+8bb9df9d9dc5d950833c796c07ae9e0216ba7db4ea2b9fb77f757f5891c3b077 pigway_frame_flow/api/app.py
+1efe9b1fc774a97e5be72d81199b1b65b25c258956fdf166aebff732cc820d7c pigway_frame_flow/api/history.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
@@ -110,7 +111,7 @@ ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_f
d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
-36cfc19b9583809f59a2276da3ba0f023a747013b6903d3a11fbb5941931681a pigway_frame_flow/storage/database.py
+2a288fe13d0c9b626ceaf1c825d079cf1e93f5c3abadc5907fc438211f3bfa46 pigway_frame_flow/storage/database.py
44c0c47d852ffd80cb05969965ee216479c1047daffabf9c3bdc8fe2b036db12 pigway_frame_flow/templates/calendar.json
1b01fcda4b1cba11c3b152dfc2223d4119f1879ddd0b7eaf6a2e2731a6f6bf6d pigway_frame_flow/templates/collage.json
7bfe623e0569beae25b44261d949679c95d42c8bd095ef6b5cca25cf1b0d4c40 pigway_frame_flow/templates/daily-dashboard.json
@@ -120,16 +121,16 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
-0d47629b1075a171f87911001760dd0d1fca79c7afc609bf18fbaa57f008dc36 pigway_frame_flow/web/app.js
+26294166cdf50cd41885b5e71d1314b2e885b212e1efe7496d401d424b2beda4 pigway_frame_flow/web/app.js
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
-cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
-914baa94037bbe2bffa1a48a934274644eae7b8d523af0b789360e8ca7b0de27 pigway_frame_flow/web/i18n.js
+f58b5c088c4a4d55b332c4fd52dc8e2fe436b7bcc607870e06bf4d6d6f497850 pigway_frame_flow/web/features.js
+44641bdc3d7735625cd9a37dfa6597825030e0f8896a68ead4a2f434c43dced7 pigway_frame_flow/web/i18n.js
f36ec88d90471fe1702fb6d50bf99f9b189af1170cbac47113135f2761985933 pigway_frame_flow/web/index.html
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
-a97528940dc2edbb35d725770eeec5c10f0a996f235302a1aed048ca755afd96 pigway_frame_flow/web/style.css
+4eca31e33a3d14f1e25d61655715dd4a4789adceb4fb78247a12e041071b6810 pigway_frame_flow/web/style.css
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
@@ -156,8 +157,8 @@ b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/produc
f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gallery-current.cjs
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
-02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
-29c9b2ef02e11aa3320d63daec28185bf6e1e781d33d3ba934125c79c81ba33a scripts/ui-history-localization.cjs
+8260a11478e23f63bc56c119205a89df04efe7c8a9bfb25b80db24aa56fd302b scripts/ui-history-layout.cjs
+86cf401ffb8b78691b6dca0851925af3b3107acfffd6943d78027ca4e9a54df7 scripts/ui-history-localization.cjs
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
@@ -177,7 +178,7 @@ d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-pro
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
-41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
+19621a4c9dc5f060741504044710f2314d523530ae158dcf6fa3487ce2945e47 tests/contract.json
c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
@@ -195,6 +196,7 @@ f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_ext
f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py
2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py
5dc4027bcdad63c4a3e02c7735e6b8445c69edb43f2a3c0cd8902ef6aa1c6342 tests/test_history_images.py
+1f548f3ac8023da49c3cfd1e61ed986d949229e67e5b34b5e4947852ce3b5ca4 tests/test_history_management.py
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
diff --git a/docs/openapi.json b/docs/openapi.json
index 0cb30db..af11ff2 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "PIGWay Frame Flow API",
- "version": "0.1.23"
+ "version": "0.1.26"
},
"paths": {
"/api/v1/devices/{device_id}/widgets/selection": {
@@ -14118,6 +14118,398 @@
}
}
}
+ },
+ "/api/v1/operation-history": {
+ "get": {
+ "tags": [
+ "Operations"
+ ],
+ "summary": "List History",
+ "operationId": "list_history_api_v1_operation_history_get",
+ "security": [
+ {
+ "HTTPBearer": []
+ },
+ {
+ "AccountSession": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 1,
+ "title": "Page"
+ }
+ },
+ {
+ "name": "page_size",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1,
+ "default": 20,
+ "title": "Page Size"
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/OperationStatus"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ }
+ },
+ {
+ "name": "action",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "maxLength": 80,
+ "default": "",
+ "title": "Action"
+ }
+ },
+ {
+ "name": "device_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "maxLength": 120,
+ "default": "",
+ "title": "Device Id"
+ }
+ },
+ {
+ "name": "q",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "maxLength": 200,
+ "default": "",
+ "title": "Q"
+ }
+ },
+ {
+ "name": "start",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Start"
+ }
+ },
+ {
+ "name": "end",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string",
+ "format": "date"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "End"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HistoryPage"
+ }
+ }
+ }
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Unauthorized"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Not Found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Conflict"
+ },
+ "413": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Content Too Large"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Unprocessable Content"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Too Many Requests"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Service Unavailable"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Operations"
+ ],
+ "summary": "Delete History",
+ "operationId": "delete_history_api_v1_operation_history_delete",
+ "security": [
+ {
+ "HTTPBearer": []
+ },
+ {
+ "AccountSession": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HistoryDelete"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Unauthorized"
+ },
+ "403": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Forbidden"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Not Found"
+ },
+ "409": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Conflict"
+ },
+ "413": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Content Too Large"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Unprocessable Content"
+ },
+ "429": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Too Many Requests"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "Service Unavailable"
+ }
+ }
+ }
}
},
"components": {
@@ -17733,6 +18125,55 @@
"catalog"
],
"title": "WidgetState"
+ },
+ "HistoryPage": {
+ "properties": {
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/Operation"
+ },
+ "type": "array",
+ "title": "Items"
+ },
+ "total": {
+ "type": "integer",
+ "title": "Total"
+ },
+ "page": {
+ "type": "integer",
+ "title": "Page"
+ },
+ "page_size": {
+ "type": "integer",
+ "title": "Page Size"
+ }
+ },
+ "type": "object",
+ "required": [
+ "items",
+ "total",
+ "page",
+ "page_size"
+ ],
+ "title": "HistoryPage"
+ },
+ "HistoryDelete": {
+ "properties": {
+ "operation_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "maxItems": 200,
+ "minItems": 1,
+ "title": "Operation Ids"
+ }
+ },
+ "type": "object",
+ "required": [
+ "operation_ids"
+ ],
+ "title": "HistoryDelete"
}
},
"securitySchemes": {
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index f6dfa45..25a4cd8 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -14,6 +14,7 @@ from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.exceptions import HTTPException
+from pigway_frame_flow.api.history import install_history
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
from pigway_frame_flow.core.auth import require, seed_client
from pigway_frame_flow.core.registry import DeviceRegistry
@@ -132,6 +133,7 @@ def create_app(settings=None, start_worker=True):
)
install_extensions(app, service)
install_accounts(app, db)
+ install_history(app, service)
app.state.service = service
app.state.worker_enabled = start_worker
app.state.scheduler = scheduler
@@ -842,7 +844,7 @@ def create_app(settings=None, start_worker=True):
return [
service.operation(x["id"])
for x in db.all(
- "SELECT id FROM operations WHERE (? OR client_id=?) ORDER BY created_at DESC LIMIT ?",
+ "SELECT id FROM operations WHERE (? OR client_id=?) AND NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=operations.id) ORDER BY created_at DESC,rowid DESC LIMIT ?",
("admin" in request.state.permissions, request.state.client_id, limit),
)
]
@@ -1001,7 +1003,10 @@ def create_app(settings=None, start_worker=True):
dependencies=[Depends(require("history:read"))],
)
def history(limit: int = Query(default=100, ge=1, le=200)):
- return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
+ return db.all(
+ "SELECT * FROM history WHERE NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=history.operation_id) ORDER BY id DESC LIMIT ?",
+ (limit,),
+ )
@app.get(
"/api/v1/settings",
diff --git a/pigway_frame_flow/api/history.py b/pigway_frame_flow/api/history.py
new file mode 100644
index 0000000..8acedbb
--- /dev/null
+++ b/pigway_frame_flow/api/history.py
@@ -0,0 +1,121 @@
+"""Paginated history without removing operation receipts used by other services."""
+
+from datetime import UTC, date, datetime, time, timedelta
+from zoneinfo import ZoneInfo
+
+from fastapi import Depends, Query, Request
+from pydantic import BaseModel, Field
+
+from pigway_frame_flow.core.auth import require
+from pigway_frame_flow.errors import ErrorCode as E
+from pigway_frame_flow.errors import FlowError
+from pigway_frame_flow.models import Operation, OperationStatus
+
+
+class HistoryPage(BaseModel):
+ items: list[Operation]
+ total: int
+ page: int
+ page_size: int
+
+
+class HistoryDelete(BaseModel):
+ operation_ids: list[str] = Field(min_length=1, max_length=200)
+
+
+def install_history(app, service):
+ db = service.db
+
+ @app.get(
+ "/api/v1/operation-history",
+ response_model=HistoryPage,
+ tags=["Operations"],
+ dependencies=[Depends(require("history:read"))],
+ )
+ def list_history(
+ request: Request,
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ status: OperationStatus | None = None,
+ action: str = Query("", max_length=80),
+ device_id: str = Query("", max_length=120),
+ q: str = Query("", max_length=200),
+ start: date | None = None,
+ end: date | None = None,
+ ):
+ if start and end and start > end:
+ raise FlowError(
+ E.INVALID_REQUEST, "Start date must not follow end date", 422
+ )
+ clauses = [
+ "NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=o.id)"
+ ]
+ args: list = []
+ if "admin" not in request.state.permissions:
+ clauses.append("o.client_id=?")
+ args.append(request.state.client_id)
+ for column, value in [
+ ("status", status),
+ ("action", action),
+ ("device_id", device_id),
+ ]:
+ if value:
+ clauses.append(f"o.{column}=?")
+ args.append(value)
+ if q.strip():
+ clauses.append(
+ "instr(lower(o.id||' '||o.request_id||' '||o.client_id||' '||coalesce(o.result,'')),lower(?))>0"
+ )
+ args.append(q.strip())
+ for day, operator, offset in [(start, ">=", 0), (end, "<", 1)]:
+ if day:
+ boundary = datetime.combine(
+ day, time(), ZoneInfo("Asia/Shanghai")
+ ) + timedelta(days=offset)
+ clauses.append(f"o.created_at{operator}?")
+ args.append(boundary.astimezone(UTC).isoformat())
+ where = " AND ".join(clauses)
+ # Keep count and rows consistent if a worker finishes or a deletion occurs.
+ with db.lock:
+ total = db.one(
+ "SELECT count(*) AS total FROM operations o WHERE " + where, args
+ )["total"]
+ page = min(page, max(1, (total + page_size - 1) // page_size))
+ rows = db.all(
+ "SELECT o.id FROM operations o WHERE "
+ + where
+ + " ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?",
+ [*args, page_size, (page - 1) * page_size],
+ )
+ return HistoryPage(
+ items=[service.operation(row["id"]) for row in rows],
+ total=total,
+ page=page,
+ page_size=page_size,
+ )
+
+ @app.delete(
+ "/api/v1/operation-history",
+ tags=["Operations"],
+ dependencies=[Depends(require("admin"))],
+ )
+ def delete_history(payload: HistoryDelete):
+ ids = list(dict.fromkeys(payload.operation_ids))
+ placeholders = ",".join("?" for _ in ids)
+ with db.transaction() as connection:
+ rows = connection.execute(
+ f"SELECT id,status FROM operations WHERE id IN ({placeholders})", ids
+ ).fetchall()
+ if len(rows) != len(ids):
+ raise FlowError(E.NOT_FOUND, "History record not found", 404)
+ if any(
+ row["status"] not in {"COMPLETED", "FAILED", "UNKNOWN"} for row in rows
+ ):
+ raise FlowError(E.CONFLICT, "Running operations cannot be deleted", 409)
+ before = connection.total_changes
+ connection.executemany(
+ "INSERT OR IGNORE INTO hidden_history(operation_id) VALUES(?)",
+ [(key,) for key in ids],
+ )
+ deleted = connection.total_changes - before
+ return {"deleted": deleted}
diff --git a/pigway_frame_flow/storage/database.py b/pigway_frame_flow/storage/database.py
index 240bb5f..bd50a41 100644
--- a/pigway_frame_flow/storage/database.py
+++ b/pigway_frame_flow/storage/database.py
@@ -45,6 +45,7 @@ class Database:
last_run TEXT, last_result TEXT);
CREATE TABLE IF NOT EXISTS job_runs (job_id TEXT, scheduled_at TEXT,
operation_id TEXT, PRIMARY KEY(job_id,scheduled_at));
+ CREATE TABLE IF NOT EXISTS hidden_history (operation_id TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, client_id TEXT,
request_id TEXT, operation_id TEXT, device_id TEXT, action TEXT,
result TEXT, timestamp TEXT);
diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js
index 1daba57..79b7bbc 100644
--- a/pigway_frame_flow/web/app.js
+++ b/pigway_frame_flow/web/app.js
@@ -151,7 +151,7 @@ const views={
async upload(){state.preview=null;$('#main').innerHTML=notice()+`
${t('uploadNotice')}
${t('upload')}
${buttons('make-preview','preview',true)}${t('previewHint')}
${t('preview')}
${t('previewHint')}
`;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='
';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()+`${t('templateHint')}
${t('templateSteps')}
${state.templates.map((x,i)=>`
${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}
1200 × 1600 · ${esc(x.id)} `).join('')}
${t('templateData')}
${t('templateGuideTitle')}
${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}
${['weather','daily-dashboard'].includes(selected.id)?`${t('templateWeatherHelp')}
`:''}${t('helpProfile')}
${t('templateActionHelp')}
${t('templateAutomationHelp')}
${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}
${t('preview')}
${t('templatePreviewHint')}
`;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='
';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()+`${t('playlistNotice')}
${t('playlists')}
${buttons('new-playlist','newPlaylist',true)}${playlists.map((p,i)=>`
${t(p.enabled?'enabled':'disabled')}${esc(p.name)}
${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}
${esc(p.image_ids.join(' → '))}
${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}`).join('')||`
${t('noTasks')}
`}
`;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=`${t('history')}
${buttons('history-refresh','refresh')}${ops.some(x=>x.status==='UNKNOWN')?`${t('unknown')}
`:''}${ops.length?`
${['created','action','status','result','client','requestId'].map(x=>`| ${t(x)} | `).join('')}
${ops.map((o,i)=>`| ${fmt(o.created_at)} | ${esc(operationActionLabel(o.action))} ${esc(deviceNameForId(o.device_id))} | ${t(o.status)}${o.status==='UNKNOWN'?``:''} | ${operationResultHTML(o)} | ${esc(operationClientLabel(o.client_id))} | ${esc(o.request_id)} ${esc(o.operation_id)} |
`).join('')}
`:`
${t('noHistory')}
`}
`;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(`${t('reconcile')}
${t('current')}: ${esc(r.current_image_id)}
${t('result')}: ${esc(r.expected_image_id)}
${t('unknown')}
${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}
`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
+ async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`${t('history')}
${buttons('history-refresh','refresh')}${ops.some(x=>x.status==='UNKNOWN')?`${t('unknown')}
`:''}${ops.length?`
${['created','action','status','result','client','requestId'].map(x=>`| ${t(x)} | `).join('')}
${ops.map((o,i)=>`| ${fmt(o.created_at)} | ${esc(operationActionLabel(o.action))} ${esc(deviceNameForId(o.device_id))} | ${t(o.status)}${o.status==='UNKNOWN'?``:''} | ${operationResultHTML(o)} | ${esc(operationClientLabel(o.client_id))} | ${esc(o.request_id)} ${esc(o.operation_id)} |
`).join('')}
`:`
${t('noHistory')}
`}
`;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(`${t('reconcile')}
${t('current')}: ${esc(r.current_image_id)}
${t('result')}: ${esc(r.expected_image_id)}
${t('unknown')}
${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}
`);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()+`${t('settings')}
${t('tokenHint')}
${buttons('logout','logout')}${t('auth')}
${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)}
${t('refreshInterval')}
${t('apiDocs')} →GET /openapi.json · /api/v1
`;$('#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};}
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index 2d1482c..986dcb9 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -45,7 +45,8 @@ async function weatherSettings(){
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
}
async function enhanceHistory(){
- const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`${t('details')}
${t(current.status)} · ${esc(operationActionLabel(current.action))}
${operationResultHTML(current)}${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}${(current.result?.preview_url||current.result?.url)?'
':''}${buttons('modal-cancel','cancel')}
`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
+ historyControls();
+ const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);const op=state.historyPageData?.items.find(x=>x.operation_id===operationId);if(op&&['COMPLETED','FAILED','UNKNOWN'].includes(op.status)){const remove=document.createElement('button');remove.textContent=t('delete');remove.className='danger';remove.onclick=()=>deleteHistory([operationId]);actions.append(remove);}button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`${t('details')}
${t(current.status)} · ${esc(operationActionLabel(current.action))}
${operationResultHTML(current)}${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}${(current.result?.preview_url||current.result?.url)?'
':''}${buttons('modal-cancel','cancel')}
`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
async function enhanceAutomation(){
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`${t('jobPreview')}
![Preview]()
${buttons('modal-cancel','cancel')}
`);await blobImage($('#job-preview-image'),op.result.url);});}});
}
@@ -166,3 +167,25 @@ async function previewSource(id,item_id=null){
async function sourceJobForm(id){
await jobEditor({name:t('randomPhoto'),device_id:state.device,enabled:false,schedule:{type:'interval',interval_minutes:120},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50});
}
+
+function historyQuery(){return new URLSearchParams({...state.historyFilters,page:state.historyPage||1,page_size:state.historyPageSize||20});}
+function historyControls(){
+ const data=state.historyPageData;if(!data)return;
+ const filters=state.historyFilters||{};
+ const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
+ const select=(id,label,options)=>``;
+ form.innerHTML=``+select('history-status','status',``+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>``).join(''))+select('history-action','action',``+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>``).join(''))+select('history-device','device',``+state.devices.map(d=>``).join(''))+``;
+ const wrap=$('#main .table-wrap');wrap.before(form);
+ for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
+ form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
+ $('#history-end').oninput=()=>$('#history-end').setCustomValidity('');$('#history-start').oninput=()=>$('#history-end').setCustomValidity('');
+ bind('history-reset',async()=>{state.historyFilters={};state.historyPage=1;await navigate();});
+ const bar=document.createElement('div');bar.className='actions history-toolbar';bar.innerHTML=``;wrap.before(bar);
+ const pager=document.createElement('div');pager.className='actions history-pagination';const pages=Math.max(1,Math.ceil(data.total/data.page_size));pager.innerHTML=`${t('historyTotal').replace('{count}',data.total)} · ${data.page} / ${pages}`;wrap.after(pager);
+ bind('history-prev',async()=>{state.historyPage=data.page-1;await navigate();});bind('history-next',async()=>{state.historyPage=data.page+1;await navigate();});$('#history-page-size').onchange=busy(async()=>{state.historyPageSize=Number($('#history-page-size').value);state.historyPage=1;await navigate();});
+ const checkboxes=()=>[...document.querySelectorAll('.history-select:not(:disabled)')];
+ const update=()=>{const selected=checkboxes().filter(x=>x.checked);$('#history-delete').disabled=!selected.length;$('#history-selected').textContent=t('historySelected').replace('{count}',selected.length);};
+ for(const op of data.items){const row=[...wrap.querySelectorAll('tbody tr')].find(r=>r.dataset.operationId===op.operation_id);if(!row)continue;const box=document.createElement('input');box.type='checkbox';box.className='history-select';box.value=op.operation_id;box.setAttribute('aria-label',t('selectHistory')+' '+op.operation_id);box.disabled=!['COMPLETED','FAILED','UNKNOWN'].includes(op.status);box.onchange=update;row.cells[0].prepend(box);}
+ bind('history-all',()=>{const boxes=checkboxes(),checked=boxes.length>0&&boxes.every(x=>x.checked);boxes.forEach(x=>x.checked=!checked);update();});bind('history-invert',()=>{checkboxes().forEach(x=>x.checked=!x.checked);update();});bind('history-delete',()=>deleteHistory(checkboxes().filter(x=>x.checked).map(x=>x.value)));update();
+}
+async function deleteHistory(ids){await confirmDialog(t('deleteHistoryConfirm').replace('{count}',ids.length),async()=>{await api('/api/v1/operation-history',{method:'DELETE',body:{operation_ids:ids}});await navigate();});}
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index 60a5fc7..274cb65 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -149,3 +149,6 @@ Object.assign(window.MESSAGES.zh,{signIn:'登录',livingRoom:'客厅'});Object.a
Object.assign(MESSAGES.zh,{RESTORE_IMAGE_PROTECTED:'这张图片仍用于恢复显示,暂时不能删除。请先结束临时展示,或显示另一张图片以更新恢复目标。'});
Object.assign(MESSAGES.en,{RESTORE_IMAGE_PROTECTED:'This image is still needed to restore the display. End the temporary display or show another image to update the restore target before deleting it.'});
+
+Object.assign(MESSAGES.zh,{searchHistory:'搜索图片名称、请求 ID、任务 ID 或来源',allStatuses:'全部状态',allActions:'全部操作',allDevices:'全部设备',startDate:'开始日期',endDate:'结束日期',filter:'筛选',resetFilters:'重置筛选',dateRangeInvalid:'结束日期不能早于开始日期',historyTotal:'共 {count} 条',pageSize:'每页条数',historySelected:'已选 {count} 条(仅当前页)',selectHistory:'选择记录',deleteHistoryConfirm:'确认删除这 {count} 条历史记录?记录将从历史列表移除,不会删除图片或取消任务。此操作无法撤销。'});
+Object.assign(MESSAGES.en,{searchHistory:'Search image name, request ID, operation ID or client',allStatuses:'All statuses',allActions:'All actions',allDevices:'All devices',startDate:'Start date',endDate:'End date',filter:'Filter',resetFilters:'Reset filters',dateRangeInvalid:'End date must not precede start date',historyTotal:'{count} records',pageSize:'Rows per page',historySelected:'{count} selected (current page only)',selectHistory:'Select record',deleteHistoryConfirm:'Delete these {count} history records? This removes them from history without deleting images or cancelling tasks. This cannot be undone.'});
diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css
index 667fe42..0811fc3 100644
--- a/pigway_frame_flow/web/style.css
+++ b/pigway_frame_flow/web/style.css
@@ -49,7 +49,7 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
-.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
+.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:180px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:190px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
@@ -238,3 +238,12 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
/* Stacked wordmark keeps the product name readable within the sidebar. */
.sidebar .brand{font-size:26px;font-weight:800;line-height:1.15;letter-spacing:1px}
.sidebar .brand small{margin-top:6px;font-size:12px;font-weight:700;letter-spacing:4px;line-height:1.4;white-space:nowrap}
+
+form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
+#history-filters .field{min-width:0;margin:0}
+#history-filters>.actions{grid-column:1/-1}
+.history-select{margin:0 10px 0 0;vertical-align:middle}
+.history-actions button+button{margin-left:10px}
+.history-pagination select{width:auto}
+@media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}}
diff --git a/scripts/ui-history-layout.cjs b/scripts/ui-history-layout.cjs
index 9b7c7c4..c773204 100644
--- a/scripts/ui-history-layout.cjs
+++ b/scripts/ui-history-layout.cjs
@@ -1,3 +1,3 @@
/* Mock history fixtures only; verifies long identifiers and detail actions. */
const assert=require('assert'),{chromium}=require('playwright');
-(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const op={operation_id:'a'.repeat(64),request_id:'b'.repeat(64),action:'render',device_id:'living-room',client_id:'user:way',created_at:'2026-10-05T00:28:05Z',status:'COMPLETED',result:{asset_id:'c'.repeat(64)}};await page.route('**/api/v1/operations',r=>r.fulfill({json:[op,op]}));await page.route('**/api/v1/operations/'+op.operation_id,r=>r.fulfill({json:op}));await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();let checks=0;for(const lang of ['zh','en'])for(const width of [1440,768,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="history"]').click();await page.locator('.history-actions button').first().waitFor();assert(await page.evaluate(()=>{const table=document.querySelector('.history-table');return table.querySelectorAll('th').length===7&&[...table.querySelectorAll('tbody tr')].every(row=>row.children.length===7&&row.lastElementChild.classList.contains('history-actions')&&row.lastElementChild.previousElementSibling.querySelector('button')===null)&&document.documentElement.scrollWidth<=innerWidth;}));await page.locator('.history-actions button').first().click();await page.locator('#modal[open]').waitFor();await page.locator('#modal-cancel').click();checks++;}await browser.close();console.log('History layout and detail dialog: '+checks+' passed');})().catch(e=>{console.error(e);process.exit(1)});
+(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();const op={operation_id:'a'.repeat(64),request_id:'b'.repeat(64),action:'render',device_id:'living-room',client_id:'user:way',created_at:'2026-10-05T00:28:05Z',status:'COMPLETED',result:{asset_id:'c'.repeat(64)}};await page.route('**/api/v1/operation-history?*',r=>r.fulfill({json:{items:[op,{...op,operation_id:'d'.repeat(64)}],total:2,page:1,page_size:20}}));await page.route('**/api/v1/operations/'+op.operation_id,r=>r.fulfill({json:op}));await page.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();let checks=0;for(const lang of ['zh','en'])for(const width of [1440,768,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="history"]').click();await page.locator('.history-actions button').first().waitFor();assert(await page.evaluate(()=>{const table=document.querySelector('.history-table');return table.querySelectorAll('th').length===7&&[...table.querySelectorAll('tbody tr')].every(row=>row.children.length===7&&row.lastElementChild.classList.contains('history-actions')&&row.lastElementChild.previousElementSibling.querySelector('button')===null)&&document.documentElement.scrollWidth<=innerWidth;}));await page.locator('.history-actions button').first().click();await page.locator('#modal[open]').waitFor();await page.locator('#modal-cancel').click();checks++;}await browser.close();console.log('History layout and detail dialog: '+checks+' passed');})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/scripts/ui-history-localization.cjs b/scripts/ui-history-localization.cjs
index eca3c32..fce3c49 100644
--- a/scripts/ui-history-localization.cjs
+++ b/scripts/ui-history-localization.cjs
@@ -13,6 +13,6 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
- await p.evaluate(async()=>{const op={operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',result:{image_id:999,image_name:'example .jpg',preview_url:'/api/v1/assets/fixture'},error:{code:'WRITE_DISABLED'},request_id:'fixture'};api=async(path,options)=>options?.binary?(await fetch(path)).blob():path.endsWith('/fixture')?op:[op];state.devices=[{id:'living-room',name:'living-room'}];document.body.classList.remove('login-mode');shell();await views.history(state.epoch);await enhanceHistory();});
- const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes('example .jpg'));assert(text.includes('999'));assert(text.includes(lang==='zh'?'云端图片 ID':'Cloud image ID'));assert(await p.locator('td div.bad').count()===1);assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));await p.locator('.history-actions button').click();await p.locator('#operation-preview').waitFor();assert(await p.locator('#operation-preview').evaluate(img=>img.complete&&img.naturalWidth>0));assert(await p.locator('#modal-content').innerText().then(x=>x.includes('example .jpg')));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
+ await p.evaluate(async()=>{const op={operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',result:{image_id:999,image_name:'example .jpg',preview_url:'/api/v1/assets/fixture'},error:{code:'WRITE_DISABLED'},request_id:'fixture'};api=async(path,options)=>options?.binary?(await fetch(path)).blob():path.endsWith('/fixture')?op:{items:[op],total:1,page:1,page_size:20};state.devices=[{id:'living-room',name:'living-room'}];document.body.classList.remove('login-mode');shell();await views.history(state.epoch);await enhanceHistory();});
+ const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes('example .jpg'));assert(text.includes('999'));assert(text.includes(lang==='zh'?'云端图片 ID':'Cloud image ID'));assert(await p.locator('td div.bad').count()===1);assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));await p.locator('.history-actions button').first().click();await p.locator('#operation-preview').waitFor();assert(await p.locator('#operation-preview').evaluate(img=>img.complete&&img.naturalWidth>0));assert(await p.locator('#modal-content').innerText().then(x=>x.includes('example .jpg')));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/tests/contract.json b/tests/contract.json
index 1b368d7..c446f08 100644
--- a/tests/contract.json
+++ b/tests/contract.json
@@ -1448,5 +1448,35 @@
"502",
"503"
]
+ },
+ "/api/v1/operation-history": {
+ "get": [
+ "200",
+ "400",
+ "401",
+ "403",
+ "404",
+ "409",
+ "413",
+ "422",
+ "429",
+ "500",
+ "502",
+ "503"
+ ],
+ "delete": [
+ "200",
+ "400",
+ "401",
+ "403",
+ "404",
+ "409",
+ "413",
+ "422",
+ "429",
+ "500",
+ "502",
+ "503"
+ ]
}
}
diff --git a/tests/test_history_management.py b/tests/test_history_management.py
new file mode 100644
index 0000000..a3e948d
--- /dev/null
+++ b/tests/test_history_management.py
@@ -0,0 +1,151 @@
+import hashlib
+import json
+
+from pigway_frame_flow.core.accounts import password_hash
+
+
+def make_op(service, n, status="COMPLETED", client_id="admin", action="render"):
+ key = service.submit(action, {}, "living-room", client_id, f"request-{n}")
+ service.update(key, status)
+ service.db.execute(
+ "UPDATE operations SET created_at=?,result=? WHERE id=?",
+ (
+ f"2026-10-{n:02d}T10:00:00+00:00",
+ json.dumps({"image_name": f"photo-{n}.jpg"}),
+ key,
+ ),
+ )
+ return key
+
+
+def test_pagination_filters_and_page_clamp(client, service):
+ ids = [make_op(service, n, "FAILED" if n % 2 else "COMPLETED") for n in range(1, 7)]
+ page = client.get("/api/v1/operation-history?page_size=2").json()
+ assert page["total"] == 6
+ assert [x["operation_id"] for x in page["items"]] == ids[-1:-3:-1]
+ assert (
+ client.get("/api/v1/operation-history?page=2&page_size=2").json()["items"][0][
+ "operation_id"
+ ]
+ == ids[3]
+ )
+ assert (
+ client.get("/api/v1/operation-history?page=99&page_size=2").json()["page"] == 3
+ )
+ assert client.get("/api/v1/operation-history?status=FAILED").json()["total"] == 3
+ assert (
+ client.get("/api/v1/operation-history?q=photo-3").json()["items"][0][
+ "operation_id"
+ ]
+ == ids[2]
+ )
+ assert (
+ client.get("/api/v1/operation-history?start=2026-10-03&end=2026-10-04").json()[
+ "total"
+ ]
+ == 2
+ )
+ assert (
+ client.get(
+ "/api/v1/operation-history?start=2026-10-05&end=2026-10-01"
+ ).status_code
+ == 422
+ )
+ assert client.get("/api/v1/operation-history?device_id=other").json()["total"] == 0
+ assert client.get("/api/v1/operation-history?action=display").json()["total"] == 0
+
+
+def test_delete_preserves_receipts_and_protects_running(client, service):
+ done = make_op(service, 1)
+ running = make_op(service, 2, "QUEUED")
+ assert (
+ client.request(
+ "DELETE",
+ "/api/v1/operation-history",
+ json={"operation_ids": [done, running]},
+ ).status_code
+ == 409
+ )
+ assert client.get("/api/v1/operation-history").json()["total"] == 2
+ assert (
+ client.request(
+ "DELETE",
+ "/api/v1/operation-history",
+ json={"operation_ids": [done, "missing"]},
+ ).status_code
+ == 404
+ )
+ assert (
+ client.request(
+ "DELETE", "/api/v1/operation-history", json={"operation_ids": [done, done]}
+ ).json()["deleted"]
+ == 1
+ )
+ assert client.get("/api/v1/operation-history").json()["total"] == 1
+ assert done not in [
+ x["operation_id"] for x in client.get("/api/v1/operations").json()
+ ]
+ assert client.get("/api/v1/operations/" + done).status_code == 200
+ assert service.db.one("SELECT id FROM operations WHERE id=?", (done,))
+ assert (
+ client.request(
+ "DELETE", "/api/v1/operation-history", json={"operation_ids": [done]}
+ ).json()["deleted"]
+ == 0
+ )
+
+
+def test_history_permissions(client, service):
+ mine = make_op(service, 1, client_id="reader")
+ make_op(service, 2)
+ service.db.execute(
+ "INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
+ (
+ "reader",
+ hashlib.sha256(b"reader-token").hexdigest(),
+ "Reader",
+ json.dumps(["history:read"]),
+ "",
+ None,
+ 1,
+ ),
+ )
+ client.headers["Authorization"] = "Bearer reader-token"
+ result = client.get("/api/v1/operation-history").json()
+ assert result["total"] == 1 and result["items"][0]["operation_id"] == mine
+ assert (
+ client.request(
+ "DELETE", "/api/v1/operation-history", json={"operation_ids": [mine]}
+ ).status_code
+ == 403
+ )
+ client.headers.pop("Authorization")
+ assert client.get("/api/v1/operation-history").status_code == 401
+
+
+def test_history_delete_requires_browser_csrf(client, service):
+ op = make_op(service, 1)
+ service.db.execute(
+ "INSERT INTO users VALUES(?,?,1)",
+ ("fixture", password_hash("fixture-password")),
+ )
+ client.headers.pop("Authorization")
+ login = client.post(
+ "/api/v1/auth/login",
+ json={"username": "fixture", "password": "fixture-password"},
+ ).json()
+ assert (
+ client.request(
+ "DELETE", "/api/v1/operation-history", json={"operation_ids": [op]}
+ ).status_code
+ == 403
+ )
+ assert (
+ client.request(
+ "DELETE",
+ "/api/v1/operation-history",
+ json={"operation_ids": [op]},
+ headers={"X-CSRF-Token": login["csrf_token"]},
+ ).status_code
+ == 200
+ )