add top bar

This commit is contained in:
2026-06-16 11:17:28 +07:00
parent 9aee5f4100
commit 1156e1ab29
19 changed files with 1625 additions and 80 deletions

View File

@@ -47,7 +47,9 @@ add_executable(lidar_manager_web
src/mission/mission_enqueue.cpp
src/mission/modbus_trigger_service.cpp
src/mission/mission_scheduler.cpp
src/robot/robot_runtime.cpp
src/server/api_mission_routes.cpp
src/server/api_robot_routes.cpp
)
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)

13
data/robot_runtime.json Normal file
View File

@@ -0,0 +1,13 @@
{
"battery_charging": false,
"battery_percent": 54,
"cmd_angular": 0.0,
"cmd_linear": 0.0,
"error": null,
"health": "ok",
"joystick_engaged": false,
"joystick_speed": "fast",
"message": "Robot paused",
"motion": "paused",
"updated_at": "2026-06-16T03:40:34Z"
}

View File

@@ -99,6 +99,29 @@ assert_json_true "auth me" "$TMP/me.json" 'doc.get("user",{}).get("group_name")
assert_code "GET /" 200 "$TMP/index.html" -X GET "$BASE/"
assert_code "GET /auth.js" 200 "$TMP/auth.js" -X GET "$BASE/auth.js"
assert_code "GET /missions.js" 200 "$TMP/missions.js" -X GET "$BASE/missions.js"
assert_code "GET /topbar.js" 200 "$TMP/topbar.js" -X GET "$BASE/topbar.js"
assert_code "GET /api/robot/status" 200 "$TMP/robot_status.json" -X GET "$BASE/api/robot/status"
assert_json_true "robot status motion" "$TMP/robot_status.json" 'doc.get("motion") in ("paused", "running")'
assert_json_true "robot status battery" "$TMP/robot_status.json" 'doc.get("battery_percent", 0) >= 0'
assert_code "POST /api/robot/start" 200 "$TMP/robot_start.json" \
-X POST "$BASE/api/robot/start" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot started" "$TMP/robot_start.json" 'doc.get("motion") == "running"'
assert_code "POST /api/robot/pause" 200 "$TMP/robot_pause.json" \
-X POST "$BASE/api/robot/pause" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot paused" "$TMP/robot_pause.json" 'doc.get("motion") == "paused"'
assert_code "POST /api/robot/errors/reset" 200 "$TMP/robot_reset.json" \
-X POST "$BASE/api/robot/errors/reset" -H 'Content-Type: application/json' -d '{}'
assert_json_true "robot health ok" "$TMP/robot_reset.json" 'doc.get("health") == "ok"'
assert_code "PUT /api/auth/profile" 200 "$TMP/profile.json" \
-X PUT "$BASE/api/auth/profile" \
-H 'Content-Type: application/json' \
-d '{"display_name":"Admin Test"}'
assert_json_true "profile display_name" "$TMP/profile.json" 'doc.get("user",{}).get("display_name") == "Admin Test"'
assert_code "GET /api/state" 200 "$TMP/state.json" -X GET "$BASE/api/state"

View File

@@ -6,6 +6,7 @@
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "server/api_server.hpp"
#include "server/static_file_server.hpp"
#include "storage/state_repository.hpp"
@@ -31,6 +32,7 @@ int LidarManagerApp::run()
const std::filesystem::path missions_store_path = data_path_.parent_path() / "missions.json";
MissionQueue mission_queue(mission_queue_path);
MissionStore mission_store(missions_store_path);
RobotRuntime robot_runtime(data_path_.parent_path() / "robot_runtime.json", mission_queue);
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
nlohmann::json payload;
@@ -49,7 +51,7 @@ int LidarManagerApp::run()
return auth.preRoute(req, res);
});
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler);
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler, robot_runtime);
api.registerRoutes(svr);
auth.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);

View File

@@ -157,7 +157,8 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
return std::nullopt;
if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0)
return "users";
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0)
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0 ||
path.rfind("/api/robot", 0) == 0)
return "missions";
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
@@ -410,6 +411,47 @@ bool AuthService::changePassword(const std::string& token,
return false;
}
std::optional<nlohmann::json> AuthService::changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err)
{
if (!payload.is_object())
{
err = "invalid payload";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
const auto it = sessions_.find(token);
if (it == sessions_.end())
{
err = "not authenticated";
return std::nullopt;
}
for (auto& user : data_["users"])
{
if (user.value("id", "") != it->second.user_id)
continue;
if (payload.contains("display_name") && payload["display_name"].is_string())
{
const std::string name = StringUtil::trimCopy(payload["display_name"].get<std::string>());
if (name.empty())
{
err = "display_name cannot be empty";
return std::nullopt;
}
user["display_name"] = name;
}
saveUnlocked();
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
return userPublicView(user, group ? *group : nlohmann::json::object());
}
err = "user not found";
return std::nullopt;
}
nlohmann::json AuthService::listGroups() const
{
std::lock_guard<std::mutex> lock(mu_);
@@ -704,6 +746,31 @@ void AuthService::registerRoutes(httplib::Server& svr)
HttpUtil::addCors(res);
});
svr.Put("/api/auth/profile", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
const std::string token = extractToken(req);
std::string err;
const auto profile = changeProfile(token, body, err);
if (!profile)
{
HttpUtil::jsonError(res, 400, err);
return;
}
nlohmann::json out = {{"user", *profile}};
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) {
nlohmann::json out = {{"groups", listGroups()}};
res.set_content(out.dump(), "application/json; charset=utf-8");

View File

@@ -40,6 +40,9 @@ public:
const std::string& current_password,
const std::string& new_password,
std::string& err);
std::optional<nlohmann::json> changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err);
nlohmann::json listGroups() const;
nlohmann::json listUsers() const;

265
src/robot/robot_runtime.cpp Normal file
View File

@@ -0,0 +1,265 @@
#include "robot/robot_runtime.hpp"
#include "mission/mission_queue.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace lm {
namespace {
constexpr const char* kDefaultMessage = "Waiting for new missions...";
} // namespace
RobotRuntime::RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue)
: runtime_path_(std::move(runtime_path)), mission_queue_(mission_queue)
{
load();
ensureDefaultsUnlocked();
}
void RobotRuntime::load()
{
std::lock_guard<std::mutex> lock(mu_);
state_ = nlohmann::json::object();
if (!std::filesystem::exists(runtime_path_))
return;
try
{
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(runtime_path_));
if (parsed.is_object())
state_ = parsed;
}
catch (...)
{
state_ = nlohmann::json::object();
}
}
void RobotRuntime::saveUnlocked() const
{
FileUtil::writeBinaryAtomic(runtime_path_, state_.dump(2));
}
void RobotRuntime::ensureDefaultsUnlocked()
{
if (!state_.is_object())
state_ = nlohmann::json::object();
if (!state_.contains("motion"))
state_["motion"] = "paused";
if (!state_.contains("health"))
state_["health"] = "ok";
if (!state_.contains("message"))
state_["message"] = kDefaultMessage;
if (!state_.contains("error"))
state_["error"] = nullptr;
if (!state_.contains("battery_percent"))
state_["battery_percent"] = 54;
if (!state_.contains("battery_charging"))
state_["battery_charging"] = false;
if (!state_.contains("joystick_engaged"))
state_["joystick_engaged"] = false;
if (!state_.contains("joystick_speed"))
state_["joystick_speed"] = "fast";
if (!state_.contains("cmd_linear"))
state_["cmd_linear"] = 0.0;
if (!state_.contains("cmd_angular"))
state_["cmd_angular"] = 0.0;
if (!state_.contains("updated_at"))
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
}
nlohmann::json RobotRuntime::buildStatusUnlocked() const
{
const auto runner = mission_queue_.runnerStatus();
const auto queue = mission_queue_.list();
int pending = 0;
if (queue.is_array())
{
for (const auto& item : queue)
{
if (item.value("status", "") == "pending")
++pending;
}
}
const std::string motion = state_.value("motion", "paused");
const std::string health = state_.value("health", "ok");
const std::string runner_state = runner.value("state", "idle");
std::string message = state_.value("message", kDefaultMessage);
if (health == "ok")
{
if (!runner.value("message", "").empty())
message = runner.value("message", "");
else if (runner_state == "idle" && motion == "paused" && pending == 0)
message = kDefaultMessage;
}
nlohmann::json body = {{"motion", motion},
{"health", health},
{"message", message},
{"error", state_.contains("error") ? state_["error"] : nullptr},
{"battery_percent", state_.value("battery_percent", 54)},
{"battery_charging", state_.value("battery_charging", false)},
{"joystick_engaged", state_.value("joystick_engaged", false)},
{"joystick_speed", state_.value("joystick_speed", "fast")},
{"cmd_linear", state_.value("cmd_linear", 0.0)},
{"cmd_angular", state_.value("cmd_angular", 0.0)},
{"runner", runner},
{"queue_pending", pending},
{"updated_at", state_.value("updated_at", "")}};
return body;
}
nlohmann::json RobotRuntime::status() const
{
std::lock_guard<std::mutex> lock(mu_);
return buildStatusUnlocked();
}
bool RobotRuntime::start(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "cannot start while robot is in error state — reset error first";
return false;
}
state_["motion"] = "running";
state_["message"] = "Robot running";
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "paused")
return mission_queue_.resume(err);
return true;
}
bool RobotRuntime::pause(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
state_["motion"] = "paused";
state_["message"] = "Robot paused";
state_["joystick_engaged"] = false;
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "running" || runner_state == "paused")
return mission_queue_.pause(err);
return true;
}
bool RobotRuntime::resetError(std::string& err)
{
(void)err;
std::lock_guard<std::mutex> lock(mu_);
state_["health"] = "ok";
state_["error"] = nullptr;
state_["message"] = kDefaultMessage;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setCmdVel(double linear, double angular, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "robot is in error state";
return false;
}
if (!state_.value("joystick_engaged", false))
{
err = "joystick is not engaged";
return false;
}
if (state_.value("motion", "paused") != "running")
{
err = "robot motion is paused — press START first";
return false;
}
const std::string speed = state_.value("joystick_speed", "fast");
double scale = 1.0;
if (speed == "medium")
scale = 0.55;
else if (speed == "slow")
scale = 0.25;
linear = std::clamp(linear, -1.0, 1.0) * scale;
angular = std::clamp(angular, -1.0, 1.0) * scale;
state_["cmd_linear"] = linear;
state_["cmd_angular"] = angular;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setJoystick(bool engaged, const std::string& speed, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (engaged && state_.value("health", "ok") == "error")
{
err = "cannot engage joystick while robot is in error state";
return false;
}
if (engaged && state_.value("motion", "paused") != "running")
{
err = "start the robot before engaging the joystick";
return false;
}
state_["joystick_engaged"] = engaged;
if (!speed.empty())
{
if (speed != "fast" && speed != "medium" && speed != "slow")
{
err = "speed must be fast, medium, or slow";
return false;
}
state_["joystick_speed"] = speed;
}
if (!engaged)
{
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
}
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
void RobotRuntime::tick()
{
std::lock_guard<std::mutex> lock(mu_);
const bool running = state_.value("motion", "paused") == "running";
const bool joy = state_.value("joystick_engaged", false);
double battery = state_.value("battery_percent", 54.0);
if (running && (joy || std::abs(state_.value("cmd_linear", 0.0)) > 0.01))
battery = std::max(0.0, battery - 0.02);
else if (state_.value("battery_charging", false))
battery = std::min(100.0, battery + 0.05);
else if (!running)
battery = std::min(100.0, battery + 0.005);
state_["battery_percent"] = static_cast<int>(std::lround(battery));
saveUnlocked();
}
} // namespace lm

View File

@@ -0,0 +1,38 @@
#pragma once
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <string>
namespace lm {
class MissionQueue;
class RobotRuntime
{
public:
explicit RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue);
nlohmann::json status() const;
bool start(std::string& err);
bool pause(std::string& err);
bool resetError(std::string& err);
bool setCmdVel(double linear, double angular, std::string& err);
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
void tick();
private:
std::filesystem::path runtime_path_;
MissionQueue& mission_queue_;
mutable std::mutex mu_;
nlohmann::json state_;
void load();
void saveUnlocked() const;
void ensureDefaultsUnlocked();
nlohmann::json buildStatusUnlocked() const;
};
} // namespace lm

View File

@@ -0,0 +1,85 @@
#include "server/api_server.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerRobotRoutes(httplib::Server& svr)
{
svr.Get("/api/robot/status", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
robot_runtime_.tick();
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/start", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.start(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/pause", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.pause(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/errors/reset", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.resetError(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/cmd_vel", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
const double linear = body.value("linear", body.value("vx", 0.0));
const double angular = body.value("angular", body.value("omega", 0.0));
std::string err;
if (!robot_runtime_.setCmdVel(linear, angular, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/joystick", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!body.contains("engaged") || !body["engaged"].is_boolean())
return HttpUtil::jsonError(res, 400, "engaged (boolean) is required");
const std::string speed = body.value("speed", "");
std::string err;
if (!robot_runtime_.setJoystick(body["engaged"].get<bool>(), speed, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
}
} // namespace lm

View File

@@ -14,12 +14,14 @@ ApiServer::ApiServer(StateRepository& repo,
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler)
MissionScheduler& scheduler,
RobotRuntime& robot_runtime)
: repo_(repo),
mission_queue_(mission_queue),
mission_store_(mission_store),
modbus_(modbus),
scheduler_(scheduler)
scheduler_(scheduler),
robot_runtime_(robot_runtime)
{
}
@@ -538,6 +540,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
registerMissionRoutes(svr);
registerIntegrationRoutes(svr);
registerMirV2Routes(svr);
registerRobotRoutes(svr);
}
} // namespace lm

View File

@@ -6,6 +6,7 @@
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "storage/state_repository.hpp"
namespace lm {
@@ -17,7 +18,8 @@ public:
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler);
MissionScheduler& scheduler,
RobotRuntime& robot_runtime);
void registerRoutes(httplib::Server& svr);
@@ -27,6 +29,7 @@ private:
MissionStore& mission_store_;
ModbusTriggerService& modbus_;
MissionScheduler& scheduler_;
RobotRuntime& robot_runtime_;
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
@@ -34,6 +37,7 @@ private:
void registerMissionRoutes(httplib::Server& svr);
void registerMirV2Routes(httplib::Server& svr);
void registerIntegrationRoutes(httplib::Server& svr);
void registerRobotRoutes(httplib::Server& svr);
};
} // namespace lm

View File

@@ -3,7 +3,6 @@ const el = (id) => document.getElementById(id);
const statusEl = el("status");
const listEl = el("lidarList");
const lidarFormHintEl = el("lidarFormHint");
const pageTitleEl = document.querySelector(".pageTitle");
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
const pageOverviewEl = el("pageOverview");
const pageConfigEl = el("pageConfig");
@@ -134,13 +133,6 @@ function setActivePage(page) {
if (on) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
const titles = {
dashboard: "Dashboard",
config: "Cấu Hình",
missions: "Missions",
integrations: "Tích hợp",
};
if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình";
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
@@ -153,7 +145,6 @@ function setActivePage(page) {
contentEl.classList.toggle("content--missions", p === "missions");
contentEl.classList.toggle("content--integrations", p === "integrations");
}
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();
@@ -3155,7 +3146,7 @@ async function loadAll() {
}
}
el("refreshBtn").addEventListener("click", async () => {
el("refreshBtn")?.addEventListener("click", async () => {
try {
state.viewInitialized = false;
await loadAll();
@@ -3398,7 +3389,7 @@ window.addEventListener("keyup", (evt) => {
if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan");
});
saveLayoutBtn.addEventListener("click", async () => {
saveLayoutBtn?.addEventListener("click", async () => {
try {
await saveCurrentLayout();
setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`);

View File

@@ -12,10 +12,8 @@
const loginPinErrorEl = el("loginPinError");
const loginTabPasswordEl = el("loginTabPassword");
const loginTabPinEl = el("loginTabPin");
const userMenuBtnEl = el("userMenuBtn");
const userMenuPanelEl = el("userMenuPanel");
const userMenuNameEl = el("userMenuName");
const userMenuGroupEl = el("userMenuGroup");
const userMenuBtnEl = el("mirUserBtn");
const userMenuPanelEl = el("mirUserPanel");
const changePasswordDialogEl = el("changePasswordDialog");
const changePasswordFormEl = el("changePasswordForm");
const changePasswordErrorEl = el("changePasswordError");
@@ -168,17 +166,21 @@
function updateUserMenu() {
if (!currentUser) return;
if (userMenuNameEl) userMenuNameEl.textContent = currentUser.display_name || currentUser.username || "—";
if (userMenuGroupEl) userMenuGroupEl.textContent = currentUser.group_name || "—";
if (window.TopbarApp?.updateUserMenu) {
window.TopbarApp.updateUserMenu(currentUser);
return;
}
if (userMenuBtnEl) {
const label = currentUser.display_name || currentUser.username || "User";
userMenuBtnEl.textContent = label;
userMenuBtnEl.title = `${label} (${currentUser.group_name || ""})`;
const label = (currentUser.group_name || "USER").toUpperCase();
userMenuBtnEl.title = `${currentUser.display_name || currentUser.username} (${currentUser.group_name || ""})`;
const labelEl = el("mirUserLabel");
if (labelEl) labelEl.textContent = label;
}
}
function unlockApp() {
setLoginLoading(false);
document.body.classList.remove("auth-logged-out");
if (loginScreenEl) {
loginScreenEl.setAttribute("hidden", "");
loginScreenEl.style.display = "none";
@@ -188,14 +190,16 @@
shellEl.style.display = "";
}
applyNavPermissions();
updateUserMenu();
ready = true;
window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } }));
updateUserMenu();
}
function lockApp() {
ready = false;
currentUser = null;
document.body.classList.add("auth-logged-out");
window.TopbarApp?.hideJoystickOverlay?.();
if (shellEl) shellEl.classList.add("auth-locked");
if (loginScreenEl) {
loginScreenEl.removeAttribute("hidden");
@@ -239,6 +243,12 @@
}
async function logout() {
try {
await window.TopbarApp?.disengageJoystick?.();
} catch {
/* ignore */
}
window.TopbarApp?.hideJoystickOverlay?.();
try {
await apiJson("/api/auth/logout", { method: "POST", body: "{}" });
} catch {
@@ -251,6 +261,17 @@
window.dispatchEvent(new Event("lm:auth-logout"));
}
async function saveProfile() {
const display_name = el("mirProfileDisplayName")?.value?.trim() || "";
if (!display_name) throw new Error("Tên hiển thị không được trống");
const data = await apiJson("/api/auth/profile", {
method: "PUT",
body: JSON.stringify({ display_name }),
});
currentUser = data.user;
updateUserMenu();
}
function bindEvents() {
loginTabPasswordEl?.addEventListener("click", (evt) => {
evt.preventDefault();
@@ -308,29 +329,28 @@
}
});
userMenuBtnEl?.addEventListener("click", (evt) => {
evt.stopPropagation();
const open = userMenuPanelEl?.hasAttribute("hidden");
if (open) userMenuPanelEl?.removeAttribute("hidden");
else userMenuPanelEl?.setAttribute("hidden", "");
});
document.addEventListener("click", () => {
userMenuPanelEl?.setAttribute("hidden", "");
});
el("userMenuSignOutBtn")?.addEventListener("click", (evt) => {
el("mirUserSignOutBtn")?.addEventListener("click", (evt) => {
evt.preventDefault();
logout();
});
el("userMenuChangePasswordBtn")?.addEventListener("click", (evt) => {
el("mirUserChangePasswordBtn")?.addEventListener("click", (evt) => {
evt.preventDefault();
userMenuPanelEl?.setAttribute("hidden", "");
changePasswordErrorEl && (changePasswordErrorEl.textContent = "");
changePasswordDialogEl?.showModal();
});
el("mirProfileSaveBtn")?.addEventListener("click", async (evt) => {
evt.preventDefault();
try {
await saveProfile();
userMenuPanelEl?.setAttribute("hidden", "");
} catch (e) {
alert(e.message || "Lưu thông tin thất bại");
}
});
changePasswordFormEl?.addEventListener("submit", async (evt) => {
evt.preventDefault();
const current = el("changePasswordCurrent")?.value || "";
@@ -368,5 +388,12 @@
bindEvents();
setLoginMode("password");
shellEl?.classList.add("auth-locked");
if (window.location.search) {
try {
history.replaceState({}, "", window.location.pathname);
} catch {
/* ignore */
}
}
tryRestoreSession();
})();

View File

@@ -22,7 +22,7 @@
const store = {
widgets: [],
editMode: false,
pollTimer: null,
pollActive: false,
queueUnsub: null,
};
@@ -353,13 +353,14 @@
stopDashboardPoll();
missions()?.refreshQueue?.();
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
store.pollTimer = setInterval(() => missions()?.refreshQueue?.(), 2000);
missions()?.startQueuePoll?.();
store.pollActive = true;
}
function stopDashboardPoll() {
if (store.pollTimer) {
clearInterval(store.pollTimer);
store.pollTimer = null;
if (store.pollActive) {
missions()?.stopQueuePoll?.();
store.pollActive = false;
}
if (store.queueUnsub) {
store.queueUnsub();

View File

@@ -6,7 +6,7 @@
<title>LiDAR Manager</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<body class="auth-logged-out">
<div id="loginScreen" class="loginScreen">
<div class="loginFrame">
<header class="loginHeader">
@@ -33,7 +33,7 @@
<p>Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
</div>
<div class="loginForms">
<form id="loginForm" class="loginForm" action="#" method="post">
<form id="loginForm" class="loginForm" action="#" method="post" novalidate>
<label class="loginField">
<span class="loginFieldLabel">Tên đăng nhập:</span>
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" required />
@@ -131,25 +131,95 @@
</aside>
<div class="body">
<header class="topbar">
<div class="topbarTitle">
<div class="kicker">PhenikaaX Robotics</div>
<div class="pageTitle">Cấu Hình</div>
</div>
<div class="topbarActions">
<div class="userMenuWrap">
<button id="userMenuBtn" type="button" class="btn subtle userMenuBtn" aria-haspopup="true"></button>
<div id="userMenuPanel" class="userMenuPanel" hidden>
<div class="userMenuHeader">
<div id="userMenuName" class="userMenuName"></div>
<div id="userMenuGroup" class="userMenuGroup mutedNote"></div>
</div>
<button id="userMenuChangePasswordBtn" type="button" class="userMenuItem">Đổi mật khẩu</button>
<button id="userMenuSignOutBtn" type="button" class="userMenuItem userMenuItemDanger">Đăng xuất</button>
<header class="mirTopbar" id="mirTopbar">
<div class="mirTopbarInner">
<div class="mirTopbarLeft">
<div class="mirRobotId" id="mirRobotId" title="Robot">RobotApp</div>
<button type="button" class="mirPauseBtn" id="mirSegControl" aria-label="Start / Pause robot" title="Start / Pause robot">
<svg class="mirPauseBtnIcon mirPauseBtnIcon--pause" id="mirControlIconPause" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<rect x="6" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
<rect x="13.5" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
</svg>
<svg class="mirPauseBtnIcon mirPauseBtnIcon--play" id="mirControlIconPlay" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" hidden>
<path d="M9 6.5v11l9-5.5-9-5.5z" fill="#7bed9f"/>
</svg>
</button>
<div class="mirMissionStrip" id="mirMissionStrip">
<span class="mirMissionMsg" id="mirMissionMsg"></span>
<span class="mirStatePill" id="mirControlPill">PAUSED</span>
</div>
</div>
<div class="mirTopbarRight">
<button type="button" class="mirSegment mirSegment--status" id="mirSegStatus" aria-haspopup="true" aria-expanded="false">
<svg class="mirSvgIcon mirStatusSvg is-ok" id="mirStatusIcon" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 10.2l2.4 2.4L14 7.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirSegmentLabel" id="mirStatusLabel" data-i18n="topbar.allOk">ALL OK</span>
<span class="mirCaret" aria-hidden="true"></span>
</button>
<div class="mirPanel mirPanel--status" id="mirStatusPanel" hidden>
<div class="mirPanelBody" id="mirStatusPanelBody"></div>
<div class="mirPanelFooter" id="mirStatusPanelFooter" hidden>
<button type="button" class="mirBtn mirBtn--reset" id="mirErrorResetBtn" data-i18n="topbar.reset">RESET</button>
</div>
</div>
<button type="button" class="mirSegment mirSegment--locale" id="mirSegLocale" aria-haspopup="true" aria-expanded="false">
<span class="mirFlag" id="mirLocaleFlag" aria-hidden="true">🇻🇳</span>
<span class="mirSegmentLabel" id="mirLocaleLabel">TIẾNG VIỆT</span>
<span class="mirCaret" aria-hidden="true"></span>
</button>
<div class="mirPanel mirPanel--locale" id="mirLocalePanel" hidden>
<button type="button" class="mirLocaleOption" data-locale="vi">🇻🇳 Tiếng Việt</button>
<button type="button" class="mirLocaleOption" data-locale="en">🇺🇸 English</button>
</div>
<button type="button" class="mirSegment mirSegment--user" id="mirUserBtn" aria-haspopup="true" aria-expanded="false">
<svg class="mirSvgIcon mirUserSvg" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="8" r="4" fill="currentColor"/>
<path d="M5 20c0-4 3.5-6 7-6s7 2 7 6" fill="currentColor"/>
</svg>
<span class="mirSegmentLabel" id="mirUserLabel">USER</span>
<span class="mirCaret" aria-hidden="true"></span>
</button>
<div class="mirPanel mirPanel--user" id="mirUserPanel" hidden>
<div class="mirUserPanelHeader">
<div class="mirUserPanelAvatar" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="#64748b"><circle cx="12" cy="8" r="4"/><path d="M5 20c0-4 3.5-6 7-6s7 2 7 6"/></svg>
</div>
<div>
<div class="mirUserPanelRole" id="mirUserPanelRole"></div>
<div class="mirUserPanelName mutedNote" id="mirUserPanelName"></div>
</div>
</div>
<label class="mirProfileField">
<span data-i18n="topbar.displayName">Tên hiển thị</span>
<input id="mirProfileDisplayName" type="text" autocomplete="name" />
</label>
<button type="button" class="mirBtn mirBtn--primary" id="mirProfileSaveBtn" data-i18n="topbar.changeUserData">Đổi thông tin</button>
<button type="button" class="mirBtn mirBtn--primary subtle" id="mirUserChangePasswordBtn" data-i18n="topbar.changePassword">Đổi mật khẩu</button>
<button type="button" class="mirBtn mirBtn--danger" id="mirUserSignOutBtn" data-i18n="topbar.logout">Đăng xuất</button>
</div>
<button type="button" class="mirSegment mirSegment--joystick" id="mirSegJoystick" title="Engage joystick" aria-label="Joystick">
<svg class="mirSvgIcon mirJoystickSvg" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<rect x="7" y="10" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<line x1="12" y1="10" x2="12" y2="4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<circle cx="12" cy="3" r="2.2" fill="currentColor"/>
</svg>
</button>
<div class="mirSegment mirSegment--battery" id="mirSegBattery" title="Battery">
<span class="mirBatteryIcon" id="mirBatteryIcon" aria-hidden="true">
<span class="mirBatteryLevel" id="mirBatteryLevel"></span>
</span>
<span class="mirSegmentLabel mirBatteryPct" id="mirBatteryLabel">—%</span>
</div>
</div>
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
<button id="saveLayoutBtn" class="btn primary" type="button">Lưu layout</button>
</div>
</header>
@@ -210,6 +280,10 @@
<div class="cardTitle">Quản lý layout</div>
<div class="cardSub">Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.</div>
</div>
<div class="configPageActions">
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
<button id="saveLayoutBtn" type="button" class="btn primary">Lưu layout</button>
</div>
</div>
<div class="cardBody">
<div class="row rowWide">
@@ -1042,8 +1116,34 @@ GET /api/v2.0.0/status</pre>
</form>
</dialog>
<div id="joystickOverlay" class="joystickOverlay" hidden>
<div class="joystickOverlayCard">
<div class="joystickOverlayHeader">
<strong data-i18n="topbar.joystickTitle">Điều khiển tay (Joystick)</strong>
<span class="mutedNote" id="joystickSpeedLabel">fast</span>
</div>
<div class="joystickPadWrap">
<div class="joystickPad" id="joystickPad">
<div class="joystickStick" id="joystickStick"></div>
</div>
</div>
<div class="joystickOverlayActions">
<label class="joystickSpeedSelect">
<span data-i18n="topbar.joystickSpeed">Tốc độ</span>
<select id="joystickSpeedSelect">
<option value="slow">Slow</option>
<option value="medium">Medium</option>
<option value="fast" selected>Fast</option>
</select>
</label>
<button type="button" class="mirBtn mirBtn--danger" id="joystickDisengageBtn" data-i18n="topbar.joystickOff">Tắt joystick</button>
</div>
</div>
</div>
<script src="/auth.js"></script>
<script src="/missions.js"></script>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>
<script src="/integrations.js"></script>
<script src="/app.js"></script>

View File

@@ -87,10 +87,12 @@
configListPath: "root",
queue: [],
runner: { state: "idle", message: "" },
queuePollTimer: null,
pendingQueueMissionId: null,
};
let queuePollRefs = 0;
let queuePollTimer = null;
function newId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -714,15 +716,27 @@
function startQueuePoll() {
if (window.AuthApp && !window.AuthApp.isReady()) return;
stopQueuePoll();
refreshQueue();
store.queuePollTimer = setInterval(refreshQueue, 1500);
queuePollRefs += 1;
if (queuePollRefs === 1) {
refreshQueue();
queuePollTimer = setInterval(refreshQueue, 1500);
}
}
function stopQueuePoll() {
if (store.queuePollTimer) {
clearInterval(store.queuePollTimer);
store.queuePollTimer = null;
if (queuePollRefs <= 0) return;
queuePollRefs -= 1;
if (queuePollRefs === 0 && queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
}
}
function stopQueuePollForce() {
queuePollRefs = 0;
if (queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
}
}
@@ -1381,5 +1395,5 @@
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePoll);
window.addEventListener("lm:auth-logout", stopQueuePollForce);
})();

View File

@@ -118,21 +118,397 @@ body {
.body {
display: grid;
grid-template-rows: 72px 1fr;
grid-template-rows: auto 1fr;
min-width: 0;
}
.topbar {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 18px;
/* —— MiR-style top bar —— */
.mirTopbar {
background: linear-gradient(180deg, #4d7fbe 0%, #2f5f9e 48%, #254f87 100%);
color: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.22);
position: relative;
z-index: 40;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.mirTopbarInner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 12px;
min-height: 52px;
padding: 0 10px 0 14px;
overflow-x: auto;
overscroll-behavior: contain;
}
.mirTopbarLeft {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
}
.mirTopbarRight {
display: flex;
align-items: stretch;
flex-shrink: 0;
margin-left: auto;
}
.mirRobotId {
font-size: 22px;
font-weight: 800;
letter-spacing: 0.02em;
color: #fff;
padding-right: 6px;
flex-shrink: 0;
}
.mirPauseBtn {
appearance: none;
border: 1px solid rgba(0, 0, 0, 0.28);
background: rgba(16, 42, 82, 0.72);
border-radius: 6px;
width: 42px;
height: 42px;
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.mirPauseBtn:hover:not(:disabled) {
background: rgba(16, 42, 82, 0.9);
}
.mirPauseBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mirPauseBtnIcon { display: block; }
.mirPauseBtnIcon[hidden] { display: none !important; }
.mirMissionStrip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: 1 1 auto;
min-width: 0;
max-width: 520px;
padding: 7px 8px 7px 16px;
border-radius: 999px;
background: rgba(14, 36, 72, 0.72);
border: 1px solid rgba(0, 0, 0, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.mirMissionMsg {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.mirStatePill {
flex-shrink: 0;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
padding: 5px 12px;
border-radius: 999px;
background: #e67e22;
color: #fff;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15);
}
.mirStatePill.is-running { background: #3dba6a; }
.mirStatePill.is-paused { background: #e67e22; }
.mirStatePill.is-error { background: #c0392b; }
.mirSegment {
appearance: none;
border: 0;
background: transparent;
color: inherit;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
border-left: 1px solid rgba(255, 255, 255, 0.18);
cursor: pointer;
font: inherit;
white-space: nowrap;
flex-shrink: 0;
min-height: 52px;
}
.mirTopbarRight > .mirPanel {
top: 100%;
}
.mirSegment:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.mirSegment:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.07);
}
.mirSegment--battery {
cursor: default;
border-left: 1px solid rgba(255, 255, 255, 0.18);
}
.mirSegment--battery:hover {
background: transparent;
}
.mirSegment--joystick.is-active {
background: rgba(255, 255, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.mirSegment--status.is-error {
background: rgba(192, 57, 43, 0.35);
}
.mirSvgIcon {
display: block;
flex-shrink: 0;
color: #fff;
}
.mirStatusSvg.is-ok { color: #6ee7a0; }
.mirStatusSvg.is-error { color: #ff8a8a; }
.mirFlag {
font-size: 16px;
line-height: 1;
}
.mirSegmentLabel {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.mirBatteryPct {
font-size: 12px;
letter-spacing: 0.02em;
text-transform: none;
font-weight: 700;
}
.mirCaret {
font-size: 9px;
opacity: 0.85;
margin-left: 2px;
line-height: 1;
}
.mirPanel {
position: absolute;
top: calc(100% - 2px);
min-width: 280px;
background: #ececec;
color: #1f2937;
border: 1px solid #c5c5c5;
border-radius: 2px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
z-index: 50;
}
.mirPanel[hidden] {
display: none !important;
}
.mirPanel--status { right: 280px; left: auto; }
.mirPanel--locale { right: 140px; left: auto; }
.mirPanel--user { right: 8px; left: auto; }
.mirPanelBody { padding: 14px 16px; font-size: 13px; }
.mirStatusOkTitle, .mirStatusErrorTitle {
font-weight: 800;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.mirStatusErrorTitle { color: #c0392b; }
.mirStatusDesc { color: #4b5563; line-height: 1.45; }
.mirStatusRow { font-size: 12px; margin-bottom: 4px; }
.mirStatusMeta { margin-top: 8px; font-size: 12px; color: #6b7280; }
.mirPanelFooter {
padding: 0 16px 14px;
display: flex;
justify-content: flex-end;
}
.mirPanel--locale {
flex-direction: column;
padding: 6px;
min-width: 200px;
}
.mirPanel--locale:not([hidden]) {
display: flex;
}
.mirLocaleOption {
appearance: none;
border: 0;
background: transparent;
text-align: left;
padding: 10px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.mirLocaleOption:hover { background: rgba(0, 0, 0, 0.06); }
.mirUserPanelHeader {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px 8px;
}
.mirUserPanelAvatar {
width: 42px;
height: 42px;
border-radius: 999px;
background: #d1d5db;
display: grid;
place-items: center;
font-size: 20px;
}
.mirUserPanelRole {
font-weight: 800;
letter-spacing: 0.05em;
}
.mirProfileField {
display: grid;
gap: 6px;
padding: 0 16px 10px;
font-size: 12px;
color: #4b5563;
}
.mirProfileField input {
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
}
.mirPanel--user .mirBtn {
margin: 0 16px 8px;
width: calc(100% - 32px);
}
.mirBtn {
appearance: none;
border: 0;
border-radius: 4px;
padding: 10px 14px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
cursor: pointer;
}
.mirBtn--primary { background: #2980b9; color: #fff; }
.mirBtn--primary.subtle { background: #5dade2; }
.mirBtn--danger { background: #c0392b; color: #fff; }
.mirBtn--reset { background: #7f8c8d; color: #fff; min-width: 96px; }
.mirBatteryIcon {
width: 28px;
height: 13px;
border: 2px solid rgba(255, 255, 255, 0.95);
border-radius: 2px;
position: relative;
padding: 1px;
box-sizing: border-box;
flex-shrink: 0;
}
.mirBatteryIcon::after {
content: "";
position: absolute;
right: -5px;
top: 3px;
width: 3px;
height: 6px;
background: rgba(255, 255, 255, 0.9);
border-radius: 0 2px 2px 0;
}
.mirBatteryLevel {
display: block;
height: 100%;
width: 54%;
background: #fff;
border-radius: 1px;
}
.mirSegment--battery.is-low .mirBatteryLevel { background: #ffb4b4; }
.mirSegment--battery.is-mid .mirBatteryLevel { background: #ffe08a; }
.configPageActions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.joystickOverlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 80;
display: grid;
place-items: center;
padding: 20px;
}
.joystickOverlay[hidden],
body.auth-logged-out .joystickOverlay {
display: none !important;
pointer-events: none !important;
}
.joystickOverlayCard {
background: #fff;
border-radius: 16px;
padding: 18px;
width: min(360px, 100%);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
}
.joystickOverlayHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.joystickPadWrap { display: grid; place-items: center; padding: 8px 0 14px; }
.joystickPad {
width: 200px;
height: 200px;
border-radius: 999px;
background: radial-gradient(circle at 50% 50%, #f8fafc 0%, #e2e8f0 100%);
border: 2px solid #cbd5e1;
position: relative;
touch-action: none;
}
.joystickStick {
position: absolute;
left: 50%;
top: 50%;
width: 56px;
height: 56px;
margin: -28px 0 0 -28px;
border-radius: 999px;
background: linear-gradient(180deg, #3b82f6, #1d4ed8);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);
}
.joystickOverlayActions {
display: flex;
gap: 10px;
align-items: end;
justify-content: space-between;
}
.joystickSpeedSelect {
display: grid;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.joystickSpeedSelect select {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
}
.mirTopbar--no-missions .mirTopbarLeft {
visibility: hidden;
width: 0;
overflow: hidden;
flex: 0;
padding: 0;
}
.mirTopbar--no-missions .mirTopbarRight .mirSegment--joystick,
.mirTopbar--no-missions .mirTopbarRight .mirSegment--battery {
display: none;
}
.kicker { font-size: 12px; color: var(--muted); }
.pageTitle { font-size: 16px; font-weight: 800; letter-spacing: 0.2px; margin-top: 2px; }
.topbarActions { display: flex; gap: 10px; align-items: center; }
.content {
padding: 18px;
@@ -986,6 +1362,13 @@ canvas {
.dashboardInfoCard .dashboardInfoGrid { display: grid; gap: 8px; }
.dashboardEmpty { text-align: center; padding: 12px 0 0; }
@media (max-width: 1100px) {
.mirMissionStrip { max-width: 280px; }
.mirPanel--status { right: 8px; left: 8px; }
.mirPanel--locale { right: 8px; left: 8px; }
.mirPanel--user { right: 8px; left: auto; }
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: relative; height: auto; }

524
www/topbar.js Normal file
View File

@@ -0,0 +1,524 @@
(() => {
const el = (id) => document.getElementById(id);
const I18N = {
vi: {
"topbar.allOk": "ỔN ĐỊNH",
"topbar.error": "LỖI",
"topbar.paused": "TẠM DỪNG",
"topbar.running": "ĐANG CHẠY",
"topbar.waiting": "Đang chờ mission mới…",
"topbar.noMissionsQueue": "Không có mission trong queue…",
"topbar.reset": "RESET",
"topbar.changeUserData": "Lưu thông tin",
"topbar.changePassword": "Đổi mật khẩu",
"topbar.logout": "Đăng xuất",
"topbar.displayName": "Tên hiển thị",
"topbar.reload": "Tải lại",
"topbar.saveLayout": "Lưu layout",
"topbar.joystickTitle": "Điều khiển tay (Joystick)",
"topbar.joystickSpeed": "Tốc độ",
"topbar.joystickOff": "Tắt joystick",
"topbar.localeVi": "TIẾNG VIỆT",
"topbar.localeEn": "ENGLISH",
"topbar.startHint": "Bấm để START robot",
"topbar.pauseHint": "Bấm để PAUSE robot",
"topbar.code": "Mã",
"topbar.module": "Module",
},
en: {
"topbar.allOk": "ALL OK",
"topbar.error": "ERROR",
"topbar.paused": "PAUSED",
"topbar.running": "RUNNING",
"topbar.waiting": "Waiting for new missions…",
"topbar.noMissionsQueue": "No missions in queue…",
"topbar.reset": "RESET",
"topbar.changeUserData": "Change user data",
"topbar.changePassword": "Change password",
"topbar.logout": "Log out",
"topbar.displayName": "Display name",
"topbar.reload": "Reload",
"topbar.saveLayout": "Save layout",
"topbar.joystickTitle": "Manual control (Joystick)",
"topbar.joystickSpeed": "Speed",
"topbar.joystickOff": "Disengage joystick",
"topbar.localeVi": "TIẾNG VIỆT",
"topbar.localeEn": "ENGLISH",
"topbar.startHint": "Click to START the robot",
"topbar.pauseHint": "Click to PAUSE the robot",
"topbar.code": "Code",
"topbar.module": "Module",
},
};
const LOCALE_META = {
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
};
let locale = "vi";
let robotStatus = null;
let pollTimer = null;
let eventsBound = false;
let openPanel = null;
let joystickActive = false;
let joystickPointerId = null;
let joystickRaf = null;
let lastCmd = { linear: 0, angular: 0 };
function t(key) {
return I18N[locale]?.[key] ?? I18N.en[key] ?? key;
}
function canSeeMissions() {
return window.AuthApp?.canAccessPage?.("missions");
}
function canControl() {
return window.AuthApp?.canWrite?.("missions");
}
async function apiJson(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function applyLocale(next) {
locale = LOCALE_META[next] ? next : "vi";
try {
localStorage.setItem("lm_locale", locale);
} catch {
/* ignore */
}
document.documentElement.lang = locale;
document.querySelectorAll("[data-i18n]").forEach((node) => {
const key = node.dataset.i18n;
if (key) node.textContent = t(key);
});
const meta = LOCALE_META[locale];
if (el("mirLocaleFlag")) el("mirLocaleFlag").textContent = meta.flag;
if (el("mirLocaleLabel")) el("mirLocaleLabel").textContent = t(meta.labelKey);
window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } }));
if (robotStatus) renderAll(robotStatus);
}
function loadLocale() {
try {
const saved = localStorage.getItem("lm_locale");
if (saved && LOCALE_META[saved]) locale = saved;
} catch {
/* ignore */
}
applyLocale(locale);
}
function closePanels() {
document.querySelectorAll(".mirPanel").forEach((p) => {
p.hidden = true;
});
document.querySelectorAll(".mirSegment[aria-haspopup='true']").forEach((btn) => {
btn.setAttribute("aria-expanded", "false");
});
openPanel = null;
}
function togglePanel(btn, panel) {
if (!btn || !panel) return;
const isOpen = btn.getAttribute("aria-expanded") === "true";
closePanels();
if (!isOpen) {
panel.hidden = false;
btn.setAttribute("aria-expanded", "true");
openPanel = panel;
}
}
function missionStripMessage(status) {
const pending = Number(status.queue_pending) || 0;
const runnerState = status.runner?.state || "idle";
const msg = status.message || "";
if (runnerState === "running" || runnerState === "paused") {
if (msg && msg !== t("topbar.waiting")) return msg;
const name = status.runner?.current_action;
if (name) return String(name);
}
if (pending === 0 && runnerState === "idle") return t("topbar.noMissionsQueue");
if (msg && msg !== t("topbar.waiting")) return msg;
return t("topbar.waiting");
}
function renderControl(status) {
const motion = status.motion || "paused";
const running = motion === "running";
const runnerState = status.runner?.state || "idle";
const isError = status.health === "error" || runnerState === "error";
const pauseIcon = el("mirControlIconPause");
const playIcon = el("mirControlIconPlay");
const pillEl = el("mirControlPill");
const msgEl = el("mirMissionMsg");
const btnEl = el("mirSegControl");
const stripEl = el("mirMissionStrip");
if (pauseIcon && playIcon) {
pauseIcon.hidden = !running;
playIcon.hidden = running;
}
if (pillEl) {
pillEl.textContent = isError ? t("topbar.error") : running ? t("topbar.running") : t("topbar.paused");
pillEl.classList.toggle("is-running", running && !isError);
pillEl.classList.toggle("is-paused", !running && !isError);
pillEl.classList.toggle("is-error", isError);
}
if (msgEl) msgEl.textContent = missionStripMessage(status);
if (stripEl) stripEl.classList.toggle("is-error", isError);
if (btnEl) {
btnEl.disabled = !canControl() || status.health === "error";
btnEl.title = running ? t("topbar.pauseHint") : t("topbar.startHint");
btnEl.classList.toggle("is-readonly", !canControl());
}
}
function renderStatus(status) {
const health = status.health || "ok";
const runnerState = status.runner?.state || "idle";
const isError = health === "error" || runnerState === "error";
const labelEl = el("mirStatusLabel");
const iconEl = el("mirStatusIcon");
const bodyEl = el("mirStatusPanelBody");
const footerEl = el("mirStatusPanelFooter");
const segEl = el("mirSegStatus");
if (labelEl) labelEl.textContent = isError ? t("topbar.error") : t("topbar.allOk");
if (iconEl) {
iconEl.classList.toggle("is-ok", !isError);
iconEl.classList.toggle("is-error", isError);
}
if (segEl) segEl.classList.toggle("is-error", isError);
if (!bodyEl) return;
const err = status.error && typeof status.error === "object" ? status.error : null;
const runnerErr = runnerState === "error" ? status.runner?.message : "";
const message = status.message || t("topbar.waiting");
if (isError && (err || runnerErr)) {
bodyEl.innerHTML = `
<div class="mirStatusErrorTitle">${t("topbar.error")}</div>
${err?.code != null ? `<div class="mirStatusRow"><span>${t("topbar.code")}:</span> <strong>${err.code}</strong></div>` : ""}
${err?.module ? `<div class="mirStatusRow"><span>${t("topbar.module")}:</span> ${err.module}</div>` : ""}
<div class="mirStatusDesc">${err?.description || runnerErr || message}</div>`;
if (footerEl) footerEl.hidden = !canControl();
} else {
bodyEl.innerHTML = `
<div class="mirStatusOkTitle">${t("topbar.allOk")}</div>
<div class="mirStatusDesc">${message}</div>
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${status.queue_pending} mission(s) in queue</div>` : ""}`;
if (footerEl) footerEl.hidden = true;
}
}
function renderBattery(status) {
const pct = Math.max(0, Math.min(100, Number(status.battery_percent) || 0));
const labelEl = el("mirBatteryLabel");
const levelEl = el("mirBatteryLevel");
const segEl = el("mirSegBattery");
if (labelEl) labelEl.textContent = `${pct}%`;
if (levelEl) levelEl.style.width = `${pct}%`;
if (segEl) {
segEl.classList.toggle("is-low", pct < 20);
segEl.classList.toggle("is-mid", pct >= 20 && pct < 50);
segEl.classList.toggle("is-charging", !!status.battery_charging);
}
}
function hideJoystickOverlay() {
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = true;
joystickActive = false;
const stick = el("joystickStick");
if (stick) stick.style.transform = "translate(0, 0)";
lastCmd = { linear: 0, angular: 0 };
}
function renderJoystick(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
const seg = el("mirSegJoystick");
const engaged = !!status.joystick_engaged;
if (seg) seg.classList.toggle("is-active", engaged);
const overlay = el("joystickOverlay");
if (overlay) overlay.hidden = !engaged;
joystickActive = engaged;
const speedSel = el("joystickSpeedSelect");
if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed;
if (el("joystickSpeedLabel")) el("joystickSpeedLabel").textContent = status.joystick_speed || "fast";
}
function renderAll(status) {
if (!window.AuthApp?.isReady?.()) {
hideJoystickOverlay();
return;
}
robotStatus = status;
if (!canSeeMissions()) {
el("mirTopbar")?.classList.add("mirTopbar--no-missions");
return;
}
el("mirTopbar")?.classList.remove("mirTopbar--no-missions");
renderControl(status);
renderStatus(status);
renderBattery(status);
renderJoystick(status);
}
async function fetchStatus() {
if (!window.AuthApp?.isReady() || !canSeeMissions()) return;
try {
const data = await apiJson("/api/robot/status");
renderAll(data);
window.dispatchEvent(new CustomEvent("lm:robot-status", { detail: data }));
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
}
}
function startPoll() {
stopPoll();
fetchStatus();
pollTimer = setInterval(fetchStatus, 1500);
window.MissionsApp?.startQueuePoll?.();
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
window.MissionsApp?.stopQueuePoll?.();
}
async function toggleRobotMotion() {
if (!robotStatus || !canControl()) return;
const running = robotStatus.motion === "running";
const path = running ? "/api/robot/pause" : "/api/robot/start";
const data = await apiJson(path, { method: "POST", body: "{}" });
renderAll(data);
}
async function resetError() {
const data = await apiJson("/api/robot/errors/reset", { method: "POST", body: "{}" });
renderAll(data);
closePanels();
}
async function engageJoystick(engaged, speed) {
const payload = { engaged };
if (speed) payload.speed = speed;
const data = await apiJson("/api/robot/joystick", {
method: "POST",
body: JSON.stringify(payload),
});
renderAll(data);
}
function sendCmdVel(linear, angular) {
if (!joystickActive) return;
if (Math.abs(linear - lastCmd.linear) < 0.02 && Math.abs(angular - lastCmd.angular) < 0.02) return;
lastCmd = { linear, angular };
apiJson("/api/robot/cmd_vel", {
method: "POST",
body: JSON.stringify({ linear, angular }),
}).catch(() => {});
}
function bindJoystickPad() {
const pad = el("joystickPad");
const stick = el("joystickStick");
if (!pad || !stick) return;
const center = () => {
const r = pad.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / 2 - 24 };
};
const moveStick = (clientX, clientY) => {
const c = center();
let dx = clientX - c.x;
let dy = clientY - c.y;
const dist = Math.hypot(dx, dy);
if (dist > c.radius) {
dx = (dx / dist) * c.radius;
dy = (dy / dist) * c.radius;
}
stick.style.transform = `translate(${dx}px, ${dy}px)`;
const linear = -dy / c.radius;
const angular = dx / c.radius;
if (joystickRaf) cancelAnimationFrame(joystickRaf);
joystickRaf = requestAnimationFrame(() => sendCmdVel(linear, angular));
};
const resetStick = () => {
stick.style.transform = "translate(0, 0)";
sendCmdVel(0, 0);
lastCmd = { linear: 0, angular: 0 };
};
const onDown = (evt) => {
if (!joystickActive) return;
joystickPointerId = evt.pointerId;
pad.setPointerCapture(evt.pointerId);
moveStick(evt.clientX, evt.clientY);
};
const onMove = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
moveStick(evt.clientX, evt.clientY);
};
const onUp = (evt) => {
if (evt.pointerId !== joystickPointerId) return;
joystickPointerId = null;
resetStick();
};
pad.addEventListener("pointerdown", onDown);
pad.addEventListener("pointermove", onMove);
pad.addEventListener("pointerup", onUp);
pad.addEventListener("pointercancel", onUp);
}
function bindEvents() {
if (eventsBound) return;
eventsBound = true;
el("mirSegControl")?.addEventListener("click", () => {
toggleRobotMotion().catch((e) => alert(e.message));
});
el("mirSegStatus")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegStatus"), el("mirStatusPanel"));
});
el("mirSegLocale")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirSegLocale"), el("mirLocalePanel"));
});
el("mirUserBtn")?.addEventListener("click", (evt) => {
evt.stopPropagation();
togglePanel(el("mirUserBtn"), el("mirUserPanel"));
});
el("mirErrorResetBtn")?.addEventListener("click", () => {
resetError().catch((e) => alert(e.message));
});
document.querySelectorAll(".mirLocaleOption").forEach((btn) => {
btn.addEventListener("click", (evt) => {
evt.stopPropagation();
applyLocale(btn.dataset.locale || "vi");
closePanels();
});
});
el("mirSegJoystick")?.addEventListener("click", async () => {
if (!canControl()) {
alert(locale === "vi" ? "Không có quyền điều khiển" : "No control permission");
return;
}
try {
if (robotStatus?.joystick_engaged) await engageJoystick(false);
else await engageJoystick(true, el("joystickSpeedSelect")?.value || "fast");
} catch (e) {
alert(e.message);
}
});
el("joystickDisengageBtn")?.addEventListener("click", () => {
engageJoystick(false).catch((e) => alert(e.message));
});
el("joystickSpeedSelect")?.addEventListener("change", (evt) => {
if (robotStatus?.joystick_engaged) {
engageJoystick(true, evt.target.value).catch(() => {});
}
});
document.addEventListener("click", (evt) => {
if (evt.target.closest(".mirSegment") || evt.target.closest(".mirPanel")) return;
closePanels();
});
document.addEventListener("keydown", (evt) => {
if (evt.key === "Escape") {
closePanels();
if (robotStatus?.joystick_engaged) engageJoystick(false).catch(() => {});
}
});
bindJoystickPad();
}
function start() {
loadLocale();
bindEvents();
if (!window.AuthApp?.isReady()) return;
startPoll();
}
function stop() {
stopPoll();
closePanels();
hideJoystickOverlay();
if (window.AuthApp?.isReady?.()) {
engageJoystick(false).catch(() => {});
}
}
async function disengageJoystick() {
hideJoystickOverlay();
if (!window.AuthApp?.isReady?.()) return;
try {
await engageJoystick(false);
} catch {
/* session may already be gone */
}
}
window.TopbarApp = {
t,
getLocale: () => locale,
applyLocale,
refresh: fetchStatus,
getRobotStatus: () => robotStatus,
hideJoystickOverlay,
disengageJoystick,
updateUserMenu(user) {
const role = (user?.group_name || "USER").toUpperCase();
if (el("mirUserLabel")) el("mirUserLabel").textContent = role;
if (el("mirUserPanelRole")) el("mirUserPanelRole").textContent = role;
if (el("mirUserPanelName")) el("mirUserPanelName").textContent = user?.display_name || user?.username || "—";
if (el("mirProfileDisplayName")) el("mirProfileDisplayName").value = user?.display_name || user?.username || "";
},
};
if (window.AuthApp?.isReady()) start();
else window.addEventListener("lm:auth-ready", () => start(), { once: true });
window.addEventListener("lm:auth-logout", () => stop());
hideJoystickOverlay();
})();