chức năng dashboard
This commit is contained in:
@@ -1,115 +1,11 @@
|
|||||||
{
|
{
|
||||||
"queue": [
|
"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": {
|
"runner": {
|
||||||
"current_action": null,
|
"current_action": null,
|
||||||
"current_queue_id": null,
|
"current_queue_id": null,
|
||||||
"message": "Hoàn thành: ABC",
|
"message": "",
|
||||||
|
"paused": false,
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"updated_at": "2026-06-13T05:17:40Z"
|
"updated_at": "2026-06-13T05:43:11Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,8 @@ void MissionQueue::ensureRunnerDefaults()
|
|||||||
runner_["current_queue_id"] = nullptr;
|
runner_["current_queue_id"] = nullptr;
|
||||||
if (!runner_.contains("current_action"))
|
if (!runner_.contains("current_action"))
|
||||||
runner_["current_action"] = nullptr;
|
runner_["current_action"] = nullptr;
|
||||||
|
if (!runner_.contains("paused"))
|
||||||
|
runner_["paused"] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionQueue::startWorkerIfNeeded()
|
void MissionQueue::startWorkerIfNeeded()
|
||||||
@@ -255,6 +257,43 @@ bool MissionQueue::reorder(const nlohmann::json& ordered_ids, std::string& err)
|
|||||||
return true;
|
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()
|
void MissionQueue::workerLoop()
|
||||||
{
|
{
|
||||||
while (!stop_)
|
while (!stop_)
|
||||||
@@ -273,6 +312,8 @@ void MissionQueue::processQueueUnlocked()
|
|||||||
{
|
{
|
||||||
if (!queue_.is_array())
|
if (!queue_.is_array())
|
||||||
return;
|
return;
|
||||||
|
if (paused_)
|
||||||
|
return;
|
||||||
|
|
||||||
for (auto& item : queue_)
|
for (auto& item : queue_)
|
||||||
{
|
{
|
||||||
@@ -335,6 +376,10 @@ void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
|||||||
{
|
{
|
||||||
if (!action.is_object())
|
if (!action.is_object())
|
||||||
continue;
|
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 action_id = action.value("id", "");
|
||||||
const std::string kind = action.value("kind", "action");
|
const std::string kind = action.value("kind", "action");
|
||||||
@@ -433,7 +478,11 @@ void MissionQueue::sleepMs(int ms)
|
|||||||
return;
|
return;
|
||||||
const int step = 100;
|
const int step = 100;
|
||||||
for (int elapsed = 0; elapsed < ms && !stop_; elapsed += step)
|
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)));
|
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MissionQueue::setRunnerState(const std::string& state, const std::string& message)
|
void MissionQueue::setRunnerState(const std::string& state, const std::string& message)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public:
|
|||||||
bool removeById(const std::string& id, std::string& err);
|
bool removeById(const std::string& id, std::string& err);
|
||||||
bool clearAll(std::string& err);
|
bool clearAll(std::string& err);
|
||||||
bool reorder(const nlohmann::json& ordered_ids, std::string& err);
|
bool reorder(const nlohmann::json& ordered_ids, std::string& err);
|
||||||
|
bool pause(std::string& err);
|
||||||
|
bool resume(std::string& err);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::filesystem::path queue_path_;
|
std::filesystem::path queue_path_;
|
||||||
@@ -37,6 +39,7 @@ private:
|
|||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
std::atomic<bool> stop_{false};
|
std::atomic<bool> stop_{false};
|
||||||
std::atomic<bool> wake_{false};
|
std::atomic<bool> wake_{false};
|
||||||
|
std::atomic<bool> paused_{false};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
void saveUnlocked() const;
|
void saveUnlocked() const;
|
||||||
|
|||||||
@@ -502,6 +502,24 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
|||||||
return HttpUtil::jsonError(res, 400, err);
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
res.status = 204;
|
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
|
} // namespace lm
|
||||||
|
|||||||
17
www/app.js
17
www/app.js
@@ -120,29 +120,32 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const valid = ["overview", "config", "missions"];
|
const valid = ["dashboard", "config", "missions"];
|
||||||
const p = valid.includes(page) ? page : "config";
|
let p = valid.includes(page) ? page : "config";
|
||||||
|
if (page === "overview") p = "dashboard";
|
||||||
navItemEls.forEach((a) => {
|
navItemEls.forEach((a) => {
|
||||||
const on = (a.dataset.page || "") === p;
|
const on = (a.dataset.page || "") === p;
|
||||||
a.classList.toggle("active", on);
|
a.classList.toggle("active", on);
|
||||||
if (on) a.setAttribute("aria-current", "page");
|
if (on) a.setAttribute("aria-current", "page");
|
||||||
else a.removeAttribute("aria-current");
|
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 (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 (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
||||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||||
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
|
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
|
||||||
if (contentRightEl) contentRightEl.hidden = p !== "config";
|
if (contentRightEl) contentRightEl.hidden = p !== "config";
|
||||||
if (contentEl) {
|
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--config", p === "config");
|
||||||
contentEl.classList.toggle("content--missions", p === "missions");
|
contentEl.classList.toggle("content--missions", p === "missions");
|
||||||
}
|
}
|
||||||
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
||||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||||
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
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 {
|
try {
|
||||||
localStorage.setItem("activePage", p);
|
localStorage.setItem("activePage", p);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -161,7 +164,9 @@ function initNavigation() {
|
|||||||
let initial = "config";
|
let initial = "config";
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem("activePage");
|
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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
378
www/dashboard.js
Normal file
378
www/dashboard.js
Normal 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, "&")
|
||||||
|
.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) =>
|
||||||
|
`<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();
|
||||||
|
})();
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
|
|
||||||
<div class="navTitle">WORKSPACE</div>
|
<div class="navTitle">WORKSPACE</div>
|
||||||
<nav class="nav">
|
<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>
|
<span class="navDot"></span>
|
||||||
Tổng quan
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
<a class="navItem" href="#" data-page="config">
|
<a class="navItem" href="#" data-page="config">
|
||||||
<span class="navDot"></span>
|
<span class="navDot"></span>
|
||||||
@@ -58,34 +58,37 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<div class="page" id="pageOverview" data-page-content="overview" hidden>
|
<div class="page" id="pageOverview" data-page-content="dashboard" hidden>
|
||||||
|
<div class="dashboardPage">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="cardHeader">
|
<div class="cardHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="cardTitle">Phần mềm</div>
|
<div class="cardTitle">Dashboard</div>
|
||||||
<div class="cardSub">Thông tin phiên bản và trạng thái backend.</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>
|
||||||
<div class="cardBody">
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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 class="cardBody dashboardInfoGrid">
|
||||||
<div class="row rowWide">
|
<div class="row rowWide">
|
||||||
<label>Backend</label>
|
<label>Backend</label>
|
||||||
<div id="overviewBackend" class="mutedNote">—</div>
|
<div id="overviewBackend" class="mutedNote">—</div>
|
||||||
</div>
|
</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 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cardBody">
|
|
||||||
<div class="row rowWide">
|
<div class="row rowWide">
|
||||||
<label>Layout</label>
|
<label>Layout</label>
|
||||||
<div id="overviewActiveLayout" class="mutedNote">—</div>
|
<div id="overviewActiveLayout" class="mutedNote">—</div>
|
||||||
@@ -101,6 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageConfig" data-page-content="config">
|
<div class="page" id="pageConfig" data-page-content="config">
|
||||||
<div class="contentLeft">
|
<div class="contentLeft">
|
||||||
@@ -720,7 +724,57 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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="/missions.js"></script>
|
||||||
|
<script src="/dashboard.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
110
www/missions.js
110
www/missions.js
@@ -435,33 +435,70 @@
|
|||||||
store.queue = Array.isArray(data.queue) ? data.queue : [];
|
store.queue = Array.isArray(data.queue) ? data.queue : [];
|
||||||
store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" };
|
store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" };
|
||||||
renderQueuePanel();
|
renderQueuePanel();
|
||||||
|
notifyQueueUpdate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
|
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueuePanel() {
|
const queueListeners = new Set();
|
||||||
if (!missionQueueListEl) return;
|
function notifyQueueUpdate() {
|
||||||
missionQueueListEl.innerHTML = "";
|
queueListeners.forEach((fn) => {
|
||||||
if (missionQueueEmptyEl) missionQueueEmptyEl.hidden = store.queue.length > 0;
|
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";
|
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}` : "";
|
const action = store.runner.current_action ? ` • ${store.runner.current_action}` : "";
|
||||||
missionQueueRunnerEl.textContent = store.runner.message
|
runnerEl.textContent = store.runner.message
|
||||||
? `${store.runner.message}${action}`
|
? `${store.runner.message}${action}`
|
||||||
: st === "idle"
|
: 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) => {
|
store.queue.forEach((entry, index) => {
|
||||||
const row = document.createElement("div");
|
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 paramHtml = formatQueueParameters(entry);
|
||||||
const canReorder = entry.status === "pending";
|
const canReorder = entry.status === "pending" && !compact;
|
||||||
row.innerHTML = `
|
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">
|
<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-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>
|
<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-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
|
||||||
row.querySelector("[data-queue-down]")?.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));
|
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) {
|
async function moveQueueItem(id, delta) {
|
||||||
const ids = store.queue.map((q) => q.id);
|
const ids = store.queue.map((q) => q.id);
|
||||||
const idx = ids.indexOf(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) {
|
function openQueueDialog(missionId) {
|
||||||
const mission = findMission(missionId);
|
const mission = findMission(missionId);
|
||||||
if (!mission) return;
|
if (!mission) return;
|
||||||
@@ -1192,6 +1261,23 @@
|
|||||||
|
|
||||||
window.MissionsApp = {
|
window.MissionsApp = {
|
||||||
init,
|
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() {
|
onPageShow() {
|
||||||
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -541,9 +541,9 @@ canvas {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
.content.content--overview {
|
.content.content--dashboard {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
max-width: 900px;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
.content.content--config {
|
.content.content--config {
|
||||||
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
|
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
|
||||||
@@ -861,6 +861,96 @@ canvas {
|
|||||||
.missionConfigGrid { display: grid; gap: 12px; }
|
.missionConfigGrid { display: grid; gap: 12px; }
|
||||||
.missionConfigGrid .rowWide { grid-template-columns: 1fr; gap: 6px; }
|
.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) {
|
@media (max-width: 980px) {
|
||||||
.shell { grid-template-columns: 1fr; }
|
.shell { grid-template-columns: 1fr; }
|
||||||
.sidebar { position: relative; height: auto; }
|
.sidebar { position: relative; height: auto; }
|
||||||
|
|||||||
Reference in New Issue
Block a user