diff --git a/CMakeLists.txt b/CMakeLists.txt index 7bb4535..09ac2d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/data/robot_runtime.json b/data/robot_runtime.json new file mode 100644 index 0000000..f694bbd --- /dev/null +++ b/data/robot_runtime.json @@ -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" +} \ No newline at end of file diff --git a/scripts/test/smoke.sh b/scripts/test/smoke.sh index e4ecaaf..fc7117a 100755 --- a/scripts/test/smoke.sh +++ b/scripts/test/smoke.sh @@ -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" diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index d0e7243..35d31a8 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -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_); diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp index 7754163..5adddf0 100644 --- a/src/auth/auth_service.cpp +++ b/src/auth/auth_service.cpp @@ -157,7 +157,8 @@ std::optional 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 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 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()); + 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 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"); diff --git a/src/auth/auth_service.hpp b/src/auth/auth_service.hpp index 4fbd30c..365d728 100644 --- a/src/auth/auth_service.hpp +++ b/src/auth/auth_service.hpp @@ -40,6 +40,9 @@ public: const std::string& current_password, const std::string& new_password, std::string& err); + std::optional changeProfile(const std::string& token, + const nlohmann::json& payload, + std::string& err); nlohmann::json listGroups() const; nlohmann::json listUsers() const; diff --git a/src/robot/robot_runtime.cpp b/src/robot/robot_runtime.cpp new file mode 100644 index 0000000..ffef40c --- /dev/null +++ b/src/robot/robot_runtime.cpp @@ -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 +#include +#include + +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 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 lock(mu_); + return buildStatusUnlocked(); +} + +bool RobotRuntime::start(std::string& err) +{ + std::lock_guard 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 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 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 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 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 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(std::lround(battery)); + saveUnlocked(); +} + +} // namespace lm diff --git a/src/robot/robot_runtime.hpp b/src/robot/robot_runtime.hpp new file mode 100644 index 0000000..e361762 --- /dev/null +++ b/src/robot/robot_runtime.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include +#include +#include + +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 diff --git a/src/server/api_robot_routes.cpp b/src/server/api_robot_routes.cpp new file mode 100644 index 0000000..b23d173 --- /dev/null +++ b/src/server/api_robot_routes.cpp @@ -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(), 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 diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 764441a..3d70ed4 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -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 diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index ea8babc..43c35ae 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -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 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 diff --git a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc index ddc6c53..25299be 100644 Binary files a/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc and b/tests/__pycache__/test_api_integration.cpython-38-pytest-8.3.5.pyc differ diff --git a/www/app.js b/www/app.js index e68aca4..ad9212d 100644 --- a/www/app.js +++ b/www/app.js @@ -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 || ""}»`); diff --git a/www/auth.js b/www/auth.js index 454cb4f..d043c8b 100644 --- a/www/auth.js +++ b/www/auth.js @@ -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(); })(); diff --git a/www/dashboard.js b/www/dashboard.js index 2f1df02..f3a8cfb 100644 --- a/www/dashboard.js +++ b/www/dashboard.js @@ -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(); diff --git a/www/index.html b/www/index.html index 6917bcc..459e94c 100644 --- a/www/index.html +++ b/www/index.html @@ -6,7 +6,7 @@ LiDAR Manager - +
@@ -33,7 +33,7 @@

Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.

-
+