add top bar
This commit is contained in:
@@ -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
13
data/robot_runtime.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
265
src/robot/robot_runtime.cpp
Normal 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
|
||||
38
src/robot/robot_runtime.hpp
Normal file
38
src/robot/robot_runtime.hpp
Normal 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
|
||||
85
src/server/api_robot_routes.cpp
Normal file
85
src/server/api_robot_routes.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
13
www/app.js
13
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 || ""}»`);
|
||||
|
||||
73
www/auth.js
73
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();
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
|
||||
138
www/index.html
138
www/index.html
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
401
www/style.css
401
www/style.css
@@ -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
524
www/topbar.js
Normal 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user