This commit is contained in:
@@ -45,6 +45,10 @@ add_executable(lidar_manager_web
|
||||
src/storage/site_store.cpp
|
||||
src/storage/sound_store.cpp
|
||||
src/storage/transition_store.cpp
|
||||
src/storage/io_module_store.cpp
|
||||
src/io/io_module_service.cpp
|
||||
src/io/io_zone_runtime.cpp
|
||||
src/io/io_module_usage.cpp
|
||||
src/storage/dashboard_store.cpp
|
||||
src/storage/state_repository.cpp
|
||||
src/validation/sensor_validator.cpp
|
||||
@@ -61,6 +65,7 @@ add_executable(lidar_manager_web
|
||||
src/server/api_robot_routes.cpp
|
||||
src/server/api_media_routes.cpp
|
||||
src/server/api_transition_routes.cpp
|
||||
src/server/api_io_module_routes.cpp
|
||||
src/server/api_dashboard_routes.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "app/lidar_manager_app.hpp"
|
||||
|
||||
#include "auth/auth_service.hpp"
|
||||
#include "io/io_module_service.hpp"
|
||||
#include "io/io_zone_runtime.hpp"
|
||||
#include "mission/mission_enqueue.hpp"
|
||||
#include "mission/mission_queue.hpp"
|
||||
#include "mission/mission_scheduler.hpp"
|
||||
@@ -14,6 +16,7 @@
|
||||
#include "storage/map_store.hpp"
|
||||
#include "storage/site_store.hpp"
|
||||
#include "storage/sound_store.hpp"
|
||||
#include "storage/io_module_store.hpp"
|
||||
#include "storage/transition_store.hpp"
|
||||
#include "storage/state_repository.hpp"
|
||||
|
||||
@@ -64,8 +67,11 @@ int LidarManagerApp::run()
|
||||
|
||||
MapStore map_store(database);
|
||||
TransitionStore transition_store(database);
|
||||
IoModuleStore io_module_store(database);
|
||||
IoModuleService io_module_service(io_module_store);
|
||||
IoZoneRuntime io_zone_runtime;
|
||||
MissionStore mission_store(database);
|
||||
MissionQueue mission_queue(database, map_store, transition_store, mission_store);
|
||||
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime);
|
||||
RobotRuntime robot_runtime(database, mission_queue);
|
||||
SiteStore site_store(database);
|
||||
site_store.ensureDefaultSiteId();
|
||||
@@ -104,6 +110,8 @@ int LidarManagerApp::run()
|
||||
site_store,
|
||||
sound_store,
|
||||
transition_store,
|
||||
io_module_store,
|
||||
io_module_service,
|
||||
dashboard_store);
|
||||
api.registerRoutes(svr);
|
||||
auth.registerRoutes(svr);
|
||||
|
||||
@@ -231,6 +231,8 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
|
||||
return "dashboard";
|
||||
if (path.rfind("/api/sounds", 0) == 0)
|
||||
return "sounds";
|
||||
if (path.rfind("/api/io_modules", 0) == 0)
|
||||
return "integrations";
|
||||
if (path == "/api/robot/active_map")
|
||||
return "maps";
|
||||
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
|
||||
@@ -326,6 +328,20 @@ bool AuthService::groupAllowsPinUnlocked(const std::string& group_id) const
|
||||
return group && group->value("allow_pin", false);
|
||||
}
|
||||
|
||||
bool AuthService::pinInUseUnlocked(const std::string& pin, const std::string& except_user_id) const
|
||||
{
|
||||
if (pin.size() != 4)
|
||||
return false;
|
||||
for (const auto& user : data_["users"])
|
||||
{
|
||||
if (user.value("id", "") == except_user_id)
|
||||
continue;
|
||||
if (verifyPinUnlocked(user, pin))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<AuthSession> AuthService::buildSessionUnlocked(const nlohmann::json& user)
|
||||
{
|
||||
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
|
||||
@@ -347,9 +363,11 @@ nlohmann::json AuthService::userPublicView(const nlohmann::json& user, const nlo
|
||||
return {{"id", user.value("id", "")},
|
||||
{"username", user.value("username", "")},
|
||||
{"display_name", user.value("display_name", "")},
|
||||
{"email", user.value("email", "")},
|
||||
{"group_id", user.value("group_id", "")},
|
||||
{"group_name", group.value("name", "")},
|
||||
{"permissions", group.value("permissions", nlohmann::json::object())},
|
||||
{"enabled", user.value("enabled", true)},
|
||||
{"has_pin", !user.value("pin_hash", nlohmann::json()).is_null()}};
|
||||
}
|
||||
|
||||
@@ -525,20 +543,215 @@ std::optional<nlohmann::json> AuthService::changeProfile(const std::string& toke
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool AuthService::isBuiltinGroupId(const std::string& id)
|
||||
{
|
||||
return id == "group_distributors" || id == "group_administrators" || id == "group_users";
|
||||
}
|
||||
|
||||
nlohmann::json AuthService::normalizePermissions(const nlohmann::json& perms)
|
||||
{
|
||||
static const char* kResources[] = {
|
||||
"dashboard", "config", "maps", "missions", "sounds", "integrations", "users"};
|
||||
nlohmann::json out = nlohmann::json::object();
|
||||
for (const char* key : kResources)
|
||||
{
|
||||
std::string level = "none";
|
||||
if (perms.is_object() && perms.contains(key) && perms[key].is_string())
|
||||
level = perms[key].get<std::string>();
|
||||
if (level != "none" && level != "read" && level != "write")
|
||||
level = "none";
|
||||
out[key] = level;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
nlohmann::json AuthService::groupPublicView(const nlohmann::json& group, size_t user_count)
|
||||
{
|
||||
return {{"id", group.value("id", "")},
|
||||
{"name", group.value("name", "")},
|
||||
{"allow_pin", group.value("allow_pin", false)},
|
||||
{"permissions", normalizePermissions(group.value("permissions", nlohmann::json::object()))},
|
||||
{"user_count", user_count},
|
||||
{"builtin", isBuiltinGroupId(group.value("id", ""))}};
|
||||
}
|
||||
|
||||
bool AuthService::canManageGroupUnlocked(const std::string& editor_group_id,
|
||||
const std::string& target_group_id) const
|
||||
{
|
||||
if (editor_group_id == "group_distributors")
|
||||
return true;
|
||||
return target_group_id != "group_distributors" && target_group_id != "group_administrators";
|
||||
}
|
||||
|
||||
size_t AuthService::countUsersInGroupUnlocked(const std::string& group_id) const
|
||||
{
|
||||
size_t count = 0;
|
||||
if (!data_.contains("users") || !data_["users"].is_array())
|
||||
return 0;
|
||||
for (const auto& u : data_["users"])
|
||||
{
|
||||
if (u.value("group_id", "") == group_id)
|
||||
++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const nlohmann::json* AuthService::findGroupByNameUnlocked(const std::string& name,
|
||||
const std::string& except_id) const
|
||||
{
|
||||
const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name));
|
||||
if (needle.empty() || !data_.contains("groups") || !data_["groups"].is_array())
|
||||
return nullptr;
|
||||
for (const auto& g : data_["groups"])
|
||||
{
|
||||
if (g.value("id", "") == except_id)
|
||||
continue;
|
||||
if (StringUtil::toLower(g.value("name", "")) == needle)
|
||||
return &g;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
nlohmann::json AuthService::listGroups() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
for (const auto& g : data_["groups"])
|
||||
{
|
||||
out.push_back({{"id", g.value("id", "")},
|
||||
{"name", g.value("name", "")},
|
||||
{"allow_pin", g.value("allow_pin", false)},
|
||||
{"permissions", g.value("permissions", nlohmann::json::object())}});
|
||||
const std::string id = g.value("id", "");
|
||||
out.push_back(groupPublicView(g, countUsersInGroupUnlocked(id)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> AuthService::createGroup(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||
if (name.empty())
|
||||
{
|
||||
err = "name required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const AuthSession* session = activeSession();
|
||||
if (!session)
|
||||
{
|
||||
err = "authentication required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!canManageGroupUnlocked(session->group_id, ""))
|
||||
{
|
||||
err = "insufficient permissions to create user groups";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (findGroupByNameUnlocked(name))
|
||||
{
|
||||
err = "group name already exists";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
nlohmann::json group = {{"id", "group_" + IdUtil::newId()},
|
||||
{"name", name},
|
||||
{"allow_pin", payload.value("allow_pin", false)},
|
||||
{"permissions", normalizePermissions(payload.value("permissions", nlohmann::json::object()))}};
|
||||
data_["groups"].push_back(group);
|
||||
saveUnlocked();
|
||||
return groupPublicView(group, 0);
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> AuthService::updateGroup(const std::string& id,
|
||||
const nlohmann::json& payload,
|
||||
std::string& err)
|
||||
{
|
||||
const AuthSession* session = activeSession();
|
||||
if (!session)
|
||||
{
|
||||
err = "authentication required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!canManageGroupUnlocked(session->group_id, id))
|
||||
{
|
||||
err = "insufficient permissions to edit this user group";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
for (auto& group : data_["groups"])
|
||||
{
|
||||
if (group.value("id", "") != id)
|
||||
continue;
|
||||
|
||||
if (payload.contains("name") && payload["name"].is_string())
|
||||
{
|
||||
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
||||
if (name.empty())
|
||||
{
|
||||
err = "name cannot be empty";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (findGroupByNameUnlocked(name, id))
|
||||
{
|
||||
err = "group name already exists";
|
||||
return std::nullopt;
|
||||
}
|
||||
group["name"] = name;
|
||||
}
|
||||
if (payload.contains("allow_pin") && payload["allow_pin"].is_boolean())
|
||||
group["allow_pin"] = payload["allow_pin"].get<bool>();
|
||||
if (payload.contains("permissions"))
|
||||
group["permissions"] = normalizePermissions(payload["permissions"]);
|
||||
|
||||
saveUnlocked();
|
||||
return groupPublicView(group, countUsersInGroupUnlocked(id));
|
||||
}
|
||||
|
||||
err = "group not found";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool AuthService::deleteGroup(const std::string& id, std::string& err)
|
||||
{
|
||||
const AuthSession* session = activeSession();
|
||||
if (!session)
|
||||
{
|
||||
err = "authentication required";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!canManageGroupUnlocked(session->group_id, id))
|
||||
{
|
||||
err = "insufficient permissions to delete this user group";
|
||||
return false;
|
||||
}
|
||||
if (isBuiltinGroupId(id))
|
||||
{
|
||||
err = "cannot delete built-in user group";
|
||||
return false;
|
||||
}
|
||||
if (countUsersInGroupUnlocked(id) > 0)
|
||||
{
|
||||
err = "group still has users";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto& groups = data_["groups"];
|
||||
const auto it = std::remove_if(groups.begin(), groups.end(), [&](const nlohmann::json& g) {
|
||||
return g.value("id", "") == id;
|
||||
});
|
||||
if (it == groups.end())
|
||||
{
|
||||
err = "group not found";
|
||||
return false;
|
||||
}
|
||||
groups.erase(it, groups.end());
|
||||
saveUnlocked();
|
||||
return true;
|
||||
}
|
||||
|
||||
nlohmann::json AuthService::listUsers() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
@@ -576,6 +789,8 @@ std::optional<nlohmann::json> AuthService::createUser(const nlohmann::json& payl
|
||||
|
||||
const std::string id = "user_" + IdUtil::newId();
|
||||
auto user = makeUser(id, username, password, group_id, payload.value("display_name", username));
|
||||
if (payload.contains("email") && payload["email"].is_string())
|
||||
user["email"] = StringUtil::trimCopy(payload["email"].get<std::string>());
|
||||
if (payload.contains("pin") && !payload["pin"].is_null())
|
||||
{
|
||||
const std::string pin = payload.value("pin", "");
|
||||
@@ -589,6 +804,11 @@ std::optional<nlohmann::json> AuthService::createUser(const nlohmann::json& payl
|
||||
err = "group does not allow pin";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (pinInUseUnlocked(pin))
|
||||
{
|
||||
err = "pin already in use";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string pin_salt = CryptoUtil::randomToken(16);
|
||||
user["pin_salt"] = pin_salt;
|
||||
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
|
||||
@@ -612,6 +832,29 @@ std::optional<nlohmann::json> AuthService::updateUser(const std::string& id,
|
||||
|
||||
if (payload.contains("display_name"))
|
||||
user["display_name"] = payload["display_name"];
|
||||
if (payload.contains("email"))
|
||||
{
|
||||
if (payload["email"].is_null())
|
||||
user["email"] = "";
|
||||
else if (payload["email"].is_string())
|
||||
user["email"] = StringUtil::trimCopy(payload["email"].get<std::string>());
|
||||
}
|
||||
if (payload.contains("username") && payload["username"].is_string())
|
||||
{
|
||||
const std::string username = StringUtil::trimCopy(payload["username"].get<std::string>());
|
||||
if (username.empty())
|
||||
{
|
||||
err = "username cannot be empty";
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto* existing = findUserByUsernameUnlocked(username);
|
||||
if (existing && existing->value("id", "") != id)
|
||||
{
|
||||
err = "username already exists";
|
||||
return std::nullopt;
|
||||
}
|
||||
user["username"] = username;
|
||||
}
|
||||
if (payload.contains("enabled"))
|
||||
user["enabled"] = payload["enabled"];
|
||||
if (payload.contains("group_id"))
|
||||
@@ -651,6 +894,11 @@ std::optional<nlohmann::json> AuthService::updateUser(const std::string& id,
|
||||
err = "group does not allow pin";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (pinInUseUnlocked(pin, id))
|
||||
{
|
||||
err = "pin already in use";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string pin_salt = CryptoUtil::randomToken(16);
|
||||
user["pin_salt"] = pin_salt;
|
||||
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
|
||||
@@ -854,6 +1102,62 @@ void AuthService::registerRoutes(httplib::Server& svr)
|
||||
HttpUtil::addCors(res);
|
||||
});
|
||||
|
||||
svr.Post("/api/user_groups", [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;
|
||||
}
|
||||
std::string err;
|
||||
const auto group = createGroup(body, err);
|
||||
if (!group)
|
||||
{
|
||||
HttpUtil::jsonError(res, 400, err);
|
||||
return;
|
||||
}
|
||||
res.status = 201;
|
||||
res.set_content(group->dump(), "application/json; charset=utf-8");
|
||||
HttpUtil::addCors(res);
|
||||
});
|
||||
|
||||
svr.Put(R"(/api/user_groups/([^/]+))", [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;
|
||||
}
|
||||
std::string err;
|
||||
const auto group = updateGroup(req.matches[1].str(), body, err);
|
||||
if (!group)
|
||||
{
|
||||
HttpUtil::jsonError(res, 400, err);
|
||||
return;
|
||||
}
|
||||
res.set_content(group->dump(), "application/json; charset=utf-8");
|
||||
HttpUtil::addCors(res);
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/user_groups/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
std::string err;
|
||||
if (!deleteGroup(req.matches[1].str(), err))
|
||||
{
|
||||
HttpUtil::jsonError(res, 400, err);
|
||||
return;
|
||||
}
|
||||
res.set_content(R"({"ok":true})", "application/json; charset=utf-8");
|
||||
HttpUtil::addCors(res);
|
||||
});
|
||||
|
||||
svr.Get("/api/users", [this](const httplib::Request&, httplib::Response& res) {
|
||||
nlohmann::json out = {{"users", listUsers()}};
|
||||
res.set_content(out.dump(), "application/json; charset=utf-8");
|
||||
@@ -906,8 +1210,17 @@ void AuthService::registerRoutes(httplib::Server& svr)
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
const std::string id = req.matches[1].str();
|
||||
if (const AuthSession* session = activeSession())
|
||||
{
|
||||
if (session->user_id == id)
|
||||
{
|
||||
HttpUtil::jsonError(res, 400, "cannot delete current user");
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::string err;
|
||||
if (!deleteUser(req.matches[1].str(), err))
|
||||
if (!deleteUser(id, err))
|
||||
{
|
||||
HttpUtil::jsonError(res, 400, err);
|
||||
return;
|
||||
|
||||
@@ -48,6 +48,11 @@ public:
|
||||
std::string& err);
|
||||
|
||||
nlohmann::json listGroups() const;
|
||||
std::optional<nlohmann::json> createGroup(const nlohmann::json& payload, std::string& err);
|
||||
std::optional<nlohmann::json> updateGroup(const std::string& id,
|
||||
const nlohmann::json& payload,
|
||||
std::string& err);
|
||||
bool deleteGroup(const std::string& id, std::string& err);
|
||||
nlohmann::json listUsers() const;
|
||||
std::optional<nlohmann::json> createUser(const nlohmann::json& payload, std::string& err);
|
||||
std::optional<nlohmann::json> updateUser(const std::string& id,
|
||||
@@ -80,6 +85,13 @@ private:
|
||||
bool verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const;
|
||||
bool verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const;
|
||||
bool groupAllowsPinUnlocked(const std::string& group_id) const;
|
||||
bool pinInUseUnlocked(const std::string& pin, const std::string& except_user_id = "") const;
|
||||
static bool isBuiltinGroupId(const std::string& id);
|
||||
static nlohmann::json normalizePermissions(const nlohmann::json& perms);
|
||||
static nlohmann::json groupPublicView(const nlohmann::json& group, size_t user_count);
|
||||
bool canManageGroupUnlocked(const std::string& editor_group_id, const std::string& target_group_id) const;
|
||||
size_t countUsersInGroupUnlocked(const std::string& group_id) const;
|
||||
const nlohmann::json* findGroupByNameUnlocked(const std::string& name, const std::string& except_id = "") const;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
|
||||
366
src/io/io_module_service.cpp
Normal file
366
src/io/io_module_service.cpp
Normal file
@@ -0,0 +1,366 @@
|
||||
#include "io/io_module_service.hpp"
|
||||
|
||||
#include "storage/io_module_store.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <arpa/inet.h>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <netdb.h>
|
||||
#include <sys/socket.h>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kDefaultModbusPort = 502;
|
||||
constexpr int kPortCount = 4;
|
||||
|
||||
bool parseBool(const nlohmann::json& v, bool fallback)
|
||||
{
|
||||
if (v.is_boolean())
|
||||
return v.get<bool>();
|
||||
if (v.is_string())
|
||||
{
|
||||
const std::string s = StringUtil::toLower(v.get<std::string>());
|
||||
return s == "on" || s == "true" || s == "1";
|
||||
}
|
||||
if (v.is_number())
|
||||
return v.get<int>() != 0;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int parsePort(const nlohmann::json& params, const std::string& module_type)
|
||||
{
|
||||
int port = 0;
|
||||
if (params.contains("output") && params["output"].is_number_integer())
|
||||
port = params["output"].get<int>();
|
||||
else if (params.contains("pin") && params["pin"].is_number_integer())
|
||||
port = params["pin"].get<int>();
|
||||
else if (params.contains("input") && params["input"].is_number_integer())
|
||||
port = params["input"].get<int>();
|
||||
if (!IoModuleService::validPortForType(module_type, port))
|
||||
port = IoModuleService::defaultPortForType(module_type);
|
||||
return port;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IoModuleService::IoModuleService(IoModuleStore& store) : store_(store) {}
|
||||
|
||||
bool IoModuleService::validPortForType(const std::string& module_type, int port)
|
||||
{
|
||||
if (module_type == "wise")
|
||||
return port >= 0 && port <= 3;
|
||||
return port >= 1 && port <= 4;
|
||||
}
|
||||
|
||||
int IoModuleService::defaultPortForType(const std::string& module_type)
|
||||
{
|
||||
return module_type == "wise" ? 0 : 1;
|
||||
}
|
||||
|
||||
bool IoModuleService::testConnection(const std::string& ip_address, int port, std::string& err) const
|
||||
{
|
||||
const std::string ip = StringUtil::trimCopy(ip_address);
|
||||
if (ip.empty())
|
||||
{
|
||||
err = "ip_address is required";
|
||||
return false;
|
||||
}
|
||||
if (port <= 0)
|
||||
port = kDefaultModbusPort;
|
||||
|
||||
addrinfo hints{};
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
addrinfo* res = nullptr;
|
||||
const std::string port_str = std::to_string(port);
|
||||
if (getaddrinfo(ip.c_str(), port_str.c_str(), &hints, &res) != 0)
|
||||
{
|
||||
err = "cannot resolve host";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
for (addrinfo* p = res; p; p = p->ai_next)
|
||||
{
|
||||
const int fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
|
||||
if (fd < 0)
|
||||
continue;
|
||||
const int flags = fcntl(fd, F_GETFL, 0);
|
||||
if (flags >= 0)
|
||||
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
const int rc = ::connect(fd, p->ai_addr, p->ai_addrlen);
|
||||
if (rc == 0)
|
||||
{
|
||||
ok = true;
|
||||
close(fd);
|
||||
break;
|
||||
}
|
||||
if (errno == EINPROGRESS)
|
||||
{
|
||||
fd_set wfds;
|
||||
FD_ZERO(&wfds);
|
||||
FD_SET(fd, &wfds);
|
||||
timeval tv{};
|
||||
tv.tv_sec = 2;
|
||||
tv.tv_usec = 0;
|
||||
if (select(fd + 1, nullptr, &wfds, nullptr, &tv) > 0)
|
||||
{
|
||||
int so_error = 0;
|
||||
socklen_t len = sizeof(so_error);
|
||||
getsockopt(fd, SOL_SOCKET, SO_ERROR, &so_error, &len);
|
||||
ok = (so_error == 0);
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
if (ok)
|
||||
break;
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
if (!ok)
|
||||
err = "connection failed";
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> IoModuleService::resolveModule(const std::string& ref) const
|
||||
{
|
||||
const std::string needle = StringUtil::trimCopy(ref);
|
||||
if (needle.empty())
|
||||
return std::nullopt;
|
||||
if (auto by_id = store_.find(needle))
|
||||
return by_id;
|
||||
return store_.findByName(needle);
|
||||
}
|
||||
|
||||
bool IoModuleService::connect(const std::string& module_id, std::string& err)
|
||||
{
|
||||
const auto mod = store_.find(module_id);
|
||||
if (!mod)
|
||||
{
|
||||
err = "io module not found";
|
||||
return false;
|
||||
}
|
||||
std::string test_err;
|
||||
if (!testConnection(mod->value("ip_address", ""), kDefaultModbusPort, test_err))
|
||||
{
|
||||
err = "connection test failed: " + test_err;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
connected_[module_id] = true;
|
||||
ensureSimStateUnlocked(module_id, mod->value("module_type", "bluetooth"));
|
||||
|
||||
std::string persist_err;
|
||||
if (!store_.setConnected(module_id, true, persist_err))
|
||||
{
|
||||
err = persist_err;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IoModuleService::disconnect(const std::string& module_id, std::string& err)
|
||||
{
|
||||
if (!store_.find(module_id))
|
||||
{
|
||||
err = "io module not found";
|
||||
return false;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
connected_[module_id] = false;
|
||||
}
|
||||
return store_.setConnected(module_id, false, err);
|
||||
}
|
||||
|
||||
bool IoModuleService::isConnected(const std::string& module_id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
const auto it = connected_.find(module_id);
|
||||
if (it != connected_.end())
|
||||
return it->second;
|
||||
const auto mod = store_.find(module_id);
|
||||
return mod && mod->value("connected", false);
|
||||
}
|
||||
|
||||
void IoModuleService::ensureSimStateUnlocked(const std::string& module_id, const std::string& module_type)
|
||||
{
|
||||
if (outputs_.find(module_id) == outputs_.end())
|
||||
outputs_[module_id] = std::vector<bool>(kPortCount, false);
|
||||
if (inputs_.find(module_id) == inputs_.end())
|
||||
inputs_[module_id] = std::vector<bool>(kPortCount, false);
|
||||
(void)module_type;
|
||||
}
|
||||
|
||||
bool IoModuleService::requireConnectedUnlocked(const std::string& module_id, std::string& err) const
|
||||
{
|
||||
const auto it = connected_.find(module_id);
|
||||
const bool connected = (it != connected_.end()) ? it->second : false;
|
||||
if (!connected)
|
||||
{
|
||||
const auto mod = store_.find(module_id);
|
||||
if (!mod || !mod->value("connected", false))
|
||||
{
|
||||
err = "io module is not connected";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IoModuleService::setOutput(const std::string& module_id, int port, bool on, std::string& err)
|
||||
{
|
||||
const auto mod = store_.find(module_id);
|
||||
if (!mod)
|
||||
{
|
||||
err = "io module not found";
|
||||
return false;
|
||||
}
|
||||
const std::string type = mod->value("module_type", "bluetooth");
|
||||
if (!validPortForType(type, port))
|
||||
{
|
||||
err = "invalid output port for module type";
|
||||
return false;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!requireConnectedUnlocked(module_id, err))
|
||||
return false;
|
||||
ensureSimStateUnlocked(module_id, type);
|
||||
const int idx = std::clamp(port, 0, kPortCount - 1);
|
||||
outputs_[module_id][static_cast<size_t>(idx)] = on;
|
||||
inputs_[module_id][static_cast<size_t>(idx)] = on;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IoModuleService::setOutputTimed(const std::string& module_id,
|
||||
int port,
|
||||
bool on,
|
||||
int timeout_ms,
|
||||
std::string& err)
|
||||
{
|
||||
if (!setOutput(module_id, port, on, err))
|
||||
return false;
|
||||
if (on && timeout_ms > 0)
|
||||
{
|
||||
std::thread([this, module_id, port, timeout_ms]() {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms));
|
||||
std::string ignore;
|
||||
setOutput(module_id, port, false, ignore);
|
||||
}).detach();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IoModuleService::getInput(const std::string& module_id, int port, bool& value, std::string& err) const
|
||||
{
|
||||
const auto mod = store_.find(module_id);
|
||||
if (!mod)
|
||||
{
|
||||
err = "io module not found";
|
||||
return false;
|
||||
}
|
||||
const std::string type = mod->value("module_type", "bluetooth");
|
||||
if (!validPortForType(type, port))
|
||||
{
|
||||
err = "invalid input port for module type";
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!requireConnectedUnlocked(module_id, err))
|
||||
return false;
|
||||
const auto it = inputs_.find(module_id);
|
||||
if (it == inputs_.end())
|
||||
{
|
||||
value = false;
|
||||
return true;
|
||||
}
|
||||
const int idx = std::clamp(port, 0, kPortCount - 1);
|
||||
value = it->second[static_cast<size_t>(idx)];
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IoModuleService::waitInput(const std::string& module_id,
|
||||
int port,
|
||||
bool expected,
|
||||
int timeout_ms,
|
||||
std::string& err)
|
||||
{
|
||||
const int step = 100;
|
||||
for (int elapsed = 0; elapsed <= timeout_ms; elapsed += step)
|
||||
{
|
||||
bool value = false;
|
||||
if (!getInput(module_id, port, value, err))
|
||||
return false;
|
||||
if (value == expected)
|
||||
return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(step));
|
||||
}
|
||||
err = "wait for input timed out";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IoModuleService::applyZoneSettings(const nlohmann::json& zone, std::string& err)
|
||||
{
|
||||
const std::string module_ref = zone.value("io_module", "");
|
||||
if (module_ref.empty())
|
||||
{
|
||||
err = "io_module is required";
|
||||
return false;
|
||||
}
|
||||
const auto mod = resolveModule(module_ref);
|
||||
if (!mod)
|
||||
{
|
||||
err = "unknown io module: " + module_ref;
|
||||
return false;
|
||||
}
|
||||
const std::string module_id = mod->value("id", "");
|
||||
if (!isConnected(module_id))
|
||||
{
|
||||
if (!connect(module_id, err))
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json params = zone;
|
||||
const std::string type = mod->value("module_type", "bluetooth");
|
||||
const int port = parsePort(params, type);
|
||||
const bool on = true;
|
||||
if (!setOutput(module_id, port, on, err))
|
||||
return false;
|
||||
|
||||
if (zone.contains("plc_register") && zone["plc_register"].is_number())
|
||||
{
|
||||
// PLC register side-effect is logged only in simulated runtime for now.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
nlohmann::json IoModuleService::runtimeSnapshot(const std::string& module_id) const
|
||||
{
|
||||
nlohmann::json out = {{"connected", isConnected(module_id)}, {"outputs", nlohmann::json::array()}, {"inputs", nlohmann::json::array()}};
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
const auto oit = outputs_.find(module_id);
|
||||
if (oit != outputs_.end())
|
||||
{
|
||||
for (bool v : oit->second)
|
||||
out["outputs"].push_back(v);
|
||||
}
|
||||
const auto iit = inputs_.find(module_id);
|
||||
if (iit != inputs_.end())
|
||||
{
|
||||
for (bool v : iit->second)
|
||||
out["inputs"].push_back(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
54
src/io/io_module_service.hpp
Normal file
54
src/io/io_module_service.hpp
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class IoModuleStore;
|
||||
|
||||
class IoModuleService
|
||||
{
|
||||
public:
|
||||
explicit IoModuleService(IoModuleStore& store);
|
||||
|
||||
bool testConnection(const std::string& ip_address, int port, std::string& err) const;
|
||||
std::optional<nlohmann::json> resolveModule(const std::string& ref) const;
|
||||
|
||||
bool connect(const std::string& module_id, std::string& err);
|
||||
bool disconnect(const std::string& module_id, std::string& err);
|
||||
bool isConnected(const std::string& module_id) const;
|
||||
|
||||
bool setOutput(const std::string& module_id, int port, bool on, std::string& err);
|
||||
bool setOutputTimed(const std::string& module_id,
|
||||
int port,
|
||||
bool on,
|
||||
int timeout_ms,
|
||||
std::string& err);
|
||||
bool getInput(const std::string& module_id, int port, bool& value, std::string& err) const;
|
||||
bool waitInput(const std::string& module_id, int port, bool expected, int timeout_ms, std::string& err);
|
||||
|
||||
bool applyZoneSettings(const nlohmann::json& zone, std::string& err);
|
||||
|
||||
nlohmann::json runtimeSnapshot(const std::string& module_id) const;
|
||||
|
||||
static bool validPortForType(const std::string& module_type, int port);
|
||||
static int defaultPortForType(const std::string& module_type);
|
||||
|
||||
private:
|
||||
IoModuleStore& store_;
|
||||
mutable std::mutex mu_;
|
||||
std::unordered_map<std::string, bool> connected_;
|
||||
std::unordered_map<std::string, std::vector<bool>> outputs_;
|
||||
std::unordered_map<std::string, std::vector<bool>> inputs_;
|
||||
|
||||
void ensureSimStateUnlocked(const std::string& module_id, const std::string& module_type);
|
||||
bool requireConnectedUnlocked(const std::string& module_id, std::string& err) const;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
90
src/io/io_module_usage.cpp
Normal file
90
src/io/io_module_usage.cpp
Normal file
@@ -0,0 +1,90 @@
|
||||
#include "io/io_module_usage.hpp"
|
||||
|
||||
#include "mission/mission_store.hpp"
|
||||
#include "storage/map_store.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
bool moduleRefMatches(const std::string& ref, const std::string& module_id, const std::string& module_name)
|
||||
{
|
||||
const std::string needle = StringUtil::trimCopy(ref);
|
||||
if (needle.empty())
|
||||
return false;
|
||||
return needle == module_id || StringUtil::toLower(needle) == StringUtil::toLower(module_name);
|
||||
}
|
||||
|
||||
void scanActions(const nlohmann::json& actions,
|
||||
const std::string& module_id,
|
||||
const std::string& module_name,
|
||||
nlohmann::json& mission_hits)
|
||||
{
|
||||
if (!actions.is_array())
|
||||
return;
|
||||
for (const auto& action : actions)
|
||||
{
|
||||
if (!action.is_object())
|
||||
continue;
|
||||
const auto& params = action.contains("params") && action["params"].is_object() ? action["params"]
|
||||
: nlohmann::json::object();
|
||||
const std::string module_ref = params.value("module", params.value("module_id", ""));
|
||||
if (moduleRefMatches(module_ref, module_id, module_name))
|
||||
{
|
||||
mission_hits.push_back({{"mission_action", action.value("type", "")},
|
||||
{"label", action.value("label", "")}});
|
||||
}
|
||||
if (action.contains("children"))
|
||||
scanActions(action["children"], module_id, module_name, mission_hits);
|
||||
if (action.contains("else_children"))
|
||||
scanActions(action["else_children"], module_id, module_name, mission_hits);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
nlohmann::json findIoModuleUsages(const std::string& module_id,
|
||||
const std::string& module_name,
|
||||
MapStore& maps,
|
||||
MissionStore& missions)
|
||||
{
|
||||
nlohmann::json zones = nlohmann::json::array();
|
||||
nlohmann::json mission_hits = nlohmann::json::array();
|
||||
|
||||
for (const auto& map : maps.list())
|
||||
{
|
||||
if (!map.is_object())
|
||||
continue;
|
||||
const auto& zlist = map.value("zones", nlohmann::json::array());
|
||||
if (!zlist.is_array())
|
||||
continue;
|
||||
for (const auto& z : zlist)
|
||||
{
|
||||
if (!z.is_object() || z.value("type", "") != "io")
|
||||
continue;
|
||||
if (moduleRefMatches(z.value("io_module", ""), module_id, module_name))
|
||||
{
|
||||
zones.push_back({{"map_id", map.value("id", "")},
|
||||
{"map_name", map.value("name", "")},
|
||||
{"zone_id", z.value("id", "")}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& mission : missions.listMissions())
|
||||
{
|
||||
if (!mission.is_object())
|
||||
continue;
|
||||
const auto before = mission_hits.size();
|
||||
scanActions(mission.value("actions", nlohmann::json::array()), module_id, module_name, mission_hits);
|
||||
if (mission_hits.size() > before)
|
||||
{
|
||||
mission_hits.push_back({{"mission_id", mission.value("id", "")}, {"mission_name", mission.value("name", "")}});
|
||||
}
|
||||
}
|
||||
|
||||
return {{"zones", zones}, {"missions", mission_hits}, {"in_use", !zones.empty() || !mission_hits.empty()}};
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
18
src/io/io_module_usage.hpp
Normal file
18
src/io/io_module_usage.hpp
Normal file
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class IoModuleStore;
|
||||
class MapStore;
|
||||
class MissionStore;
|
||||
|
||||
nlohmann::json findIoModuleUsages(const std::string& module_id,
|
||||
const std::string& module_name,
|
||||
MapStore& maps,
|
||||
MissionStore& missions);
|
||||
|
||||
} // namespace lm
|
||||
120
src/io/io_zone_runtime.cpp
Normal file
120
src/io/io_zone_runtime.cpp
Normal file
@@ -0,0 +1,120 @@
|
||||
#include "io/io_zone_runtime.hpp"
|
||||
|
||||
#include "io/io_module_service.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "storage/map_store.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
void updatePose(Database& db, double x, double y, double yaw)
|
||||
{
|
||||
nlohmann::json rt;
|
||||
if (!db.getDocument("robot_runtime", rt) || !rt.is_object())
|
||||
rt = nlohmann::json::object();
|
||||
rt["pose"] = {{"x", x}, {"y", y}, {"yaw", yaw}};
|
||||
rt["updated_at"] = IdUtil::nowIso8601();
|
||||
db.setDocument("robot_runtime", rt);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool IoZoneRuntime::pointInPolygon(double x, double y, const nlohmann::json& points)
|
||||
{
|
||||
if (!points.is_array() || points.size() < 3)
|
||||
return false;
|
||||
bool inside = false;
|
||||
size_t j = points.size() - 1;
|
||||
for (size_t i = 0; i < points.size(); ++i)
|
||||
{
|
||||
const auto& pi = points[i];
|
||||
const auto& pj = points[j];
|
||||
if (!pi.is_object() || !pj.is_object())
|
||||
continue;
|
||||
const double xi = pi.value("x", 0.0);
|
||||
const double yi = pi.value("y", 0.0);
|
||||
const double xj = pj.value("x", 0.0);
|
||||
const double yj = pj.value("y", 0.0);
|
||||
const bool intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-12) + xi);
|
||||
if (intersect)
|
||||
inside = !inside;
|
||||
j = i;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
bool IoZoneRuntime::zoneContainsPoint(const nlohmann::json& zone, double x, double y)
|
||||
{
|
||||
const auto& points = zone.value("points", nlohmann::json::array());
|
||||
return pointInPolygon(x, y, points);
|
||||
}
|
||||
|
||||
std::optional<std::pair<double, double>> IoZoneRuntime::positionCoords(const nlohmann::json& map,
|
||||
const std::string& position_id)
|
||||
{
|
||||
const auto& zones = map.value("zones", nlohmann::json::array());
|
||||
if (!zones.is_array())
|
||||
return std::nullopt;
|
||||
for (const auto& z : zones)
|
||||
{
|
||||
if (!z.is_object() || z.value("type", "") != "position")
|
||||
continue;
|
||||
if (z.value("id", "") != position_id)
|
||||
continue;
|
||||
return std::pair<double, double>{z.value("x", 0.0), z.value("y", 0.0)};
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void IoZoneRuntime::onRobotAtPosition(Database& db,
|
||||
MapStore& maps,
|
||||
IoModuleService& io,
|
||||
const std::string& map_id,
|
||||
const std::string& position_id,
|
||||
nlohmann::json& log)
|
||||
{
|
||||
const auto map = maps.find(map_id);
|
||||
if (!map)
|
||||
return;
|
||||
const auto coords = positionCoords(*map, position_id);
|
||||
if (!coords)
|
||||
return;
|
||||
|
||||
const double x = coords->first;
|
||||
const double y = coords->second;
|
||||
updatePose(db, x, y, 0.0);
|
||||
|
||||
const auto& zones = map->value("zones", nlohmann::json::array());
|
||||
if (!zones.is_array())
|
||||
return;
|
||||
|
||||
for (const auto& zone : zones)
|
||||
{
|
||||
if (!zone.is_object() || zone.value("type", "") != "io")
|
||||
continue;
|
||||
if (!zoneContainsPoint(zone, x, y))
|
||||
continue;
|
||||
|
||||
const std::string zone_id = zone.value("id", "");
|
||||
const std::string key = map_id + ":" + zone_id;
|
||||
if (key == last_zone_key_)
|
||||
continue;
|
||||
last_zone_key_ = key;
|
||||
|
||||
std::string err;
|
||||
if (!io.applyZoneSettings(zone, err))
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "error"},
|
||||
{"message", "I/O zone failed: " + err}});
|
||||
continue;
|
||||
}
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", "I/O zone activated: " + zone.value("io_module", "")}});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
32
src/io/io_zone_runtime.hpp
Normal file
32
src/io/io_zone_runtime.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
class IoModuleService;
|
||||
class MapStore;
|
||||
|
||||
class IoZoneRuntime
|
||||
{
|
||||
public:
|
||||
void onRobotAtPosition(Database& db,
|
||||
MapStore& maps,
|
||||
IoModuleService& io,
|
||||
const std::string& map_id,
|
||||
const std::string& position_id,
|
||||
nlohmann::json& log);
|
||||
|
||||
private:
|
||||
std::string last_zone_key_;
|
||||
|
||||
static bool pointInPolygon(double x, double y, const nlohmann::json& points);
|
||||
static bool zoneContainsPoint(const nlohmann::json& zone, double x, double y);
|
||||
static std::optional<std::pair<double, double>> positionCoords(const nlohmann::json& map,
|
||||
const std::string& position_id);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
@@ -1,13 +1,17 @@
|
||||
#include "mission/mission_queue.hpp"
|
||||
|
||||
#include "io/io_module_service.hpp"
|
||||
#include "io/io_zone_runtime.hpp"
|
||||
#include "mission/mission_store.hpp"
|
||||
#include "mission/position_resolver.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "storage/map_store.hpp"
|
||||
#include "storage/transition_store.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <unordered_set>
|
||||
@@ -42,10 +46,61 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double
|
||||
return fallback;
|
||||
}
|
||||
|
||||
bool paramBool(const nlohmann::json& params, const std::string& key, bool fallback)
|
||||
{
|
||||
if (params.contains(key))
|
||||
{
|
||||
if (params[key].is_boolean())
|
||||
return params[key].get<bool>();
|
||||
if (params[key].is_number())
|
||||
return params[key].get<int>() != 0;
|
||||
if (params[key].is_string())
|
||||
{
|
||||
const std::string s = StringUtil::toLower(params[key].get<std::string>());
|
||||
return s == "on" || s == "true" || s == "1";
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int ioPortFromParams(const nlohmann::json& params, const std::string& module_type)
|
||||
{
|
||||
int port = 0;
|
||||
if (params.contains("output") && params["output"].is_number_integer())
|
||||
port = params["output"].get<int>();
|
||||
else if (params.contains("input") && params["input"].is_number_integer())
|
||||
port = params["input"].get<int>();
|
||||
else if (params.contains("pin") && params["pin"].is_number_integer())
|
||||
port = params["pin"].get<int>();
|
||||
if (!IoModuleService::validPortForType(module_type, port))
|
||||
port = IoModuleService::defaultPortForType(module_type);
|
||||
return port;
|
||||
}
|
||||
|
||||
std::string moduleRefFromParams(const nlohmann::json& params)
|
||||
{
|
||||
if (params.contains("module") && params["module"].is_string())
|
||||
return params["module"].get<std::string>();
|
||||
if (params.contains("module_id") && params["module_id"].is_string())
|
||||
return params["module_id"].get<std::string>();
|
||||
return "";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MissionQueue::MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions)
|
||||
: db_(db), maps_(maps), transitions_(transitions), missions_(missions), position_resolver_(maps)
|
||||
MissionQueue::MissionQueue(Database& db,
|
||||
MapStore& maps,
|
||||
TransitionStore& transitions,
|
||||
MissionStore& missions,
|
||||
IoModuleService& io_modules,
|
||||
IoZoneRuntime& io_zones)
|
||||
: db_(db),
|
||||
maps_(maps),
|
||||
transitions_(transitions),
|
||||
missions_(missions),
|
||||
io_modules_(io_modules),
|
||||
io_zones_(io_zones),
|
||||
position_resolver_(maps)
|
||||
{
|
||||
load();
|
||||
ensureRunnerDefaults();
|
||||
@@ -676,6 +731,24 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
|
||||
sleepMs(1200);
|
||||
if (cancel_)
|
||||
throw MissionCancelled();
|
||||
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved->map_id, resolved->position_id, log);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (allow_auto_transition && type == "move_to_position" && !pos_ref.empty())
|
||||
{
|
||||
const std::string active_map = getActiveMapIdFromDb(db_);
|
||||
const auto resolved = position_resolver_.resolve(pos_ref, active_map);
|
||||
if (resolved)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", label + " → " + resolved->label}});
|
||||
sleepMs(1200);
|
||||
if (cancel_)
|
||||
throw MissionCancelled();
|
||||
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved->map_id, resolved->position_id, log);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -737,6 +810,228 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == "set_digital_output" || type == "set_output")
|
||||
{
|
||||
const std::string module_ref = moduleRefFromParams(params);
|
||||
const auto mod = io_modules_.resolveModule(module_ref);
|
||||
if (!mod)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "error"},
|
||||
{"message", "Unknown I/O module: " + module_ref}});
|
||||
throw std::runtime_error("unknown io module");
|
||||
}
|
||||
const std::string module_id = mod->value("id", "");
|
||||
const std::string module_type = mod->value("module_type", "bluetooth");
|
||||
const int port = ioPortFromParams(params, module_type);
|
||||
const bool on = paramBool(params, "value", true);
|
||||
const std::string operation = params.value("operation", "set");
|
||||
const int timeout_ms = static_cast<int>(paramNumber(params, "timeout_ms", paramNumber(params, "timeout_s", 0) * 1000));
|
||||
std::string err;
|
||||
bool ok = false;
|
||||
if (operation == "timed" && on && timeout_ms > 0)
|
||||
ok = io_modules_.setOutputTimed(module_id, port, on, timeout_ms, err);
|
||||
else
|
||||
ok = io_modules_.setOutput(module_id, port, on, err);
|
||||
if (!ok)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Set output failed: " + err}});
|
||||
throw std::runtime_error("set output failed");
|
||||
}
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", mod->value("name", module_ref) + " output " + std::to_string(port) + " → " +
|
||||
(on ? "ON" : "OFF")}});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == "wait_digital_input" || type == "wait_for_input")
|
||||
{
|
||||
const std::string module_ref = moduleRefFromParams(params);
|
||||
const auto mod = io_modules_.resolveModule(module_ref);
|
||||
if (!mod)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "error"},
|
||||
{"message", "Unknown I/O module: " + module_ref}});
|
||||
throw std::runtime_error("unknown io module");
|
||||
}
|
||||
const std::string module_id = mod->value("id", "");
|
||||
const std::string module_type = mod->value("module_type", "bluetooth");
|
||||
const int port = ioPortFromParams(params, module_type);
|
||||
const bool expected = paramBool(params, "expected", true);
|
||||
const int timeout_ms = static_cast<int>(paramNumber(params, "timeout_ms", paramNumber(params, "timeout_s", 30) * 1000));
|
||||
std::string err;
|
||||
if (!io_modules_.waitInput(module_id, port, expected, timeout_ms, err))
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Wait input failed: " + err}});
|
||||
throw std::runtime_error("wait input failed");
|
||||
}
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", mod->value("name", module_ref) + " input " + std::to_string(port) + " = " +
|
||||
(expected ? "ON" : "OFF")}});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == "connect_bluetooth" || type == "connect_io")
|
||||
{
|
||||
const std::string module_ref = moduleRefFromParams(params);
|
||||
const auto mod = io_modules_.resolveModule(module_ref);
|
||||
if (!mod)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "error"},
|
||||
{"message", "Unknown I/O module: " + module_ref}});
|
||||
throw std::runtime_error("unknown io module");
|
||||
}
|
||||
const std::string module_id = mod->value("id", "");
|
||||
std::string err;
|
||||
if (!io_modules_.connect(module_id, err))
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Connect failed: " + err}});
|
||||
throw std::runtime_error("connect failed");
|
||||
}
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", "Connected " + mod->value("name", module_ref)}});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == "disconnect_bluetooth" || type == "disconnect_io")
|
||||
{
|
||||
const std::string module_ref = moduleRefFromParams(params);
|
||||
const auto mod = io_modules_.resolveModule(module_ref);
|
||||
if (!mod)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "error"},
|
||||
{"message", "Unknown I/O module: " + module_ref}});
|
||||
throw std::runtime_error("unknown io module");
|
||||
}
|
||||
const std::string module_id = mod->value("id", "");
|
||||
std::string err;
|
||||
if (!io_modules_.disconnect(module_id, err))
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Disconnect failed: " + err}});
|
||||
throw std::runtime_error("disconnect failed");
|
||||
}
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", "Disconnected " + mod->value("name", module_ref)}});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == "if")
|
||||
{
|
||||
const std::string condition = params.value("condition", "");
|
||||
bool branch = false;
|
||||
if (condition == "io_input")
|
||||
{
|
||||
const std::string module_ref = moduleRefFromParams(params);
|
||||
const auto mod = io_modules_.resolveModule(module_ref);
|
||||
if (!mod)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "error"},
|
||||
{"message", "Unknown I/O module: " + module_ref}});
|
||||
throw std::runtime_error("unknown io module");
|
||||
}
|
||||
const std::string module_id = mod->value("id", "");
|
||||
const std::string module_type = mod->value("module_type", "bluetooth");
|
||||
const int port = ioPortFromParams(params, module_type);
|
||||
const bool expected = paramBool(params, "expected", true);
|
||||
bool value = false;
|
||||
std::string err;
|
||||
if (!io_modules_.getInput(module_id, port, value, err))
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "If input read failed: " + err}});
|
||||
throw std::runtime_error("if input read failed");
|
||||
}
|
||||
branch = (value == expected);
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", "If I/O input " + std::to_string(port) + " → " + (branch ? "true" : "false")}});
|
||||
}
|
||||
else
|
||||
{
|
||||
branch = true;
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", "If " + condition + " (simulated true)"}});
|
||||
}
|
||||
const auto& branch_actions =
|
||||
branch
|
||||
? (action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array())
|
||||
: (action.contains("else_children") && action["else_children"].is_array()
|
||||
? action["else_children"]
|
||||
: nlohmann::json::array());
|
||||
if (!branch_actions.empty())
|
||||
{
|
||||
const LoopControl ctrl = executeActionsUnlocked(branch_actions, parameters, log, loop_depth + 1, allow_auto_transition);
|
||||
if (ctrl == LoopControl::Break)
|
||||
return LoopControl::Break;
|
||||
if (ctrl == LoopControl::Continue)
|
||||
continue;
|
||||
}
|
||||
if (cancel_)
|
||||
throw MissionCancelled();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == "while")
|
||||
{
|
||||
const std::string condition = params.value("condition", "io_input");
|
||||
const auto& children =
|
||||
action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array();
|
||||
const int max_iter = static_cast<int>(paramNumber(params, "max_iterations", 1000));
|
||||
for (int i = 0; i < max_iter && !stop_ && !cancel_; ++i)
|
||||
{
|
||||
bool continue_loop = false;
|
||||
if (condition == "io_input")
|
||||
{
|
||||
const std::string module_ref = moduleRefFromParams(params);
|
||||
const auto mod = io_modules_.resolveModule(module_ref);
|
||||
if (!mod)
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "error"},
|
||||
{"message", "Unknown I/O module: " + module_ref}});
|
||||
throw std::runtime_error("unknown io module");
|
||||
}
|
||||
const std::string module_id = mod->value("id", "");
|
||||
const std::string module_type = mod->value("module_type", "bluetooth");
|
||||
const int port = ioPortFromParams(params, module_type);
|
||||
const bool expected = paramBool(params, "expected", true);
|
||||
bool value = false;
|
||||
std::string err;
|
||||
if (!io_modules_.getInput(module_id, port, value, err))
|
||||
{
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "While input read failed: " + err}});
|
||||
throw std::runtime_error("while input read failed");
|
||||
}
|
||||
continue_loop = (value == expected);
|
||||
}
|
||||
else
|
||||
{
|
||||
continue_loop = false;
|
||||
}
|
||||
if (!continue_loop)
|
||||
break;
|
||||
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||
{"level", "info"},
|
||||
{"message", "While iteration " + std::to_string(i + 1)}});
|
||||
const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1, allow_auto_transition);
|
||||
if (ctrl == LoopControl::Break)
|
||||
break;
|
||||
if (ctrl == LoopControl::Continue)
|
||||
continue;
|
||||
}
|
||||
if (cancel_)
|
||||
throw MissionCancelled();
|
||||
continue;
|
||||
}
|
||||
|
||||
log.push_back(
|
||||
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
|
||||
sleepMs(400);
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
class IoModuleService;
|
||||
class IoZoneRuntime;
|
||||
class MapStore;
|
||||
class MissionStore;
|
||||
class TransitionStore;
|
||||
@@ -20,7 +22,12 @@ class TransitionStore;
|
||||
class MissionQueue
|
||||
{
|
||||
public:
|
||||
MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions);
|
||||
MissionQueue(Database& db,
|
||||
MapStore& maps,
|
||||
TransitionStore& transitions,
|
||||
MissionStore& missions,
|
||||
IoModuleService& io_modules,
|
||||
IoZoneRuntime& io_zones);
|
||||
~MissionQueue();
|
||||
|
||||
MissionQueue(const MissionQueue&) = delete;
|
||||
@@ -44,6 +51,8 @@ private:
|
||||
MapStore& maps_;
|
||||
TransitionStore& transitions_;
|
||||
MissionStore& missions_;
|
||||
IoModuleService& io_modules_;
|
||||
IoZoneRuntime& io_zones_;
|
||||
PositionResolver position_resolver_;
|
||||
mutable std::recursive_mutex mu_;
|
||||
nlohmann::json queue_;
|
||||
|
||||
204
src/server/api_io_module_routes.cpp
Normal file
204
src/server/api_io_module_routes.cpp
Normal file
@@ -0,0 +1,204 @@
|
||||
#include "server/api_server.hpp"
|
||||
|
||||
#include "auth/auth_service.hpp"
|
||||
#include "io/io_module_service.hpp"
|
||||
#include "io/io_module_usage.hpp"
|
||||
#include "util/http_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
void ApiServer::registerIoModuleRoutes(httplib::Server& svr)
|
||||
{
|
||||
svr.Get("/api/io_modules", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string site_id = req.has_param("site_id") ? req.get_param_value("site_id") : "";
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json({{"io_modules", io_module_store_.list(site_id)}}).dump();
|
||||
});
|
||||
|
||||
svr.Post("/api/io_modules/test", [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 std::string ip = body.value("ip_address", "");
|
||||
int port = body.value("port", 502);
|
||||
if (body.contains("port") && body["port"].is_number())
|
||||
port = body["port"].get<int>();
|
||||
std::string err;
|
||||
const bool ok = io_module_service_.testConnection(ip, port, err);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
if (!ok)
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.body = nlohmann::json({{"ok", true}}).dump();
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/io_modules/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto mod = io_module_store_.find(id);
|
||||
if (!mod)
|
||||
return HttpUtil::jsonError(res, 404, "io module not found");
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = mod->dump();
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/io_modules/([^/]+)/runtime$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
if (!io_module_store_.find(id))
|
||||
return HttpUtil::jsonError(res, 404, "io module not found");
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = io_module_service_.runtimeSnapshot(id).dump();
|
||||
});
|
||||
|
||||
svr.Post(R"(/api/io_modules/([^/]+)/output$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto mod = io_module_store_.find(id);
|
||||
if (!mod)
|
||||
return HttpUtil::jsonError(res, 404, "io module not found");
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
const std::string module_type = mod->value("module_type", "bluetooth");
|
||||
int port = body.value("port", body.value("output", IoModuleService::defaultPortForType(module_type)));
|
||||
if (body.contains("output") && body["output"].is_number())
|
||||
port = body["output"].get<int>();
|
||||
else if (body.contains("port") && body["port"].is_number())
|
||||
port = body["port"].get<int>();
|
||||
bool on = true;
|
||||
if (body.contains("value"))
|
||||
{
|
||||
if (body["value"].is_boolean())
|
||||
on = body["value"].get<bool>();
|
||||
else if (body["value"].is_number())
|
||||
on = body["value"].get<int>() != 0;
|
||||
}
|
||||
const int timeout_ms = body.value("timeout_ms", 0);
|
||||
std::string err;
|
||||
bool ok = false;
|
||||
if (timeout_ms > 0 && on)
|
||||
ok = io_module_service_.setOutputTimed(id, port, on, timeout_ms, err);
|
||||
else
|
||||
ok = io_module_service_.setOutput(id, port, on, err);
|
||||
if (!ok)
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = io_module_service_.runtimeSnapshot(id).dump();
|
||||
});
|
||||
|
||||
svr.Post("/api/io_modules", [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 (const AuthSession* session = AuthService::activeSession())
|
||||
{
|
||||
if (!body.contains("created_by") || !body["created_by"].is_string() ||
|
||||
body["created_by"].get<std::string>().empty())
|
||||
{
|
||||
body["created_by"] = session->group_name.empty() ? session->username : session->group_name;
|
||||
}
|
||||
if (!body.contains("created_by_group") || !body["created_by_group"].is_string() ||
|
||||
body["created_by_group"].get<std::string>().empty())
|
||||
body["created_by_group"] = session->group_id;
|
||||
}
|
||||
std::string err;
|
||||
const auto created = io_module_store_.create(body, err);
|
||||
if (!created)
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.status = 201;
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = created->dump();
|
||||
});
|
||||
|
||||
svr.Put(R"(/api/io_modules/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
if (!io_module_store_.update(id, body, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
const auto updated = io_module_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/io_modules/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto existing = io_module_store_.find(id);
|
||||
if (!existing)
|
||||
return HttpUtil::jsonError(res, 404, "io module not found");
|
||||
if (const AuthSession* session = AuthService::activeSession())
|
||||
{
|
||||
if (!AuthService::canDeleteMap(*existing, *session))
|
||||
return HttpUtil::jsonError(res, 403, "cannot delete io module from another user group");
|
||||
}
|
||||
const auto usages =
|
||||
findIoModuleUsages(id, existing->value("name", ""), map_store_, mission_store_);
|
||||
if (usages.value("in_use", false))
|
||||
{
|
||||
res.status = 409;
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json({{"error", "io module is in use"}, {"usages", usages}}).dump();
|
||||
return;
|
||||
}
|
||||
std::string err;
|
||||
if (!io_module_store_.remove(id, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.status = 204;
|
||||
});
|
||||
|
||||
svr.Post(R"(/api/io_modules/([^/]+)/connect$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
std::string err;
|
||||
if (!io_module_service_.connect(id, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
const auto updated = io_module_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
|
||||
svr.Post(R"(/api/io_modules/([^/]+)/disconnect$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
std::string err;
|
||||
if (!io_module_service_.disconnect(id, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
const auto updated = io_module_store_.find(id);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
@@ -3,6 +3,10 @@
|
||||
#include "auth/auth_service.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/http_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
namespace lm {
|
||||
|
||||
@@ -63,6 +67,126 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
||||
res.status = 204;
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/sites/([^/]+)/export$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string site_id = req.matches[1];
|
||||
const auto site = site_store_.find(site_id);
|
||||
if (!site)
|
||||
return HttpUtil::jsonError(res, 404, "site not found");
|
||||
|
||||
nlohmann::json maps = nlohmann::json::array();
|
||||
for (const auto& map : map_store_.list())
|
||||
{
|
||||
if (map.value("site_id", "") == site_id)
|
||||
maps.push_back(map);
|
||||
}
|
||||
|
||||
const nlohmann::json bundle = {{"format", "rbs-site-bundle"},
|
||||
{"version", 1},
|
||||
{"site", *site},
|
||||
{"maps", maps},
|
||||
{"transitions", transition_store_.list(site_id)},
|
||||
{"io_modules", io_module_store_.list(site_id)},
|
||||
{"sounds", sound_store_.list()}};
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = bundle.dump(2);
|
||||
});
|
||||
|
||||
svr.Post(R"(/api/sites/([^/]+)/import$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string site_id = req.matches[1];
|
||||
if (!site_store_.find(site_id))
|
||||
return HttpUtil::jsonError(res, 404, "site not found");
|
||||
|
||||
nlohmann::json bundle;
|
||||
try
|
||||
{
|
||||
bundle = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
if (bundle.value("format", "") != "rbs-site-bundle")
|
||||
return HttpUtil::jsonError(res, 400, "unsupported bundle format");
|
||||
|
||||
nlohmann::json imported = {{"maps", nlohmann::json::array()},
|
||||
{"io_modules", nlohmann::json::array()},
|
||||
{"transitions", nlohmann::json::array()}};
|
||||
std::unordered_map<std::string, std::string> map_id_remap;
|
||||
|
||||
if (bundle.contains("maps") && bundle["maps"].is_array())
|
||||
{
|
||||
for (const auto& map : bundle["maps"])
|
||||
{
|
||||
if (!map.is_object())
|
||||
continue;
|
||||
nlohmann::json payload = map;
|
||||
const std::string old_id = payload.value("id", "");
|
||||
payload["id"] = IdUtil::newId();
|
||||
payload["site_id"] = site_id;
|
||||
if (!old_id.empty())
|
||||
map_id_remap[old_id] = payload["id"].get<std::string>();
|
||||
std::string err;
|
||||
const auto created = map_store_.create(payload, err);
|
||||
if (!created)
|
||||
return HttpUtil::jsonError(res, 400, "map import failed: " + err);
|
||||
imported["maps"].push_back(*created);
|
||||
}
|
||||
}
|
||||
|
||||
if (bundle.contains("io_modules") && bundle["io_modules"].is_array())
|
||||
{
|
||||
for (const auto& mod : bundle["io_modules"])
|
||||
{
|
||||
if (!mod.is_object())
|
||||
continue;
|
||||
nlohmann::json payload = mod;
|
||||
payload.erase("id");
|
||||
payload["site_id"] = site_id;
|
||||
if (const AuthSession* session = AuthService::activeSession())
|
||||
{
|
||||
if (!payload.contains("created_by") || payload["created_by"].get<std::string>().empty())
|
||||
payload["created_by"] = session->group_name.empty() ? session->username : session->group_name;
|
||||
if (!payload.contains("created_by_group") || payload["created_by_group"].get<std::string>().empty())
|
||||
payload["created_by_group"] = session->group_id;
|
||||
}
|
||||
std::string err;
|
||||
const auto created = io_module_store_.create(payload, err);
|
||||
if (!created)
|
||||
return HttpUtil::jsonError(res, 400, "io module import failed: " + err);
|
||||
imported["io_modules"].push_back(*created);
|
||||
}
|
||||
}
|
||||
|
||||
if (bundle.contains("transitions") && bundle["transitions"].is_array())
|
||||
{
|
||||
for (const auto& tr : bundle["transitions"])
|
||||
{
|
||||
if (!tr.is_object())
|
||||
continue;
|
||||
nlohmann::json payload = tr;
|
||||
payload.erase("id");
|
||||
payload["site_id"] = site_id;
|
||||
const std::string from_old = payload.value("from_map_id", "");
|
||||
const std::string to_old = payload.value("to_map_id", "");
|
||||
if (map_id_remap.count(from_old))
|
||||
payload["from_map_id"] = map_id_remap[from_old];
|
||||
if (map_id_remap.count(to_old))
|
||||
payload["to_map_id"] = map_id_remap[to_old];
|
||||
std::string err;
|
||||
const auto created = transition_store_.create(payload, err);
|
||||
if (!created)
|
||||
return HttpUtil::jsonError(res, 400, "transition import failed: " + err);
|
||||
imported["transitions"].push_back(*created);
|
||||
}
|
||||
}
|
||||
|
||||
res.status = 201;
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = imported.dump(2);
|
||||
});
|
||||
|
||||
svr.Get("/api/maps", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
|
||||
@@ -20,6 +20,8 @@ ApiServer::ApiServer(StateRepository& repo,
|
||||
SiteStore& site_store,
|
||||
SoundStore& sound_store,
|
||||
TransitionStore& transition_store,
|
||||
IoModuleStore& io_module_store,
|
||||
IoModuleService& io_module_service,
|
||||
DashboardStore& dashboard_store)
|
||||
: repo_(repo),
|
||||
mission_queue_(mission_queue),
|
||||
@@ -31,6 +33,8 @@ ApiServer::ApiServer(StateRepository& repo,
|
||||
site_store_(site_store),
|
||||
sound_store_(sound_store),
|
||||
transition_store_(transition_store),
|
||||
io_module_store_(io_module_store),
|
||||
io_module_service_(io_module_service),
|
||||
dashboard_store_(dashboard_store)
|
||||
{
|
||||
}
|
||||
@@ -553,6 +557,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
||||
registerRobotRoutes(svr);
|
||||
registerMediaRoutes(svr);
|
||||
registerTransitionRoutes(svr);
|
||||
registerIoModuleRoutes(svr);
|
||||
registerDashboardRoutes(svr);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "storage/map_store.hpp"
|
||||
#include "storage/site_store.hpp"
|
||||
#include "storage/sound_store.hpp"
|
||||
#include "storage/io_module_store.hpp"
|
||||
#include "io/io_module_service.hpp"
|
||||
#include "storage/transition_store.hpp"
|
||||
#include "storage/state_repository.hpp"
|
||||
|
||||
@@ -29,6 +31,8 @@ public:
|
||||
SiteStore& site_store,
|
||||
SoundStore& sound_store,
|
||||
TransitionStore& transition_store,
|
||||
IoModuleStore& io_module_store,
|
||||
IoModuleService& io_module_service,
|
||||
DashboardStore& dashboard_store);
|
||||
|
||||
void registerRoutes(httplib::Server& svr);
|
||||
@@ -44,6 +48,8 @@ private:
|
||||
SiteStore& site_store_;
|
||||
SoundStore& sound_store_;
|
||||
TransitionStore& transition_store_;
|
||||
IoModuleStore& io_module_store_;
|
||||
IoModuleService& io_module_service_;
|
||||
DashboardStore& dashboard_store_;
|
||||
|
||||
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
||||
@@ -55,6 +61,7 @@ private:
|
||||
void registerRobotRoutes(httplib::Server& svr);
|
||||
void registerMediaRoutes(httplib::Server& svr);
|
||||
void registerTransitionRoutes(httplib::Server& svr);
|
||||
void registerIoModuleRoutes(httplib::Server& svr);
|
||||
void registerDashboardRoutes(httplib::Server& svr);
|
||||
};
|
||||
|
||||
|
||||
@@ -108,6 +108,20 @@ CREATE TABLE IF NOT EXISTS transitions (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS io_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
module_type TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_by_group TEXT NOT NULL DEFAULT '',
|
||||
connected INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboards (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -395,6 +409,28 @@ bool Database::applySchemaMigrations(std::string& err)
|
||||
setMeta("schema_version", "6");
|
||||
}
|
||||
|
||||
ver = getMeta("schema_version").value_or("1");
|
||||
if (ver == "6")
|
||||
{
|
||||
if (!execSql(db_,
|
||||
"CREATE TABLE IF NOT EXISTS io_modules ("
|
||||
"id TEXT PRIMARY KEY, "
|
||||
"site_id TEXT NOT NULL, "
|
||||
"name TEXT NOT NULL, "
|
||||
"module_type TEXT NOT NULL, "
|
||||
"ip_address TEXT NOT NULL, "
|
||||
"created_by TEXT NOT NULL DEFAULT '', "
|
||||
"created_by_group TEXT NOT NULL DEFAULT '', "
|
||||
"connected INTEGER NOT NULL DEFAULT 0, "
|
||||
"created_at TEXT NOT NULL, "
|
||||
"updated_at TEXT NOT NULL, "
|
||||
"FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE"
|
||||
")",
|
||||
err))
|
||||
return false;
|
||||
setMeta("schema_version", "7");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
324
src/storage/io_module_store.cpp
Normal file
324
src/storage/io_module_store.cpp
Normal file
@@ -0,0 +1,324 @@
|
||||
#include "storage/io_module_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
auto text = [&](int col) -> std::string {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return "";
|
||||
const char* v = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
|
||||
return v ? std::string(v) : "";
|
||||
};
|
||||
return {{"id", text(0)},
|
||||
{"site_id", text(1)},
|
||||
{"name", text(2)},
|
||||
{"module_type", text(3)},
|
||||
{"ip_address", text(4)},
|
||||
{"created_by", text(5)},
|
||||
{"created_by_group", text(6)},
|
||||
{"connected", sqlite3_column_int(stmt, 7) != 0},
|
||||
{"created_at", text(8)},
|
||||
{"updated_at", text(9)}};
|
||||
}
|
||||
|
||||
constexpr const char* kSelect =
|
||||
"SELECT id, site_id, name, module_type, ip_address, created_by, created_by_group, connected, created_at, updated_at "
|
||||
"FROM io_modules";
|
||||
|
||||
bool validModuleType(const std::string& t)
|
||||
{
|
||||
return t == "bluetooth" || t == "wise";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IoModuleStore::IoModuleStore(Database& db) : db_(db) {}
|
||||
|
||||
bool IoModuleStore::findNameConflictUnlocked(const std::string& site_id,
|
||||
const std::string& name,
|
||||
const std::string& except_id) const
|
||||
{
|
||||
const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name));
|
||||
if (needle.empty())
|
||||
return false;
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = except_id.empty()
|
||||
? "SELECT id FROM io_modules WHERE site_id = ?1 AND lower(name) = lower(?2) LIMIT 1"
|
||||
: "SELECT id FROM io_modules WHERE site_id = ?1 AND lower(name) = lower(?2) AND id != ?3 LIMIT 1";
|
||||
if (sqlite3_prepare_v2(db_.handle(), sql, -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (!except_id.empty())
|
||||
sqlite3_bind_text(stmt, 3, except_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool conflict = sqlite3_step(stmt) == SQLITE_ROW;
|
||||
sqlite3_finalize(stmt);
|
||||
return conflict;
|
||||
}
|
||||
|
||||
nlohmann::json IoModuleStore::list(const std::string& site_id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
std::string sql = kSelect;
|
||||
if (!site_id.empty())
|
||||
sql += " WHERE site_id = ?1";
|
||||
sql += " ORDER BY name";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return out;
|
||||
if (!site_id.empty())
|
||||
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out.push_back(rowToJson(stmt));
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> IoModuleStore::find(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
const std::string sql = std::string(kSelect) + " WHERE id = ?1";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out = rowToJson(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> IoModuleStore::findByName(const std::string& name) const
|
||||
{
|
||||
const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name));
|
||||
if (needle.empty())
|
||||
return std::nullopt;
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, site_id, name, module_type, ip_address, created_by, created_by_group, connected, "
|
||||
"created_at, updated_at FROM io_modules WHERE lower(name) = lower(?1) LIMIT 1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out = rowToJson(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> IoModuleStore::resolveRef(const std::string& ref) const
|
||||
{
|
||||
if (auto by_id = find(ref))
|
||||
return by_id;
|
||||
return findByName(ref);
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> IoModuleStore::create(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string site_id = StringUtil::trimCopy(payload.value("site_id", ""));
|
||||
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||
std::string module_type = StringUtil::toLower(StringUtil::trimCopy(payload.value("module_type", "")));
|
||||
const std::string ip_address = StringUtil::trimCopy(payload.value("ip_address", ""));
|
||||
const std::string created_by = StringUtil::trimCopy(payload.value("created_by", ""));
|
||||
const std::string created_by_group = StringUtil::trimCopy(payload.value("created_by_group", ""));
|
||||
|
||||
if (site_id.empty() || name.empty() || module_type.empty() || ip_address.empty())
|
||||
{
|
||||
err = "missing required fields";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!validModuleType(module_type))
|
||||
{
|
||||
err = "module_type must be bluetooth or wise";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (findNameConflictUnlocked(site_id, name, ""))
|
||||
{
|
||||
err = "io module name already exists for this site";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string id = payload.value("id", IdUtil::newId());
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO io_modules(id, site_id, name, module_type, ip_address, created_by, "
|
||||
"created_by_group, connected, created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,0,?8,?9)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, module_type.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, ip_address.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, created_by.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return nlohmann::json{{"id", id},
|
||||
{"site_id", site_id},
|
||||
{"name", name},
|
||||
{"module_type", module_type},
|
||||
{"ip_address", ip_address},
|
||||
{"created_by", created_by},
|
||||
{"created_by_group", created_by_group},
|
||||
{"connected", false},
|
||||
{"created_at", now},
|
||||
{"updated_at", now}};
|
||||
}
|
||||
|
||||
bool IoModuleStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "io module not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
for (const char* key : {"site_id", "name", "module_type", "ip_address"})
|
||||
{
|
||||
if (payload.contains(key))
|
||||
merged[key] = payload[key];
|
||||
}
|
||||
|
||||
const std::string site_id = StringUtil::trimCopy(merged.value("site_id", ""));
|
||||
const std::string name = StringUtil::trimCopy(merged.value("name", ""));
|
||||
std::string module_type = StringUtil::toLower(StringUtil::trimCopy(merged.value("module_type", "")));
|
||||
const std::string ip_address = StringUtil::trimCopy(merged.value("ip_address", ""));
|
||||
|
||||
if (site_id.empty() || name.empty() || module_type.empty() || ip_address.empty())
|
||||
{
|
||||
err = "missing required fields";
|
||||
return false;
|
||||
}
|
||||
if (!validModuleType(module_type))
|
||||
{
|
||||
err = "module_type must be bluetooth or wise";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (findNameConflictUnlocked(site_id, name, id))
|
||||
{
|
||||
err = "io module name already exists for this site";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE io_modules SET site_id=?2, name=?3, module_type=?4, ip_address=?5, updated_at=?6 WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, module_type.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, ip_address.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool IoModuleStore::remove(const std::string& id, std::string& err)
|
||||
{
|
||||
if (!find(id))
|
||||
{
|
||||
err = "io module not found";
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM io_modules WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool IoModuleStore::setConnected(const std::string& id, bool connected, std::string& err)
|
||||
{
|
||||
if (!find(id))
|
||||
{
|
||||
err = "io module not found";
|
||||
return false;
|
||||
}
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE io_modules SET connected=?2, updated_at=?3 WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 2, connected ? 1 : 0);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
36
src/storage/io_module_store.hpp
Normal file
36
src/storage/io_module_store.hpp
Normal file
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class IoModuleStore
|
||||
{
|
||||
public:
|
||||
explicit IoModuleStore(Database& db);
|
||||
|
||||
nlohmann::json list(const std::string& site_id = "") const;
|
||||
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||
std::optional<nlohmann::json> findByName(const std::string& name) const;
|
||||
std::optional<nlohmann::json> resolveRef(const std::string& ref) const;
|
||||
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||
bool remove(const std::string& id, std::string& err);
|
||||
bool setConnected(const std::string& id, bool connected, std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
|
||||
bool findNameConflictUnlocked(const std::string& site_id,
|
||||
const std::string& name,
|
||||
const std::string& except_id) const;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
17
www/app.js
17
www/app.js
@@ -12,6 +12,9 @@ const pageMissionsEl = el("pageMissions");
|
||||
const pageIntegrationsEl = el("pageIntegrations");
|
||||
const pageSoundsEl = el("pageSounds");
|
||||
const pageTransitionsEl = el("pageTransitions");
|
||||
const pageUsersEl = el("pageUsers");
|
||||
const pageUserGroupsEl = el("pageUserGroups");
|
||||
const pageIoModulesEl = el("pageIoModules");
|
||||
const pageMonitoringEl = el("pageMonitoring");
|
||||
const pageHelpEl = el("pageHelp");
|
||||
const contentEl = document.querySelector(".content");
|
||||
@@ -126,7 +129,7 @@ const state = {
|
||||
};
|
||||
|
||||
function setActivePage(page) {
|
||||
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "integrations", "monitoring", "help"];
|
||||
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "users", "integrations", "monitoring", "help"];
|
||||
let p = valid.includes(page) ? page : "missions";
|
||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||
@@ -139,6 +142,9 @@ function setActivePage(page) {
|
||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
|
||||
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
|
||||
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
|
||||
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
|
||||
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
||||
@@ -151,6 +157,9 @@ function setActivePage(page) {
|
||||
contentEl.classList.toggle("content--missions", p === "missions");
|
||||
contentEl.classList.toggle("content--sounds", p === "sounds");
|
||||
contentEl.classList.toggle("content--transitions", p === "transitions");
|
||||
contentEl.classList.toggle("content--user-groups", p === "user-groups");
|
||||
contentEl.classList.toggle("content--io-modules", p === "io-modules");
|
||||
contentEl.classList.toggle("content--users", p === "users");
|
||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||
contentEl.classList.toggle("content--help", p === "help");
|
||||
@@ -162,6 +171,12 @@ function setActivePage(page) {
|
||||
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
|
||||
if (p === "transitions" && window.TransitionsApp) window.TransitionsApp.onPageShow();
|
||||
else if (window.TransitionsApp?.onPageHide) window.TransitionsApp.onPageHide();
|
||||
if (p === "user-groups" && window.UserGroupsApp) window.UserGroupsApp.onPageShow();
|
||||
else if (window.UserGroupsApp?.onPageHide) window.UserGroupsApp.onPageHide();
|
||||
if (p === "io-modules" && window.IoModulesApp) window.IoModulesApp.onPageShow();
|
||||
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
|
||||
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
|
||||
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
|
||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
||||
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
||||
|
||||
@@ -151,6 +151,9 @@
|
||||
missions: "missions",
|
||||
sounds: "sounds",
|
||||
transitions: "maps",
|
||||
"user-groups": "users",
|
||||
"io-modules": "integrations",
|
||||
users: "users",
|
||||
integrations: "integrations",
|
||||
};
|
||||
const resource = map[page];
|
||||
@@ -171,6 +174,9 @@
|
||||
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
|
||||
document.body.classList.toggle("auth-readonly-sounds", !canWrite("sounds") && !canWrite("integrations"));
|
||||
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
|
||||
document.body.classList.toggle("auth-readonly-users", !canWrite("users"));
|
||||
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
|
||||
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
|
||||
}
|
||||
|
||||
function updateUserMenu() {
|
||||
|
||||
188
www/dashboard.js
188
www/dashboard.js
@@ -56,6 +56,7 @@
|
||||
userGroups: [],
|
||||
maps: [],
|
||||
mapsLoaded: false,
|
||||
ioModules: [],
|
||||
robotPose: null,
|
||||
robotPoseAt: 0,
|
||||
mapPollTimer: null,
|
||||
@@ -277,6 +278,167 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureIoModulesLoaded() {
|
||||
if (store.ioModules.length) return store.ioModules;
|
||||
try {
|
||||
if (window.IoModulesCatalog?.refresh) {
|
||||
store.ioModules = await window.IoModulesCatalog.refresh();
|
||||
} else {
|
||||
const res = await fetch("/api/io_modules", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
store.ioModules = Array.isArray(data.io_modules) ? data.io_modules : [];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
store.ioModules = [];
|
||||
}
|
||||
return store.ioModules;
|
||||
}
|
||||
|
||||
function ioModuleOptions(selectedId) {
|
||||
const list = store.ioModules || [];
|
||||
if (!list.length) return `<option value="">${escapeHtml(t("ioModules.empty"))}</option>`;
|
||||
return list
|
||||
.map(
|
||||
(m) =>
|
||||
`<option value="${escapeHtml(m.id)}" ${m.id === selectedId ? "selected" : ""}>${escapeHtml(m.name || m.id)}</option>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function ioModuleById(id) {
|
||||
return (store.ioModules || []).find((m) => m.id === id) || null;
|
||||
}
|
||||
|
||||
async function setIoModuleConnected(id, connect) {
|
||||
const path = connect ? "connect" : "disconnect";
|
||||
const res = await fetch(`/api/io_modules/${encodeURIComponent(id)}/${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || res.statusText);
|
||||
}
|
||||
const data = await res.json();
|
||||
const idx = store.ioModules.findIndex((m) => m.id === id);
|
||||
if (idx >= 0) store.ioModules[idx] = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderIoConnectWidget(widget, bodyEl) {
|
||||
if (!bodyEl) return;
|
||||
const mod = ioModuleById(widget.io_module_id);
|
||||
const connected = !!mod?.connected;
|
||||
const label = widget.title || mod?.name || t("dashboard.widget.io_connect");
|
||||
bodyEl.innerHTML = `
|
||||
<button type="button" class="dashboardMirMissionBtn dashboardIoConnectBtn" data-io-connect="${escapeHtml(widget.io_module_id || "")}">
|
||||
${escapeHtml(connected ? t("dashboard.widget.io_disconnect") : t("dashboard.widget.io_connectAction"))}
|
||||
</button>
|
||||
<p class="mutedNote dashboardIoWidgetMeta">${escapeHtml(label)} · ${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}</p>`;
|
||||
const btn = bodyEl.querySelector("[data-io-connect]");
|
||||
btn?.addEventListener("click", async () => {
|
||||
if (!widget.io_module_id) return;
|
||||
try {
|
||||
await setIoModuleConnected(widget.io_module_id, !connected);
|
||||
renderIoConnectWidget(widget, bodyEl);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderIoStatusWidget(widget, bodyEl) {
|
||||
if (!bodyEl) return;
|
||||
const mod = ioModuleById(widget.io_module_id);
|
||||
const label = widget.title || mod?.name || t("dashboard.widget.io_status");
|
||||
bodyEl.innerHTML = `
|
||||
<div class="dashboardIoStatusCard">
|
||||
<div class="dashboardIoStatusName">${escapeHtml(label)}</div>
|
||||
<div class="dashboardIoStatusLine">${escapeHtml(mod?.module_type ? t(`ioModules.type.${mod.module_type}`) : "—")} · ${escapeHtml(mod?.ip_address || "—")}</div>
|
||||
<div class="dashboardIoStatusBadge ${mod?.connected ? "is-on" : "is-off"}">${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function fetchIoRuntime(moduleId) {
|
||||
const res = await fetch(`/api/io_modules/${encodeURIComponent(moduleId)}/runtime`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function setIoOutput(moduleId, port, value, timeoutMs = 0) {
|
||||
const res = await fetch(`/api/io_modules/${encodeURIComponent(moduleId)}/output`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ output: port, value, timeout_ms: timeoutMs }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function ioPortRange(mod) {
|
||||
return mod?.module_type === "wise" ? [0, 1, 2, 3] : [1, 2, 3, 4];
|
||||
}
|
||||
|
||||
function renderIoConfigurationWidget(widget, bodyEl) {
|
||||
if (!bodyEl) return;
|
||||
const mod = ioModuleById(widget.io_module_id);
|
||||
const label = widget.title || mod?.name || t("dashboard.widget.io_configuration");
|
||||
if (!mod) {
|
||||
bodyEl.innerHTML = `<p class="mutedNote">${escapeHtml(t("dashboard.widget.configHint"))}</p>`;
|
||||
return;
|
||||
}
|
||||
const ports = ioPortRange(mod);
|
||||
bodyEl.innerHTML = `
|
||||
<div class="dashboardIoConfigHead">
|
||||
<div class="dashboardIoStatusName">${escapeHtml(label)}</div>
|
||||
<div class="dashboardIoStatusBadge ${mod.connected ? "is-on" : "is-off"}">${escapeHtml(mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected"))}</div>
|
||||
</div>
|
||||
<div class="dashboardIoConfigPorts" data-io-config-ports></div>`;
|
||||
const portsEl = bodyEl.querySelector("[data-io-config-ports]");
|
||||
ports.forEach((port) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "dashboardIoConfigRow";
|
||||
row.innerHTML = `
|
||||
<span class="dashboardIoConfigPortLabel">${escapeHtml(t("dashboard.widget.ioPort", { port }))}</span>
|
||||
<button type="button" class="dashboardIoConfigToggle" data-port="${port}" data-value="off">${escapeHtml(t("dashboard.widget.ioOff"))}</button>
|
||||
<button type="button" class="dashboardIoConfigToggle is-primary" data-port="${port}" data-value="on">${escapeHtml(t("dashboard.widget.ioOn"))}</button>`;
|
||||
portsEl.appendChild(row);
|
||||
});
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const rt = await fetchIoRuntime(mod.id);
|
||||
portsEl.querySelectorAll("[data-port]").forEach((btn) => {
|
||||
const port = Number(btn.dataset.port);
|
||||
const outputs = Array.isArray(rt.outputs) ? rt.outputs : [];
|
||||
const idx = mod.module_type === "wise" ? port : port;
|
||||
const on = !!outputs[idx];
|
||||
btn.classList.toggle("is-active", (btn.dataset.value === "on") === on);
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
portsEl.addEventListener("click", async (evt) => {
|
||||
const btn = evt.target.closest("[data-port]");
|
||||
if (!btn || !widget.io_module_id) return;
|
||||
const port = Number(btn.dataset.port);
|
||||
const on = btn.dataset.value === "on";
|
||||
try {
|
||||
await setIoOutput(widget.io_module_id, port, on);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
void refresh();
|
||||
}
|
||||
|
||||
function widgetTypeLabel(type) {
|
||||
return t(`dashboard.widget.${type}`) || type;
|
||||
}
|
||||
@@ -986,6 +1148,16 @@
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.robot_summary"))}" />
|
||||
</div>`;
|
||||
} else if (type === "io_connect" || type === "io_status" || type === "io_configuration") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.ioModule")}</label>
|
||||
<select data-field="io_module_id">${ioModuleOptions(widget.io_module_id || "")}</select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${escapeHtml(widgetTypeLabel(type))}" />
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1209,6 +1381,15 @@
|
||||
case "robot_summary":
|
||||
renderRobotSummaryWidget(widget, bodyEl);
|
||||
break;
|
||||
case "io_connect":
|
||||
renderIoConnectWidget(widget, bodyEl);
|
||||
break;
|
||||
case "io_status":
|
||||
renderIoStatusWidget(widget, bodyEl);
|
||||
break;
|
||||
case "io_configuration":
|
||||
renderIoConfigurationWidget(widget, bodyEl);
|
||||
break;
|
||||
default:
|
||||
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
|
||||
}
|
||||
@@ -1245,6 +1426,9 @@
|
||||
if (widget.type === "mission_queue") refreshQueueWidget(bodyEl);
|
||||
if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl);
|
||||
if (widget.type === "mission_action_log") renderMissionActionLogWidget(widget, bodyEl);
|
||||
if (widget.type === "io_connect") renderIoConnectWidget(widget, bodyEl);
|
||||
if (widget.type === "io_status") renderIoStatusWidget(widget, bodyEl);
|
||||
if (widget.type === "io_configuration") renderIoConfigurationWidget(widget, bodyEl);
|
||||
});
|
||||
if (hasMapWidget()) refreshMapWidgets();
|
||||
}
|
||||
@@ -1260,6 +1444,8 @@
|
||||
};
|
||||
if (widget.type === "map" || widget.type === "map_locked") {
|
||||
void ensureMapsLoaded().then(open);
|
||||
} else if (widget.type === "io_connect" || widget.type === "io_status" || widget.type === "io_configuration") {
|
||||
void ensureIoModulesLoaded().then(open);
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
@@ -1484,6 +1670,7 @@
|
||||
async function init() {
|
||||
await loadStoreFromBackend();
|
||||
await loadUserGroups();
|
||||
await ensureIoModulesLoaded();
|
||||
bindEvents();
|
||||
setView("list");
|
||||
}
|
||||
@@ -1493,6 +1680,7 @@
|
||||
getNavItems,
|
||||
handleNav,
|
||||
onPageShow() {
|
||||
void ensureIoModulesLoaded();
|
||||
if (store.view === "designer") {
|
||||
syncDesignerEditMode();
|
||||
renderDesignerChrome();
|
||||
|
||||
280
www/i18n.js
280
www/i18n.js
@@ -74,6 +74,9 @@
|
||||
"nav.maps": "Maps",
|
||||
"nav.sounds": "Sounds",
|
||||
"nav.transitions": "Transitions",
|
||||
"nav.user-groups": "User groups",
|
||||
"nav.io-modules": "I/O modules",
|
||||
"nav.users": "Users",
|
||||
"nav.build-robot": "Build Robot",
|
||||
"nav.monitoring-log": "System log",
|
||||
"nav.integrations": "Tích hợp",
|
||||
@@ -194,6 +197,15 @@
|
||||
"dashboard.widget.map_locked": "Locked map",
|
||||
"dashboard.widget.map": "Map",
|
||||
"dashboard.widget.robot_summary": "Robot summary",
|
||||
"dashboard.widget.io_connect": "Connect I/O module",
|
||||
"dashboard.widget.io_status": "I/O status",
|
||||
"dashboard.widget.io_connectAction": "Connect",
|
||||
"dashboard.widget.io_disconnect": "Disconnect",
|
||||
"dashboard.widget.io_configuration": "I/O configuration",
|
||||
"dashboard.widget.ioPort": "Port {port}",
|
||||
"dashboard.widget.ioOn": "ON",
|
||||
"dashboard.widget.ioOff": "OFF",
|
||||
"dashboard.widget.field.ioModule": "I/O module",
|
||||
"dashboard.widget.field.map": "Map",
|
||||
"dashboard.widget.mapActive": "Active map (robot)",
|
||||
"dashboard.widget.mapHint": "Chọn map cố định hoặc để «Active map» dùng map đang gắn với robot.",
|
||||
@@ -304,6 +316,8 @@
|
||||
"maps.activeHint": "Map đang hoạt động: {name}",
|
||||
"maps.view": "Xem",
|
||||
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
|
||||
"maps.importNoSite": "Chọn site trước khi import.",
|
||||
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
|
||||
"maps.helpTitle": "Trợ giúp Maps",
|
||||
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
|
||||
"maps.createDialog.title": "Tạo map",
|
||||
@@ -555,6 +569,129 @@
|
||||
"transitions.deleteConfirmText": "Xóa transition {from} → {to}?",
|
||||
"transitions.error.missing": "Thiếu trường bắt buộc.",
|
||||
|
||||
"users.title": "Users",
|
||||
"users.subtitle": "Tạo và chỉnh sửa users.",
|
||||
"users.helpTitle": "Trợ giúp Users",
|
||||
"users.helpBody": "Mỗi người cần user profile. Name hiển thị góc phải khi đăng nhập; Username dùng để login. Quyền theo User group (§4.6). PIN 4 số chỉ cho nhóm cho phép. User tự đổi password qua menu góc phải.",
|
||||
"users.create": "Tạo user",
|
||||
"users.clearFilters": "Xóa bộ lọc",
|
||||
"users.filterLabel": "Lọc:",
|
||||
"users.filterPlaceholder": "Lọc theo tên hoặc username...",
|
||||
"users.itemsFound": "{n} mục",
|
||||
"users.pageOf": "Trang {page} / {total}",
|
||||
"users.colName": "Name",
|
||||
"users.colUsername": "Username",
|
||||
"users.colGroup": "User group",
|
||||
"users.colEmail": "Email",
|
||||
"users.colPin": "PIN",
|
||||
"users.colFunctions": "Functions",
|
||||
"users.empty": "Chưa có user.",
|
||||
"users.emptyFilter": "Không có user khớp bộ lọc.",
|
||||
"users.createTitle": "Tạo user",
|
||||
"users.editTitle": "Sửa user",
|
||||
"users.fieldName": "Name",
|
||||
"users.fieldUsername": "Username",
|
||||
"users.fieldPassword": "Password",
|
||||
"users.fieldEmail": "Email address",
|
||||
"users.fieldGroup": "User group",
|
||||
"users.fieldEnabled": "Enabled",
|
||||
"users.fieldPinEnable": "Cho phép đăng nhập PIN",
|
||||
"users.fieldPin": "Mã PIN (4 số)",
|
||||
"users.passwordEditHint": "User tự đổi password qua menu góc phải trên.",
|
||||
"users.pinGroupHint": "PIN chỉ dùng được với user group cho phép PIN.",
|
||||
"users.pinYes": "Có",
|
||||
"users.pinNo": "Không",
|
||||
"users.save": "Lưu",
|
||||
"users.deleteTitle": "Xóa user?",
|
||||
"users.deleteConfirmText": "Xóa user \"{name}\"?",
|
||||
"users.deleteHint": "Chỉ xóa hồ sơ user. Map, mission và dữ liệu khác do user tạo vẫn giữ nguyên.",
|
||||
"users.error.missing": "Thiếu trường bắt buộc.",
|
||||
"users.error.passwordRequired": "Nhập password khi tạo user.",
|
||||
"users.error.pinInvalid": "PIN phải đúng 4 chữ số.",
|
||||
|
||||
"userGroups.title": "User groups",
|
||||
"userGroups.subtitle": "Tạo và chỉnh sửa user groups.",
|
||||
"userGroups.helpTitle": "Trợ giúp User groups",
|
||||
"userGroups.helpBody": "Mỗi user thuộc một user group. Quyền (none/read/write) áp dụng cho từng module. Tạo group trước khi tạo user. PIN chỉ dùng khi bật Allow PIN sign-in.",
|
||||
"userGroups.create": "Tạo user group",
|
||||
"userGroups.clearFilters": "Xóa bộ lọc",
|
||||
"userGroups.filterLabel": "Lọc:",
|
||||
"userGroups.filterPlaceholder": "Lọc theo tên...",
|
||||
"userGroups.itemsFound": "{n} mục",
|
||||
"userGroups.pageOf": "Trang {page} / {total}",
|
||||
"userGroups.colName": "Name",
|
||||
"userGroups.colUsers": "Users",
|
||||
"userGroups.colPin": "PIN sign-in",
|
||||
"userGroups.colPermissions": "Permissions",
|
||||
"userGroups.colFunctions": "Functions",
|
||||
"userGroups.empty": "Chưa có user group.",
|
||||
"userGroups.emptyFilter": "Không có user group khớp bộ lọc.",
|
||||
"userGroups.createTitle": "Tạo user group",
|
||||
"userGroups.editTitle": "Sửa user group",
|
||||
"userGroups.fieldName": "Name",
|
||||
"userGroups.fieldAllowPin": "Cho phép đăng nhập PIN",
|
||||
"userGroups.permissionsTitle": "Permissions",
|
||||
"userGroups.permissionsHint": "Gán quyền read hoặc write cho từng module. None ẩn module khỏi menu.",
|
||||
"userGroups.permColModule": "Module",
|
||||
"userGroups.permColAccess": "Access",
|
||||
"userGroups.perm.dashboard": "Dashboards",
|
||||
"userGroups.perm.config": "Build robot",
|
||||
"userGroups.perm.maps": "Maps",
|
||||
"userGroups.perm.missions": "Missions",
|
||||
"userGroups.perm.sounds": "Sounds",
|
||||
"userGroups.perm.integrations": "Integrations",
|
||||
"userGroups.perm.users": "Users & User groups",
|
||||
"userGroups.permLevel.none": "None",
|
||||
"userGroups.permLevel.read": "Read",
|
||||
"userGroups.permLevel.write": "Write",
|
||||
"userGroups.permSummaryAllWrite": "Full write",
|
||||
"userGroups.permSummaryNone": "No access",
|
||||
"userGroups.permSummaryMixed": "{write} write, {read} read",
|
||||
"userGroups.pinYes": "Có",
|
||||
"userGroups.pinNo": "Không",
|
||||
"userGroups.userCount": "{n} user(s)",
|
||||
"userGroups.save": "Lưu",
|
||||
"userGroups.deleteTitle": "Xóa user group?",
|
||||
"userGroups.deleteConfirmText": "Xóa user group \"{name}\"?",
|
||||
"userGroups.deleteHint": "Phải chuyển users sang group khác trước khi xóa.",
|
||||
"userGroups.error.missing": "Thiếu trường bắt buộc.",
|
||||
|
||||
"ioModules.title": "I/O modules",
|
||||
"ioModules.subtitle": "Tạo và quản lý kết nối I/O.",
|
||||
"ioModules.helpTitle": "Trợ giúp I/O modules",
|
||||
"ioModules.helpBody": "I/O module (Bluetooth hoặc WISE) dùng để giao tiếp cửa, thang nâng, băng tải… Nhập IP và chọn loại module. Dùng trong mission (Set output / Wait for input) và I/O zones trên map.",
|
||||
"ioModules.create": "Tạo I/O connection",
|
||||
"ioModules.clearFilters": "Xóa bộ lọc",
|
||||
"ioModules.filterLabel": "Lọc:",
|
||||
"ioModules.filterPlaceholder": "Lọc theo tên hoặc IP...",
|
||||
"ioModules.itemsFound": "{n} mục",
|
||||
"ioModules.pageOf": "Trang {page} / {total}",
|
||||
"ioModules.colName": "Name",
|
||||
"ioModules.colType": "Type",
|
||||
"ioModules.colIp": "IP address",
|
||||
"ioModules.colStatus": "Status",
|
||||
"ioModules.colFunctions": "Functions",
|
||||
"ioModules.empty": "Chưa có I/O module.",
|
||||
"ioModules.emptyFilter": "Không có I/O module khớp bộ lọc.",
|
||||
"ioModules.createTitle": "Tạo I/O connection",
|
||||
"ioModules.editTitle": "Sửa I/O connection",
|
||||
"ioModules.fieldSite": "Site",
|
||||
"ioModules.fieldName": "Name",
|
||||
"ioModules.fieldType": "I/O module type",
|
||||
"ioModules.fieldIp": "IP address",
|
||||
"ioModules.type.bluetooth": "Bluetooth",
|
||||
"ioModules.type.wise": "WISE",
|
||||
"ioModules.typeHint.bluetooth": "Output ports 1–4.",
|
||||
"ioModules.typeHint.wise": "Output ports 0–3.",
|
||||
"ioModules.status.connected": "Connected",
|
||||
"ioModules.status.disconnected": "Disconnected",
|
||||
"ioModules.save": "Lưu",
|
||||
"ioModules.deleteTitle": "Xóa I/O connection?",
|
||||
"ioModules.deleteConfirmText": "Xóa I/O module \"{name}\"?",
|
||||
"ioModules.error.missing": "Thiếu trường bắt buộc.",
|
||||
"ioModules.testConnection": "Kiểm tra kết nối",
|
||||
"ioModules.testOk": "Kết nối TCP thành công.",
|
||||
|
||||
"missions.title": "Missions",
|
||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||
"missions.create": "Tạo mission",
|
||||
@@ -618,6 +755,9 @@
|
||||
"missions.action.pause": "Pause",
|
||||
"missions.action.set_digital_output": "Set digital output",
|
||||
"missions.action.wait_digital_input": "Wait for digital input",
|
||||
"missions.action.connect_bluetooth": "Connect Bluetooth module",
|
||||
"missions.action.disconnect_bluetooth": "Disconnect Bluetooth module",
|
||||
"missions.action.while": "While",
|
||||
"missions.action.set_plc_register": "Set PLC register",
|
||||
"missions.action.pick_cart": "Pick cart",
|
||||
"missions.action.drop_cart": "Drop cart",
|
||||
@@ -745,6 +885,9 @@
|
||||
"nav.maps": "Maps",
|
||||
"nav.sounds": "Sounds",
|
||||
"nav.transitions": "Transitions",
|
||||
"nav.user-groups": "User groups",
|
||||
"nav.io-modules": "I/O modules",
|
||||
"nav.users": "Users",
|
||||
"nav.build-robot": "Build Robot",
|
||||
"nav.monitoring-log": "System log",
|
||||
"nav.integrations": "Integrations",
|
||||
@@ -865,6 +1008,15 @@
|
||||
"dashboard.widget.map_locked": "Locked map",
|
||||
"dashboard.widget.map": "Map",
|
||||
"dashboard.widget.robot_summary": "Robot summary",
|
||||
"dashboard.widget.io_connect": "Connect I/O module",
|
||||
"dashboard.widget.io_status": "I/O status",
|
||||
"dashboard.widget.io_connectAction": "Connect",
|
||||
"dashboard.widget.io_disconnect": "Disconnect",
|
||||
"dashboard.widget.io_configuration": "I/O configuration",
|
||||
"dashboard.widget.ioPort": "Port {port}",
|
||||
"dashboard.widget.ioOn": "ON",
|
||||
"dashboard.widget.ioOff": "OFF",
|
||||
"dashboard.widget.field.ioModule": "I/O module",
|
||||
"dashboard.widget.field.map": "Map",
|
||||
"dashboard.widget.mapActive": "Active map (robot)",
|
||||
"dashboard.widget.mapHint": "Pick a fixed map or leave «Active map» to follow the robot's current map.",
|
||||
@@ -975,6 +1127,8 @@
|
||||
"maps.activeHint": "Active map: {name}",
|
||||
"maps.view": "View",
|
||||
"maps.importComingSoon": "Import site will be available in a future release.",
|
||||
"maps.importNoSite": "Select a site before importing.",
|
||||
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
|
||||
"maps.helpTitle": "Maps help",
|
||||
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
|
||||
"maps.createDialog.title": "Create map",
|
||||
@@ -1226,6 +1380,129 @@
|
||||
"transitions.deleteConfirmText": "Delete transition {from} → {to}?",
|
||||
"transitions.error.missing": "Missing required fields.",
|
||||
|
||||
"users.title": "Users",
|
||||
"users.subtitle": "Create and edit users.",
|
||||
"users.helpTitle": "Users help",
|
||||
"users.helpBody": "Everyone needs a user profile. Name is shown in the upper right when signed in; Username is used to log in. Permissions come from the user group (§4.6). PIN is only for groups that allow PIN sign-in. Users change their own password from the menu in the upper right.",
|
||||
"users.create": "Create user",
|
||||
"users.clearFilters": "Clear filters",
|
||||
"users.filterLabel": "Filter:",
|
||||
"users.filterPlaceholder": "Filter by name or username...",
|
||||
"users.itemsFound": "{n} item(s) found",
|
||||
"users.pageOf": "Page {page} of {total}",
|
||||
"users.colName": "Name",
|
||||
"users.colUsername": "Username",
|
||||
"users.colGroup": "User group",
|
||||
"users.colEmail": "Email",
|
||||
"users.colPin": "PIN",
|
||||
"users.colFunctions": "Functions",
|
||||
"users.empty": "No users yet.",
|
||||
"users.emptyFilter": "No users match the filter.",
|
||||
"users.createTitle": "Create user",
|
||||
"users.editTitle": "Edit user",
|
||||
"users.fieldName": "Name",
|
||||
"users.fieldUsername": "Username",
|
||||
"users.fieldPassword": "Password",
|
||||
"users.fieldEmail": "Email address",
|
||||
"users.fieldGroup": "User group",
|
||||
"users.fieldEnabled": "Enabled",
|
||||
"users.fieldPinEnable": "Allow PIN sign-in",
|
||||
"users.fieldPin": "PIN code (4 digits)",
|
||||
"users.passwordEditHint": "Users change their own password from the menu in the upper right corner.",
|
||||
"users.pinGroupHint": "PIN is only available for user groups that allow PIN sign-in.",
|
||||
"users.pinYes": "Yes",
|
||||
"users.pinNo": "No",
|
||||
"users.save": "Save",
|
||||
"users.deleteTitle": "Delete user?",
|
||||
"users.deleteConfirmText": "Delete user \"{name}\"?",
|
||||
"users.deleteHint": "Only the user profile is removed. Maps, missions and other data created by this user remain unchanged.",
|
||||
"users.error.missing": "Missing required fields.",
|
||||
"users.error.passwordRequired": "Enter a password when creating a user.",
|
||||
"users.error.pinInvalid": "PIN must be exactly 4 digits.",
|
||||
|
||||
"userGroups.title": "User groups",
|
||||
"userGroups.subtitle": "Create and edit user groups.",
|
||||
"userGroups.helpTitle": "User groups help",
|
||||
"userGroups.helpBody": "Each user belongs to one user group. Permissions (none/read/write) apply per module. Create groups before users. PIN sign-in works only when Allow PIN sign-in is enabled.",
|
||||
"userGroups.create": "Create user group",
|
||||
"userGroups.clearFilters": "Clear filters",
|
||||
"userGroups.filterLabel": "Filter:",
|
||||
"userGroups.filterPlaceholder": "Filter by name...",
|
||||
"userGroups.itemsFound": "{n} item(s) found",
|
||||
"userGroups.pageOf": "Page {page} of {total}",
|
||||
"userGroups.colName": "Name",
|
||||
"userGroups.colUsers": "Users",
|
||||
"userGroups.colPin": "PIN sign-in",
|
||||
"userGroups.colPermissions": "Permissions",
|
||||
"userGroups.colFunctions": "Functions",
|
||||
"userGroups.empty": "No user groups yet.",
|
||||
"userGroups.emptyFilter": "No user groups match the filter.",
|
||||
"userGroups.createTitle": "Create user group",
|
||||
"userGroups.editTitle": "Edit user group",
|
||||
"userGroups.fieldName": "Name",
|
||||
"userGroups.fieldAllowPin": "Allow PIN sign-in",
|
||||
"userGroups.permissionsTitle": "Permissions",
|
||||
"userGroups.permissionsHint": "Set read or write access per module. None hides the module from the menu.",
|
||||
"userGroups.permColModule": "Module",
|
||||
"userGroups.permColAccess": "Access",
|
||||
"userGroups.perm.dashboard": "Dashboards",
|
||||
"userGroups.perm.config": "Build robot",
|
||||
"userGroups.perm.maps": "Maps",
|
||||
"userGroups.perm.missions": "Missions",
|
||||
"userGroups.perm.sounds": "Sounds",
|
||||
"userGroups.perm.integrations": "Integrations",
|
||||
"userGroups.perm.users": "Users & User groups",
|
||||
"userGroups.permLevel.none": "None",
|
||||
"userGroups.permLevel.read": "Read",
|
||||
"userGroups.permLevel.write": "Write",
|
||||
"userGroups.permSummaryAllWrite": "Full write",
|
||||
"userGroups.permSummaryNone": "No access",
|
||||
"userGroups.permSummaryMixed": "{write} write, {read} read",
|
||||
"userGroups.pinYes": "Yes",
|
||||
"userGroups.pinNo": "No",
|
||||
"userGroups.userCount": "{n} user(s)",
|
||||
"userGroups.save": "Save",
|
||||
"userGroups.deleteTitle": "Delete user group?",
|
||||
"userGroups.deleteConfirmText": "Delete user group \"{name}\"?",
|
||||
"userGroups.deleteHint": "Users must be moved to another group before this group can be deleted.",
|
||||
"userGroups.error.missing": "Missing required fields.",
|
||||
|
||||
"ioModules.title": "I/O modules",
|
||||
"ioModules.subtitle": "Create and manage I/O connections.",
|
||||
"ioModules.helpTitle": "I/O modules help",
|
||||
"ioModules.helpBody": "I/O modules (Bluetooth or WISE) communicate with doors, lifts, conveyors, etc. Enter the IP and module type. Use in missions (Set output / Wait for input) and I/O zones on maps.",
|
||||
"ioModules.create": "Create I/O connection",
|
||||
"ioModules.clearFilters": "Clear filters",
|
||||
"ioModules.filterLabel": "Filter:",
|
||||
"ioModules.filterPlaceholder": "Filter by name or IP...",
|
||||
"ioModules.itemsFound": "{n} item(s) found",
|
||||
"ioModules.pageOf": "Page {page} of {total}",
|
||||
"ioModules.colName": "Name",
|
||||
"ioModules.colType": "Type",
|
||||
"ioModules.colIp": "IP address",
|
||||
"ioModules.colStatus": "Status",
|
||||
"ioModules.colFunctions": "Functions",
|
||||
"ioModules.empty": "No I/O modules yet.",
|
||||
"ioModules.emptyFilter": "No I/O modules match the filter.",
|
||||
"ioModules.createTitle": "Create I/O connection",
|
||||
"ioModules.editTitle": "Edit I/O connection",
|
||||
"ioModules.fieldSite": "Site",
|
||||
"ioModules.fieldName": "Name",
|
||||
"ioModules.fieldType": "I/O module type",
|
||||
"ioModules.fieldIp": "IP address",
|
||||
"ioModules.type.bluetooth": "Bluetooth",
|
||||
"ioModules.type.wise": "WISE",
|
||||
"ioModules.typeHint.bluetooth": "Output ports 1–4.",
|
||||
"ioModules.typeHint.wise": "Output ports 0–3.",
|
||||
"ioModules.status.connected": "Connected",
|
||||
"ioModules.status.disconnected": "Disconnected",
|
||||
"ioModules.save": "Save",
|
||||
"ioModules.deleteTitle": "Delete I/O connection?",
|
||||
"ioModules.deleteConfirmText": "Delete I/O module \"{name}\"?",
|
||||
"ioModules.error.missing": "Missing required fields.",
|
||||
"ioModules.testConnection": "Test connection",
|
||||
"ioModules.testOk": "TCP connection successful.",
|
||||
|
||||
"missions.title": "Missions",
|
||||
"missions.subtitle": "Setup → Missions — robot task list.",
|
||||
"missions.create": "Create mission",
|
||||
@@ -1289,6 +1566,9 @@
|
||||
"missions.action.pause": "Pause",
|
||||
"missions.action.set_digital_output": "Set digital output",
|
||||
"missions.action.wait_digital_input": "Wait for digital input",
|
||||
"missions.action.connect_bluetooth": "Connect Bluetooth module",
|
||||
"missions.action.disconnect_bluetooth": "Disconnect Bluetooth module",
|
||||
"missions.action.while": "While",
|
||||
"missions.action.set_plc_register": "Set PLC register",
|
||||
"missions.action.pick_cart": "Pick cart",
|
||||
"missions.action.drop_cart": "Drop cart",
|
||||
|
||||
329
www/index.html
329
www/index.html
@@ -358,7 +358,9 @@
|
||||
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">Widget nhóm này sẽ có trong bản cập nhật sau.</p>
|
||||
</div>
|
||||
<div class="dashboardMirWidgetPanel" data-panel="io" hidden>
|
||||
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">I/O widgets — sắp có.</p>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_connect" data-i18n="dashboard.widget.io_connect">Connect I/O module</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_status" data-i18n="dashboard.widget.io_status">I/O status</button>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_configuration" data-i18n="dashboard.widget.io_configuration">I/O configuration</button>
|
||||
</div>
|
||||
<div class="dashboardMirWidgetPanel" data-panel="misc" hidden>
|
||||
<button type="button" class="dashboardMirWidgetPick" data-add-widget="robot_summary" data-i18n="dashboard.widget.robot_summary">Robot summary</button>
|
||||
@@ -1069,6 +1071,323 @@
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageUserGroups" data-page-content="user-groups" hidden>
|
||||
<div id="userGroupsListView" class="mapsMirPage">
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="userGroups.title">User groups</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span data-i18n="userGroups.subtitle">Create and edit user groups.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="userGroupsHelpBtn" data-i18n-title="userGroups.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="userGroupCreateBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="userGroups.create">Create user group</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupsClearFiltersBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="userGroups.clearFilters">Clear filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="userGroupsFilterInput" data-i18n="userGroups.filterLabel">Filter:</label>
|
||||
<input type="search" id="userGroupsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="userGroups.filterPlaceholder" placeholder="Filter by name..." autocomplete="off" />
|
||||
<span id="userGroupsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||
<div class="mapsMirPager">
|
||||
<button type="button" class="mapsMirPageBtn" id="userGroupsPageFirst" aria-label="First page">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="userGroupsPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="userGroupsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="userGroupsPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="userGroupsPageLast" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--userGroups" id="userGroupsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="userGroupsMirThIcon" aria-hidden="true"></th>
|
||||
<th data-i18n="userGroups.colName">Name</th>
|
||||
<th data-i18n="userGroups.colUsers">Users</th>
|
||||
<th data-i18n="userGroups.colPin">PIN sign-in</th>
|
||||
<th data-i18n="userGroups.colPermissions">Permissions</th>
|
||||
<th class="mapsMirThFunctions" data-i18n="userGroups.colFunctions">Functions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userGroupList"></tbody>
|
||||
</table>
|
||||
<div id="userGroupListEmpty" class="mapsMirEmpty" hidden data-i18n="userGroups.empty">No user groups yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="userGroupEditDialog" class="mapsMirDialog mapsMirDialog--wide">
|
||||
<form id="userGroupEditForm" method="dialog">
|
||||
<h2 class="mapsMirDialogTitle" id="userGroupEditTitle" data-i18n="userGroups.createTitle">Create user group</h2>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="userGroups.fieldName">Name</span>
|
||||
<input type="text" id="userGroupEditName" required />
|
||||
</label>
|
||||
<label class="mapsMirField mapsMirField--checkbox">
|
||||
<input type="checkbox" id="userGroupEditAllowPin" />
|
||||
<span data-i18n="userGroups.fieldAllowPin">Allow PIN sign-in</span>
|
||||
</label>
|
||||
<div class="userGroupsPermSection">
|
||||
<h3 class="userGroupsPermTitle" data-i18n="userGroups.permissionsTitle">Permissions</h3>
|
||||
<p class="mapsMirFieldHint" data-i18n="userGroups.permissionsHint">Set read or write access per module. None hides the module from the menu.</p>
|
||||
<table class="userGroupsPermTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="userGroups.permColModule">Module</th>
|
||||
<th data-i18n="userGroups.permColAccess">Access</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userGroupEditPermsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupEditCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userGroupEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
||||
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="userGroupEditSaveBtn" data-i18n="userGroups.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="userGroupDeleteConfirmDialog" class="mapsMirDialog">
|
||||
<div class="mapsMirDialogPanel">
|
||||
<h2 class="mapsMirDialogTitle" data-i18n="userGroups.deleteTitle">Delete user group?</h2>
|
||||
<p id="userGroupDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||
<p class="mapsMirDialogHint" data-i18n="userGroups.deleteHint">Users must be moved to another group before this group can be deleted.</p>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userGroupDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageIoModules" data-page-content="io-modules" hidden>
|
||||
<div id="ioModulesListView" class="mapsMirPage">
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="ioModules.title">I/O modules</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span data-i18n="ioModules.subtitle">Create and manage I/O connections.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="ioModulesHelpBtn" data-i18n-title="ioModules.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="ioModuleCreateBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="ioModules.create">Create I/O connection</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModulesClearFiltersBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="ioModules.clearFilters">Clear filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="ioModulesFilterInput" data-i18n="ioModules.filterLabel">Filter:</label>
|
||||
<input type="search" id="ioModulesFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="ioModules.filterPlaceholder" placeholder="Filter by name or IP..." autocomplete="off" />
|
||||
<span id="ioModulesFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||
<div class="mapsMirPager">
|
||||
<button type="button" class="mapsMirPageBtn" id="ioModulesPageFirst" aria-label="First page">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="ioModulesPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="ioModulesPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="ioModulesPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="ioModulesPageLast" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--ioModules" id="ioModulesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ioModulesMirThIcon" aria-hidden="true"></th>
|
||||
<th data-i18n="ioModules.colName">Name</th>
|
||||
<th data-i18n="ioModules.colType">Type</th>
|
||||
<th data-i18n="ioModules.colIp">IP address</th>
|
||||
<th data-i18n="ioModules.colStatus">Status</th>
|
||||
<th class="mapsMirThFunctions" data-i18n="ioModules.colFunctions">Functions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ioModuleList"></tbody>
|
||||
</table>
|
||||
<div id="ioModuleListEmpty" class="mapsMirEmpty" hidden data-i18n="ioModules.empty">No I/O modules yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="ioModuleEditDialog" class="mapsMirDialog">
|
||||
<form id="ioModuleEditForm" method="dialog">
|
||||
<h2 class="mapsMirDialogTitle" id="ioModuleEditTitle" data-i18n="ioModules.createTitle">Create I/O connection</h2>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldSite">Site</span>
|
||||
<select id="ioModuleEditSite" required></select>
|
||||
</label>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldName">Name</span>
|
||||
<input type="text" id="ioModuleEditName" required autocomplete="off" />
|
||||
</label>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldType">I/O module type</span>
|
||||
<select id="ioModuleEditType" required>
|
||||
<option value="bluetooth" data-i18n="ioModules.type.bluetooth">Bluetooth</option>
|
||||
<option value="wise" data-i18n="ioModules.type.wise">WISE</option>
|
||||
</select>
|
||||
</label>
|
||||
<p id="ioModuleEditTypeHint" class="mapsMirFieldHint" data-i18n="ioModules.typeHint.bluetooth">Output ports 1–4.</p>
|
||||
<div class="mapsMirFieldRow mapsMirFieldRow--actions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleTestBtn" data-i18n="ioModules.testConnection">Test connection</button>
|
||||
</div>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldIp">IP address</span>
|
||||
<input type="text" id="ioModuleEditIp" required autocomplete="off" inputmode="decimal" />
|
||||
</label>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleEditCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="ioModuleEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
||||
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="ioModuleEditSaveBtn" data-i18n="ioModules.save">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="ioModuleDeleteConfirmDialog" class="mapsMirDialog">
|
||||
<div class="mapsMirDialogPanel">
|
||||
<h2 class="mapsMirDialogTitle" data-i18n="ioModules.deleteTitle">Delete I/O connection?</h2>
|
||||
<p id="ioModuleDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="ioModuleDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageUsers" data-page-content="users" hidden>
|
||||
<div id="usersListView" class="mapsMirPage">
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="users.title">Users</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span data-i18n="users.subtitle">Create and edit users.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="usersHelpBtn" data-i18n-title="users.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="userCreateBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="users.create">Create user</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="usersClearFiltersBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="users.clearFilters">Clear filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="usersFilterInput" data-i18n="users.filterLabel">Filter:</label>
|
||||
<input type="search" id="usersFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="users.filterPlaceholder" placeholder="Filter by name or username..." autocomplete="off" />
|
||||
<span id="usersFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||
<div class="mapsMirPager">
|
||||
<button type="button" class="mapsMirPageBtn" id="usersPageFirst" aria-label="First page">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="usersPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="usersPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="usersPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="usersPageLast" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--users" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="usersMirThIcon" aria-hidden="true"></th>
|
||||
<th data-i18n="users.colName">Name</th>
|
||||
<th data-i18n="users.colUsername">Username</th>
|
||||
<th data-i18n="users.colGroup">User group</th>
|
||||
<th data-i18n="users.colEmail">Email</th>
|
||||
<th data-i18n="users.colPin">PIN</th>
|
||||
<th class="mapsMirThFunctions" data-i18n="users.colFunctions">Functions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userList"></tbody>
|
||||
</table>
|
||||
<div id="userListEmpty" class="mapsMirEmpty" hidden data-i18n="users.empty">No users yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="userEditDialog" class="mapsMirDialog">
|
||||
<form id="userEditForm" method="dialog">
|
||||
<h2 class="mapsMirDialogTitle" id="userEditTitle" data-i18n="users.createTitle">Create user</h2>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="users.fieldName">Name</span>
|
||||
<input type="text" id="userEditDisplayName" autocomplete="name" required />
|
||||
</label>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="users.fieldUsername">Username</span>
|
||||
<input type="text" id="userEditUsername" autocomplete="username" required />
|
||||
</label>
|
||||
<label class="mapsMirField" id="userEditPasswordField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="users.fieldPassword">Password</span>
|
||||
<input type="password" id="userEditPassword" autocomplete="new-password" minlength="4" />
|
||||
</label>
|
||||
<p id="userEditPasswordHint" class="mapsMirFieldHint" hidden data-i18n="users.passwordEditHint">Users change their own password from the menu in the upper right corner.</p>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="users.fieldEmail">Email address</span>
|
||||
<input type="email" id="userEditEmail" autocomplete="email" />
|
||||
</label>
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="users.fieldGroup">User group</span>
|
||||
<select id="userEditGroup" required></select>
|
||||
</label>
|
||||
<label class="mapsMirField mapsMirField--checkbox">
|
||||
<input type="checkbox" id="userEditEnabled" checked />
|
||||
<span data-i18n="users.fieldEnabled">Enabled</span>
|
||||
</label>
|
||||
<div id="userEditPinSection" class="mapsMirField">
|
||||
<label class="mapsMirField mapsMirField--checkbox">
|
||||
<input type="checkbox" id="userEditPinEnabled" />
|
||||
<span data-i18n="users.fieldPinEnable">Allow PIN sign-in</span>
|
||||
</label>
|
||||
<label class="mapsMirField" id="userEditPinField" hidden>
|
||||
<span class="mapsMirFieldLabel" data-i18n="users.fieldPin">PIN code (4 digits)</span>
|
||||
<input type="text" id="userEditPin" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="off" />
|
||||
</label>
|
||||
<span id="userEditPinHint" class="mapsMirFieldHint" hidden data-i18n="users.pinGroupHint">PIN is only available for user groups that allow PIN sign-in.</span>
|
||||
</div>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userEditCancelBtn" data-i18n="common.cancel">Cancel</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
||||
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="userEditSaveBtn" data-i18n="users.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="userDeleteConfirmDialog" class="mapsMirDialog">
|
||||
<div class="mapsMirDialogPanel">
|
||||
<h2 class="mapsMirDialogTitle" data-i18n="users.deleteTitle">Delete user?</h2>
|
||||
<p id="userDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||
<p class="mapsMirDialogHint" data-i18n="users.deleteHint">Only the user profile is removed. Maps, missions and other data created by this user remain unchanged.</p>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageMaps" data-page-content="maps" hidden>
|
||||
<div id="mapsListView" class="mapsMirPage">
|
||||
<header class="mapsMirHeader">
|
||||
@@ -1666,10 +1985,7 @@
|
||||
<label class="mapsMirField">
|
||||
<span class="mapsMirFieldLabel" data-i18n="maps.editor.io.module">I/O module</span>
|
||||
<input type="text" id="mapIoModule" list="mapIoModuleList" autocomplete="off" required />
|
||||
<datalist id="mapIoModuleList">
|
||||
<option value="GPIO module 1"></option>
|
||||
<option value="PLC I/O 1"></option>
|
||||
</datalist>
|
||||
<datalist id="mapIoModuleList"></datalist>
|
||||
</label>
|
||||
<div class="mapsMirFieldRow">
|
||||
<label class="mapsMirField">
|
||||
@@ -2238,6 +2554,9 @@ GET /api/v2.0.0/status</pre>
|
||||
<script src="/maps.js"></script>
|
||||
<script src="/sounds.js"></script>
|
||||
<script src="/transitions.js"></script>
|
||||
<script src="/users.js"></script>
|
||||
<script src="/user-groups.js"></script>
|
||||
<script src="/io-modules.js"></script>
|
||||
<script src="/map-editor.js"></script>
|
||||
<script src="/topbar.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
|
||||
404
www/io-modules.js
Normal file
404
www/io-modules.js
Normal file
@@ -0,0 +1,404 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const ICONS = {
|
||||
io: `<svg class="ioModulesMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="6" width="14" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="7" cy="10" r="1.2" fill="currentColor"/><circle cx="13" cy="10" r="1.2" fill="currentColor"/><path d="M10 3v3M10 14v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
|
||||
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const listEl = el("ioModuleList");
|
||||
const emptyEl = el("ioModuleListEmpty");
|
||||
const tableEl = el("ioModulesTable");
|
||||
const filterInputEl = el("ioModulesFilterInput");
|
||||
const filterCountEl = el("ioModulesFilterCount");
|
||||
const pageLabelEl = el("ioModulesPageLabel");
|
||||
const dialogEl = el("ioModuleEditDialog");
|
||||
const formEl = el("ioModuleEditForm");
|
||||
const titleEl = el("ioModuleEditTitle");
|
||||
const deleteBtnEl = el("ioModuleEditDeleteBtn");
|
||||
const typeHintEl = el("ioModuleEditTypeHint");
|
||||
const deleteConfirmDialogEl = el("ioModuleDeleteConfirmDialog");
|
||||
const deleteConfirmTextEl = el("ioModuleDeleteConfirmText");
|
||||
|
||||
const fields = {
|
||||
site: el("ioModuleEditSite"),
|
||||
name: el("ioModuleEditName"),
|
||||
type: el("ioModuleEditType"),
|
||||
ip: el("ioModuleEditIp"),
|
||||
};
|
||||
|
||||
const store = {
|
||||
modules: [],
|
||||
sites: [],
|
||||
editingId: null,
|
||||
pendingDeleteId: null,
|
||||
filter: "",
|
||||
page: 1,
|
||||
};
|
||||
|
||||
function canWrite() {
|
||||
if (!window.AuthApp?.canWrite) return true;
|
||||
return window.AuthApp.canWrite("integrations");
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, { credentials: "include", ...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 defaultSiteId() {
|
||||
return store.sites[0]?.id || "site_configuration";
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
if (type === "bluetooth") return t("ioModules.type.bluetooth");
|
||||
if (type === "wise") return t("ioModules.type.wise");
|
||||
return type || "—";
|
||||
}
|
||||
|
||||
function statusLabel(mod) {
|
||||
return mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected");
|
||||
}
|
||||
|
||||
function updateTypeHint() {
|
||||
if (!typeHintEl || !fields.type) return;
|
||||
const type = fields.type.value;
|
||||
typeHintEl.textContent =
|
||||
type === "wise" ? t("ioModules.typeHint.wise") : t("ioModules.typeHint.bluetooth");
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const [sitesData, modulesData] = await Promise.all([
|
||||
apiJson("/api/sites"),
|
||||
apiJson("/api/io_modules"),
|
||||
]);
|
||||
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
|
||||
store.modules = Array.isArray(modulesData.io_modules) ? modulesData.io_modules : [];
|
||||
return store.modules;
|
||||
}
|
||||
|
||||
function moduleById(id) {
|
||||
return store.modules.find((m) => m.id === id) || null;
|
||||
}
|
||||
|
||||
function filteredModules() {
|
||||
const q = store.filter.trim().toLowerCase();
|
||||
let items = [...store.modules].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
if (q) {
|
||||
items = items.filter((m) => {
|
||||
const name = (m.name || "").toLowerCase();
|
||||
const ip = (m.ip_address || "").toLowerCase();
|
||||
const type = (m.module_type || "").toLowerCase();
|
||||
return name.includes(q) || ip.includes(q) || type.includes(q);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function pagedItems(items) {
|
||||
const totalPages = pageCount(items.length);
|
||||
if (store.page > totalPages) store.page = totalPages;
|
||||
if (store.page < 1) store.page = 1;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
return items.slice(start, start + PAGE_SIZE);
|
||||
}
|
||||
|
||||
function updatePagerUi(totalItems) {
|
||||
const totalPages = pageCount(totalItems);
|
||||
if (filterCountEl) filterCountEl.textContent = t("ioModules.itemsFound", { n: totalItems });
|
||||
if (pageLabelEl) pageLabelEl.textContent = t("ioModules.pageOf", { page: store.page, total: totalPages });
|
||||
const atStart = store.page <= 1;
|
||||
const atEnd = store.page >= totalPages;
|
||||
el("ioModulesPageFirst")?.toggleAttribute("disabled", atStart);
|
||||
el("ioModulesPagePrev")?.toggleAttribute("disabled", atStart);
|
||||
el("ioModulesPageNext")?.toggleAttribute("disabled", atEnd);
|
||||
el("ioModulesPageLast")?.toggleAttribute("disabled", atEnd);
|
||||
}
|
||||
|
||||
function fillSiteSelect(selectedId) {
|
||||
if (!fields.site) return;
|
||||
fields.site.innerHTML = "";
|
||||
store.sites.forEach((site) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = site.id;
|
||||
opt.textContent = site.name || site.id;
|
||||
if (site.id === selectedId) opt.selected = true;
|
||||
fields.site.appendChild(opt);
|
||||
});
|
||||
if (!fields.site.value && store.sites[0]) fields.site.value = store.sites[0].id;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
const items = filteredModules();
|
||||
const pageItems = pagedItems(items);
|
||||
updatePagerUi(items.length);
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const showEmpty = items.length === 0;
|
||||
if (tableEl) tableEl.hidden = showEmpty;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = !showEmpty;
|
||||
emptyEl.textContent = store.filter.trim() ? t("ioModules.emptyFilter") : t("ioModules.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((mod) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "mapsMirRow ioModulesMirRow";
|
||||
tr.dataset.id = mod.id;
|
||||
|
||||
const actions = canWrite()
|
||||
? `<div class="mapsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn ioModuleEditBtn" data-edit="${escapeHtml(mod.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
||||
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger ioModuleDeleteBtn" data-delete="${escapeHtml(mod.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const statusClass = mod.connected ? "ioModulesMirStatus--on" : "ioModulesMirStatus--off";
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="ioModulesMirCellIcon">${ICONS.io}</td>
|
||||
<td class="ioModulesMirCellName">
|
||||
<button type="button" class="mapsMirNameLink ioModulesMirNameLink" data-edit="${escapeHtml(mod.id)}">${escapeHtml(mod.name || "—")}</button>
|
||||
</td>
|
||||
<td>${escapeHtml(typeLabel(mod.module_type))}</td>
|
||||
<td>${escapeHtml(mod.ip_address || "—")}</td>
|
||||
<td><span class="ioModulesMirStatus ${statusClass}">${escapeHtml(statusLabel(mod))}</span></td>
|
||||
<td class="mapsMirCellFunctions">${actions}</td>
|
||||
`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
store.editingId = null;
|
||||
if (titleEl) titleEl.textContent = t("ioModules.createTitle");
|
||||
fillSiteSelect(defaultSiteId());
|
||||
if (fields.name) fields.name.value = "";
|
||||
if (fields.type) fields.type.value = "bluetooth";
|
||||
if (fields.ip) fields.ip.value = "";
|
||||
updateTypeHint();
|
||||
deleteBtnEl?.toggleAttribute("hidden", true);
|
||||
dialogEl?.showModal();
|
||||
}
|
||||
|
||||
function openEditDialog(id) {
|
||||
const mod = moduleById(id);
|
||||
if (!mod) return;
|
||||
store.editingId = id;
|
||||
if (titleEl) titleEl.textContent = t("ioModules.editTitle");
|
||||
fillSiteSelect(mod.site_id || defaultSiteId());
|
||||
if (fields.name) fields.name.value = mod.name || "";
|
||||
if (fields.type) fields.type.value = mod.module_type || "bluetooth";
|
||||
if (fields.ip) fields.ip.value = mod.ip_address || "";
|
||||
updateTypeHint();
|
||||
deleteBtnEl?.toggleAttribute("hidden", false);
|
||||
dialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function saveModule(evt) {
|
||||
evt?.preventDefault();
|
||||
const payload = {
|
||||
site_id: fields.site?.value || defaultSiteId(),
|
||||
name: (fields.name?.value || "").trim(),
|
||||
module_type: fields.type?.value || "bluetooth",
|
||||
ip_address: (fields.ip?.value || "").trim(),
|
||||
};
|
||||
if (!payload.name || !payload.ip_address) {
|
||||
alert(t("ioModules.error.missing"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (store.editingId) {
|
||||
await apiJson(`/api/io_modules/${encodeURIComponent(store.editingId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
await apiJson("/api/io_modules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
dialogEl?.close();
|
||||
await refreshAll();
|
||||
renderList();
|
||||
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(id) {
|
||||
const mod = moduleById(id);
|
||||
if (!mod) return;
|
||||
store.pendingDeleteId = id;
|
||||
if (deleteConfirmTextEl) {
|
||||
deleteConfirmTextEl.textContent = t("ioModules.deleteConfirmText", { name: mod.name || "" });
|
||||
}
|
||||
deleteConfirmDialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = store.pendingDeleteId;
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await fetch(`/api/io_modules/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "include" });
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const err = await res.json();
|
||||
if (err.error) msg = err.error;
|
||||
if (err.usages) msg += "\n" + JSON.stringify(err.usages, null, 2);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
dialogEl?.close();
|
||||
await refreshAll();
|
||||
renderList();
|
||||
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("ioModuleCreateBtn")?.addEventListener("click", () => {
|
||||
if (canWrite()) openCreateDialog();
|
||||
});
|
||||
formEl?.addEventListener("submit", saveModule);
|
||||
el("ioModuleEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
|
||||
deleteBtnEl?.addEventListener("click", () => {
|
||||
if (store.editingId) openDeleteConfirm(store.editingId);
|
||||
});
|
||||
fields.type?.addEventListener("change", updateTypeHint);
|
||||
el("ioModuleTestBtn")?.addEventListener("click", async () => {
|
||||
const ip = (fields.ip?.value || "").trim();
|
||||
if (!ip) {
|
||||
alert(t("ioModules.error.missing"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiJson("/api/io_modules/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ip_address: ip, port: 502 }),
|
||||
});
|
||||
alert(t("ioModules.testOk"));
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
listEl?.addEventListener("click", (evt) => {
|
||||
const editBtn = evt.target.closest("[data-edit]");
|
||||
const deleteBtn = evt.target.closest("[data-delete]");
|
||||
if (editBtn?.dataset.edit && canWrite()) openEditDialog(editBtn.dataset.edit);
|
||||
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
|
||||
});
|
||||
|
||||
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("ioModuleDeleteCancelBtn")?.addEventListener("click", () => {
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("ioModuleDeleteYesBtn")?.addEventListener("click", confirmDelete);
|
||||
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("ioModulesClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||
el("ioModulesPageFirst")?.addEventListener("click", () => {
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("ioModulesPagePrev")?.addEventListener("click", () => {
|
||||
store.page = Math.max(1, store.page - 1);
|
||||
renderList();
|
||||
});
|
||||
el("ioModulesPageNext")?.addEventListener("click", () => {
|
||||
store.page += 1;
|
||||
renderList();
|
||||
});
|
||||
el("ioModulesPageLast")?.addEventListener("click", () => {
|
||||
store.page = pageCount(filteredModules().length);
|
||||
renderList();
|
||||
});
|
||||
el("ioModulesHelpBtn")?.addEventListener("click", () => alert(t("ioModules.helpBody")));
|
||||
|
||||
window.addEventListener("lm:locale-change", () => renderList());
|
||||
}
|
||||
|
||||
async function onPageShow() {
|
||||
if (!window.AuthApp?.canAccessPage?.("io-modules")) return;
|
||||
document.body.classList.toggle("auth-readonly-io-modules", !canWrite());
|
||||
try {
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = false;
|
||||
emptyEl.textContent = e.message;
|
||||
}
|
||||
if (tableEl) tableEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onPageHide() {
|
||||
dialogEl?.close();
|
||||
deleteConfirmDialogEl?.close();
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
|
||||
window.IoModulesCatalog = {
|
||||
getModules: () => [...store.modules],
|
||||
getNames: () => store.modules.map((m) => m.name).filter(Boolean),
|
||||
refresh: refreshAll,
|
||||
};
|
||||
|
||||
window.IoModulesApp = { onPageShow, onPageHide, refreshAll, getModules: () => [...store.modules] };
|
||||
})();
|
||||
@@ -146,6 +146,28 @@
|
||||
plcMode: el("mapIoPlcMode"),
|
||||
};
|
||||
|
||||
async function refreshMapIoModuleList() {
|
||||
const listEl = document.getElementById("mapIoModuleList");
|
||||
if (!listEl) return;
|
||||
let modules = [];
|
||||
try {
|
||||
if (window.IoModulesCatalog?.refresh) {
|
||||
modules = await window.IoModulesCatalog.refresh();
|
||||
} else {
|
||||
const res = await fetch("/api/io_modules", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
modules = Array.isArray(data.io_modules) ? data.io_modules : [];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
modules = [];
|
||||
}
|
||||
listEl.innerHTML = modules
|
||||
.map((m) => `<option value="${String(m.name || "").replace(/"/g, """)}"></option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Off-screen base scan layer (map_base.png — eraser edits this). */
|
||||
let baseCanvasEl = null;
|
||||
function getBaseCanvas() {
|
||||
@@ -1177,6 +1199,7 @@
|
||||
}
|
||||
if (zone.type === obj.TYPES.io) {
|
||||
const s = adv.normalizeIoSettings(zone);
|
||||
void refreshMapIoModuleList().then(() => {
|
||||
if (ioFields.module) ioFields.module.value = s.io_module || "";
|
||||
if (ioFields.plcRegister) {
|
||||
ioFields.plcRegister.value = s.plc_register == null ? "" : String(s.plc_register);
|
||||
@@ -1184,6 +1207,7 @@
|
||||
if (ioFields.plcValue) ioFields.plcValue.value = s.plc_value == null ? "" : String(s.plc_value);
|
||||
if (ioFields.plcMode) ioFields.plcMode.value = s.plc_mode || "set";
|
||||
ioDialogEl?.showModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
www/maps.js
35
www/maps.js
@@ -530,12 +530,45 @@
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function importSiteBundle() {
|
||||
const site_id = createSiteSelectEl?.value || store.sites[0]?.id;
|
||||
if (!site_id) {
|
||||
alert(t("maps.importNoSite"));
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json,application/json";
|
||||
input.addEventListener("change", async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const result = await api(`/api/sites/${encodeURIComponent(site_id)}/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: text,
|
||||
});
|
||||
await loadMaps();
|
||||
const counts = [
|
||||
Array.isArray(result.maps) ? result.maps.length : 0,
|
||||
Array.isArray(result.io_modules) ? result.io_modules.length : 0,
|
||||
Array.isArray(result.transitions) ? result.transitions.length : 0,
|
||||
];
|
||||
alert(t("maps.importSuccess", { maps: counts[0], io: counts[1], transitions: counts[2] }));
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("mapsCreateOpenBtn")?.addEventListener("click", openCreatePage);
|
||||
el("mapsCreateGoBackBtn")?.addEventListener("click", showList);
|
||||
el("mapsCreateCancelBtn")?.addEventListener("click", showList);
|
||||
el("mapsCreateHelpBtn")?.addEventListener("click", () => alert(t("maps.createPage.helpText")));
|
||||
el("mapsImportSiteBtn")?.addEventListener("click", () => alert(t("maps.importComingSoon")));
|
||||
el("mapsImportSiteBtn")?.addEventListener("click", () => importSiteBundle().catch((e) => alert(e.message)));
|
||||
el("mapsClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||
el("mapsHelpBtn")?.addEventListener("click", () => alert(t("maps.helpText")));
|
||||
|
||||
|
||||
194
www/missions.js
194
www/missions.js
@@ -12,6 +12,7 @@
|
||||
],
|
||||
Logic: [
|
||||
{ type: "if", label: "If" },
|
||||
{ type: "while", label: "While", isLoop: true },
|
||||
{ type: "loop", label: "Loop", isLoop: true },
|
||||
{ type: "break", label: "Break" },
|
||||
{ type: "continue", label: "Continue" },
|
||||
@@ -20,6 +21,8 @@
|
||||
"I/O": [
|
||||
{ type: "set_digital_output", label: "Set digital output" },
|
||||
{ type: "wait_digital_input", label: "Wait for digital input" },
|
||||
{ type: "connect_bluetooth", label: "Connect Bluetooth module" },
|
||||
{ type: "disconnect_bluetooth", label: "Disconnect Bluetooth module" },
|
||||
{ type: "set_plc_register", label: "Set PLC register" },
|
||||
],
|
||||
Cart: [
|
||||
@@ -37,6 +40,42 @@
|
||||
const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"];
|
||||
const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"];
|
||||
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
|
||||
let ioModuleCatalog = [];
|
||||
|
||||
function ioModuleNames() {
|
||||
const names = ioModuleCatalog.map((m) => m.name).filter(Boolean);
|
||||
return names.length ? names : SAMPLE_IO_MODULES;
|
||||
}
|
||||
|
||||
function ioModuleByName(name) {
|
||||
return ioModuleCatalog.find((m) => m.name === name || m.id === name) || null;
|
||||
}
|
||||
|
||||
function ioOutputPorts(moduleName) {
|
||||
const mod = ioModuleByName(moduleName);
|
||||
return mod?.module_type === "wise" ? [0, 1, 2, 3] : [1, 2, 3, 4];
|
||||
}
|
||||
|
||||
function ioInputPorts(moduleName) {
|
||||
return ioOutputPorts(moduleName);
|
||||
}
|
||||
|
||||
async function loadIoModuleCatalog() {
|
||||
try {
|
||||
if (window.IoModulesCatalog?.refresh) {
|
||||
ioModuleCatalog = await window.IoModulesCatalog.refresh();
|
||||
} else {
|
||||
const res = await fetch("/api/io_modules", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
ioModuleCatalog = Array.isArray(data.io_modules) ? data.io_modules : [];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
ioModuleCatalog = [];
|
||||
}
|
||||
return ioModuleCatalog;
|
||||
}
|
||||
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
|
||||
|
||||
let missionPositionCatalog = [];
|
||||
@@ -170,13 +209,18 @@
|
||||
case "switch_map":
|
||||
return { map_id: "", entry_position_id: "" };
|
||||
case "if":
|
||||
return { condition: "position_free", position: positionIds()[0] || SAMPLE_POSITIONS[0] };
|
||||
return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
|
||||
case "while":
|
||||
return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
|
||||
case "loop":
|
||||
return { count: 1, mode: "count" };
|
||||
case "set_digital_output":
|
||||
return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true };
|
||||
return { module: ioModuleNames()[0] || "", output: 1, value: true, operation: "set", timeout_ms: 0 };
|
||||
case "wait_digital_input":
|
||||
return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 };
|
||||
return { module: ioModuleNames()[0] || "", input: 1, expected: true, timeout_s: 30 };
|
||||
case "connect_bluetooth":
|
||||
case "disconnect_bluetooth":
|
||||
return { module: ioModuleNames()[0] || "" };
|
||||
case "set_plc_register":
|
||||
return { register: 1, action: "set", value: 0 };
|
||||
case "pick_cart":
|
||||
@@ -344,12 +388,28 @@
|
||||
return `Speed: ${p.speed}`;
|
||||
case "loop":
|
||||
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
|
||||
case "if":
|
||||
case "if": {
|
||||
if (p.condition === "io_input") {
|
||||
const port = p.input != null ? p.input : p.pin;
|
||||
return `If I/O ${p.module} in ${port} = ${p.expected ? "ON" : "OFF"}`;
|
||||
}
|
||||
return `If ${p.condition} @ ${positionLabel(p.position)}`;
|
||||
case "set_digital_output":
|
||||
return `${p.module} pin ${p.pin} → ${p.value ? "ON" : "OFF"}`;
|
||||
case "wait_digital_input":
|
||||
return `${p.module} pin ${p.pin} = ${p.expected ? "ON" : "OFF"}`;
|
||||
}
|
||||
case "while":
|
||||
return `While I/O ${p.module} in ${p.input != null ? p.input : p.pin} = ${p.expected ? "ON" : "OFF"} • ${action.children?.length || 0} bước`;
|
||||
case "set_digital_output": {
|
||||
const port = p.output != null ? p.output : p.pin;
|
||||
const op = p.operation === "timed" ? ` (${p.timeout_ms || 0}ms)` : "";
|
||||
return `${p.module} out ${port} → ${p.value ? "ON" : "OFF"}${op}`;
|
||||
}
|
||||
case "wait_digital_input": {
|
||||
const port = p.input != null ? p.input : p.pin;
|
||||
return `${p.module} in ${port} = ${p.expected ? "ON" : "OFF"} (${p.timeout_s}s)`;
|
||||
}
|
||||
case "connect_bluetooth":
|
||||
return `Connect ${p.module || "?"}`;
|
||||
case "disconnect_bluetooth":
|
||||
return `Disconnect ${p.module || "?"}`;
|
||||
case "set_plc_register":
|
||||
return `Reg ${p.register}: ${p.action} ${p.value}`;
|
||||
case "pick_cart":
|
||||
@@ -374,10 +434,15 @@
|
||||
function normalizeActionTree(actions) {
|
||||
if (!Array.isArray(actions)) return;
|
||||
actions.forEach((action) => {
|
||||
if (action.type === "loop" && !Array.isArray(action.children)) {
|
||||
if ((action.type === "loop" || action.type === "while") && !Array.isArray(action.children)) {
|
||||
action.children = [];
|
||||
}
|
||||
if (action.type === "if") {
|
||||
if (!Array.isArray(action.children)) action.children = [];
|
||||
if (!Array.isArray(action.else_children)) action.else_children = [];
|
||||
}
|
||||
if (Array.isArray(action.children)) normalizeActionTree(action.children);
|
||||
if (Array.isArray(action.else_children)) normalizeActionTree(action.else_children);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,9 +452,24 @@
|
||||
if (path === "root") return draft.actions;
|
||||
const parts = path.split(".").filter((p) => p !== "root");
|
||||
let list = draft.actions;
|
||||
for (const part of parts) {
|
||||
let i = 0;
|
||||
while (i < parts.length) {
|
||||
const part = parts[i];
|
||||
if (part === "else") return null;
|
||||
const node = list.find((a) => a.id === part);
|
||||
if (!node || !Array.isArray(node.children)) return null;
|
||||
if (!node) return null;
|
||||
i += 1;
|
||||
if (i < parts.length && parts[i] === "else") {
|
||||
if (!Array.isArray(node.else_children)) return null;
|
||||
list = node.else_children;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (i >= parts.length) {
|
||||
if (!Array.isArray(node.children)) return null;
|
||||
return node.children;
|
||||
}
|
||||
if (!Array.isArray(node.children)) return null;
|
||||
list = node.children;
|
||||
}
|
||||
return list;
|
||||
@@ -402,7 +482,11 @@
|
||||
if (action.id === actionId) return { action, list, index: i, path, parent };
|
||||
if (Array.isArray(action.children)) {
|
||||
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
|
||||
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
|
||||
if (hit) return hit;
|
||||
}
|
||||
if (Array.isArray(action.else_children)) {
|
||||
const hit = findActionWithParent(actionId, action.else_children, `${path}.${action.id}.else`, action);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -492,6 +576,7 @@
|
||||
actions.forEach((action) => {
|
||||
visit(action, depth);
|
||||
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
|
||||
if (Array.isArray(action.else_children)) walkActions(action.else_children, visit, depth + 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -504,6 +589,9 @@
|
||||
if (Array.isArray(copy.children)) {
|
||||
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
|
||||
}
|
||||
if (Array.isArray(copy.else_children)) {
|
||||
copy.else_children = copy.else_children.map((child) => resolveActionSnapshot(child, depth));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
@@ -949,8 +1037,8 @@
|
||||
row.dataset.index = String(index);
|
||||
|
||||
const iconClass =
|
||||
action.kind === "mission" ? "kind-mission" : action.type === "loop" ? "kind-loop" : "";
|
||||
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" ? "↻" : "▶";
|
||||
action.kind === "mission" ? "kind-mission" : action.type === "loop" || action.type === "while" ? "kind-loop" : action.type === "if" ? "kind-if" : "";
|
||||
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" || action.type === "while" ? "↻" : action.type === "if" ? "?" : "▶";
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="missionDragHandle" draggable="true" title="Kéo để sắp xếp" aria-label="Kéo để sắp xếp">↕</div>
|
||||
@@ -968,17 +1056,17 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (action.type === "loop" && Array.isArray(action.children)) {
|
||||
if ((action.type === "loop" || action.type === "while") && Array.isArray(action.children)) {
|
||||
const loop = document.createElement("div");
|
||||
loop.className = "missionLoopBlock";
|
||||
loop.innerHTML = `<div class="missionLoopLabel">Loop body — kéo action vào đây</div>`;
|
||||
loop.innerHTML = `<div class="missionLoopLabel">${action.type === "while" ? "While body" : "Loop body"} — kéo action vào đây</div>`;
|
||||
const drop = document.createElement("div");
|
||||
drop.className = "missionLoopDrop";
|
||||
drop.dataset.loopPath = `${listPath}.${action.id}`;
|
||||
if (!action.children.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "missionLoopEmpty";
|
||||
empty.textContent = "Kéo action hoặc mission vào loop";
|
||||
empty.textContent = "Kéo action hoặc mission vào đây";
|
||||
drop.appendChild(empty);
|
||||
} else {
|
||||
renderActionRows(action.children, `${listPath}.${action.id}`, drop);
|
||||
@@ -987,6 +1075,31 @@
|
||||
row.appendChild(loop);
|
||||
}
|
||||
|
||||
if (action.type === "if" && Array.isArray(action.children)) {
|
||||
const makeBranch = (label, branchPath, children) => {
|
||||
const block = document.createElement("div");
|
||||
block.className = "missionLoopBlock missionIfBlock";
|
||||
block.innerHTML = `<div class="missionLoopLabel">${label}</div>`;
|
||||
const drop = document.createElement("div");
|
||||
drop.className = "missionLoopDrop";
|
||||
drop.dataset.loopPath = branchPath;
|
||||
if (!children.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "missionLoopEmpty";
|
||||
empty.textContent = "Kéo action vào nhánh này";
|
||||
drop.appendChild(empty);
|
||||
} else {
|
||||
renderActionRows(children, branchPath, drop);
|
||||
}
|
||||
block.appendChild(drop);
|
||||
return block;
|
||||
};
|
||||
row.appendChild(makeBranch("Then", `${listPath}.${action.id}`, action.children));
|
||||
row.appendChild(
|
||||
makeBranch("Else", `${listPath}.${action.id}.else`, Array.isArray(action.else_children) ? action.else_children : [])
|
||||
);
|
||||
}
|
||||
|
||||
row.querySelector("[data-config]").addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
openActionConfig(action.id);
|
||||
@@ -1282,13 +1395,35 @@
|
||||
addField("Số lần lặp", textInput("count", p.count, "number"));
|
||||
break;
|
||||
case "if":
|
||||
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
|
||||
addField("Điều kiện", selectInput("condition", p.condition, ["io_input", "position_free", "position_occupied", "register_equals"]));
|
||||
if (p.condition === "io_input") {
|
||||
addField("Module", selectInput("module", p.module, ioModuleNames()));
|
||||
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
|
||||
{
|
||||
const chk = document.createElement("label");
|
||||
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
|
||||
addField("Kỳ vọng", chk);
|
||||
}
|
||||
} else {
|
||||
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
|
||||
addVariableToggle("position", "Position");
|
||||
}
|
||||
break;
|
||||
case "while":
|
||||
addField("Điều kiện", selectInput("condition", p.condition || "io_input", ["io_input"]));
|
||||
addField("Module", selectInput("module", p.module, ioModuleNames()));
|
||||
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
|
||||
{
|
||||
const chk = document.createElement("label");
|
||||
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
|
||||
addField("Kỳ vọng", chk);
|
||||
}
|
||||
break;
|
||||
case "set_digital_output":
|
||||
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
|
||||
addField("Pin", textInput("pin", p.pin, "number"));
|
||||
addField("Module", selectInput("module", p.module, ioModuleNames()));
|
||||
addField("Output", selectInput("output", String(p.output != null ? p.output : p.pin || 1), ioOutputPorts(p.module).map(String)));
|
||||
addField("Operation", selectInput("operation", p.operation || "set", ["set", "timed"]));
|
||||
addField("Timeout (ms)", textInput("timeout_ms", p.timeout_ms != null ? p.timeout_ms : 0, "number"));
|
||||
{
|
||||
const chk = document.createElement("label");
|
||||
chk.innerHTML = `<input type="checkbox" data-param="value" ${p.value ? "checked" : ""} /> Bật (ON)`;
|
||||
@@ -1296,8 +1431,8 @@
|
||||
}
|
||||
break;
|
||||
case "wait_digital_input":
|
||||
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
|
||||
addField("Pin", textInput("pin", p.pin, "number"));
|
||||
addField("Module", selectInput("module", p.module, ioModuleNames()));
|
||||
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
|
||||
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
|
||||
{
|
||||
const chk = document.createElement("label");
|
||||
@@ -1305,6 +1440,10 @@
|
||||
addField("Kỳ vọng", chk);
|
||||
}
|
||||
break;
|
||||
case "connect_bluetooth":
|
||||
case "disconnect_bluetooth":
|
||||
addField("Module", selectInput("module", p.module, ioModuleNames()));
|
||||
break;
|
||||
case "set_plc_register":
|
||||
addField("Register", textInput("register", p.register, "number"));
|
||||
addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"]));
|
||||
@@ -1364,6 +1503,8 @@
|
||||
else params[key] = node.value;
|
||||
});
|
||||
hit.action.params = params;
|
||||
if (params.input != null && params.input !== "") params.input = Number(params.input);
|
||||
if (params.output != null && params.output !== "") params.output = Number(params.output);
|
||||
setDirty(true);
|
||||
renderMissionEditor();
|
||||
}
|
||||
@@ -1464,6 +1605,7 @@
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await loadIoModuleCatalog();
|
||||
try {
|
||||
const maps = await fetch("/api/maps", { credentials: "include" });
|
||||
if (maps.ok) {
|
||||
@@ -1503,11 +1645,13 @@
|
||||
startQueuePoll,
|
||||
stopQueuePoll,
|
||||
onPageShow() {
|
||||
void loadIoModuleCatalog().then(() => {
|
||||
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||||
else {
|
||||
renderMissionList();
|
||||
startQueuePoll();
|
||||
}
|
||||
});
|
||||
},
|
||||
onPageHide() {
|
||||
stopQueuePoll();
|
||||
@@ -1527,6 +1671,12 @@
|
||||
}
|
||||
window.addEventListener("lm:locale-change", onLocaleChange);
|
||||
|
||||
window.addEventListener("lm:io-modules-changed", () => {
|
||||
void loadIoModuleCatalog().then(() => {
|
||||
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||||
});
|
||||
});
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
window.addEventListener("lm:auth-logout", stopQueuePollForce);
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{ section: "maps", page: "maps" },
|
||||
{ section: "sounds", page: "sounds" },
|
||||
{ section: "transitions", page: "transitions" },
|
||||
{ section: "user-groups", page: "user-groups" },
|
||||
{ section: "io-modules", page: "io-modules" },
|
||||
{ section: "users", page: "users" },
|
||||
{ section: "build-robot", page: "config" },
|
||||
],
|
||||
},
|
||||
@@ -38,6 +41,9 @@
|
||||
missions: { module: "setup", section: "missions" },
|
||||
sounds: { module: "setup", section: "sounds" },
|
||||
transitions: { module: "setup", section: "transitions" },
|
||||
"user-groups": { module: "setup", section: "user-groups" },
|
||||
"io-modules": { module: "setup", section: "io-modules" },
|
||||
users: { module: "setup", section: "users" },
|
||||
integrations: { module: "system", section: "integrations" },
|
||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||
help: { module: "help", section: "help-api" },
|
||||
|
||||
345
www/style.css
345
www/style.css
@@ -1103,6 +1103,57 @@ canvas {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content.content--users {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
align-content: stretch;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.content.content--users > #pageUsers {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content.content--user-groups {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
align-content: stretch;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.content.content--user-groups > #pageUserGroups {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content.content--io-modules {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
align-content: stretch;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.content.content--io-modules > #pageIoModules {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.missionsPage { min-width: 0; width: 100%; }
|
||||
|
||||
.soundVolumeRow {
|
||||
@@ -3141,6 +3192,45 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#pageUsers {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#usersListView {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#pageUserGroups {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#userGroupsListView {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#pageIoModules {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#ioModulesListView {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#mapsListView[hidden],
|
||||
#mapsCreateView[hidden],
|
||||
#mapEditorView[hidden] {
|
||||
@@ -3590,6 +3680,261 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
||||
.mapsMirTable--transitions thead th:nth-child(4) { width: 22%; }
|
||||
.mapsMirTable--transitions thead th:nth-child(5) { width: 16%; }
|
||||
|
||||
.mapsMirTable--users thead th.usersMirThIcon { width: 52px; }
|
||||
.mapsMirTable--users thead th:nth-child(2) { width: 18%; }
|
||||
.mapsMirTable--users thead th:nth-child(3) { width: 14%; }
|
||||
.mapsMirTable--users thead th:nth-child(4) { width: 16%; }
|
||||
.mapsMirTable--users thead th:nth-child(5) { width: 22%; }
|
||||
.mapsMirTable--users thead th:nth-child(6) { width: 72px; }
|
||||
|
||||
.usersMirIcon {
|
||||
color: var(--mir-green, #5cb85c);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.usersMirCellIcon {
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
.usersMirNameLink {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usersMirDisabledTag {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #a94442;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
body.auth-readonly-users #userCreateBtn { display: none !important; }
|
||||
|
||||
body.auth-readonly-users .usersMirRow .userEditBtn,
|
||||
body.auth-readonly-users .usersMirRow .userDeleteBtn { pointer-events: none; opacity: 0.55; }
|
||||
|
||||
.mapsMirTable--userGroups thead th.userGroupsMirThIcon { width: 52px; }
|
||||
.mapsMirTable--userGroups thead th:nth-child(2) { width: 20%; }
|
||||
.mapsMirTable--userGroups thead th:nth-child(3) { width: 12%; }
|
||||
.mapsMirTable--userGroups thead th:nth-child(4) { width: 12%; }
|
||||
.mapsMirTable--userGroups thead th:nth-child(5) { width: auto; }
|
||||
|
||||
.userGroupsMirIcon {
|
||||
color: var(--mir-green, #5cb85c);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.userGroupsMirCellIcon {
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
.userGroupsMirNameLink { font-weight: 600; }
|
||||
|
||||
.userGroupsMirCellPerms {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
body.auth-readonly-user-groups #userGroupCreateBtn { display: none !important; }
|
||||
|
||||
body.auth-readonly-user-groups .userGroupsMirRow .userGroupEditBtn,
|
||||
body.auth-readonly-user-groups .userGroupsMirRow .userGroupDeleteBtn { pointer-events: none; opacity: 0.55; }
|
||||
|
||||
.mapsMirTable--ioModules thead th.ioModulesMirThIcon { width: 52px; }
|
||||
.mapsMirTable--ioModules thead th:nth-child(2) { width: 20%; }
|
||||
.mapsMirTable--ioModules thead th:nth-child(3) { width: 14%; }
|
||||
.mapsMirTable--ioModules thead th:nth-child(4) { width: 18%; }
|
||||
.mapsMirTable--ioModules thead th:nth-child(5) { width: 14%; }
|
||||
|
||||
.ioModulesMirIcon {
|
||||
color: var(--mir-green, #5cb85c);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ioModulesMirCellIcon {
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
.ioModulesMirNameLink { font-weight: 600; }
|
||||
|
||||
.ioModulesMirStatus {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ioModulesMirStatus--on {
|
||||
color: #2d6a2d;
|
||||
background: #e8f5e8;
|
||||
}
|
||||
|
||||
.ioModulesMirStatus--off {
|
||||
color: #666;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
body.auth-readonly-io-modules #ioModuleCreateBtn { display: none !important; }
|
||||
|
||||
body.auth-readonly-io-modules .ioModulesMirRow .ioModuleEditBtn,
|
||||
body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-events: none; opacity: 0.55; }
|
||||
|
||||
.dashboardIoConnectBtn { width: 100%; }
|
||||
|
||||
.dashboardIoWidgetMeta {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboardIoStatusCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.dashboardIoStatusName {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dashboardIoStatusLine {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dashboardIoStatusBadge {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.dashboardIoStatusBadge.is-on {
|
||||
color: #2d6a2d;
|
||||
background: #e8f5e8;
|
||||
}
|
||||
|
||||
.dashboardIoStatusBadge.is-off {
|
||||
color: #666;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.dashboardIoConfigHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboardIoConfigPorts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboardIoConfigRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboardIoConfigPortLabel {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboardIoConfigToggle {
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #d1d5db);
|
||||
background: #fff;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboardIoConfigToggle.is-primary {
|
||||
border-color: #16a34a;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.dashboardIoConfigToggle.is-active {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.missionIfBlock {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.mapsMirDialog--wide {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.userGroupsPermSection {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.userGroupsPermTitle {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.userGroupsPermTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.userGroupsPermTable th,
|
||||
.userGroupsPermTable td {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #eee;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.userGroupsPermTable thead th {
|
||||
font-weight: 700;
|
||||
color: #444;
|
||||
background: #fafafa;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.userGroupsPermTable th[scope="row"] {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
.userGroupsPermSelect {
|
||||
width: 100%;
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.transMirIcon {
|
||||
color: var(--mir-green, #5cb85c);
|
||||
display: block;
|
||||
|
||||
407
www/user-groups.js
Normal file
407
www/user-groups.js
Normal file
@@ -0,0 +1,407 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const PERM_RESOURCES = [
|
||||
{ key: "dashboard", labelKey: "userGroups.perm.dashboard" },
|
||||
{ key: "config", labelKey: "userGroups.perm.config" },
|
||||
{ key: "maps", labelKey: "userGroups.perm.maps" },
|
||||
{ key: "missions", labelKey: "userGroups.perm.missions" },
|
||||
{ key: "sounds", labelKey: "userGroups.perm.sounds" },
|
||||
{ key: "integrations", labelKey: "userGroups.perm.integrations" },
|
||||
{ key: "users", labelKey: "userGroups.perm.users" },
|
||||
];
|
||||
|
||||
const ICONS = {
|
||||
group: `<svg class="userGroupsMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="7" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="13" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M3.5 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5M10 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
|
||||
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const listEl = el("userGroupList");
|
||||
const emptyEl = el("userGroupListEmpty");
|
||||
const tableEl = el("userGroupsTable");
|
||||
const filterInputEl = el("userGroupsFilterInput");
|
||||
const filterCountEl = el("userGroupsFilterCount");
|
||||
const pageLabelEl = el("userGroupsPageLabel");
|
||||
const dialogEl = el("userGroupEditDialog");
|
||||
const formEl = el("userGroupEditForm");
|
||||
const titleEl = el("userGroupEditTitle");
|
||||
const deleteBtnEl = el("userGroupEditDeleteBtn");
|
||||
const permsBodyEl = el("userGroupEditPermsBody");
|
||||
const deleteConfirmDialogEl = el("userGroupDeleteConfirmDialog");
|
||||
const deleteConfirmTextEl = el("userGroupDeleteConfirmText");
|
||||
|
||||
const fields = {
|
||||
name: el("userGroupEditName"),
|
||||
allowPin: el("userGroupEditAllowPin"),
|
||||
};
|
||||
|
||||
const store = {
|
||||
groups: [],
|
||||
editingId: null,
|
||||
pendingDeleteId: null,
|
||||
filter: "",
|
||||
page: 1,
|
||||
permSelects: {},
|
||||
};
|
||||
|
||||
function canWrite() {
|
||||
if (!window.AuthApp?.canWrite) return true;
|
||||
return window.AuthApp.canWrite("users");
|
||||
}
|
||||
|
||||
function isDistributor() {
|
||||
return window.AuthApp?.getUser?.()?.group_id === "group_distributors";
|
||||
}
|
||||
|
||||
function canManageGroup(group) {
|
||||
if (!group) return canWrite();
|
||||
if (isDistributor()) return canWrite();
|
||||
return canWrite() && group.id !== "group_distributors" && group.id !== "group_administrators";
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, { credentials: "include", ...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;
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const data = await apiJson("/api/user_groups");
|
||||
store.groups = Array.isArray(data.groups) ? data.groups : [];
|
||||
}
|
||||
|
||||
function groupById(id) {
|
||||
return store.groups.find((g) => g.id === id) || null;
|
||||
}
|
||||
|
||||
function filteredGroups() {
|
||||
const q = store.filter.trim().toLowerCase();
|
||||
let items = [...store.groups].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
if (q) {
|
||||
items = items.filter((g) => (g.name || "").toLowerCase().includes(q));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function pagedItems(items) {
|
||||
const totalPages = pageCount(items.length);
|
||||
if (store.page > totalPages) store.page = totalPages;
|
||||
if (store.page < 1) store.page = 1;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
return items.slice(start, start + PAGE_SIZE);
|
||||
}
|
||||
|
||||
function updatePagerUi(totalItems) {
|
||||
const totalPages = pageCount(totalItems);
|
||||
if (filterCountEl) filterCountEl.textContent = t("userGroups.itemsFound", { n: totalItems });
|
||||
if (pageLabelEl) pageLabelEl.textContent = t("userGroups.pageOf", { page: store.page, total: totalPages });
|
||||
const atStart = store.page <= 1;
|
||||
const atEnd = store.page >= totalPages;
|
||||
el("userGroupsPageFirst")?.toggleAttribute("disabled", atStart);
|
||||
el("userGroupsPagePrev")?.toggleAttribute("disabled", atStart);
|
||||
el("userGroupsPageNext")?.toggleAttribute("disabled", atEnd);
|
||||
el("userGroupsPageLast")?.toggleAttribute("disabled", atEnd);
|
||||
}
|
||||
|
||||
function permSummary(group) {
|
||||
const perms = group.permissions || {};
|
||||
const writeCount = PERM_RESOURCES.filter((r) => perms[r.key] === "write").length;
|
||||
const readCount = PERM_RESOURCES.filter((r) => perms[r.key] === "read").length;
|
||||
if (writeCount === PERM_RESOURCES.length) return t("userGroups.permSummaryAllWrite");
|
||||
if (writeCount === 0 && readCount === 0) return t("userGroups.permSummaryNone");
|
||||
return t("userGroups.permSummaryMixed", { write: writeCount, read: readCount });
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
const items = filteredGroups();
|
||||
const pageItems = pagedItems(items);
|
||||
updatePagerUi(items.length);
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const showEmpty = items.length === 0;
|
||||
if (tableEl) tableEl.hidden = showEmpty;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = !showEmpty;
|
||||
emptyEl.textContent = store.filter.trim() ? t("userGroups.emptyFilter") : t("userGroups.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((group) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "mapsMirRow userGroupsMirRow";
|
||||
tr.dataset.id = group.id;
|
||||
|
||||
const manageable = canManageGroup(group);
|
||||
const canDelete = manageable && !group.builtin && (group.user_count || 0) === 0;
|
||||
const actions = canWrite()
|
||||
? `<div class="mapsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn userGroupEditBtn" data-edit="${escapeHtml(group.id)}" title="${escapeHtml(t("common.edit"))}" ${manageable ? "" : "disabled"}>${ICONS.edit}</button>
|
||||
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger userGroupDeleteBtn" data-delete="${escapeHtml(group.id)}" title="${escapeHtml(t("common.delete"))}" ${canDelete ? "" : "disabled"}>${ICONS.delete}</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const pinLabel = group.allow_pin ? t("userGroups.pinYes") : t("userGroups.pinNo");
|
||||
const usersLabel = t("userGroups.userCount", { n: group.user_count || 0 });
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="userGroupsMirCellIcon">${ICONS.group}</td>
|
||||
<td class="userGroupsMirCellName">
|
||||
<button type="button" class="mapsMirNameLink userGroupsMirNameLink" data-edit="${escapeHtml(group.id)}" ${manageable ? "" : "disabled"}>${escapeHtml(group.name || "—")}</button>
|
||||
</td>
|
||||
<td>${escapeHtml(usersLabel)}</td>
|
||||
<td>${escapeHtml(pinLabel)}</td>
|
||||
<td class="userGroupsMirCellPerms">${escapeHtml(permSummary(group))}</td>
|
||||
<td class="mapsMirCellFunctions">${actions}</td>
|
||||
`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function buildPermRows(permissions = {}) {
|
||||
if (!permsBodyEl) return;
|
||||
permsBodyEl.innerHTML = "";
|
||||
store.permSelects = {};
|
||||
PERM_RESOURCES.forEach((res) => {
|
||||
const tr = document.createElement("tr");
|
||||
const select = document.createElement("select");
|
||||
select.className = "userGroupsPermSelect";
|
||||
select.dataset.resource = res.key;
|
||||
["none", "read", "write"].forEach((level) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = level;
|
||||
opt.textContent = t(`userGroups.permLevel.${level}`);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.value = permissions[res.key] || "none";
|
||||
store.permSelects[res.key] = select;
|
||||
|
||||
tr.innerHTML = `<th scope="row">${escapeHtml(t(res.labelKey))}</th>`;
|
||||
const td = document.createElement("td");
|
||||
td.appendChild(select);
|
||||
tr.appendChild(td);
|
||||
permsBodyEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function readPermissionsFromForm() {
|
||||
const perms = {};
|
||||
PERM_RESOURCES.forEach((res) => {
|
||||
const select = store.permSelects[res.key];
|
||||
perms[res.key] = select?.value || "none";
|
||||
});
|
||||
return perms;
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
store.editingId = null;
|
||||
if (titleEl) titleEl.textContent = t("userGroups.createTitle");
|
||||
if (fields.name) {
|
||||
fields.name.value = "";
|
||||
fields.name.disabled = false;
|
||||
}
|
||||
if (fields.allowPin) fields.allowPin.checked = false;
|
||||
buildPermRows(defaultPermissionsForNewGroup());
|
||||
deleteBtnEl?.toggleAttribute("hidden", true);
|
||||
dialogEl?.showModal();
|
||||
}
|
||||
|
||||
function defaultPermissionsForNewGroup() {
|
||||
return {
|
||||
dashboard: "write",
|
||||
config: "none",
|
||||
maps: "none",
|
||||
missions: "read",
|
||||
sounds: "read",
|
||||
integrations: "read",
|
||||
users: "none",
|
||||
};
|
||||
}
|
||||
|
||||
function openEditDialog(id) {
|
||||
const group = groupById(id);
|
||||
if (!group || !canManageGroup(group)) return;
|
||||
store.editingId = id;
|
||||
if (titleEl) titleEl.textContent = t("userGroups.editTitle");
|
||||
if (fields.name) fields.name.value = group.name || "";
|
||||
if (fields.allowPin) fields.allowPin.checked = !!group.allow_pin;
|
||||
buildPermRows(group.permissions || {});
|
||||
const canDelete = !group.builtin && (group.user_count || 0) === 0;
|
||||
deleteBtnEl?.toggleAttribute("hidden", !canDelete);
|
||||
dialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function saveGroup(evt) {
|
||||
evt?.preventDefault();
|
||||
const name = (fields.name?.value || "").trim();
|
||||
if (!name) {
|
||||
alert(t("userGroups.error.missing"));
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name,
|
||||
allow_pin: !!fields.allowPin?.checked,
|
||||
permissions: readPermissionsFromForm(),
|
||||
};
|
||||
try {
|
||||
if (store.editingId) {
|
||||
await apiJson(`/api/user_groups/${encodeURIComponent(store.editingId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
await apiJson("/api/user_groups", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
dialogEl?.close();
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(id) {
|
||||
const group = groupById(id);
|
||||
if (!group || group.builtin || (group.user_count || 0) > 0) return;
|
||||
store.pendingDeleteId = id;
|
||||
if (deleteConfirmTextEl) {
|
||||
deleteConfirmTextEl.textContent = t("userGroups.deleteConfirmText", { name: group.name || "" });
|
||||
}
|
||||
deleteConfirmDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function openDeleteConfirmFromDialog() {
|
||||
if (store.editingId) openDeleteConfirm(store.editingId);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = store.pendingDeleteId;
|
||||
if (!id) return;
|
||||
try {
|
||||
await apiJson(`/api/user_groups/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
dialogEl?.close();
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("userGroupCreateBtn")?.addEventListener("click", () => {
|
||||
if (canWrite()) openCreateDialog();
|
||||
});
|
||||
formEl?.addEventListener("submit", saveGroup);
|
||||
el("userGroupEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
|
||||
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
|
||||
|
||||
listEl?.addEventListener("click", (evt) => {
|
||||
const editBtn = evt.target.closest("[data-edit]");
|
||||
const deleteBtn = evt.target.closest("[data-delete]");
|
||||
if (editBtn?.dataset.edit) openEditDialog(editBtn.dataset.edit);
|
||||
else if (deleteBtn?.dataset.delete) openDeleteConfirm(deleteBtn.dataset.delete);
|
||||
});
|
||||
|
||||
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("userGroupDeleteCancelBtn")?.addEventListener("click", () => {
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("userGroupDeleteYesBtn")?.addEventListener("click", confirmDelete);
|
||||
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("userGroupsClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||
el("userGroupsPageFirst")?.addEventListener("click", () => {
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("userGroupsPagePrev")?.addEventListener("click", () => {
|
||||
store.page = Math.max(1, store.page - 1);
|
||||
renderList();
|
||||
});
|
||||
el("userGroupsPageNext")?.addEventListener("click", () => {
|
||||
store.page += 1;
|
||||
renderList();
|
||||
});
|
||||
el("userGroupsPageLast")?.addEventListener("click", () => {
|
||||
store.page = pageCount(filteredGroups().length);
|
||||
renderList();
|
||||
});
|
||||
el("userGroupsHelpBtn")?.addEventListener("click", () => alert(t("userGroups.helpBody")));
|
||||
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
if (store.editingId || dialogEl?.open) {
|
||||
const perms = readPermissionsFromForm();
|
||||
buildPermRows(perms);
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
}
|
||||
|
||||
async function onPageShow() {
|
||||
if (!window.AuthApp?.canAccessPage?.("user-groups")) return;
|
||||
document.body.classList.toggle("auth-readonly-user-groups", !canWrite());
|
||||
try {
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = false;
|
||||
emptyEl.textContent = e.message;
|
||||
}
|
||||
if (tableEl) tableEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onPageHide() {
|
||||
dialogEl?.close();
|
||||
deleteConfirmDialogEl?.close();
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
window.UserGroupsApp = { onPageShow, onPageHide };
|
||||
})();
|
||||
441
www/users.js
Normal file
441
www/users.js
Normal file
@@ -0,0 +1,441 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const ICONS = {
|
||||
user: `<svg class="usersMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="7" r="3.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
|
||||
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const listEl = el("userList");
|
||||
const emptyEl = el("userListEmpty");
|
||||
const tableEl = el("usersTable");
|
||||
const createBtnEl = el("userCreateBtn");
|
||||
const filterInputEl = el("usersFilterInput");
|
||||
const filterCountEl = el("usersFilterCount");
|
||||
const pageLabelEl = el("usersPageLabel");
|
||||
const dialogEl = el("userEditDialog");
|
||||
const formEl = el("userEditForm");
|
||||
const titleEl = el("userEditTitle");
|
||||
const deleteBtnEl = el("userEditDeleteBtn");
|
||||
const passwordFieldEl = el("userEditPasswordField");
|
||||
const passwordHintEl = el("userEditPasswordHint");
|
||||
const pinSectionEl = el("userEditPinSection");
|
||||
const pinFieldEl = el("userEditPinField");
|
||||
const pinHintEl = el("userEditPinHint");
|
||||
const deleteConfirmDialogEl = el("userDeleteConfirmDialog");
|
||||
const deleteConfirmTextEl = el("userDeleteConfirmText");
|
||||
|
||||
const fields = {
|
||||
displayName: el("userEditDisplayName"),
|
||||
username: el("userEditUsername"),
|
||||
password: el("userEditPassword"),
|
||||
email: el("userEditEmail"),
|
||||
group: el("userEditGroup"),
|
||||
enabled: el("userEditEnabled"),
|
||||
pinEnabled: el("userEditPinEnabled"),
|
||||
pin: el("userEditPin"),
|
||||
};
|
||||
|
||||
const store = {
|
||||
users: [],
|
||||
groups: [],
|
||||
editingId: null,
|
||||
pendingDeleteId: null,
|
||||
filter: "",
|
||||
page: 1,
|
||||
};
|
||||
|
||||
function canWrite() {
|
||||
if (!window.AuthApp?.canWrite) return true;
|
||||
return window.AuthApp.canWrite("users");
|
||||
}
|
||||
|
||||
function currentUserId() {
|
||||
return window.AuthApp?.getUser?.()?.id || "";
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, { credentials: "include", ...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;
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const [usersData, groupsData] = await Promise.all([
|
||||
apiJson("/api/users"),
|
||||
apiJson("/api/user_groups"),
|
||||
]);
|
||||
store.users = Array.isArray(usersData.users) ? usersData.users : [];
|
||||
store.groups = Array.isArray(groupsData.groups) ? groupsData.groups : [];
|
||||
}
|
||||
|
||||
function groupById(id) {
|
||||
return store.groups.find((g) => g.id === id) || null;
|
||||
}
|
||||
|
||||
function groupAllowsPin(groupId) {
|
||||
return !!groupById(groupId)?.allow_pin;
|
||||
}
|
||||
|
||||
function filteredUsers() {
|
||||
const q = store.filter.trim().toLowerCase();
|
||||
let items = [...store.users].sort((a, b) =>
|
||||
(a.display_name || a.username || "").localeCompare(b.display_name || b.username || ""),
|
||||
);
|
||||
if (q) {
|
||||
items = items.filter((u) => {
|
||||
const name = (u.display_name || "").toLowerCase();
|
||||
const username = (u.username || "").toLowerCase();
|
||||
const email = (u.email || "").toLowerCase();
|
||||
const group = (u.group_name || "").toLowerCase();
|
||||
return name.includes(q) || username.includes(q) || email.includes(q) || group.includes(q);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function pagedItems(items) {
|
||||
const totalPages = pageCount(items.length);
|
||||
if (store.page > totalPages) store.page = totalPages;
|
||||
if (store.page < 1) store.page = 1;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
return items.slice(start, start + PAGE_SIZE);
|
||||
}
|
||||
|
||||
function updatePagerUi(totalItems) {
|
||||
const totalPages = pageCount(totalItems);
|
||||
if (filterCountEl) filterCountEl.textContent = t("users.itemsFound", { n: totalItems });
|
||||
if (pageLabelEl) pageLabelEl.textContent = t("users.pageOf", { page: store.page, total: totalPages });
|
||||
const atStart = store.page <= 1;
|
||||
const atEnd = store.page >= totalPages;
|
||||
el("usersPageFirst")?.toggleAttribute("disabled", atStart);
|
||||
el("usersPagePrev")?.toggleAttribute("disabled", atStart);
|
||||
el("usersPageNext")?.toggleAttribute("disabled", atEnd);
|
||||
el("usersPageLast")?.toggleAttribute("disabled", atEnd);
|
||||
}
|
||||
|
||||
function pinLabel(user) {
|
||||
if (!user.has_pin) return t("users.pinNo");
|
||||
return t("users.pinYes");
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
const items = filteredUsers();
|
||||
const pageItems = pagedItems(items);
|
||||
updatePagerUi(items.length);
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const showEmpty = items.length === 0;
|
||||
if (tableEl) tableEl.hidden = showEmpty;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = !showEmpty;
|
||||
emptyEl.textContent = store.filter.trim() ? t("users.emptyFilter") : t("users.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((user) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "mapsMirRow usersMirRow";
|
||||
tr.dataset.id = user.id;
|
||||
|
||||
const isSelf = user.id === currentUserId();
|
||||
const actions = canWrite()
|
||||
? `<div class="mapsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(user.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
||||
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(user.id)}" title="${escapeHtml(t("common.delete"))}" ${isSelf ? "disabled" : ""}>${ICONS.delete}</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const disabledTag = user.enabled === false ? ` <span class="usersMirDisabledTag">${escapeHtml(t("common.disabled"))}</span>` : "";
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="usersMirCellIcon">${ICONS.user}</td>
|
||||
<td class="usersMirCellName">
|
||||
<button type="button" class="mapsMirNameLink usersMirNameLink" data-edit="${escapeHtml(user.id)}">${escapeHtml(user.display_name || user.username)}</button>${disabledTag}
|
||||
</td>
|
||||
<td>${escapeHtml(user.username || "—")}</td>
|
||||
<td>${escapeHtml(user.group_name || user.group_id || "—")}</td>
|
||||
<td>${escapeHtml(user.email || "—")}</td>
|
||||
<td>${escapeHtml(pinLabel(user))}</td>
|
||||
<td class="mapsMirCellActions">${actions}</td>`;
|
||||
|
||||
tr.querySelectorAll("[data-edit]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => openDialog(btn.dataset.edit));
|
||||
});
|
||||
tr.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(user.id));
|
||||
tr.addEventListener("dblclick", () => {
|
||||
if (canWrite()) openDialog(user.id);
|
||||
});
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
|
||||
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||
}
|
||||
|
||||
function fillGroupSelect(value) {
|
||||
if (!fields.group) return;
|
||||
fields.group.innerHTML = "";
|
||||
store.groups.forEach((g) => {
|
||||
const o = document.createElement("option");
|
||||
o.value = g.id;
|
||||
o.textContent = g.name || g.id;
|
||||
if (g.id === value) o.selected = true;
|
||||
fields.group.appendChild(o);
|
||||
});
|
||||
}
|
||||
|
||||
function syncPinUi() {
|
||||
const groupId = fields.group?.value || "";
|
||||
const allowPin = groupAllowsPin(groupId);
|
||||
if (pinSectionEl) pinSectionEl.hidden = !allowPin;
|
||||
if (pinHintEl) pinHintEl.hidden = allowPin;
|
||||
if (!allowPin) {
|
||||
if (fields.pinEnabled) fields.pinEnabled.checked = false;
|
||||
if (pinFieldEl) pinFieldEl.hidden = true;
|
||||
if (fields.pin) fields.pin.value = "";
|
||||
return;
|
||||
}
|
||||
const pinOn = fields.pinEnabled?.checked;
|
||||
if (pinFieldEl) pinFieldEl.hidden = !pinOn;
|
||||
if (!pinOn && fields.pin) fields.pin.value = "";
|
||||
}
|
||||
|
||||
function openDialog(id = null) {
|
||||
store.editingId = id;
|
||||
const existing = id ? store.users.find((x) => x.id === id) : null;
|
||||
const isEdit = !!existing;
|
||||
|
||||
if (titleEl) titleEl.textContent = isEdit ? t("users.editTitle") : t("users.createTitle");
|
||||
if (fields.displayName) fields.displayName.value = existing?.display_name || "";
|
||||
if (fields.username) fields.username.value = existing?.username || "";
|
||||
if (fields.email) fields.email.value = existing?.email || "";
|
||||
if (fields.enabled) fields.enabled.checked = existing?.enabled !== false;
|
||||
if (fields.password) fields.password.value = "";
|
||||
fillGroupSelect(existing?.group_id || store.groups[0]?.id || "");
|
||||
if (fields.pinEnabled) fields.pinEnabled.checked = !!existing?.has_pin;
|
||||
if (fields.pin) fields.pin.value = "";
|
||||
|
||||
if (passwordFieldEl) passwordFieldEl.hidden = isEdit;
|
||||
if (passwordHintEl) passwordHintEl.hidden = !isEdit;
|
||||
if (fields.password) fields.password.required = !isEdit;
|
||||
|
||||
const ro = !canWrite();
|
||||
Object.values(fields).forEach((node) => {
|
||||
if (!node) return;
|
||||
node.disabled = ro;
|
||||
});
|
||||
if (deleteBtnEl) {
|
||||
const isSelf = existing?.id === currentUserId();
|
||||
deleteBtnEl.hidden = !isEdit || ro || isSelf;
|
||||
}
|
||||
syncPinUi();
|
||||
dialogEl?.showModal();
|
||||
}
|
||||
|
||||
function readPayload(isCreate) {
|
||||
const payload = {
|
||||
display_name: fields.displayName?.value.trim() || "",
|
||||
username: fields.username?.value.trim() || "",
|
||||
email: fields.email?.value.trim() || "",
|
||||
group_id: fields.group?.value || "",
|
||||
enabled: fields.enabled?.checked !== false,
|
||||
};
|
||||
if (isCreate) payload.password = fields.password?.value || "";
|
||||
if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) {
|
||||
const pin = (fields.pin?.value || "").trim();
|
||||
if (pin) {
|
||||
payload.pin = pin;
|
||||
} else if (!store.editingId) {
|
||||
payload.pin = null;
|
||||
}
|
||||
} else {
|
||||
payload.pin = null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function saveDialog() {
|
||||
if (!canWrite()) return;
|
||||
const isCreate = !store.editingId;
|
||||
const payload = readPayload(isCreate);
|
||||
|
||||
if (!payload.display_name || !payload.username || !payload.group_id) {
|
||||
alert(t("users.error.missing"));
|
||||
return;
|
||||
}
|
||||
if (isCreate && !payload.password) {
|
||||
alert(t("users.error.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) {
|
||||
const pin = (fields.pin?.value || "").trim();
|
||||
const needPin = isCreate || !store.users.find((u) => u.id === store.editingId)?.has_pin;
|
||||
if (needPin && pin.length !== 4) {
|
||||
alert(t("users.error.pinInvalid"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (store.editingId) {
|
||||
const updatePayload = { ...payload };
|
||||
delete updatePayload.password;
|
||||
await apiJson(`/api/users/${encodeURIComponent(store.editingId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updatePayload),
|
||||
});
|
||||
} else {
|
||||
await apiJson("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
await refreshAll();
|
||||
renderList();
|
||||
dialogEl?.close();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(id) {
|
||||
const user = store.users.find((x) => x.id === id);
|
||||
if (!user || !canWrite() || user.id === currentUserId()) return;
|
||||
store.pendingDeleteId = id;
|
||||
if (deleteConfirmTextEl) {
|
||||
deleteConfirmTextEl.textContent = t("users.deleteConfirmText", {
|
||||
name: user.display_name || user.username,
|
||||
});
|
||||
}
|
||||
deleteConfirmDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function openDeleteConfirmFromDialog() {
|
||||
const id = store.editingId;
|
||||
if (!id) return;
|
||||
openDeleteConfirm(id);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = store.pendingDeleteId || store.editingId;
|
||||
if (!id || !canWrite()) return;
|
||||
try {
|
||||
await apiJson(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
deleteConfirmDialogEl?.close();
|
||||
dialogEl?.close();
|
||||
store.editingId = null;
|
||||
store.pendingDeleteId = null;
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
createBtnEl?.addEventListener("click", () => openDialog(null));
|
||||
formEl?.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
saveDialog();
|
||||
});
|
||||
el("userEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
|
||||
dialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
dialogEl?.close();
|
||||
});
|
||||
fields.group?.addEventListener("change", syncPinUi);
|
||||
fields.pinEnabled?.addEventListener("change", syncPinUi);
|
||||
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
|
||||
el("userDeleteCancelBtn")?.addEventListener("click", () => {
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("userDeleteYesBtn")?.addEventListener("click", confirmDelete);
|
||||
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("usersClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||
el("usersPageFirst")?.addEventListener("click", () => {
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("usersPagePrev")?.addEventListener("click", () => {
|
||||
store.page = Math.max(1, store.page - 1);
|
||||
renderList();
|
||||
});
|
||||
el("usersPageNext")?.addEventListener("click", () => {
|
||||
store.page += 1;
|
||||
renderList();
|
||||
});
|
||||
el("usersPageLast")?.addEventListener("click", () => {
|
||||
store.page = pageCount(filteredUsers().length);
|
||||
renderList();
|
||||
});
|
||||
el("usersHelpBtn")?.addEventListener("click", () => alert(t("users.helpBody")));
|
||||
|
||||
window.addEventListener("lm:locale-change", () => renderList());
|
||||
}
|
||||
|
||||
async function onPageShow() {
|
||||
if (!window.AuthApp?.canAccessPage?.("users")) return;
|
||||
document.body.classList.toggle("auth-readonly-users", !canWrite());
|
||||
try {
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = false;
|
||||
emptyEl.textContent = e.message;
|
||||
}
|
||||
if (tableEl) tableEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onPageHide() {
|
||||
dialogEl?.close();
|
||||
deleteConfirmDialogEl?.close();
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
window.UsersApp = { onPageShow, onPageHide };
|
||||
})();
|
||||
Reference in New Issue
Block a user