From 77ea3cc36118281eaf8b883b456a40ac3746bf91 Mon Sep 17 00:00:00 2001 From: HiepLM Date: Thu, 25 Jun 2026 16:00:18 +0700 Subject: [PATCH] =?UTF-8?q?Th=C3=AAm=20ph=E1=BA=A7n=205.=20Monitoring=20v?= =?UTF-8?q?=C3=A0=206.=20System?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 15 + CMakeLists.txt | 5 + docs/Reference_guide.md | 77 ++- src/app/robot_app.cpp | 10 +- src/auth/auth_service.cpp | 3 +- src/mission/mission_queue.cpp | 22 + src/mission/mission_queue.hpp | 4 + src/monitoring/monitoring_service.cpp | 577 ++++++++++++++++ src/monitoring/monitoring_service.hpp | 68 ++ src/robot/robot_runtime.cpp | 24 +- src/robot/robot_runtime.hpp | 6 + src/server/api_monitoring_routes.cpp | 141 ++++ src/server/api_server.cpp | 11 +- src/server/api_server.hpp | 10 +- src/server/api_settings_routes.cpp | 35 + src/settings/settings_service.cpp | 110 +++ src/settings/settings_service.hpp | 37 + src/storage/database.cpp | 58 ++ src/storage/mission_run_store.cpp | 385 +++++++++++ src/storage/mission_run_store.hpp | 31 + www/app.js | 9 +- www/i18n.js | 306 ++++++++- www/index.html | 367 +++++++++- www/monitoring.js | 927 ++++++++++++++++++++++++++ www/nav.js | 20 +- www/settings.js | 148 ++++ www/style.css | 189 ++++++ 27 files changed, 3564 insertions(+), 31 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/monitoring/monitoring_service.cpp create mode 100644 src/monitoring/monitoring_service.hpp create mode 100644 src/server/api_monitoring_routes.cpp create mode 100644 src/server/api_settings_routes.cpp create mode 100644 src/settings/settings_service.cpp create mode 100644 src/settings/settings_service.hpp create mode 100644 src/storage/mission_run_store.cpp create mode 100644 src/storage/mission_run_store.hpp create mode 100644 www/monitoring.js create mode 100644 www/settings.js diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2ba986f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 00eff4c..ae279b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,10 +51,12 @@ add_executable(robot_app src/io/io_module_usage.cpp src/storage/path_store.cpp src/storage/path_guide_store.cpp + src/storage/mission_run_store.cpp src/path/path_planner.cpp src/path/path_service.cpp src/storage/dashboard_store.cpp src/storage/state_repository.cpp + src/settings/settings_service.cpp src/validation/sensor_validator.cpp src/server/static_file_server.cpp src/server/api_server.cpp @@ -64,6 +66,7 @@ add_executable(robot_app src/mission/mission_enqueue.cpp src/mission/modbus_trigger_service.cpp src/mission/mission_scheduler.cpp + src/monitoring/monitoring_service.cpp src/robot/robot_runtime.cpp src/server/api_mission_routes.cpp src/server/api_robot_routes.cpp @@ -73,6 +76,8 @@ add_executable(robot_app src/server/api_path_routes.cpp src/server/api_path_guide_routes.cpp src/server/api_dashboard_routes.cpp + src/server/api_monitoring_routes.cpp + src/server/api_settings_routes.cpp ) target_link_libraries(robot_app PRIVATE Threads::Threads SQLite::SQLite3) diff --git a/docs/Reference_guide.md b/docs/Reference_guide.md index b15fbee..967d039 100644 --- a/docs/Reference_guide.md +++ b/docs/Reference_guide.md @@ -126,7 +126,7 @@ Rail trái → flyout submenu → content (`www/nav.js`). |--------|-------------------------|---------|------------| | **Dashboards** | Danh sách động → `dashboard` | `dashboard.js` | ✅ | | **Setup** | missions, maps, sounds, transitions, users, user-groups, io-modules, paths, path-guides, build-robot → `config` | `*.js` | 🔶 | -| **Monitoring** | monitoring-log → `monitoring` | — | ⏳ | +| **Monitoring** | analytics (mặc định), system log, error logs, hardware, safety, mission log → `monitoring` | — | ✅ | | **System** | integrations → `integrations` | `integrations.js` | 🔶 | | **Help** | help-api → `help` | — | ⏳ | @@ -276,11 +276,58 @@ API: `/api/layouts`, `/api/lidars`, `/api/imus`, `/api/state`. File: `www/app.js --- -## 5. Monitoring — ⏳ +## 5. Monitoring — ✅ -Trang `pageMonitoring`: placeholder «System log — đang phát triển». +Flyout 6 mục (giống MiR): **Analytics**, **System log**, **Error logs**, **Hardware health**, **Safety system**, **Mission log**. +Frontend: `www/monitoring.js` (poll ~2.5s), layout `www/index.html`, style `www/style.css`, i18n `www/i18n.js`. +Backend: `src/monitoring/monitoring_service.*`, routes `src/server/api_monitoring_routes.cpp`, mission persist trong `src/storage/mission_run_store.*`. -**Chưa có:** Analytics, Error logs, Hardware health, Safety system, Mission log browser. +### 5.1 Analytics — ✅ +- **UI**: chọn `Start date` / `End date`; preset (current week/7d/30d/365d); `Grouping` day/month; chart mode **Bar graph** / **Accumulated**. +- **API**: `GET /api/monitoring/analytics?start=YYYY-MM-DD&end=YYYY-MM-DD&grouping=day|month` +- **Data**: document SQLite `monitoring_analytics` (tích lũy khi robot di chuyển; tick từ `RobotRuntime`). + +### 5.2 System log — ✅ +- **UI**: bảng `State` (dot màu), `Module`, `Message`, `Time`; filter + pager; refresh. +- **API**: `GET /api/monitoring/system_log` +- **Data**: document SQLite `monitoring_system_log` (sự kiện hệ thống + một phần sự kiện error). + +### 5.3 Error logs — ✅ +- **UI**: bảng `Description`, `Module`, `Time`; nút **Generate log** (user log); **Delete all**; xóa từng dòng; **Download** từng log. +- **API**: + - `GET /api/monitoring/error_logs` + - `POST /api/monitoring/error_logs/generate` + - `DELETE /api/monitoring/error_logs` + - `DELETE /api/monitoring/error_logs/` + - `GET /api/monitoring/error_logs//download` +- **Data**: document SQLite `monitoring_error_logs` (+ action snapshot ring `monitoring_action_ring`). + +### 5.4 Hardware health — ✅ +- **UI**: accordion nhóm (Computer, Motors, Power system, Safety system, Sensors, Modbus) → expand xem component. +- **API**: `GET /api/monitoring/hardware_health` +- **Data**: ghép từ `robot_status` + `StateRepository` (Build robot: LiDAR/IMU). + +### 5.5 Safety system — ✅ +- **UI**: live cards (E-stop, Front scanner, Rear scanner) với trạng thái Free/Blocked, Released/Activated. +- **API**: `GET /api/monitoring/safety` +- **Data**: từ `robot_status.safety` (hiện default/placeholder nếu chưa có phần cứng thật). + +### 5.6 Mission log — ✅ (MiR-like, có lịch sử bền vững) +- **UI**: bảng Mission/State/Message/Start time/Ran for/Started by; filter + pager; **View action log** (icon mắt); Phase 3/4: **Delete history**, xóa từng run, **Download run bundle**. +- **API**: + - Queue hiện tại: `GET /api/mission_queue` + - Lịch sử persist: + - `GET /api/monitoring/mission_runs?limit=N` + - `GET /api/monitoring/mission_runs//actions` + - `DELETE /api/monitoring/mission_runs` (clear history) + - `DELETE /api/monitoring/mission_runs/` + - `GET /api/monitoring/mission_runs//download` (Phase 4 bundle JSON: run + actions + robot snapshot) +- **Data**: + - Queue: document SQLite `mission_queue` (mất khi clear queue) + - History: tables `mission_runs`, `mission_run_actions` (không mất khi clear queue) + - **Retention** (Phase 4): giữ tối đa ~2000 runs gần nhất (DB bounded history). + +**Chưa có (so với MiR)**: error log file mã hóa gửi Support; retention cấu hình qua UI; safety/hardware đọc từ phần cứng thật (thay vì stub/snapshot). --- @@ -302,6 +349,22 @@ File: `www/integrations.js`. **Chưa có:** Settings, Backups, PLC registers UI, Processes, Software versions; sửa trigger/schedule (chỉ add/delete). +### 6.1 System — Settings — ✅ + +Trang Settings theo style MiR (card/section + Apply). Frontend: `www/settings.js`. + +**Nhóm Logging & retention:** +- `retention_mission_runs`: số mission runs lịch sử tối đa (bounded history; ảnh hưởng Mission log Phase 3/4). +- `retention_system_log`: số dòng system log tối đa. +- `retention_error_logs`: số dòng error log tối đa. +- `log_level`: `error|warn|info|debug` (hiện mới dùng làm setting; chưa ảnh hưởng sâu tới backend logging). + +**API:** +- `GET /api/settings` +- `PUT /api/settings` + +**Data:** document SQLite `settings`. Retention được áp dụng khi ghi log/runs mới (trim tự động). + --- ## 7. Help — ⏳ @@ -333,13 +396,13 @@ Xác thực: session cookie hoặc Bearer (ưu tiên session web); tích hợp n ## 9. Tóm tắt triển khai ``` -✅ Hoàn thiện tốt: Sign in, Users, User groups, Sounds, Transitions, Path guides +✅ Hoàn thiện tốt: Sign in, Users, User groups, Sounds, Transitions, Path guides, Monitoring 🔶 Đang dùng được: Dashboard, Missions, Maps, I/O, Paths, Integrations, Topbar, Build robot -⏳ Placeholder: Monitoring, Help, Robot summary widget, PLC tab +⏳ Placeholder: Help, Robot summary widget, PLC tab ❌ Chưa bắt đầu: Shelf types, Analytics/Error logs/Hardware/Safety, System Settings/Backups ``` -**File frontend chính:** `index.html`, `nav.js`, `topbar.js`, `auth.js`, `app.js`, `dashboard.js`, `missions.js`, `maps.js`, `map-editor.js`, `sounds.js`, `transitions.js`, `users.js`, `user-groups.js`, `io-modules.js`, `paths.js`, `path-guides.js`, `integrations.js`, `style.css`, `i18n.js`. +**File frontend chính:** `index.html`, `nav.js`, `topbar.js`, `auth.js`, `app.js`, `dashboard.js`, `missions.js`, `maps.js`, `map-editor.js`, `sounds.js`, `transitions.js`, `users.js`, `user-groups.js`, `io-modules.js`, `paths.js`, `path-guides.js`, `integrations.js`, `monitoring.js`, `style.css`, `i18n.js`. --- diff --git a/src/app/robot_app.cpp b/src/app/robot_app.cpp index ca3d678..c660695 100644 --- a/src/app/robot_app.cpp +++ b/src/app/robot_app.cpp @@ -11,6 +11,8 @@ #include "mission/mission_scheduler.hpp" #include "mission/mission_store.hpp" #include "mission/modbus_trigger_service.hpp" +#include "settings/settings_service.hpp" +#include "monitoring/monitoring_service.hpp" #include "robot/robot_runtime.hpp" #include "server/api_server.hpp" #include "server/static_file_server.hpp" @@ -77,7 +79,11 @@ int RobotApp::run() MissionStore mission_store(database); MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime, path_service); + SettingsService settings(database); + MonitoringService monitoring(database); RobotRuntime robot_runtime(database, mission_queue); + robot_runtime.setMonitoring(&monitoring); + mission_queue.setMonitoring(&monitoring); SiteStore site_store(database); site_store.ensureDefaultSiteId(); SoundStore sound_store(database); @@ -120,7 +126,9 @@ int RobotApp::run() path_store, path_guide_store, path_service, - dashboard_store); + dashboard_store, + monitoring, + settings); api.registerRoutes(svr); auth.registerRoutes(svr); StaticFileServer::mount(svr, www_root_); diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp index 8bc9411..a19c4c2 100644 --- a/src/auth/auth_service.cpp +++ b/src/auth/auth_service.cpp @@ -225,7 +225,8 @@ std::optional AuthService::resourceForApiPath(const std::string& pa return "missions"; if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 || path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 || - path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0) + path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0 || + path.rfind("/api/settings", 0) == 0) return "integrations"; if (path.rfind("/api/dashboards", 0) == 0) return "dashboard"; diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index e636a54..6d3a72d 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -2,6 +2,7 @@ #include "io/io_module_service.hpp" #include "io/io_zone_runtime.hpp" +#include "monitoring/monitoring_service.hpp" #include "path/path_service.hpp" #include "mission/mission_store.hpp" #include "mission/position_resolver.hpp" @@ -489,6 +490,11 @@ void MissionQueue::runMissionActions(nlohmann::json& entry) setRunnerState("idle", "Hoàn thành: " + entry.value("mission_name", "Mission")); saveUnlocked(); } + if (monitoring_) + { + std::string mon_err; + monitoring_->recordMissionRun(entry, mon_err); + } } catch (const MissionCancelled&) { @@ -501,9 +507,15 @@ void MissionQueue::runMissionActions(nlohmann::json& entry) setRunnerState("idle", "Đã hủy: " + entry.value("mission_name", "Mission")); saveUnlocked(); } + if (monitoring_) + { + std::string mon_err; + monitoring_->recordMissionRun(entry, mon_err); + } } catch (...) { + entry["log"] = log; entry["status"] = "failed"; entry["finished_at"] = IdUtil::nowIso8601(); { @@ -511,6 +523,16 @@ void MissionQueue::runMissionActions(nlohmann::json& entry) setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission")); saveUnlocked(); } + if (monitoring_) + { + { + std::string mon_err; + monitoring_->recordMissionRun(entry, mon_err); + } + monitoring_->logError("/Mission", + "Mission failed: " + entry.value("mission_name", "Mission"), + nlohmann::json{{"entry_id", entry.value("id", "")}, {"log", log}}); + } } cancel_ = false; } diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index a4cb8df..2a5f263 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -17,6 +17,7 @@ class IoModuleService; class IoZoneRuntime; class MapStore; class MissionStore; +class MonitoringService; class PathService; class TransitionStore; @@ -46,6 +47,8 @@ public: bool resume(std::string& err); bool cancel(std::string& err); + void setMonitoring(MonitoringService* monitoring) { monitoring_ = monitoring; } + private: enum class LoopControl { None, Break, Continue }; @@ -56,6 +59,7 @@ private: IoModuleService& io_modules_; IoZoneRuntime& io_zones_; PathService& paths_; + MonitoringService* monitoring_ = nullptr; PositionResolver position_resolver_; mutable std::recursive_mutex mu_; nlohmann::json queue_; diff --git a/src/monitoring/monitoring_service.cpp b/src/monitoring/monitoring_service.cpp new file mode 100644 index 0000000..3efa512 --- /dev/null +++ b/src/monitoring/monitoring_service.cpp @@ -0,0 +1,577 @@ +#include "monitoring/monitoring_service.hpp" + +#include "storage/database.hpp" +#include "storage/mission_run_store.hpp" +#include "storage/state_repository.hpp" +#include "util/id_util.hpp" + +#include +#include +#include +#include +#include + +namespace lm { + +namespace { + +constexpr size_t kMaxActionRing = 120; + +std::string formatDateYmd(int y, int m, int d) +{ + std::ostringstream oss; + oss << std::setfill('0') << y << '-' << std::setw(2) << m << '-' << std::setw(2) << d; + return oss.str(); +} + +bool parseYmd(const std::string& s, int& y, int& m, int& d) +{ + if (s.size() != 10 || s[4] != '-' || s[7] != '-') + return false; + try + { + y = std::stoi(s.substr(0, 4)); + m = std::stoi(s.substr(5, 2)); + d = std::stoi(s.substr(8, 2)); + return m >= 1 && m <= 12 && d >= 1 && d <= 31; + } + catch (...) + { + return false; + } +} + +std::time_t toTimeUtc(int y, int m, int d) +{ + std::tm tm{}; + tm.tm_year = y - 1900; + tm.tm_mon = m - 1; + tm.tm_mday = d; + tm.tm_hour = 12; + return std::mktime(&tm); +} + +std::string addDays(const std::string& ymd, int delta) +{ + int y = 0, m = 0, d = 0; + if (!parseYmd(ymd, y, m, d)) + return ymd; + std::time_t t = toTimeUtc(y, m, d); + t += static_cast(delta) * 86400; + std::tm* utc = std::gmtime(&t); + if (!utc) + return ymd; + return formatDateYmd(utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday); +} + +std::string monthKey(const std::string& ymd) +{ + if (ymd.size() >= 7) + return ymd.substr(0, 7); + return ymd; +} + +std::string statusLabel(const std::string& status) +{ + if (status == "ok") + return "OK"; + if (status == "warn") + return "Warning"; + if (status == "error") + return "Error"; + return status; +} + +nlohmann::json component(const std::string& id, + const std::string& name, + const std::string& status, + const std::string& message = "") +{ + return {{"id", id}, + {"name", name}, + {"status", status}, + {"status_label", statusLabel(status)}, + {"message", message}, + {"children", nlohmann::json::array()}}; +} + +} // namespace + +MonitoringService::MonitoringService(Database& db) : db_(db) +{ + loadUnlocked(); + seedIfEmptyUnlocked(); + last_tick_ = std::chrono::steady_clock::now(); +} + +void MonitoringService::loadUnlocked() +{ + analytics_ = nlohmann::json::object({{"total_meters", 0.0}, {"daily", nlohmann::json::object()}}); + system_log_ = nlohmann::json::array(); + error_logs_ = nlohmann::json::array(); + action_ring_ = nlohmann::json::array(); + + nlohmann::json parsed; + if (db_.getDocument("monitoring_analytics", parsed) && parsed.is_object()) + analytics_ = parsed; + if (!analytics_.contains("daily") || !analytics_["daily"].is_object()) + analytics_["daily"] = nlohmann::json::object(); + if (!analytics_.contains("total_meters")) + analytics_["total_meters"] = 0.0; + + if (db_.getDocument("monitoring_system_log", parsed) && parsed.is_array()) + system_log_ = parsed; + if (db_.getDocument("monitoring_error_logs", parsed) && parsed.is_array()) + error_logs_ = parsed; + if (db_.getDocument("monitoring_action_ring", parsed) && parsed.is_array()) + action_ring_ = parsed; +} + +void MonitoringService::saveAnalyticsUnlocked() const +{ + db_.setDocument("monitoring_analytics", analytics_); +} + +void MonitoringService::saveSystemLogUnlocked() const +{ + db_.setDocument("monitoring_system_log", system_log_); +} + +void MonitoringService::saveErrorLogsUnlocked() const +{ + db_.setDocument("monitoring_error_logs", error_logs_); +} + +void MonitoringService::trimSystemLogUnlocked() +{ + int keep = 2000; + nlohmann::json s; + if (db_.getDocument("settings", s) && s.is_object()) + keep = std::clamp(s.value("retention_system_log", 2000), 100, 20000); + while (system_log_.is_array() && system_log_.size() > keep) + system_log_.erase(system_log_.begin()); +} + +void MonitoringService::trimErrorLogsUnlocked() +{ + int keep = 200; + nlohmann::json s; + if (db_.getDocument("settings", s) && s.is_object()) + keep = std::clamp(s.value("retention_error_logs", 200), 10, 5000); + while (error_logs_.is_array() && error_logs_.size() > keep) + error_logs_.erase(error_logs_.begin()); +} + +void MonitoringService::trimActionRingUnlocked() +{ + while (action_ring_.is_array() && action_ring_.size() > static_cast(kMaxActionRing)) + action_ring_.erase(action_ring_.begin()); +} + +void MonitoringService::seedIfEmptyUnlocked() +{ + if (!system_log_.is_array() || system_log_.empty()) + { + logSystem("ok", "Application", "Robot application started"); + logSystem("ok", "Database", "Configuration database loaded"); + } + + if (!analytics_["daily"].is_object() || analytics_["daily"].empty()) + { + const std::string today = todayKey(); + for (int i = 6; i >= 0; --i) + { + const std::string day = addDays(today, -i); + analytics_["daily"][day] = 42.5 + (6 - i) * 11.3; + analytics_["total_meters"] = analytics_.value("total_meters", 0.0) + analytics_["daily"][day].get(); + } + saveAnalyticsUnlocked(); + } +} + +std::string MonitoringService::todayKey() +{ + const std::string iso = IdUtil::nowIso8601(); + return dateKeyFromIso(iso); +} + +std::string MonitoringService::dateKeyFromIso(const std::string& iso) +{ + if (iso.size() >= 10) + return iso.substr(0, 10); + return todayKey(); +} + +void MonitoringService::recordDistance(double meters) +{ + if (!std::isfinite(meters) || meters <= 0.0) + return; + std::lock_guard lock(mu_); + const std::string day = todayKey(); + const double daily = analytics_["daily"].value(day, 0.0); + analytics_["daily"][day] = daily + meters; + analytics_["total_meters"] = analytics_.value("total_meters", 0.0) + meters; + saveAnalyticsUnlocked(); +} + +void MonitoringService::logSystem(const std::string& state, const std::string& module, const std::string& message) +{ + std::lock_guard lock(mu_); + if (!system_log_.is_array()) + system_log_ = nlohmann::json::array(); + system_log_.push_back({{"id", IdUtil::newId()}, + {"ts", IdUtil::nowIso8601()}, + {"state", state}, + {"module", module}, + {"message", message}}); + trimSystemLogUnlocked(); + saveSystemLogUnlocked(); +} + +void MonitoringService::logError(const std::string& module, + const std::string& description, + const nlohmann::json& context) +{ + std::lock_guard lock(mu_); + if (!error_logs_.is_array()) + error_logs_ = nlohmann::json::array(); + error_logs_.push_back({{"id", IdUtil::newId()}, + {"ts", IdUtil::nowIso8601()}, + {"module", module}, + {"description", description}, + {"context", context.is_null() ? nlohmann::json::object() : context}}); + trimErrorLogsUnlocked(); + saveErrorLogsUnlocked(); + + if (!system_log_.is_array()) + system_log_ = nlohmann::json::array(); + system_log_.push_back({{"id", IdUtil::newId()}, + {"ts", IdUtil::nowIso8601()}, + {"state", "error"}, + {"module", module}, + {"message", description}}); + trimSystemLogUnlocked(); + saveSystemLogUnlocked(); +} + +void MonitoringService::recordActionSnapshot(const std::string& action, const std::string& message) +{ + std::lock_guard lock(mu_); + if (!action_ring_.is_array()) + action_ring_ = nlohmann::json::array(); + action_ring_.push_back({{"ts", IdUtil::nowIso8601()}, {"action", action}, {"message", message}}); + trimActionRingUnlocked(); + db_.setDocument("monitoring_action_ring", action_ring_); +} + +void MonitoringService::onRobotTick(const nlohmann::json& robot_state, double delta_seconds) +{ + if (!std::isfinite(delta_seconds) || delta_seconds <= 0.0) + return; + + const bool running = robot_state.value("motion", "paused") == "running"; + const double linear = std::abs(robot_state.value("cmd_linear", 0.0)); + const double angular = std::abs(robot_state.value("cmd_angular", 0.0)); + if (running && (linear > 0.01 || angular > 0.01)) + { + const double meters = (linear * 0.6 + angular * 0.15) * delta_seconds; + recordDistance(meters); + recordActionSnapshot("Motion", "linear=" + std::to_string(linear) + " angular=" + std::to_string(angular)); + } + + const auto runner = robot_state.value("runner", nlohmann::json::object()); + const std::string cur = runner.value("current_action", ""); + if (!cur.empty()) + recordActionSnapshot(cur, runner.value("message", cur)); +} + +nlohmann::json MonitoringService::analytics(const std::string& start_date, + const std::string& end_date, + const std::string& grouping) const +{ + std::lock_guard lock(mu_); + const std::string today = todayKey(); + std::string start = start_date.empty() ? addDays(today, -6) : start_date; + std::string end = end_date.empty() ? today : end_date; + if (start > end) + std::swap(start, end); + + const bool per_month = grouping == "month"; + nlohmann::json buckets = nlohmann::json::array(); + double total = 0.0; + double accumulated = 0.0; + nlohmann::json accumulated_series = nlohmann::json::array(); + + std::string cursor = start; + while (cursor <= end) + { + const std::string key = per_month ? monthKey(cursor) : cursor; + double meters = 0.0; + if (per_month) + { + if (analytics_.contains("daily") && analytics_["daily"].is_object()) + { + for (auto it = analytics_["daily"].begin(); it != analytics_["daily"].end(); ++it) + { + if (monthKey(it.key()) == key) + meters += it.value().get(); + } + } + } + else + { + meters = analytics_["daily"].value(cursor, 0.0); + } + + if (!per_month || buckets.empty() || buckets.back().value("key", "") != key) + { + accumulated += meters; + total += meters; + buckets.push_back({{"key", key}, + {"label", key}, + {"date", cursor}, + {"meters", meters}, + {"accumulated", accumulated}}); + accumulated_series.push_back({{"date", cursor}, {"meters", accumulated}}); + } + else + { + buckets.back()["meters"] = buckets.back().value("meters", 0.0) + meters; + total += meters; + accumulated += meters; + buckets.back()["accumulated"] = accumulated; + if (!accumulated_series.empty()) + accumulated_series.back()["meters"] = accumulated; + } + + cursor = addDays(cursor, 1); + if (per_month) + { + while (cursor <= end && monthKey(cursor) == key) + cursor = addDays(cursor, 1); + } + } + + return {{"start", start}, + {"end", end}, + {"grouping", per_month ? "month" : "day"}, + {"total_meters", total}, + {"lifetime_meters", analytics_.value("total_meters", 0.0)}, + {"buckets", buckets}, + {"accumulated", accumulated_series}}; +} + +nlohmann::json MonitoringService::systemLog() const +{ + std::lock_guard lock(mu_); + nlohmann::json items = nlohmann::json::array(); + if (!system_log_.is_array()) + return {{"items", items}}; + for (auto it = system_log_.rbegin(); it != system_log_.rend(); ++it) + items.push_back(*it); + return {{"items", items}}; +} + +nlohmann::json MonitoringService::errorLogs() const +{ + std::lock_guard lock(mu_); + nlohmann::json items = nlohmann::json::array(); + if (!error_logs_.is_array()) + return {{"items", items}}; + for (auto it = error_logs_.rbegin(); it != error_logs_.rend(); ++it) + items.push_back(*it); + return {{"items", items}}; +} + +std::optional MonitoringService::errorLogById(const std::string& id) const +{ + std::lock_guard lock(mu_); + if (!error_logs_.is_array()) + return std::nullopt; + for (const auto& e : error_logs_) + { + if (e.value("id", "") == id) + return e; + } + return std::nullopt; +} + +bool MonitoringService::deleteErrorLog(const std::string& id) +{ + std::lock_guard lock(mu_); + if (!error_logs_.is_array()) + return false; + for (auto it = error_logs_.begin(); it != error_logs_.end(); ++it) + { + if ((*it).value("id", "") == id) + { + error_logs_.erase(it); + saveErrorLogsUnlocked(); + return true; + } + } + return false; +} + +void MonitoringService::deleteAllErrorLogs() +{ + std::lock_guard lock(mu_); + error_logs_ = nlohmann::json::array(); + saveErrorLogsUnlocked(); +} + +nlohmann::json MonitoringService::generateUserLog(const nlohmann::json& robot_status, + const nlohmann::json& runner_status) +{ + nlohmann::json context = {{"robot", robot_status}, + {"runner", runner_status}, + {"actions", action_ring_}}; + logError("/User", "User generated log", context); + std::lock_guard lock(mu_); + if (error_logs_.is_array() && !error_logs_.empty()) + return error_logs_.back(); + return nlohmann::json::object(); +} + +nlohmann::json MonitoringService::safetyStatus(const nlohmann::json& robot_status) const +{ + const auto safety = robot_status.value("safety", nlohmann::json::object()); + return {{"emergency_stop", safety.value("emergency_stop", "released")}, + {"front_scanner", safety.value("front_scanner", "free")}, + {"rear_scanner", safety.value("rear_scanner", "free")}, + {"updated_at", robot_status.value("updated_at", "")}}; +} + +nlohmann::json MonitoringService::hardwareHealth(const StateRepository& repo, + const nlohmann::json& robot_status) const +{ + const std::string health = robot_status.value("health", "ok"); + const int battery = robot_status.value("battery_percent", 0); + const bool charging = robot_status.value("battery_charging", false); + const auto safety = safetyStatus(robot_status); + + const std::string computer_status = health == "error" ? "error" : "ok"; + nlohmann::json computer_components = nlohmann::json::array(); + computer_components.push_back( + component("main", "Main computer", computer_status, "CPU and system services")); + + std::string motor_status = "ok"; + if (robot_status.value("runner", nlohmann::json::object()).value("state", "") == "error") + motor_status = "warn"; + nlohmann::json motor_components = nlohmann::json::array(); + motor_components.push_back(component("left", "Left motor controller", motor_status)); + motor_components.push_back(component("right", "Right motor controller", motor_status)); + + std::string power_status = "ok"; + const std::string power_msg = charging ? "Charging" : "Discharging"; + if (battery < 10) + power_status = "error"; + else if (battery < 25) + power_status = "warn"; + nlohmann::json power_components = nlohmann::json::array(); + power_components.push_back( + component("battery", "Battery", power_status, std::to_string(battery) + "% — " + power_msg)); + + std::string safety_group = "ok"; + if (safety.value("emergency_stop", "") == "activated" || safety.value("front_scanner", "") == "blocked" || + safety.value("rear_scanner", "") == "blocked") + safety_group = "error"; + nlohmann::json safety_components = nlohmann::json::array(); + safety_components.push_back( + component("estop", "Emergency stop", safety.value("emergency_stop", "") == "activated" ? "error" : "ok")); + safety_components.push_back( + component("front_laser", "Front laser scanner", safety.value("front_scanner", "") == "blocked" ? "error" : "ok")); + safety_components.push_back( + component("rear_laser", "Rear laser scanner", safety.value("rear_scanner", "") == "blocked" ? "error" : "ok")); + + nlohmann::json sensor_components = nlohmann::json::array(); + const auto& state = repo.app().state; + if (state.contains("lidars") && state["lidars"].is_array()) + { + int idx = 0; + for (const auto& lidar : state["lidars"]) + { + const std::string name = lidar.value("name", "LiDAR " + std::to_string(++idx)); + sensor_components.push_back( + component(lidar.value("id", "lidar_" + std::to_string(idx)), name, "ok", "Configured")); + } + } + if (state.contains("imus") && state["imus"].is_array()) + { + int idx = 0; + for (const auto& imu : state["imus"]) + { + const std::string name = imu.value("name", "IMU " + std::to_string(++idx)); + sensor_components.push_back(component(imu.value("id", "imu_" + std::to_string(idx)), name, "ok", "Configured")); + } + } + if (sensor_components.empty()) + sensor_components.push_back( + component("lidar_front", "Front LiDAR", "warn", "No sensors configured in Build robot")); + + std::string sensors_status = "ok"; + for (const auto& c : sensor_components) + { + if (c.value("status", "") == "error") + sensors_status = "error"; + else if (c.value("status", "") == "warn" && sensors_status == "ok") + sensors_status = "warn"; + } + + auto group = [](const std::string& id, const std::string& name, const std::string& status, nlohmann::json components) { + return nlohmann::json{{"id", id}, + {"name", name}, + {"status", status}, + {"status_label", statusLabel(status)}, + {"components", std::move(components)}}; + }; + + nlohmann::json groups = nlohmann::json::array(); + groups.push_back(group("computer", "Computer", computer_status, computer_components)); + groups.push_back(group("motors", "Motors", motor_status, motor_components)); + groups.push_back(group("power", "Power system", power_status, power_components)); + groups.push_back(group("safety", "Safety system", safety_group, safety_components)); + groups.push_back(group("sensors", "Sensors", sensors_status, sensor_components)); + groups.push_back(group("modbus", "Modbus", "ok", + nlohmann::json::array({component("tcp", "Modbus TCP server", "ok", "Port 5502")}))); + + return {{"groups", groups}, {"updated_at", robot_status.value("updated_at", "")}}; +} + +bool MonitoringService::recordMissionRun(const nlohmann::json& queue_entry, std::string& err) +{ + MissionRunStore store(db_); + return store.insertFromQueueEntry(queue_entry, err); +} + +nlohmann::json MonitoringService::missionRuns(int limit) const +{ + MissionRunStore store(db_); + return store.list(limit); +} + +std::optional MonitoringService::missionRun(const std::string& run_id) const +{ + MissionRunStore store(db_); + return store.findRun(run_id); +} + +std::optional MonitoringService::missionRunActions(const std::string& run_id) const +{ + MissionRunStore store(db_); + return store.actionsForRun(run_id); +} + +bool MonitoringService::deleteMissionRun(const std::string& run_id, std::string& err) +{ + MissionRunStore store(db_); + return store.deleteRun(run_id, err); +} + +bool MonitoringService::clearMissionRuns(std::string& err) +{ + MissionRunStore store(db_); + return store.clearAll(err); +} + +} // namespace lm diff --git a/src/monitoring/monitoring_service.hpp b/src/monitoring/monitoring_service.hpp new file mode 100644 index 0000000..46bfb82 --- /dev/null +++ b/src/monitoring/monitoring_service.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace lm { + +class Database; +class StateRepository; + +class MonitoringService +{ +public: + explicit MonitoringService(Database& db); + + void recordDistance(double meters); + void logSystem(const std::string& state, const std::string& module, const std::string& message); + void logError(const std::string& module, const std::string& description, const nlohmann::json& context = nullptr); + void recordActionSnapshot(const std::string& action, const std::string& message); + void onRobotTick(const nlohmann::json& robot_state, double delta_seconds); + + nlohmann::json analytics(const std::string& start_date, + const std::string& end_date, + const std::string& grouping) const; + nlohmann::json systemLog() const; + nlohmann::json errorLogs() const; + nlohmann::json hardwareHealth(const StateRepository& repo, const nlohmann::json& robot_status) const; + nlohmann::json safetyStatus(const nlohmann::json& robot_status) const; + + // Phase 3: persisted mission runs (MiR-like Mission log history) + bool recordMissionRun(const nlohmann::json& queue_entry, std::string& err); + nlohmann::json missionRuns(int limit = 200) const; + std::optional missionRun(const std::string& run_id) const; + std::optional missionRunActions(const std::string& run_id) const; + bool deleteMissionRun(const std::string& run_id, std::string& err); + bool clearMissionRuns(std::string& err); + + std::optional errorLogById(const std::string& id) const; + bool deleteErrorLog(const std::string& id); + void deleteAllErrorLogs(); + nlohmann::json generateUserLog(const nlohmann::json& robot_status, const nlohmann::json& runner_status); + +private: + Database& db_; + mutable std::mutex mu_; + nlohmann::json analytics_; + nlohmann::json system_log_; + nlohmann::json error_logs_; + nlohmann::json action_ring_; + std::chrono::steady_clock::time_point last_tick_; + + void loadUnlocked(); + void saveAnalyticsUnlocked() const; + void saveSystemLogUnlocked() const; + void saveErrorLogsUnlocked() const; + void trimSystemLogUnlocked(); + void trimErrorLogsUnlocked(); + void trimActionRingUnlocked(); + void seedIfEmptyUnlocked(); + static std::string todayKey(); + static std::string dateKeyFromIso(const std::string& iso); +}; + +} // namespace lm diff --git a/src/robot/robot_runtime.cpp b/src/robot/robot_runtime.cpp index 1a9d243..e2b0f3b 100644 --- a/src/robot/robot_runtime.cpp +++ b/src/robot/robot_runtime.cpp @@ -1,6 +1,7 @@ #include "robot/robot_runtime.hpp" #include "mission/mission_queue.hpp" +#include "monitoring/monitoring_service.hpp" #include "storage/database.hpp" #include "util/id_util.hpp" @@ -17,7 +18,7 @@ constexpr const char* kDefaultMessage = "Waiting for new missions..."; } // namespace RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue) - : db_(db), mission_queue_(mission_queue) + : db_(db), mission_queue_(mission_queue), last_tick_(std::chrono::steady_clock::now()) { load(); ensureDefaultsUnlocked(); @@ -67,6 +68,8 @@ void RobotRuntime::ensureDefaultsUnlocked() state_["active_map_id"] = nullptr; if (!state_.contains("pose") || !state_["pose"].is_object()) state_["pose"] = {{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}}; + if (!state_.contains("safety") || !state_["safety"].is_object()) + state_["safety"] = {{"emergency_stop", "released"}, {"front_scanner", "free"}, {"rear_scanner", "free"}}; saveUnlocked(); } @@ -110,6 +113,7 @@ nlohmann::json RobotRuntime::buildStatusUnlocked() const {"cmd_angular", state_.value("cmd_angular", 0.0)}, {"active_map_id", state_.contains("active_map_id") ? state_["active_map_id"] : nullptr}, {"pose", state_.contains("pose") ? state_["pose"] : nlohmann::json::object({{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}})}, + {"safety", state_.contains("safety") ? state_["safety"] : nlohmann::json::object()}, {"runner", runner}, {"queue_pending", pending}, {"updated_at", state_.value("updated_at", "")}}; @@ -135,6 +139,8 @@ bool RobotRuntime::start(std::string& err) state_["message"] = "Robot running"; state_["updated_at"] = IdUtil::nowIso8601(); saveUnlocked(); + if (monitoring_) + monitoring_->logSystem("ok", "Robot", "Robot started"); const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle"); if (runner_state == "paused") @@ -152,6 +158,8 @@ bool RobotRuntime::pause(std::string& err) state_["cmd_angular"] = 0.0; state_["updated_at"] = IdUtil::nowIso8601(); saveUnlocked(); + if (monitoring_) + monitoring_->logSystem("warn", "Robot", "Robot paused"); const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle"); if (runner_state == "running" || runner_state == "paused") @@ -168,6 +176,8 @@ bool RobotRuntime::resetError(std::string& err) state_["message"] = kDefaultMessage; state_["updated_at"] = IdUtil::nowIso8601(); saveUnlocked(); + if (monitoring_) + monitoring_->logSystem("ok", "Robot", "Error state cleared"); return true; } @@ -288,6 +298,11 @@ bool RobotRuntime::queueSoundPlay(const std::string& sound_id, int volume, std:: void RobotRuntime::tick() { std::lock_guard lock(mu_); + const auto now = std::chrono::steady_clock::now(); + double dt = std::chrono::duration(now - last_tick_).count(); + last_tick_ = now; + dt = std::clamp(dt, 0.05, 5.0); + const bool running = state_.value("motion", "paused") == "running"; const bool joy = state_.value("joystick_engaged", false); double battery = state_.value("battery_percent", 54.0); @@ -300,6 +315,13 @@ void RobotRuntime::tick() battery = std::min(100.0, battery + 0.005); state_["battery_percent"] = static_cast(std::lround(battery)); + + if (monitoring_) + { + nlohmann::json status = buildStatusUnlocked(); + monitoring_->onRobotTick(status, dt); + } + saveUnlocked(); } diff --git a/src/robot/robot_runtime.hpp b/src/robot/robot_runtime.hpp index 3485e11..8a22e43 100644 --- a/src/robot/robot_runtime.hpp +++ b/src/robot/robot_runtime.hpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -9,12 +10,15 @@ namespace lm { class Database; class MissionQueue; +class MonitoringService; class RobotRuntime { public: explicit RobotRuntime(Database& db, MissionQueue& mission_queue); + void setMonitoring(MonitoringService* monitoring) { monitoring_ = monitoring; } + nlohmann::json status() const; bool start(std::string& err); bool pause(std::string& err); @@ -29,8 +33,10 @@ public: private: Database& db_; MissionQueue& mission_queue_; + MonitoringService* monitoring_ = nullptr; mutable std::mutex mu_; nlohmann::json state_; + std::chrono::steady_clock::time_point last_tick_; void load(); void saveUnlocked() const; diff --git a/src/server/api_monitoring_routes.cpp b/src/server/api_monitoring_routes.cpp new file mode 100644 index 0000000..81d2447 --- /dev/null +++ b/src/server/api_monitoring_routes.cpp @@ -0,0 +1,141 @@ +#include "server/api_server.hpp" + +#include "monitoring/monitoring_service.hpp" +#include "util/http_util.hpp" + +namespace lm { + +void ApiServer::registerMonitoringRoutes(httplib::Server& svr) +{ + svr.Get("/api/monitoring/mission_runs", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + int limit = 200; + try + { + if (req.has_param("limit")) + limit = std::stoi(req.get_param_value("limit")); + } + catch (...) + { + limit = 200; + } + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = monitoring_.missionRuns(limit).dump(); + }); + + svr.Get(R"(/api/monitoring/mission_runs/([^/]+)/actions)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const auto items = monitoring_.missionRunActions(req.matches[1]); + if (!items) + return HttpUtil::jsonError(res, 404, "mission run not found"); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = items->dump(); + }); + + svr.Get(R"(/api/monitoring/mission_runs/([^/]+)/download)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto run = monitoring_.missionRun(id); + const auto actions = monitoring_.missionRunActions(id); + if (!run || !actions) + return HttpUtil::jsonError(res, 404, "mission run not found"); + + robot_runtime_.tick(); + const auto robot = robot_runtime_.status(); + + nlohmann::json bundle = {{"run", *run}, {"actions", (*actions).value("items", nlohmann::json::array())}, {"robot_snapshot", robot}}; + res.set_header("Content-Type", "application/octet-stream"); + res.set_header("Content-Disposition", "attachment; filename=\"mission_run_" + id + ".json\""); + res.body = bundle.dump(2); + }); + + svr.Delete(R"(/api/monitoring/mission_runs/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!monitoring_.deleteMissionRun(req.matches[1], err)) + return HttpUtil::jsonError(res, 404, err.empty() ? "mission run not found" : err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json{{"ok", true}}.dump(); + }); + + svr.Delete("/api/monitoring/mission_runs", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!monitoring_.clearMissionRuns(err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json{{"ok", true}}.dump(); + }); + + svr.Get("/api/monitoring/analytics", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string start = req.get_param_value("start"); + const std::string end = req.get_param_value("end"); + const std::string grouping = req.get_param_value("grouping"); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = monitoring_.analytics(start, end, grouping).dump(); + }); + + svr.Get("/api/monitoring/system_log", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = monitoring_.systemLog().dump(); + }); + + svr.Get("/api/monitoring/error_logs", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = monitoring_.errorLogs().dump(); + }); + + svr.Delete("/api/monitoring/error_logs", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + monitoring_.deleteAllErrorLogs(); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json{{"ok", true}}.dump(); + }); + + svr.Post("/api/monitoring/error_logs/generate", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + robot_runtime_.tick(); + const auto status = robot_runtime_.status(); + const auto entry = monitoring_.generateUserLog(status, status.value("runner", nlohmann::json::object())); + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = entry.dump(); + }); + + svr.Get(R"(/api/monitoring/error_logs/([^/]+)/download)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const auto entry = monitoring_.errorLogById(req.matches[1]); + if (!entry) + return HttpUtil::jsonError(res, 404, "error log not found"); + res.set_header("Content-Type", "application/octet-stream"); + res.set_header("Content-Disposition", "attachment; filename=\"error_log_" + req.matches[1].str() + ".json\""); + res.body = entry->dump(2); + }); + + svr.Delete(R"(/api/monitoring/error_logs/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + if (!monitoring_.deleteErrorLog(req.matches[1])) + return HttpUtil::jsonError(res, 404, "error log not found"); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json{{"ok", true}}.dump(); + }); + + svr.Get("/api/monitoring/hardware_health", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + robot_runtime_.tick(); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = monitoring_.hardwareHealth(repo_, robot_runtime_.status()).dump(); + }); + + svr.Get("/api/monitoring/safety", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + robot_runtime_.tick(); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = monitoring_.safetyStatus(robot_runtime_.status()).dump(); + }); +} + +} // namespace lm diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 014ccf4..c0dbeee 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -3,6 +3,7 @@ #include "domain/layout_profile.hpp" #include "domain/layout_schema.hpp" #include "mission/mission_enqueue.hpp" +#include "monitoring/monitoring_service.hpp" #include "util/http_util.hpp" #include "util/id_util.hpp" #include "util/string_util.hpp" @@ -25,7 +26,9 @@ ApiServer::ApiServer(StateRepository& repo, PathStore& path_store, PathGuideStore& path_guide_store, PathService& path_service, - DashboardStore& dashboard_store) + DashboardStore& dashboard_store, + MonitoringService& monitoring, + SettingsService& settings) : repo_(repo), mission_queue_(mission_queue), mission_store_(mission_store), @@ -41,7 +44,9 @@ ApiServer::ApiServer(StateRepository& repo, path_store_(path_store), path_guide_store_(path_guide_store), path_service_(path_service), - dashboard_store_(dashboard_store) + dashboard_store_(dashboard_store), + monitoring_(monitoring), + settings_(settings) { } @@ -567,6 +572,8 @@ void ApiServer::registerRoutes(httplib::Server& svr) registerPathRoutes(svr); registerPathGuideRoutes(svr); registerDashboardRoutes(svr); + registerMonitoringRoutes(svr); + registerSettingsRoutes(svr); } } // namespace lm diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index df0c079..ff74b48 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -9,6 +9,8 @@ #include "robot/robot_runtime.hpp" #include "storage/dashboard_store.hpp" #include "storage/map_store.hpp" +#include "monitoring/monitoring_service.hpp" +#include "settings/settings_service.hpp" #include "storage/site_store.hpp" #include "storage/sound_store.hpp" #include "storage/io_module_store.hpp" @@ -39,7 +41,9 @@ public: PathStore& path_store, PathGuideStore& path_guide_store, PathService& path_service, - DashboardStore& dashboard_store); + DashboardStore& dashboard_store, + MonitoringService& monitoring, + SettingsService& settings); void registerRoutes(httplib::Server& svr); @@ -60,6 +64,8 @@ private: PathGuideStore& path_guide_store_; PathService& path_service_; DashboardStore& dashboard_store_; + MonitoringService& monitoring_; + SettingsService& settings_; bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201); std::optional enqueueMission(const nlohmann::json& request, std::string& err); @@ -74,6 +80,8 @@ private: void registerPathRoutes(httplib::Server& svr); void registerPathGuideRoutes(httplib::Server& svr); void registerDashboardRoutes(httplib::Server& svr); + void registerMonitoringRoutes(httplib::Server& svr); + void registerSettingsRoutes(httplib::Server& svr); }; } // namespace lm diff --git a/src/server/api_settings_routes.cpp b/src/server/api_settings_routes.cpp new file mode 100644 index 0000000..5aac6bc --- /dev/null +++ b/src/server/api_settings_routes.cpp @@ -0,0 +1,35 @@ +#include "server/api_server.hpp" + +#include "util/http_util.hpp" + +namespace lm { + +void ApiServer::registerSettingsRoutes(httplib::Server& svr) +{ + svr.Get("/api/settings", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = settings_.get().dump(); + }); + + svr.Put("/api/settings", [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"); + } + std::string err; + if (!settings_.put(body, err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = settings_.get().dump(); + }); +} + +} // namespace lm + diff --git a/src/settings/settings_service.cpp b/src/settings/settings_service.cpp new file mode 100644 index 0000000..e61eb06 --- /dev/null +++ b/src/settings/settings_service.cpp @@ -0,0 +1,110 @@ +#include "settings/settings_service.hpp" + +#include "storage/database.hpp" + +namespace lm { + +SettingsService::SettingsService(Database& db) : db_(db) +{ + loadUnlocked(); + ensureDefaultsUnlocked(); +} + +void SettingsService::loadUnlocked() +{ + settings_ = nlohmann::json::object(); + nlohmann::json parsed; + if (db_.getDocument("settings", parsed) && parsed.is_object()) + settings_ = parsed; +} + +int SettingsService::clampInt(int v, int lo, int hi) +{ + if (v < lo) + return lo; + if (v > hi) + return hi; + return v; +} + +void SettingsService::ensureDefaultsUnlocked() +{ + if (!settings_.is_object()) + settings_ = nlohmann::json::object(); + if (!settings_.contains("retention_mission_runs")) + settings_["retention_mission_runs"] = 2000; + if (!settings_.contains("retention_system_log")) + settings_["retention_system_log"] = 2000; + if (!settings_.contains("retention_error_logs")) + settings_["retention_error_logs"] = 200; + if (!settings_.contains("log_level")) + settings_["log_level"] = "info"; + db_.setDocument("settings", settings_); +} + +nlohmann::json SettingsService::sanitize(const nlohmann::json& payload) +{ + nlohmann::json out = nlohmann::json::object(); + const int mission_runs = clampInt(payload.value("retention_mission_runs", 2000), 10, 20000); + const int system_log = clampInt(payload.value("retention_system_log", 2000), 100, 20000); + const int error_logs = clampInt(payload.value("retention_error_logs", 200), 10, 5000); + std::string lvl = payload.value("log_level", std::string("info")); + if (lvl != "error" && lvl != "warn" && lvl != "info" && lvl != "debug") + lvl = "info"; + out["retention_mission_runs"] = mission_runs; + out["retention_system_log"] = system_log; + out["retention_error_logs"] = error_logs; + out["log_level"] = lvl; + return out; +} + +nlohmann::json SettingsService::get() const +{ + std::lock_guard lock(mu_); + return settings_; +} + +bool SettingsService::put(const nlohmann::json& payload, std::string& err) +{ + if (!payload.is_object()) + { + err = "payload must be an object"; + return false; + } + const nlohmann::json sanitized = sanitize(payload); + std::lock_guard lock(mu_); + settings_ = sanitized; + if (!db_.setDocument("settings", settings_)) + { + err = "failed to persist settings"; + return false; + } + return true; +} + +int SettingsService::retentionMissionRuns() const +{ + std::lock_guard lock(mu_); + return clampInt(settings_.value("retention_mission_runs", 2000), 10, 20000); +} + +int SettingsService::retentionSystemLog() const +{ + std::lock_guard lock(mu_); + return clampInt(settings_.value("retention_system_log", 2000), 100, 20000); +} + +int SettingsService::retentionErrorLogs() const +{ + std::lock_guard lock(mu_); + return clampInt(settings_.value("retention_error_logs", 200), 10, 5000); +} + +std::string SettingsService::logLevel() const +{ + std::lock_guard lock(mu_); + return settings_.value("log_level", std::string("info")); +} + +} // namespace lm + diff --git a/src/settings/settings_service.hpp b/src/settings/settings_service.hpp new file mode 100644 index 0000000..e6713c2 --- /dev/null +++ b/src/settings/settings_service.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include +#include + +namespace lm { + +class Database; + +class SettingsService +{ +public: + explicit SettingsService(Database& db); + + nlohmann::json get() const; + bool put(const nlohmann::json& payload, std::string& err); + + int retentionMissionRuns() const; + int retentionSystemLog() const; + int retentionErrorLogs() const; + std::string logLevel() const; + +private: + Database& db_; + mutable std::mutex mu_; + nlohmann::json settings_; + + void loadUnlocked(); + void ensureDefaultsUnlocked(); + static nlohmann::json sanitize(const nlohmann::json& payload); + static int clampInt(int v, int lo, int hi); +}; + +} // namespace lm + diff --git a/src/storage/database.cpp b/src/storage/database.cpp index 58242f1..b35da07 100644 --- a/src/storage/database.cpp +++ b/src/storage/database.cpp @@ -185,6 +185,29 @@ CREATE TABLE IF NOT EXISTS dashboard_state ( id INTEGER PRIMARY KEY CHECK (id = 1), active_dashboard_id TEXT ); + +CREATE TABLE IF NOT EXISTS mission_runs ( + id TEXT PRIMARY KEY, + mission_id TEXT NOT NULL, + mission_name TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'ui', + status TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mission_run_actions ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + seq INTEGER NOT NULL, + action TEXT NOT NULL, + state TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + ts TEXT, + FOREIGN KEY (run_id) REFERENCES mission_runs(id) ON DELETE CASCADE +); )SQL"; } // namespace @@ -508,6 +531,41 @@ bool Database::applySchemaMigrations(std::string& err) setMeta("schema_version", "9"); } + ver = getMeta("schema_version").value_or("1"); + if (ver == "9") + { + if (!execSql(db_, + "CREATE TABLE IF NOT EXISTS mission_runs (" + "id TEXT PRIMARY KEY, " + "mission_id TEXT NOT NULL, " + "mission_name TEXT NOT NULL, " + "source TEXT NOT NULL DEFAULT 'ui', " + "status TEXT NOT NULL, " + "message TEXT NOT NULL DEFAULT '', " + "started_at TEXT, " + "finished_at TEXT, " + "created_at TEXT NOT NULL" + ")", + err)) + return false; + + if (!execSql(db_, + "CREATE TABLE IF NOT EXISTS mission_run_actions (" + "id TEXT PRIMARY KEY, " + "run_id TEXT NOT NULL, " + "seq INTEGER NOT NULL, " + "action TEXT NOT NULL, " + "state TEXT NOT NULL, " + "message TEXT NOT NULL DEFAULT '', " + "ts TEXT, " + "FOREIGN KEY (run_id) REFERENCES mission_runs(id) ON DELETE CASCADE" + ")", + err)) + return false; + + setMeta("schema_version", "10"); + } + return true; } diff --git a/src/storage/mission_run_store.cpp b/src/storage/mission_run_store.cpp new file mode 100644 index 0000000..db76368 --- /dev/null +++ b/src/storage/mission_run_store.cpp @@ -0,0 +1,385 @@ +#include "storage/mission_run_store.hpp" + +#include "storage/database.hpp" +#include "util/id_util.hpp" + +#include + +namespace lm { + +MissionRunStore::MissionRunStore(Database& db) : db_(db) {} + +namespace { + +bool execSql(sqlite3* db, const char* sql, std::string& err) +{ + char* msg = nullptr; + const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &msg); + if (rc != SQLITE_OK) + { + err = msg ? msg : sqlite3_errstr(rc); + sqlite3_free(msg); + return false; + } + return true; +} + +bool trimRuns(sqlite3* db, int keep_last, std::string& err) +{ + if (keep_last <= 0) + return true; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + "DELETE FROM mission_runs WHERE id NOT IN (" + "SELECT id FROM mission_runs ORDER BY COALESCE(started_at, created_at) DESC LIMIT ?1" + ")", + -1, + &stmt, + nullptr) != SQLITE_OK) + { + err = sqlite3_errmsg(db); + return false; + } + sqlite3_bind_int(stmt, 1, keep_last); + const bool ok = sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + if (!ok) + err = sqlite3_errmsg(db); + return ok; +} + +int settingsRetentionMissionRuns(Database& db) +{ + nlohmann::json s; + if (db.getDocument("settings", s) && s.is_object()) + { + const int v = s.value("retention_mission_runs", 2000); + if (v >= 10 && v <= 20000) + return v; + } + return 2000; +} + +} // namespace + +static std::string safeString(const nlohmann::json& obj, const char* key, const std::string& fallback = "") +{ + if (!obj.is_object()) + return fallback; + if (!obj.contains(key)) + return fallback; + if (obj[key].is_string()) + return obj[key].get(); + if (obj[key].is_null()) + return fallback; + return obj[key].dump(); +} + +bool MissionRunStore::insertFromQueueEntry(const nlohmann::json& queue_entry, std::string& err) +{ + if (!queue_entry.is_object()) + { + err = "queue entry must be an object"; + return false; + } + + const std::string run_id = safeString(queue_entry, "id"); + if (run_id.empty()) + { + err = "queue entry id missing"; + return false; + } + + const std::string mission_id = safeString(queue_entry, "mission_id"); + const std::string mission_name = safeString(queue_entry, "mission_name", "Mission"); + const std::string source = safeString(queue_entry, "source", "ui"); + const std::string status = safeString(queue_entry, "status", "unknown"); + const std::string started_at = + queue_entry.contains("started_at") && queue_entry["started_at"].is_string() ? queue_entry["started_at"].get() : ""; + const std::string finished_at = + queue_entry.contains("finished_at") && queue_entry["finished_at"].is_string() ? queue_entry["finished_at"].get() : ""; + + const auto log = queue_entry.contains("log") && queue_entry["log"].is_array() ? queue_entry["log"] : nlohmann::json::array(); + std::string message = ""; + if (status == "executing") + { + message = "Running"; + } + else if (!log.empty() && log.back().is_object() && log.back().contains("message")) + { + message = safeString(log.back(), "message"); + } + + std::lock_guard lock(mu_); + sqlite3* db = db_.handle(); + if (!db) + { + err = "database not initialized"; + return false; + } + + execSql(db, "BEGIN", err); + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + "INSERT OR REPLACE INTO mission_runs" + "(id, mission_id, mission_name, source, status, message, started_at, finished_at, created_at)" + "VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + -1, + &stmt, + nullptr) != SQLITE_OK) + { + err = sqlite3_errmsg(db); + execSql(db, "ROLLBACK", err); + return false; + } + const std::string now = IdUtil::nowIso8601(); + sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, mission_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, mission_name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, source.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, status.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, message.c_str(), -1, SQLITE_TRANSIENT); + if (started_at.empty()) + sqlite3_bind_null(stmt, 7); + else + sqlite3_bind_text(stmt, 7, started_at.c_str(), -1, SQLITE_TRANSIENT); + if (finished_at.empty()) + sqlite3_bind_null(stmt, 8); + else + sqlite3_bind_text(stmt, 8, finished_at.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT); + + const bool ok_insert = sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + if (!ok_insert) + { + err = sqlite3_errmsg(db); + execSql(db, "ROLLBACK", err); + return false; + } + + // Replace actions for this run + if (sqlite3_prepare_v2(db, "DELETE FROM mission_run_actions WHERE run_id = ?1", -1, &stmt, nullptr) != SQLITE_OK) + { + err = sqlite3_errmsg(db); + execSql(db, "ROLLBACK", err); + return false; + } + sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + + if (sqlite3_prepare_v2(db, + "INSERT INTO mission_run_actions(id, run_id, seq, action, state, message, ts) " + "VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)", + -1, + &stmt, + nullptr) != SQLITE_OK) + { + err = sqlite3_errmsg(db); + execSql(db, "ROLLBACK", err); + return false; + } + + int seq = 0; + for (const auto& line : log) + { + if (!line.is_object()) + continue; + const std::string id = IdUtil::newId(); + const std::string action = safeString(line, "action", safeString(line, "message", "—")); + const std::string level = safeString(line, "level", "info"); + const std::string msg = safeString(line, "message", ""); + const std::string ts = + line.contains("ts") && line["ts"].is_string() ? line["ts"].get() : ""; + + sqlite3_reset(stmt); + sqlite3_clear_bindings(stmt); + sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, run_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 3, seq++); + sqlite3_bind_text(stmt, 4, action.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, level.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, msg.c_str(), -1, SQLITE_TRANSIENT); + if (ts.empty()) + sqlite3_bind_null(stmt, 7); + else + sqlite3_bind_text(stmt, 7, ts.c_str(), -1, SQLITE_TRANSIENT); + + if (sqlite3_step(stmt) != SQLITE_DONE) + { + err = sqlite3_errmsg(db); + sqlite3_finalize(stmt); + execSql(db, "ROLLBACK", err); + return false; + } + } + sqlite3_finalize(stmt); + + // Retention: keep last N runs (MiR-like bounded history) + std::string trim_err; + trimRuns(db, settingsRetentionMissionRuns(db_), trim_err); + + execSql(db, "COMMIT", err); + return true; +} + +nlohmann::json MissionRunStore::list(int limit) const +{ + nlohmann::json runs = nlohmann::json::array(); + sqlite3* db = db_.handle(); + if (!db) + return {{ "runs", runs }}; + + limit = std::clamp(limit, 1, 2000); + + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT id, mission_id, mission_name, source, status, message, started_at, finished_at, created_at " + "FROM mission_runs ORDER BY COALESCE(started_at, created_at) DESC LIMIT ?1", + -1, + &stmt, + nullptr) != SQLITE_OK) + { + return {{ "runs", runs }}; + } + sqlite3_bind_int(stmt, 1, limit); + while (sqlite3_step(stmt) == SQLITE_ROW) + { + auto colText = [&](int idx) -> std::string { + const char* t = reinterpret_cast(sqlite3_column_text(stmt, idx)); + return t ? std::string(t) : std::string(""); + }; + nlohmann::json row = {{"id", colText(0)}, + {"mission_id", colText(1)}, + {"mission_name", colText(2)}, + {"source", colText(3)}, + {"status", colText(4)}, + {"message", colText(5)}, + {"started_at", colText(6).empty() ? nullptr : nlohmann::json(colText(6))}, + {"finished_at", colText(7).empty() ? nullptr : nlohmann::json(colText(7))}, + {"created_at", colText(8)}}; + runs.push_back(row); + } + sqlite3_finalize(stmt); + return {{ "runs", runs }}; +} + +std::optional MissionRunStore::findRun(const std::string& run_id) const +{ + sqlite3* db = db_.handle(); + if (!db || run_id.empty()) + return std::nullopt; + + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT id, mission_id, mission_name, source, status, message, started_at, finished_at, created_at " + "FROM mission_runs WHERE id = ?1", + -1, + &stmt, + nullptr) != SQLITE_OK) + { + return std::nullopt; + } + sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(stmt) != SQLITE_ROW) + { + sqlite3_finalize(stmt); + return std::nullopt; + } + auto colText = [&](int idx) -> std::string { + const char* t = reinterpret_cast(sqlite3_column_text(stmt, idx)); + return t ? std::string(t) : std::string(""); + }; + nlohmann::json row = {{"id", colText(0)}, + {"mission_id", colText(1)}, + {"mission_name", colText(2)}, + {"source", colText(3)}, + {"status", colText(4)}, + {"message", colText(5)}, + {"started_at", colText(6).empty() ? nullptr : nlohmann::json(colText(6))}, + {"finished_at", colText(7).empty() ? nullptr : nlohmann::json(colText(7))}, + {"created_at", colText(8)}}; + sqlite3_finalize(stmt); + return row; +} + +std::optional MissionRunStore::actionsForRun(const std::string& run_id) const +{ + sqlite3* db = db_.handle(); + if (!db) + return std::nullopt; + + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + "SELECT action, state, message, ts FROM mission_run_actions " + "WHERE run_id = ?1 ORDER BY seq ASC", + -1, + &stmt, + nullptr) != SQLITE_OK) + { + return std::nullopt; + } + sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT); + nlohmann::json items = nlohmann::json::array(); + while (sqlite3_step(stmt) == SQLITE_ROW) + { + auto colText = [&](int idx) -> std::string { + const char* t = reinterpret_cast(sqlite3_column_text(stmt, idx)); + return t ? std::string(t) : std::string(""); + }; + items.push_back({{"action", colText(0)}, + {"level", colText(1)}, + {"message", colText(2)}, + {"ts", colText(3).empty() ? nullptr : nlohmann::json(colText(3))}}); + } + sqlite3_finalize(stmt); + return nlohmann::json{{"items", items}}; +} + +bool MissionRunStore::deleteRun(const std::string& run_id, std::string& err) +{ + sqlite3* db = db_.handle(); + if (!db) + { + err = "database not initialized"; + return false; + } + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, "DELETE FROM mission_runs WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK) + { + err = sqlite3_errmsg(db); + return false; + } + sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT); + const bool ok = sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + if (!ok) + err = sqlite3_errmsg(db); + return ok; +} + +bool MissionRunStore::clearAll(std::string& err) +{ + sqlite3* db = db_.handle(); + if (!db) + { + err = "database not initialized"; + return false; + } + std::lock_guard lock(mu_); + if (sqlite3_exec(db, "DELETE FROM mission_runs", nullptr, nullptr, nullptr) != SQLITE_OK) + { + err = sqlite3_errmsg(db); + return false; + } + return true; +} + +} // namespace lm + diff --git a/src/storage/mission_run_store.hpp b/src/storage/mission_run_store.hpp new file mode 100644 index 0000000..50b661b --- /dev/null +++ b/src/storage/mission_run_store.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include +#include + +namespace lm { + +class Database; + +class MissionRunStore +{ +public: + explicit MissionRunStore(Database& db); + + bool insertFromQueueEntry(const nlohmann::json& queue_entry, std::string& err); + nlohmann::json list(int limit = 200) const; + std::optional findRun(const std::string& run_id) const; + std::optional actionsForRun(const std::string& run_id) const; + bool deleteRun(const std::string& run_id, std::string& err); + bool clearAll(std::string& err); + +private: + Database& db_; + mutable std::mutex mu_; +}; + +} // namespace lm + diff --git a/www/app.js b/www/app.js index e290f8e..3a7282f 100644 --- a/www/app.js +++ b/www/app.js @@ -10,6 +10,7 @@ const pageConfigEl = el("pageConfig"); const pageMapsEl = el("pageMaps"); const pageMissionsEl = el("pageMissions"); const pageIntegrationsEl = el("pageIntegrations"); +const pageSettingsEl = el("pageSettings"); const pageSoundsEl = el("pageSounds"); const pageTransitionsEl = el("pageTransitions"); const pageUsersEl = el("pageUsers"); @@ -131,7 +132,7 @@ const state = { }; function setActivePage(page) { - const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "integrations", "monitoring", "help"]; + const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "settings", "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)); @@ -149,6 +150,7 @@ function setActivePage(page) { if (pagePathsEl) pagePathsEl.hidden = p !== "paths"; if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides"; if (pageUsersEl) pageUsersEl.hidden = p !== "users"; + if (pageSettingsEl) pageSettingsEl.hidden = p !== "settings"; if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations"; if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring"; if (pageHelpEl) pageHelpEl.hidden = p !== "help"; @@ -166,6 +168,7 @@ function setActivePage(page) { contentEl.classList.toggle("content--paths", p === "paths"); contentEl.classList.toggle("content--path-guides", p === "path-guides"); contentEl.classList.toggle("content--users", p === "users"); + contentEl.classList.toggle("content--settings", p === "settings"); contentEl.classList.toggle("content--integrations", p === "integrations"); contentEl.classList.toggle("content--monitoring", p === "monitoring"); contentEl.classList.toggle("content--help", p === "help"); @@ -191,6 +194,10 @@ function setActivePage(page) { else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide(); if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow(); else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide(); + if (p === "settings" && window.SettingsApp) window.SettingsApp.onPageShow(); + else if (window.SettingsApp?.onPageHide) window.SettingsApp.onPageHide(); + if (p === "monitoring" && window.MonitoringApp) window.MonitoringApp.onPageShow(); + else if (window.MonitoringApp?.onPageHide) window.MonitoringApp.onPageHide(); window.NavApp?.syncFromPage?.(p); try { localStorage.setItem("activePage", p); diff --git a/www/i18n.js b/www/i18n.js index 42723fb..1613ca5 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -66,6 +66,7 @@ "nav.setup": "Setup", "nav.monitoring": "Monitoring", "nav.system": "System", + "nav.settings": "Settings", "nav.help": "Help", "nav.logout": "Log out", "nav.dashboard": "Dashboard", @@ -81,6 +82,11 @@ "nav.users": "Users", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", + "nav.analytics": "Analytics", + "nav.error-logs": "Error logs", + "nav.hardware-health": "Hardware health", + "nav.safety-system": "Safety system", + "nav.mission-log": "Mission log", "nav.integrations": "Tích hợp", "nav.help-api": "API documentation", @@ -884,9 +890,150 @@ "integrations.dialog.schedule.startTime": "Thời gian bắt đầu", "integrations.schedule.runNow": "Chạy ngay", - "monitoring.log.title": "System log", - "monitoring.log.subtitle": "Monitoring → System log — nhật ký hệ thống (đang phát triển).", - "monitoring.log.placeholder": "Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.", + "monitoring.comingSoon": "Tính năng đang phát triển.", + "monitoring.refresh": "Làm mới", + "monitoring.clearFilters": "Xóa bộ lọc", + "monitoring.filterLabel": "Lọc:", + "monitoring.itemsFound": "{n} mục", + "monitoring.pageOf": "Trang {page} / {total}", + "monitoring.analytics.title": "Analytics", + "monitoring.analytics.subtitle": "Quãng đường robot đã chạy theo thời gian.", + "monitoring.analytics.helpTitle": "Trợ giúp Analytics", + "monitoring.analytics.helpBody": "Biểu đồ quãng đường (mét) theo ngày hoặc tháng. Chọn khoảng thời gian hoặc dùng nút preset. Dữ liệu tích lũy khi robot di chuyển (joystick/mission).", + "monitoring.analytics.startDate": "Start date", + "monitoring.analytics.endDate": "End date", + "monitoring.analytics.grouping": "Grouping", + "monitoring.analytics.perDay": "Per day", + "monitoring.analytics.perMonth": "Per month", + "monitoring.analytics.chartType": "Chart", + "monitoring.analytics.barChart": "Bar graph", + "monitoring.analytics.accumulated": "Accumulated", + "monitoring.analytics.presetWeek": "Current week", + "monitoring.analytics.preset7d": "Last 7 days", + "monitoring.analytics.preset30d": "Last 30 days", + "monitoring.analytics.preset365d": "Last 365 days", + "monitoring.analytics.totalPeriod": "Kỳ đã chọn: {n} m · Tổng tích lũy: {lifetime} m", + "monitoring.analytics.empty": "Không có dữ liệu quãng đường trong kỳ này.", + "monitoring.systemLog.title": "System log", + "monitoring.systemLog.subtitle": "Sự kiện từ các thành phần hệ thống.", + "monitoring.systemLog.filterPlaceholder": "Lọc theo module, message hoặc state...", + "monitoring.systemLog.colState": "State", + "monitoring.systemLog.colModule": "Module", + "monitoring.systemLog.colMessage": "Message", + "monitoring.systemLog.colTime": "Time", + "monitoring.systemLog.empty": "Chưa có sự kiện system log.", + "monitoring.systemLog.emptyFilter": "Không có sự kiện khớp bộ lọc.", + "monitoring.errorLogs.title": "Error logs", + "monitoring.errorLogs.subtitle": "Danh sách lỗi hệ thống đã phát hiện.", + "monitoring.errorLogs.filterPlaceholder": "Lọc theo mô tả hoặc module...", + "monitoring.errorLogs.colDescription": "Description", + "monitoring.errorLogs.colModule": "Module", + "monitoring.errorLogs.colTime": "Time", + "monitoring.errorLogs.colFunctions": "Functions", + "monitoring.errorLogs.generate": "Generate log", + "monitoring.errorLogs.deleteAll": "Delete all", + "monitoring.errorLogs.deleteAllConfirm": "Xóa toàn bộ error log?", + "monitoring.errorLogs.download": "Download", + "monitoring.errorLogs.delete": "Delete", + "monitoring.errorLogs.empty": "Chưa có lỗi được ghi.", + "monitoring.errorLogs.emptyFilter": "Không có lỗi khớp bộ lọc.", + "monitoring.hardware.title": "Hardware health", + "monitoring.hardware.subtitle": "Tình trạng phần cứng robot.", + "monitoring.hardware.empty": "Không có dữ liệu phần cứng.", + "monitoring.safety.title": "Safety system", + "monitoring.safety.subtitle": "Trạng thái E-stop và laser scanner (live).", + "monitoring.safety.estop": "Emergency stop", + "monitoring.safety.frontScanner": "Front scanner", + "monitoring.safety.rearScanner": "Rear scanner", + "monitoring.safety.estopReleased": "Released", + "monitoring.safety.estopActivated": "Activated", + "monitoring.safety.scannerFree": "Free", + "monitoring.safety.scannerBlocked": "Blocked", + "monitoring.missionLog.title": "Mission log", + "monitoring.missionLog.subtitle": "Mission đã chạy và đang chạy.", + "monitoring.missionLog.helpTitle": "Trợ giúp Mission log", + "monitoring.missionLog.helpBody": "Danh sách mission đã chạy/đang chạy. Bấm biểu tượng mắt để xem action log chi tiết. Phase 3: lịch sử được lưu bền vững (không mất khi xóa queue).", + "monitoring.missionLog.filterPlaceholder": "Lọc theo tên mission hoặc state...", + "monitoring.missionLog.colMission": "Mission", + "monitoring.missionLog.colState": "State", + "monitoring.missionLog.colMessage": "Message", + "monitoring.missionLog.colStart": "Start time", + "monitoring.missionLog.colRanFor": "Ran for", + "monitoring.missionLog.colStartedBy": "Started by", + "monitoring.missionLog.colFunctions": "Functions", + "monitoring.missionLog.empty": "Chưa có mission trong log.", + "monitoring.missionLog.emptyFilter": "Không có mission khớp bộ lọc.", + "monitoring.missionLog.viewActions": "Xem action log", + "monitoring.missionLog.clearHistory": "Xóa lịch sử", + "monitoring.missionLog.clearHistoryConfirm": "Xóa toàn bộ lịch sử mission log?", + "monitoring.missionLog.deleteRun": "Xóa", + "monitoring.missionLog.deleteRunConfirm": "Xóa mission này khỏi lịch sử?", + "monitoring.missionLog.downloadRun": "Tải log", + "monitoring.missionLog.running": "Đang chạy…", + "monitoring.missionLog.stateCompleted": "Hoàn thành", + "monitoring.missionLog.stateFailed": "Thất bại", + "monitoring.missionLog.stateCancelled": "Đã hủy", + "monitoring.missionLog.state.executing": "Executing", + "monitoring.missionLog.state.completed": "Completed", + "monitoring.missionLog.state.failed": "Failed", + "monitoring.missionLog.state.cancelled": "Cancelled", + "monitoring.missionLog.source.ui": "User interface", + "monitoring.missionLog.source.modbus": "Modbus", + "monitoring.missionLog.source.api": "REST API", + "monitoring.missionLog.source.schedule": "Scheduler", + "monitoring.actionLog.title": "Mission action log", + "monitoring.actionLog.back": "← Mission log", + "monitoring.actionLog.subtitleMission": "Mission: {name}", + "monitoring.actionLog.filterPlaceholder": "Lọc theo action, state hoặc message...", + "monitoring.actionLog.colAction": "Action", + "monitoring.actionLog.colState": "State", + "monitoring.actionLog.colMessage": "Message", + "monitoring.actionLog.colStart": "Start time", + "monitoring.actionLog.colRanFor": "Ran for", + "monitoring.actionLog.empty": "Chưa có action nào trong log.", + "monitoring.actionLog.emptyFilter": "Không có action khớp bộ lọc.", + "monitoring.actionLog.missingEntry": "Không tìm thấy mission.", + "monitoring.actionLog.state.executing": "Executing", + "monitoring.actionLog.level.info": "Info", + "monitoring.actionLog.level.warn": "Warning", + "monitoring.actionLog.level.error": "Error", + "monitoring.actionLog.level.user": "User", + "monitoring.actionLog.download": "Download", + + "settings.title": "Settings", + "settings.subtitle": "System → Settings — cấu hình hệ thống robot.", + "settings.loading": "Đang tải settings…", + "settings.loaded": "Đã tải.", + "settings.apply": "Apply", + "settings.back": "Quay lại", + "settings.saving": "Đang lưu…", + "settings.saved": "Đã lưu.", + "settings.saveFailed": "Lưu thất bại.", + "settings.loggingRetention.title": "Logging & retention", + "settings.loggingRetention.subtitle": "Giới hạn lịch sử và mức độ log.", + "settings.retention.missionRuns": "Mission history (runs)", + "settings.retention.missionRunsHint": "Số lượng mission runs được lưu (lịch sử có giới hạn).", + "settings.retention.systemLog": "System log entries", + "settings.retention.systemLogHint": "Số dòng system log tối đa lưu trong DB.", + "settings.retention.errorLogs": "Error log entries", + "settings.retention.errorLogsHint": "Số dòng error log tối đa lưu trong DB.", + "settings.logLevel": "Log level", + "settings.logLevelHint": "Điều khiển mức độ chi tiết của system log.", + "settings.comingSoon.title": "More settings", + "settings.comingSoon.subtitle": "Time, network, security và backups sẽ nằm ở đây.", + + "settings.tile.mapping": "Mapping", + "settings.tile.mappingSub": "Chỉnh cấu hình mapping.", + "settings.tile.errorHandling": "Error handling", + "settings.tile.errorHandlingSub": "Chỉnh xử lý lỗi của robot.", + "settings.tile.battery": "Battery", + "settings.tile.batterySub": "Cấu hình cho battery.", + "settings.tile.wifi": "WiFi", + "settings.tile.wifiSub": "Cấu hình kết nối WiFi.", + "settings.tile.dateTime": "Date & time", + "settings.tile.dateTimeSub": "Thiết lập thời gian hệ thống.", + "settings.tile.advanced": "Advanced", + "settings.tile.advancedSub": "Retention và logging.", "help.api.title": "API documentation", "help.api.subtitle": "Help → API — tham chiếu REST MiR v2.0.0 cho tích hợp bên ngoài.", @@ -954,6 +1101,7 @@ "nav.setup": "Setup", "nav.monitoring": "Monitoring", "nav.system": "System", + "nav.settings": "Settings", "nav.help": "Help", "nav.logout": "Log out", "nav.dashboard": "Dashboard", @@ -969,6 +1117,11 @@ "nav.users": "Users", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", + "nav.analytics": "Analytics", + "nav.error-logs": "Error logs", + "nav.hardware-health": "Hardware health", + "nav.safety-system": "Safety system", + "nav.mission-log": "Mission log", "nav.integrations": "Integrations", "nav.help-api": "API documentation", @@ -1772,9 +1925,150 @@ "integrations.dialog.schedule.startTime": "Start time", "integrations.schedule.runNow": "Run now", - "monitoring.log.title": "System log", - "monitoring.log.subtitle": "Monitoring → System log — system log (coming soon).", - "monitoring.log.placeholder": "Monitoring will show robot logs, alerts and mission history here.", + "monitoring.comingSoon": "Feature coming soon.", + "monitoring.refresh": "Refresh", + "monitoring.clearFilters": "Clear filters", + "monitoring.filterLabel": "Filter:", + "monitoring.itemsFound": "{n} item(s) found", + "monitoring.pageOf": "Page {page} of {total}", + "monitoring.analytics.title": "Analytics", + "monitoring.analytics.subtitle": "Driven distance over time.", + "monitoring.analytics.helpTitle": "Analytics help", + "monitoring.analytics.helpBody": "Chart of driven distance (meters) per day or month. Select a date range or use a preset. Distance accumulates when the robot moves (joystick/missions).", + "monitoring.analytics.startDate": "Start date", + "monitoring.analytics.endDate": "End date", + "monitoring.analytics.grouping": "Grouping", + "monitoring.analytics.perDay": "Per day", + "monitoring.analytics.perMonth": "Per month", + "monitoring.analytics.chartType": "Chart", + "monitoring.analytics.barChart": "Bar graph", + "monitoring.analytics.accumulated": "Accumulated", + "monitoring.analytics.presetWeek": "Current week", + "monitoring.analytics.preset7d": "Last 7 days", + "monitoring.analytics.preset30d": "Last 30 days", + "monitoring.analytics.preset365d": "Last 365 days", + "monitoring.analytics.totalPeriod": "Selected period: {n} m · Lifetime: {lifetime} m", + "monitoring.analytics.empty": "No distance data for this period.", + "monitoring.systemLog.title": "System log", + "monitoring.systemLog.subtitle": "Events from operating system components.", + "monitoring.systemLog.filterPlaceholder": "Filter by module, message or state...", + "monitoring.systemLog.colState": "State", + "monitoring.systemLog.colModule": "Module", + "monitoring.systemLog.colMessage": "Message", + "monitoring.systemLog.colTime": "Time", + "monitoring.systemLog.empty": "No system log entries yet.", + "monitoring.systemLog.emptyFilter": "No entries match the filter.", + "monitoring.errorLogs.title": "Error logs", + "monitoring.errorLogs.subtitle": "Detected system errors.", + "monitoring.errorLogs.filterPlaceholder": "Filter by description or module...", + "monitoring.errorLogs.colDescription": "Description", + "monitoring.errorLogs.colModule": "Module", + "monitoring.errorLogs.colTime": "Time", + "monitoring.errorLogs.colFunctions": "Functions", + "monitoring.errorLogs.generate": "Generate log", + "monitoring.errorLogs.deleteAll": "Delete all", + "monitoring.errorLogs.deleteAllConfirm": "Delete all error log entries?", + "monitoring.errorLogs.download": "Download", + "monitoring.errorLogs.delete": "Delete", + "monitoring.errorLogs.empty": "No errors logged.", + "monitoring.errorLogs.emptyFilter": "No errors match the filter.", + "monitoring.hardware.title": "Hardware health", + "monitoring.hardware.subtitle": "Robot hardware component status.", + "monitoring.hardware.empty": "No hardware data.", + "monitoring.safety.title": "Safety system", + "monitoring.safety.subtitle": "Live emergency stop and laser scanner status.", + "monitoring.safety.estop": "Emergency stop", + "monitoring.safety.frontScanner": "Front scanner", + "monitoring.safety.rearScanner": "Rear scanner", + "monitoring.safety.estopReleased": "Released", + "monitoring.safety.estopActivated": "Activated", + "monitoring.safety.scannerFree": "Free", + "monitoring.safety.scannerBlocked": "Blocked", + "monitoring.missionLog.title": "Mission log", + "monitoring.missionLog.subtitle": "Executed and currently running missions.", + "monitoring.missionLog.helpTitle": "Mission log help", + "monitoring.missionLog.helpBody": "Lists missions that have run or are running. Click the eye icon for the action log. Phase 3: history is persisted (clearing the queue does not delete history).", + "monitoring.missionLog.filterPlaceholder": "Filter by mission name or state...", + "monitoring.missionLog.colMission": "Mission", + "monitoring.missionLog.colState": "State", + "monitoring.missionLog.colMessage": "Message", + "monitoring.missionLog.colStart": "Start time", + "monitoring.missionLog.colRanFor": "Ran for", + "monitoring.missionLog.colStartedBy": "Started by", + "monitoring.missionLog.colFunctions": "Functions", + "monitoring.missionLog.empty": "No missions in the log yet.", + "monitoring.missionLog.emptyFilter": "No missions match the filter.", + "monitoring.missionLog.viewActions": "View action log", + "monitoring.missionLog.clearHistory": "Clear history", + "monitoring.missionLog.clearHistoryConfirm": "Clear all mission log history?", + "monitoring.missionLog.deleteRun": "Delete", + "monitoring.missionLog.deleteRunConfirm": "Delete this mission from history?", + "monitoring.missionLog.downloadRun": "Download log", + "monitoring.missionLog.running": "Running…", + "monitoring.missionLog.stateCompleted": "Completed", + "monitoring.missionLog.stateFailed": "Failed", + "monitoring.missionLog.stateCancelled": "Cancelled", + "monitoring.missionLog.state.executing": "Executing", + "monitoring.missionLog.state.completed": "Completed", + "monitoring.missionLog.state.failed": "Failed", + "monitoring.missionLog.state.cancelled": "Cancelled", + "monitoring.missionLog.source.ui": "User interface", + "monitoring.missionLog.source.modbus": "Modbus", + "monitoring.missionLog.source.api": "REST API", + "monitoring.missionLog.source.schedule": "Scheduler", + "monitoring.actionLog.title": "Mission action log", + "monitoring.actionLog.back": "← Mission log", + "monitoring.actionLog.subtitleMission": "Mission: {name}", + "monitoring.actionLog.filterPlaceholder": "Filter by action, state or message...", + "monitoring.actionLog.colAction": "Action", + "monitoring.actionLog.colState": "State", + "monitoring.actionLog.colMessage": "Message", + "monitoring.actionLog.colStart": "Start time", + "monitoring.actionLog.colRanFor": "Ran for", + "monitoring.actionLog.empty": "No actions logged for this mission.", + "monitoring.actionLog.emptyFilter": "No actions match the filter.", + "monitoring.actionLog.missingEntry": "Mission not found.", + "monitoring.actionLog.state.executing": "Executing", + "monitoring.actionLog.level.info": "Info", + "monitoring.actionLog.level.warn": "Warning", + "monitoring.actionLog.level.error": "Error", + "monitoring.actionLog.level.user": "User", + "monitoring.actionLog.download": "Download", + + "settings.title": "Settings", + "settings.subtitle": "System → Settings — global robot configuration.", + "settings.loading": "Loading settings…", + "settings.loaded": "Loaded.", + "settings.apply": "Apply", + "settings.back": "Back", + "settings.saving": "Saving…", + "settings.saved": "Saved.", + "settings.saveFailed": "Save failed.", + "settings.loggingRetention.title": "Logging & retention", + "settings.loggingRetention.subtitle": "Tune history limits and log level.", + "settings.retention.missionRuns": "Mission history (runs)", + "settings.retention.missionRunsHint": "Max persisted mission runs (bounded history).", + "settings.retention.systemLog": "System log entries", + "settings.retention.systemLogHint": "Max number of system log rows stored.", + "settings.retention.errorLogs": "Error log entries", + "settings.retention.errorLogsHint": "Max number of error log rows stored.", + "settings.logLevel": "Log level", + "settings.logLevelHint": "Controls verbosity of system logs.", + "settings.comingSoon.title": "More settings", + "settings.comingSoon.subtitle": "Time, network, security and backups will appear here.", + + "settings.tile.mapping": "Mapping", + "settings.tile.mappingSub": "Mapping configuration.", + "settings.tile.errorHandling": "Error handling", + "settings.tile.errorHandlingSub": "Robot error handling configuration.", + "settings.tile.battery": "Battery", + "settings.tile.batterySub": "Battery configuration.", + "settings.tile.wifi": "WiFi", + "settings.tile.wifiSub": "WiFi connection configuration.", + "settings.tile.dateTime": "Date & time", + "settings.tile.dateTimeSub": "Set system time.", + "settings.tile.advanced": "Advanced", + "settings.tile.advancedSub": "Retention and logging.", "help.api.title": "API documentation", "help.api.subtitle": "Help → API — MiR v2.0.0 REST reference for external integration.", diff --git a/www/index.html b/www/index.html index b97617a..9241c90 100644 --- a/www/index.html +++ b/www/index.html @@ -2378,18 +2378,367 @@ GET /api/v2.0.0/status + + + + + + + + + + + + + + + +