diff --git a/data/mission_queue.json b/data/mission_queue.json index 7602060..0283222 100644 --- a/data/mission_queue.json +++ b/data/mission_queue.json @@ -1,115 +1,11 @@ { - "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" - } - ], + "queue": [], "runner": { "current_action": null, "current_queue_id": null, - "message": "Hoàn thành: ABC", + "message": "", + "paused": false, "state": "idle", - "updated_at": "2026-06-13T05:17:40Z" + "updated_at": "2026-06-13T05:43:11Z" } } \ No newline at end of file diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index 0bc2231..cd82fb1 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -93,6 +93,8 @@ void MissionQueue::ensureRunnerDefaults() runner_["current_queue_id"] = nullptr; if (!runner_.contains("current_action")) runner_["current_action"] = nullptr; + if (!runner_.contains("paused")) + runner_["paused"] = false; } void MissionQueue::startWorkerIfNeeded() @@ -255,6 +257,43 @@ bool MissionQueue::reorder(const nlohmann::json& ordered_ids, std::string& err) return true; } +bool MissionQueue::pause(std::string& err) +{ + std::lock_guard lock(mu_); + const std::string state = runner_.value("state", "idle"); + if (state != "running") + { + err = "no mission is running"; + return false; + } + paused_ = true; + runner_["paused"] = true; + runner_["state"] = "paused"; + runner_["message"] = "Mission tạm dừng"; + runner_["updated_at"] = IdUtil::nowIso8601(); + saveUnlocked(); + return true; +} + +bool MissionQueue::resume(std::string& err) +{ + (void)err; + paused_ = false; + { + std::lock_guard lock(mu_); + runner_["paused"] = false; + if (runner_.value("state", "") == "paused") + { + runner_["state"] = "running"; + runner_["message"] = "Tiếp tục mission"; + runner_["updated_at"] = IdUtil::nowIso8601(); + } + saveUnlocked(); + } + wake_ = true; + return true; +} + void MissionQueue::workerLoop() { while (!stop_) @@ -273,6 +312,8 @@ void MissionQueue::processQueueUnlocked() { if (!queue_.is_array()) return; + if (paused_) + return; for (auto& item : queue_) { @@ -335,6 +376,10 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, { if (!action.is_object()) continue; + while (paused_ && !stop_) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (stop_) + return; const std::string action_id = action.value("id", ""); const std::string kind = action.value("kind", "action"); @@ -433,7 +478,11 @@ void MissionQueue::sleepMs(int ms) return; const int step = 100; for (int elapsed = 0; elapsed < ms && !stop_; elapsed += step) + { + while (paused_ && !stop_) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed))); + } } void MissionQueue::setRunnerState(const std::string& state, const std::string& message) diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index 3d2906c..763923d 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -27,6 +27,8 @@ public: bool removeById(const std::string& id, std::string& err); bool clearAll(std::string& err); bool reorder(const nlohmann::json& ordered_ids, std::string& err); + bool pause(std::string& err); + bool resume(std::string& err); private: std::filesystem::path queue_path_; @@ -37,6 +39,7 @@ private: std::thread worker_; std::atomic stop_{false}; std::atomic wake_{false}; + std::atomic paused_{false}; void load(); void saveUnlocked() const; diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 44d2cc8..6bec843 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -502,6 +502,24 @@ void ApiServer::registerRoutes(httplib::Server& svr) return HttpUtil::jsonError(res, 400, err); res.status = 204; }); + + svr.Post("/api/mission_queue/pause", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!mission_queue_.pause(err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_queue_.runnerStatus().dump(); + }); + + svr.Post("/api/mission_queue/continue", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!mission_queue_.resume(err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_queue_.runnerStatus().dump(); + }); } } // namespace lm diff --git a/www/app.js b/www/app.js index 1c540a0..c1cc8f0 100644 --- a/www/app.js +++ b/www/app.js @@ -120,29 +120,32 @@ const state = { }; function setActivePage(page) { - const valid = ["overview", "config", "missions"]; - const p = valid.includes(page) ? page : "config"; + const valid = ["dashboard", "config", "missions"]; + let p = valid.includes(page) ? page : "config"; + if (page === "overview") p = "dashboard"; navItemEls.forEach((a) => { const on = (a.dataset.page || "") === p; a.classList.toggle("active", on); if (on) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); }); - const titles = { overview: "Tổng quan", config: "Cấu Hình", missions: "Missions" }; + const titles = { dashboard: "Dashboard", config: "Cấu Hình", missions: "Missions" }; if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình"; - if (pageOverviewEl) pageOverviewEl.hidden = p !== "overview"; + if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard"; if (pageConfigEl) pageConfigEl.hidden = p !== "config"; if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; if (configSplitterEl) configSplitterEl.hidden = p !== "config"; if (contentRightEl) contentRightEl.hidden = p !== "config"; if (contentEl) { - contentEl.classList.toggle("content--overview", p === "overview"); + contentEl.classList.toggle("content--dashboard", p === "dashboard"); contentEl.classList.toggle("content--config", p === "config"); contentEl.classList.toggle("content--missions", p === "missions"); } if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config"; if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow(); else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide(); + if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow(); + else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide(); try { localStorage.setItem("activePage", p); } catch { @@ -161,7 +164,9 @@ function initNavigation() { let initial = "config"; try { const saved = localStorage.getItem("activePage"); - if (saved === "overview" || saved === "config" || saved === "missions") initial = saved; + if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions") { + initial = saved === "overview" ? "dashboard" : saved; + } } catch { /* ignore */ } diff --git a/www/dashboard.js b/www/dashboard.js new file mode 100644 index 0000000..07dad0a --- /dev/null +++ b/www/dashboard.js @@ -0,0 +1,378 @@ +(() => { + const STORAGE_KEY = "phenikaax_dashboard_v1"; + + const WIDGET_LABELS = { + mission_button: "Mission button", + mission_group: "Mission group", + mission_queue: "Mission queue", + pause_continue: "Pause / Continue", + }; + + const el = (id) => document.getElementById(id); + const gridEl = el("dashboardGrid"); + const emptyEl = el("dashboardEmpty"); + const addDialogEl = el("dashboardAddWidgetDialog"); + const editDialogEl = el("dashboardEditWidgetDialog"); + const addTypeEl = el("dashboardWidgetType"); + const addFieldsEl = el("dashboardAddWidgetFields"); + const editFieldsEl = el("dashboardEditWidgetFields"); + const editWidgetIdEl = el("dashboardEditWidgetId"); + const editWidgetTypeEl = el("dashboardEditWidgetType"); + + const store = { + widgets: [], + editMode: false, + pollTimer: null, + queueUnsub: null, + }; + + function newId() { + if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); + return `w_${Date.now().toString(36)}`; + } + + function missions() { + return window.MissionsApp || null; + } + + function loadStore() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + bootstrapDefaults(); + return; + } + const data = JSON.parse(raw); + store.widgets = Array.isArray(data.widgets) ? data.widgets : []; + if (!store.widgets.length) bootstrapDefaults(); + } catch { + bootstrapDefaults(); + } + } + + function persistStore() { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ widgets: store.widgets })); + } + + function bootstrapDefaults() { + const m = missions()?.getMissions?.() || []; + const firstId = m[0]?.id || ""; + store.widgets = [ + { id: newId(), type: "mission_button", mission_id: firstId, title: "" }, + { id: newId(), type: "mission_group", group: "Missions", title: "" }, + { id: newId(), type: "mission_queue", title: "Mission queue" }, + { id: newId(), type: "pause_continue", title: "" }, + ]; + persistStore(); + } + + function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function widgetTitle(widget) { + if (widget.title) return widget.title; + return WIDGET_LABELS[widget.type] || widget.type; + } + + function missionOptions(selected) { + const list = missions()?.getMissions?.() || []; + return list + .map( + (m) => + `` + ) + .join(""); + } + + function groupOptions(selected) { + const groups = missions()?.getGroups?.() || ["Missions"]; + return groups + .map((g) => ``) + .join(""); + } + + function fillTypeFields(container, type, widget = {}) { + if (!container) return; + container.innerHTML = ""; + if (type === "mission_button") { + container.innerHTML = ` +
+ + +
+
+ + +
`; + } else if (type === "mission_group") { + container.innerHTML = ` +
+ + +
+
+ + +
`; + } else if (type === "mission_queue") { + container.innerHTML = ` +
+ + +
`; + } else if (type === "pause_continue") { + container.innerHTML = ` +
+ + +
+

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

`; + } + } + + function readFields(container) { + const out = {}; + container?.querySelectorAll("[data-field]").forEach((node) => { + out[node.dataset.field] = node.value; + }); + return out; + } + + function renderMissionButtonWidget(widget, bodyEl) { + const m = missions()?.getMissionById?.(widget.mission_id); + const label = m?.name || "Chọn mission…"; + bodyEl.innerHTML = ` + + ${!m ? `

Cấu hình widget và chọn mission.

` : ""}`; + bodyEl.querySelector("[data-run-mission]")?.addEventListener("click", () => { + if (!widget.mission_id) return; + missions()?.queueMission?.(widget.mission_id); + }); + } + + function renderMissionGroupWidget(widget, bodyEl) { + const group = widget.group || "Missions"; + const list = (missions()?.getMissions?.() || []).filter((m) => m.group === group); + if (!list.length) { + bodyEl.innerHTML = `

Không có mission trong nhóm «${escapeHtml(group)}».

`; + return; + } + bodyEl.innerHTML = `
`; + const listEl = bodyEl.querySelector(".dashboardMissionGroupList"); + list.forEach((m) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "dashboardMissionGroupBtn"; + btn.innerHTML = `${escapeHtml(m.name)}`; + btn.addEventListener("click", () => missions()?.queueMission?.(m.id)); + listEl.appendChild(btn); + }); + } + + function renderMissionQueueWidget(widget, bodyEl) { + bodyEl.innerHTML = ` +
+
+

Queue trống

+ `; + bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.()); + refreshQueueWidget(bodyEl); + } + + function refreshQueueWidget(bodyEl) { + const snap = missions()?.getQueueSnapshot?.(); + if (!snap) return; + missions()?.renderQueueInto?.( + { + listEl: bodyEl.querySelector('[data-role="list"]'), + runnerEl: bodyEl.querySelector('[data-role="runner"]'), + emptyEl: bodyEl.querySelector('[data-role="empty"]'), + }, + { compact: true } + ); + } + + function renderPauseContinueWidget(widget, bodyEl) { + const snap = missions()?.getQueueSnapshot?.(); + const state = snap?.runner?.state || "idle"; + 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; + try { + if (action === "pause") await missions()?.pauseRunner?.(); + else await missions()?.continueRunner?.(); + } catch (e) { + alert(e.message); + } + }); + } + + function renderWidget(widget) { + const card = document.createElement("article"); + card.className = `dashboardWidget dashboardWidget--${widget.type}`; + card.dataset.widgetId = widget.id; + + card.innerHTML = ` +
+
${escapeHtml(widgetTitle(widget))}
+ +
+
`; + + const bodyEl = card.querySelector(".dashboardWidgetBody"); + switch (widget.type) { + case "mission_button": + renderMissionButtonWidget(widget, bodyEl); + break; + case "mission_group": + renderMissionGroupWidget(widget, bodyEl); + break; + case "mission_queue": + renderMissionQueueWidget(widget, bodyEl); + break; + case "pause_continue": + renderPauseContinueWidget(widget, bodyEl); + break; + default: + bodyEl.innerHTML = `

Widget không hỗ trợ.

`; + } + + card.querySelector("[data-widget-config]")?.addEventListener("click", () => openEditDialog(widget.id)); + card.querySelector("[data-widget-delete]")?.addEventListener("click", () => deleteWidget(widget.id)); + return card; + } + + function renderDashboard() { + if (!gridEl) return; + gridEl.innerHTML = ""; + if (emptyEl) emptyEl.hidden = store.widgets.length > 0; + gridEl.classList.toggle("dashboardGrid--edit", store.editMode); + store.widgets.forEach((w) => gridEl.appendChild(renderWidget(w))); + gridEl.querySelectorAll(".dashboardWidgetChrome").forEach((n) => { + n.hidden = !store.editMode; + }); + } + + function refreshDynamicWidgets() { + store.widgets.forEach((widget) => { + const card = gridEl?.querySelector(`[data-widget-id="${widget.id}"]`); + if (!card) return; + const bodyEl = card.querySelector(".dashboardWidgetBody"); + if (widget.type === "mission_queue") refreshQueueWidget(bodyEl); + if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl); + }); + } + + function openAddDialog() { + fillTypeFields(addFieldsEl, addTypeEl.value); + addDialogEl.showModal(); + } + + function openEditDialog(widgetId) { + const widget = store.widgets.find((w) => w.id === widgetId); + if (!widget) return; + editWidgetIdEl.value = widget.id; + editWidgetTypeEl.value = WIDGET_LABELS[widget.type] || widget.type; + fillTypeFields(editFieldsEl, widget.type, widget); + editDialogEl.showModal(); + } + + function deleteWidget(widgetId) { + if (!confirm("Xóa widget này?")) return; + store.widgets = store.widgets.filter((w) => w.id !== widgetId); + persistStore(); + renderDashboard(); + editDialogEl.close(); + } + + function bindEvents() { + el("dashboardAddWidgetBtn")?.addEventListener("click", openAddDialog); + el("dashboardEditBtn")?.addEventListener("click", () => { + store.editMode = !store.editMode; + el("dashboardEditBtn").textContent = store.editMode ? "Xong" : "Sửa layout"; + renderDashboard(); + }); + + addTypeEl?.addEventListener("change", () => fillTypeFields(addFieldsEl, addTypeEl.value)); + + el("dashboardAddWidgetForm")?.addEventListener("submit", (evt) => { + evt.preventDefault(); + const type = addTypeEl.value; + const fields = readFields(addFieldsEl); + store.widgets.push({ id: newId(), type, ...fields }); + persistStore(); + addDialogEl.close(); + renderDashboard(); + }); + + el("dashboardEditWidgetForm")?.addEventListener("submit", (evt) => { + evt.preventDefault(); + const id = editWidgetIdEl.value; + const widget = store.widgets.find((w) => w.id === id); + if (!widget) return; + Object.assign(widget, readFields(editFieldsEl)); + persistStore(); + editDialogEl.close(); + renderDashboard(); + }); + + el("dashboardDeleteWidgetBtn")?.addEventListener("click", () => deleteWidget(editWidgetIdEl.value)); + } + + function startDashboardPoll() { + stopDashboardPoll(); + missions()?.refreshQueue?.(); + store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets()); + store.pollTimer = setInterval(() => missions()?.refreshQueue?.(), 2000); + } + + function stopDashboardPoll() { + if (store.pollTimer) { + clearInterval(store.pollTimer); + store.pollTimer = null; + } + if (store.queueUnsub) { + store.queueUnsub(); + store.queueUnsub = null; + } + } + + function init() { + loadStore(); + bindEvents(); + renderDashboard(); + } + + window.DashboardApp = { + init, + onPageShow() { + renderDashboard(); + startDashboardPoll(); + }, + onPageHide() { + stopDashboardPoll(); + }, + refresh() { + renderDashboard(); + }, + }; + + init(); +})(); diff --git a/www/index.html b/www/index.html index 699ea94..c3bc5cc 100644 --- a/www/index.html +++ b/www/index.html @@ -19,9 +19,9 @@