chức năng dashboard

This commit is contained in:
2026-06-13 13:20:57 +07:00
parent c116b30bea
commit 6f6d925fdd
9 changed files with 746 additions and 167 deletions

View File

@@ -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"
}
}

View File

@@ -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<std::mutex> 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<std::mutex> 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)

View File

@@ -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<bool> stop_{false};
std::atomic<bool> wake_{false};
std::atomic<bool> paused_{false};
void load();
void saveUnlocked() const;

View File

@@ -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

View File

@@ -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 */
}

378
www/dashboard.js Normal file
View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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) =>
`<option value="${escapeHtml(m.id)}" ${m.id === selected ? "selected" : ""}>${escapeHtml(m.name)} (${escapeHtml(m.group)})</option>`
)
.join("");
}
function groupOptions(selected) {
const groups = missions()?.getGroups?.() || ["Missions"];
return groups
.map((g) => `<option value="${escapeHtml(g)}" ${g === selected ? "selected" : ""}>${escapeHtml(g)}</option>`)
.join("");
}
function fillTypeFields(container, type, widget = {}) {
if (!container) return;
container.innerHTML = "";
if (type === "mission_button") {
container.innerHTML = `
<div class="row rowWide">
<label>Mission</label>
<select data-field="mission_id">${missionOptions(widget.mission_id || "")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="VD: Go to charging" />
</div>`;
} else if (type === "mission_group") {
container.innerHTML = `
<div class="row rowWide">
<label>Nhóm mission</label>
<select data-field="group">${groupOptions(widget.group || "Missions")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>`;
} else if (type === "mission_queue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission queue")}" />
</div>`;
} else if (type === "pause_continue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>
<p class="mutedNote">Tạm dừng / tiếp tục mission đang chạy trên robot.</p>`;
}
}
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 = `
<button type="button" class="dashboardMissionBtn" data-run-mission="${escapeHtml(widget.mission_id || "")}">
<span class="dashboardMissionBtnIcon">▶</span>
<span>${escapeHtml(label)}</span>
</button>
${!m ? `<p class="mutedNote dashboardWidgetHint">Cấu hình widget và chọn mission.</p>` : ""}`;
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 = `<p class="mutedNote">Không có mission trong nhóm «${escapeHtml(group)}».</p>`;
return;
}
bodyEl.innerHTML = `<div class="dashboardMissionGroupList"></div>`;
const listEl = bodyEl.querySelector(".dashboardMissionGroupList");
list.forEach((m) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "dashboardMissionGroupBtn";
btn.innerHTML = `<span class="dashboardMissionBtnIcon">▶</span><span>${escapeHtml(m.name)}</span>`;
btn.addEventListener("click", () => missions()?.queueMission?.(m.id));
listEl.appendChild(btn);
});
}
function renderMissionQueueWidget(widget, bodyEl) {
bodyEl.innerHTML = `
<div class="dashboardQueueRunner mutedNote" data-role="runner">—</div>
<div class="dashboardQueueList" data-role="list"></div>
<p class="mutedNote dashboardQueueEmpty" data-role="empty">Queue trống</p>
<button type="button" class="btn subtle btnBlock dashboardQueueClear">Xóa queue chờ</button>`;
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 = `
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
${paused ? "Continue" : "Pause"}
</button>
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? "Mission đang tạm dừng" : "Mission đang chạy") : "Không có mission đang chạy"}</p>`;
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 = `
<div class="dashboardWidgetHeader">
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
<div class="dashboardWidgetChrome" hidden>
<button type="button" class="iconBtn" data-widget-config title="Cấu hình">⚙</button>
<button type="button" class="iconBtn danger" data-widget-delete title="Xóa">×</button>
</div>
</div>
<div class="dashboardWidgetBody"></div>`;
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 = `<p class="mutedNote">Widget không hỗ trợ.</p>`;
}
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();
})();

View File

@@ -19,9 +19,9 @@
<div class="navTitle">WORKSPACE</div>
<nav class="nav">
<a class="navItem active" href="#" data-page="overview" aria-current="page">
<a class="navItem active" href="#" data-page="dashboard" aria-current="page">
<span class="navDot"></span>
Tổng quan
Dashboard
</a>
<a class="navItem" href="#" data-page="config">
<span class="navDot"></span>
@@ -58,48 +58,52 @@
</header>
<main class="content">
<div class="page" id="pageOverview" data-page-content="overview" hidden>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Phần mềm</div>
<div class="cardSub">Thông tin phiên bản và trạng thái backend.</div>
<div class="page" id="pageOverview" data-page-content="dashboard" hidden>
<div class="dashboardPage">
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Dashboard</div>
<div class="cardSub">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</div>
</div>
<div class="dashboardToolbar">
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle">Thêm widget</button>
<button id="dashboardEditBtn" type="button" class="btn subtle">Sửa layout</button>
</div>
</div>
</div>
<div class="cardBody">
<div class="row rowWide">
<label>Backend</label>
<div id="overviewBackend" class="mutedNote"></div>
<div class="cardBody">
<div id="dashboardGrid" class="dashboardGrid"></div>
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden>Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
</div>
<div class="row rowWide">
<label>Dữ liệu</label>
<div class="mutedNote">`data/state.json` (catalog) + `data/models/{id}.json` (layout)</div>
</div>
</div>
</section>
</section>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Cấu hình đang active</div>
<div class="cardSub">Tóm tắt layout đang mở.</div>
<section class="card dashboardInfoCard">
<div class="cardHeader">
<div>
<div class="cardTitle">Hệ thống</div>
<div class="cardSub">Trạng thái backend và layout đang active.</div>
</div>
</div>
</div>
<div class="cardBody">
<div class="row rowWide">
<label>Layout</label>
<div id="overviewActiveLayout" class="mutedNote"></div>
<div class="cardBody dashboardInfoGrid">
<div class="row rowWide">
<label>Backend</label>
<div id="overviewBackend" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>Layout</label>
<div id="overviewActiveLayout" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>Model robot</label>
<div id="overviewActiveModel" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>LiDAR / IMU</label>
<div id="overviewActiveSensors" class="mutedNote"></div>
</div>
</div>
<div class="row rowWide">
<label>Model robot</label>
<div id="overviewActiveModel" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>LiDAR / IMU</label>
<div id="overviewActiveSensors" class="mutedNote"></div>
</div>
</div>
</section>
</section>
</div>
</div>
<div class="page" id="pageConfig" data-page-content="config">
@@ -720,7 +724,57 @@
</form>
</dialog>
</dialog>
<dialog id="dashboardAddWidgetDialog" class="missionDialog">
<form id="dashboardAddWidgetForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Thêm widget</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardAddWidgetDialog" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="dashboardWidgetType">Loại widget</label>
<select id="dashboardWidgetType" required>
<option value="mission_button">Mission button</option>
<option value="mission_group">Mission group</option>
<option value="mission_queue">Mission queue</option>
<option value="pause_continue">Pause / Continue</option>
</select>
</div>
<div id="dashboardAddWidgetFields" class="missionConfigGrid"></div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="dashboardAddWidgetDialog">Hủy</button>
<button type="submit" class="btn primary">Thêm</button>
</div>
</form>
</dialog>
<dialog id="dashboardEditWidgetDialog" class="missionDialog">
<form id="dashboardEditWidgetForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Cấu hình widget</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditWidgetDialog" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody">
<input type="hidden" id="dashboardEditWidgetId" />
<div class="row rowWide">
<label for="dashboardEditWidgetType">Loại</label>
<input id="dashboardEditWidgetType" type="text" readonly />
</div>
<div id="dashboardEditWidgetFields" class="missionConfigGrid"></div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle danger" id="dashboardDeleteWidgetBtn">Xóa widget</button>
<button type="button" class="btn subtle" data-close-dialog="dashboardEditWidgetDialog">Hủy</button>
<button type="submit" class="btn primary">Lưu</button>
</div>
</form>
</dialog>
<script src="/missions.js"></script>
<script src="/dashboard.js"></script>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -435,33 +435,70 @@
store.queue = Array.isArray(data.queue) ? data.queue : [];
store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" };
renderQueuePanel();
notifyQueueUpdate();
} 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;
const queueListeners = new Set();
function notifyQueueUpdate() {
queueListeners.forEach((fn) => {
try {
fn(getQueueSnapshot());
} catch {
/* ignore */
}
});
}
if (missionQueueRunnerEl) {
function getQueueSnapshot() {
return {
queue: JSON.parse(JSON.stringify(store.queue)),
runner: JSON.parse(JSON.stringify(store.runner)),
};
}
function renderQueueInto(target, options = {}) {
const listEl = target.listEl;
const runnerEl = target.runnerEl;
const emptyEl = target.emptyEl;
const compact = !!options.compact;
if (!listEl) return;
listEl.innerHTML = "";
if (emptyEl) emptyEl.hidden = store.queue.length > 0;
if (runnerEl) {
const st = store.runner.state || "idle";
missionQueueRunnerEl.classList.toggle("running", st === "running");
runnerEl.classList.toggle("running", st === "running" || st === "paused");
runnerEl.classList.toggle("paused", st === "paused");
const action = store.runner.current_action ? `${store.runner.current_action}` : "";
missionQueueRunnerEl.textContent = store.runner.message
runnerEl.textContent = store.runner.message
? `${store.runner.message}${action}`
: st === "idle"
? "Robot sẵn sàng — queue trống hoặc chờ mission mới."
? compact
? "Sẵn sàng"
: "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"}`;
row.className = `missionQueueItem status-${entry.status || "pending"}${compact ? " compact" : ""}`;
const paramHtml = formatQueueParameters(entry);
const canReorder = entry.status === "pending";
row.innerHTML = `
const canReorder = entry.status === "pending" && !compact;
row.innerHTML = compact
? `
<div>
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
<div class="missionQueueItemMeta">${queueStatusLabel(entry.status)} • #${index + 1}</div>
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
</div>
<div class="missionQueueWidgetActions">
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="Xóa">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
</div>`
: `
<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>
@@ -479,10 +516,21 @@
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);
listEl.appendChild(row);
});
}
function renderQueuePanel() {
renderQueueInto(
{
listEl: missionQueueListEl,
runnerEl: missionQueueRunnerEl,
emptyEl: missionQueueEmptyEl,
},
{ compact: false }
);
}
async function moveQueueItem(id, delta) {
const ids = store.queue.map((q) => q.id);
const idx = ids.indexOf(id);
@@ -522,6 +570,27 @@
}
}
async function enqueueMission(missionId, parameters = {}) {
const mission = findMission(missionId);
if (!mission) throw new Error("Mission không tồn tại");
const payload = {
mission: resolveMissionSnapshot(mission),
parameters,
};
await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) });
await refreshQueue();
}
async function pauseRunner() {
await missionApi("/api/mission_queue/pause", { method: "POST", body: "{}" });
await refreshQueue();
}
async function continueRunner() {
await missionApi("/api/mission_queue/continue", { method: "POST", body: "{}" });
await refreshQueue();
}
function openQueueDialog(missionId) {
const mission = findMission(missionId);
if (!mission) return;
@@ -1192,6 +1261,23 @@
window.MissionsApp = {
init,
getMissions: () => [...store.missions],
getGroups: () => allGroups(),
getMissionById: findMission,
queueMission: openQueueDialog,
enqueueMission,
pauseRunner,
continueRunner,
refreshQueue,
clearQueue,
getQueueSnapshot,
renderQueueInto,
onQueueUpdate(fn) {
queueListeners.add(fn);
return () => queueListeners.delete(fn);
},
startQueuePoll,
stopQueuePoll,
onPageShow() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {

View File

@@ -541,9 +541,9 @@ canvas {
grid-template-columns: minmax(0, 1fr);
max-width: 1100px;
}
.content.content--overview {
.content.content--dashboard {
grid-template-columns: minmax(0, 1fr);
max-width: 900px;
max-width: 1100px;
}
.content.content--config {
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
@@ -861,6 +861,96 @@ canvas {
.missionConfigGrid { display: grid; gap: 12px; }
.missionConfigGrid .rowWide { grid-template-columns: 1fr; gap: 6px; }
.dashboardPage { display: grid; gap: 16px; min-width: 0; width: 100%; }
.dashboardToolbar { display: flex; gap: 8px; flex-wrap: wrap; }
.dashboardGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.dashboardGrid--edit .dashboardWidget { outline: 2px dashed rgba(37, 99, 235, 0.25); outline-offset: 2px; }
.dashboardWidget {
border: 1px solid var(--border);
border-radius: 14px;
background: #fff;
box-shadow: var(--shadow);
overflow: hidden;
min-height: 140px;
display: flex;
flex-direction: column;
}
.dashboardWidgetHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: var(--panel2);
}
.dashboardWidgetTitle { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.dashboardWidgetChrome { display: flex; gap: 6px; }
.dashboardWidgetBody { padding: 12px; display: grid; gap: 10px; flex: 1; align-content: start; }
.dashboardWidgetHint { margin: 0; font-size: 11px; }
.dashboardMissionBtn,
.dashboardMissionGroupBtn {
appearance: none;
width: 100%;
border: 1px solid rgba(37, 99, 235, 0.25);
background: linear-gradient(180deg, #eff6ff, #dbeafe);
color: #1e3a8a;
border-radius: 12px;
padding: 14px 12px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.dashboardMissionBtn:hover,
.dashboardMissionGroupBtn:hover { border-color: rgba(37, 99, 235, 0.45); box-shadow: var(--shadow); }
.dashboardMissionBtnIcon {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(37, 99, 235, 0.15);
font-size: 12px;
}
.dashboardMissionGroupList { display: grid; gap: 8px; max-height: 220px; overflow: auto; }
.dashboardMissionGroupBtn { padding: 10px 12px; font-size: 13px; justify-content: flex-start; }
.dashboardQueueRunner {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #f8fafc;
font-size: 12px;
}
.dashboardQueueRunner.running { background: #eff6ff; border-color: rgba(37, 99, 235, 0.25); color: #1e3a8a; }
.dashboardQueueRunner.paused { background: #fef3c7; border-color: #fcd34d; color: #92400e; }
.dashboardQueueList { display: grid; gap: 6px; max-height: 180px; overflow: auto; }
.missionQueueItem.compact { grid-template-columns: 1fr auto; padding: 8px 10px; }
.missionQueueWidgetActions { display: flex; align-items: center; }
.dashboardPauseBtn {
appearance: none;
width: 100%;
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
font-size: 16px;
font-weight: 800;
cursor: pointer;
background: #fff7ed;
color: #9a3412;
}
.dashboardPauseBtn.is-paused { background: #ecfdf5; color: #047857; }
.dashboardPauseBtn:disabled { opacity: 0.45; cursor: not-allowed; }
.dashboardInfoCard .dashboardInfoGrid { display: grid; gap: 8px; }
.dashboardEmpty { text-align: center; padding: 12px 0 0; }
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: relative; height: auto; }