Compare commits

...

2 Commits

Author SHA1 Message Date
c116b30bea excuting misstion from queue 2026-06-13 12:21:29 +07:00
7c505e919c save mission 2026-06-13 12:06:48 +07:00
12 changed files with 1142 additions and 8 deletions

View File

@@ -40,6 +40,7 @@ add_executable(lidar_manager_web
src/validation/sensor_validator.cpp
src/server/static_file_server.cpp
src/server/api_server.cpp
src/mission/mission_queue.cpp
)
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)

115
data/mission_queue.json Normal file
View File

@@ -0,0 +1,115 @@
{
"queue": [
{
"created_at": "2026-06-13T05:07:41Z",
"finished_at": "2026-06-13T05:07:43Z",
"id": "cc23d24643aba46b",
"log": [
{
"level": "info",
"message": "Go to marker → Marker 1",
"ts": "2026-06-13T05:07:41Z"
},
{
"level": "info",
"message": "Set digital output (set_digital_output) simulated",
"ts": "2026-06-13T05:07:43Z"
}
],
"mission": {
"actions": [
{
"id": "ad9e5143-5b78-42ba-a381-ec15cfd6e0ce",
"kind": "action",
"label": "Go to marker",
"params": {
"marker": "Marker 1"
},
"type": "move_to_marker"
},
{
"id": "8ec5c14a-9439-40d2-844c-5db52232f79f",
"kind": "action",
"label": "Set digital output",
"params": {
"module": "GPIO module 1",
"pin": 1,
"value": true
},
"type": "set_digital_output"
}
],
"description": "",
"group": "Move",
"id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
"name": "ABC",
"updated_at": "2026-06-13T04:48:24.794Z"
},
"mission_group": "Move",
"mission_id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
"mission_name": "ABC",
"parameters": {},
"started_at": "2026-06-13T05:07:41Z",
"status": "completed"
},
{
"created_at": "2026-06-13T05:17:38Z",
"finished_at": "2026-06-13T05:17:40Z",
"id": "708c564e8cc6bd1a",
"log": [
{
"level": "info",
"message": "Go to marker → Marker 1",
"ts": "2026-06-13T05:17:38Z"
},
{
"level": "info",
"message": "Set digital output (set_digital_output) simulated",
"ts": "2026-06-13T05:17:39Z"
}
],
"mission": {
"actions": [
{
"id": "ad9e5143-5b78-42ba-a381-ec15cfd6e0ce",
"kind": "action",
"label": "Go to marker",
"params": {
"marker": "Marker 1"
},
"type": "move_to_marker"
},
{
"id": "8ec5c14a-9439-40d2-844c-5db52232f79f",
"kind": "action",
"label": "Set digital output",
"params": {
"module": "GPIO module 1",
"pin": 1,
"value": true
},
"type": "set_digital_output"
}
],
"description": "",
"group": "Move",
"id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
"name": "ABC",
"updated_at": "2026-06-13T04:48:24.794Z"
},
"mission_group": "Move",
"mission_id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
"mission_name": "ABC",
"parameters": {},
"started_at": "2026-06-13T05:17:38Z",
"status": "completed"
}
],
"runner": {
"current_action": null,
"current_queue_id": null,
"message": "Hoàn thành: ABC",
"state": "idle",
"updated_at": "2026-06-13T05:17:40Z"
}
}

53
data/missions.json Normal file
View File

@@ -0,0 +1,53 @@
{
"dashboard": {
"widgets": []
},
"groups": [
"Missions",
"Move",
"Logic",
"I/O",
"Cart",
"Misc"
],
"missions": [
{
"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"
},
{
"actions": [
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"description": "",
"group": "Missions",
"id": "68950059fc0bd633",
"name": "Test run 3",
"updated_at": "2026-06-13T04:45:08Z"
}
],
"triggers": [],
"version": 1
}

View File

@@ -1,5 +1,6 @@
#include "app/lidar_manager_app.hpp"
#include "mission/mission_queue.hpp"
#include "server/api_server.hpp"
#include "server/static_file_server.hpp"
#include "storage/state_repository.hpp"
@@ -21,8 +22,11 @@ int LidarManagerApp::run()
StateRepository repo(data_path_);
repo.load();
const std::filesystem::path mission_queue_path = data_path_.parent_path() / "mission_queue.json";
MissionQueue mission_queue(mission_queue_path);
httplib::Server svr;
ApiServer api(repo);
ApiServer api(repo, mission_queue);
api.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);

View File

@@ -0,0 +1,446 @@
#include "mission/mission_queue.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include <algorithm>
#include <chrono>
#include <stdexcept>
#include <thread>
#include <unordered_set>
namespace lm {
namespace {
std::string paramValue(const std::string& action_id,
const nlohmann::json& params,
const std::string& key,
const nlohmann::json& parameters)
{
const std::string lookup = action_id + ":" + key;
if (parameters.is_object() && parameters.contains(lookup))
return parameters[lookup].get<std::string>();
if (params.contains(key) && params[key].is_string())
return params[key].get<std::string>();
return "";
}
double paramNumber(const nlohmann::json& params, const std::string& key, double fallback)
{
if (params.contains(key) && params[key].is_number())
return params[key].get<double>();
return fallback;
}
} // namespace
MissionQueue::MissionQueue(std::filesystem::path queue_path) : queue_path_(std::move(queue_path))
{
load();
ensureRunnerDefaults();
startWorkerIfNeeded();
}
MissionQueue::~MissionQueue()
{
stop_ = true;
wake_ = true;
if (worker_.joinable())
worker_.join();
}
void MissionQueue::load()
{
std::lock_guard<std::mutex> lock(mu_);
queue_ = nlohmann::json::array();
runner_ = nlohmann::json::object();
if (!std::filesystem::exists(queue_path_))
return;
try
{
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(queue_path_));
if (parsed.is_object())
{
if (parsed.contains("queue") && parsed["queue"].is_array())
queue_ = parsed["queue"];
if (parsed.contains("runner") && parsed["runner"].is_object())
runner_ = parsed["runner"];
}
}
catch (...)
{
queue_ = nlohmann::json::array();
}
ensureRunnerDefaults();
}
void MissionQueue::saveUnlocked() const
{
const nlohmann::json payload = {{"queue", queue_}, {"runner", runner_}};
FileUtil::writeBinaryAtomic(queue_path_, payload.dump(2));
}
void MissionQueue::ensureRunnerDefaults()
{
if (!runner_.is_object())
runner_ = nlohmann::json::object();
if (!runner_.contains("state"))
runner_["state"] = "idle";
if (!runner_.contains("message"))
runner_["message"] = "";
if (!runner_.contains("current_queue_id"))
runner_["current_queue_id"] = nullptr;
if (!runner_.contains("current_action"))
runner_["current_action"] = nullptr;
}
void MissionQueue::startWorkerIfNeeded()
{
if (worker_.joinable())
return;
worker_ = std::thread([this] { workerLoop(); });
}
nlohmann::json MissionQueue::list() const
{
std::lock_guard<std::mutex> lock(mu_);
return queue_;
}
nlohmann::json MissionQueue::runnerStatus() const
{
std::lock_guard<std::mutex> lock(mu_);
return runner_;
}
std::optional<nlohmann::json> MissionQueue::enqueue(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
if (!payload.contains("mission") || !payload["mission"].is_object())
{
err = "mission is required";
return std::nullopt;
}
nlohmann::json entry = nlohmann::json::object();
entry["id"] = IdUtil::newId();
entry["mission_id"] = payload["mission"].value("id", "");
entry["mission_name"] = payload["mission"].value("name", "Mission");
entry["mission_group"] = payload["mission"].value("group", "Missions");
entry["mission"] = payload["mission"];
entry["parameters"] = payload.contains("parameters") && payload["parameters"].is_object() ? payload["parameters"]
: nlohmann::json::object();
entry["status"] = "pending";
entry["created_at"] = IdUtil::nowIso8601();
entry["started_at"] = nullptr;
entry["finished_at"] = nullptr;
entry["log"] = nlohmann::json::array();
{
std::lock_guard<std::mutex> lock(mu_);
queue_.push_back(entry);
saveUnlocked();
}
wake_ = true;
return entry;
}
bool MissionQueue::removeById(const std::string& id, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (!queue_.is_array())
{
err = "queue unavailable";
return false;
}
const auto before = queue_.size();
nlohmann::json next = nlohmann::json::array();
for (const auto& item : queue_)
{
if (!item.is_object())
continue;
if (item.value("id", "") == id)
{
const std::string status = item.value("status", "");
if (status == "executing")
{
err = "cannot remove executing mission";
return false;
}
continue;
}
next.push_back(item);
}
if (next.size() == before)
{
err = "queue item not found";
return false;
}
queue_ = std::move(next);
saveUnlocked();
return true;
}
bool MissionQueue::clearAll(std::string& err)
{
(void)err;
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json next = nlohmann::json::array();
for (const auto& item : queue_)
{
if (!item.is_object())
continue;
if (item.value("status", "") == "executing")
next.push_back(item);
}
queue_ = std::move(next);
setRunnerState(next.empty() ? "idle" : "running", next.empty() ? "" : "Đang thực thi mission");
saveUnlocked();
return true;
}
bool MissionQueue::reorder(const nlohmann::json& ordered_ids, std::string& err)
{
if (!ordered_ids.is_array())
{
err = "ordered_ids must be an array";
return false;
}
std::lock_guard<std::mutex> lock(mu_);
if (!queue_.is_array())
{
err = "queue unavailable";
return false;
}
nlohmann::json by_id = nlohmann::json::object();
for (auto& item : queue_)
{
if (item.is_object())
by_id[item.value("id", "")] = item;
}
nlohmann::json next = nlohmann::json::array();
std::unordered_set<std::string> seen;
for (const auto& id_json : ordered_ids)
{
if (!id_json.is_string())
continue;
const std::string id = id_json.get<std::string>();
if (!by_id.contains(id))
continue;
next.push_back(by_id[id]);
seen.insert(id);
}
for (const auto& item : queue_)
{
if (!item.is_object())
continue;
const std::string id = item.value("id", "");
if (seen.count(id))
continue;
next.push_back(item);
}
queue_ = std::move(next);
saveUnlocked();
return true;
}
void MissionQueue::workerLoop()
{
while (!stop_)
{
{
std::lock_guard<std::mutex> lock(mu_);
processQueueUnlocked();
}
for (int i = 0; i < 20 && !wake_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
wake_ = false;
}
}
void MissionQueue::processQueueUnlocked()
{
if (!queue_.is_array())
return;
for (auto& item : queue_)
{
if (!item.is_object())
continue;
if (item.value("status", "") != "pending")
continue;
executeMissionUnlocked(item);
return;
}
if (runner_.value("state", "") == "running")
setRunnerState("idle", "Queue trống");
}
void MissionQueue::executeMissionUnlocked(nlohmann::json& entry)
{
entry["status"] = "executing";
entry["started_at"] = IdUtil::nowIso8601();
runner_["current_queue_id"] = entry.value("id", "");
runner_["current_action"] = nullptr;
setRunnerState("running", "Đang chạy: " + entry.value("mission_name", "Mission"));
saveUnlocked();
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);
entry["log"] = log;
entry["status"] = "completed";
entry["finished_at"] = IdUtil::nowIso8601();
setRunnerState("idle", "Hoàn thành: " + entry.value("mission_name", "Mission"));
}
catch (...)
{
entry["status"] = "failed";
entry["finished_at"] = IdUtil::nowIso8601();
setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission"));
}
runner_["current_queue_id"] = nullptr;
runner_["current_action"] = nullptr;
saveUnlocked();
wake_ = true;
}
void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters,
nlohmann::json& log,
int loop_depth)
{
if (loop_depth > 8)
throw std::runtime_error("loop depth exceeded");
for (const auto& action : actions)
{
if (!action.is_object())
continue;
const std::string action_id = action.value("id", "");
const std::string kind = action.value("kind", "action");
const std::string type = action.value("type", "");
const std::string label = action.value("label", type);
const auto& params = action.contains("params") && action["params"].is_object() ? action["params"]
: nlohmann::json::object();
runner_["current_action"] = label;
saveUnlocked();
if (kind == "mission")
{
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);
}
continue;
}
if (type == "loop")
{
const std::string mode = params.value("mode", "count");
const int count = static_cast<int>(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)
{
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);
}
continue;
}
if (type == "wait")
{
const int ms = static_cast<int>(paramNumber(params, "seconds", 1) * 1000);
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Wait " + std::to_string(ms) + "ms"}});
sleepMs(ms);
continue;
}
if (type == "move_to_position" || type == "adjust_localization" || type == "pick_cart" || type == "drop_cart")
{
const std::string pos = paramValue(action_id, params, "position", parameters);
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", label + "" + (pos.empty() ? "?" : pos)}});
sleepMs(1200);
continue;
}
if (type == "move_to_marker")
{
const std::string marker = paramValue(action_id, params, "marker", parameters);
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", label + "" + (marker.empty() ? "?" : marker)}});
sleepMs(1200);
continue;
}
if (type == "user_log")
{
const std::string message = params.value("message", "Mission step");
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "user"}, {"message", message}});
sleepMs(200);
continue;
}
if (type == "pause")
{
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Pause (simulated)"}});
sleepMs(500);
continue;
}
log.push_back(
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
sleepMs(400);
}
}
void MissionQueue::sleepMs(int ms)
{
if (ms <= 0)
return;
const int step = 100;
for (int elapsed = 0; elapsed < ms && !stop_; elapsed += step)
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed)));
}
void MissionQueue::setRunnerState(const std::string& state, const std::string& message)
{
runner_["state"] = state;
runner_["message"] = message;
runner_["updated_at"] = IdUtil::nowIso8601();
}
} // namespace lm

View File

@@ -0,0 +1,56 @@
#pragma once
#include <nlohmann/json.hpp>
#include <atomic>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
namespace lm {
class MissionQueue
{
public:
explicit MissionQueue(std::filesystem::path queue_path);
~MissionQueue();
MissionQueue(const MissionQueue&) = delete;
MissionQueue& operator=(const MissionQueue&) = delete;
nlohmann::json list() const;
nlohmann::json runnerStatus() const;
std::optional<nlohmann::json> enqueue(const nlohmann::json& payload, std::string& err);
bool removeById(const std::string& id, std::string& err);
bool clearAll(std::string& err);
bool reorder(const nlohmann::json& ordered_ids, std::string& err);
private:
std::filesystem::path queue_path_;
mutable std::mutex mu_;
nlohmann::json queue_;
nlohmann::json runner_;
std::thread worker_;
std::atomic<bool> stop_{false};
std::atomic<bool> wake_{false};
void load();
void saveUnlocked() const;
void ensureRunnerDefaults();
void startWorkerIfNeeded();
void workerLoop();
void processQueueUnlocked();
void executeMissionUnlocked(nlohmann::json& entry);
void 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 = "");
};
} // namespace lm

View File

@@ -9,7 +9,10 @@
namespace lm {
ApiServer::ApiServer(StateRepository& repo) : repo_(repo) {}
ApiServer::ApiServer(StateRepository& repo, MissionQueue& mission_queue)
: repo_(repo), mission_queue_(mission_queue)
{
}
void ApiServer::registerRoutes(httplib::Server& svr)
{
@@ -436,6 +439,69 @@ void ApiServer::registerRoutes(httplib::Server& svr)
return HttpUtil::jsonError(res, 500, "failed to save layout");
res.status = 204;
});
svr.Get("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"queue", mission_queue_.list()}, {"runner", mission_queue_.runnerStatus()}}).dump();
});
svr.Post("/api/mission_queue", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
std::string err;
const auto entry = mission_queue_.enqueue(payload, err);
if (!entry)
return HttpUtil::jsonError(res, 400, err);
res.status = 201;
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = entry->dump();
});
svr.Delete("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!mission_queue_.clearAll(err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
svr.Put("/api/mission_queue/reorder", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json payload;
try
{
payload = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!payload.contains("ordered_ids") || !payload["ordered_ids"].is_array())
return HttpUtil::jsonError(res, 400, "ordered_ids is required");
std::string err;
if (!mission_queue_.reorder(payload["ordered_ids"], err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"ok", true}}).dump();
});
svr.Delete(R"(/api/mission_queue/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1].str();
std::string err;
if (!mission_queue_.removeById(id, err))
return HttpUtil::jsonError(res, 400, err);
res.status = 204;
});
}
} // namespace lm

View File

@@ -2,6 +2,7 @@
#include <httplib.h>
#include "mission/mission_queue.hpp"
#include "storage/state_repository.hpp"
namespace lm {
@@ -9,12 +10,13 @@ namespace lm {
class ApiServer
{
public:
explicit ApiServer(StateRepository& repo);
ApiServer(StateRepository& repo, MissionQueue& mission_queue);
void registerRoutes(httplib::Server& svr);
private:
StateRepository& repo_;
MissionQueue& mission_queue_;
};
} // namespace lm

View File

@@ -142,6 +142,7 @@ function setActivePage(page) {
}
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
try {
localStorage.setItem("activePage", p);
} catch {

View File

@@ -532,6 +532,21 @@
<div id="missionList" class="missionList"></div>
</div>
</section>
<section class="card" id="missionQueueCard">
<div class="cardHeader">
<div>
<div class="cardTitle">Mission queue</div>
<div class="cardSub">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
</div>
<button id="missionQueueClearBtn" type="button" class="btn subtle danger">Xóa queue</button>
</div>
<div class="cardBody">
<div id="missionQueueRunner" class="missionQueueRunner mutedNote"></div>
<div id="missionQueueEmpty" class="mutedNote">Queue trống. Bấm <span class="mono"></span> trên mission để thêm.</div>
<div id="missionQueueList" class="missionQueueList"></div>
</div>
</section>
</div>
<div id="missionEditorView" class="missionsPage" hidden>
@@ -687,6 +702,24 @@
</form>
</dialog>
<dialog id="missionQueueDialog" class="missionDialog">
<form id="missionQueueForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Thêm vào mission queue</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody">
<p id="missionQueueDialogMission" class="mutedNote"></p>
<div id="missionQueueVarFields" class="missionConfigGrid"></div>
<p id="missionQueueVarHint" class="mutedNote" hidden>Tham số đã chọn sẽ hiển thị màu xanh trong queue.</p>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionQueueDialog">Hủy</button>
<button type="submit" class="btn primary">Thêm vào queue</button>
</div>
</form>
</dialog>
<script src="/missions.js"></script>
<script src="/app.js"></script>
</body>

View File

@@ -56,6 +56,25 @@
const missionActionConfigDialogEl = el("missionActionConfigDialog");
const missionActionConfigBodyEl = el("missionActionConfigBody");
const missionActionConfigTitleEl = el("missionActionConfigTitle");
const missionQueueListEl = el("missionQueueList");
const missionQueueEmptyEl = el("missionQueueEmpty");
const missionQueueRunnerEl = el("missionQueueRunner");
const missionQueueDialogEl = el("missionQueueDialog");
const missionQueueVarFieldsEl = el("missionQueueVarFields");
const missionQueueDialogMissionEl = el("missionQueueDialogMission");
const missionQueueVarHintEl = el("missionQueueVarHint");
const VARIABLE_FIELD_DEFS = {
move_to_position: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
adjust_localization: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
move_to_marker: [{ key: "marker", label: "Marker", options: SAMPLE_MARKERS }],
if: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
pick_cart: [
{ key: "position", label: "Position", options: SAMPLE_POSITIONS },
{ key: "cart", label: "Cart", options: SAMPLE_CARTS },
],
drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
};
const store = {
missions: [],
@@ -66,6 +85,10 @@
drag: null,
configActionId: null,
configListPath: "root",
queue: [],
runner: { state: "idle", message: "" },
queuePollTimer: null,
pendingQueueMissionId: null,
};
function newId() {
@@ -209,11 +232,12 @@
function actionSummary(action) {
if (action.kind === "mission") return `Mission con: ${action.label}`;
const p = action.params || {};
const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val);
switch (action.type) {
case "move_to_position":
return `Position: ${p.position}${p.check_free ? " • kiểm tra trống" : ""}`;
return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`;
case "move_to_marker":
return `Marker: ${p.marker}`;
return `Marker: ${fmtVar("marker", p.marker)}`;
case "wait":
return `${p.seconds}s`;
case "set_speed":
@@ -230,7 +254,7 @@
return `Reg ${p.register}: ${p.action} ${p.value}`;
case "pick_cart":
case "drop_cart":
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${p.position}`;
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", p.position)}${action.type === "pick_cart" ? `${fmtVar("cart", p.cart)}` : ""}`;
case "user_log":
return p.message || "—";
case "play_sound":
@@ -323,6 +347,253 @@
renderMissionEditor();
}
async function missionApi(path, opts = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function walkActions(actions, visit, depth = 0) {
if (!Array.isArray(actions) || depth > 12) return;
actions.forEach((action) => {
visit(action, depth);
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
});
}
function resolveActionSnapshot(action, depth = 0) {
const copy = JSON.parse(JSON.stringify(action));
if (copy.kind === "mission") {
const ref = findMission(copy.refId);
if (ref && depth < 8) copy.resolved_mission = resolveMissionSnapshot(ref, depth + 1);
}
if (Array.isArray(copy.children)) {
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
}
return copy;
}
function resolveMissionSnapshot(mission, depth = 0) {
const copy = JSON.parse(JSON.stringify(mission));
copy.actions = (copy.actions || []).map((a) => resolveActionSnapshot(a, depth));
return copy;
}
function collectMissionVariables(mission) {
const vars = [];
walkActions(mission.actions, (action) => {
if (action.kind === "mission" || !action.params) return;
const defs = VARIABLE_FIELD_DEFS[action.type] || [];
defs.forEach((def) => {
if (!action.params[`${def.key}_var`]) return;
vars.push({
key: `${action.id}:${def.key}`,
label: `${action.label}${def.label}`,
options: def.options,
default: action.params[def.key] || def.options[0] || "",
});
});
});
return vars;
}
function formatQueueParameters(entry) {
const params = entry.parameters || {};
const keys = Object.keys(params);
if (!keys.length) return "";
return keys
.map((key) => `<span class="missionQueueParamVar">${escapeHtml(params[key])}</span>`)
.join(" • ");
}
function queueStatusLabel(status) {
const map = {
pending: "Chờ",
executing: "Đang chạy",
completed: "Xong",
failed: "Lỗi",
};
return map[status] || status;
}
async function refreshQueue() {
try {
const data = await missionApi("/api/mission_queue");
store.queue = Array.isArray(data.queue) ? data.queue : [];
store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" };
renderQueuePanel();
} catch (e) {
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
}
}
function renderQueuePanel() {
if (!missionQueueListEl) return;
missionQueueListEl.innerHTML = "";
if (missionQueueEmptyEl) missionQueueEmptyEl.hidden = store.queue.length > 0;
if (missionQueueRunnerEl) {
const st = store.runner.state || "idle";
missionQueueRunnerEl.classList.toggle("running", st === "running");
const action = store.runner.current_action ? `${store.runner.current_action}` : "";
missionQueueRunnerEl.textContent = store.runner.message
? `${store.runner.message}${action}`
: st === "idle"
? "Robot sẵn sàng — queue trống hoặc chờ mission mới."
: "—";
}
store.queue.forEach((entry, index) => {
const row = document.createElement("div");
row.className = `missionQueueItem status-${entry.status || "pending"}`;
const paramHtml = formatQueueParameters(entry);
const canReorder = entry.status === "pending";
row.innerHTML = `
<div class="missionQueueOrder">
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="Lên" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="Xuống" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
</div>
<div>
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
<div class="missionQueueItemMeta">${escapeHtml(entry.mission_group || "")} • #${index + 1}</div>
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">Xóa</button>` : ""}
</div>`;
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
row.querySelector("[data-queue-down]")?.addEventListener("click", () => moveQueueItem(entry.id, 1));
row.querySelector("[data-queue-remove]")?.addEventListener("click", () => removeQueueItem(entry.id));
missionQueueListEl.appendChild(row);
});
}
async function moveQueueItem(id, delta) {
const ids = store.queue.map((q) => q.id);
const idx = ids.indexOf(id);
if (idx < 0) return;
if (store.queue[idx].status !== "pending") return;
const next = idx + delta;
if (next < 0 || next >= store.queue.length) return;
if (store.queue[next].status !== "pending") return;
[ids[idx], ids[next]] = [ids[next], ids[idx]];
try {
await missionApi("/api/mission_queue/reorder", {
method: "PUT",
body: JSON.stringify({ ordered_ids: ids }),
});
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
async function removeQueueItem(id) {
try {
await missionApi(`/api/mission_queue/${id}`, { method: "DELETE" });
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
async function clearQueue() {
if (!confirm("Xóa các mission đang chờ trong queue?")) return;
try {
await missionApi("/api/mission_queue", { method: "DELETE" });
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
function openQueueDialog(missionId) {
const mission = findMission(missionId);
if (!mission) return;
store.pendingQueueMissionId = missionId;
const vars = collectMissionVariables(mission);
if (missionQueueDialogMissionEl) {
missionQueueDialogMissionEl.textContent = `Mission: ${mission.name}`;
}
if (missionQueueVarFieldsEl) {
missionQueueVarFieldsEl.innerHTML = "";
if (!vars.length) {
missionQueueVarFieldsEl.innerHTML = `<p class="mutedNote">Mission không có tham số biến. Bấm «Thêm vào queue» để chạy.</p>`;
} else {
vars.forEach((v) => {
const row = document.createElement("div");
row.className = "row rowWide";
const lab = document.createElement("label");
lab.textContent = v.label;
const sel = document.createElement("select");
sel.dataset.varKey = v.key;
v.options.forEach((opt) => {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (opt === v.default) o.selected = true;
sel.appendChild(o);
});
row.appendChild(lab);
row.appendChild(sel);
missionQueueVarFieldsEl.appendChild(row);
});
}
}
if (missionQueueVarHintEl) missionQueueVarHintEl.hidden = vars.length === 0;
missionQueueDialogEl.showModal();
}
async function submitQueueDialog(evt) {
evt.preventDefault();
const mission = findMission(store.pendingQueueMissionId);
if (!mission) return;
const parameters = {};
missionQueueVarFieldsEl?.querySelectorAll("[data-var-key]").forEach((sel) => {
parameters[sel.dataset.varKey] = sel.value;
});
const payload = {
mission: resolveMissionSnapshot(mission),
parameters,
};
try {
await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) });
missionQueueDialogEl.close();
store.pendingQueueMissionId = null;
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
function startQueuePoll() {
stopQueuePoll();
refreshQueue();
store.queuePollTimer = setInterval(refreshQueue, 1500);
}
function stopQueuePoll() {
if (store.queuePollTimer) {
clearInterval(store.queuePollTimer);
store.queuePollTimer = null;
}
}
function renderMissionList() {
if (!missionListEl) return;
missionListEl.innerHTML = "";
@@ -338,6 +609,7 @@
<div class="missionListItemMeta">${escapeHtml(mission.group)}${mission.actions.length} action(s)${mission.description ? `${escapeHtml(mission.description)}` : ""}</div>
</div>
<div class="missionListItemActions">
<button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
<button type="button" class="btn subtle" data-edit="${mission.id}">Sửa</button>
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
</div>`;
@@ -345,6 +617,10 @@
if (evt.target.closest("button")) return;
openEditor(mission.id);
});
row.querySelector("[data-queue]").addEventListener("click", (evt) => {
evt.stopPropagation();
openQueueDialog(mission.id);
});
row.querySelector("[data-edit]").addEventListener("click", (evt) => {
evt.stopPropagation();
openEditor(mission.id);
@@ -722,10 +998,17 @@
return select;
};
const addVariableToggle = (paramKey, fieldLabel) => {
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="${paramKey}_var" ${p[`${paramKey}_var`] ? "checked" : ""} /> Biến — hỏi khi thêm vào queue`;
addField(`${fieldLabel} (biến)`, chk);
};
switch (action.type) {
case "move_to_position":
case "adjust_localization":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
if (action.type === "move_to_position") {
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="check_free" ${p.check_free ? "checked" : ""} /> Kiểm tra vị trí trống`;
@@ -734,6 +1017,7 @@
break;
case "move_to_marker":
addField("Marker", selectInput("marker", p.marker, SAMPLE_MARKERS));
addVariableToggle("marker", "Marker");
break;
case "wait":
addField("Giây", textInput("seconds", p.seconds, "number"));
@@ -748,6 +1032,7 @@
case "if":
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
break;
case "set_digital_output":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
@@ -775,10 +1060,13 @@
break;
case "pick_cart":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
addVariableToggle("cart", "Cart");
break;
case "drop_cart":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="collision_check" ${p.collision_check ? "checked" : ""} /> Kiểm tra va chạm`;
@@ -891,6 +1179,9 @@
document.addEventListener("click", (evt) => {
if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus();
});
el("missionQueueClearBtn")?.addEventListener("click", clearQueue);
el("missionQueueForm")?.addEventListener("submit", submitQueueDialog);
}
function init() {
@@ -903,7 +1194,13 @@
init,
onPageShow() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else renderMissionList();
else {
renderMissionList();
startQueuePoll();
}
},
onPageHide() {
stopQueuePoll();
},
};

View File

@@ -569,8 +569,68 @@ canvas {
}
.missionListItemTitle { font-weight: 700; font-size: 14px; }
.missionListItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
.missionListItemActions { display: flex; gap: 8px; }
.missionListItemActions { display: flex; gap: 8px; align-items: center; }
.missionListItemActions .btn { padding: 6px 10px; font-size: 12px; }
.missionQueueBtn {
font-size: 15px;
font-weight: 700;
color: var(--accent);
}
.missionQueueBtn:hover {
border-color: rgba(37, 99, 235, 0.35);
background: #eff6ff;
}
.missionQueueList { display: grid; gap: 8px; margin-top: 12px; }
.missionQueueItem {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: start;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #fff;
}
.missionQueueItem.status-executing {
border-color: rgba(37, 99, 235, 0.45);
background: #eff6ff;
}
.missionQueueItem.status-completed { opacity: 0.72; }
.missionQueueItem.status-failed {
border-color: rgba(239, 68, 68, 0.35);
background: #fef2f2;
}
.missionQueueOrder { display: flex; flex-direction: column; gap: 4px; }
.missionQueueOrder .iconBtn { width: 28px; height: 28px; font-size: 12px; }
.missionQueueItemTitle { font-weight: 700; font-size: 13px; }
.missionQueueItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
.missionQueueItemParams { font-size: 12px; margin-top: 6px; }
.missionQueueParamVar { color: var(--accent); font-weight: 600; }
.missionQueueStatus {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 4px 8px;
border-radius: 999px;
background: #f1f5f9;
color: var(--muted);
white-space: nowrap;
}
.missionQueueStatus.executing { background: #dbeafe; color: #1d4ed8; }
.missionQueueStatus.completed { background: #d1fae5; color: #047857; }
.missionQueueStatus.failed { background: #fee2e2; color: #b91c1c; }
.missionQueueRunner {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel2);
font-size: 12px;
margin-bottom: 10px;
}
.missionQueueRunner.running { border-color: rgba(37, 99, 235, 0.35); background: #eff6ff; color: #1e3a8a; }
.missionVarHint { font-size: 11px; color: var(--muted); margin-top: 4px; }
.missionEditorCard { overflow: hidden; }
.missionEditorTop {