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 3912ca1..6c22837 100644 Binary files a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc and b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc differ 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; }