diff --git a/data/mission_queue.json b/data/mission_queue.json index 35dd188..7be9009 100644 --- a/data/mission_queue.json +++ b/data/mission_queue.json @@ -1,33 +1,66 @@ { "queue": [ { - "created_at": "2026-06-13T07:03:00Z", - "finished_at": "2026-06-13T07:03:01Z", - "id": "c636ad0d89937cd2", + "created_at": "2026-06-13T07:19:31Z", + "finished_at": "2026-06-13T07:19:32Z", + "id": "3a520bd342883c75", "log": [ + { + "level": "info", + "message": "Loop 1/1", + "ts": "2026-06-13T07:19:31Z" + }, + { + "level": "info", + "message": "Set PLC register (set_plc_register) simulated", + "ts": "2026-06-13T07:19:31Z" + }, { "level": "info", "message": "Wait 1000ms", - "ts": "2026-06-13T07:03:00Z" + "ts": "2026-06-13T07:19:31Z" } ], "mission": { "actions": [ { - "id": "a1", + "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": "Wait", + "label": "Loop", "params": { - "seconds": 1 + "count": 1, + "mode": "count" }, - "type": "wait" + "type": "loop" } ], "description": "", "group": "Missions", "id": "5ae9dbcb0722dffb", "name": "Test run", - "updated_at": "2026-06-13T04:44:03Z" + "updated_at": "2026-06-13T07:19:17.185Z" }, "mission_group": "Missions", "mission_id": "5ae9dbcb0722dffb", @@ -36,37 +69,70 @@ "priority": 0, "robot_id": "default", "source": "ui", - "started_at": "2026-06-13T07:03:00Z", + "started_at": "2026-06-13T07:19:31Z", "status": "completed" }, { - "created_at": "2026-06-13T07:03:01Z", - "finished_at": "2026-06-13T07:03:02Z", - "id": "06048341b549f0ac", + "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:03:01Z" + "ts": "2026-06-13T07:19:42Z" } ], "mission": { "actions": [ { - "id": "a1", + "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": "Wait", + "label": "Loop", "params": { - "seconds": 1 + "count": 1, + "mode": "count" }, - "type": "wait" + "type": "loop" } ], "description": "", "group": "Missions", "id": "5ae9dbcb0722dffb", "name": "Test run", - "updated_at": "2026-06-13T04:44:03Z" + "updated_at": "2026-06-13T07:19:17.185Z" }, "mission_group": "Missions", "mission_id": "5ae9dbcb0722dffb", @@ -75,85 +141,7 @@ "priority": 0, "robot_id": "default", "source": "ui", - "started_at": "2026-06-13T07:03:01Z", - "status": "completed" - }, - { - "created_at": "2026-06-13T07:03:01Z", - "finished_at": "2026-06-13T07:03:03Z", - "id": "887245afd51df357", - "log": [ - { - "level": "info", - "message": "Wait 1000ms", - "ts": "2026-06-13T07:03:02Z" - } - ], - "mission": { - "actions": [ - { - "id": "a1", - "kind": "action", - "label": "Wait", - "params": { - "seconds": 1 - }, - "type": "wait" - } - ], - "description": "", - "group": "Missions", - "id": "5ae9dbcb0722dffb", - "name": "Test run", - "updated_at": "2026-06-13T04:44:03Z" - }, - "mission_group": "Missions", - "mission_id": "5ae9dbcb0722dffb", - "mission_name": "Test run", - "parameters": {}, - "priority": 0, - "robot_id": "default", - "source": "modbus:1005", - "started_at": "2026-06-13T07:03:02Z", - "status": "completed" - }, - { - "created_at": "2026-06-13T07:03:01Z", - "finished_at": "2026-06-13T07:03:04Z", - "id": "4365bd4d8beedfd2", - "log": [ - { - "level": "info", - "message": "Wait 1000ms", - "ts": "2026-06-13T07:03:03Z" - } - ], - "mission": { - "actions": [ - { - "id": "a1", - "kind": "action", - "label": "Wait", - "params": { - "seconds": 1 - }, - "type": "wait" - } - ], - "description": "", - "group": "Missions", - "id": "5ae9dbcb0722dffb", - "name": "Test run", - "updated_at": "2026-06-13T04:44:03Z" - }, - "mission_group": "Missions", - "mission_id": "5ae9dbcb0722dffb", - "mission_name": "Test run", - "parameters": {}, - "priority": 0, - "robot_id": "default", - "source": "fleet:pytest-schedule", - "started_at": "2026-06-13T07:03:03Z", + "started_at": "2026-06-13T07:19:41Z", "status": "completed" } ], @@ -163,6 +151,6 @@ "message": "Hoàn thành: Test run", "paused": false, "state": "idle", - "updated_at": "2026-06-13T07:03:04Z" + "updated_at": "2026-06-13T07:19:43Z" } } \ No newline at end of file diff --git a/data/missions.json b/data/missions.json index f12c04c..6e90417 100644 --- a/data/missions.json +++ b/data/missions.json @@ -14,20 +14,43 @@ { "actions": [ { - "id": "a1", + "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": "Wait", + "label": "Loop", "params": { - "seconds": 1 + "count": 1, + "mode": "count" }, - "type": "wait" + "type": "loop" } ], "description": "", "group": "Missions", "id": "5ae9dbcb0722dffb", "name": "Test run", - "updated_at": "2026-06-13T04:44:03Z" + "updated_at": "2026-06-13T07:19:17.185Z" }, { "actions": [ diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index 2418b9e..a6b7b75 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -411,7 +411,7 @@ void MissionQueue::runMissionActions(nlohmann::json& entry) } } -void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, +MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, const nlohmann::json& parameters, nlohmann::json& log, int loop_depth) @@ -426,7 +426,7 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, while (paused_ && !stop_) std::this_thread::sleep_for(std::chrono::milliseconds(100)); if (stop_) - return; + return LoopControl::None; const std::string action_id = action.value("id", ""); const std::string kind = action.value("kind", "action"); @@ -445,30 +445,58 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, { const std::string ref_id = action.value("refId", ""); nlohmann::json ref_mission = nlohmann::json::object(); - // Nested mission snapshot should be resolved by frontend before queueing. (void)ref_id; log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}}); if (action.contains("resolved_mission") && action["resolved_mission"].is_object()) { const auto& nested_actions = action["resolved_mission"]["actions"]; - executeActionsUnlocked(nested_actions, parameters, log, loop_depth); + const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth); + if (nested == LoopControl::Break) + return LoopControl::Break; + if (nested == LoopControl::Continue) + continue; } continue; } + if (type == "break") + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Break loop"}}); + return LoopControl::Break; + } + + if (type == "continue") + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Continue loop"}}); + return LoopControl::Continue; + } + if (type == "loop") { const std::string mode = params.value("mode", "count"); const int count = static_cast(paramNumber(params, "count", 1)); const auto& children = action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array(); - const int iterations = mode == "endless" ? 1 : std::max(1, count); - for (int i = 0; i < iterations; ++i) + const int iterations = mode == "endless" ? 10000 : std::max(1, count); + for (int i = 0; i < iterations && !stop_; ++i) { - log.push_back({{"ts", IdUtil::nowIso8601()}, - {"level", "info"}, - {"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}}); - executeActionsUnlocked(children, parameters, log, loop_depth + 1); + if (mode == "endless" && i == 0) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "Loop endless (simulated, max " + std::to_string(iterations) + ")"}}); + } + else if (mode != "endless") + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}}); + } + const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1); + if (ctrl == LoopControl::Break) + break; + if (ctrl == LoopControl::Continue) + continue; } continue; } @@ -520,6 +548,7 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, {{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}}); sleepMs(400); } + return LoopControl::None; } void MissionQueue::sleepMs(int ms) diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index 787983c..0450de6 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -31,6 +31,8 @@ public: bool resume(std::string& err); private: + enum class LoopControl { None, Break, Continue }; + std::filesystem::path queue_path_; mutable std::recursive_mutex mu_; nlohmann::json queue_; @@ -47,10 +49,10 @@ private: void startWorkerIfNeeded(); void workerLoop(); void runMissionActions(nlohmann::json& entry); - void executeActionsUnlocked(const nlohmann::json& actions, - const nlohmann::json& parameters, - nlohmann::json& log, - int loop_depth); + LoopControl executeActionsUnlocked(const nlohmann::json& actions, + const nlohmann::json& parameters, + nlohmann::json& log, + int loop_depth); void sleepMs(int ms); void setRunnerState(const std::string& state, const std::string& message = ""); void insertByPriorityUnlocked(nlohmann::json& entry); diff --git a/www/missions.js b/www/missions.js index 5e9a64d..3063fc4 100644 --- a/www/missions.js +++ b/www/missions.js @@ -302,11 +302,21 @@ } } + function normalizeActionTree(actions) { + if (!Array.isArray(actions)) return; + actions.forEach((action) => { + if (action.type === "loop" && !Array.isArray(action.children)) { + action.children = []; + } + if (Array.isArray(action.children)) normalizeActionTree(action.children); + }); + } + function findActionList(path) { const draft = getDraft(); if (!draft) return null; if (path === "root") return draft.actions; - const parts = path.split("."); + const parts = path.split(".").filter((p) => p !== "root"); let list = draft.actions; for (const part of parts) { const node = list.find((a) => a.id === part); @@ -321,7 +331,7 @@ for (let i = 0; i < list.length; i += 1) { const action = list[i]; if (action.id === actionId) return { action, list, index: i, path, parent }; - if (action.children?.length) { + if (Array.isArray(action.children)) { const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action); if (hit) return hit; } @@ -765,12 +775,17 @@ const item = document.createElement("button"); item.type = "button"; item.className = "missionPaletteItem"; + item.draggable = true; item.innerHTML = `${def.isLoop ? "↻" : "▶"}${escapeHtml(def.label)}`; item.addEventListener("click", () => { addActionToList(def.type, "root"); menu.hidden = true; btn.classList.remove("open"); }); + item.addEventListener("dragstart", (evt) => { + onPaletteDragStart(evt, { actionType: def.type }); + }); + item.addEventListener("dragend", onDragEnd); menu.appendChild(item); }); @@ -785,12 +800,17 @@ const item = document.createElement("button"); item.type = "button"; item.className = "missionPaletteItem missionRef"; + item.draggable = true; item.innerHTML = `${escapeHtml(m.name)}`; item.addEventListener("click", () => { addMissionRefToList(m.id, "root"); menu.hidden = true; btn.classList.remove("open"); }); + item.addEventListener("dragstart", (evt) => { + onPaletteDragStart(evt, { missionId: m.id }); + }); + item.addEventListener("dragend", onDragEnd); menu.appendChild(item); }); } @@ -891,15 +911,13 @@ renderActionPalette(); missionActionListEl.innerHTML = ""; renderActionRows(draft.actions, "root", missionActionListEl); - bindDragPaletteItems(); } - function bindDragPaletteItems() { - document.querySelectorAll(".missionLoopDrop").forEach((drop) => { - drop.addEventListener("dragover", onLoopDragOver); - drop.addEventListener("dragleave", onLoopDragLeave); - drop.addEventListener("drop", onLoopDrop); - }); + function onPaletteDragStart(evt, payload) { + store.drag = { mode: "palette", ...payload }; + evt.dataTransfer.effectAllowed = "copy"; + evt.dataTransfer.setData("text/plain", payload.actionType || payload.missionId || "palette"); + evt.currentTarget.classList.add("dragging"); } function onDragStart(evt, actionId, listPath) { @@ -913,6 +931,7 @@ document.querySelectorAll(".missionActionRow.dragging, .missionActionRow.dropBefore, .missionActionRow.dropAfter").forEach((n) => { n.classList.remove("dragging", "dropBefore", "dropAfter"); }); + document.querySelectorAll(".missionPaletteItem.dragging").forEach((n) => n.classList.remove("dragging")); document.querySelectorAll(".missionLoopDrop.dragOver").forEach((n) => n.classList.remove("dragOver")); store.drag = null; } @@ -980,6 +999,7 @@ if (!mission) return; store.editingId = missionId; store.draft = JSON.parse(JSON.stringify(mission)); + normalizeActionTree(store.draft.actions); setDirty(false); missionsListViewEl.hidden = true; missionEditorViewEl.hidden = false; diff --git a/www/style.css b/www/style.css index 1e50d8b..1740e75 100644 --- a/www/style.css +++ b/www/style.css @@ -718,6 +718,7 @@ canvas { color: var(--text); } .missionPaletteItem:hover { background: #eff6ff; } +.missionPaletteItem.dragging { opacity: 0.45; } .missionPaletteItem.missionRef .missionActionIcon { background: rgba(16, 185, 129, 0.15); color: #059669; } .missionEditorBody { padding: 16px 18px 20px; }