fix bug kéo thả của loop
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-13 14:20:23 +07:00
parent 9776e29d7d
commit 1716351016
6 changed files with 190 additions and 127 deletions

View File

@@ -1,33 +1,66 @@
{ {
"queue": [ "queue": [
{ {
"created_at": "2026-06-13T07:03:00Z", "created_at": "2026-06-13T07:19:31Z",
"finished_at": "2026-06-13T07:03:01Z", "finished_at": "2026-06-13T07:19:32Z",
"id": "c636ad0d89937cd2", "id": "3a520bd342883c75",
"log": [ "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", "level": "info",
"message": "Wait 1000ms", "message": "Wait 1000ms",
"ts": "2026-06-13T07:03:00Z" "ts": "2026-06-13T07:19:31Z"
} }
], ],
"mission": { "mission": {
"actions": [ "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", "kind": "action",
"label": "Wait", "label": "Loop",
"params": { "params": {
"seconds": 1 "count": 1,
"mode": "count"
}, },
"type": "wait" "type": "loop"
} }
], ],
"description": "", "description": "",
"group": "Missions", "group": "Missions",
"id": "5ae9dbcb0722dffb", "id": "5ae9dbcb0722dffb",
"name": "Test run", "name": "Test run",
"updated_at": "2026-06-13T04:44:03Z" "updated_at": "2026-06-13T07:19:17.185Z"
}, },
"mission_group": "Missions", "mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb", "mission_id": "5ae9dbcb0722dffb",
@@ -36,37 +69,70 @@
"priority": 0, "priority": 0,
"robot_id": "default", "robot_id": "default",
"source": "ui", "source": "ui",
"started_at": "2026-06-13T07:03:00Z", "started_at": "2026-06-13T07:19:31Z",
"status": "completed" "status": "completed"
}, },
{ {
"created_at": "2026-06-13T07:03:01Z", "created_at": "2026-06-13T07:19:41Z",
"finished_at": "2026-06-13T07:03:02Z", "finished_at": "2026-06-13T07:19:43Z",
"id": "06048341b549f0ac", "id": "51de82d74cb4b1cd",
"log": [ "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", "level": "info",
"message": "Wait 1000ms", "message": "Wait 1000ms",
"ts": "2026-06-13T07:03:01Z" "ts": "2026-06-13T07:19:42Z"
} }
], ],
"mission": { "mission": {
"actions": [ "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", "kind": "action",
"label": "Wait", "label": "Loop",
"params": { "params": {
"seconds": 1 "count": 1,
"mode": "count"
}, },
"type": "wait" "type": "loop"
} }
], ],
"description": "", "description": "",
"group": "Missions", "group": "Missions",
"id": "5ae9dbcb0722dffb", "id": "5ae9dbcb0722dffb",
"name": "Test run", "name": "Test run",
"updated_at": "2026-06-13T04:44:03Z" "updated_at": "2026-06-13T07:19:17.185Z"
}, },
"mission_group": "Missions", "mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb", "mission_id": "5ae9dbcb0722dffb",
@@ -75,85 +141,7 @@
"priority": 0, "priority": 0,
"robot_id": "default", "robot_id": "default",
"source": "ui", "source": "ui",
"started_at": "2026-06-13T07:03:01Z", "started_at": "2026-06-13T07:19:41Z",
"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",
"status": "completed" "status": "completed"
} }
], ],
@@ -163,6 +151,6 @@
"message": "Hoàn thành: Test run", "message": "Hoàn thành: Test run",
"paused": false, "paused": false,
"state": "idle", "state": "idle",
"updated_at": "2026-06-13T07:03:04Z" "updated_at": "2026-06-13T07:19:43Z"
} }
} }

View File

@@ -14,20 +14,43 @@
{ {
"actions": [ "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", "kind": "action",
"label": "Wait", "label": "Loop",
"params": { "params": {
"seconds": 1 "count": 1,
"mode": "count"
}, },
"type": "wait" "type": "loop"
} }
], ],
"description": "", "description": "",
"group": "Missions", "group": "Missions",
"id": "5ae9dbcb0722dffb", "id": "5ae9dbcb0722dffb",
"name": "Test run", "name": "Test run",
"updated_at": "2026-06-13T04:44:03Z" "updated_at": "2026-06-13T07:19:17.185Z"
}, },
{ {
"actions": [ "actions": [

View File

@@ -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, const nlohmann::json& parameters,
nlohmann::json& log, nlohmann::json& log,
int loop_depth) int loop_depth)
@@ -426,7 +426,7 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
while (paused_ && !stop_) while (paused_ && !stop_)
std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (stop_) if (stop_)
return; return LoopControl::None;
const std::string action_id = action.value("id", ""); const std::string action_id = action.value("id", "");
const std::string kind = action.value("kind", "action"); 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", ""); const std::string ref_id = action.value("refId", "");
nlohmann::json ref_mission = nlohmann::json::object(); nlohmann::json ref_mission = nlohmann::json::object();
// Nested mission snapshot should be resolved by frontend before queueing.
(void)ref_id; (void)ref_id;
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}}); log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}});
if (action.contains("resolved_mission") && action["resolved_mission"].is_object()) if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
{ {
const auto& nested_actions = action["resolved_mission"]["actions"]; 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; 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") if (type == "loop")
{ {
const std::string mode = params.value("mode", "count"); const std::string mode = params.value("mode", "count");
const int count = static_cast<int>(paramNumber(params, "count", 1)); const int count = static_cast<int>(paramNumber(params, "count", 1));
const auto& children = const auto& children =
action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array(); action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array();
const int iterations = mode == "endless" ? 1 : std::max(1, count); const int iterations = mode == "endless" ? 10000 : std::max(1, count);
for (int i = 0; i < iterations; ++i) for (int i = 0; i < iterations && !stop_; ++i)
{ {
log.push_back({{"ts", IdUtil::nowIso8601()}, if (mode == "endless" && i == 0)
{"level", "info"}, {
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}}); log.push_back({{"ts", IdUtil::nowIso8601()},
executeActionsUnlocked(children, parameters, log, loop_depth + 1); {"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; continue;
} }
@@ -520,6 +548,7 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}}); {{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
sleepMs(400); sleepMs(400);
} }
return LoopControl::None;
} }
void MissionQueue::sleepMs(int ms) void MissionQueue::sleepMs(int ms)

View File

@@ -31,6 +31,8 @@ public:
bool resume(std::string& err); bool resume(std::string& err);
private: private:
enum class LoopControl { None, Break, Continue };
std::filesystem::path queue_path_; std::filesystem::path queue_path_;
mutable std::recursive_mutex mu_; mutable std::recursive_mutex mu_;
nlohmann::json queue_; nlohmann::json queue_;
@@ -47,10 +49,10 @@ private:
void startWorkerIfNeeded(); void startWorkerIfNeeded();
void workerLoop(); void workerLoop();
void runMissionActions(nlohmann::json& entry); void runMissionActions(nlohmann::json& entry);
void executeActionsUnlocked(const nlohmann::json& actions, LoopControl executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters, const nlohmann::json& parameters,
nlohmann::json& log, nlohmann::json& log,
int loop_depth); int loop_depth);
void sleepMs(int ms); void sleepMs(int ms);
void setRunnerState(const std::string& state, const std::string& message = ""); void setRunnerState(const std::string& state, const std::string& message = "");
void insertByPriorityUnlocked(nlohmann::json& entry); void insertByPriorityUnlocked(nlohmann::json& entry);

View File

@@ -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) { function findActionList(path) {
const draft = getDraft(); const draft = getDraft();
if (!draft) return null; if (!draft) return null;
if (path === "root") return draft.actions; if (path === "root") return draft.actions;
const parts = path.split("."); const parts = path.split(".").filter((p) => p !== "root");
let list = draft.actions; let list = draft.actions;
for (const part of parts) { for (const part of parts) {
const node = list.find((a) => a.id === part); const node = list.find((a) => a.id === part);
@@ -321,7 +331,7 @@
for (let i = 0; i < list.length; i += 1) { for (let i = 0; i < list.length; i += 1) {
const action = list[i]; const action = list[i];
if (action.id === actionId) return { action, list, index: i, path, parent }; 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); const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return hit; if (hit) return hit;
} }
@@ -765,12 +775,17 @@
const item = document.createElement("button"); const item = document.createElement("button");
item.type = "button"; item.type = "button";
item.className = "missionPaletteItem"; item.className = "missionPaletteItem";
item.draggable = true;
item.innerHTML = `<span class="missionActionIcon">${def.isLoop ? "↻" : "▶"}</span><span>${escapeHtml(def.label)}</span>`; item.innerHTML = `<span class="missionActionIcon">${def.isLoop ? "↻" : "▶"}</span><span>${escapeHtml(def.label)}</span>`;
item.addEventListener("click", () => { item.addEventListener("click", () => {
addActionToList(def.type, "root"); addActionToList(def.type, "root");
menu.hidden = true; menu.hidden = true;
btn.classList.remove("open"); btn.classList.remove("open");
}); });
item.addEventListener("dragstart", (evt) => {
onPaletteDragStart(evt, { actionType: def.type });
});
item.addEventListener("dragend", onDragEnd);
menu.appendChild(item); menu.appendChild(item);
}); });
@@ -785,12 +800,17 @@
const item = document.createElement("button"); const item = document.createElement("button");
item.type = "button"; item.type = "button";
item.className = "missionPaletteItem missionRef"; item.className = "missionPaletteItem missionRef";
item.draggable = true;
item.innerHTML = `<span class="missionActionIcon kind-mission">◎</span><span>${escapeHtml(m.name)}</span>`; item.innerHTML = `<span class="missionActionIcon kind-mission">◎</span><span>${escapeHtml(m.name)}</span>`;
item.addEventListener("click", () => { item.addEventListener("click", () => {
addMissionRefToList(m.id, "root"); addMissionRefToList(m.id, "root");
menu.hidden = true; menu.hidden = true;
btn.classList.remove("open"); btn.classList.remove("open");
}); });
item.addEventListener("dragstart", (evt) => {
onPaletteDragStart(evt, { missionId: m.id });
});
item.addEventListener("dragend", onDragEnd);
menu.appendChild(item); menu.appendChild(item);
}); });
} }
@@ -891,15 +911,13 @@
renderActionPalette(); renderActionPalette();
missionActionListEl.innerHTML = ""; missionActionListEl.innerHTML = "";
renderActionRows(draft.actions, "root", missionActionListEl); renderActionRows(draft.actions, "root", missionActionListEl);
bindDragPaletteItems();
} }
function bindDragPaletteItems() { function onPaletteDragStart(evt, payload) {
document.querySelectorAll(".missionLoopDrop").forEach((drop) => { store.drag = { mode: "palette", ...payload };
drop.addEventListener("dragover", onLoopDragOver); evt.dataTransfer.effectAllowed = "copy";
drop.addEventListener("dragleave", onLoopDragLeave); evt.dataTransfer.setData("text/plain", payload.actionType || payload.missionId || "palette");
drop.addEventListener("drop", onLoopDrop); evt.currentTarget.classList.add("dragging");
});
} }
function onDragStart(evt, actionId, listPath) { function onDragStart(evt, actionId, listPath) {
@@ -913,6 +931,7 @@
document.querySelectorAll(".missionActionRow.dragging, .missionActionRow.dropBefore, .missionActionRow.dropAfter").forEach((n) => { document.querySelectorAll(".missionActionRow.dragging, .missionActionRow.dropBefore, .missionActionRow.dropAfter").forEach((n) => {
n.classList.remove("dragging", "dropBefore", "dropAfter"); 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")); document.querySelectorAll(".missionLoopDrop.dragOver").forEach((n) => n.classList.remove("dragOver"));
store.drag = null; store.drag = null;
} }
@@ -980,6 +999,7 @@
if (!mission) return; if (!mission) return;
store.editingId = missionId; store.editingId = missionId;
store.draft = JSON.parse(JSON.stringify(mission)); store.draft = JSON.parse(JSON.stringify(mission));
normalizeActionTree(store.draft.actions);
setDirty(false); setDirty(false);
missionsListViewEl.hidden = true; missionsListViewEl.hidden = true;
missionEditorViewEl.hidden = false; missionEditorViewEl.hidden = false;

View File

@@ -718,6 +718,7 @@ canvas {
color: var(--text); color: var(--text);
} }
.missionPaletteItem:hover { background: #eff6ff; } .missionPaletteItem:hover { background: #eff6ff; }
.missionPaletteItem.dragging { opacity: 0.45; }
.missionPaletteItem.missionRef .missionActionIcon { background: rgba(16, 185, 129, 0.15); color: #059669; } .missionPaletteItem.missionRef .missionActionIcon { background: rgba(16, 185, 129, 0.15); color: #059669; }
.missionEditorBody { padding: 16px 18px 20px; } .missionEditorBody { padding: 16px 18px 20px; }