chức năng dashboard
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
17
www/app.js
17
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 */
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
132
www/index.html
132
www/index.html
@@ -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>
|
||||
|
||||
110
www/missions.js
110
www/missions.js
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user