From 4b372100eb93e92f23559cf3a0dc68900aa8e8d8 Mon Sep 17 00:00:00 2001 From: HiepLM Date: Mon, 15 Jun 2026 10:30:00 +0700 Subject: [PATCH] update mission cancel --- .gitignore | 3 +- data/mission_queue.json | 740 ++++++++++++++++-- data/missions.json | 6 +- scripts/test/smoke.sh | 27 + src/mission/mission_queue.cpp | 72 +- src/mission/mission_queue.hpp | 2 + src/server/api_server.cpp | 9 + ...pi_integration.cpython-38-pytest-8.3.5.pyc | Bin 10203 -> 14808 bytes tests/test_api_integration.py | 82 +- www/dashboard.js | 20 +- www/index.html | 5 +- www/missions.js | 41 +- www/style.css | 49 +- 13 files changed, 922 insertions(+), 134 deletions(-) diff --git a/.gitignore b/.gitignore index 2c02567..5dde2a7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,4 @@ # Built Visual Studio Code Extensions *.vsix -build/ -data/ \ No newline at end of file +build/ \ No newline at end of file diff --git a/data/mission_queue.json b/data/mission_queue.json index 7be9009..1068394 100644 --- a/data/mission_queue.json +++ b/data/mission_queue.json @@ -1,24 +1,664 @@ { "queue": [ { - "created_at": "2026-06-13T07:19:31Z", - "finished_at": "2026-06-13T07:19:32Z", - "id": "3a520bd342883c75", + "created_at": "2026-06-15T03:25:12Z", + "finished_at": "2026-06-15T03:26:42Z", + "id": "6732b109c5f13b8f", "log": [ { "level": "info", - "message": "Loop 1/1", - "ts": "2026-06-13T07:19:31Z" + "message": "Loop endless (simulated, max 10000)", + "ts": "2026-06-15T03:25:12Z" }, { "level": "info", "message": "Set PLC register (set_plc_register) simulated", - "ts": "2026-06-13T07:19:31Z" + "ts": "2026-06-15T03:25:12Z" }, { "level": "info", "message": "Wait 1000ms", - "ts": "2026-06-13T07:19:31Z" + "ts": "2026-06-15T03:25:13Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:14Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:14Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:15Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:16Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:17Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:17Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:18Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:18Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:19Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:20Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:21Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:21Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:22Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:23Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:24Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:24Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:25Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:25Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:26Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:27Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:28Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:28Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:29Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:30Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:31Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:31Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:32Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:32Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:33Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:34Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:35Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:35Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:36Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:37Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:38Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:38Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:39Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:39Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:40Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:41Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:42Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:42Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:43Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:44Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:45Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:45Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:46Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:46Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:47Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:48Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:49Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:49Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:50Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:51Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:52Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:52Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:53Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:53Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:54Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:55Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:56Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:56Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:57Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:58Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:25:59Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:25:59Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:00Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:00Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:01Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:02Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:03Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:03Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:04Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:05Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:06Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:06Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:07Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:07Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:08Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:09Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:10Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:10Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:11Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:12Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:13Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:13Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:14Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:14Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:15Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:16Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:17Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:17Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:18Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:19Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:20Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:20Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:21Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:21Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:22Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:23Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:24Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:24Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:25Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:26Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:27Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:27Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:28Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:28Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:29Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:30Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:31Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:31Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:32Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:33Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:34Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:34Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:35Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:35Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:36Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:37Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:38Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:38Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:39Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:40Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:41Z" + }, + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-15T03:26:41Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-15T03:26:42Z" + }, + { + "level": "warn", + "message": "Mission hủy bởi operator", + "ts": "2026-06-15T03:26:42Z" } ], "mission": { @@ -50,8 +690,8 @@ "kind": "action", "label": "Loop", "params": { - "count": 1, - "mode": "count" + "count": 0, + "mode": "endless" }, "type": "loop" } @@ -60,7 +700,7 @@ "group": "Missions", "id": "5ae9dbcb0722dffb", "name": "Test run", - "updated_at": "2026-06-13T07:19:17.185Z" + "updated_at": "2026-06-15T03:08:55.138Z" }, "mission_group": "Missions", "mission_id": "5ae9dbcb0722dffb", @@ -69,88 +709,16 @@ "priority": 0, "robot_id": "default", "source": "ui", - "started_at": "2026-06-13T07:19:31Z", - "status": "completed" - }, - { - "created_at": "2026-06-13T07:19:41Z", - "finished_at": "2026-06-13T07:19:43Z", - "id": "51de82d74cb4b1cd", - "log": [ - { - "level": "info", - "message": "Loop 1/1", - "ts": "2026-06-13T07:19:41Z" - }, - { - "level": "info", - "message": "Set PLC register (set_plc_register) simulated", - "ts": "2026-06-13T07:19:41Z" - }, - { - "level": "info", - "message": "Wait 1000ms", - "ts": "2026-06-13T07:19:42Z" - } - ], - "mission": { - "actions": [ - { - "children": [ - { - "id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c", - "kind": "action", - "label": "Set PLC register", - "params": { - "action": "set", - "register": 1, - "value": 0 - }, - "type": "set_plc_register" - }, - { - "id": "a1", - "kind": "action", - "label": "Wait", - "params": { - "seconds": 1 - }, - "type": "wait" - } - ], - "id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4", - "kind": "action", - "label": "Loop", - "params": { - "count": 1, - "mode": "count" - }, - "type": "loop" - } - ], - "description": "", - "group": "Missions", - "id": "5ae9dbcb0722dffb", - "name": "Test run", - "updated_at": "2026-06-13T07:19:17.185Z" - }, - "mission_group": "Missions", - "mission_id": "5ae9dbcb0722dffb", - "mission_name": "Test run", - "parameters": {}, - "priority": 0, - "robot_id": "default", - "source": "ui", - "started_at": "2026-06-13T07:19:41Z", - "status": "completed" + "started_at": "2026-06-15T03:25:12Z", + "status": "cancelled" } ], "runner": { "current_action": null, "current_queue_id": null, - "message": "Hoàn thành: Test run", + "message": "Đã hủy: Test run", "paused": false, "state": "idle", - "updated_at": "2026-06-13T07:19:43Z" + "updated_at": "2026-06-15T03:26:42Z" } } \ No newline at end of file diff --git a/data/missions.json b/data/missions.json index 6e90417..9d443fd 100644 --- a/data/missions.json +++ b/data/missions.json @@ -40,8 +40,8 @@ "kind": "action", "label": "Loop", "params": { - "count": 1, - "mode": "count" + "count": 0, + "mode": "endless" }, "type": "loop" } @@ -50,7 +50,7 @@ "group": "Missions", "id": "5ae9dbcb0722dffb", "name": "Test run", - "updated_at": "2026-06-13T07:19:17.185Z" + "updated_at": "2026-06-15T03:08:55.138Z" }, { "actions": [ diff --git a/scripts/test/smoke.sh b/scripts/test/smoke.sh index 3f91cbf..aeb20dd 100755 --- a/scripts/test/smoke.sh +++ b/scripts/test/smoke.sh @@ -126,10 +126,37 @@ if [[ "$RUNNER_STATE" == "running" ]]; then assert_code "POST /api/mission_queue/continue" 200 "$TMP/cont.json" \ -X POST "$BASE/api/mission_queue/continue" assert_json_true "runner not paused" "$TMP/cont.json" 'doc.get("state") != "paused"' + for _ in $(seq 1 15); do + curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" + RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")" + if [[ "$RUNNER_STATE" == "running" || "$RUNNER_STATE" == "paused" ]]; then + break + fi + sleep 0.2 + done + if [[ "$RUNNER_STATE" == "running" || "$RUNNER_STATE" == "paused" ]]; then + assert_code "POST /api/mission_queue/cancel" 200 "$TMP/cancel.json" \ + -X POST "$BASE/api/mission_queue/cancel" + for _ in $(seq 1 40); do + curl -s "$BASE/api/mission_queue" -o "$TMP/runner_poll.json" + RUNNER_STATE="$(python3 -c "import json; print(json.load(open('$TMP/runner_poll.json')).get('runner',{}).get('state',''))")" + if [[ "$RUNNER_STATE" == "idle" ]]; then + break + fi + sleep 0.15 + done + assert_json_true "runner idle after cancel" "$TMP/runner_poll.json" \ + 'doc.get("runner",{}).get("state") == "idle"' + else + log_fail "runner not active for cancel test (skipped)" + fi else log_fail "runner never reached running (pause test skipped)" fi +assert_code "POST /api/mission_queue/cancel idle" 400 "$TMP/cancel_idle.json" \ + -X POST "$BASE/api/mission_queue/cancel" + # --- LiDAR CRUD --- assert_code "POST /api/lidars" 201 "$TMP/lidar.json" \ -X POST "$BASE/api/lidars" \ diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index a6b7b75..0de8e2b 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -12,6 +12,12 @@ namespace lm { namespace { +class MissionCancelled : public std::runtime_error +{ +public: + MissionCancelled() : std::runtime_error("mission cancelled") {} +}; + std::string paramValue(const std::string& action_id, const nlohmann::json& params, const std::string& key, @@ -319,6 +325,30 @@ bool MissionQueue::resume(std::string& err) return true; } +bool MissionQueue::cancel(std::string& err) +{ + std::lock_guard lock(mu_); + const std::string state = runner_.value("state", "idle"); + if (state != "running" && state != "paused") + { + err = "no mission is running"; + return false; + } + if (cancel_) + { + err = "cancel already in progress"; + return false; + } + cancel_ = true; + paused_ = false; + runner_["paused"] = false; + runner_["message"] = "Đang hủy mission…"; + runner_["updated_at"] = IdUtil::nowIso8601(); + saveUnlocked(); + wake_ = true; + return true; +} + void MissionQueue::workerLoop() { while (!stop_) @@ -382,14 +412,17 @@ void MissionQueue::workerLoop() void MissionQueue::runMissionActions(nlohmann::json& entry) { + cancel_ = false; + nlohmann::json log = nlohmann::json::array(); try { - nlohmann::json log = nlohmann::json::array(); const auto& mission = entry["mission"]; const auto& parameters = entry["parameters"]; const auto& actions = mission.contains("actions") && mission["actions"].is_array() ? mission["actions"] : nlohmann::json::array(); executeActionsUnlocked(actions, parameters, log, 0); + if (cancel_) + throw MissionCancelled(); entry["log"] = log; entry["status"] = "completed"; entry["finished_at"] = IdUtil::nowIso8601(); @@ -399,6 +432,18 @@ void MissionQueue::runMissionActions(nlohmann::json& entry) saveUnlocked(); } } + catch (const MissionCancelled&) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Mission hủy bởi operator"}}); + entry["log"] = log; + entry["status"] = "cancelled"; + entry["finished_at"] = IdUtil::nowIso8601(); + { + std::lock_guard lock(mu_); + setRunnerState("idle", "Đã hủy: " + entry.value("mission_name", "Mission")); + saveUnlocked(); + } + } catch (...) { entry["status"] = "failed"; @@ -409,6 +454,7 @@ void MissionQueue::runMissionActions(nlohmann::json& entry) saveUnlocked(); } } + cancel_ = false; } MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, @@ -423,10 +469,12 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j { if (!action.is_object()) continue; - while (paused_ && !stop_) + while (paused_ && !stop_ && !cancel_) std::this_thread::sleep_for(std::chrono::milliseconds(100)); if (stop_) return LoopControl::None; + if (cancel_) + throw MissionCancelled(); const std::string action_id = action.value("id", ""); const std::string kind = action.value("kind", "action"); @@ -478,7 +526,7 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j const auto& children = action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array(); const int iterations = mode == "endless" ? 10000 : std::max(1, count); - for (int i = 0; i < iterations && !stop_; ++i) + for (int i = 0; i < iterations && !stop_ && !cancel_; ++i) { if (mode == "endless" && i == 0) { @@ -497,6 +545,8 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j break; if (ctrl == LoopControl::Continue) continue; + if (cancel_) + throw MissionCancelled(); } continue; } @@ -506,6 +556,8 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j const int ms = static_cast(paramNumber(params, "seconds", 1) * 1000); log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Wait " + std::to_string(ms) + "ms"}}); sleepMs(ms); + if (cancel_) + throw MissionCancelled(); continue; } @@ -516,6 +568,8 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j {"level", "info"}, {"message", label + " → " + (pos.empty() ? "?" : pos)}}); sleepMs(1200); + if (cancel_) + throw MissionCancelled(); continue; } @@ -526,6 +580,8 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j {"level", "info"}, {"message", label + " → " + (marker.empty() ? "?" : marker)}}); sleepMs(1200); + if (cancel_) + throw MissionCancelled(); continue; } @@ -534,6 +590,8 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j const std::string message = params.value("message", "Mission step"); log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "user"}, {"message", message}}); sleepMs(200); + if (cancel_) + throw MissionCancelled(); continue; } @@ -541,12 +599,16 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j { log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Pause (simulated)"}}); sleepMs(500); + if (cancel_) + throw MissionCancelled(); continue; } log.push_back( {{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}}); sleepMs(400); + if (cancel_) + throw MissionCancelled(); } return LoopControl::None; } @@ -556,9 +618,9 @@ void MissionQueue::sleepMs(int ms) if (ms <= 0) return; const int step = 100; - for (int elapsed = 0; elapsed < ms && !stop_; elapsed += step) + for (int elapsed = 0; elapsed < ms && !stop_ && !cancel_; elapsed += step) { - while (paused_ && !stop_) + while (paused_ && !stop_ && !cancel_) std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed))); } diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index 0450de6..2308138 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -29,6 +29,7 @@ public: bool reorder(const nlohmann::json& ordered_ids, std::string& err); bool pause(std::string& err); bool resume(std::string& err); + bool cancel(std::string& err); private: enum class LoopControl { None, Break, Continue }; @@ -42,6 +43,7 @@ private: std::atomic stop_{false}; std::atomic wake_{false}; std::atomic paused_{false}; + std::atomic cancel_{false}; void load(); void saveUnlocked() const; diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 50a0ea3..764441a 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -526,6 +526,15 @@ void ApiServer::registerRoutes(httplib::Server& svr) res.body = mission_queue_.runnerStatus().dump(); }); + svr.Post("/api/mission_queue/cancel", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!mission_queue_.cancel(err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_queue_.runnerStatus().dump(); + }); + registerMissionRoutes(svr); registerIntegrationRoutes(svr); registerMirV2Routes(svr); diff --git a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc index 3912ca1d9d73a2616392092ceeeaefd5ba461d39..6c2283713443249bfc280a60b7abb02a299b2e49 100644 GIT binary patch literal 14808 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 delta 3391 zcmcImU2Igx6~1%-_W#dbuYYzIuUl}q!NDxH!TcPq{~%sppj&Ua?!obzYhB+mb;TnHs$42HnPmZ#w^V{ zGo$=&WS4#xW=zTaMyO|XPxK<`AH+>Y5XKPl2>TEYA{^qsiXGZ67ER!I)AE7Cw&_e& z7OF=IJRC28e^16Ug}tbm12AN^AN%6~%h8f;o6O1W*jZf~HEi}GN?t-Oupj$kTArs< z*%xy#E{2X)mnNqxY@y_gu+ylqY4>hHZq1kWWNEQv&6h6Cn+N&BxIxaX8j0`Zw>_xP2XSu^=yGT*@MJb#s06=pe0o}X1NRU9~xJF@U9!@GI^kGSm*dk3YsjzN|? zkXPQYbA^JSIAYj9{|E>@rMfio82vGzzvlo>e&flL(LV*0A4NEZFvuS!C-&iHufnau zivY`aK;x;FV=mkwE&mA@b>Zj0;mc;(yjo>PUgPzy0$JwYbnR^z%A+F@M_|M~otpEe zV~R`8@XmW`wPHK$1?aMu5ne$!j_?zNpCU{koIp5NRZ z!Ba0)=n{J!zx*7a{iH`*Cw&HGZy^Xmm#`;TW%x&3nJ{|9E&@pKn2(L8`GZs;Hv_{d z@sv;iKfJMcia>$b?<;&F(cd7qWvaNuB92Ve8w!yebwzDTRO^=Na;xvka}s;cm1Z@E zd?`_w0lM-Hc|~tZD;m`;sVUbLSAo8-NvI!Q=i-%*VXlt`nuJJK4m$po01aXvR#C(% zq1Gym8**KB)!ATOqv5*lYOX#f*L|+*PH`B~_|x z@b`fU>a1SZTPErHd$15183iMgD0$pS z(D)EEgqb%9`%DQgEFRf}2DeT5H2=Z3LKf-SUC;F`A;?ZcH$VuQ7=?HxU6c zLHLK@ABKMv%wBhW&-wv^=NnIQy=>nC6aItiZ_4&X&}gxe*^Dr_jnxLJ|K@(u_#fS` z2=^;C6^8Z-_xC-){fez`7^<-N7DF5B7v=x%ekwuQO~1;{!vkz(GK^gfb^-7{#(Pg^ zwQ|{Ho`z}F^n5t9%9q(r5H3I866UBybLEPYGs_U5a%(C%F(YS{bB?uOF86GW@Wa(o z4YJLaKq{4PMb1`Y9cv%#FY-TSjz3nD zY|C!jeH!N4WdMkqL?wQr6Zzk|7A9emBT*ugupE?=M2Gm`mvsKi>`78xeSYXIWfTuj zoE^J}y^WOUsSdL)Z}KUNpw4;z`(c9&@_WO@xF`cK8L~-)1N`)!?H7c@u(A?K=c#f#h{MJ>@NDyI!Nm5ZB(@p77;M*uqA}Hnu2HZSu)2D<=-I7t6${5 zA*V3K7#h2Ris*r25N;lGCKs>*#N5r^M8G?}$uCAIpU2OYb4)6@3ra(=<%JY*ukQI-l8RjWE&{m%ot6K zi88UJH^pl7Q}}34@T2(wesO%4{w@rSEdP9b2RY7vH$Ku36;C&=CLS}sUS=TlAc*Vs z=+b8K8Z`!)*)R@_JAZJl{4gqTuRz+AwxQ2imw_U&W$}Is^>0!8FwR!Uh0Tp|Q U-#+l;uwVE4{0V=rKk4uM9$XIWd;kCd diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py index 07d47b5..6bbf555 100644 --- a/tests/test_api_integration.py +++ b/tests/test_api_integration.py @@ -49,6 +49,29 @@ def mission_id(api): return resolve_mission_id(api) +def clear_queue(api: requests.Session) -> None: + api.delete(f"{BASE}/api/mission_queue", timeout=TIMEOUT) + + +def wait_runner_state(api: requests.Session, state: str, timeout: float = 8.0) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + runner = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json().get("runner", {}) + if runner.get("state") == state: + return runner + time.sleep(0.15) + pytest.fail(f"runner did not reach state {state!r} within {timeout}s") + + +def enqueue_mission(api: requests.Session, mission_id: str) -> None: + r = api.post( + f"{BASE}/api/mission_queue", + json={"mission_id": mission_id}, + timeout=TIMEOUT, + ) + assert r.status_code == 201 + + def test_health(api): r = api.get(f"{BASE}/api/health", timeout=TIMEOUT) assert r.status_code == 200 @@ -80,20 +103,9 @@ def test_mir_v2_enqueue_and_list(api, mission_id): def test_queue_pause_continue(api, mission_id): - api.delete(f"{BASE}/api/mission_queue", timeout=TIMEOUT) - api.post( - f"{BASE}/api/mission_queue", - json={"mission_id": mission_id}, - timeout=TIMEOUT, - ) - deadline = time.time() + 5 - while time.time() < deadline: - runner = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json().get("runner", {}) - if runner.get("state") == "running": - break - time.sleep(0.2) - else: - pytest.skip("runner did not enter running state in time") + clear_queue(api) + enqueue_mission(api, mission_id) + wait_runner_state(api, "running", timeout=5) r = api.post(f"{BASE}/api/mission_queue/pause", timeout=TIMEOUT) assert r.status_code == 200 @@ -104,6 +116,48 @@ def test_queue_pause_continue(api, mission_id): assert r.json().get("state") != "paused" +def test_queue_cancel_rejects_when_idle(api): + clear_queue(api) + r = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT) + assert r.status_code == 400 + body = r.json() + assert "error" in body + + +def test_queue_cancel_stops_running_mission(api, mission_id): + clear_queue(api) + enqueue_mission(api, mission_id) + wait_runner_state(api, "running", timeout=5) + + r = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT) + assert r.status_code == 200 + assert r.json().get("message") + + wait_runner_state(api, "idle", timeout=8) + data = api.get(f"{BASE}/api/mission_queue", timeout=TIMEOUT).json() + assert data["runner"]["state"] == "idle" + + cancelled = [item for item in data.get("queue", []) if item.get("status") == "cancelled"] + assert cancelled, "expected a cancelled queue entry" + assert cancelled[0].get("mission_id") == mission_id + log = cancelled[0].get("log") or [] + assert any(entry.get("message") == "Mission hủy bởi operator" for entry in log if isinstance(entry, dict)) + + +def test_queue_cancel_rejects_while_cancelling(api, mission_id): + clear_queue(api) + enqueue_mission(api, mission_id) + wait_runner_state(api, "running", timeout=5) + + first = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT) + assert first.status_code == 200 + + second = api.post(f"{BASE}/api/mission_queue/cancel", timeout=TIMEOUT) + assert second.status_code == 400 + + wait_runner_state(api, "idle", timeout=8) + + def test_modbus_trigger_flow(api, mission_id): trig = api.post( f"{BASE}/api/triggers", diff --git a/www/dashboard.js b/www/dashboard.js index 07dad0a..99b2588 100644 --- a/www/dashboard.js +++ b/www/dashboard.js @@ -131,7 +131,7 @@ -

Tạm dừng / tiếp tục mission đang chạy trên robot.

`; +

Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.

`; } } @@ -206,9 +206,14 @@ const paused = state === "paused" || snap?.runner?.paused; const running = state === "running" || paused; bodyEl.innerHTML = ` - +
+ + +

${running ? (paused ? "Mission đang tạm dừng" : "Mission đang chạy") : "Không có mission đang chạy"}

`; bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => { const action = evt.currentTarget.dataset.pauseAction; @@ -219,6 +224,13 @@ alert(e.message); } }); + bodyEl.querySelector("[data-cancel-mission]")?.addEventListener("click", async () => { + try { + await missions()?.cancelRunner?.(); + } catch (e) { + alert(e.message); + } + }); } function renderWidget(widget) { diff --git a/www/index.html b/www/index.html index cf68348..49663fd 100644 --- a/www/index.html +++ b/www/index.html @@ -547,7 +547,10 @@
Mission queue
Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.
- +
+ + +
diff --git a/www/missions.js b/www/missions.js index 3063fc4..51d85d0 100644 --- a/www/missions.js +++ b/www/missions.js @@ -473,6 +473,7 @@ executing: "Đang chạy", completed: "Xong", failed: "Lỗi", + cancelled: "Đã hủy", }; return map[status] || status; } @@ -639,6 +640,12 @@ await refreshQueue(); } + async function cancelRunner() { + if (!confirm("Hủy mission đang chạy? (thoát loop và dừng ngay)")) return; + await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" }); + await refreshQueue(); + } + function openQueueDialog(missionId) { const mission = findMission(missionId); if (!mission) return; @@ -850,15 +857,19 @@ row.innerHTML = `
-
-
- ${iconChar} - ${escapeHtml(action.label)} +
+
+
+ ${iconChar} + ${escapeHtml(action.label)} +
+
${escapeHtml(actionSummary(action))}
-
${escapeHtml(actionSummary(action))}
-
- - `; +
+ + +
+
`; if (action.type === "loop" && Array.isArray(action.children)) { const loop = document.createElement("div"); @@ -876,11 +887,15 @@ renderActionRows(action.children, `${listPath}.${action.id}`, drop); } loop.appendChild(drop); - row.querySelector(".missionActionMain").appendChild(loop); + row.appendChild(loop); } - row.querySelector("[data-config]").addEventListener("click", () => openActionConfig(action.id)); - row.querySelector("[data-remove]").addEventListener("click", () => { + row.querySelector("[data-config]").addEventListener("click", (evt) => { + evt.stopPropagation(); + openActionConfig(action.id); + }); + row.querySelector("[data-remove]").addEventListener("click", (evt) => { + evt.stopPropagation(); removeActionFromTree(action.id); renderMissionEditor(); }); @@ -1308,6 +1323,9 @@ }); el("missionQueueClearBtn")?.addEventListener("click", clearQueue); + el("missionQueueCancelBtn")?.addEventListener("click", () => { + cancelRunner().catch((e) => alert(e.message)); + }); el("missionQueueForm")?.addEventListener("submit", submitQueueDialog); } @@ -1327,6 +1345,7 @@ enqueueMission, pauseRunner, continueRunner, + cancelRunner, refreshQueue, clearQueue, getQueueSnapshot, diff --git a/www/style.css b/www/style.css index 1740e75..6d3150c 100644 --- a/www/style.css +++ b/www/style.css @@ -622,6 +622,9 @@ canvas { .missionQueueStatus.executing { background: #dbeafe; color: #1d4ed8; } .missionQueueStatus.completed { background: #d1fae5; color: #047857; } .missionQueueStatus.failed { background: #fee2e2; color: #b91c1c; } +.missionQueueStatus.cancelled { background: #f3f4f6; color: #6b7280; } +.missionQueueItem.status-cancelled { opacity: 0.72; } +.missionQueueCardActions { display: flex; gap: 8px; flex-wrap: wrap; } .missionQueueRunner { padding: 10px 12px; border-radius: 10px; @@ -730,7 +733,7 @@ canvas { .missionActionRow { display: grid; - grid-template-columns: 32px 1fr auto auto; + grid-template-columns: 32px 1fr; gap: 10px; align-items: start; padding: 10px 12px; @@ -739,6 +742,28 @@ canvas { background: #fff; box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); } +.missionActionTop { + grid-column: 2; + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; +} +.missionActionBtns { + display: flex; + gap: 6px; + flex-shrink: 0; + position: relative; + z-index: 2; +} +.missionLoopBlock { + grid-column: 2; + margin-top: 0; + border-radius: 10px; + border: 1px dashed rgba(124, 58, 237, 0.35); + background: rgba(124, 58, 237, 0.04); + padding: 10px; +} .missionActionRow.dragging { opacity: 0.45; } .missionActionRow.dropBefore { box-shadow: inset 0 3px 0 var(--accent); } .missionActionRow.dropAfter { box-shadow: inset 0 -3px 0 var(--accent); } @@ -789,13 +814,6 @@ canvas { .iconBtn:hover { border-color: rgba(37, 99, 235, 0.35); color: var(--accent); background: #eff6ff; } .iconBtn.danger:hover { border-color: rgba(239, 68, 68, 0.35); color: var(--danger); background: #fef2f2; } -.missionLoopBlock { - margin-top: 10px; - border-radius: 10px; - border: 1px dashed rgba(124, 58, 237, 0.35); - background: rgba(124, 58, 237, 0.04); - padding: 10px; -} .missionLoopLabel { font-size: 11px; font-weight: 700; @@ -950,6 +968,21 @@ canvas { } .dashboardPauseBtn.is-paused { background: #ecfdf5; color: #047857; } .dashboardPauseBtn:disabled { opacity: 0.45; cursor: not-allowed; } +.dashboardRunnerControls { display: grid; gap: 8px; } +.dashboardCancelBtn { + appearance: none; + width: 100%; + border: 1px solid rgba(239, 68, 68, 0.35); + border-radius: 12px; + padding: 12px; + font-size: 14px; + font-weight: 700; + cursor: pointer; + background: #fef2f2; + color: #b91c1c; +} +.dashboardCancelBtn:hover:not(:disabled) { background: #fee2e2; } +.dashboardCancelBtn:disabled { opacity: 0.45; cursor: not-allowed; } .dashboardInfoCard .dashboardInfoGrid { display: grid; gap: 8px; } .dashboardEmpty { text-align: center; padding: 12px 0 0; }