From 4771db6f2a5b81ad77b748011af6c17252d7bc42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Tue, 20 Oct 2026 16:53:27 +0800 Subject: [PATCH] feat: paginate filter and delete operation history --- README.zh-CN.md | 2 + SHA256SUMS | 24 +- docs/openapi.json | 443 +++++++++++++++++++++++++- pigway_frame_flow/api/app.py | 9 +- pigway_frame_flow/api/history.py | 121 +++++++ pigway_frame_flow/storage/database.py | 1 + pigway_frame_flow/web/app.js | 2 +- pigway_frame_flow/web/features.js | 25 +- pigway_frame_flow/web/i18n.js | 3 + pigway_frame_flow/web/style.css | 11 +- scripts/ui-history-layout.cjs | 2 +- scripts/ui-history-localization.cjs | 4 +- tests/contract.json | 30 ++ tests/test_history_management.py | 151 +++++++++ 14 files changed, 808 insertions(+), 20 deletions(-) create mode 100644 pigway_frame_flow/api/history.py create mode 100644 tests/test_history_management.py 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')}

${t('selectFile')}

${t('uploadHint')}

${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='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()+`
${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='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()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;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=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${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)}
`:`
${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')}

`);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=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${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)}
`:`
${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')}

`);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)?'Preview':''}`);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)?'Preview':''}`);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`);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 + )